# 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" ```