Files
gui-video-clipper/docs/superpowers/plans/2026-09-23-v021-polish-and-cut-clip.md
2026-09-23 01:49:46 -04:00

995 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```