# 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