# Color Scheme Switcher Design **Date:** 2026-03-16 **Status:** Revised after review **Scope:** Interactive color theme selection for Mainline news ticker --- ## Overview Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors. The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes. --- ## Requirements **Functional:** 1. User selects a color theme from an interactive menu at startup (green, orange, or purple) 2. Main headline gradient uses the selected primary color (white → color) 3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite) 4. Selection is fresh each run (no persistence) 5. Design supports future "random rotation" mode without refactoring **Complementary colors (precise opposites):** - Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)* - Orange (38;5;208) → Blue (38;5;21) - Purple (38;5;129) → Yellow (38;5;226) **Non-functional:** - Reuse the existing font picker pattern for UI consistency - Zero runtime overhead during streaming (theme lookup happens once at startup) - **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 --- ## Architecture ### 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.""" def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]): self.name = name self.main_gradient = main_gradient # white → primary color self.message_gradient = message_gradient # white → complementary ``` **Theme Registry:** 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 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 KeyError if not found - `THEME_REGISTRY` — dict of all available themes (for picker) --- ### Modified: `engine/config.py` **New globals:** ```python 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 = "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` constants --- ### Modified: `engine/render.py` **Updated gradient access in existing functions:** Current pattern (will be removed): ```python GRAD_COLS = [...] # hardcoded green MSG_GRAD_COLS = [...] # hardcoded magenta ``` 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_green_gradient()) # ... rest of function unchanged ``` **Define fallback:** ```python 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 cols = (config.ACTIVE_THEME.message_gradient if config.ACTIVE_THEME else _default_magenta_gradient()) return lr_gradient(rows, offset, cols) ``` --- ### Modified: `engine/app.py` **New function: `pick_color_theme()`** Mirrors `pick_font_face()` pattern: ```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 def main(): # ... signal handler setup ... pick_color_theme() # NEW — before title/subtitle pick_font_face() # ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ... ``` **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. --- ## Data Flow ``` User starts: mainline.py ↓ main() called ↓ pick_color_theme() → 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 in hardcoded green UI colors ↓ Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients) ↓ stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients ↓ On exit: no persistence ``` --- ## Implementation Notes ### 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 ### 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. ### Color Code Finalization All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain. ### Theme ID Naming IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity. ### Terminal Resize Handling The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because: 1. The picker completes quickly (< 5 seconds typical interaction) 2. Once a theme is selected, the menu closes and rendering begins 3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions No special resize handling is needed for the color picker beyond what exists for the font picker. ### 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` (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 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