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