catacomb/docs/superpowers/plans/2026-06-27-desktop-visual-refresh.md

1518 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<f64>` 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<f64>` 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<f64>` 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<SidebarView, ViewMode>,
```
- [ ] **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 41844014 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 23 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.