From d9422b1fec07ea8947374dedf42b67487a734873 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:47:25 -0700 Subject: [PATCH] docs: add color scheme implementation plan Comprehensive plan with 6 chunks, each containing bite-sized TDD tasks: - Chunk 1: Theme class and registry - Chunk 2: Config integration - Chunk 3: Render pipeline - Chunk 4: Message gradient integration - Chunk 5: Color picker UI - Chunk 6: Integration and validation Each step includes exact code, test commands, and expected output. --- .../2026-03-16-color-scheme-implementation.md | 894 ++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-color-scheme-implementation.md diff --git a/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md b/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md new file mode 100644 index 0000000..c08017f --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md @@ -0,0 +1,894 @@ +# Color Scheme Switcher Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors. + +**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern. + +**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities + +--- + +## File Structure + +| File | Purpose | Change Type | +|------|---------|------------| +| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create | +| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify | +| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify | +| `engine/scroll.py` | Update message gradient call | Modify | +| `engine/app.py` | pick_color_theme(), call in main() | Modify | +| `tests/test_themes.py` | Theme class and registry unit tests | Create | + +--- + +## Chunk 1: Theme Data Module + +### Task 1: Create themes.py with Theme class and registry + +**Files:** +- Create: `engine/themes.py` +- Test: `tests/test_themes.py` + +- [ ] **Step 1: Write failing test for Theme class** + +Create `tests/test_themes.py`: + +```python +"""Test color themes and registry.""" +from engine.themes import Theme, THEME_REGISTRY, get_theme + + +def test_theme_construction(): + """Theme stores name and gradient lists.""" + main = ["\033[1;38;5;231m"] * 12 + msg = ["\033[1;38;5;225m"] * 12 + theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg) + + assert theme.name == "Test Green" + assert theme.main_gradient == main + assert theme.message_gradient == msg + + +def test_gradient_length(): + """Each gradient must have exactly 12 ANSI codes.""" + for theme_id, theme in THEME_REGISTRY.items(): + assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length" + assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length" + + +def test_theme_registry_has_three_themes(): + """Registry contains green, orange, purple.""" + assert len(THEME_REGISTRY) == 3 + assert "green" in THEME_REGISTRY + assert "orange" in THEME_REGISTRY + assert "purple" in THEME_REGISTRY + + +def test_get_theme_valid(): + """get_theme returns Theme object for valid ID.""" + theme = get_theme("green") + assert isinstance(theme, Theme) + assert theme.name == "Verdant Green" + + +def test_get_theme_invalid(): + """get_theme raises KeyError for invalid ID.""" + with pytest.raises(KeyError): + get_theme("invalid_theme") + + +def test_green_theme_unchanged(): + """Green theme uses original green → magenta colors.""" + green_theme = get_theme("green") + # First color should be white (bold) + assert green_theme.main_gradient[0] == "\033[1;38;5;231m" + # Last deep green + assert green_theme.main_gradient[9] == "\033[38;5;22m" + # Message gradient is magenta + assert green_theme.message_gradient[9] == "\033[38;5;89m" +``` + +Run: `pytest tests/test_themes.py -v` +Expected: FAIL (module doesn't exist) + +- [ ] **Step 2: Create themes.py with Theme class and finalized gradients** + +Create `engine/themes.py`: + +```python +"""Color theme definitions and registry.""" +from typing import Optional + + +class Theme: + """Encapsulates a color scheme: name, main gradient, message gradient.""" + + def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]): + """Initialize theme with display name and gradient lists. + + Args: + name: Display name (e.g., "Verdant Green") + main_gradient: List of 12 ANSI 256-color codes (white → primary color) + message_gradient: List of 12 ANSI codes (white → complementary color) + """ + self.name = name + self.main_gradient = main_gradient + self.message_gradient = message_gradient + + +# ─── FINALIZED GRADIENTS ────────────────────────────────────────────────── +# Each gradient: white → primary/complementary, 12 steps total +# Format: "\033[;m" where color is 38;5; + +_GREEN_MAIN = [ + "\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 green + "\033[38;5;40m", # green + "\033[38;5;34m", # medium green + "\033[38;5;28m", # dark green + "\033[38;5;22m", # deep green + "\033[2;38;5;22m", # dim deep green + "\033[2;38;5;235m", # near black +] + +_GREEN_MESSAGE = [ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;225m", # pale pink-white + "\033[38;5;219m", # bright pink + "\033[38;5;213m", # hot pink + "\033[38;5;207m", # magenta + "\033[38;5;201m", # bright magenta + "\033[38;5;165m", # orchid-red + "\033[38;5;161m", # ruby-magenta + "\033[38;5;125m", # dark magenta + "\033[38;5;89m", # deep maroon-magenta + "\033[2;38;5;89m", # dim deep maroon-magenta + "\033[2;38;5;235m", # near black +] + +_ORANGE_MAIN = [ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;215m", # pale orange-white + "\033[38;5;209m", # bright orange + "\033[38;5;208m", # vibrant orange + "\033[38;5;202m", # orange + "\033[38;5;166m", # dark orange + "\033[38;5;130m", # burnt orange + "\033[38;5;94m", # rust + "\033[38;5;58m", # dark rust + "\033[38;5;94m", # rust (hold) + "\033[2;38;5;94m", # dim rust + "\033[2;38;5;235m", # near black +] + +_ORANGE_MESSAGE = [ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;195m", # pale cyan-white + "\033[38;5;33m", # bright blue + "\033[38;5;27m", # blue + "\033[38;5;21m", # deep blue + "\033[38;5;21m", # deep blue (hold) + "\033[38;5;21m", # deep blue (hold) + "\033[38;5;18m", # navy + "\033[38;5;18m", # navy (hold) + "\033[38;5;18m", # navy (hold) + "\033[2;38;5;18m", # dim navy + "\033[2;38;5;235m", # near black +] + +_PURPLE_MAIN = [ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;225m", # pale purple-white + "\033[38;5;177m", # bright purple + "\033[38;5;171m", # vibrant purple + "\033[38;5;165m", # purple + "\033[38;5;135m", # medium purple + "\033[38;5;129m", # purple + "\033[38;5;93m", # dark purple + "\033[38;5;57m", # deep purple + "\033[38;5;57m", # deep purple (hold) + "\033[2;38;5;57m", # dim deep purple + "\033[2;38;5;235m", # near black +] + +_PURPLE_MESSAGE = [ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;226m", # pale yellow-white + "\033[38;5;226m", # bright yellow + "\033[38;5;220m", # yellow + "\033[38;5;220m", # yellow (hold) + "\033[38;5;184m", # dark yellow + "\033[38;5;184m", # dark yellow (hold) + "\033[38;5;178m", # olive-yellow + "\033[38;5;178m", # olive-yellow (hold) + "\033[38;5;172m", # golden + "\033[2;38;5;172m", # dim golden + "\033[2;38;5;235m", # near black +] + +# ─── THEME REGISTRY ─────────────────────────────────────────────────────── + +THEME_REGISTRY = { + "green": Theme( + name="Verdant Green", + main_gradient=_GREEN_MAIN, + message_gradient=_GREEN_MESSAGE, + ), + "orange": Theme( + name="Molten Orange", + main_gradient=_ORANGE_MAIN, + message_gradient=_ORANGE_MESSAGE, + ), + "purple": Theme( + name="Violet Purple", + main_gradient=_PURPLE_MAIN, + message_gradient=_PURPLE_MESSAGE, + ), +} + + +def get_theme(theme_id: str) -> Theme: + """Retrieve a theme by ID. + + Args: + theme_id: One of "green", "orange", "purple" + + Returns: + Theme object + + Raises: + KeyError: If theme_id not found in registry + """ + if theme_id not in THEME_REGISTRY: + raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}") + return THEME_REGISTRY[theme_id] +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `pytest tests/test_themes.py -v` +Expected: PASS (all 6 tests) + +- [ ] **Step 4: Commit** + +```bash +git add engine/themes.py tests/test_themes.py +git commit -m "feat: create Theme class and registry with finalized color gradients + +- Define Theme class to encapsulate name and main/message gradients +- Create THEME_REGISTRY with green, orange, purple themes +- Each gradient has 12 ANSI 256-color codes finalized +- Complementary color pairs: green/magenta, orange/blue, purple/yellow +- Add get_theme() lookup with error handling +- Add comprehensive unit tests" +``` + +--- + +## Chunk 2: Config Integration + +### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py + +**Files:** +- Modify: `engine/config.py:1-30` +- Test: `tests/test_config.py` (expand existing) + +- [ ] **Step 1: Write failing tests for config changes** + +Add to `tests/test_config.py`: + +```python +def test_active_theme_initially_none(): + """ACTIVE_THEME is None before initialization.""" + # This test may fail if config is already initialized + # We'll set it to None first for testing + import engine.config + engine.config.ACTIVE_THEME = None + assert engine.config.ACTIVE_THEME is None + + +def test_set_active_theme_green(): + """set_active_theme('green') sets ACTIVE_THEME to green theme.""" + from engine.config import set_active_theme + from engine.themes import get_theme + + set_active_theme("green") + + assert config.ACTIVE_THEME is not None + assert config.ACTIVE_THEME.name == "Verdant Green" + assert config.ACTIVE_THEME == get_theme("green") + + +def test_set_active_theme_default(): + """set_active_theme() with no args defaults to green.""" + from engine.config import set_active_theme + + set_active_theme() + + assert config.ACTIVE_THEME.name == "Verdant Green" + + +def test_set_active_theme_invalid(): + """set_active_theme() with invalid ID raises KeyError.""" + from engine.config import set_active_theme + + with pytest.raises(KeyError): + set_active_theme("invalid") +``` + +Run: `pytest tests/test_config.py -v` +Expected: FAIL (functions don't exist yet) + +- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py** + +Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function): + +```python +# ─── COLOR THEME ────────────────────────────────────────────────────────── +ACTIVE_THEME = None # set by set_active_theme() after picker + + +def set_active_theme(theme_id: str = "green"): + """Set the active color theme. Defaults to 'green' if not specified. + + Args: + theme_id: One of "green", "orange", "purple" + + Raises: + KeyError: If theme_id is invalid + """ + global ACTIVE_THEME + from engine import themes + ACTIVE_THEME = themes.get_theme(theme_id) +``` + +- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py** + +Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays): + +```python +# DELETED: +# GRAD_COLS = [...] +# MSG_GRAD_COLS = [...] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_config.py::test_active_theme_initially_none -v` +Run: `pytest tests/test_config.py::test_set_active_theme_green -v` +Run: `pytest tests/test_config.py::test_set_active_theme_default -v` +Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v` + +Expected: PASS (all 4 new tests) + +- [ ] **Step 5: Verify existing config tests still pass** + +Run: `pytest tests/test_config.py -v` + +Expected: PASS (all existing + new tests) + +- [ ] **Step 6: Commit** + +```bash +git add engine/config.py tests/test_config.py +git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config + +- Add ACTIVE_THEME global (initialized to None) +- Add set_active_theme(theme_id) function with green default +- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py) +- Add comprehensive tests for theme setting" +``` + +--- + +## Chunk 3: Render Pipeline Integration + +### Task 3: Update render.py to use config.ACTIVE_THEME + +**Files:** +- Modify: `engine/render.py:15-220` +- Test: `tests/test_render.py` (expand existing) + +- [ ] **Step 1: Write failing test for lr_gradient with theme** + +Add to `tests/test_render.py`: + +```python +def test_lr_gradient_uses_active_theme(monkeypatch): + """lr_gradient uses config.ACTIVE_THEME when cols=None.""" + from engine import config, render + from engine.themes import get_theme + + # Set orange theme + config.set_active_theme("orange") + + # Create simple rows + rows = ["test row"] + result = render.lr_gradient(rows, offset=0, cols=None) + + # Result should start with first color from orange main gradient + assert result[0].startswith("\033[1;38;5;231m") # white (same for all) + + +def test_lr_gradient_fallback_when_no_theme(monkeypatch): + """lr_gradient uses fallback when ACTIVE_THEME is None.""" + from engine import config, render + + # Clear active theme + config.ACTIVE_THEME = None + + rows = ["test row"] + result = render.lr_gradient(rows, offset=0, cols=None) + + # Should not crash and should return something + assert result is not None + assert len(result) > 0 + + +def test_default_green_gradient_length(): + """_default_green_gradient returns 12 colors.""" + from engine import render + + colors = render._default_green_gradient() + assert len(colors) == 12 +``` + +Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v` +Expected: FAIL (function signature doesn't match) + +- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME** + +Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it: + +```python +def lr_gradient(rows, offset, cols=None): + """ + Render rows through a left-to-right color sweep. + + Args: + rows: List of text rows to colorize + offset: Gradient position offset (for animation) + cols: Optional list of color codes. If None, uses active theme. + + Returns: + List of colorized rows + """ + if cols is None: + from engine import config + cols = ( + config.ACTIVE_THEME.main_gradient + if config.ACTIVE_THEME + else _default_green_gradient() + ) + + # ... rest of function unchanged ... +``` + +- [ ] **Step 3: Add _default_green_gradient() fallback function** + +Add to `engine/render.py` before `lr_gradient()`: + +```python +def _default_green_gradient(): + """Fallback green gradient (original colors) for initialization.""" + return [ + "\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 green + "\033[38;5;40m", # green + "\033[38;5;34m", # medium green + "\033[38;5;28m", # dark green + "\033[38;5;22m", # deep green + "\033[2;38;5;22m", # dim deep green + "\033[2;38;5;235m", # near black + ] + + +def _default_magenta_gradient(): + """Fallback magenta gradient (original message colors) for initialization.""" + return [ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;225m", # pale pink-white + "\033[38;5;219m", # bright pink + "\033[38;5;213m", # hot pink + "\033[38;5;207m", # magenta + "\033[38;5;201m", # bright magenta + "\033[38;5;165m", # orchid-red + "\033[38;5;161m", # ruby-magenta + "\033[38;5;125m", # dark magenta + "\033[38;5;89m", # deep maroon-magenta + "\033[2;38;5;89m", # dim deep maroon-magenta + "\033[2;38;5;235m", # near black + ] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v` +Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v` +Run: `pytest tests/test_render.py::test_default_green_gradient_length -v` + +Expected: PASS (all 3 new tests) + +- [ ] **Step 5: Run full render test suite** + +Run: `pytest tests/test_render.py -v` + +Expected: PASS (existing tests may need adjustment for mocking) + +- [ ] **Step 6: Commit** + +```bash +git add engine/render.py tests/test_render.py +git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME + +- Update lr_gradient(cols=None) to check config.ACTIVE_THEME +- Add _default_green_gradient() and _default_magenta_gradient() fallbacks +- Fallback used when ACTIVE_THEME is None (non-interactive init) +- Add tests for theme-aware and fallback gradient rendering" +``` + +--- + +## Chunk 4: Message Gradient Integration + +### Task 4: Update scroll.py to use message gradient from config + +**Files:** +- Modify: `engine/scroll.py:85-95` +- Test: existing `tests/test_scroll.py` + +- [ ] **Step 1: Locate message gradient calls in scroll.py** + +Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py` + +Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used + +- [ ] **Step 2: Update scroll.py to use theme message gradient** + +Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update: + +Old code: +```python +# Some variation of: +rows = lr_gradient(rows, offset, MSG_GRAD_COLS) +``` + +New code: +```python +from engine import config +msg_cols = ( + config.ACTIVE_THEME.message_gradient + if config.ACTIVE_THEME + else render._default_magenta_gradient() +) +rows = lr_gradient(rows, offset, msg_cols) +``` + +Or use the helper approach (create `msg_gradient()` in render.py): + +```python +def msg_gradient(rows, offset): + """Apply message (ntfy) gradient using theme complementary colors.""" + from engine import config + cols = ( + config.ACTIVE_THEME.message_gradient + if config.ACTIVE_THEME + else _default_magenta_gradient() + ) + return lr_gradient(rows, offset, cols) +``` + +Then in scroll.py: +```python +rows = render.msg_gradient(rows, offset) +``` + +- [ ] **Step 3: Run existing scroll tests** + +Run: `pytest tests/test_scroll.py -v` + +Expected: PASS (existing functionality unchanged) + +- [ ] **Step 4: Commit** + +```bash +git add engine/scroll.py engine/render.py +git commit -m "feat: update scroll.py to use theme message gradient + +- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient +- Use fallback magenta gradient when theme not initialized +- Ensure ntfy messages render in complementary color from selected theme" +``` + +--- + +## Chunk 5: Color Picker UI + +### Task 5: Create pick_color_theme() function in app.py + +**Files:** +- Modify: `engine/app.py:1-300` +- Test: manual/integration (interactive) + +- [ ] **Step 1: Write helper functions for color picker UI** + +Edit `engine/app.py`, add before `pick_font_face()` function: + +```python +def _draw_color_picker(themes_list, selected): + """Draw the color theme picker menu.""" + import sys + from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw + + print(CLR, end="") + print() + print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}") + print(f" {W_GHOST}{'─' * (tw() - 4)}\n") + + for i, (theme_id, theme) in enumerate(themes_list): + prefix = " ▶ " if i == selected else " " + color = G_HI if i == selected else "" + reset = "" if i == selected else W_GHOST + print(f"{prefix}{color}{theme.name}{reset}") + + print() +``` + +- [ ] **Step 2: Create pick_color_theme() function** + +Edit `engine/app.py`, add after helper function: + +```python +def pick_color_theme(): + """Interactive color theme picker. Defaults to 'green' if not TTY.""" + import sys + import termios + import tty + from engine import config, themes + + # Non-interactive fallback: use green + if not sys.stdin.isatty(): + config.set_active_theme("green") + return + + themes_list = list(themes.THEME_REGISTRY.items()) + selected = 0 + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + while True: + _draw_color_picker(themes_list, selected) + key = _read_picker_key() + if key == "up": + selected = max(0, selected - 1) + elif key == "down": + selected = min(len(themes_list) - 1, selected + 1) + elif key == "enter": + break + elif key == "interrupt": + raise KeyboardInterrupt + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + selected_theme_id = themes_list[selected][0] + config.set_active_theme(selected_theme_id) + + theme_name = themes_list[selected][1].name + print(f" {G_DIM}> using {theme_name}{RST}") + time.sleep(0.8) + print(CLR, end="") + print(CURSOR_OFF, end="") + print() +``` + +- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()** + +Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it: + +```python +def main(): + # ... existing signal handler setup ... + + pick_color_theme() # NEW LINE - before font picker + pick_font_face() + + # ... rest of main unchanged ... +``` + +- [ ] **Step 4: Manual test - run in interactive terminal** + +Run: `python3 mainline.py` + +Expected: +- See color theme picker menu before font picker +- Can navigate with ↑/↓ or j/k +- Can select with Enter or q +- Selected theme applies to scrolling headlines +- Can select different themes and see colors change + +- [ ] **Step 5: Manual test - run in non-interactive environment** + +Run: `echo "" | python3 mainline.py` + +Expected: +- No color picker menu shown +- Defaults to green theme +- App runs without error + +- [ ] **Step 6: Commit** + +```bash +git add engine/app.py +git commit -m "feat: add pick_color_theme() UI and integration + +- Create _draw_color_picker() to render menu +- Create pick_color_theme() function mirroring font picker pattern +- Integrate into main() before font picker +- Fallback to green theme in non-interactive environments +- Support arrow keys and j/k navigation" +``` + +--- + +## Chunk 6: Integration & Validation + +### Task 6: End-to-end testing and cleanup + +**Files:** +- Test: All modified files +- Verify: App functionality + +- [ ] **Step 1: Run full test suite** + +Run: `pytest tests/ -v` + +Expected: PASS (all tests, including new ones) + +- [ ] **Step 2: Run linter** + +Run: `ruff check engine/ mainline.py` + +Expected: No errors (fix any style issues) + +- [ ] **Step 3: Manual integration test - green theme** + +Run: `python3 mainline.py` + +Then select "Verdant Green" from picker. + +Expected: +- Headlines render in green → deep green +- ntfy messages render in magenta gradient +- Both work correctly during streaming + +- [ ] **Step 4: Manual integration test - orange theme** + +Run: `python3 mainline.py` + +Then select "Molten Orange" from picker. + +Expected: +- Headlines render in orange → deep orange +- ntfy messages render in blue gradient +- Colors are visually distinct from green + +- [ ] **Step 5: Manual integration test - purple theme** + +Run: `python3 mainline.py` + +Then select "Violet Purple" from picker. + +Expected: +- Headlines render in purple → deep purple +- ntfy messages render in yellow gradient +- Colors are visually distinct from green and orange + +- [ ] **Step 6: Test poetry mode with color picker** + +Run: `python3 mainline.py --poetry` + +Then select "orange" from picker. + +Expected: +- Poetry mode works with color picker +- Colors apply to poetry rendering + +- [ ] **Step 7: Test code mode with color picker** + +Run: `python3 mainline.py --code` + +Then select "purple" from picker. + +Expected: +- Code mode works with color picker +- Colors apply to code rendering + +- [ ] **Step 8: Verify acceptance criteria** + +✓ Color picker displays 3 theme options at startup +✓ Selection applies to all headline and message gradients +✓ Boot UI (title, status) uses hardcoded green (not theme) +✓ Scrolling headlines and ntfy messages use theme gradients +✓ No persistence between runs (each run picks fresh) +✓ Non-TTY environments default to green without error +✓ Architecture supports future random/animation modes +✓ All gradient color codes finalized with no TBD values + +- [ ] **Step 9: Final commit** + +```bash +git add -A +git commit -m "feat: color scheme switcher implementation complete + +Closes color-pick feature with: +- Three selectable color themes (green, orange, purple) +- Interactive menu at startup (mirrors font picker UI) +- Complementary colors for ntfy message queue +- Fallback to green in non-interactive environments +- All tests passing, manual validation complete" +``` + +- [ ] **Step 10: Create feature branch PR summary** + +``` +## Color Scheme Switcher + +Implements interactive color theme selection for Mainline news ticker. + +### What's New +- 3 color themes: Verdant Green, Molten Orange, Violet Purple +- Interactive picker at startup (↑/↓ or j/k, Enter to select) +- Complementary gradients for ntfy messages (magenta, blue, yellow) +- Fresh theme selection each run (no persistence) + +### Files Changed +- `engine/themes.py` (new) +- `engine/config.py` (ACTIVE_THEME, set_active_theme) +- `engine/render.py` (theme-aware gradients) +- `engine/scroll.py` (message gradient integration) +- `engine/app.py` (pick_color_theme UI) +- `tests/test_themes.py` (new theme tests) +- `README.md` (documentation) + +### Acceptance Criteria +All met. App fully tested and ready for merge. +``` + +--- + +## Testing Checklist + +- [ ] Unit tests: `pytest tests/test_themes.py -v` +- [ ] Unit tests: `pytest tests/test_config.py -v` +- [ ] Unit tests: `pytest tests/test_render.py -v` +- [ ] Full suite: `pytest tests/ -v` +- [ ] Linting: `ruff check engine/ mainline.py` +- [ ] Manual: Green theme selection +- [ ] Manual: Orange theme selection +- [ ] Manual: Purple theme selection +- [ ] Manual: Poetry mode with colors +- [ ] Manual: Code mode with colors +- [ ] Manual: Non-TTY fallback + +--- + +## Notes + +- `themes.py` is data-only; never import config or render to prevent cycles +- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme() +- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme) +- Message gradients use complementary colors; lookup in scroll.py +- Each gradient has 12 colors; verify length in tests +- No persistence; fresh picker each run