Files
sideline/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Gene Johnson d9422b1fec docs: add color scheme implementation plan
Comprehensive plan with 6 chunks, each containing bite-sized TDD tasks:
- Chunk 1: Theme class and registry
- Chunk 2: Config integration
- Chunk 3: Render pipeline
- Chunk 4: Message gradient integration
- Chunk 5: Color picker UI
- Chunk 6: Integration and validation

Each step includes exact code, test commands, and expected output.
2026-03-16 02:47:25 -07:00

26 KiB

Color Scheme Switcher Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.

Architecture: New themes.py data module defines Theme class and THEME_REGISTRY. Config adds ACTIVE_THEME global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.

Tech Stack: Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities


File Structure

File Purpose Change Type
engine/themes.py Theme class, THEME_REGISTRY, color codes Create
engine/config.py ACTIVE_THEME global, set_active_theme() Modify
engine/render.py Replace GRAD_COLS/MSG_GRAD_COLS with config lookup Modify
engine/scroll.py Update message gradient call Modify
engine/app.py pick_color_theme(), call in main() Modify
tests/test_themes.py Theme class and registry unit tests Create

Chunk 1: Theme Data Module

Task 1: Create themes.py with Theme class and registry

Files:

  • Create: engine/themes.py

  • Test: tests/test_themes.py

  • Step 1: Write failing test for Theme class

Create tests/test_themes.py:

"""Test color themes and registry."""
from engine.themes import Theme, THEME_REGISTRY, get_theme


def test_theme_construction():
    """Theme stores name and gradient lists."""
    main = ["\033[1;38;5;231m"] * 12
    msg = ["\033[1;38;5;225m"] * 12
    theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)

    assert theme.name == "Test Green"
    assert theme.main_gradient == main
    assert theme.message_gradient == msg


def test_gradient_length():
    """Each gradient must have exactly 12 ANSI codes."""
    for theme_id, theme in THEME_REGISTRY.items():
        assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
        assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"


def test_theme_registry_has_three_themes():
    """Registry contains green, orange, purple."""
    assert len(THEME_REGISTRY) == 3
    assert "green" in THEME_REGISTRY
    assert "orange" in THEME_REGISTRY
    assert "purple" in THEME_REGISTRY


def test_get_theme_valid():
    """get_theme returns Theme object for valid ID."""
    theme = get_theme("green")
    assert isinstance(theme, Theme)
    assert theme.name == "Verdant Green"


def test_get_theme_invalid():
    """get_theme raises KeyError for invalid ID."""
    with pytest.raises(KeyError):
        get_theme("invalid_theme")


def test_green_theme_unchanged():
    """Green theme uses original green → magenta colors."""
    green_theme = get_theme("green")
    # First color should be white (bold)
    assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
    # Last deep green
    assert green_theme.main_gradient[9] == "\033[38;5;22m"
    # Message gradient is magenta
    assert green_theme.message_gradient[9] == "\033[38;5;89m"

Run: pytest tests/test_themes.py -v Expected: FAIL (module doesn't exist)

  • Step 2: Create themes.py with Theme class and finalized gradients

Create engine/themes.py:

"""Color theme definitions and registry."""
from typing import Optional


class Theme:
    """Encapsulates a color scheme: name, main gradient, message gradient."""

    def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
        """Initialize theme with display name and gradient lists.

        Args:
            name: Display name (e.g., "Verdant Green")
            main_gradient: List of 12 ANSI 256-color codes (white → primary color)
            message_gradient: List of 12 ANSI codes (white → complementary color)
        """
        self.name = name
        self.main_gradient = main_gradient
        self.message_gradient = message_gradient


# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
# Each gradient: white → primary/complementary, 12 steps total
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>

_GREEN_MAIN = [
    "\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 green
    "\033[38;5;40m",     # green
    "\033[38;5;34m",     # medium green
    "\033[38;5;28m",     # dark green
    "\033[38;5;22m",     # deep green
    "\033[2;38;5;22m",   # dim deep green
    "\033[2;38;5;235m",  # near black
]

_GREEN_MESSAGE = [
    "\033[1;38;5;231m",  # white (bold)
    "\033[1;38;5;225m",  # pale pink-white
    "\033[38;5;219m",    # bright pink
    "\033[38;5;213m",    # hot pink
    "\033[38;5;207m",    # magenta
    "\033[38;5;201m",    # bright magenta
    "\033[38;5;165m",    # orchid-red
    "\033[38;5;161m",    # ruby-magenta
    "\033[38;5;125m",    # dark magenta
    "\033[38;5;89m",     # deep maroon-magenta
    "\033[2;38;5;89m",   # dim deep maroon-magenta
    "\033[2;38;5;235m",  # near black
]

_ORANGE_MAIN = [
    "\033[1;38;5;231m",  # white (bold)
    "\033[1;38;5;215m",  # pale orange-white
    "\033[38;5;209m",    # bright orange
    "\033[38;5;208m",    # vibrant orange
    "\033[38;5;202m",    # orange
    "\033[38;5;166m",    # dark orange
    "\033[38;5;130m",    # burnt orange
    "\033[38;5;94m",     # rust
    "\033[38;5;58m",     # dark rust
    "\033[38;5;94m",     # rust (hold)
    "\033[2;38;5;94m",   # dim rust
    "\033[2;38;5;235m",  # near black
]

_ORANGE_MESSAGE = [
    "\033[1;38;5;231m",  # white (bold)
    "\033[1;38;5;195m",  # pale cyan-white
    "\033[38;5;33m",     # bright blue
    "\033[38;5;27m",     # blue
    "\033[38;5;21m",     # deep blue
    "\033[38;5;21m",     # deep blue (hold)
    "\033[38;5;21m",     # deep blue (hold)
    "\033[38;5;18m",     # navy
    "\033[38;5;18m",     # navy (hold)
    "\033[38;5;18m",     # navy (hold)
    "\033[2;38;5;18m",   # dim navy
    "\033[2;38;5;235m",  # near black
]

_PURPLE_MAIN = [
    "\033[1;38;5;231m",  # white (bold)
    "\033[1;38;5;225m",  # pale purple-white
    "\033[38;5;177m",    # bright purple
    "\033[38;5;171m",    # vibrant purple
    "\033[38;5;165m",    # purple
    "\033[38;5;135m",    # medium purple
    "\033[38;5;129m",    # purple
    "\033[38;5;93m",     # dark purple
    "\033[38;5;57m",     # deep purple
    "\033[38;5;57m",     # deep purple (hold)
    "\033[2;38;5;57m",   # dim deep purple
    "\033[2;38;5;235m",  # near black
]

_PURPLE_MESSAGE = [
    "\033[1;38;5;231m",  # white (bold)
    "\033[1;38;5;226m",  # pale yellow-white
    "\033[38;5;226m",    # bright yellow
    "\033[38;5;220m",    # yellow
    "\033[38;5;220m",    # yellow (hold)
    "\033[38;5;184m",    # dark yellow
    "\033[38;5;184m",    # dark yellow (hold)
    "\033[38;5;178m",    # olive-yellow
    "\033[38;5;178m",    # olive-yellow (hold)
    "\033[38;5;172m",    # golden
    "\033[2;38;5;172m",  # dim golden
    "\033[2;38;5;235m",  # near black
]

# ─── THEME REGISTRY ───────────────────────────────────────────────────────

THEME_REGISTRY = {
    "green": Theme(
        name="Verdant Green",
        main_gradient=_GREEN_MAIN,
        message_gradient=_GREEN_MESSAGE,
    ),
    "orange": Theme(
        name="Molten Orange",
        main_gradient=_ORANGE_MAIN,
        message_gradient=_ORANGE_MESSAGE,
    ),
    "purple": Theme(
        name="Violet Purple",
        main_gradient=_PURPLE_MAIN,
        message_gradient=_PURPLE_MESSAGE,
    ),
}


def get_theme(theme_id: str) -> Theme:
    """Retrieve a theme by ID.

    Args:
        theme_id: One of "green", "orange", "purple"

    Returns:
        Theme object

    Raises:
        KeyError: If theme_id not found in registry
    """
    if theme_id not in THEME_REGISTRY:
        raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
    return THEME_REGISTRY[theme_id]
  • Step 3: Run tests to verify they pass

Run: pytest tests/test_themes.py -v Expected: PASS (all 6 tests)

  • Step 4: Commit
git add engine/themes.py tests/test_themes.py
git commit -m "feat: create Theme class and registry with finalized color gradients

- Define Theme class to encapsulate name and main/message gradients
- Create THEME_REGISTRY with green, orange, purple themes
- Each gradient has 12 ANSI 256-color codes finalized
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
- Add get_theme() lookup with error handling
- Add comprehensive unit tests"

Chunk 2: Config Integration

Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py

Files:

  • Modify: engine/config.py:1-30

  • Test: tests/test_config.py (expand existing)

  • Step 1: Write failing tests for config changes

Add to tests/test_config.py:

def test_active_theme_initially_none():
    """ACTIVE_THEME is None before initialization."""
    # This test may fail if config is already initialized
    # We'll set it to None first for testing
    import engine.config
    engine.config.ACTIVE_THEME = None
    assert engine.config.ACTIVE_THEME is None


def test_set_active_theme_green():
    """set_active_theme('green') sets ACTIVE_THEME to green theme."""
    from engine.config import set_active_theme
    from engine.themes import get_theme

    set_active_theme("green")

    assert config.ACTIVE_THEME is not None
    assert config.ACTIVE_THEME.name == "Verdant Green"
    assert config.ACTIVE_THEME == get_theme("green")


def test_set_active_theme_default():
    """set_active_theme() with no args defaults to green."""
    from engine.config import set_active_theme

    set_active_theme()

    assert config.ACTIVE_THEME.name == "Verdant Green"


def test_set_active_theme_invalid():
    """set_active_theme() with invalid ID raises KeyError."""
    from engine.config import set_active_theme

    with pytest.raises(KeyError):
        set_active_theme("invalid")

Run: pytest tests/test_config.py -v Expected: FAIL (functions don't exist yet)

  • Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py

Edit engine/config.py, add after line 30 (after _resolve_font_path function):

# ─── COLOR THEME ──────────────────────────────────────────────────────────
ACTIVE_THEME = None  # set by set_active_theme() after picker


def set_active_theme(theme_id: str = "green"):
    """Set the active color theme. Defaults to 'green' if not specified.

    Args:
        theme_id: One of "green", "orange", "purple"

    Raises:
        KeyError: If theme_id is invalid
    """
    global ACTIVE_THEME
    from engine import themes
    ACTIVE_THEME = themes.get_theme(theme_id)
  • Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py

Edit engine/render.py, find and delete lines 20-49 (the hardcoded gradient arrays):

# DELETED:
# GRAD_COLS = [...]
# MSG_GRAD_COLS = [...]
  • Step 4: Run tests to verify they pass

Run: pytest tests/test_config.py::test_active_theme_initially_none -v Run: pytest tests/test_config.py::test_set_active_theme_green -v Run: pytest tests/test_config.py::test_set_active_theme_default -v Run: pytest tests/test_config.py::test_set_active_theme_invalid -v

Expected: PASS (all 4 new tests)

  • Step 5: Verify existing config tests still pass

Run: pytest tests/test_config.py -v

Expected: PASS (all existing + new tests)

  • Step 6: Commit
git add engine/config.py tests/test_config.py
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config

- Add ACTIVE_THEME global (initialized to None)
- Add set_active_theme(theme_id) function with green default
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
- Add comprehensive tests for theme setting"

Chunk 3: Render Pipeline Integration

Task 3: Update render.py to use config.ACTIVE_THEME

Files:

  • Modify: engine/render.py:15-220

  • Test: tests/test_render.py (expand existing)

  • Step 1: Write failing test for lr_gradient with theme

Add to tests/test_render.py:

def test_lr_gradient_uses_active_theme(monkeypatch):
    """lr_gradient uses config.ACTIVE_THEME when cols=None."""
    from engine import config, render
    from engine.themes import get_theme

    # Set orange theme
    config.set_active_theme("orange")

    # Create simple rows
    rows = ["test row"]
    result = render.lr_gradient(rows, offset=0, cols=None)

    # Result should start with first color from orange main gradient
    assert result[0].startswith("\033[1;38;5;231m")  # white (same for all)


def test_lr_gradient_fallback_when_no_theme(monkeypatch):
    """lr_gradient uses fallback when ACTIVE_THEME is None."""
    from engine import config, render

    # Clear active theme
    config.ACTIVE_THEME = None

    rows = ["test row"]
    result = render.lr_gradient(rows, offset=0, cols=None)

    # Should not crash and should return something
    assert result is not None
    assert len(result) > 0


def test_default_green_gradient_length():
    """_default_green_gradient returns 12 colors."""
    from engine import render

    colors = render._default_green_gradient()
    assert len(colors) == 12

Run: pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v Expected: FAIL (function signature doesn't match)

  • Step 2: Update lr_gradient() to use config.ACTIVE_THEME

Edit engine/render.py, find the lr_gradient() function (around line 194) and update it:

def lr_gradient(rows, offset, cols=None):
    """
    Render rows through a left-to-right color sweep.

    Args:
        rows: List of text rows to colorize
        offset: Gradient position offset (for animation)
        cols: Optional list of color codes. If None, uses active theme.

    Returns:
        List of colorized rows
    """
    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 ...
  • Step 3: Add _default_green_gradient() fallback function

Add to engine/render.py before lr_gradient():

def _default_green_gradient():
    """Fallback green gradient (original colors) for initialization."""
    return [
        "\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 green
        "\033[38;5;40m",     # green
        "\033[38;5;34m",     # medium green
        "\033[38;5;28m",     # dark green
        "\033[38;5;22m",     # deep green
        "\033[2;38;5;22m",   # dim deep green
        "\033[2;38;5;235m",  # near black
    ]


def _default_magenta_gradient():
    """Fallback magenta gradient (original message colors) for initialization."""
    return [
        "\033[1;38;5;231m",  # white (bold)
        "\033[1;38;5;225m",  # pale pink-white
        "\033[38;5;219m",    # bright pink
        "\033[38;5;213m",    # hot pink
        "\033[38;5;207m",    # magenta
        "\033[38;5;201m",    # bright magenta
        "\033[38;5;165m",    # orchid-red
        "\033[38;5;161m",    # ruby-magenta
        "\033[38;5;125m",    # dark magenta
        "\033[38;5;89m",     # deep maroon-magenta
        "\033[2;38;5;89m",   # dim deep maroon-magenta
        "\033[2;38;5;235m",  # near black
    ]
  • Step 4: Run tests to verify they pass

Run: pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v Run: pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v Run: pytest tests/test_render.py::test_default_green_gradient_length -v

Expected: PASS (all 3 new tests)

  • Step 5: Run full render test suite

Run: pytest tests/test_render.py -v

Expected: PASS (existing tests may need adjustment for mocking)

  • Step 6: Commit
git add engine/render.py tests/test_render.py
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME

- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
- Fallback used when ACTIVE_THEME is None (non-interactive init)
- Add tests for theme-aware and fallback gradient rendering"

Chunk 4: Message Gradient Integration

Task 4: Update scroll.py to use message gradient from config

Files:

  • Modify: engine/scroll.py:85-95

  • Test: existing tests/test_scroll.py

  • Step 1: Locate message gradient calls in scroll.py

Run: grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py

Expected: Should find line(s) where MSG_GRAD_COLS or similar is used

  • Step 2: Update scroll.py to use theme message gradient

Edit engine/scroll.py, find the line that uses message gradients (around line 89 based on spec) and update:

Old code:

# Some variation of:
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)

New code:

from engine import config
msg_cols = (
    config.ACTIVE_THEME.message_gradient
    if config.ACTIVE_THEME
    else render._default_magenta_gradient()
)
rows = lr_gradient(rows, offset, msg_cols)

Or use the helper approach (create msg_gradient() in render.py):

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)

Then in scroll.py:

rows = render.msg_gradient(rows, offset)
  • Step 3: Run existing scroll tests

Run: pytest tests/test_scroll.py -v

Expected: PASS (existing functionality unchanged)

  • Step 4: Commit
git add engine/scroll.py engine/render.py
git commit -m "feat: update scroll.py to use theme message gradient

- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
- Use fallback magenta gradient when theme not initialized
- Ensure ntfy messages render in complementary color from selected theme"

Chunk 5: Color Picker UI

Task 5: Create pick_color_theme() function in app.py

Files:

  • Modify: engine/app.py:1-300

  • Test: manual/integration (interactive)

  • Step 1: Write helper functions for color picker UI

Edit engine/app.py, add before pick_font_face() function:

def _draw_color_picker(themes_list, selected):
    """Draw the color theme picker menu."""
    import sys
    from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw

    print(CLR, end="")
    print()
    print(f"  {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
    print(f"  {W_GHOST}{'─' * (tw() - 4)}\n")

    for i, (theme_id, theme) in enumerate(themes_list):
        prefix = "  ▶ " if i == selected else "    "
        color = G_HI if i == selected else ""
        reset = "" if i == selected else W_GHOST
        print(f"{prefix}{color}{theme.name}{reset}")

    print()
  • Step 2: Create pick_color_theme() function

Edit engine/app.py, add after helper function:

def pick_color_theme():
    """Interactive color theme picker. Defaults to 'green' if not TTY."""
    import sys
    import termios
    import tty
    from engine import config, themes

    # Non-interactive fallback: use green
    if not sys.stdin.isatty():
        config.set_active_theme("green")
        return

    themes_list = list(themes.THEME_REGISTRY.items())
    selected = 0

    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setcbreak(fd)
        while True:
            _draw_color_picker(themes_list, selected)
            key = _read_picker_key()
            if key == "up":
                selected = max(0, selected - 1)
            elif key == "down":
                selected = min(len(themes_list) - 1, selected + 1)
            elif key == "enter":
                break
            elif key == "interrupt":
                raise KeyboardInterrupt
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

    selected_theme_id = themes_list[selected][0]
    config.set_active_theme(selected_theme_id)

    theme_name = themes_list[selected][1].name
    print(f"  {G_DIM}> using {theme_name}{RST}")
    time.sleep(0.8)
    print(CLR, end="")
    print(CURSOR_OFF, end="")
    print()
  • Step 3: Update main() to call pick_color_theme() before pick_font_face()

Edit engine/app.py, find the main() function and locate where pick_font_face() is called (around line 265). Add before it:

def main():
    # ... existing signal handler setup ...

    pick_color_theme()  # NEW LINE - before font picker
    pick_font_face()

    # ... rest of main unchanged ...
  • Step 4: Manual test - run in interactive terminal

Run: python3 mainline.py

Expected:

  • See color theme picker menu before font picker

  • Can navigate with ↑/↓ or j/k

  • Can select with Enter or q

  • Selected theme applies to scrolling headlines

  • Can select different themes and see colors change

  • Step 5: Manual test - run in non-interactive environment

Run: echo "" | python3 mainline.py

Expected:

  • No color picker menu shown

  • Defaults to green theme

  • App runs without error

  • Step 6: Commit

git add engine/app.py
git commit -m "feat: add pick_color_theme() UI and integration

- Create _draw_color_picker() to render menu
- Create pick_color_theme() function mirroring font picker pattern
- Integrate into main() before font picker
- Fallback to green theme in non-interactive environments
- Support arrow keys and j/k navigation"

Chunk 6: Integration & Validation

Task 6: End-to-end testing and cleanup

Files:

  • Test: All modified files

  • Verify: App functionality

  • Step 1: Run full test suite

Run: pytest tests/ -v

Expected: PASS (all tests, including new ones)

  • Step 2: Run linter

Run: ruff check engine/ mainline.py

Expected: No errors (fix any style issues)

  • Step 3: Manual integration test - green theme

Run: python3 mainline.py

Then select "Verdant Green" from picker.

Expected:

  • Headlines render in green → deep green

  • ntfy messages render in magenta gradient

  • Both work correctly during streaming

  • Step 4: Manual integration test - orange theme

Run: python3 mainline.py

Then select "Molten Orange" from picker.

Expected:

  • Headlines render in orange → deep orange

  • ntfy messages render in blue gradient

  • Colors are visually distinct from green

  • Step 5: Manual integration test - purple theme

Run: python3 mainline.py

Then select "Violet Purple" from picker.

Expected:

  • Headlines render in purple → deep purple

  • ntfy messages render in yellow gradient

  • Colors are visually distinct from green and orange

  • Step 6: Test poetry mode with color picker

Run: python3 mainline.py --poetry

Then select "orange" from picker.

Expected:

  • Poetry mode works with color picker

  • Colors apply to poetry rendering

  • Step 7: Test code mode with color picker

Run: python3 mainline.py --code

Then select "purple" from picker.

Expected:

  • Code mode works with color picker

  • Colors apply to code rendering

  • Step 8: Verify acceptance criteria

✓ Color picker displays 3 theme options at startup ✓ Selection applies to all headline and message gradients ✓ Boot UI (title, status) uses hardcoded green (not theme) ✓ Scrolling headlines and ntfy messages use theme gradients ✓ No persistence between runs (each run picks fresh) ✓ Non-TTY environments default to green without error ✓ Architecture supports future random/animation modes ✓ All gradient color codes finalized with no TBD values

  • Step 9: Final commit
git add -A
git commit -m "feat: color scheme switcher implementation complete

Closes color-pick feature with:
- Three selectable color themes (green, orange, purple)
- Interactive menu at startup (mirrors font picker UI)
- Complementary colors for ntfy message queue
- Fallback to green in non-interactive environments
- All tests passing, manual validation complete"
  • Step 10: Create feature branch PR summary
## Color Scheme Switcher

Implements interactive color theme selection for Mainline news ticker.

### What's New
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
- Complementary gradients for ntfy messages (magenta, blue, yellow)
- Fresh theme selection each run (no persistence)

### Files Changed
- `engine/themes.py` (new)
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
- `engine/render.py` (theme-aware gradients)
- `engine/scroll.py` (message gradient integration)
- `engine/app.py` (pick_color_theme UI)
- `tests/test_themes.py` (new theme tests)
- `README.md` (documentation)

### Acceptance Criteria
All met. App fully tested and ready for merge.

Testing Checklist

  • Unit tests: pytest tests/test_themes.py -v
  • Unit tests: pytest tests/test_config.py -v
  • Unit tests: pytest tests/test_render.py -v
  • Full suite: pytest tests/ -v
  • Linting: ruff check engine/ mainline.py
  • Manual: Green theme selection
  • Manual: Orange theme selection
  • Manual: Purple theme selection
  • Manual: Poetry mode with colors
  • Manual: Code mode with colors
  • Manual: Non-TTY fallback

Notes

  • themes.py is data-only; never import config or render to prevent cycles
  • ACTIVE_THEME initialized to None; guaranteed non-None before stream() via pick_color_theme()
  • Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
  • Message gradients use complementary colors; lookup in scroll.py
  • Each gradient has 12 colors; verify length in tests
  • No persistence; fresh picker each run