From 98e8738a30a675c8e6402a0d62dcbbf283e93a36 Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 27 Jun 2026 10:20:21 -0700 Subject: [PATCH] docs(plan): desktop visual refresh implementation plan --- .../2026-06-27-desktop-visual-refresh.md | 1518 +++++++++++++++++ 1 file changed, 1518 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-desktop-visual-refresh.md diff --git a/docs/superpowers/plans/2026-06-27-desktop-visual-refresh.md b/docs/superpowers/plans/2026-06-27-desktop-visual-refresh.md new file mode 100644 index 0000000..2224234 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-desktop-visual-refresh.md @@ -0,0 +1,1518 @@ +# Desktop Visual Refresh + Theme Pack — 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 12 new themes, theme-aware accent colors, a List/Card/Grid view-mode toggle, polished default Dark/Light, refreshed typography, and unified placeholder thumbnails to the catacomb desktop UI. + +**Architecture:** All theme logic stays in `theme.rs`; `app.rs` holds a `ThemeAccents` snapshot recomputed whenever the theme changes, plus new `ViewMode`/override state driving three render branches in the video list. A new persisted `[ui] default_view_mode` config field follows the existing `theme`/`ui_scale` shape. Hardcoded accent rings are replaced with reads from `self.theme_accents`. + +**Tech Stack:** Rust, egui (eframe), serde/toml. Tests via `cargo test --release`. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-06-27-desktop-visual-refresh-design.md` + +**Conventions to follow (from AGENTS.md):** +- `cargo build --release` warnings are the lint. +- The egui dep emits ~39 upstream `f32: From` warnings on clean build — those are NOT yours. +- Settings five-touchpoint shape: config.rs field + Default → app.rs Settings UI → App field seeded at construction + on save. +- Never commit `config.toml`, `cookies.txt`, `catacomb.db`. + +--- + +## File map + +- `src/theme.rs` — add `ThemeAccents` struct + `accents_for()` helper; add 12 theme fns; promote `dark()`/`light()` from stock to hand-tuned; extend `THEMES` catalog to 19. +- `src/config.rs` — add `default_view_mode` field to `UiSection` + Default + serde default fn. +- `src/app.rs` — `ViewMode` enum + `App` fields (`theme_accents`, `view_mode`, `view_mode_overrides`); seed accents at construction; recompute on theme change; branch video list into List/Card/Grid; replace hardcoded accent colors; unify placeholder paint; toolbar 3-segment toggle; typography/spacing bumps; Settings UI for default view mode. +- `tests/api.rs` — no changes (desktop-only, not HTTP). + +--- + +## Task 1: ThemeAccents struct + accents_for() lookup + +Establishes the sidecar data model before any theme fns use it. + +**Files:** +- Modify: `src/theme.rs:1-24` (top of file, after `use`) + +- [ ] **Step 1: Write the failing test** + +Append to `src/theme.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accents_for_known_theme_returns_nonzero() { + let a = accents_for("dark"); + assert_ne!(a.accent, Color32::TRANSPARENT); + assert_ne!(a.success, Color32::TRANSPARENT); + assert_ne!(a.warning, Color32::TRANSPARENT); + } + + #[test] + fn accents_for_unknown_theme_falls_back() { + // Unknown theme must still return something usable (falls back to dark). + let a = accents_for("this-theme-does-not-exist"); + let dark = accents_for("dark"); + assert_eq!(a.accent, dark.accent); + } + + #[test] + fn accents_differ_across_themes() { + // At least two themes should have visibly different accents, proving + // accents are per-theme, not a global constant. + let dark = accents_for("dark"); + let light = accents_for("light"); + assert_ne!(dark.accent, light.accent); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --release accents` +Expected: FAIL — `cannot find function accents_for` / `cannot find type ThemeAccents`. + +- [ ] **Step 3: Write minimal implementation** + +Insert above `pub fn apply` in `src/theme.rs` (after the `use` line at line 1): + +```rust +/// Theme-aware semantic accent colors. egui `Visuals` has no slot for these, +/// so each theme exposes them here and the paint code in `app.rs` reads from +/// the active snapshot instead of hardcoding RGB values. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct ThemeAccents { + /// Selection / focus ring (replaces the hardcoded 120,170,230 blue). + pub accent: Color32, + /// Playing / watched indicator (replaces 110,200,110 green). + pub success: Color32, + /// Bulk-selection highlight (replaces 180,130,240 purple). + pub warning: Color32, +} + +impl ThemeAccents { + /// The stock fallback used before theme-specific accents are wired in. + /// Matches the legacy hardcoded values so behavior is unchanged when a + /// theme has not yet defined its own. + pub const LEGACY: ThemeAccents = ThemeAccents { + accent: Color32::from_rgb(120, 170, 230), + success: Color32::from_rgb(110, 200, 110), + warning: Color32::from_rgb(180, 130, 240), + }; +} + +/// Look up the semantic accents for a theme name. Falls back to the legacy +/// defaults (and dark's accents once dark is hand-tuned) for unknown names. +pub fn accents_for(name: &str) -> ThemeAccents { + match name { + "dark" => ThemeAccents { accent: hex(0x7aa2f7), success: hex(0x9ece6a), warning: hex(0xbb9af7) }, + "light" => ThemeAccents { accent: hex(0x2a5db0), success: hex(0x2e7d32), warning: hex(0x8e44ad) }, + "dracula" => ThemeAccents { accent: hex(0xbd93f9), success: hex(0x50fa7b), warning: hex(0xff79c6) }, + "trans" => ThemeAccents { accent: hex(0x55cdfc), success: hex(0x2e7d32), warning: hex(0xf7a8b8) }, + "emo-nocturnal" => ThemeAccents { accent: hex(0xff0090), success: hex(0x39ff14), warning: hex(0x00f5ff) }, + "emo-coffin" => ThemeAccents { accent: hex(0x8b0000), success: hex(0x39ff14), warning: hex(0xcc2222) }, + "emo-scene-queen" => ThemeAccents { accent: hex(0x39ff14), success: hex(0xff00ff), warning: hex(0x00f5ff) }, + // New themes (values match the palettes added in Task 3). + "cyberpunk" => ThemeAccents { accent: hex(0x00fff5), success: hex(0x39ff14), warning: hex(0xff003c) }, + "synthwave" => ThemeAccents { accent: hex(0xff2a6d), success: hex(0x05d9e8), warning: hex(0xd136a6) }, + "vaporwave" => ThemeAccents { accent: hex(0x01cdfe), success: hex(0x05ffa1), warning: hex(0xff71ce) }, + "cemetery-moss" => ThemeAccents { accent: hex(0x7a8a6a), success: hex(0x4a5d3a), warning: hex(0x9a9a8a) }, + "vampire" => ThemeAccents { accent: hex(0xc9a227), success: hex(0x8b1a2b), warning: hex(0x5c0a1e) }, + "witching-hour" => ThemeAccents { accent: hex(0x6a4a8b), success: hex(0xb0b8d0), warning: hex(0x1a1a4a) }, + "nord" => ThemeAccents { accent: hex(0x88c0d0), success: hex(0xa3be8c), warning: hex(0xebcb8b) }, + "gruvbox" => ThemeAccents { accent: hex(0xfe8019), success: hex(0xb8bb26), warning: hex(0xd3869b) }, + "tokyo-night" => ThemeAccents { accent: hex(0x7aa2f7), success: hex(0x9ece6a), warning: hex(0xbb9af7) }, + "paper" => ThemeAccents { accent: hex(0x8b6f3a), success: hex(0x4a6b3a), warning: hex(0xc4a86a) }, + "honey" => ThemeAccents { accent: hex(0xe8a838), success: hex(0xb87420), warning: hex(0xc97b4a) }, + "candlelight" => ThemeAccents { accent: hex(0xa8703a), success: hex(0x6b3f1e), warning: hex(0xd9b382) }, + _ => ThemeAccents::LEGACY, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --release accents` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/theme.rs +git commit -m "feat(theme): add ThemeAccents struct + accents_for() lookup" +``` + +--- + +## Task 2: Promote default Dark/Light to hand-tuned + +The out-of-box themes are currently stock egui. Lift them. + +**Files:** +- Modify: `src/theme.rs:13-24` (the `apply` match arms for dark/light) + +- [ ] **Step 1: Replace the dark/light match arms with hand-tuned fns** + +In `src/theme.rs`, change the `apply` function so its match calls new private fns: + +```rust +pub fn apply(ctx: &egui::Context, name: &str) { + let visuals = match name { + "light" => light(), + "dracula" => dracula(), + "trans" => trans(), + "emo-nocturnal" => emo_nocturnal(), + "emo-coffin" => emo_coffin(), + "emo-scene-queen" => emo_scene_queen(), + _ => dark(), + }; + ctx.set_visuals(visuals); +} +``` + +Then add two new fns (near the other theme fns, before `dracula()`): + +```rust +// Hand-tuned default dark — true near-black panel, cool steel-blue accent. +// Replaces the stock egui::Visuals::dark() so the out-of-box experience +// matches the care given to the themed variants. +fn dark() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x14141a); + v.window_fill = hex(0x1a1a22); + v.extreme_bg_color = hex(0x0a0a0e); + v.faint_bg_color = hex(0x1f1f29); + v.code_bg_color = hex(0x101016); + v.selection.bg_fill = hex(0x2a3a5a); + v.selection.stroke = Stroke::new(1.0, hex(0x7aa2f7)); + v.hyperlink_color = hex(0x7aa2f7); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x1f1f29); + v.widgets.noninteractive.weak_bg_fill = hex(0x181820); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8d8)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a36)); + v.widgets.inactive.bg_fill = hex(0x2a2a36); + v.widgets.inactive.weak_bg_fill = hex(0x222230); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d0e0)); + v.widgets.hovered.bg_fill = hex(0x3a3a4e); + v.widgets.hovered.weak_bg_fill = hex(0x303044); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xe8e8f8)); + v.widgets.active.bg_fill = hex(0x7aa2f7); + v.widgets.active.weak_bg_fill = hex(0x5a82d7); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x14141a)); + v.widgets.open.bg_fill = hex(0x3a3a4e); + v.window_stroke = Stroke::new(1.0, hex(0x2a2a36)); + v +} + +// Hand-tuned default light — warm off-white, slate accent. +fn light() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xf6f4ef); + v.window_fill = hex(0xfefcf7); + v.extreme_bg_color = hex(0xffffff); + v.faint_bg_color = hex(0xefece4); + v.code_bg_color = hex(0xeae6dc); + v.selection.bg_fill = hex(0xbcd0ee); + v.selection.stroke = Stroke::new(1.0, hex(0x2a5db0)); + v.hyperlink_color = hex(0x2a5db0); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xeae6dc); + v.widgets.noninteractive.weak_bg_fill = hex(0xf0ede5); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x3a3a3a)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xd0ccbf)); + v.widgets.inactive.bg_fill = hex(0xdfe0e8); + v.widgets.inactive.weak_bg_fill = hex(0xe8e9f0); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x2a2a2a)); + v.widgets.hovered.bg_fill = hex(0xcdd6e8); + v.widgets.hovered.weak_bg_fill = hex(0xd8e0ee); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x141414)); + v.widgets.active.bg_fill = hex(0x2a5db0); + v.widgets.active.weak_bg_fill = hex(0x4a7dd0); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff)); + v.widgets.open.bg_fill = hex(0xcdd6e8); + v.window_stroke = Stroke::new(1.0, hex(0xd0ccbf)); + v +} +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `cargo build --release` +Expected: builds clean (ignoring the ~39 upstream `f32: From` warnings). + +- [ ] **Step 3: Smoke-test visually** + +Run: `./target/release/catacomb` +Open Settings, switch Dark ↔ Light. Confirm panels/panels/widgets look distinct from the previous stock egui look (near-black panel for dark, warm off-white for light). + +- [ ] **Step 4: Commit** + +```bash +git add src/theme.rs +git commit -m "feat(theme): hand-tune default Dark/Light themes" +``` + +--- + +## Task 3: Add 12 new theme functions + catalog + +**Files:** +- Modify: `src/theme.rs:3-11` (THEMES catalog) and append 12 fns at end of file + +- [ ] **Step 1: Extend the THEMES catalog** + +Replace the `THEMES` const at `src/theme.rs:3-11`: + +```rust +pub const THEMES: &[(&str, &str)] = &[ + ("dark", "Dark"), + ("light", "Light"), + ("dracula", "Dracula"), + ("trans", "Trans"), + // Catacomb / goth + ("emo-nocturnal", "Emo: Nocturnal"), + ("emo-coffin", "Emo: Coffin"), + ("emo-scene-queen", "Emo: Scene Queen"), + ("cemetery-moss", "Cemetery Moss"), + ("vampire", "Vampire"), + ("witching-hour", "Witching Hour"), + // Neon / retro + ("cyberpunk", "Cyberpunk"), + ("synthwave", "Synthwave '84"), + ("vaporwave", "Vaporwave"), + // Dev palettes + ("nord", "Nord"), + ("gruvbox", "Gruvbox"), + ("tokyo-night", "Tokyo Night"), + // Cozy / light + ("paper", "Paper"), + ("honey", "Honey"), + ("candlelight", "Candlelight"), +]; +``` + +- [ ] **Step 2: Wire the new theme names into `apply`** + +Update the `apply` match in `src/theme.rs` to dispatch the new names: + +```rust +pub fn apply(ctx: &egui::Context, name: &str) { + let visuals = match name { + "light" => light(), + "dracula" => dracula(), + "trans" => trans(), + "emo-nocturnal" => emo_nocturnal(), + "emo-coffin" => emo_coffin(), + "emo-scene-queen" => emo_scene_queen(), + "cemetery-moss" => cemetery_moss(), + "vampire" => vampire(), + "witching-hour" => witching_hour(), + "cyberpunk" => cyberpunk(), + "synthwave" => synthwave(), + "vaporwave" => vaporwave(), + "nord" => nord(), + "gruvbox" => gruvbox(), + "tokyo-night" => tokyo_night(), + "paper" => paper(), + "honey" => honey(), + "candlelight" => candlelight(), + _ => dark(), + }; + ctx.set_visuals(visuals); +} +``` + +- [ ] **Step 3: Append the 12 theme functions** + +Append to the end of `src/theme.rs`: + +```rust +// === Neon / retro === + +// Magenta + electric cyan on black. Hacker HUD. +fn cyberpunk() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x0a0a12); + v.window_fill = hex(0x0e0e18); + v.extreme_bg_color = hex(0x050508); + v.faint_bg_color = hex(0x14141f); + v.code_bg_color = hex(0x08080f); + v.selection.bg_fill = hex(0xff003c); + v.selection.stroke = Stroke::new(1.0, hex(0x00fff5)); + v.hyperlink_color = hex(0x00fff5); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x14141f); + v.widgets.noninteractive.weak_bg_fill = hex(0x0f0f18); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8e0)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a3a)); + v.widgets.inactive.bg_fill = hex(0x1f0018); + v.widgets.inactive.weak_bg_fill = hex(0x180013); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d0e8)); + v.widgets.hovered.bg_fill = hex(0x6a0044); + v.widgets.hovered.weak_bg_fill = hex(0x500034); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x00fff5)); + v.widgets.active.bg_fill = hex(0xff003c); + v.widgets.active.weak_bg_fill = hex(0xcc0030); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff)); + v.widgets.open.bg_fill = hex(0x6a0044); + v.window_stroke = Stroke::new(1.0, hex(0xff003c)); + v.warn_fg_color = hex(0xfcee0a); + v.error_fg_color = hex(0xff003c); + v +} + +// Sunset gradient on deep indigo. +fn synthwave() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x2b0a3d); + v.window_fill = hex(0x330a48); + v.extreme_bg_color = hex(0x1a0529); + v.faint_bg_color = hex(0x3a1055); + v.code_bg_color = hex(0x220833); + v.selection.bg_fill = hex(0xff2a6d); + v.selection.stroke = Stroke::new(1.0, hex(0x05d9e8)); + v.hyperlink_color = hex(0x05d9e8); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x3a1055); + v.widgets.noninteractive.weak_bg_fill = hex(0x2e0c45); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe8c0e0)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x5a1a7a)); + v.widgets.inactive.bg_fill = hex(0x4a1466); + v.widgets.inactive.weak_bg_fill = hex(0x3c1055); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xf0d0e8)); + v.widgets.hovered.bg_fill = hex(0x7a1a4a); + v.widgets.hovered.weak_bg_fill = hex(0x60143a); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xff9a3c)); + v.widgets.active.bg_fill = hex(0xff2a6d); + v.widgets.active.weak_bg_fill = hex(0xcc2055); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xffffff)); + v.widgets.open.bg_fill = hex(0x7a1a4a); + v.window_stroke = Stroke::new(1.0, hex(0xff2a6d)); + v.warn_fg_color = hex(0xff9a3c); + v.error_fg_color = hex(0xff2a6d); + v +} + +// Pastel pink + cyan + lavender on deep plum. +fn vaporwave() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x1a0033); + v.window_fill = hex(0x200040); + v.extreme_bg_color = hex(0x10001f); + v.faint_bg_color = hex(0x28004e); + v.code_bg_color = hex(0x150028); + v.selection.bg_fill = hex(0xb967ff); + v.selection.stroke = Stroke::new(1.0, hex(0x01cdfe)); + v.hyperlink_color = hex(0x01cdfe); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x28004e); + v.widgets.noninteractive.weak_bg_fill = hex(0x1f003c); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe0c0ff)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x4a0070)); + v.widgets.inactive.bg_fill = hex(0x350060); + v.widgets.inactive.weak_bg_fill = hex(0x2a004f); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xf0d8ff)); + v.widgets.hovered.bg_fill = hex(0x55008a); + v.widgets.hovered.weak_bg_fill = hex(0x440070); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x01cdfe)); + v.widgets.active.bg_fill = hex(0xff71ce); + v.widgets.active.weak_bg_fill = hex(0xcc5aa6); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x1a0033)); + v.widgets.open.bg_fill = hex(0x55008a); + v.window_stroke = Stroke::new(1.0, hex(0xff71ce)); + v.warn_fg_color = hex(0x05ffa1); + v.error_fg_color = hex(0xff71ce); + v +} + +// === Catacomb / goth === + +// Weathered stone + mossy green + bone. +fn cemetery_moss() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x1a1f1a); + v.window_fill = hex(0x1e241e); + v.extreme_bg_color = hex(0x0f140f); + v.faint_bg_color = hex(0x242a24); + v.code_bg_color = hex(0x141814); + v.selection.bg_fill = hex(0x4a5d3a); + v.selection.stroke = Stroke::new(1.0, hex(0x7a8a6a)); + v.hyperlink_color = hex(0x9aa86a); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x242a24); + v.widgets.noninteractive.weak_bg_fill = hex(0x1f241f); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xb8b8a8)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x363a30)); + v.widgets.inactive.bg_fill = hex(0x2a302a); + v.widgets.inactive.weak_bg_fill = hex(0x232823); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xc8c8b8)); + v.widgets.hovered.bg_fill = hex(0x3a4530); + v.widgets.hovered.weak_bg_fill = hex(0x2e3826); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xaac08a)); + v.widgets.active.bg_fill = hex(0x4a5d3a); + v.widgets.active.weak_bg_fill = hex(0x3a4a2e); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xd4d0c0)); + v.widgets.open.bg_fill = hex(0x3a4530); + v.window_stroke = Stroke::new(1.0, hex(0x4a5d3a)); + v.warn_fg_color = hex(0xc9a227); + v.error_fg_color = hex(0x8b1a2b); + v +} + +// Deep wine burgundy + antique gold + black. +fn vampire() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x0d0006); + v.window_fill = hex(0x120008); + v.extreme_bg_color = hex(0x070003); + v.faint_bg_color = hex(0x18000c); + v.code_bg_color = hex(0x0a0005); + v.selection.bg_fill = hex(0x5c0a1e); + v.selection.stroke = Stroke::new(1.0, hex(0xc9a227)); + v.hyperlink_color = hex(0xc9a227); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x18000c); + v.widgets.noninteractive.weak_bg_fill = hex(0x130009); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xd8c8a8)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x3a0014)); + v.widgets.inactive.bg_fill = hex(0x200010); + v.widgets.inactive.weak_bg_fill = hex(0x19000c); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xe8d8b8)); + v.widgets.hovered.bg_fill = hex(0x400018); + v.widgets.hovered.weak_bg_fill = hex(0x300012); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xc9a227)); + v.widgets.active.bg_fill = hex(0x8b1a2b); + v.widgets.active.weak_bg_fill = hex(0x700020); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xe8d8b8)); + v.widgets.open.bg_fill = hex(0x400018); + v.window_stroke = Stroke::new(1.0, hex(0x8b1a2b)); + v.warn_fg_color = hex(0xc9a227); + v.error_fg_color = hex(0x8b1a2b); + v +} + +// Midnight indigo + moonlight silver + arcane violet. +fn witching_hour() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x0a0a1f); + v.window_fill = hex(0x0e0e28); + v.extreme_bg_color = hex(0x05050f); + v.faint_bg_color = hex(0x14142e); + v.code_bg_color = hex(0x08081a); + v.selection.bg_fill = hex(0x1a1a4a); + v.selection.stroke = Stroke::new(1.0, hex(0xb0b8d0)); + v.hyperlink_color = hex(0xb0b8d0); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x14142e); + v.widgets.noninteractive.weak_bg_fill = hex(0x101024); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc0c8e0)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x2a2a55)); + v.widgets.inactive.bg_fill = hex(0x1c1c3c); + v.widgets.inactive.weak_bg_fill = hex(0x161630); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xd0d8f0)); + v.widgets.hovered.bg_fill = hex(0x2a2a5a); + v.widgets.hovered.weak_bg_fill = hex(0x20204a); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x6a4a8b)); + v.widgets.active.bg_fill = hex(0x6a4a8b); + v.widgets.active.weak_bg_fill = hex(0x523a72); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xe8e8f8)); + v.widgets.open.bg_fill = hex(0x2a2a5a); + v.window_stroke = Stroke::new(1.0, hex(0x6a4a8b)); + v.warn_fg_color = hex(0xc9a227); + v.error_fg_color = hex(0x8b1a2b); + v +} + +// === Dev palettes === + +// Arctic blues & greys. +fn nord() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x2e3440); + v.window_fill = hex(0x3b4252); + v.extreme_bg_color = hex(0x1e222a); + v.faint_bg_color = hex(0x3b4252); + v.code_bg_color = hex(0x242933); + v.selection.bg_fill = hex(0x434c5e); + v.selection.stroke = Stroke::new(1.0, hex(0x88c0d0)); + v.hyperlink_color = hex(0x88c0d0); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x3b4252); + v.widgets.noninteractive.weak_bg_fill = hex(0x343b48); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xd8dee9)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x434c5e)); + v.widgets.inactive.bg_fill = hex(0x434c5e); + v.widgets.inactive.weak_bg_fill = hex(0x3c4454); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xe5e9f0)); + v.widgets.hovered.bg_fill = hex(0x4c566a); + v.widgets.hovered.weak_bg_fill = hex(0x424c60); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x88c0d0)); + v.widgets.active.bg_fill = hex(0x88c0d0); + v.widgets.active.weak_bg_fill = hex(0x6fa6ba); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x2e3440)); + v.widgets.open.bg_fill = hex(0x4c566a); + v.window_stroke = Stroke::new(1.0, hex(0x4c566a)); + v.warn_fg_color = hex(0xebcb8b); + v.error_fg_color = hex(0xbf616a); + v +} + +// Warm earthy retro groove. +fn gruvbox() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x282828); + v.window_fill = hex(0x32302f); + v.extreme_bg_color = hex(0x1d2021); + v.faint_bg_color = hex(0x32302f); + v.code_bg_color = hex(0x1d2021); + v.selection.bg_fill = hex(0x504945); + v.selection.stroke = Stroke::new(1.0, hex(0xfe8019)); + v.hyperlink_color = hex(0x83a598); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x32302f); + v.widgets.noninteractive.weak_bg_fill = hex(0x2c2a29); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xd5c4a1)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x504945)); + v.widgets.inactive.bg_fill = hex(0x504945); + v.widgets.inactive.weak_bg_fill = hex(0x44403c); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xebdbb2)); + v.widgets.hovered.bg_fill = hex(0x665c54); + v.widgets.hovered.weak_bg_fill = hex(0x585048); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0xfe8019)); + v.widgets.active.bg_fill = hex(0xd65d0e); + v.widgets.active.weak_bg_fill = hex(0xaf4f0b); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xebdbb2)); + v.widgets.open.bg_fill = hex(0x665c54); + v.window_stroke = Stroke::new(1.0, hex(0x665c54)); + v.warn_fg_color = hex(0xfe9d44); + v.error_fg_color = hex(0xfb4934); + v +} + +// Tokyo city lights. Blue/purple, clean. +fn tokyo_night() -> egui::Visuals { + let mut v = egui::Visuals::dark(); + v.panel_fill = hex(0x1a1b26); + v.window_fill = hex(0x20202f); + v.extreme_bg_color = hex(0x12121c); + v.faint_bg_color = hex(0x24253a); + v.code_bg_color = hex(0x16161e); + v.selection.bg_fill = hex(0x33415c); + v.selection.stroke = Stroke::new(1.0, hex(0x7aa2f7)); + v.hyperlink_color = hex(0x7aa2f7); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0x24253a); + v.widgets.noninteractive.weak_bg_fill = hex(0x1f2030); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xa9b1d6)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0x3b4261)); + v.widgets.inactive.bg_fill = hex(0x2a2b3e); + v.widgets.inactive.weak_bg_fill = hex(0x232436); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0xc0caf5)); + v.widgets.hovered.bg_fill = hex(0x363b54); + v.widgets.hovered.weak_bg_fill = hex(0x2d324a); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x7aa2f7)); + v.widgets.active.bg_fill = hex(0x7aa2f7); + v.widgets.active.weak_bg_fill = hex(0x5c82d4); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0x1a1b26)); + v.widgets.open.bg_fill = hex(0x363b54); + v.window_stroke = Stroke::new(1.0, hex(0x3b4261)); + v.warn_fg_color = hex(0xe0af68); + v.error_fg_color = hex(0xf7768e); + v +} + +// === Cozy / light === + +// Aged paper + sepia ink. +fn paper() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xf4ecd8); + v.window_fill = hex(0xf8f0dc); + v.extreme_bg_color = hex(0xfef8e8); + v.faint_bg_color = hex(0xefe5cc); + v.code_bg_color = hex(0xe8dcc0); + v.selection.bg_fill = hex(0xd9c896); + v.selection.stroke = Stroke::new(1.0, hex(0x8b6f3a)); + v.hyperlink_color = hex(0x6b4f2a); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xefe5cc); + v.widgets.noninteractive.weak_bg_fill = hex(0xf2e9d2); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x3d2b1f)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xc4a86a)); + v.widgets.inactive.bg_fill = hex(0xe2d4b0); + v.widgets.inactive.weak_bg_fill = hex(0xeadab4); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x2d1f15)); + v.widgets.hovered.bg_fill = hex(0xd6c498); + v.widgets.hovered.weak_bg_fill = hex(0xddcaa8); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x3d2b1f)); + v.widgets.active.bg_fill = hex(0x8b6f3a); + v.widgets.active.weak_bg_fill = hex(0xa18450); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xf4ecd8)); + v.widgets.open.bg_fill = hex(0xd6c498); + v.window_stroke = Stroke::new(1.0, hex(0xc4a86a)); + v.warn_fg_color = hex(0xb87420); + v.error_fg_color = hex(0x9a3a1a); + v +} + +// Warm amber + gold + cream. +fn honey() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xfff4e0); + v.window_fill = hex(0xfff9ec); + v.extreme_bg_color = hex(0xffffff); + v.faint_bg_color = hex(0xffecc8); + v.code_bg_color = hex(0xffe6b8); + v.selection.bg_fill = hex(0xffd97a); + v.selection.stroke = Stroke::new(1.0, hex(0xb87420)); + v.hyperlink_color = hex(0x9a5a10); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xffecc8); + v.widgets.noninteractive.weak_bg_fill = hex(0xfff2d4); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x5c3818)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xe8a838)); + v.widgets.inactive.bg_fill = hex(0xffdf9c); + v.widgets.inactive.weak_bg_fill = hex(0xffe6b0); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x4a2c10)); + v.widgets.hovered.bg_fill = hex(0xffd166); + v.widgets.hovered.weak_bg_fill = hex(0xffdb84); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x4a2c10)); + v.widgets.active.bg_fill = hex(0xe8a838); + v.widgets.active.weak_bg_fill = hex(0xc8902a); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xfff4e0)); + v.widgets.open.bg_fill = hex(0xffd166); + v.window_stroke = Stroke::new(1.0, hex(0xe8a838)); + v.warn_fg_color = hex(0xb87420); + v.error_fg_color = hex(0xa83a1a); + v +} + +// Dim warm glow + toasted brown. +fn candlelight() -> egui::Visuals { + let mut v = egui::Visuals::light(); + v.panel_fill = hex(0xf2e6d0); + v.window_fill = hex(0xf6ecd9); + v.extreme_bg_color = hex(0xfbf3e2); + v.faint_bg_color = hex(0xead8b8); + v.code_bg_color = hex(0xe2cea0); + v.selection.bg_fill = hex(0xd9b382); + v.selection.stroke = Stroke::new(1.0, hex(0x6b3f1e)); + v.hyperlink_color = hex(0x5a3418); + v.override_text_color = None; + v.widgets.noninteractive.bg_fill = hex(0xead8b8); + v.widgets.noninteractive.weak_bg_fill = hex(0xeedec2); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x3a2818)); + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xa8703a)); + v.widgets.inactive.bg_fill = hex(0xddc498); + v.widgets.inactive.weak_bg_fill = hex(0xe4ceaa); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x2d1a0c)); + v.widgets.hovered.bg_fill = hex(0xd2ac6e); + v.widgets.hovered.weak_bg_fill = hex(0xdab880); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x2d1a0c)); + v.widgets.active.bg_fill = hex(0xa8703a); + v.widgets.active.weak_bg_fill = hex(0x8c5a2c); + v.widgets.active.fg_stroke = Stroke::new(2.0, hex(0xf2e6d0)); + v.widgets.open.bg_fill = hex(0xd2ac6e); + v.window_stroke = Stroke::new(1.0, hex(0xa8703a)); + v.warn_fg_color = hex(0xb87420); + v.error_fg_color = hex(0x9a3a1a); + v +} +``` + +- [ ] **Step 4: Build to verify it compiles** + +Run: `cargo build --release` +Expected: builds clean (ignoring the ~39 upstream `f32: From` warnings). If any hex literal errors appear, double-check the values against the palette comments. + +- [ ] **Step 5: Smoke-test all 19 themes** + +Run: `./target/release/catacomb` +Open Settings → Theme, click through all 19. Confirm each renders without panic and looks like its identity. + +- [ ] **Step 6: Commit** + +```bash +git add src/theme.rs +git commit -m "feat(theme): add 12 new themes (cyberpunk, goth, dev, cozy)" +``` + +--- + +## Task 4: Add `default_view_mode` to config + +**Files:** +- Modify: `src/config.rs:190-222` (UiSection + Default) and add a default fn near line 279 + +- [ ] **Step 1: Write the failing test** + +Add a test module at the end of `src/config.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_section_default_view_mode_is_list() { + let ui = UiSection::default(); + assert_eq!(ui.default_view_mode, "list"); + } + + #[test] + fn ui_section_round_trips_through_toml() { + let ui = UiSection { default_view_mode: "grid".into(), ..Default::default() }; + let s = toml::to_string(&ui).unwrap(); + let back: UiSection = toml::from_str(&s).unwrap(); + assert_eq!(back.default_view_mode, "grid"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --release config::tests` +Expected: FAIL — no field `default_view_mode`. + +- [ ] **Step 3: Add the field + Default + default fn** + +In `src/config.rs`, add the field to `UiSection` (after `ui_scale`): + +```rust + /// Default video-list render mode: "list", "card", or "grid". + /// Per-view overrides live in App state (not persisted beyond session). + #[serde(default = "default_view_mode")] + pub default_view_mode: String, +``` + +Add the default fn near `default_theme()` (line ~279): + +```rust +fn default_view_mode() -> String { "list".to_string() } +``` + +Update `impl Default for UiSection`: + +```rust +impl Default for UiSection { + fn default() -> Self { + Self { + theme: default_theme(), + minimize_to_tray: false, + ui_scale: default_ui_scale(), + default_view_mode: default_view_mode(), + } + } +} +``` + +Update the doc comment listing themes at line 192 if it enumerates them — leave the theme list as-is (it's still accurate), just note view modes are documented elsewhere. (Optional: no change required.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --release config::tests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/config.rs +git commit -m "feat(config): add [ui] default_view_mode field" +``` + +--- + +## Task 5: Wire ThemeAccents into App + recompute on theme change + +**Files:** +- Modify: `src/app.rs:167` (App fields), `:371` (construction), `:3178` (theme picker callback) + +- [ ] **Step 1: Add the App field** + +In `src/app.rs`, add to the `App` struct fields near `card_density` (line 167): + +```rust + /// Theme-aware accent colors, recomputed whenever the theme changes. + theme_accents: crate::theme::ThemeAccents, +``` + +- [ ] **Step 2: Seed at construction** + +At `src/app.rs:371` where `theme::apply(&cc.egui_ctx, &config.ui.theme);` is called, add immediately after: + +```rust + theme::apply(&cc.egui_ctx, &config.ui.theme); + let theme_accents = theme::accents_for(&config.ui.theme); +``` + +Then in the `App { ... }` literal (around line 553, near `card_density: 1.0,`), add: + +```rust + theme_accents, +``` + +- [ ] **Step 3: Recompute on theme change** + +In the Settings theme picker callback at `src/app.rs:3178`, change: + +```rust + { + self.config.ui.theme = id.to_string(); + theme::apply(ctx, id); + } +``` + +to: + +```rust + { + self.config.ui.theme = id.to_string(); + theme::apply(ctx, id); + self.theme_accents = theme::accents_for(id); + } +``` + +- [ ] **Step 4: Build to verify** + +Run: `cargo build --release` +Expected: builds clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): wire ThemeAccents snapshot, recompute on theme change" +``` + +--- + +## Task 6: Replace hardcoded accent colors with theme accents + +**Files:** +- Modify: `src/app.rs:4225, 4232, 4239` (the three hardcoded rings in the video list) + +- [ ] **Step 1: Replace the three hardcoded colors** + +At `src/app.rs:4225` change: +```rust + egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)), +``` +to: +```rust + egui::Stroke::new(2.0, self.theme_accents.accent), +``` + +At `src/app.rs:4232` change: +```rust + egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)), +``` +to: +```rust + egui::Stroke::new(2.0, self.theme_accents.success), +``` + +At `src/app.rs:4239` change: +```rust + egui::Stroke::new(3.0, egui::Color32::from_rgb(180, 130, 240)), +``` +to: +```rust + egui::Stroke::new(3.0, self.theme_accents.warning), +``` + +- [ ] **Step 2: Build + smoke-test** + +Run: `cargo build --release && ./target/release/catacomb` +Switch themes in Settings; select/play/bulk-check videos. Confirm the rings change color to match each theme (e.g. Witching Hour = arcane-violet, Honey = amber). + +- [ ] **Step 3: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): use theme-aware accent rings for select/play/bulk" +``` + +--- + +## Task 7: ViewMode enum + App state + global default seeding + +**Files:** +- Modify: `src/app.rs` (new enum near `SortMode` at line 66; App fields near line 167) + +- [ ] **Step 1: Add the ViewMode enum** + +Near the `SortMode` enum at `src/app.rs:66`, add: + +```rust +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ViewMode { + List, + Card, + Grid, +} + +impl ViewMode { + fn from_config(s: &str) -> Self { + match s { + "card" => ViewMode::Card, + "grid" => ViewMode::Grid, + _ => ViewMode::List, + } + } +} +``` + +- [ ] **Step 2: Add App fields** + +Near `card_density` in the `App` struct (line 167), add: + +```rust + /// Global default video-list render mode (from config). + default_view_mode: ViewMode, + /// Per-SidebarView overrides; a view absent here falls back to default. + view_mode_overrides: std::collections::HashMap, +``` + +- [ ] **Step 3: Seed at construction** + +In the `App { ... }` literal near `card_density: 1.0,` (line 553), add: + +```rust + default_view_mode: ViewMode::from_config(&config.ui.default_view_mode), + view_mode_overrides: Default::default(), +``` + +- [ ] **Step 4: Add a helper to resolve the active mode for a view** + +Add an `impl App` method (place near other small helpers in the same impl block): + +```rust + /// The view mode to use for `view`: the per-view override if set, else + /// the global default. + fn view_mode_for(&self, view: &SidebarView) -> ViewMode { + self.view_mode_overrides + .get(view) + .copied() + .unwrap_or(self.default_view_mode) + } +``` + +- [ ] **Step 5: Build to verify** + +Run: `cargo build --release` +Note: `SidebarView` must derive `Hash` and `Eq` for HashMap use. Check the derive at `src/app.rs:79` (`#[derive(Clone, PartialEq)]`) and add `Eq, Hash`: + +```rust +#[derive(Clone, PartialEq, Eq, Hash)] +enum SidebarView { +``` +(Verify all variants are compatible — they are plain enums/struct tuples of `usize`, which all impl Eq+Hash.) + +Expected: builds clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): ViewMode enum + global default + per-view overrides" +``` + +--- + +## Task 8: Unify placeholder thumbnail paint + +**Files:** +- Modify: `src/app.rs:3972-3979` (channel card placeholder) and `src/app.rs:4210-4217` (video card placeholder) + +- [ ] **Step 1: Add a shared placeholder helper** + +Add an `impl App` method: + +```rust + /// Paint a consistent missing-thumbnail placeholder inside `rect`: + /// a theme-tinted gradient + a single glyph. Used for both channel and + /// video cards so the empty states stop diverging. + fn paint_thumb_placeholder(&self, ui: &egui::Ui, rect: egui::Rect, glyph: &str, density: f32) { + let v = ui.visuals(); + // Two-tone vertical gradient from faint_bg to panel_fill. + let top = v.faint_bg_color; + let bot = v.panel_fill; + let (top, bot) = (top.to_array(), bot.to_array()); + // Use a simple split fill: top half one color, bottom half another, + // blended by drawing two stacked rects (egui has no native gradient). + let mid = rect.top() + rect.height() * 0.5; + ui.painter().rect_filled( + egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, mid)), + 4.0, + egui::Color32::from_rgba_unmultiplied(top[0], top[1], top[2], 255), + ); + ui.painter().rect_filled( + egui::Rect::from_min_max(egui::pos2(rect.min.x, mid), rect.max), + 4.0, + egui::Color32::from_rgba_unmultiplied(bot[0], bot[1], bot[2], 255), + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + glyph, + egui::FontId::proportional(24.0 * density), + v.weak_text_color(), + ); + } +``` + +- [ ] **Step 2: Replace the channel-card placeholder** + +At `src/app.rs:3972-3980`, replace the `None => { ... }` arm with: + +```rust + None => { + self.paint_thumb_placeholder(ui, thumb_rect, "🎬", density); + } +``` + +- [ ] **Step 3: Replace the video-card placeholder** + +At `src/app.rs:4210-4218`, replace the `None => { ... }` arm with: + +```rust + None => { + self.paint_thumb_placeholder(ui, rect, "🎬", density); + } +``` + +- [ ] **Step 4: Build + smoke-test** + +Run: `cargo build --release && ./target/release/catacomb` +Find a video/channel with no thumbnail. Confirm both now render the same theme-tinted gradient + 🎬 glyph. + +- [ ] **Step 5: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): unified theme-tinted placeholder thumbnails" +``` + +--- + +## Task 9: List / Card / Grid render branches in the video list + +This is the largest task. The existing flat-row code becomes the `List` branch; `Card` wraps each row in a rounded rect; `Grid` reflows into vertical cards. + +**Files:** +- Modify: `src/app.rs:4184-4014` (the `for card in &cards { ... }` loop inside the `ScrollArea`) + +- [ ] **Step 1: Read the current loop fully** + +Run: read `src/app.rs` lines 4184–4014 to see the existing `ui.horizontal(|ui| { ... })` row body. The title/metadata/flags body must be reused across all three modes. + +- [ ] **Step 2: Wrap the existing row code in a `match self.view_mode_for(&self.sidebar_view.clone())`** + +At the top of the `ScrollArea::vertical().show(ui, |ui| { ... })` closure (line ~4184), after the `thumb_w/thumb_h` setup, insert the mode resolution: + +```rust + let view_mode = self.view_mode_for(&self.sidebar_view.clone()); +``` + +(We clone `sidebar_view` to avoid borrowing `self` while the loop also borrows `cards`/`self` mutably via flag writes — match the existing pattern where the loop body uses `let mut clicked_card = false;` etc. to defer mutations.) + +Then branch. The simplest refactor that preserves all existing behavior for `List`: + +```rust + match view_mode { + ViewMode::List => { + // === existing loop body, unchanged === + for card in &cards { + // ... (the entire existing row code stays here verbatim) + } + } + ViewMode::Card => { + self.render_video_list_cards(ui, ctx, &cards, density); + } + ViewMode::Grid => { + self.render_video_list_grid(ui, ctx, &cards, density); + } + } +``` + +Move the entire existing `for card in &cards { ... }` block under the `ViewMode::List` arm (cut and paste verbatim — no changes to its internals yet). + +- [ ] **Step 3: Add the `Card` render helper** + +Add `impl App` methods (signatures must match how they're called above): + +```rust + /// Card-row mode: same horizontal layout as List, but each row is a + /// rounded card on faint_bg_fill with a hover lift. + fn render_video_list_cards( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + cards: &[VideoCard], + density: f32, + ) { + let thumb_w = (176.0 * density).round(); + let thumb_h = (99.0 * density).round(); + let thumb_size = egui::vec2(thumb_w, thumb_h); + + for (i, card) in cards.iter().enumerate() { + // Per-row mutations are deferred with the same flag-out pattern + // the List branch uses; mirror it exactly. + let mut clicked_card = false; + let mut play_card = false; + let mut toggle_watched_card = false; + let mut toggle_flag_card: Option<&'static str> = None; + let selected = self.selected_video.as_deref() == Some(card.id.as_str()); + let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str()); + let bulk_checked = self.bulk_selected.contains(&card.id); + + let frame = egui::Frame::NONE + .fill(ui.visuals().faint_bg_color) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) + .rounding(egui::Rounding::same(8.0)) + .inner_margin(egui::Margin::same(8)) + .outer_margin(egui::Margin::symmetric(0, 3)); + + let card_resp = frame.show(ui, |ui| { + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click()); + let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p)); + match &texture { + Some(handle) => { + egui::Image::new(handle).maintain_aspect_ratio(true).paint_at(ui, rect); + } + None => self.paint_thumb_placeholder(ui, rect, "🎬", density), + } + if selected { + ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(2.0, self.theme_accents.accent)); + } + if is_playing { + ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(2.0, self.theme_accents.success)); + } + if bulk_checked { + ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(3.0, self.theme_accents.warning)); + } + if resp.clicked() { clicked_card = true; } + if resp.double_clicked() { play_card = true; } + self.render_video_meta_row(ui, card, selected, &mut clicked_card); + }); + }); + if card_resp.response.hovered() { + ui.painter().rect_stroke( + card_resp.response.rect, + 8.0, + egui::Stroke::new(1.5, self.theme_accents.accent), + ); + } + self.apply_video_card_actions( + i, cards, clicked_card, play_card, toggle_watched_card, toggle_flag_card, + ); + ui.add_space(4.0); + } + } +``` + +- [ ] **Step 4: Add the `Grid` render helper** + +```rust + /// Grid mode: YouTube/Plex-style vertical cards, responsive columns. + fn render_video_list_grid( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + cards: &[VideoCard], + density: f32, + ) { + let thumb_w = (176.0 * density).round(); + let thumb_h = (99.0 * density).round(); + let card_w = thumb_w + 8.0; + let avail = ui.available_width(); + let cols = ((avail / card_w).floor() as usize).max(1); + + let mut clicked_card = false; + let mut play_card = false; + let mut toggle_watched_card = false; + let mut toggle_flag_card: Option<&'static str> = None; + + egui::Grid::new("video_grid") + .num_columns(cols) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + for (i, card) in cards.iter().enumerate() { + let selected = self.selected_video.as_deref() == Some(card.id.as_str()); + let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str()); + let bulk_checked = self.bulk_selected.contains(&card.id); + + let (rect, resp) = ui.allocate_exact_size( + egui::vec2(thumb_w, thumb_h), + egui::Sense::click(), + ); + let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p)); + match &texture { + Some(handle) => { + egui::Image::new(handle).maintain_aspect_ratio(true).paint_at(ui, rect); + } + None => self.paint_thumb_placeholder(ui, rect, "🎬", density), + } + if selected { + ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(2.0, self.theme_accents.accent)); + } + if is_playing { + ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(2.0, self.theme_accents.success)); + } + if bulk_checked { + ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(3.0, self.theme_accents.warning)); + } + if resp.clicked() { clicked_card = true; } + if resp.double_clicked() { play_card = true; } + + ui.vertical(|ui| { + ui.add_space(4.0); + self.render_video_meta_row(ui, card, selected, &mut clicked_card); + }); + ui.end_row(); + let _ = i; + } + }); + self.apply_video_card_actions( + 0, cards, clicked_card, play_card, toggle_watched_card, toggle_flag_card, + ); + } +``` + +- [ ] **Step 5: Extract the shared metadata/title/flags body into `render_video_meta_row` and the action application into `apply_video_card_actions`** + +These two helpers are refactor extractions from the existing `List` branch. **Read the existing row body at `src/app.rs:4267-4400` (the `ui.vertical(|ui| { ... })` block containing title, channel, id, duration, size, flag buttons) and move it verbatim into:** + +```rust + /// Shared title + metadata + flag-button row, used by all three view modes. + fn render_video_meta_row( + &mut self, + ui: &mut egui::Ui, + card: &VideoCard, + selected: bool, + clicked_card: &mut bool, + ) { + // === the body of the existing ui.vertical(|ui| { ... }) block === + // (title selectable_label, channel/id/duration/size metadata, flag + // buttons). Copy verbatim from the current List branch. + } +``` + +And the deferred-mutation application (the code after each row that writes `self.flags`, `self.selected_video`, plays, etc.) into: + +```rust + /// Apply the per-card deferred actions (click/play/watch/flag toggles). + /// `cards` + `i` let it locate the right card for DB writes. + fn apply_video_card_actions( + &mut self, + i: usize, + cards: &[VideoCard], + clicked_card: bool, + play_card: bool, + toggle_watched_card: bool, + toggle_flag_card: Option<&'static str>, + ) { + // === the existing post-row mutation code, factored out === + // (the `if clicked_card { self.selected_video = ... }` etc. block) + } +``` + +**Important:** because the exact contents of these blocks depend on the current code (which may have evolved), the engineer implementing this MUST read `src/app.rs:4267-4420` and copy the real code rather than trusting the comments above. The signatures above are the contract; the bodies are the existing code. + +- [ ] **Step 6: Wire the `List` branch to also use the extracted helpers** + +Once `render_video_meta_row` and `apply_video_card_actions` exist, refactor the `ViewMode::List` arm to call them too, so all three modes share one code path for metadata + actions. This avoids the three-way drift the spec warned against. + +- [ ] **Step 7: Build** + +Run: `cargo build --release` +Expected: builds clean. Borrow-checker may complain about `self.texture(ctx, ...)` inside the loop while `&cards` is borrowed — the existing code already solves this with the deferred-flags pattern; mirror it. + +- [ ] **Step 8: Smoke-test all three modes** + +Run: `./target/release/catacomb` +For each of List/Card/Grid: click a video, double-click to play, toggle watched, toggle a flag, bulk-select. Confirm all actions work in all three modes. + +- [ ] **Step 9: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): List/Card/Grid video list render modes" +``` + +--- + +## Task 10: Toolbar view-mode toggle + per-view override write + +**Files:** +- Modify: `src/app.rs:4163-4166` (just above the `if cards.is_empty()` check, in the video list header area) + +- [ ] **Step 1: Add the 3-segment toggle** + +Just before `ui.separator();` at line ~4164, insert: + +```rust + ui.horizontal(|ui| { + ui.label(egui::RichText::new("View:").weak().small()); + let current = self.view_mode_for(&self.sidebar_view.clone()); + for (mode, label) in [ + (ViewMode::List, "☰ List"), + (ViewMode::Card, "▢ Card"), + (ViewMode::Grid, "⊫ Grid"), + ] { + if ui.selectable_label(current == mode, label).clicked() { + self.view_mode_overrides + .insert(self.sidebar_view.clone(), mode); + } + } + }); +``` + +- [ ] **Step 2: Build + smoke-test** + +Run: `cargo build --release && ./target/release/catacomb` +Switch to "All Videos", toggle to Grid. Switch to a channel — confirm it still shows the default (override is per-view). Toggle a channel to Card, switch away and back — confirm Card persists. + +- [ ] **Step 3: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): view-mode toolbar toggle with per-view override" +``` + +--- + +## Task 11: Settings UI for default view mode + +**Files:** +- Modify: `src/app.rs:3182` (the Settings screen, after the theme picker row) + +- [ ] **Step 1: Add a default-view-mode combo row** + +After the Theme combo row ends at `src/app.rs:3182` (`ui.end_row();`), insert a new row in the same `egui::Grid`: + +```rust + ui.label("Default view:"); + egui::ComboBox::from_id_salt("default_view_mode_combo") + .selected_text(match self.default_view_mode { + ViewMode::List => "List", + ViewMode::Card => "Card", + ViewMode::Grid => "Grid", + }) + .show_ui(ui, |ui| { + for (mode, label) in [ + (ViewMode::List, "List"), + (ViewMode::Card, "Card"), + (ViewMode::Grid, "Grid"), + ] { + if ui + .selectable_label(self.default_view_mode == mode, label) + .clicked() + { + self.default_view_mode = mode; + self.config.ui.default_view_mode = match mode { + ViewMode::List => "list", + ViewMode::Card => "card", + ViewMode::Grid => "grid", + }.to_string(); + } + } + }); + ui.end_row(); +``` + +- [ ] **Step 2: Build + smoke-test** + +Run: `cargo build --release && ./target/release/catacomb` +Open Settings, change Default view to Grid, restart the app. Confirm views with no override now default to Grid. + +- [ ] **Step 3: Verify config persists** + +After changing default view and quitting, check the written `config.toml` has `default_view_mode = "grid"` under `[ui]`. (Don't commit `config.toml`.) + +- [ ] **Step 4: Commit** + +```bash +git add src/app.rs +git commit -m "feat(app): Settings picker for default view mode" +``` + +--- + +## Task 12: Typography & spacing refresh + +**Files:** +- Modify: `src/app.rs` — base font sizes via `ctx.set_style` / `FontDefinitions`, and per-call text sizes in the card renderers + +- [ ] **Step 1: Bump base body text and set style spacing at construction** + +At construction (near `src/app.rs:371`, after `theme::apply`), add: + +```rust + // Base text scale: bump body from ~12px to ~13px for legibility. + let mut style = egui::Style::default(); + style.spacing.item_spacing = egui::vec2(8.0, 5.0); + style.spacing.button_padding = egui::vec2(6.0, 3.0); + cc.egui_ctx.set_style(style); +``` + +- [ ] **Step 2: Adjust card text sizes in `render_video_meta_row`** + +In the extracted helper (Task 9), the title uses `egui::RichText::new(&card.title).strong()` — bump its size to 14.0 (proportional). The metadata line uses `.small()`; leave that, but ensure consistent separator spacing (replace `·` ad-hoc joins with ` · ` between every metadata item). + +- [ ] **Step 3: Build + visual smoke-test** + +Run: `cargo build --release && ./target/release/catacomb` +Confirm text is slightly larger, spacing more breathable, metadata line consistent across modes. + +- [ ] **Step 4: Commit** + +```bash +git add src/app.rs +git commit -m "style(app): bump base font + spacing, tidy metadata rhythm" +``` + +--- + +## Task 13: Full verification + docs sync + +**Files:** +- Run tests, run app, verify spec coverage. + +- [ ] **Step 1: Run the full test suite** + +Run: `cargo test --release` +Expected: all pass (existing tests + new theme/config tests). + +- [ ] **Step 2: Manual theme tour** + +Run: `./target/release/catacomb` +Switch through all 19 themes. For 2–3 themes, exercise List/Card/Grid + select/play/bulk/watched/flag. Confirm accent rings track the theme in every mode. + +- [ ] **Step 3: Verify the public doc is still accurate** + +Read `docs/src/theming.md` (created earlier). Confirm the theme table count (19) and the view-mode description match what shipped. Fix any drift inline. + +- [ ] **Step 4: Final commit (docs sync if changed)** + +```bash +git add docs/src/theming.md # only if changed +git commit -m "docs(theming): sync with shipped 19 themes + view modes" +``` + +--- + +## Self-review notes + +- **Spec coverage:** §1 themes → Tasks 1,2,3. §2 accents → Tasks 1,5,6. §3 view toggle → Tasks 4,7,9,10,11. §4 default polish → Task 2. §5 typography → Task 12. §6 placeholders → Task 8. All spec sections covered. +- **Hex literal values** in Task 3 have been checked and corrected; build should be clean on first try. +- **Borrow-checker risk** in Task 9 is flagged; the existing deferred-flags pattern is the mitigation. +- **`render_video_meta_row` / `apply_video_card_actions` bodies are intentionally not pasted** because they must be copied from the *current* code, which may have drifted since this plan was written. The signatures are the contract.