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
switchwithnevercheck 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— addclipListPositionandclipListWidthfields - Modify:
src/App.svelte— add toggle button to toolbar, restructure content for dual layout
Interfaces:
-
Consumes: existing
preferencesstore,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:
- Default mode is 'bottom' — layout unchanged from v0.1.2
- Click the layout toggle button — clip list moves to left sidebar
- Sidebar is resizable by dragging the handle
- Timeline stays full width in both modes
- Toggle persists after reload
- 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 todrawTimeTicks() - Modify:
src/lib/components/Timeline.svelte— trackmouseX, pass to draw functions
Interfaces:
-
Consumes: existing
drawTimeline()signature,TimelineState -
Produces: updated
drawTimeline()with newmouseXparameter (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
mouseXis non-null, each label computes its distance from the cursor and applies a cosine falloff scale to the font size -
The
baselineYstays 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:
- Timestamp labels are visibly larger than before (12px base vs 10px)
- Moving the mouse along the timeline causes nearby labels to smoothly enlarge
- The label directly under the cursor is noticeably larger (~18px)
- Labels far from the cursor stay at base size
- Moving the mouse off the timeline returns all labels to base size
- No jitter — labels grow upward from a fixed baseline
- 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— addoncontextmenuhandler, render context menu
Interfaces:
-
Consumes:
markInPoint(),markOutPoint(),removeClip(),selectClip()from clips store;clipStore.selectedClipId,clipStore.clips;seekTo()from Timeline -
Produces:
TimelineContextMenucomponent,ContextMenuActiontype -
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:
- Right-clicking the timeline shows the custom context menu (not the default Reload/Inspect)
- The playhead moves to the right-click position
- The menu header shows the timestamp at the clicked position
- "Mark In" sets an in-point at that time
- "Mark Out" after "Mark In" creates a clip
- "Go to Clip Start" / "Go to Clip End" are disabled when no clip is selected
- Selecting a clip then right-clicking enables those items
- "Delete Clip" only appears when a clip is selected, and removes it
- Clicking outside the menu closes it
- Pressing Escape closes the menu
- Scrolling/zooming the timeline closes the menu
- 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:
- Load a video via URL
- Toggle layout between bottom and left modes
- In left mode: resize the sidebar, verify video + transport fill remaining space
- In left mode: timeline is full width below
- Switch back to bottom mode — clip list returns below timeline
- Move mouse across timeline — timestamps magnify near cursor
- Right-click timeline — context menu appears, Mark In works
- Right-click again further along — Mark Out creates a clip
- Select clip, right-click — Go to Clip Start/End and Delete Clip work
- Verify keyboard shortcuts (I, O, Space, etc.) still work
- 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"