Files
gui-video-clipper/docs/superpowers/plans/2026-09-22-extended-caption-styling.md
cottongin 8ad2f1c800 chore: stage all pending work — caption styling, media server, processing modal, docs, summaries
Includes:
- Extended caption styling (font, shadow, dimmed color, bg toggle)
- Media server, subtitle downloader, VTT parser, processing modal
- Waveform tiers, thumbnail/timeline improvements, transport controls
- Hybrid download model, dependency management, clip export enhancements
- 21 chat summaries, 2 implementation plans, 2 design specs

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 10:48:16 -04:00

28 KiB

Extended Caption Styling 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: Add system font selection, drop shadow controls, configurable dimmed text color, and background toggle to the shared caption settings, wired through to both preview and burn-in export.

Architecture: Extend CaptionSettings (frontend) and CaptionStyle (backend) with new fields. Add a list_system_fonts Tauri command using fc-list. Update the settings panel UI, the ASS generation code, and the preview caption rendering.

Tech Stack: Svelte 5, Rust/Tauri v2, ASS subtitle format, fontconfig (fc-list)

Global Constraints

  • Background and shadow are mutually exclusive: enabling one disables the other
  • fc-list fallback: if unavailable, use preset list: Arial, Helvetica, Verdana, Georgia, Times New Roman, Courier New, Impact
  • Dimmed color auto mode derives from textColor + dimmedOpacity; custom mode uses dimmedColor directly
  • ASS BackColour serves as background color (when background ON) or shadow color (when shadow ON)
  • All new fields must have backwards-compatible defaults (existing saved preferences without new fields should merge cleanly via { ...DEFAULT_CAPTION_SETTINGS, ...savedCaptions })
  • No new crate dependencies

Task 1: Data model — add new fields to frontend and backend

Files:

  • Modify: src/lib/stores/preferences.svelte.ts:6-27
  • Modify: src-tauri/src/models.rs:91-108
  • Modify: src/lib/bindings/export.ts:3-10
  • Modify: src/lib/components/ExportDialog.svelte:86-94

Interfaces:

  • Consumes: Existing CaptionSettings, CaptionStyle, CaptionStyle (TS binding)

  • Produces: Extended versions of all three with new fields. All later tasks depend on these types.

  • Step 1: Update CaptionSettings interface and defaults

In src/lib/stores/preferences.svelte.ts, replace the interface and defaults:

export interface CaptionSettings {
  fontFamily: string;
  fontSize: number;
  textColor: string;
  bold: boolean;
  outlineColor: string;
  backgroundEnabled: boolean;
  backgroundOpacity: number;
  backgroundColor: string;
  textOutline: boolean;
  shadowEnabled: boolean;
  shadowDepth: number;
  shadowColor: string;
  dimmedColorMode: 'auto' | 'custom';
  dimmedOpacity: number;
  dimmedColor: string;
  position: 'bottom' | 'top';
  wordHighlight: boolean;
}

export const DEFAULT_CAPTION_SETTINGS: CaptionSettings = {
  fontFamily: 'Arial',
  fontSize: 18,
  textColor: '#ffffff',
  bold: true,
  outlineColor: '#000000',
  backgroundEnabled: true,
  backgroundOpacity: 0.75,
  backgroundColor: '#000000',
  textOutline: true,
  shadowEnabled: false,
  shadowDepth: 2,
  shadowColor: '#000000',
  dimmedColorMode: 'auto',
  dimmedOpacity: 0.4,
  dimmedColor: '#999999',
  position: 'bottom',
  wordHighlight: true,
};
  • Step 2: Update CaptionStyle struct in Rust

In src-tauri/src/models.rs, replace the struct:

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CaptionStyle {
    pub font_family: String,
    pub font_size: u32,
    pub text_color: String,
    pub bold: bool,
    pub outline_color: String,
    pub background_enabled: bool,
    pub background_opacity: f64,
    pub background_color: String,
    pub text_outline: bool,
    pub shadow_enabled: bool,
    pub shadow_depth: u32,
    pub shadow_color: String,
    pub dimmed_color_mode: String,
    pub dimmed_opacity: f64,
    pub dimmed_color: String,
    pub position: String,
    pub word_highlight: bool,
}
  • Step 3: Update TypeScript CaptionStyle binding

In src/lib/bindings/export.ts, replace the interface:

export interface CaptionStyle {
  fontFamily: string;
  fontSize: number;
  textColor: string;
  bold: boolean;
  outlineColor: string;
  backgroundEnabled: boolean;
  backgroundOpacity: number;
  backgroundColor: string;
  textOutline: boolean;
  shadowEnabled: boolean;
  shadowDepth: number;
  shadowColor: string;
  dimmedColorMode: string;
  dimmedOpacity: number;
  dimmedColor: string;
  position: string;
  wordHighlight: boolean;
}
  • Step 4: Update ExportDialog passthrough

In src/lib/components/ExportDialog.svelte, replace the captionStyle construction:

captionStyle: (burnInCaptions && includeCaptions && hasCaptions)
  ? {
      fontFamily: preferences.captionSettings.fontFamily,
      fontSize: preferences.captionSettings.fontSize,
      textColor: preferences.captionSettings.textColor,
      bold: preferences.captionSettings.bold,
      outlineColor: preferences.captionSettings.outlineColor,
      backgroundEnabled: preferences.captionSettings.backgroundEnabled,
      backgroundOpacity: preferences.captionSettings.backgroundOpacity,
      backgroundColor: preferences.captionSettings.backgroundColor,
      textOutline: preferences.captionSettings.textOutline,
      shadowEnabled: preferences.captionSettings.shadowEnabled,
      shadowDepth: preferences.captionSettings.shadowDepth,
      shadowColor: preferences.captionSettings.shadowColor,
      dimmedColorMode: preferences.captionSettings.dimmedColorMode,
      dimmedOpacity: preferences.captionSettings.dimmedOpacity,
      dimmedColor: preferences.captionSettings.dimmedColor,
      position: preferences.captionSettings.position,
      wordHighlight: preferences.captionSettings.wordHighlight,
    }
  : null,
  • Step 5: Build and test

Run: cd src-tauri && cargo build 2>&1 | tail -5 Expected: Clean build (warnings OK if pre-existing).

Run: cd src-tauri && cargo test --lib services::clip_exporter::tests -- --nocapture 2>&1 | tail -5 Expected: All 23 tests pass (they don't construct CaptionStyle directly).

  • Step 6: Commit
git add src/lib/stores/preferences.svelte.ts src-tauri/src/models.rs src/lib/bindings/export.ts src/lib/components/ExportDialog.svelte
git commit -m "feat(captions): extend data model with font, shadow, dimmed color, bg toggle fields"

Task 2: System font detection — add list_system_fonts command

Files:

  • Modify: src-tauri/src/commands/media_analysis.rs (add new command)
  • Modify: src-tauri/src/lib.rs:31-46 (register command)
  • Modify: src/lib/bindings/mediaAnalysis.ts (add TS binding)

Interfaces:

  • Consumes: fc-list CLI tool (from fontconfig)

  • Produces: list_system_fonts() -> Result<Vec<String>, String> (Rust), listSystemFonts(): Promise<string[]> (TS)

  • Step 1: Add the Rust command

Append to src-tauri/src/commands/media_analysis.rs:

#[tauri::command]
pub async fn list_system_fonts() -> Result<Vec<String>, String> {
    tokio::task::spawn_blocking(|| {
        // Try fc-list first (available if fontconfig is installed, which ffmpeg depends on)
        let output = std::process::Command::new("fc-list")
            .args([":lang=en", "family"])
            .output();

        match output {
            Ok(out) if out.status.success() => {
                let text = String::from_utf8_lossy(&out.stdout);
                let mut families: Vec<String> = text
                    .lines()
                    .flat_map(|line| line.split(','))
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();
                families.sort_unstable();
                families.dedup();
                Ok(families)
            }
            _ => {
                // Fallback: common cross-platform fonts
                Ok(vec![
                    "Arial".to_string(),
                    "Comic Sans MS".to_string(),
                    "Courier New".to_string(),
                    "Georgia".to_string(),
                    "Helvetica".to_string(),
                    "Impact".to_string(),
                    "Times New Roman".to_string(),
                    "Verdana".to_string(),
                ])
            }
        }
    })
    .await
    .map_err(|e| format!("Font detection failed: {e}"))?
}
  • Step 2: Register the command in lib.rs

In src-tauri/src/lib.rs, add media_analysis::list_system_fonts to the generate_handler! macro, after media_analysis::check_embedded_subtitles:

media_analysis::check_embedded_subtitles,
media_analysis::list_system_fonts,
  • Step 3: Add the TS binding

Append to src/lib/bindings/mediaAnalysis.ts:

export async function listSystemFonts(): Promise<string[]> {
  return invoke<string[]>('list_system_fonts');
}
  • Step 4: Build and test

Run: cd src-tauri && cargo build 2>&1 | tail -5 Expected: Clean build.

  • Step 5: Commit
git add src-tauri/src/commands/media_analysis.rs src-tauri/src/lib.rs src/lib/bindings/mediaAnalysis.ts
git commit -m "feat(captions): add list_system_fonts command with fc-list + fallback"

Task 3: ASS generation — wire all new fields into burn-in subtitle output

Files:

  • Modify: src-tauri/src/services/clip_exporter.rs:549-584

Interfaces:

  • Consumes: CaptionStyle struct from Task 1 (with all new fields)

  • Produces: Updated vtt_to_ass_with_karaoke() that uses font family, background/shadow mutual exclusivity, and dimmed color settings

  • Step 1: Replace the style extraction block in vtt_to_ass_with_karaoke

In src-tauri/src/services/clip_exporter.rs, replace lines 550-566 (the style extraction block) with:

    // Font
    let font_name = style.map_or("Arial".to_string(), |s| s.font_family.clone());
    let font_size = style.map_or(50, |s| (s.font_size as f64 * 2.75).round() as u32);
    let bold_flag: i32 = if style.map_or(true, |s| s.bold) { -1 } else { 0 };

    // Colors
    let primary_colour = style.map_or("&H00FFFFFF".to_string(), |s| hex_to_ass_color(&s.text_color));
    let outline_colour = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.outline_color));

    // Dimmed color (SecondaryColour) — auto derives from text color, custom uses direct color
    let secondary_colour = style.map_or("&H73CCCCCC".to_string(), |s| {
        if s.dimmed_color_mode == "custom" {
            hex_to_ass_color(&s.dimmed_color)
        } else {
            let alpha = ((1.0 - s.dimmed_opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
            hex_to_ass_color_with_alpha(&s.text_color, alpha)
        }
    });

    // Background/Shadow mutual exclusivity
    let bg_enabled = style.map_or(true, |s| s.background_enabled);
    let shadow_enabled = style.map_or(false, |s| s.shadow_enabled);
    let text_outline = style.map_or(true, |s| s.text_outline);

    let (border_style, outline_val, shadow_val, back_colour) = if bg_enabled {
        // Background mode: box + optional outline, no shadow
        let bc = style.map_or("&H80000000".to_string(), |s| {
            let alpha = ((1.0 - s.background_opacity.clamp(0.0, 1.0)) * 255.0).round() as u8;
            hex_to_ass_color_with_alpha(&s.background_color, alpha)
        });
        let bs = if text_outline { 4 } else { 3 };
        let ol = if text_outline { 2 } else { 0 };
        (bs, ol, 0, bc)
    } else if shadow_enabled {
        // Shadow mode: outline + shadow, no box
        let sc = style.map_or("&H00000000".to_string(), |s| hex_to_ass_color(&s.shadow_color));
        let sd = style.map_or(2, |s| s.shadow_depth) as i32;
        let ol = if text_outline { 2 } else { 0 };
        (1, ol, sd, sc)
    } else {
        // Neither: just outline if enabled
        let ol = if text_outline { 2 } else { 0 };
        (1, ol, 0, "&HFF000000".to_string()) // fully transparent back
    };

    let word_highlight = style.map_or(true, |s| s.word_highlight);
    let margin_v: i32 = 40;
  • Step 2: Update the Style format string to use font_name

Replace the Style line (currently around line 580-584):

    output.push_str(&format!(
        "Style: Default,{},{},{},{},{},{},{},0,0,0,100,100,0,0,{},{},{},2,20,20,{},1\n",
        font_name, font_size, primary_colour, secondary_colour, outline_colour, back_colour,
        bold_flag, border_style, outline_val, shadow_val, margin_v
    ));
  • Step 3: Run tests

Run: cd src-tauri && cargo test --lib services::clip_exporter::tests -- --nocapture 2>&1 | tail -10 Expected: All 23 tests pass.

Run: cd src-tauri && cargo build 2>&1 | tail -5 Expected: Clean build.

  • Step 4: Commit
git add src-tauri/src/services/clip_exporter.rs
git commit -m "feat(captions): wire font, shadow, dimmed color, bg toggle into ASS generation"

Task 4: Preview captions — use new settings in VideoPlayer

Files:

  • Modify: src/lib/components/VideoPlayer.svelte:29-40, 184-189

Interfaces:

  • Consumes: CaptionSettings from Task 1 (via preferences.captionSettings)

  • Produces: Updated preview caption rendering using font, background toggle, shadow, and dimmed color settings

  • Step 1: Update the captionStyle derived

In src/lib/components/VideoPlayer.svelte, replace the captionStyle derived block (lines 29-36):

  let captionStyle = $derived.by(() => {
    const s = preferences.captionSettings;
    const bg = s.backgroundEnabled
      ? `rgba(${hexToRgb(s.backgroundColor)}, ${s.backgroundOpacity})`
      : 'transparent';
    const shadow = s.shadowEnabled
      ? `${s.shadowDepth}px ${s.shadowDepth}px ${s.shadowDepth * 2}px ${s.shadowColor}`
      : s.textOutline
        ? '1px 1px 2px rgba(0,0,0,0.9), -1px -1px 2px rgba(0,0,0,0.9)'
        : 'none';
    return {
      fontFamily: s.fontFamily,
      fontSize: `${s.fontSize}px`,
      fontWeight: s.bold ? 'bold' : 'normal',
      color: s.textColor,
      background: bg,
      textShadow: shadow,
    };
  });
  • Step 2: Add the hexToRgb helper

Add this helper function at the top of the <script> block (after imports):

  function hexToRgb(hex: string): string {
    const h = hex.replace('#', '');
    const r = parseInt(h.substring(0, 2), 16);
    const g = parseInt(h.substring(2, 4), 16);
    const b = parseInt(h.substring(4, 6), 16);
    return `${r}, ${g}, ${b}`;
  }
  • Step 3: Update the dimmed word color in the template

In the caption rendering section (around line 184-189), update the unspoken word styling:

Replace:

style="color: {word.spoken ? captionStyle.color : captionStyle.color}; opacity: {word.spoken ? 1 : 0.4};"

With:

style="color: {word.spoken
  ? captionStyle.color
  : preferences.captionSettings.dimmedColorMode === 'custom'
    ? preferences.captionSettings.dimmedColor
    : captionStyle.color}; opacity: {word.spoken ? 1 : (preferences.captionSettings.dimmedColorMode === 'custom' ? 1 : preferences.captionSettings.dimmedOpacity)};"
  • Step 4: Update template references to use new properties

$derived.by still produces a reactive value, so all template references remain captionStyle.X (unchanged from before). Add font-family and font-weight to the inline styles on .caption-text:

Replace the style attribute on .caption-text:

style="font-family: {captionStyle.fontFamily}; font-size: {captionStyle.fontSize}; font-weight: {captionStyle.fontWeight}; background: {captionStyle.background}; text-shadow: {captionStyle.textShadow};"

And the non-karaoke span:

<span style="color: {captionStyle.color};">{cue.text}</span>
  • Step 5: Commit
git add src/lib/components/VideoPlayer.svelte
git commit -m "feat(captions): use font, shadow, dimmed color, bg toggle in preview captions"

Task 5: Settings panel UI — add all new controls

Files:

  • Modify: src/lib/components/CaptionSettingsPanel.svelte

Interfaces:

  • Consumes: CaptionSettings from Task 1, listSystemFonts() from Task 2

  • Produces: Full settings panel with all controls

  • Step 1: Rewrite the panel component

Replace the entire contents of src/lib/components/CaptionSettingsPanel.svelte with the expanded panel. The component should:

  1. Import listSystemFonts from $lib/bindings/mediaAnalysis
  2. Call listSystemFonts() on mount, store result in let systemFonts = $state<string[]>(['Arial'])
  3. Use onMount to trigger the font loading
  4. Layout the controls in this order:
    • Font: <select> dropdown with system fonts, each <option> styled with font-family: {font}. Font size slider. Bold checkbox.
    • Text Color: Color picker
    • Dimmed Text: Auto/Custom toggle (radio). When auto: opacity slider (0.1-0.9). When custom: color picker.
    • Outline: Checkbox + color picker (picker disabled when unchecked)
    • Background: Checkbox + color picker + opacity slider. When enabled, sets shadowEnabled: false.
    • Shadow: Checkbox + depth slider (1-5) + color picker. When enabled, sets backgroundEnabled: false.
    • Position: Bottom/Top radio
    • Word Highlight: Checkbox
    • Reset to defaults button

The panel width stays at 320px. Max-height with overflow-y scroll for smaller screens.

Full component code:

<script lang="ts">
  import { onMount } from 'svelte';
  import {
    preferences,
    setCaptionSettings,
    DEFAULT_CAPTION_SETTINGS,
    type CaptionSettings,
  } from '$lib/stores/preferences.svelte';
  import { listSystemFonts } from '$lib/bindings/mediaAnalysis';

  let { onClose }: { onClose: () => void } = $props();

  let settings = $derived(preferences.captionSettings);
  let systemFonts = $state<string[]>(['Arial']);

  onMount(async () => {
    try {
      systemFonts = await listSystemFonts();
    } catch {
      systemFonts = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'Courier New', 'Impact'];
    }
  });

  function update(partial: Partial<CaptionSettings>) {
    // Enforce mutual exclusivity
    if (partial.backgroundEnabled) {
      partial.shadowEnabled = false;
    } else if (partial.shadowEnabled) {
      partial.backgroundEnabled = false;
    }
    setCaptionSettings(partial);
  }

  function resetToDefaults() {
    setCaptionSettings({ ...DEFAULT_CAPTION_SETTINGS });
  }
</script>

<div class="panel">
  <div class="panel-header">
    <h3>Caption Appearance</h3>
    <button class="close-btn" onclick={onClose} title="Close">x</button>
  </div>

  <div class="scroll-area">
    <!-- Font Family -->
    <div class="control">
      <label>Font</label>
      <select
        value={settings.fontFamily}
        onchange={(e) => update({ fontFamily: (e.target as HTMLSelectElement).value })}
      >
        {#each systemFonts as font}
          <option value={font} style="font-family: {font}">{font}</option>
        {/each}
      </select>
    </div>

    <!-- Font Size -->
    <div class="control">
      <label>
        Font Size
        <span class="value">{settings.fontSize}px</span>
      </label>
      <input
        type="range" min="12" max="36" step="1"
        value={settings.fontSize}
        oninput={(e) => update({ fontSize: parseInt((e.target as HTMLInputElement).value, 10) })}
      />
    </div>

    <!-- Text Color + Bold -->
    <div class="control row">
      <label>Text Color</label>
      <div class="row-controls">
        <input
          type="color" value={settings.textColor}
          oninput={(e) => update({ textColor: (e.target as HTMLInputElement).value })}
        />
        <label class="inline-check">
          <input type="checkbox" checked={settings.bold}
            onchange={(e) => update({ bold: (e.target as HTMLInputElement).checked })} />
          Bold
        </label>
      </div>
    </div>

    <!-- Dimmed Text Color -->
    <div class="control">
      <label>Dimmed Text</label>
      <div class="radio-row">
        <label>
          <input type="radio" name="dimmed-mode" value="auto"
            checked={settings.dimmedColorMode === 'auto'}
            onchange={() => update({ dimmedColorMode: 'auto' })} />
          Auto
        </label>
        <label>
          <input type="radio" name="dimmed-mode" value="custom"
            checked={settings.dimmedColorMode === 'custom'}
            onchange={() => update({ dimmedColorMode: 'custom' })} />
          Custom
        </label>
      </div>
    </div>
    {#if settings.dimmedColorMode === 'auto'}
      <div class="control">
        <label>
          Dim Opacity
          <span class="value">{Math.round(settings.dimmedOpacity * 100)}%</span>
        </label>
        <input
          type="range" min="10" max="90" step="5"
          value={Math.round(settings.dimmedOpacity * 100)}
          oninput={(e) => update({ dimmedOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
        />
      </div>
    {:else}
      <div class="control row">
        <label>Dimmed Color</label>
        <div class="row-controls">
          <input
            type="color" value={settings.dimmedColor}
            oninput={(e) => update({ dimmedColor: (e.target as HTMLInputElement).value })}
          />
        </div>
      </div>
    {/if}

    <!-- Outline -->
    <div class="control row">
      <label>
        <input type="checkbox" checked={settings.textOutline}
          onchange={(e) => update({ textOutline: (e.target as HTMLInputElement).checked })} />
        Outline
      </label>
      <div class="row-controls">
        <input
          type="color" value={settings.outlineColor}
          disabled={!settings.textOutline}
          class:disabled={!settings.textOutline}
          oninput={(e) => update({ outlineColor: (e.target as HTMLInputElement).value })}
        />
      </div>
    </div>

    <!-- Background (mutually exclusive with Shadow) -->
    <div class="control row">
      <label>
        <input type="checkbox" checked={settings.backgroundEnabled}
          onchange={(e) => update({ backgroundEnabled: (e.target as HTMLInputElement).checked })} />
        Background
      </label>
      <div class="row-controls">
        <input
          type="color" value={settings.backgroundColor}
          disabled={!settings.backgroundEnabled}
          class:disabled={!settings.backgroundEnabled}
          oninput={(e) => update({ backgroundColor: (e.target as HTMLInputElement).value })}
        />
      </div>
    </div>
    {#if settings.backgroundEnabled}
      <div class="control">
        <label>
          BG Opacity
          <span class="value">{Math.round(settings.backgroundOpacity * 100)}%</span>
        </label>
        <input
          type="range" min="0" max="100" step="5"
          value={Math.round(settings.backgroundOpacity * 100)}
          oninput={(e) => update({ backgroundOpacity: parseInt((e.target as HTMLInputElement).value, 10) / 100 })}
        />
      </div>
    {/if}

    <!-- Shadow (mutually exclusive with Background) -->
    <div class="control row">
      <label>
        <input type="checkbox" checked={settings.shadowEnabled}
          onchange={(e) => update({ shadowEnabled: (e.target as HTMLInputElement).checked })} />
        Drop Shadow
      </label>
      <div class="row-controls">
        <input
          type="color" value={settings.shadowColor}
          disabled={!settings.shadowEnabled}
          class:disabled={!settings.shadowEnabled}
          oninput={(e) => update({ shadowColor: (e.target as HTMLInputElement).value })}
        />
      </div>
    </div>
    {#if settings.shadowEnabled}
      <div class="control">
        <label>
          Shadow Depth
          <span class="value">{settings.shadowDepth}px</span>
        </label>
        <input
          type="range" min="1" max="5" step="1"
          value={settings.shadowDepth}
          oninput={(e) => update({ shadowDepth: parseInt((e.target as HTMLInputElement).value, 10) })}
        />
      </div>
    {/if}

    <!-- Position -->
    <div class="control">
      <label>Position</label>
      <div class="radio-row">
        <label>
          <input type="radio" name="caption-position" value="bottom"
            checked={settings.position === 'bottom'}
            onchange={() => update({ position: 'bottom' })} />
          Bottom
        </label>
        <label>
          <input type="radio" name="caption-position" value="top"
            checked={settings.position === 'top'}
            onchange={() => update({ position: 'top' })} />
          Top
        </label>
      </div>
    </div>

    <!-- Word Highlight -->
    <div class="control">
      <label>
        <input type="checkbox" checked={settings.wordHighlight}
          onchange={(e) => update({ wordHighlight: (e.target as HTMLInputElement).checked })} />
        Word-by-word highlight
      </label>
    </div>
  </div>

  <div class="panel-footer">
    <button class="reset-btn" onclick={resetToDefaults}>Reset to defaults</button>
  </div>
</div>

<style>
  .panel {
    position: absolute;
    bottom: 44px;
    right: 12px;
    background: var(--bg-primary, #1e1e2e);
    border: 1px solid var(--border, #353550);
    border-radius: 8px;
    padding: 12px 16px;
    width: 320px;
    max-height: 480px;
    display: flex;
    flex-direction: column;
    z-index: 20;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  }

  .panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 8px;
    flex-shrink: 0;
  }

  h3 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text-primary, #cdd6f4); }

  .close-btn {
    background: none; border: none; color: var(--text-secondary, #6c7086);
    cursor: pointer; font-size: 14px; padding: 2px 6px; border-radius: 3px;
  }
  .close-btn:hover { background: var(--bg-tertiary, #2a2a3e); color: var(--text-primary, #cdd6f4); }

  .scroll-area {
    overflow-y: auto;
    flex: 1;
    min-height: 0;
    padding-right: 4px;
  }

  .control { margin-bottom: 10px; }
  .control label {
    display: flex; align-items: center; gap: 6px;
    font-size: 12px; color: var(--text-secondary, #6c7086); margin-bottom: 4px;
  }
  .control.row { display: flex; align-items: center; justify-content: space-between; }
  .control.row > label { margin-bottom: 0; }

  .row-controls { display: flex; align-items: center; gap: 8px; }
  .inline-check {
    display: flex; align-items: center; gap: 4px;
    font-size: 12px; color: var(--text-secondary, #6c7086);
    margin-bottom: 0 !important; cursor: pointer;
  }

  .value {
    margin-left: auto; font-size: 11px;
    color: var(--text-primary, #cdd6f4); font-variant-numeric: tabular-nums;
  }

  select {
    width: 100%; padding: 4px 6px; font-size: 12px;
    background: var(--bg-secondary, #252535); color: var(--text-primary, #cdd6f4);
    border: 1px solid var(--border, #353550); border-radius: 4px;
  }

  input[type='range'] { width: 100%; accent-color: #89b4fa; }
  input[type='color'] {
    width: 32px; height: 24px; padding: 0;
    border: 1px solid var(--border, #353550); border-radius: 3px;
    cursor: pointer; background: none;
  }
  input[type='color'].disabled { opacity: 0.3; cursor: not-allowed; }
  input[type='checkbox'] { accent-color: #89b4fa; }
  input[type='radio'] { accent-color: #89b4fa; }

  .radio-row { display: flex; gap: 16px; }
  .radio-row label { font-size: 12px; color: var(--text-secondary, #6c7086); cursor: pointer; }

  .panel-footer {
    margin-top: 8px; padding-top: 8px; flex-shrink: 0;
    border-top: 1px solid var(--border, #353550);
  }
  .reset-btn {
    font-size: 11px; color: var(--text-secondary, #6c7086);
    background: none; border: none; cursor: pointer; padding: 2px 0;
  }
  .reset-btn:hover { color: var(--text-primary, #cdd6f4); }
</style>
  • Step 2: Build check

Run: npm run check 2>&1 | grep "Error:" | head -10 Expected: Only pre-existing errors (node:path, node:process, node:url, overload). No new errors.

  • Step 3: Commit
git add src/lib/components/CaptionSettingsPanel.svelte
git commit -m "feat(captions): expand settings panel with font, shadow, dimmed color, bg toggle controls"

Task 6: Build, test, and verify

Files:

  • No new changes (verification only)

Interfaces:

  • Consumes: All changes from Tasks 1-5

  • Step 1: Full Rust build and test

Run: cd src-tauri && cargo build 2>&1 | tail -5 Expected: Clean build.

Run: cd src-tauri && cargo test --lib services::clip_exporter::tests -- --nocapture 2>&1 | tail -10 Expected: All tests pass.

  • Step 2: Frontend type check

Run: npm run check 2>&1 | grep "Error:" | head -10 Expected: Only pre-existing errors. No new errors from caption changes.

  • Step 3: Manual smoke test

Run: cargo tauri dev

Verify:

  1. Caption settings panel opens, shows font dropdown (populated with system fonts)
  2. Selecting a different font updates the preview captions
  3. Enabling shadow disables background, and vice versa
  4. Dimmed color auto mode shows opacity slider; custom mode shows color picker
  5. Export a short clip with burn-in and verify the ASS uses the selected font and style settings