diff --git a/docs/superpowers/specs/2026-03-16-color-scheme-design.md b/docs/superpowers/specs/2026-03-16-color-scheme-design.md index beebeb1..27bdf2e 100644 --- a/docs/superpowers/specs/2026-03-16-color-scheme-design.md +++ b/docs/superpowers/specs/2026-03-16-color-scheme-design.md @@ -1,7 +1,7 @@ # Color Scheme Switcher Design **Date:** 2026-03-16 -**Status:** Draft +**Status:** Revised after review **Scope:** Interactive color theme selection for Mainline news ticker --- @@ -31,7 +31,8 @@ The implementation uses a dedicated `Theme` class to encapsulate gradients and m **Non-functional:** - Reuse the existing font picker pattern for UI consistency - Zero runtime overhead during streaming (theme lookup happens once at startup) -- Boot messages and title render in the selected theme +- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients** +- Font picker UI remains hardcoded green for visual continuity --- @@ -39,6 +40,8 @@ The implementation uses a dedicated `Theme` class to encapsulate gradients and m ### New Module: `engine/themes.py` +**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies. + ```python class Theme: """Encapsulates a color scheme: name, main gradient, message gradient.""" @@ -50,20 +53,42 @@ class Theme: ``` **Theme Registry:** -Three instances registered by ID: `"verdant"`, `"molten"`, `"violet"`. +Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity). -Each gradient is a list of 12 ANSI 256-color codes matching the current format in `render.py`: +Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient: ``` [ "\033[1;38;5;231m", # white (bold) "\033[1;38;5;195m", # pale white-tint - ..., + "\033[38;5;123m", # bright cyan + "\033[38;5;118m", # bright lime + "\033[38;5;82m", # lime + "\033[38;5;46m", # bright color + "\033[38;5;40m", # color + "\033[38;5;34m", # medium color + "\033[38;5;28m", # dark color + "\033[38;5;22m", # deep color + "\033[2;38;5;22m", # dim deep color "\033[2;38;5;235m", # near black ] ``` +**Finalized color codes:** + +**Green (primary: 22, complementary: 89)** — unchanged from current +- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]` +- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]` + +**Orange (primary: 208, complementary: 21)** +- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]` +- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]` + +**Purple (primary: 129, complementary: 226)** +- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]` +- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]` + **Public API:** -- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises if not found +- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found - `THEME_REGISTRY` — dict of all available themes (for picker) --- @@ -72,49 +97,83 @@ Each gradient is a list of 12 ANSI 256-color codes matching the current format i **New globals:** ```python -ACTIVE_THEME = None # set by set_active_theme() after picker +ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream() ``` **New function:** ```python -def set_active_theme(theme_id: str): - """Set the active theme after user selection.""" +def set_active_theme(theme_id: str = "green"): + """Set the active theme. Defaults to 'green' if not specified.""" global ACTIVE_THEME from engine import themes ACTIVE_THEME = themes.get_theme(theme_id) ``` +**Behavior:** +- Called by `app.pick_color_theme()` with user selection +- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin) +- Guarantees `ACTIVE_THEME` is set before any render functions are called + **Removal:** -- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` — these move to `themes.py` +- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants --- ### Modified: `engine/render.py` -**Updated gradient access:** +**Updated gradient access in existing functions:** -Old pattern: +Current pattern (will be removed): ```python -def lr_gradient(rows, offset, cols=None): - cols = cols or GRAD_COLS # module-level constant +GRAD_COLS = [...] # hardcoded green +MSG_GRAD_COLS = [...] # hardcoded magenta ``` -New pattern: +New pattern — update `lr_gradient()` function: ```python def lr_gradient(rows, offset, cols=None): if cols is None: from engine import config - cols = config.ACTIVE_THEME.main_gradient if config.ACTIVE_THEME else _DEFAULT_GRAD + cols = (config.ACTIVE_THEME.main_gradient + if config.ACTIVE_THEME + else _default_green_gradient()) # ... rest of function unchanged ``` -**Fallback:** Define `_DEFAULT_GRAD` (current green) for cases where theme isn't selected yet. - -**Message gradient lookup:** +**Define fallback:** ```python -def msg_gradient_rows(rows, offset): +def _default_green_gradient(): + """Fallback green gradient (current colors).""" + return [ + "\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m", + "\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m", + "\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m", + "\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m", + ] +``` + +**Message gradient handling:** + +The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to: +```python +# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS) +# Use: +from engine import config +cols = (config.ACTIVE_THEME.message_gradient + if config.ACTIVE_THEME + else _default_magenta_gradient()) +lr_gradient(rows, offset, cols) +``` + +or define a helper: +```python +def msg_gradient(rows, offset): + """Apply message (ntfy) gradient using theme complementary colors.""" from engine import config - return lr_gradient(rows, offset, config.ACTIVE_THEME.message_gradient) + cols = (config.ACTIVE_THEME.message_gradient + if config.ACTIVE_THEME + else _default_magenta_gradient()) + return lr_gradient(rows, offset, cols) ``` --- @@ -124,10 +183,25 @@ def msg_gradient_rows(rows, offset): **New function: `pick_color_theme()`** Mirrors `pick_font_face()` pattern: -1. Display menu: `[1] Verdant Green [2] Molten Orange [3] Violet Purple` -2. Read arrow keys (↑/↓) or j/k to navigate -3. Return selected theme ID -4. Call `config.set_active_theme(theme_id)` + +```python +def pick_color_theme(): + """Interactive color theme picker. Defaults to 'green' if not TTY.""" + import sys + from engine import config, themes + + # Non-interactive fallback: use default + if not sys.stdin.isatty(): + config.set_active_theme("green") + return + + # Interactive picker (similar to font picker) + themes_list = list(themes.THEME_REGISTRY.items()) + selected = 0 + + # ... render menu, handle arrow keys j/k, ↑/↓ ... + # ... on Enter, call config.set_active_theme(themes_list[selected][0]) ... +``` **Placement in `main()`:** ```python @@ -135,10 +209,10 @@ def main(): # ... signal handler setup ... pick_color_theme() # NEW — before title/subtitle pick_font_face() - # ... rest of boot sequence ... + # ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ... ``` -This ensures boot messages (title, subtitle, status lines) render in the selected theme color. +**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu. --- @@ -150,15 +224,14 @@ User starts: mainline.py main() called ↓ pick_color_theme() - → Display menu - → Read user input - → config.set_active_theme(theme_id) + → If TTY: display menu, read input, call config.set_active_theme(user_choice) + → If not TTY: silently call config.set_active_theme("green") ↓ -pick_font_face() — renders with active theme +pick_font_face() — renders in hardcoded green UI colors ↓ -Boot messages (title, status, etc.) — all use active theme +Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients) ↓ -stream() — news + messages use active theme gradients +stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients ↓ On exit: no persistence ``` @@ -167,38 +240,52 @@ On exit: no persistence ## Implementation Notes -### Color Selection -ANSI 256-color palette is used throughout. Each gradient has 12 steps from white to deep primary/complementary color, matching the current green gradient structure exactly. +### Initialization Guarantee +`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because: +1. `pick_color_theme()` always sets it (either interactively or via fallback) +2. It's called before any rendering happens +3. Default fallback ensures non-TTY environments don't crash -Orange gradient: `231 → 195 → 214 → 208 → 202 → 166 → 130 → 94 → 58 → 94 → 94 (dim) → 235` -Purple gradient: `231 → 225 → 183 → 177 → 171 → 165 → 129 → 93 → 57 → 57 → 57 (dim) → 235` -(Exact codes TBD via manual testing for visual balance) +### Module Independence +`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects. -### Future Extensions -The Theme class can be extended with: -- `animation_delay`: for strobing/pulsing themes -- `metadata`: tags like `["cyberpunk", "retro", "ethereal"]` -- `random_pick()`: returns a random theme (enables future `--color=random` flag) +### Color Code Finalization +All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain. -The picker can offer a "Random" option without any code changes to render or config. +### Theme ID Naming +IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity. -### Testing -- Unit test `themes.py` for Theme construction and registry lookup -- Integration test that boot sequence respects active theme -- No changes needed to existing gradient tests (they now pull from config.ACTIVE_THEME) +### Testing Strategy +1. **Unit tests** (`tests/test_themes.py`): + - Verify Theme class construction + - Test THEME_REGISTRY lookup (valid and invalid IDs) + - Confirm gradient lists have correct length (12) + +2. **Integration tests** (`tests/test_render.py`): + - Mock `config.ACTIVE_THEME` to each theme + - Verify `lr_gradient()` uses correct colors + - Verify fallback works when `ACTIVE_THEME` is None + +3. **Existing tests:** + - Render tests that check gradient output will need to mock `config.ACTIVE_THEME` + - Use pytest fixtures to set theme per test case --- ## Files Changed - `engine/themes.py` (new) - `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`) -- `engine/render.py` (update gradient access to use config.ACTIVE_THEME) +- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups) - `engine/app.py` (add `pick_color_theme()`, call in main) - `tests/test_themes.py` (new unit tests) +- `tests/test_render.py` (update mocking strategy) ## Acceptance Criteria 1. ✓ Color picker displays 3 theme options at startup 2. ✓ Selection applies to all headline and message gradients -3. ✓ Boot messages render in selected theme -4. ✓ No persistence between runs -5. ✓ Architecture supports future random/animation modes +3. ✓ Boot UI (title, status) uses hardcoded green (not theme) +4. ✓ Scrolling headlines and ntfy messages use theme gradients +5. ✓ No persistence between runs +6. ✓ Non-TTY environments default to green without error +7. ✓ Architecture supports future random/animation modes +8. ✓ All gradient color codes finalized with no TBD values