27 KiB
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/sveltefor icons — import from@lucide/svelte(tree-shakable named imports)- All imports at top of file, no inline imports
- Exhaustive switch with
nevercheck in default case for discriminated unions - Zero
svelte-checkerrors 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:
.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):
.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:
npx svelte-check --tsconfig ./tsconfig.json
npm test
Expected: 0 errors, 0 warnings, all tests pass.
- Step 4: Commit
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
npm install @lucide/svelte
- Step 2: Replace icons in PlayerControls.svelte
Add Lucide imports at the top of the <script> block:
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:
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):
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:
npx svelte-check --tsconfig ./tsconfig.json
npm test
Expected: 0 errors, 0 warnings, all tests pass.
- Step 6: Commit
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:
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:
npx svelte-check --tsconfig ./tsconfig.json
npm test
Expected: 0 errors, 0 warnings, all tests pass.
- Step 3: Commit
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.currentTimeandsession.durationfrom$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:
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:
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:
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:
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):
import {
VolumeX,
Volume1,
Volume2,
Rewind,
FastForward,
Pause,
Play,
Scissors,
Captions,
PictureInPicture2,
Maximize,
Minimize,
} from '@lucide/svelte';
Add onCutClip to the props destructure:
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:
<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):
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:
import { getSelectedClipId, removeClip, markInPoint, markOutPoint, addClip } from '$lib/stores/clips.svelte';
Add the cutClip function after toggleMode():
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:
<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:
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:
npx svelte-check --tsconfig ./tsconfig.json
npm test
Expected: 0 errors, 0 warnings, all tests pass (including 8 new clipRange tests).
- Step 8: Commit
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),onCloseprop (unchanged) -
Produces: visual changes only —
onCloseprop API unchanged -
Step 1: Write failing component tests
Create tests/lib/components/AboutDialog.test.ts:
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:
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:
<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:
npx vitest run tests/lib/components/AboutDialog.test.ts
Expected: 5 tests pass.
- Step 5: Run full verification
Run:
npx svelte-check --tsconfig ./tsconfig.json
npm test
Expected: 0 errors, 0 warnings, all tests pass.
- Step 6: Commit
git add src/lib/components/AboutDialog.svelte tests/lib/components/AboutDialog.test.ts
git commit -m "feat: tabbed About dialog with keyboard shortcuts reference"