docs: add color scheme switcher design spec
This commit is contained in:
204
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
204
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user