Files
Mainline/docs/superpowers/specs/2026-03-16-color-scheme-design.md
Gene Johnson aa2f0f9a8b 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
2026-03-18 23:20:22 -07:00

9.8 KiB

Color Scheme Switcher Design

Date: 2026-03-16 Status: Revised after review 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 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

Architecture

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.

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: "green", "orange", "purple" (IDs match menu labels for clarity).

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 KeyError if not found
  • THEME_REGISTRY — dict of all available themes (for picker)

Modified: engine/config.py

New globals:

ACTIVE_THEME = None  # set by set_active_theme() after picker; guaranteed non-None during stream()

New function:

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 constants

Modified: engine/render.py

Updated gradient access in existing functions:

Current pattern (will be removed):

GRAD_COLS = [...]  # hardcoded green
MSG_GRAD_COLS = [...] # hardcoded magenta

New pattern — update lr_gradient() function:

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_green_gradient())
    # ... rest of function unchanged

Define fallback:

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:

# 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:

def msg_gradient(rows, offset):
    """Apply message (ntfy) gradient using theme complementary colors."""
    from engine import config
    cols = (config.ACTIVE_THEME.message_gradient
            if config.ACTIVE_THEME
            else _default_magenta_gradient())
    return lr_gradient(rows, offset, cols)

Modified: engine/app.py

New function: pick_color_theme()

Mirrors pick_font_face() pattern:

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():

def main():
    # ... signal handler setup ...
    pick_color_theme()  # NEW — before title/subtitle
    pick_font_face()
    # ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...

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.


Data Flow

User starts: mainline.py
    ↓
main() called
    ↓
pick_color_theme()
    → 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 in hardcoded green UI colors
    ↓
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
    ↓
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
    ↓
On exit: no persistence

Implementation Notes

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

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.

Color Code Finalization

All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.

Theme ID Naming

IDs are "green", "orange", "purple" — matching the menu labels exactly for clarity.

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