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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user