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
This commit is contained in:
2026-03-16 02:42:19 -07:00
parent f085042dee
commit 667bef2685

View File

@@ -1,7 +1,7 @@
# Color Scheme Switcher Design # Color Scheme Switcher Design
**Date:** 2026-03-16 **Date:** 2026-03-16
**Status:** Draft **Status:** Revised after review
**Scope:** Interactive color theme selection for Mainline news ticker **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:** **Non-functional:**
- Reuse the existing font picker pattern for UI consistency - Reuse the existing font picker pattern for UI consistency
- Zero runtime overhead during streaming (theme lookup happens once at startup) - 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` ### 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 ```python
class Theme: class Theme:
"""Encapsulates a color scheme: name, main gradient, message gradient.""" """Encapsulates a color scheme: name, main gradient, message gradient."""
@@ -50,20 +53,42 @@ class Theme:
``` ```
**Theme Registry:** **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;231m", # white (bold)
"\033[1;38;5;195m", # pale white-tint "\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 "\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:** **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) - `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:** **New globals:**
```python ```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:** **New function:**
```python ```python
def set_active_theme(theme_id: str): def set_active_theme(theme_id: str = "green"):
"""Set the active theme after user selection.""" """Set the active theme. Defaults to 'green' if not specified."""
global ACTIVE_THEME global ACTIVE_THEME
from engine import themes from engine import themes
ACTIVE_THEME = themes.get_theme(theme_id) 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:** **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` ### Modified: `engine/render.py`
**Updated gradient access:** **Updated gradient access in existing functions:**
Old pattern: Current pattern (will be removed):
```python ```python
def lr_gradient(rows, offset, cols=None): GRAD_COLS = [...] # hardcoded green
cols = cols or GRAD_COLS # module-level constant MSG_GRAD_COLS = [...] # hardcoded magenta
``` ```
New pattern: New pattern — update `lr_gradient()` function:
```python ```python
def lr_gradient(rows, offset, cols=None): def lr_gradient(rows, offset, cols=None):
if cols is None: if cols is None:
from engine import config 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 # ... rest of function unchanged
``` ```
**Fallback:** Define `_DEFAULT_GRAD` (current green) for cases where theme isn't selected yet. **Define fallback:**
**Message gradient lookup:**
```python ```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 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()`** **New function: `pick_color_theme()`**
Mirrors `pick_font_face()` pattern: 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 ```python
3. Return selected theme ID def pick_color_theme():
4. Call `config.set_active_theme(theme_id)` """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()`:** **Placement in `main()`:**
```python ```python
@@ -135,10 +209,10 @@ def main():
# ... signal handler setup ... # ... signal handler setup ...
pick_color_theme() # NEW — before title/subtitle pick_color_theme() # NEW — before title/subtitle
pick_font_face() 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 main() called
pick_color_theme() pick_color_theme()
Display menu If TTY: display menu, read input, call config.set_active_theme(user_choice)
Read user input If not TTY: silently call config.set_active_theme("green")
→ config.set_active_theme(theme_id)
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 On exit: no persistence
``` ```
@@ -167,38 +240,52 @@ On exit: no persistence
## Implementation Notes ## Implementation Notes
### Color Selection ### Initialization Guarantee
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. `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` ### Module Independence
Purple gradient: `231 → 225 → 183 → 177 → 171 → 165 → 129 → 93 → 57 → 57 → 57 (dim) → 235` `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.
(Exact codes TBD via manual testing for visual balance)
### Future Extensions ### Color Code Finalization
The Theme class can be extended with: All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
- `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. ### Theme ID Naming
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
### Testing ### Testing Strategy
- Unit test `themes.py` for Theme construction and registry lookup 1. **Unit tests** (`tests/test_themes.py`):
- Integration test that boot sequence respects active theme - Verify Theme class construction
- No changes needed to existing gradient tests (they now pull from config.ACTIVE_THEME) - 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 ## Files Changed
- `engine/themes.py` (new) - `engine/themes.py` (new)
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`) - `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) - `engine/app.py` (add `pick_color_theme()`, call in main)
- `tests/test_themes.py` (new unit tests) - `tests/test_themes.py` (new unit tests)
- `tests/test_render.py` (update mocking strategy)
## Acceptance Criteria ## Acceptance Criteria
1. ✓ Color picker displays 3 theme options at startup 1. ✓ Color picker displays 3 theme options at startup
2. ✓ Selection applies to all headline and message gradients 2. ✓ Selection applies to all headline and message gradients
3. ✓ Boot messages render in selected theme 3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
4.No persistence between runs 4.Scrolling headlines and ntfy messages use theme gradients
5.Architecture supports future random/animation modes 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