6.1 KiB
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:
- User selects a color theme from an interactive menu at startup (green, orange, or purple)
- Main headline gradient uses the selected primary color (white → color)
- Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
- Selection is fresh each run (no persistence)
- 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
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 foundTHEME_REGISTRY— dict of all available themes (for picker)
Modified: engine/config.py
New globals:
ACTIVE_THEME = None # set by set_active_theme() after picker
New function:
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_COLSandMSG_GRAD_COLS— these move tothemes.py
Modified: engine/render.py
Updated gradient access:
Old pattern:
def lr_gradient(rows, offset, cols=None):
cols = cols or GRAD_COLS # module-level constant
New pattern:
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:
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:
- Display menu:
[1] Verdant Green [2] Molten Orange [3] Violet Purple - Read arrow keys (↑/↓) or j/k to navigate
- Return selected theme ID
- Call
config.set_active_theme(theme_id)
Placement in main():
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 themesmetadata: tags like["cyberpunk", "retro", "ethereal"]random_pick(): returns a random theme (enables future--color=randomflag)
The picker can offer a "Random" option without any code changes to render or config.
Testing
- Unit test
themes.pyfor 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(addACTIVE_THEME,set_active_theme())engine/render.py(update gradient access to use config.ACTIVE_THEME)engine/app.py(addpick_color_theme(), call in main)tests/test_themes.py(new unit tests)
Acceptance Criteria
- ✓ Color picker displays 3 theme options at startup
- ✓ Selection applies to all headline and message gradients
- ✓ Boot messages render in selected theme
- ✓ No persistence between runs
- ✓ Architecture supports future random/animation modes