docs: v0.2.1 implementation plan — 5 tasks
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
994
docs/superpowers/plans/2026-09-23-v021-polish-and-cut-clip.md
Normal file
994
docs/superpowers/plans/2026-09-23-v021-polish-and-cut-clip.md
Normal file
@@ -0,0 +1,994 @@
|
|||||||
|
# v0.2.1 — Polish, Icons, Cut Clip & Shortcuts Reference 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 v0.2.1 with Lucide icons, smoother blur animation, cut-clip from Player mode, and a keyboard shortcuts reference in the About dialog.
|
||||||
|
|
||||||
|
**Architecture:** All changes are frontend-only. A new utility function handles quick-clip range calculation. The About dialog gains a tabbed interface. Lucide replaces all emoji/text-glyph icons. CSS pseudo-element fixes the backdrop blur transition.
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5, `@lucide/svelte`, Vitest, Tauri v2
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Svelte 5 with runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`)
|
||||||
|
- `@lucide/svelte` for icons — import from `@lucide/svelte` (tree-shakable named imports)
|
||||||
|
- All imports at top of file, no inline imports
|
||||||
|
- Exhaustive switch with `never` check in default case for discriminated unions
|
||||||
|
- Zero `svelte-check` errors and warnings after every task
|
||||||
|
- All existing tests must pass after every task (`npm test`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Floating Controls Position Bump + Blur Fix
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/PlayerControls.svelte` (CSS section, lines ~278–302)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new
|
||||||
|
- Produces: visual-only changes — no API changes
|
||||||
|
|
||||||
|
- [ ] **Step 1: Raise the controls overlay**
|
||||||
|
|
||||||
|
In `src/lib/components/PlayerControls.svelte`, change `bottom: 48px` to `bottom: 64px` in `.player-controls-overlay`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.player-controls-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 64px;
|
||||||
|
left: 15%;
|
||||||
|
right: 15%;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease 0.1s;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move blur to ::before pseudo-element**
|
||||||
|
|
||||||
|
Remove `background` and `backdrop-filter` from `.player-controls-panel`. Add `position: relative` and a `::before` pseudo-element that handles the blur background. The pseudo-element's opacity is driven by the `.visible` class on the ancestor overlay.
|
||||||
|
|
||||||
|
Replace the `.player-controls-panel` rule (currently around lines 294–302):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.player-controls-panel {
|
||||||
|
pointer-events: auto;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls-panel::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-radius: 12px;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-controls-overlay.visible .player-controls-panel::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx svelte-check --tsconfig ./tsconfig.json
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings, all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/PlayerControls.svelte
|
||||||
|
git commit -m "fix: raise controls to 64px, smooth blur via ::before pseudo-element"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Lucide Icon Migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/PlayerControls.svelte` (script + template)
|
||||||
|
- Modify: `src/lib/components/TransportControls.svelte` (script + template)
|
||||||
|
- Modify: `src/App.svelte` (toolbar buttons)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new
|
||||||
|
- Produces: visual-only changes — all props/events unchanged
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install @lucide/svelte**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @lucide/svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace icons in PlayerControls.svelte**
|
||||||
|
|
||||||
|
Add Lucide imports at the top of the `<script>` block:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
VolumeX,
|
||||||
|
Volume1,
|
||||||
|
Volume2,
|
||||||
|
Rewind,
|
||||||
|
FastForward,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Scissors,
|
||||||
|
Captions,
|
||||||
|
PictureInPicture2,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace each emoji/text glyph in the template:
|
||||||
|
|
||||||
|
| Location | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Mute button (line ~161) | `{#if isMuted \|\| volume === 0}🔇{:else if volume < 0.5}🔉{:else}🔊{/if}` | `{#if isMuted \|\| volume === 0}<VolumeX size={16} />{:else if volume < 0.5}<Volume1 size={16} />{:else}<Volume2 size={16} />{/if}` |
|
||||||
|
| Skip back (line ~181) | `⏪` | `<Rewind size={16} />` |
|
||||||
|
| Play/Pause (line ~187) | `{session.isPlaying ? '❚❚' : '▶'}` | `{#if session.isPlaying}<Pause size={18} />{:else}<Play size={18} />{/if}` |
|
||||||
|
| Skip forward (line ~193) | `⏩` | `<FastForward size={16} />` |
|
||||||
|
| CC button (line ~222) | `CC` | `<Captions size={16} />` |
|
||||||
|
| PiP button (line ~231) | `⧉` | `<PictureInPicture2 size={16} />` |
|
||||||
|
| Fullscreen button (line ~239) | `{fullscreenActive ? '⤓' : '⛶'}` | `{#if fullscreenActive}<Minimize size={16} />{:else}<Maximize size={16} />{/if}` |
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace icons in TransportControls.svelte**
|
||||||
|
|
||||||
|
Add Lucide imports at the top of the `<script>` block:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChevronFirst,
|
||||||
|
ChevronLast,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
VolumeX,
|
||||||
|
Volume1,
|
||||||
|
Volume2,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace each glyph in the template:
|
||||||
|
|
||||||
|
| Location | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Previous keyframe button | `◄K` | `<ChevronFirst size={16} />` |
|
||||||
|
| -5s button | `-5s` (keep as text) | `-5s` (no change — text labels are fine here) |
|
||||||
|
| -1s button | `-1s` (keep as text) | `-1s` (no change) |
|
||||||
|
| Previous frame button | `◄\|` | `<SkipBack size={16} />` |
|
||||||
|
| Play/Pause button (line ~59) | `{session.isPlaying ? '❚❚' : '▶'}` | `{#if session.isPlaying}<Pause size={16} />{:else}<Play size={16} />{/if}` |
|
||||||
|
| Next frame button | `\|►` | `<SkipForward size={16} />` |
|
||||||
|
| +1s button | `+1s` (keep as text) | `+1s` (no change) |
|
||||||
|
| +5s button | `+5s` (keep as text) | `+5s` (no change) |
|
||||||
|
| Next keyframe button | `K►` | `<ChevronLast size={16} />` |
|
||||||
|
| Mute button (line ~88–93) | `{#if isMuted \|\| volume === 0}🔇{:else if volume < 0.5}🔉{:else}🔊{/if}` | `{#if isMuted \|\| volume === 0}<VolumeX size={14} />{:else if volume < 0.5}<Volume1 size={14} />{:else}<Volume2 size={14} />{/if}` |
|
||||||
|
|
||||||
|
- [ ] **Step 4: Replace icons in App.svelte toolbar**
|
||||||
|
|
||||||
|
Add Lucide imports at the top of the `<script>` block (alongside existing imports):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
PanelLeft,
|
||||||
|
PanelBottom,
|
||||||
|
Info,
|
||||||
|
Settings,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace toolbar button contents:
|
||||||
|
|
||||||
|
| Location | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Layout toggle (line ~357) | `{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}` | `{#if preferences.clipListPosition === 'bottom'}<PanelLeft size={16} />{:else}<PanelBottom size={16} />{/if}` |
|
||||||
|
| About button (line ~360) | `ℹ` | `<Info size={16} />` |
|
||||||
|
| Prefs button (line ~361) | `⚙` | `<Settings size={16} />` |
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx svelte-check --tsconfig ./tsconfig.json
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings, all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json src/lib/components/PlayerControls.svelte src/lib/components/TransportControls.svelte src/App.svelte
|
||||||
|
git commit -m "feat: replace emoji icons with Lucide SVG components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Mode Toggle Shortcuts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/App.svelte` (handleGlobalKeydown, lines ~220–234)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing `toggleMode()` function in App.svelte
|
||||||
|
- Produces: behavioral change only — both C and P now call `toggleMode()`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update key handlers**
|
||||||
|
|
||||||
|
In `handleGlobalKeydown` in `src/App.svelte`, replace the `'p'/'P'` and `'c'/'C'` cases. Both should call `toggleMode()` and handle the Player mode auto-show:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
case 'c':
|
||||||
|
case 'C':
|
||||||
|
e.preventDefault();
|
||||||
|
toggleMode();
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `toggleMode()` function already handles showing controls when entering Player mode, so no additional logic is needed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx svelte-check --tsconfig ./tsconfig.json
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings, all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/App.svelte
|
||||||
|
git commit -m "feat: C and P both toggle between Clipper/Player modes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Cut Clip Feature
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/utils/clipRange.ts`
|
||||||
|
- Create: `tests/lib/utils/clipRange.test.ts`
|
||||||
|
- Modify: `src/App.svelte` (handleGlobalKeydown + cutClip function + PlayerControls props)
|
||||||
|
- Modify: `src/lib/components/PlayerControls.svelte` (add scissors button + onCutClip prop)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `addClip(start, end)` from `$lib/stores/clips.svelte`, `setAppMode()` from `$lib/stores/preferences.svelte`, `session.currentTime` and `session.duration` from `$lib/stores/videoSession.svelte`
|
||||||
|
- Produces: `computeQuickClipRange(currentTime, duration, position, clipDuration?)` → `{ start: number; end: number } | null`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for computeQuickClipRange**
|
||||||
|
|
||||||
|
Create `tests/lib/utils/clipRange.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { computeQuickClipRange } from '$lib/utils/clipRange';
|
||||||
|
|
||||||
|
describe('computeQuickClipRange', () => {
|
||||||
|
it('returns null when duration is 0', () => {
|
||||||
|
expect(computeQuickClipRange(5, 0, 'at')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"at" creates clip from currentTime to currentTime+10', () => {
|
||||||
|
const result = computeQuickClipRange(30, 120, 'at');
|
||||||
|
expect(result).toEqual({ start: 30, end: 40 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"before" creates clip from currentTime-10 to currentTime', () => {
|
||||||
|
const result = computeQuickClipRange(30, 120, 'before');
|
||||||
|
expect(result).toEqual({ start: 20, end: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"at" clamps end to duration', () => {
|
||||||
|
const result = computeQuickClipRange(115, 120, 'at');
|
||||||
|
expect(result).toEqual({ start: 115, end: 120 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"before" clamps start to 0', () => {
|
||||||
|
const result = computeQuickClipRange(3, 120, 'before');
|
||||||
|
expect(result).toEqual({ start: 0, end: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts custom clip duration', () => {
|
||||||
|
const result = computeQuickClipRange(50, 120, 'at', 20);
|
||||||
|
expect(result).toEqual({ start: 50, end: 70 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"at" at time 0 works', () => {
|
||||||
|
const result = computeQuickClipRange(0, 120, 'at');
|
||||||
|
expect(result).toEqual({ start: 0, end: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"before" at exact duration works', () => {
|
||||||
|
const result = computeQuickClipRange(120, 120, 'before');
|
||||||
|
expect(result).toEqual({ start: 110, end: 120 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx vitest run tests/lib/utils/clipRange.test.ts
|
||||||
|
```
|
||||||
|
Expected: FAIL — module `$lib/utils/clipRange` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement computeQuickClipRange**
|
||||||
|
|
||||||
|
Create `src/lib/utils/clipRange.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type QuickClipPosition = 'at' | 'before';
|
||||||
|
|
||||||
|
export function computeQuickClipRange(
|
||||||
|
currentTime: number,
|
||||||
|
duration: number,
|
||||||
|
position: QuickClipPosition,
|
||||||
|
clipDuration = 10,
|
||||||
|
): { start: number; end: number } | null {
|
||||||
|
if (duration === 0) return null;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'at':
|
||||||
|
return {
|
||||||
|
start: currentTime,
|
||||||
|
end: Math.min(currentTime + clipDuration, duration),
|
||||||
|
};
|
||||||
|
case 'before':
|
||||||
|
return {
|
||||||
|
start: Math.max(currentTime - clipDuration, 0),
|
||||||
|
end: currentTime,
|
||||||
|
};
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = position;
|
||||||
|
void _exhaustive;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx vitest run tests/lib/utils/clipRange.test.ts
|
||||||
|
```
|
||||||
|
Expected: 8 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add scissors button to PlayerControls**
|
||||||
|
|
||||||
|
In `src/lib/components/PlayerControls.svelte`, add the `Scissors` import (alongside existing Lucide imports from Task 2):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
VolumeX,
|
||||||
|
Volume1,
|
||||||
|
Volume2,
|
||||||
|
Rewind,
|
||||||
|
FastForward,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Scissors,
|
||||||
|
Captions,
|
||||||
|
PictureInPicture2,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `onCutClip` to the props destructure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let {
|
||||||
|
visible,
|
||||||
|
captionsEnabled,
|
||||||
|
hasCaptions,
|
||||||
|
onToggleCaptions,
|
||||||
|
onOpenCaptionSettings,
|
||||||
|
onCutClip,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
captionsEnabled: boolean;
|
||||||
|
hasCaptions: boolean;
|
||||||
|
onToggleCaptions: () => void;
|
||||||
|
onOpenCaptionSettings: () => void;
|
||||||
|
onCutClip: (position: 'at' | 'before') => void;
|
||||||
|
} = $props();
|
||||||
|
```
|
||||||
|
|
||||||
|
In the right controls group, insert the scissors button **between** the speed-wrapper `</div>` and the `{#if hasCaptions}` block:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
class="ctrl-btn"
|
||||||
|
onclick={() => onCutClip('at')}
|
||||||
|
oncontextmenu={(e) => { e.preventDefault(); onCutClip('before'); }}
|
||||||
|
title="Cut clip at playhead (X) · Right-click: 10s before (Z)"
|
||||||
|
type="button"
|
||||||
|
><Scissors size={16} /></button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Wire up cutClip in App.svelte**
|
||||||
|
|
||||||
|
In `src/App.svelte`, add the import at the top (alongside existing imports):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { computeQuickClipRange } from '$lib/utils/clipRange';
|
||||||
|
import { addClip } from '$lib/stores/clips.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `addClip` is already available via the existing import of `getSelectedClipId, removeClip, markInPoint, markOutPoint` — add `addClip` to that import line:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `cutClip` function after `toggleMode()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function cutClip(position: 'at' | 'before') {
|
||||||
|
const range = computeQuickClipRange(session.currentTime, session.duration, position);
|
||||||
|
if (!range) return;
|
||||||
|
addClip(range.start, range.end);
|
||||||
|
setAppMode('clipper');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `onCutClip` to the `<PlayerControls>` component:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<PlayerControls
|
||||||
|
visible={showPlayerControls}
|
||||||
|
{captionsEnabled}
|
||||||
|
hasCaptions={session.hasCaptions || false}
|
||||||
|
onToggleCaptions={handleToggleCaptions}
|
||||||
|
onOpenCaptionSettings={() => {}}
|
||||||
|
onCutClip={cutClip}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add X/Z keyboard shortcuts in `handleGlobalKeydown`. Insert these cases **before** the `'f'/'F'` case:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case 'x':
|
||||||
|
case 'X':
|
||||||
|
if (isPlayerMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
cutClip('at');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'z':
|
||||||
|
case 'Z':
|
||||||
|
if (isPlayerMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
cutClip('before');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx svelte-check --tsconfig ./tsconfig.json
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings, all tests pass (including 8 new clipRange tests).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/utils/clipRange.ts tests/lib/utils/clipRange.test.ts src/App.svelte src/lib/components/PlayerControls.svelte
|
||||||
|
git commit -m "feat: cut clip from Player mode (X/Z keys, scissors button)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Tabbed About Dialog with Keyboard Shortcuts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/AboutDialog.svelte` (full rewrite of template + styles)
|
||||||
|
- Create: `tests/lib/components/AboutDialog.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `getVersion()` from `@tauri-apps/api/app` (already used), `onClose` prop (unchanged)
|
||||||
|
- Produces: visual changes only — `onClose` prop API unchanged
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing component tests**
|
||||||
|
|
||||||
|
Create `tests/lib/components/AboutDialog.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, fireEvent } from '@testing-library/svelte';
|
||||||
|
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/app', () => ({
|
||||||
|
getVersion: vi.fn().mockResolvedValue('0.2.1'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AboutDialog', () => {
|
||||||
|
it('renders the About tab by default', () => {
|
||||||
|
const { getByText } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||||
|
expect(getByText('by cottongin')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows two tabs: About and Shortcuts', () => {
|
||||||
|
const { getByRole } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
const tablist = getByRole('tablist');
|
||||||
|
expect(tablist).toBeTruthy();
|
||||||
|
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to Shortcuts tab on click', async () => {
|
||||||
|
const { getByText, queryByText } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
const shortcutsTab = getByText('Shortcuts');
|
||||||
|
await fireEvent.click(shortcutsTab);
|
||||||
|
|
||||||
|
expect(getByText('Play / Pause')).toBeTruthy();
|
||||||
|
expect(getByText('Toggle mode')).toBeTruthy();
|
||||||
|
expect(getByText('Player Mode')).toBeTruthy();
|
||||||
|
expect(getByText('Clipper Mode')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches back to About tab', async () => {
|
||||||
|
const { getByText } = render(AboutDialog, {
|
||||||
|
props: { onClose: vi.fn() },
|
||||||
|
});
|
||||||
|
await fireEvent.click(getByText('Shortcuts'));
|
||||||
|
await fireEvent.click(getByText('About'));
|
||||||
|
|
||||||
|
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||||
|
expect(getByText('by cottongin')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when Close button is clicked', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const { getByText } = render(AboutDialog, {
|
||||||
|
props: { onClose },
|
||||||
|
});
|
||||||
|
await fireEvent.click(getByText('Close'));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx vitest run tests/lib/components/AboutDialog.test.ts
|
||||||
|
```
|
||||||
|
Expected: FAIL — tests fail because the current AboutDialog has no tabs.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite AboutDialog with tabs**
|
||||||
|
|
||||||
|
Replace the entire contents of `src/lib/components/AboutDialog.svelte`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
|
let version = $state('');
|
||||||
|
let activeTab = $state<'about' | 'shortcuts'>('about');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
getVersion().then((v) => {
|
||||||
|
version = v;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalShortcuts = [
|
||||||
|
{ key: 'Space', action: 'Play / Pause' },
|
||||||
|
{ key: 'K', action: 'Play / Pause' },
|
||||||
|
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
|
||||||
|
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
|
||||||
|
{ key: '←', action: 'Seek back 5s' },
|
||||||
|
{ key: '→', action: 'Seek forward 5s' },
|
||||||
|
{ key: 'Shift+←', action: 'Seek back 1s' },
|
||||||
|
{ key: 'Shift+→', action: 'Seek forward 1s' },
|
||||||
|
{ key: ',', action: 'Previous frame' },
|
||||||
|
{ key: '.', action: 'Next frame' },
|
||||||
|
{ key: '< (Shift+,)', action: 'Previous keyframe' },
|
||||||
|
{ key: '> (Shift+.)', action: 'Next keyframe' },
|
||||||
|
{ key: 'C / P', action: 'Toggle mode' },
|
||||||
|
{ key: '⌘/', action: 'About' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const playerShortcuts = [
|
||||||
|
{ key: 'F', action: 'Toggle fullscreen' },
|
||||||
|
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
|
||||||
|
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const clipperShortcuts = [
|
||||||
|
{ key: 'I', action: 'Set in-point' },
|
||||||
|
{ key: 'O', action: 'Set out-point' },
|
||||||
|
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
|
||||||
|
{ key: '⌘E', action: 'Export' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="overlay"
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
if (e.key !== 'Escape') e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
class:wide={activeTab === 'shortcuts'}
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === 'about'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'about'}
|
||||||
|
onclick={() => { activeTab = 'about'; }}
|
||||||
|
type="button"
|
||||||
|
>About</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === 'shortcuts'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'shortcuts'}
|
||||||
|
onclick={() => { activeTab = 'shortcuts'; }}
|
||||||
|
type="button"
|
||||||
|
>Shortcuts</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === 'about'}
|
||||||
|
<!-- About content -->
|
||||||
|
<div class="about-content">
|
||||||
|
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
||||||
|
<stop offset="0%" stop-color="#fb923c"/>
|
||||||
|
<stop offset="100%" stop-color="#ea580c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
||||||
|
<g transform="translate(-3, 3)">
|
||||||
|
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
||||||
|
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(3, -3)">
|
||||||
|
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
||||||
|
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||||
|
</g>
|
||||||
|
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
||||||
|
<g transform="rotate(20, 256, 256)">
|
||||||
|
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
||||||
|
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
||||||
|
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
||||||
|
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
||||||
|
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||||
|
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||||
|
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<h2>GUI Video Clipper</h2>
|
||||||
|
{#if version}
|
||||||
|
<span class="version">v{version}</span>
|
||||||
|
{/if}
|
||||||
|
<p class="author">by cottongin</p>
|
||||||
|
<p class="license">MIT License</p>
|
||||||
|
<a
|
||||||
|
class="repo-link"
|
||||||
|
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code.cottongin.xyz/cottongin/gui-video-clipper
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Shortcuts content -->
|
||||||
|
<div class="shortcuts-content">
|
||||||
|
<section>
|
||||||
|
<h3>Global</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each globalShortcuts as { key, action }}
|
||||||
|
<tr>
|
||||||
|
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||||
|
<td class="action-cell">{action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Player Mode</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each playerShortcuts as { key, action }}
|
||||||
|
<tr>
|
||||||
|
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||||
|
<td class="action-cell">{action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Clipper Mode</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each clipperShortcuts as { key, action }}
|
||||||
|
<tr>
|
||||||
|
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||||
|
<td class="action-cell">{action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" onclick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: max-width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog.wide {
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- About content --- */
|
||||||
|
.about-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Shortcuts content --- */
|
||||||
|
.shortcuts-content {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(70vh - 140px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content tr {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-content tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-cell {
|
||||||
|
width: 40%;
|
||||||
|
padding: 5px 8px 5px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
padding: 5px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Actions --- */
|
||||||
|
.actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx vitest run tests/lib/components/AboutDialog.test.ts
|
||||||
|
```
|
||||||
|
Expected: 5 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
npx svelte-check --tsconfig ./tsconfig.json
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings, all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/AboutDialog.svelte tests/lib/components/AboutDialog.test.ts
|
||||||
|
git commit -m "feat: tabbed About dialog with keyboard shortcuts reference"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user