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 03cdd70ad0
commit aa2f0f9a8b

View File

@@ -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