1518 lines
58 KiB
Markdown
1518 lines
58 KiB
Markdown
# 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 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.
|