Files
gui-video-clipper/docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md
2026-09-22 16:42:59 -04:00

1061 lines
27 KiB
Markdown

# 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<number>('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<void> {
preferences.clipListPosition = position;
await savePreferences();
}
export async function setClipListWidth(width: number): Promise<void> {
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
<button
class="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom' ? 'Move clip list to sidebar' : 'Move clip list to bottom'}
>
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
</button>
```
Add styling:
```css
.layout-btn {
font-size: 16px;
padding: 4px 8px;
}
```
- [ ] **Step 3: Restructure content area for 'left' mode**
Replace the `<main class="content">` block in `App.svelte` with a conditional layout. The key structural change:
**Bottom mode** (existing behavior — unchanged):
```svelte
<main class="content">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
<div class="timeline-clip-area" bind:this={splitAreaEl}>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
<div
class="resize-handle"
class:active={isResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize timeline and clip list"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
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);
}
}}
></div>
<div class="cliplist-pane">
<ClipList onExport={() => (showExportDialog = true)} />
</div>
</div>
</main>
```
**Left mode:**
```svelte
<main class="content content-left">
<div class="upper-section">
<div class="cliplist-sidebar" style="width: {preferences.clipListWidth}px">
<ClipList onExport={() => (showExportDialog = true)} />
</div>
<div
class="sidebar-resize-handle"
class:active={isSidebarResizing}
role="slider"
aria-orientation="vertical"
aria-label="Resize clip list sidebar"
aria-valuenow={preferences.clipListWidth}
aria-valuemin={150}
aria-valuemax={Math.round(windowWidth * 0.4)}
tabindex="0"
onmousedown={handleSidebarResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
setClipListWidth(Math.min(Math.round(windowWidth * 0.4), preferences.clipListWidth + 10));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setClipListWidth(Math.max(150, preferences.clipListWidth - 10));
}
}}
></div>
<div class="right-column">
<VideoPlayer />
<TransportControls bind:this={transportControls} />
</div>
</div>
<div class="timeline-clip-area" bind:this={splitAreaEl}>
<div
class="resize-handle"
class:active={isResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize upper section and timeline"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
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);
}
}}
></div>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
</div>
</main>
```
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 `<svelte:window>` handlers:
```svelte
<svelte:window
onkeydown={handleGlobalKeydown}
onmousemove={(e) => { 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
<div
class="clip-list"
class:sidebar={position === 'left'}
...
>
```
Add CSS:
```css
.clip-list.sidebar {
border-top: none;
height: 100%;
}
```
Pass the prop in `App.svelte`:
```svelte
<!-- In left mode -->
<ClipList onExport={() => (showExportDialog = true)} position="left" />
<!-- In bottom mode -->
<ClipList onExport={() => (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<number | null>(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
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={(e) => { handleMouseMove(e); handleMouseTrack(e); }}
onmouseup={handleMouseUp}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
></canvas>
```
- [ ] **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
<script lang="ts">
import { formatTime } from '$lib/utils/time';
export type ContextMenuAction =
| { type: 'mark-in' }
| { type: 'mark-out' }
| { type: 'go-to-clip-start' }
| { type: 'go-to-clip-end' }
| { type: 'delete-clip' };
let {
x,
y,
time,
selectedClipId,
onAction,
onClose,
}: {
x: number;
y: number;
time: number;
selectedClipId: string | null;
onAction: (action: ContextMenuAction) => void;
onClose: () => void;
} = $props();
let menuEl = $state<HTMLDivElement | null>(null);
function handleAction(action: ContextMenuAction) {
onAction(action);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
function handleClickOutside(e: MouseEvent) {
if (menuEl && !menuEl.contains(e.target as Node)) {
onClose();
}
}
// Adjust position to keep menu within viewport
let adjustedX = $state(x);
let adjustedY = $state(y);
$effect(() => {
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let newX = x;
let newY = y;
if (x + rect.width > vw) {
newX = x - rect.width;
}
if (y + rect.height > vh) {
newY = y - rect.height;
}
adjustedX = Math.max(0, newX);
adjustedY = Math.max(0, newY);
});
</script>
<svelte:window
onmousedown={handleClickOutside}
onkeydown={handleKeydown}
/>
<div
class="context-menu"
bind:this={menuEl}
style="left: {adjustedX}px; top: {adjustedY}px;"
role="menu"
>
<div class="menu-header">{formatTime(time)}</div>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-in' })}
>
<span class="label">Mark In</span>
<span class="shortcut">I</span>
</button>
<button
class="menu-item"
role="menuitem"
onclick={() => handleAction({ type: 'mark-out' })}
>
<span class="label">Mark Out</span>
<span class="shortcut">O</span>
</button>
<div class="separator"></div>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-start' })}
>
<span class="label">Go to Clip Start</span>
</button>
<button
class="menu-item"
class:disabled={!selectedClipId}
role="menuitem"
disabled={!selectedClipId}
onclick={() => handleAction({ type: 'go-to-clip-end' })}
>
<span class="label">Go to Clip End</span>
</button>
{#if selectedClipId}
<div class="separator"></div>
<button
class="menu-item danger"
role="menuitem"
onclick={() => handleAction({ type: 'delete-clip' })}
>
<span class="label">Delete Clip</span>
<span class="shortcut">⌫</span>
</button>
{/if}
</div>
<style>
.context-menu {
position: fixed;
z-index: 1000;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 0;
min-width: 180px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
font-size: 13px;
}
.menu-header {
padding: 4px 12px 2px;
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 0;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-primary);
}
.menu-item:disabled {
color: var(--text-muted);
cursor: default;
opacity: 0.5;
}
.menu-item.danger:hover {
background: var(--danger);
color: var(--bg-primary);
}
.shortcut {
font-size: 11px;
color: var(--text-muted);
margin-left: 16px;
}
.menu-item:hover:not(:disabled) .shortcut {
color: inherit;
}
.separator {
height: 1px;
background: var(--border);
margin: 4px 0;
}
</style>
```
- [ ] **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
<canvas
bind:this={canvas}
onmousedown={handleMouseDown}
onmousemove={(e) => { handleMouseMove(e); handleMouseTrack(e); }}
onmouseup={handleMouseUp}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
oncontextmenu={handleContextMenu}
></canvas>
```
Render the context menu conditionally, after the closing `</div>` of `.timeline-wrapper`:
```svelte
{#if contextMenu}
<TimelineContextMenu
x={contextMenu.x}
y={contextMenu.y}
time={contextMenu.time}
selectedClipId={clipStore.selectedClipId}
onAction={handleContextMenuAction}
onClose={() => { 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"
```