From 91550dacd69ce0e6f3a4edb3ecd761e226025b76 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 22 Sep 2026 16:42:59 -0400 Subject: [PATCH] docs: add v0.1.3 implementation plan Co-authored-by: Cursor --- .../plans/2026-09-22-v013-polish-tweaks.md | 1060 +++++++++++++++++ 1 file changed, 1060 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md diff --git a/docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md b/docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md new file mode 100644 index 0000000..e7b8fcd --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md @@ -0,0 +1,1060 @@ +# v0.1.3 Polish & Tweaks — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship three UI polish features — adjustable clip-list layout, magnifying timestamp labels, and a timeline right-click context menu — as v0.1.3. + +**Architecture:** All changes are frontend-only (Svelte + TypeScript + canvas). The layout feature restructures `App.svelte` with a preference-driven conditional layout. The timestamp feature enhances canvas rendering in `renderer.ts`. The context menu is a new Svelte component wired into `Timeline.svelte`. + +**Tech Stack:** Svelte 5, TypeScript, HTML Canvas API, Tauri plugin-store for persistence. + +## Global Constraints + +- Version: 0.1.3 +- No new npm/cargo dependencies +- No Rust backend changes +- All CSS uses existing CSS custom properties from `src/app.css` +- Imports at top of file, never inline +- Exhaustive `switch` with `never` check in default cases for discriminated unions +- The project is currently clean — any warnings or build errors are new regressions + +--- + +### Task 1: Version Bump + +**Files:** +- Modify: `VERSION` (root — create if missing, content: `0.1.3`) +- Run: `scripts/bump-version.sh` + +**Interfaces:** +- Consumes: nothing +- Produces: all manifests at version `0.1.3` + +- [ ] **Step 1: Bump version via script** + +```bash +cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper +echo "0.1.3" > VERSION +bash scripts/bump-version.sh +``` + +Expected output: +``` +Bumping to version 0.1.3 + ✓ package.json + ✓ src-tauri/Cargo.toml + ✓ src-tauri/tauri.conf.json + ✓ src-tauri/Cargo.lock +``` + +- [ ] **Step 2: Verify no stale 0.1.2 references** + +```bash +grep -r "0\.1\.2" package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml +``` + +Expected: no output (no matches). + +- [ ] **Step 3: Commit** + +```bash +git add VERSION package.json package-lock.json src-tauri/tauri.conf.json src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "chore: bump version to 0.1.3" +``` + +--- + +### Task 2: Layout Preference & Toggle Button + +**Files:** +- Modify: `src/lib/stores/preferences.svelte.ts` — add `clipListPosition` and `clipListWidth` fields +- Modify: `src/App.svelte` — add toggle button to toolbar, restructure content for dual layout + +**Interfaces:** +- Consumes: existing `preferences` store, `savePreferences()`, `loadPreferences()` +- Produces: `preferences.clipListPosition` (`'bottom' | 'left'`), `preferences.clipListWidth` (`number`) + +- [ ] **Step 1: Add preference fields** + +In `src/lib/stores/preferences.svelte.ts`, add `clipListPosition` and `clipListWidth` to the `Preferences` interface and defaults: + +```typescript +export interface Preferences { + outputDirectory: string; + cookieSource: CookieSource; + captionSettings: CaptionSettings; + clipListPosition: 'bottom' | 'left'; + clipListWidth: number; +} + +const DEFAULT_PREFERENCES: Preferences = { + outputDirectory: '~/', + cookieSource: { type: 'browser', browser: 'firefox' }, + captionSettings: { ...DEFAULT_CAPTION_SETTINGS }, + clipListPosition: 'bottom', + clipListWidth: 220, +}; +``` + +Update `loadPreferences()` to load the new fields: + +```typescript +const savedClipListPosition = await store.get<'bottom' | 'left'>('clipListPosition'); +const savedClipListWidth = await store.get('clipListWidth'); +if (savedClipListPosition) preferences.clipListPosition = savedClipListPosition; +if (savedClipListWidth) preferences.clipListWidth = savedClipListWidth; +``` + +Update `savePreferences()` to save them: + +```typescript +await store.set('clipListPosition', preferences.clipListPosition); +await store.set('clipListWidth', preferences.clipListWidth); +``` + +Add a convenience setter: + +```typescript +export async function setClipListPosition(position: 'bottom' | 'left'): Promise { + preferences.clipListPosition = position; + await savePreferences(); +} + +export async function setClipListWidth(width: number): Promise { + preferences.clipListWidth = width; + await savePreferences(); +} +``` + +- [ ] **Step 2: Add toggle button to toolbar in App.svelte** + +Import the new setter at the top of `src/App.svelte`: + +```typescript +import { preferences, setClipListPosition } from '$lib/stores/preferences.svelte'; +``` + +(The existing `loadPreferences` import stays.) + +Add a toggle function: + +```typescript +function toggleClipListPosition() { + const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom'; + setClipListPosition(next); +} +``` + +Add the button in the toolbar, before the about/prefs buttons: + +```svelte + +``` + +Add styling: + +```css +.layout-btn { + font-size: 16px; + padding: 4px 8px; +} +``` + +- [ ] **Step 3: Restructure content area for 'left' mode** + +Replace the `
` block in `App.svelte` with a conditional layout. The key structural change: + +**Bottom mode** (existing behavior — unchanged): +```svelte +
+ + +
+
+ +
+
{ + if (e.key === 'ArrowDown') { + e.preventDefault(); + timelineHeight = Math.min(500, timelineHeight + 10); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + timelineHeight = Math.max(40, timelineHeight - 10); + } + }} + >
+
+ (showExportDialog = true)} /> +
+
+
+``` + +**Left mode:** +```svelte +
+
+
+ (showExportDialog = true)} /> +
+ +
+ + +
+
+
+
{ + if (e.key === 'ArrowDown') { + e.preventDefault(); + timelineHeight = Math.min(500, timelineHeight + 10); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + timelineHeight = Math.max(40, timelineHeight - 10); + } + }} + >
+
+ +
+
+
+``` + +Note: in left mode the resize handle sits *above* the timeline pane (it's between the upper section and the timeline). The `timeline-clip-area` now only contains the resize handle and the timeline — no clip list. + +- [ ] **Step 4: Add sidebar resize logic** + +Add state and handlers for the horizontal sidebar resize in `App.svelte`: + +```typescript +let isSidebarResizing = $state(false); +let sidebarResizeStartX = $state(0); +let sidebarResizeStartWidth = $state(0); +let windowWidth = $state(window.innerWidth); + +function handleSidebarResizeStart(e: MouseEvent) { + e.preventDefault(); + isSidebarResizing = true; + sidebarResizeStartX = e.clientX; + sidebarResizeStartWidth = preferences.clipListWidth; +} + +function handleSidebarResizeMove(e: MouseEvent) { + if (!isSidebarResizing) return; + const delta = e.clientX - sidebarResizeStartX; + const maxWidth = Math.round(windowWidth * 0.4); + const newWidth = Math.max(150, Math.min(maxWidth, sidebarResizeStartWidth + delta)); + preferences.clipListWidth = newWidth; +} + +function handleSidebarResizeEnd() { + if (isSidebarResizing) { + isSidebarResizing = false; + setClipListWidth(preferences.clipListWidth); + } +} +``` + +Update the existing `handleResizeMove` to also handle sidebar resize — or better, update the `` handlers: + +```svelte + { handleResizeMove(e); handleSidebarResizeMove(e); }} + onmouseup={() => { handleResizeEnd(); handleSidebarResizeEnd(); }} + onresize={() => { windowWidth = window.innerWidth; }} +/> +``` + +- [ ] **Step 5: Add CSS for left mode** + +```css +.content-left { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.upper-section { + flex: 1; + display: flex; + flex-direction: row; + min-height: 0; + overflow: hidden; +} + +.cliplist-sidebar { + flex-shrink: 0; + overflow-y: auto; + overflow-x: hidden; + border-right: 1px solid var(--border); + background: var(--bg-secondary); +} + +.sidebar-resize-handle { + width: 5px; + background: var(--border); + cursor: ew-resize; + flex-shrink: 0; + transition: background 0.1s; +} + +.sidebar-resize-handle:hover, +.sidebar-resize-handle.active { + background: var(--accent); +} + +.right-column { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} +``` + +Also, the `ClipList` component has a `border-top` that makes sense in bottom mode but not in left mode. Add a `position` prop to `ClipList.svelte`: + +In `src/lib/components/ClipList.svelte`, change the props: + +```typescript +let { onExport, position = 'bottom' }: { onExport?: () => void; position?: 'bottom' | 'left' } = $props(); +``` + +Add a conditional class on the container: + +```svelte +
+``` + +Add CSS: + +```css +.clip-list.sidebar { + border-top: none; + height: 100%; +} +``` + +Pass the prop in `App.svelte`: + +```svelte + + (showExportDialog = true)} position="left" /> + + (showExportDialog = true)} /> +``` + +- [ ] **Step 6: Build and verify** + +```bash +cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper +npm run check +``` + +Expected: no errors or warnings. + +```bash +npm run tauri dev +``` + +Verify: +1. Default mode is 'bottom' — layout unchanged from v0.1.2 +2. Click the layout toggle button — clip list moves to left sidebar +3. Sidebar is resizable by dragging the handle +4. Timeline stays full width in both modes +5. Toggle persists after reload +6. No visual glitches when switching modes + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat: adjustable clip list layout (bottom/left sidebar)" +``` + +--- + +### Task 3: Timeline Timestamp Label Magnification + +**Files:** +- Modify: `src/lib/timeline/renderer.ts` — increase base font, add magnification to `drawTimeTicks()` +- Modify: `src/lib/components/Timeline.svelte` — track `mouseX`, pass to draw functions + +**Interfaces:** +- Consumes: existing `drawTimeline()` signature, `TimelineState` +- Produces: updated `drawTimeline()` with new `mouseX` parameter (`number | null`) + +- [ ] **Step 1: Add mouseX parameter to drawTimeline and drawTimeTicks** + +In `src/lib/timeline/renderer.ts`, update the `drawTimeline` signature to accept `mouseX`: + +```typescript +export function drawTimeline( + ctx: CanvasRenderingContext2D, + state: TimelineState, + currentTime: number, + duration: number, + clips: Clip[] = [], + selectedClipId: string | null = null, + pendingInPoint: number | null = null, + waveform: WaveformData = { tiers: null }, + thumbnailSpritesheets: ThumbnailSpritesheet[] = [], + hasVideo: boolean = false, + mouseX: number | null = null +): void { + const { width, height } = state; + ctx.clearRect(0, 0, width, height); + + ctx.fillStyle = '#181825'; + ctx.fillRect(0, 0, width, height); + + drawTimeTicks(ctx, state, duration, mouseX); + + // ... rest unchanged +} +``` + +- [ ] **Step 2: Implement magnification in drawTimeTicks** + +Replace the `drawTimeTicks` function in `src/lib/timeline/renderer.ts`: + +```typescript +const BASE_FONT_SIZE = 12; +const MAGNIFY_BONUS = 6; +const MAGNIFY_RADIUS = 80; + +function drawTimeTicks( + ctx: CanvasRenderingContext2D, + state: TimelineState, + duration: number, + mouseX: number | null +): void { + const range = state.visibleEnd - state.visibleStart; + const tickInterval = getTickInterval(range, state.width); + const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval; + + ctx.textAlign = 'center'; + const baselineY = state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT + 14; + + for (let t = startTick; t <= state.visibleEnd; t += tickInterval) { + if (t < 0 || t > duration) continue; + const x = timeToX(t, state); + + // Tick mark + ctx.strokeStyle = '#45475a'; + ctx.beginPath(); + ctx.moveTo(x, state.height - CONTROLS_LANE_HEIGHT); + ctx.lineTo(x, state.height - CONTROLS_LANE_HEIGHT + TICK_HEIGHT); + ctx.stroke(); + + // Compute magnified font size + let fontSize = BASE_FONT_SIZE; + if (mouseX !== null) { + const distance = Math.abs(x - mouseX); + if (distance < MAGNIFY_RADIUS) { + const scale = (1 + Math.cos(Math.PI * distance / MAGNIFY_RADIUS)) / 2; + fontSize = BASE_FONT_SIZE + MAGNIFY_BONUS * scale; + } + } + + ctx.fillStyle = '#6c7086'; + ctx.font = `${Math.round(fontSize)}px -apple-system, sans-serif`; + + const label = formatTickLabel(t); + ctx.fillText(label, x, baselineY); + } +} +``` + +Key changes from original: +- Base font size: `10px` → `12px` +- Baseline Y position: shifted down by 2px to accommodate the larger base font (`+ 12` → `+ 14`) +- When `mouseX` is non-null, each label computes its distance from the cursor and applies a cosine falloff scale to the font size +- The `baselineY` stays constant — larger fonts grow upward from the same baseline, avoiding jitter + +- [ ] **Step 3: Track mouseX in Timeline.svelte** + +In `src/lib/components/Timeline.svelte`, add the mouseX state: + +```typescript +let mouseX = $state(null); +``` + +Add a `mousemove` handler for tracking (the existing `handleMouseMove` only fires during drag — we need passive tracking too): + +```typescript +function handleMouseTrack(e: MouseEvent) { + mouseX = getCanvasX(e); +} + +function handleMouseLeave() { + handleMouseUp(); + mouseX = null; +} +``` + +Update the canvas element events: + +```svelte + { handleMouseMove(e); handleMouseTrack(e); }} + onmouseup={handleMouseUp} + onmouseleave={handleMouseLeave} + onwheel={handleWheel} +> +``` + +- [ ] **Step 4: Pass mouseX to drawTimeline** + +Update `drawMainCanvas()` in `Timeline.svelte`: + +```typescript +function drawMainCanvas() { + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.save(); + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + drawTimeline( + ctx, + timelineState, + session.currentTime, + session.duration, + clipStore.clips, + clipStore.selectedClipId, + clipStore.pendingInPoint, + waveformData, + session.thumbnailSpritesheets, + session.status === 'ready', + mouseX + ); + ctx.restore(); +} +``` + +Add `mouseX` to the unified redraw effect's dependency tracking: + +```typescript +$effect(() => { + // ... existing void expressions ... + void mouseX; + requestDraw(); +}); +``` + +- [ ] **Step 5: Build and verify** + +```bash +npm run check +``` + +Expected: no errors. + +```bash +npm run tauri dev +``` + +Verify: +1. Timestamp labels are visibly larger than before (12px base vs 10px) +2. Moving the mouse along the timeline causes nearby labels to smoothly enlarge +3. The label directly under the cursor is noticeably larger (~18px) +4. Labels far from the cursor stay at base size +5. Moving the mouse off the timeline returns all labels to base size +6. No jitter — labels grow upward from a fixed baseline +7. Performance is smooth — no noticeable lag when moving the mouse + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat: larger timeline timestamps with mouse-proximity magnification" +``` + +--- + +### Task 4: Timeline Right-Click Context Menu + +**Files:** +- Create: `src/lib/components/TimelineContextMenu.svelte` +- Modify: `src/lib/components/Timeline.svelte` — add `oncontextmenu` handler, render context menu + +**Interfaces:** +- Consumes: `markInPoint()`, `markOutPoint()`, `removeClip()`, `selectClip()` from clips store; `clipStore.selectedClipId`, `clipStore.clips`; `seekTo()` from Timeline +- Produces: `TimelineContextMenu` component, `ContextMenuAction` type + +- [ ] **Step 1: Create TimelineContextMenu.svelte** + +Create `src/lib/components/TimelineContextMenu.svelte`: + +```svelte + + + + + + + +``` + +- [ ] **Step 2: Wire context menu into Timeline.svelte** + +In `src/lib/components/Timeline.svelte`, add the imports and state: + +```typescript +import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte'; +import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte'; +import { + clipStore, + selectClip, + updateClip, + markInPoint, + markOutPoint, + removeClip, +} from '$lib/stores/clips.svelte'; +``` + +(Merge with the existing clips import — add `markInPoint`, `markOutPoint`, `removeClip` to the existing destructure.) + +Add context menu state: + +```typescript +let contextMenu = $state<{ + x: number; + y: number; + time: number; +} | null>(null); +``` + +Add the `contextmenu` handler: + +```typescript +function handleContextMenu(e: MouseEvent) { + e.preventDefault(); + const x = getCanvasX(e); + const time = computeClickTime(x, timelineState, session.duration); + seekTo(time); + contextMenu = { + x: e.clientX, + y: e.clientY, + time, + }; +} +``` + +Add the action handler: + +```typescript +function handleContextMenuAction(action: ContextMenuAction) { + if (!contextMenu) return; + switch (action.type) { + case 'mark-in': + markInPoint(contextMenu.time); + break; + case 'mark-out': + markOutPoint(contextMenu.time); + break; + case 'go-to-clip-start': { + const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId); + if (clip) seekTo(clip.startTime); + break; + } + case 'go-to-clip-end': { + const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId); + if (clip) seekTo(clip.endTime); + break; + } + case 'delete-clip': + if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId); + break; + default: { + const _exhaustive: never = action.type; + void _exhaustive; + } + } +} +``` + +Add `oncontextmenu` to the canvas element: + +```svelte + { handleMouseMove(e); handleMouseTrack(e); }} + onmouseup={handleMouseUp} + onmouseleave={handleMouseLeave} + onwheel={handleWheel} + oncontextmenu={handleContextMenu} +> +``` + +Render the context menu conditionally, after the closing `
` of `.timeline-wrapper`: + +```svelte +{#if contextMenu} + { contextMenu = null; }} + /> +{/if} +``` + +Note: the `TimelineContextMenu` uses `position: fixed`, so it's positioned relative to the viewport regardless of where in the DOM it's rendered. Placing it inside `Timeline.svelte` is fine. + +- [ ] **Step 3: Close context menu on scroll/wheel** + +Update the existing `handleWheel` in `Timeline.svelte` to close the menu: + +```typescript +function handleWheel(e: WheelEvent) { + e.preventDefault(); + contextMenu = null; // Close context menu on scroll + + // ... rest of existing logic +} +``` + +Also close on mousedown (clicking the timeline to seek should dismiss it): + +At the start of `handleMouseDown`: + +```typescript +function handleMouseDown(e: MouseEvent) { + contextMenu = null; + // ... rest of existing logic +} +``` + +- [ ] **Step 4: Build and verify** + +```bash +npm run check +``` + +Expected: no errors. + +```bash +npm run tauri dev +``` + +Verify: +1. Right-clicking the timeline shows the custom context menu (not the default Reload/Inspect) +2. The playhead moves to the right-click position +3. The menu header shows the timestamp at the clicked position +4. "Mark In" sets an in-point at that time +5. "Mark Out" after "Mark In" creates a clip +6. "Go to Clip Start" / "Go to Clip End" are disabled when no clip is selected +7. Selecting a clip then right-clicking enables those items +8. "Delete Clip" only appears when a clip is selected, and removes it +9. Clicking outside the menu closes it +10. Pressing Escape closes the menu +11. Scrolling/zooming the timeline closes the menu +12. The menu flips position if it would extend beyond the window edge + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat: timeline right-click context menu with mark/seek/delete actions" +``` + +--- + +### Task 5: Final Verification + +**Files:** none (verification only) + +**Interfaces:** +- Consumes: all prior tasks +- Produces: confidence that v0.1.3 is shippable + +- [ ] **Step 1: Clean build check** + +```bash +cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper +npm run check +``` + +Expected: zero errors, zero warnings. + +- [ ] **Step 2: Full integration test** + +```bash +npm run tauri dev +``` + +Test the following end-to-end: +1. Load a video via URL +2. Toggle layout between bottom and left modes +3. In left mode: resize the sidebar, verify video + transport fill remaining space +4. In left mode: timeline is full width below +5. Switch back to bottom mode — clip list returns below timeline +6. Move mouse across timeline — timestamps magnify near cursor +7. Right-click timeline — context menu appears, Mark In works +8. Right-click again further along — Mark Out creates a clip +9. Select clip, right-click — Go to Clip Start/End and Delete Clip work +10. Verify keyboard shortcuts (I, O, Space, etc.) still work +11. Verify version shows as 0.1.3 in status bar / about dialog + +- [ ] **Step 3: Commit any fixups if needed** + +If any issues were found and fixed, commit them: + +```bash +git add -A +git commit -m "fix: v0.1.3 polish fixups" +```