From f085042deeaf8b2265f372c8046eaf13d45872a7 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:40:32 -0700 Subject: [PATCH 01/11] docs: add color scheme switcher design spec --- .../specs/2026-03-16-color-scheme-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-color-scheme-design.md diff --git a/docs/superpowers/specs/2026-03-16-color-scheme-design.md b/docs/superpowers/specs/2026-03-16-color-scheme-design.md new file mode 100644 index 0000000..beebeb1 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-color-scheme-design.md @@ -0,0 +1,204 @@ +# Color Scheme Switcher Design + +**Date:** 2026-03-16 +**Status:** Draft +**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 messages and title render in the selected theme + +--- + +## Architecture + +### New Module: `engine/themes.py` + +```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: `"verdant"`, `"molten"`, `"violet"`. + +Each gradient is a list of 12 ANSI 256-color codes matching the current format in `render.py`: +``` +[ + "\033[1;38;5;231m", # white (bold) + "\033[1;38;5;195m", # pale white-tint + ..., + "\033[2;38;5;235m", # near black +] +``` + +**Public API:** +- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises 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 +``` + +**New function:** +```python +def set_active_theme(theme_id: str): + """Set the active theme after user selection.""" + global ACTIVE_THEME + from engine import themes + ACTIVE_THEME = themes.get_theme(theme_id) +``` + +**Removal:** +- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` — these move to `themes.py` + +--- + +### Modified: `engine/render.py` + +**Updated gradient access:** + +Old pattern: +```python +def lr_gradient(rows, offset, cols=None): + cols = cols or GRAD_COLS # module-level constant +``` + +New pattern: +```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 + # ... rest of function unchanged +``` + +**Fallback:** Define `_DEFAULT_GRAD` (current green) for cases where theme isn't selected yet. + +**Message gradient lookup:** +```python +def msg_gradient_rows(rows, offset): + from engine import config + return lr_gradient(rows, offset, config.ACTIVE_THEME.message_gradient) +``` + +--- + +### Modified: `engine/app.py` + +**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)` + +**Placement in `main()`:** +```python +def main(): + # ... signal handler setup ... + pick_color_theme() # NEW — before title/subtitle + pick_font_face() + # ... rest of boot sequence ... +``` + +This ensures boot messages (title, subtitle, status lines) render in the selected theme color. + +--- + +## Data Flow + +``` +User starts: mainline.py + ↓ +main() called + ↓ +pick_color_theme() + → Display menu + → Read user input + → config.set_active_theme(theme_id) + ↓ +pick_font_face() — renders with active theme + ↓ +Boot messages (title, status, etc.) — all use active theme + ↓ +stream() — news + messages use active theme gradients + ↓ +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. + +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) + +### 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) + +The picker can offer a "Random" option without any code changes to render or config. + +### 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) + +--- + +## 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/app.py` (add `pick_color_theme()`, call in main) +- `tests/test_themes.py` (new unit tests) + +## 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 From 667bef268588b960ed5b6fe6383c60ce8c2f9e17 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:42:19 -0700 Subject: [PATCH 02/11] docs: revise color scheme design spec to address review feedback - Clarify boot messages use hardcoded green, not theme gradients - Finalize all gradient ANSI color codes (no TBD) - Add initialization guarantee for ACTIVE_THEME - Resolve circular import risk with data-only themes.py - Update theme ID naming to match menu labels - Expand test strategy and mock approach --- .../specs/2026-03-16-color-scheme-design.md | 191 +++++++++++++----- 1 file changed, 139 insertions(+), 52 deletions(-) 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 From 9d9172ef0d358f93fe02d4cf051c5a7a95bdf5d1 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:42:59 -0700 Subject: [PATCH 03/11] docs: add terminal resize handling clarification --- docs/superpowers/specs/2026-03-16-color-scheme-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 27bdf2e..56120b5 100644 --- a/docs/superpowers/specs/2026-03-16-color-scheme-design.md +++ b/docs/superpowers/specs/2026-03-16-color-scheme-design.md @@ -255,6 +255,14 @@ All three gradient sequences (green, orange, purple main + complementary) are no ### 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 From 6daea90b0ae5f406e6d2b99dd7928c42ebec74ce Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:44:59 -0700 Subject: [PATCH 04/11] docs: add color scheme feature documentation to README - Update opening description to mention selectable color gradients - Add new 'Color Schemes' section with picker usage instructions - Document three available themes (Green, Orange, Purple) - Clarify that boot UI uses hardcoded green, not theme colors --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a8c8f5c..47aad1b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.* -A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). +A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). --- @@ -68,6 +68,21 @@ Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The select To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face. +### Color Schemes + +Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively). + +On startup, an interactive picker presents all available color schemes: +``` + [1] Verdant Green (white-hot → deep green) + [2] Molten Orange (white-hot → deep orange) + [3] Violet Purple (white-hot → deep purple) +``` + +Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run. + +**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient. + ### ntfy.sh Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes. From d9422b1fec07ea8947374dedf42b67487a734873 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:47:25 -0700 Subject: [PATCH 05/11] 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 From abc4483859c7b448ae014889f04ca15ec8a8eb79 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:49:15 -0700 Subject: [PATCH 06/11] feat: create Theme class and registry with finalized color gradients --- engine/themes.py | 60 +++++++++++++++ tests/test_themes.py | 169 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 engine/themes.py create mode 100644 tests/test_themes.py diff --git a/engine/themes.py b/engine/themes.py new file mode 100644 index 0000000..a6d3432 --- /dev/null +++ b/engine/themes.py @@ -0,0 +1,60 @@ +""" +Theme definitions with color gradients for terminal rendering. + +This module is data-only and does not import config or render +to prevent circular dependencies. +""" + + +class Theme: + """Represents a color theme with two gradients.""" + + def __init__(self, name, main_gradient, message_gradient): + """Initialize a theme with name and color gradients. + + Args: + name: Theme identifier string + main_gradient: List of 12 ANSI 256-color codes for main gradient + message_gradient: List of 12 ANSI 256-color codes for message gradient + """ + self.name = name + self.main_gradient = main_gradient + self.message_gradient = message_gradient + + +# ─── GRADIENT DEFINITIONS ───────────────────────────────────────────────── +# Each gradient is 12 ANSI 256-color codes in sequence +# Format: [light...] → [medium...] → [dark...] → [black] + +_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235] +_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235] + +_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235] +_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235] + +_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235] +_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235] + + +# ─── THEME REGISTRY ─────────────────────────────────────────────────────── + +THEME_REGISTRY = { + "green": Theme("green", _GREEN_MAIN, _GREEN_MSG), + "orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG), + "purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG), +} + + +def get_theme(theme_id): + """Retrieve a theme by ID. + + Args: + theme_id: Theme identifier string + + Returns: + Theme object matching the ID + + Raises: + KeyError: If theme_id is not in registry + """ + return THEME_REGISTRY[theme_id] diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..f6bbdf3 --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,169 @@ +""" +Tests for engine.themes module. +""" + +import pytest + +from engine import themes + + +class TestThemeConstruction: + """Tests for Theme class initialization.""" + + def test_theme_construction(self): + """Theme stores name and gradients correctly.""" + main_grad = ["color1", "color2", "color3"] + msg_grad = ["msg1", "msg2", "msg3"] + theme = themes.Theme("test_theme", main_grad, msg_grad) + + assert theme.name == "test_theme" + assert theme.main_gradient == main_grad + assert theme.message_gradient == msg_grad + + +class TestGradientLength: + """Tests for gradient length validation.""" + + def test_gradient_length_green(self): + """Green theme has exactly 12 colors in each gradient.""" + green = themes.THEME_REGISTRY["green"] + assert len(green.main_gradient) == 12 + assert len(green.message_gradient) == 12 + + def test_gradient_length_orange(self): + """Orange theme has exactly 12 colors in each gradient.""" + orange = themes.THEME_REGISTRY["orange"] + assert len(orange.main_gradient) == 12 + assert len(orange.message_gradient) == 12 + + def test_gradient_length_purple(self): + """Purple theme has exactly 12 colors in each gradient.""" + purple = themes.THEME_REGISTRY["purple"] + assert len(purple.main_gradient) == 12 + assert len(purple.message_gradient) == 12 + + +class TestThemeRegistry: + """Tests for THEME_REGISTRY dictionary.""" + + def test_theme_registry_has_three_themes(self): + """Registry contains exactly three themes: green, orange, purple.""" + assert len(themes.THEME_REGISTRY) == 3 + assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"} + + def test_registry_values_are_themes(self): + """All registry values are Theme instances.""" + for theme_id, theme in themes.THEME_REGISTRY.items(): + assert isinstance(theme, themes.Theme) + assert theme.name == theme_id + + +class TestGetTheme: + """Tests for get_theme function.""" + + def test_get_theme_valid_green(self): + """get_theme('green') returns correct green Theme.""" + green = themes.get_theme("green") + assert isinstance(green, themes.Theme) + assert green.name == "green" + + def test_get_theme_valid_orange(self): + """get_theme('orange') returns correct orange Theme.""" + orange = themes.get_theme("orange") + assert isinstance(orange, themes.Theme) + assert orange.name == "orange" + + def test_get_theme_valid_purple(self): + """get_theme('purple') returns correct purple Theme.""" + purple = themes.get_theme("purple") + assert isinstance(purple, themes.Theme) + assert purple.name == "purple" + + def test_get_theme_invalid(self): + """get_theme with invalid ID raises KeyError.""" + with pytest.raises(KeyError): + themes.get_theme("invalid_theme") + + def test_get_theme_invalid_none(self): + """get_theme with None raises KeyError.""" + with pytest.raises(KeyError): + themes.get_theme(None) + + +class TestGreenTheme: + """Tests for green theme specific values.""" + + def test_green_theme_unchanged(self): + """Green theme maintains original color sequence.""" + green = themes.get_theme("green") + + # Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235 + expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235] + # Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235 + expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235] + + assert green.main_gradient == expected_main + assert green.message_gradient == expected_msg + + def test_green_theme_name(self): + """Green theme has correct name.""" + green = themes.get_theme("green") + assert green.name == "green" + + +class TestOrangeTheme: + """Tests for orange theme specific values.""" + + def test_orange_theme_unchanged(self): + """Orange theme maintains original color sequence.""" + orange = themes.get_theme("orange") + + # Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235 + expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235] + # Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235 + expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235] + + assert orange.main_gradient == expected_main + assert orange.message_gradient == expected_msg + + def test_orange_theme_name(self): + """Orange theme has correct name.""" + orange = themes.get_theme("orange") + assert orange.name == "orange" + + +class TestPurpleTheme: + """Tests for purple theme specific values.""" + + def test_purple_theme_unchanged(self): + """Purple theme maintains original color sequence.""" + purple = themes.get_theme("purple") + + # Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235 + expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235] + # Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235 + expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235] + + assert purple.main_gradient == expected_main + assert purple.message_gradient == expected_msg + + def test_purple_theme_name(self): + """Purple theme has correct name.""" + purple = themes.get_theme("purple") + assert purple.name == "purple" + + +class TestThemeDataOnly: + """Tests to ensure themes module has no problematic imports.""" + + def test_themes_module_imports(self): + """themes module should be data-only without config/render imports.""" + import inspect + source = inspect.getsource(themes) + # Verify no imports of config or render (look for actual import statements) + lines = source.split('\n') + import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')] + # Filter out empty and comment lines + import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')] + # Should have no import lines + assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}" From ebe7b04ba58a28e8c6f4af9637edbf08e7eec56b Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:50:36 -0700 Subject: [PATCH 07/11] feat: add ACTIVE_THEME global and set_active_theme() to config --- engine/config.py | 23 +++++++++++++++++++ tests/test_config.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/engine/config.py b/engine/config.py index 1de24e5..5b045db 100644 --- a/engine/config.py +++ b/engine/config.py @@ -104,3 +104,26 @@ def set_font_selection(font_path=None, font_index=None): FONT_PATH = _resolve_font_path(font_path) if font_index is not None: FONT_INDEX = max(0, int(font_index)) + + +# ─── THEME MANAGEMENT ───────────────────────────────────────── +ACTIVE_THEME = None + + +def set_active_theme(theme_id: str = "green"): + """Set the active theme by ID. + + Args: + theme_id: Theme identifier ("green", "orange", or "purple") + Defaults to "green" + + Raises: + KeyError: If theme_id is not in the theme registry + + Side Effects: + Sets the ACTIVE_THEME global variable + """ + global ACTIVE_THEME + from engine import themes + + ACTIVE_THEME = themes.get_theme(theme_id) diff --git a/tests/test_config.py b/tests/test_config.py index 9cf6c1c..79a8c28 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,8 @@ import tempfile from pathlib import Path from unittest.mock import patch +import pytest + from engine import config @@ -160,3 +162,55 @@ class TestSetFontSelection: config.set_font_selection(font_path=None, font_index=None) assert original_path == config.FONT_PATH assert original_index == config.FONT_INDEX + + +class TestActiveTheme: + """Tests for ACTIVE_THEME global and set_active_theme function.""" + + def test_active_theme_initially_none(self): + """ACTIVE_THEME should be None at module start.""" + # Reset to None to test initial state + original = config.ACTIVE_THEME + config.ACTIVE_THEME = None + try: + assert config.ACTIVE_THEME is None + finally: + config.ACTIVE_THEME = original + + def test_set_active_theme_green(self): + """Setting green theme works correctly.""" + config.set_active_theme("green") + assert config.ACTIVE_THEME is not None + assert config.ACTIVE_THEME.name == "green" + assert len(config.ACTIVE_THEME.main_gradient) == 12 + assert len(config.ACTIVE_THEME.message_gradient) == 12 + + def test_set_active_theme_default(self): + """Default theme is green when not specified.""" + config.set_active_theme() + assert config.ACTIVE_THEME is not None + assert config.ACTIVE_THEME.name == "green" + + def test_set_active_theme_invalid(self): + """Invalid theme_id raises KeyError.""" + with pytest.raises(KeyError): + config.set_active_theme("nonexistent") + + def test_set_active_theme_all_themes(self): + """Verify orange and purple themes work.""" + # Test orange + config.set_active_theme("orange") + assert config.ACTIVE_THEME.name == "orange" + + # Test purple + config.set_active_theme("purple") + assert config.ACTIVE_THEME.name == "purple" + + def test_set_active_theme_idempotent(self): + """Calling set_active_theme multiple times works.""" + config.set_active_theme("green") + first_theme = config.ACTIVE_THEME + config.set_active_theme("green") + second_theme = config.ACTIVE_THEME + assert first_theme.name == second_theme.name + assert first_theme.name == "green" From d67423fe4c696268dd87f0f6bdf3a85ea494588a Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:53:22 -0700 Subject: [PATCH 08/11] feat: update lr_gradient to use config.ACTIVE_THEME - Remove hardcoded GRAD_COLS and MSG_GRAD_COLS module constants - Add _default_green_gradient() and _default_magenta_gradient() fallback functions - Add _color_codes_to_ansi() to convert integer color codes from themes to ANSI escape strings - Update lr_gradient() signature: cols parameter (was grad_cols) - lr_gradient() now pulls colors from config.ACTIVE_THEME when available - Falls back to default green gradient when no theme is active - Existing calls with explicit cols parameter continue to work - Add comprehensive tests for new functionality Co-Authored-By: Claude Haiku 4.5 --- engine/render.py | 104 ++++++++++++++++++-------- tests/test_render.py | 174 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 33 deletions(-) create mode 100644 tests/test_render.py diff --git a/engine/render.py b/engine/render.py index 4b24eef..8773c90 100644 --- a/engine/render.py +++ b/engine/render.py @@ -16,37 +16,69 @@ from engine.terminal import RST from engine.translate import detect_location_language, translate_headline # ─── GRADIENT ───────────────────────────────────────────── -# Left → right: white-hot leading edge fades to near-black -GRAD_COLS = [ - "\033[1;38;5;231m", # white - "\033[1;38;5;195m", # pale cyan-white - "\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 _color_codes_to_ansi(color_codes): + """Convert a list of 256-color codes to ANSI escape code strings. -# Complementary sweep for queue messages (opposite hue family from ticker greens) -MSG_GRAD_COLS = [ - "\033[1;38;5;231m", # white - "\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 -] + Pattern: first 2 are bold, middle 8 are normal, last 2 are dim. + + Args: + color_codes: List of 12 integers (256-color palette codes) + + Returns: + List of ANSI escape code strings + """ + if not color_codes or len(color_codes) != 12: + # Fallback to default green if invalid + return _default_green_gradient() + + result = [] + for i, code in enumerate(color_codes): + if i < 2: + # Bold for first 2 (bright leading edge) + result.append(f"\033[1;38;5;{code}m") + elif i < 10: + # Normal for middle 8 + result.append(f"\033[38;5;{code}m") + else: + # Dim for last 2 (dark trailing edge) + result.append(f"\033[2;38;5;{code}m") + return result + + +def _default_green_gradient(): + """Return the default 12-color green gradient for fallback when no theme is active.""" + return [ + "\033[1;38;5;231m", # white + "\033[1;38;5;195m", # pale cyan-white + "\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(): + """Return the default 12-color magenta gradient for fallback when no theme is active.""" + return [ + "\033[1;38;5;231m", # white + "\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 + ] # ─── FONT LOADING ───────────────────────────────────────── _FONT_OBJ = None @@ -189,9 +221,15 @@ def big_wrap(text, max_w, fnt=None): return out -def lr_gradient(rows, offset=0.0, grad_cols=None): +def lr_gradient(rows, offset=0.0, cols=None): """Color each non-space block character with a shifting left-to-right gradient.""" - cols = grad_cols or GRAD_COLS + if cols is None: + from engine import config + + if config.ACTIVE_THEME: + cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient) + else: + cols = _default_green_gradient() n = len(cols) max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1) out = [] @@ -213,7 +251,7 @@ def lr_gradient(rows, offset=0.0, grad_cols=None): def lr_gradient_opposite(rows, offset=0.0): """Complementary (opposite wheel) gradient used for queue message panels.""" - return lr_gradient(rows, offset, MSG_GRAD_COLS) + return lr_gradient(rows, offset, _default_magenta_gradient()) # ─── HEADLINE BLOCK ASSEMBLY ───────────────────────────── diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..5482853 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,174 @@ +""" +Tests for engine.render module. +""" + +import pytest + +from engine import config, render + + +class TestDefaultGradients: + """Tests for default gradient fallback functions.""" + + def test_default_green_gradient_length(self): + """_default_green_gradient returns 12 colors.""" + gradient = render._default_green_gradient() + assert len(gradient) == 12 + + def test_default_green_gradient_is_list(self): + """_default_green_gradient returns a list.""" + gradient = render._default_green_gradient() + assert isinstance(gradient, list) + + def test_default_green_gradient_all_strings(self): + """_default_green_gradient returns list of ANSI code strings.""" + gradient = render._default_green_gradient() + assert all(isinstance(code, str) for code in gradient) + + def test_default_magenta_gradient_length(self): + """_default_magenta_gradient returns 12 colors.""" + gradient = render._default_magenta_gradient() + assert len(gradient) == 12 + + def test_default_magenta_gradient_is_list(self): + """_default_magenta_gradient returns a list.""" + gradient = render._default_magenta_gradient() + assert isinstance(gradient, list) + + def test_default_magenta_gradient_all_strings(self): + """_default_magenta_gradient returns list of ANSI code strings.""" + gradient = render._default_magenta_gradient() + assert all(isinstance(code, str) for code in gradient) + + +class TestLrGradientUsesActiveTheme: + """Tests for lr_gradient using active theme.""" + + def test_lr_gradient_uses_active_theme_when_cols_none(self): + """lr_gradient uses ACTIVE_THEME.main_gradient when cols=None.""" + # Save original state + original_theme = config.ACTIVE_THEME + + try: + # Set a theme + config.set_active_theme("green") + + # Create simple test data + rows = ["text"] + + # Call without cols parameter (cols=None) + result = render.lr_gradient(rows, offset=0.0) + + # Should not raise and should return colored output + assert isinstance(result, list) + assert len(result) == 1 + # Should have ANSI codes (no plain "text") + assert result[0] != "text" + finally: + # Restore original state + config.ACTIVE_THEME = original_theme + + def test_lr_gradient_fallback_when_no_theme(self): + """lr_gradient uses fallback green when ACTIVE_THEME is None.""" + # Save original state + original_theme = config.ACTIVE_THEME + + try: + # Clear the theme + config.ACTIVE_THEME = None + + # Create simple test data + rows = ["text"] + + # Call without cols parameter (should use fallback) + result = render.lr_gradient(rows, offset=0.0) + + # Should not raise and should return colored output + assert isinstance(result, list) + assert len(result) == 1 + # Should have ANSI codes (no plain "text") + assert result[0] != "text" + finally: + # Restore original state + config.ACTIVE_THEME = original_theme + + def test_lr_gradient_explicit_cols_parameter_still_works(self): + """lr_gradient with explicit cols parameter overrides theme.""" + # Custom gradient + custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6 + + rows = ["xy"] + result = render.lr_gradient(rows, offset=0.0, cols=custom_cols) + + # Should use the provided cols + assert isinstance(result, list) + assert len(result) == 1 + + def test_lr_gradient_respects_cols_parameter_name(self): + """lr_gradient accepts cols as keyword argument.""" + custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6 + + rows = ["xy"] + # Call with cols as keyword + result = render.lr_gradient(rows, offset=0.0, cols=custom_cols) + + assert isinstance(result, list) + + +class TestLrGradientBasicFunctionality: + """Tests to ensure lr_gradient basic functionality still works.""" + + def test_lr_gradient_colors_non_space_chars(self): + """lr_gradient colors non-space characters.""" + rows = ["hello"] + + # Set a theme for the test + original_theme = config.ACTIVE_THEME + try: + config.set_active_theme("green") + result = render.lr_gradient(rows, offset=0.0) + + # Result should have ANSI codes + assert any("\033[" in r for r in result), "Expected ANSI codes in result" + finally: + config.ACTIVE_THEME = original_theme + + def test_lr_gradient_preserves_spaces(self): + """lr_gradient preserves spaces in output.""" + rows = ["a b c"] + + original_theme = config.ACTIVE_THEME + try: + config.set_active_theme("green") + result = render.lr_gradient(rows, offset=0.0) + + # Spaces should be preserved (not colored) + assert " " in result[0] + finally: + config.ACTIVE_THEME = original_theme + + def test_lr_gradient_empty_rows(self): + """lr_gradient handles empty rows correctly.""" + rows = [""] + + original_theme = config.ACTIVE_THEME + try: + config.set_active_theme("green") + result = render.lr_gradient(rows, offset=0.0) + + assert result == [""] + finally: + config.ACTIVE_THEME = original_theme + + def test_lr_gradient_multiple_rows(self): + """lr_gradient handles multiple rows.""" + rows = ["row1", "row2", "row3"] + + original_theme = config.ACTIVE_THEME + try: + config.set_active_theme("green") + result = render.lr_gradient(rows, offset=0.0) + + assert len(result) == 3 + finally: + config.ACTIVE_THEME = original_theme From 84cb16d46304a3f0bc11373da7bbf343f89c961d Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:55:17 -0700 Subject: [PATCH 09/11] feat: update scroll.py to use theme message gradient Add msg_gradient() helper function to render.py that applies message (ntfy) gradient using the active theme's message_gradient property. This replaces hardcoded magenta gradient in scroll.py with a theme-aware approach that uses complementary colors from the active theme. - Add msg_gradient(rows, offset) helper in render.py with fallback to default magenta gradient when no theme is active - Update scroll.py imports to use msg_gradient instead of lr_gradient_opposite - Replace lr_gradient_opposite() call with msg_gradient() in message overlay rendering - Add 6 comprehensive tests for msg_gradient covering theme usage, fallback behavior, and edge cases All tests pass (121 passed), no regressions detected. Co-Authored-By: Claude Haiku 4.5 --- engine/render.py | 23 ++++++++ engine/scroll.py | 4 +- tests/test_render.py | 127 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/engine/render.py b/engine/render.py index 8773c90..ccf1e12 100644 --- a/engine/render.py +++ b/engine/render.py @@ -254,6 +254,29 @@ def lr_gradient_opposite(rows, offset=0.0): return lr_gradient(rows, offset, _default_magenta_gradient()) +def msg_gradient(rows, offset): + """Apply message (ntfy) gradient using theme complementary colors. + + Returns colored rows using ACTIVE_THEME.message_gradient if available, + falling back to default magenta if no theme is set. + + Args: + rows: List of text strings to colorize + offset: Gradient offset (0.0-1.0) for animation + + Returns: + List of rows with ANSI color codes applied + """ + from engine import config + + cols = ( + _color_codes_to_ansi(config.ACTIVE_THEME.message_gradient) + if config.ACTIVE_THEME + else _default_magenta_gradient() + ) + return lr_gradient(rows, offset, cols) + + # ─── HEADLINE BLOCK ASSEMBLY ───────────────────────────── def make_block(title, src, ts, w): """Render a headline into a content block with color.""" diff --git a/engine/scroll.py b/engine/scroll.py index 810fe9f..b50b793 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -18,7 +18,7 @@ from engine.effects import ( noise, vis_trunc, ) -from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block +from engine.render import big_wrap, lr_gradient, msg_gradient, make_block from engine.terminal import CLR, RST, W_COOL, th, tw @@ -86,7 +86,7 @@ def stream(items, ntfy_poller, mic_monitor): _msg_cache = (cache_key, msg_rows) else: msg_rows = _msg_cache[1] - msg_rows = lr_gradient_opposite( + msg_rows = msg_gradient( msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0 ) # Layout: rendered text + meta + border diff --git a/tests/test_render.py b/tests/test_render.py index 5482853..20eb63e 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -172,3 +172,130 @@ class TestLrGradientBasicFunctionality: assert len(result) == 3 finally: config.ACTIVE_THEME = original_theme + + +class TestMsgGradient: + """Tests for msg_gradient function (message/ntfy overlay coloring).""" + + def test_msg_gradient_uses_active_theme(self): + """msg_gradient uses ACTIVE_THEME.message_gradient when theme is set.""" + # Save original state + original_theme = config.ACTIVE_THEME + + try: + # Set a theme + config.set_active_theme("green") + + # Create simple test data + rows = ["MESSAGE"] + + # Call msg_gradient + result = render.msg_gradient(rows, offset=0.0) + + # Should return colored output using theme's message_gradient + assert isinstance(result, list) + assert len(result) == 1 + # Should have ANSI codes from the message gradient + assert result[0] != "MESSAGE" + assert "\033[" in result[0] + finally: + # Restore original state + config.ACTIVE_THEME = original_theme + + def test_msg_gradient_fallback_when_no_theme(self): + """msg_gradient uses fallback magenta when ACTIVE_THEME is None.""" + # Save original state + original_theme = config.ACTIVE_THEME + + try: + # Clear the theme + config.ACTIVE_THEME = None + + # Create simple test data + rows = ["MESSAGE"] + + # Call msg_gradient + result = render.msg_gradient(rows, offset=0.0) + + # Should return colored output using default magenta + assert isinstance(result, list) + assert len(result) == 1 + # Should have ANSI codes + assert result[0] != "MESSAGE" + assert "\033[" in result[0] + finally: + # Restore original state + config.ACTIVE_THEME = original_theme + + def test_msg_gradient_returns_colored_rows(self): + """msg_gradient returns properly colored rows with animation offset.""" + # Save original state + original_theme = config.ACTIVE_THEME + + try: + # Set a theme + config.set_active_theme("orange") + + rows = ["NTFY", "ALERT"] + + # Call with offset + result = render.msg_gradient(rows, offset=0.5) + + # Should return same number of rows + assert len(result) == 2 + # Both should be colored + assert all("\033[" in r for r in result) + # Should not be the original text + assert result != rows + finally: + config.ACTIVE_THEME = original_theme + + def test_msg_gradient_different_themes_produce_different_results(self): + """msg_gradient produces different colors for different themes.""" + original_theme = config.ACTIVE_THEME + + try: + rows = ["TEST"] + + # Get result with green theme + config.set_active_theme("green") + result_green = render.msg_gradient(rows, offset=0.0) + + # Get result with orange theme + config.set_active_theme("orange") + result_orange = render.msg_gradient(rows, offset=0.0) + + # Results should be different (different message gradients) + assert result_green != result_orange + finally: + config.ACTIVE_THEME = original_theme + + def test_msg_gradient_preserves_spacing(self): + """msg_gradient preserves spaces in rows.""" + original_theme = config.ACTIVE_THEME + + try: + config.set_active_theme("purple") + rows = ["M E S S A G E"] + + result = render.msg_gradient(rows, offset=0.0) + + # Spaces should be preserved + assert " " in result[0] + finally: + config.ACTIVE_THEME = original_theme + + def test_msg_gradient_empty_rows(self): + """msg_gradient handles empty rows correctly.""" + original_theme = config.ACTIVE_THEME + + try: + config.set_active_theme("green") + rows = [""] + + result = render.msg_gradient(rows, offset=0.0) + + # Empty row should stay empty + assert result == [""] + finally: + config.ACTIVE_THEME = original_theme From d4d0344a12ed9c1fab070e09c5c2552451cf37fc Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 02:58:18 -0700 Subject: [PATCH 10/11] feat: add pick_color_theme() UI and integration --- engine/app.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/engine/app.py b/engine/app.py index 6c22f25..1382692 100644 --- a/engine/app.py +++ b/engine/app.py @@ -10,7 +10,7 @@ import termios import time import tty -from engine import config, render +from engine import config, render, themes from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache from engine.mic import MicMonitor from engine.ntfy import NtfyPoller @@ -65,6 +65,28 @@ def _read_picker_key(): return None +def _draw_color_picker(themes_list, selected): + """Draw the color theme picker menu. + + Args: + themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items() + selected: Index of currently selected theme (0-2) + """ + print(CLR, end="") + print() + + print(f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}") + print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\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() + + def _normalize_preview_rows(rows): """Trim shared left padding and trailing spaces for stable on-screen previews.""" non_empty = [r for r in rows if r.strip()] @@ -131,6 +153,50 @@ def _draw_font_picker(faces, selected): print(f" {shown}") +def pick_color_theme(): + """Interactive color theme picker. Defaults to 'green' if not TTY. + + Displays a menu of available themes and lets user select with arrow keys. + Non-interactive environments (piped stdin, CI) silently default to green. + """ + # Non-interactive fallback + if not sys.stdin.isatty(): + config.set_active_theme("green") + return + + # Interactive picker + 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() + + def pick_font_face(): """Interactive startup picker for selecting a face from repo OTF files.""" if not config.FONT_PICKER: @@ -262,6 +328,7 @@ def main(): w = tw() print(CLR, end="") print(CURSOR_OFF, end="") + pick_color_theme() pick_font_face() w = tw() print() From bc20a35ea9584abe53b51edc56dcae7e6e064ab6 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Mon, 16 Mar 2026 03:01:22 -0700 Subject: [PATCH 11/11] refactor: Fix import ordering and code formatting with ruff Organize imports in render.py and scroll.py to meet ruff style requirements. Add blank lines for code formatting compliance. --- engine/app.py | 7 +++++-- engine/config.py | 6 ++++-- engine/render.py | 2 ++ engine/scroll.py | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/engine/app.py b/engine/app.py index 1382692..10eea6d 100644 --- a/engine/app.py +++ b/engine/app.py @@ -75,7 +75,9 @@ def _draw_color_picker(themes_list, selected): print(CLR, end="") print() - print(f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}") + print( + f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}" + ) print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n") for i, (theme_id, theme) in enumerate(themes_list): @@ -341,7 +343,7 @@ def main(): print() _subtitle = { "poetry": "literary consciousness stream", - "code": "source consciousness stream", + "code": "source consciousness stream", }.get(config.MODE, "digital consciousness stream") print(f" {W_DIM}v0.1 · {_subtitle}{RST}") print(f" {W_GHOST}{'─' * (w - 4)}{RST}") @@ -365,6 +367,7 @@ def main(): save_cache(items) elif config.MODE == "code": from engine.fetch_code import fetch_code + slow_print(" > INITIALIZING SOURCE ARRAY...\n") time.sleep(0.2) print() diff --git a/engine/config.py b/engine/config.py index 5b045db..005f5b8 100644 --- a/engine/config.py +++ b/engine/config.py @@ -58,8 +58,10 @@ HEADLINE_LIMIT = 1000 FEED_TIMEOUT = 10 MIC_THRESHOLD_DB = 50 # dB above which glitches intensify MODE = ( - "poetry" if "--poetry" in sys.argv or "-p" in sys.argv - else "code" if "--code" in sys.argv + "poetry" + if "--poetry" in sys.argv or "-p" in sys.argv + else "code" + if "--code" in sys.argv else "news" ) FIREHOSE = "--firehose" in sys.argv diff --git a/engine/render.py b/engine/render.py index ccf1e12..c0ecb7d 100644 --- a/engine/render.py +++ b/engine/render.py @@ -15,6 +15,7 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS from engine.terminal import RST from engine.translate import detect_location_language, translate_headline + # ─── GRADIENT ───────────────────────────────────────────── def _color_codes_to_ansi(color_codes): """Convert a list of 256-color codes to ANSI escape code strings. @@ -80,6 +81,7 @@ def _default_magenta_gradient(): "\033[2;38;5;235m", # near black ] + # ─── FONT LOADING ───────────────────────────────────────── _FONT_OBJ = None _FONT_OBJ_KEY = None diff --git a/engine/scroll.py b/engine/scroll.py index b50b793..4f190f9 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -18,7 +18,7 @@ from engine.effects import ( noise, vis_trunc, ) -from engine.render import big_wrap, lr_gradient, msg_gradient, make_block +from engine.render import big_wrap, lr_gradient, make_block, msg_gradient from engine.terminal import CLR, RST, W_COOL, th, tw