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

27 KiB

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

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
grep -r "0\.1\.2" package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml

Expected: no output (no matches).

  • Step 3: Commit
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:

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:

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:

await store.set('clipListPosition', preferences.clipListPosition);
await store.set('clipListWidth', preferences.clipListWidth);

Add a convenience setter:

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:

import { preferences, setClipListPosition } from '$lib/stores/preferences.svelte';

(The existing loadPreferences import stays.)

Add a toggle function:

function toggleClipListPosition() {
  const next = preferences.clipListPosition === 'bottom' ? 'left' : 'bottom';
  setClipListPosition(next);
}

Add the button in the toolbar, before the about/prefs buttons:

<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:

.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):

<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:

<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:

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:window
  onkeydown={handleGlobalKeydown}
  onmousemove={(e) => { handleResizeMove(e); handleSidebarResizeMove(e); }}
  onmouseup={() => { handleResizeEnd(); handleSidebarResizeEnd(); }}
  onresize={() => { windowWidth = window.innerWidth; }}
/>
  • Step 5: Add CSS for left mode
.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:

let { onExport, position = 'bottom' }: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();

Add a conditional class on the container:

<div
  class="clip-list"
  class:sidebar={position === 'left'}
  ...
>

Add CSS:

.clip-list.sidebar {
  border-top: none;
  height: 100%;
}

Pass the prop in App.svelte:

<!-- In left mode -->
<ClipList onExport={() => (showExportDialog = true)} position="left" />
<!-- In bottom mode -->
<ClipList onExport={() => (showExportDialog = true)} />
  • Step 6: Build and verify
cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper
npm run check

Expected: no errors or warnings.

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
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:

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:

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:

let mouseX = $state<number | null>(null);

Add a mousemove handler for tracking (the existing handleMouseMove only fires during drag — we need passive tracking too):

function handleMouseTrack(e: MouseEvent) {
  mouseX = getCanvasX(e);
}

function handleMouseLeave() {
  handleMouseUp();
  mouseX = null;
}

Update the canvas element events:

<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:

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:

$effect(() => {
  // ... existing void expressions ...
  void mouseX;
  requestDraw();
});
  • Step 5: Build and verify
npm run check

Expected: no errors.

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
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:

<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:

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:

let contextMenu = $state<{
  x: number;
  y: number;
  time: number;
} | null>(null);

Add the contextmenu handler:

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:

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:

<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:

{#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:

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:

function handleMouseDown(e: MouseEvent) {
  contextMenu = null;
  // ... rest of existing logic
}
  • Step 4: Build and verify
npm run check

Expected: no errors.

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
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

cd /Users/erikfredericks/dev-ai/one-offs/gui-video-clipper
npm run check

Expected: zero errors, zero warnings.

  • Step 2: Full integration test
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:

git add -A
git commit -m "fix: v0.1.3 polish fixups"