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

27 KiB
Raw Blame History

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:

.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.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:

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), onClose prop (unchanged)

  • Produces: visual changes only — onClose prop 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"