docs: v0.2.1 implementation plan — 5 tasks

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 01:49:46 -04:00
parent 21a5445143
commit d8a6e0edd0

View 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"
```