Private
Public Access
0
0
Files
manual_slop/docs/superpowers/plans/2026-06-04-theme-syntax-modularization.md
T

41 KiB

Theme & Syntax Highlighting Modularization Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the hardcoded _PALETTES dict in src/theme_2.py with a TOML-based theme loading system, ship four new themes (Solarized Dark/Light, Gruvbox Dark, Moss) as TOML files, and add a syntax palette mapping that aligns imgui_color_text_edit's code-block highlighting with the active UI theme.

Architecture: Themes live in themes/<name>.toml (global) and <project>/project_themes.toml (project override). The loader merges them, project overriding global, mirroring the existing PresetManager / PersonaManager / ToolPresetManager pattern. Syntax highlighting uses the four built-in imgui_color_text_edit palettes (dark/light/mariana/retro_blue) — a per-theme TOML field selects which one to apply via set_default_palette.

Tech Stack: Python 3.11+, tomllib/tomli_w, imgui-bundle's imgui_color_text_edit (built-in PaletteId enum), pytest.

Spec: conductor/tracks/multi_themes_20260604/spec.md


Execution Constraints

  • No subagents. Execute as a single agent.
  • Pre-edit checkpoint: git add . before any file edit.
  • Per-file atomic commits.
  • Commit message format: <type>(<scope>): <imperative description>.
  • Git note format: 3-8 line rationale per commit.
  • Style baseline: 1-space indent, no comments, type hints.
  • Tests required: every new module has at least one pytest test that fails before the change and passes after.

File Structure

File Action Responsibility
src/theme_models.py Create ThemePalette dataclass + ThemeFile schema; from_dict()/to_dict() round-trip; imgui.Col_ key normalization
src/paths.py Modify Add get_global_themes_path() and get_project_themes_path(project_root); env override SLOP_GLOBAL_THEMES
src/theme_2.py Modify Replace hardcoded _PALETTES with a TOML loader; add load_themes_from_disk(); add get_syntax_palette_for_theme(); add apply_syntax_palette(); keep public API stable
themes/solarized_dark.toml Create Authoring artifact
themes/solarized_light.toml Create Same
themes/gruvbox_dark.toml Create Same
themes/moss.toml Create Same
tests/test_theme_models.py Create Round-trip + validation tests for ThemePalette and ThemeFile
tests/fixtures/themes/minimal.toml Create Minimal valid TOML fixture
tests/fixtures/themes/missing_required.toml Create TOML missing required keys — should raise
tests/test_theme.py Modify Add tests for the 4 new palettes, TOML loader, scope merge, and syntax palette mapping
docs/guide_themes.md Create Authoring guide

Task 1: Add theme path helpers

Files:

  • Modify: src/paths.py

  • Test: tests/test_paths.py (if it exists; create if not)

  • Step 1.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 1.2: Read current paths.py layout

Use manual-slop_py_get_code_outline on src/paths.py to find existing get_global_*_path and get_project_*_path helpers for stylistic consistency.

  • Step 1.3: Add the theme path helpers

Append after the last get_project_*_path function in src/paths.py:

def get_global_themes_path() -> Path:
    """
     [C: src/theme_2.py:load_themes_from_disk]
    """
    root_dir = Path(__file__).resolve().parent.parent
    return Path(os.environ.get("SLOP_GLOBAL_THEMES", root_dir / "themes.toml"))


def get_project_themes_path(project_root: Path) -> Path:
    """
     [C: src/theme_2.py:load_themes_from_disk]
    """
    return project_root / "project_themes.toml"
  • Step 1.4: Commit
git -C C:\projects\manual_slop add src/paths.py
git -C C:\projects\manual_slop commit -m "feat(paths): add global and project theme path helpers"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "Adds get_global_themes_path() (env: SLOP_GLOBAL_THEMES, default: <root>/themes.toml) and get_project_themes_path(project_root) (default: <project>/project_themes.toml). Mirrors the existing presets/personas/tool_presets path conventions." $h

Task 2: Define the ThemePalette and ThemeFile schema

Files:

  • Create: src/theme_models.py

  • Test: tests/test_theme_models.py

  • Step 2.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 2.2: Create the test fixtures

Create tests/fixtures/themes/minimal.toml:

syntax_palette = "dark"

[colors]
window_bg = [10, 20, 30]
text       = [200, 200, 200]
button_hovered = [255, 100, 50]

Create tests/fixtures/themes/missing_required.toml:

# missing [colors] section
syntax_palette = "dark"
  • Step 2.3: Create the failing test file

Create tests/test_theme_models.py:

from pathlib import Path
import pytest
from src.theme_models import ThemeFile, ThemePalette, load_theme_file


FIXTURES = Path(__file__).parent / "fixtures" / "themes"


def test_load_minimal_theme_file():
    p = FIXTURES / "minimal.toml"
    theme = load_theme_file(p, scope="project")
    assert theme.syntax_palette == "dark"
    assert theme.palette.window_bg == (10, 20, 30)
    assert theme.palette.text == (200, 200, 200)
    assert theme.palette.button_hovered == (255, 100, 50)
    assert theme.source_path == p
    assert theme.scope == "project"


def test_missing_required_keys_raises():
    p = FIXTURES / "missing_required.toml"
    with pytest.raises(ValueError, match=r"missing required \[colors\]"):
        load_theme_file(p, scope="project")


def test_invalid_syntax_palette_raises():
    p = FIXTURES / "minimal.toml"
    with pytest.raises(ValueError, match=r"invalid syntax_palette"):
        ThemeFile(name="x", palette=ThemePalette(), syntax_palette="not_a_real_palette", source_path=p, scope="project")


def test_round_trip_to_from_dict():
    p = FIXTURES / "minimal.toml"
    loaded = load_theme_file(p, scope="project")
    dumped = loaded.to_dict()
    reloaded = ThemeFile.from_dict(loaded.name, dumped, source_path=p, scope="project")
    assert reloaded.syntax_palette == loaded.syntax_palette
    assert reloaded.palette.window_bg == loaded.palette.window_bg
    assert reloaded.palette.text == loaded.palette.text


def test_scope_setter():
    p = FIXTURES / "minimal.toml"
    theme = load_theme_file(p, scope="global")
    assert theme.scope == "global"
    themed_as_project = theme.with_scope("project")
    assert themed_as_project.scope == "project"
    assert themed_as_project.name == theme.name
  • Step 2.4: Run the test to verify it fails
cd C:\projects\manual_slop; uv run pytest tests/test_theme_models.py -v --timeout=30

Expected: ModuleNotFoundError: No module named 'src.theme_models' or import error.

  • Step 2.5: Create the schema module

Create src/theme_models.py:

from __future__ import annotations
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional


VALID_SYNTAX_PALETTES: tuple[str, ...] = ("dark", "light", "mariana", "retro_blue")


@dataclass
class ThemePalette:
    window_bg: tuple[int, int, int] = (0, 0, 0)
    text: tuple[int, int, int] = (200, 200, 200)
    text_disabled: tuple[int, int, int] = (130, 130, 130)
    child_bg: tuple[int, int, int] = (0, 0, 0)
    popup_bg: tuple[int, int, int] = (0, 0, 0)
    border: tuple[int, int, int] = (60, 60, 60)
    frame_bg: tuple[int, int, int] = (45, 45, 45)
    frame_bg_hovered: tuple[int, int, int] = (60, 60, 60)
    frame_bg_active: tuple[int, int, int] = (75, 75, 75)
    title_bg: tuple[int, int, int] = (40, 40, 40)
    title_bg_active: tuple[int, int, int] = (60, 45, 15)
    menu_bar_bg: tuple[int, int, int] = (35, 35, 35)
    scrollbar_bg: tuple[int, int, int] = (30, 30, 30)
    scrollbar_grab: tuple[int, int, int] = (80, 80, 80)
    scrollbar_grab_hovered: tuple[int, int, int] = (100, 100, 100)
    scrollbar_grab_active: tuple[int, int, int] = (120, 120, 120)
    check_mark: tuple[int, int, int] = (200, 200, 200)
    slider_grab: tuple[int, int, int] = (60, 60, 60)
    slider_grab_active: tuple[int, int, int] = (100, 100, 100)
    button: tuple[int, int, int] = (60, 60, 60)
    button_hovered: tuple[int, int, int] = (100, 100, 100)
    button_active: tuple[int, int, int] = (120, 120, 120)
    header: tuple[int, int, int] = (60, 60, 60)
    header_hovered: tuple[int, int, int] = (100, 100, 100)
    header_active: tuple[int, int, int] = (120, 120, 120)
    separator: tuple[int, int, int] = (60, 60, 60)
    separator_hovered: tuple[int, int, int] = (100, 100, 100)
    separator_active: tuple[int, int, int] = (200, 200, 200)
    tab: tuple[int, int, int] = (60, 60, 60)
    tab_hovered: tuple[int, int, int] = (100, 100, 100)
    tab_selected: tuple[int, int, int] = (100, 100, 100)
    text_selected_bg: tuple[int, int, int] = (60, 100, 150)
    table_header_bg: tuple[int, int, int] = (55, 55, 55)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ThemePalette:
        kwargs: dict[str, Any] = {}
        for k, v in data.items():
            if hasattr(cls, k) and isinstance(v, (list, tuple)) and len(v) == 3:
                kwargs[k] = (int(v[0]), int(v[1]), int(v[2]))
        return cls(**kwargs)

    def to_dict(self) -> dict[str, Any]:
        return {k: list(v) for k, v in self.__dict__.items() if isinstance(v, tuple)}


@dataclass
class ThemeFile:
    name: str
    palette: ThemePalette
    syntax_palette: str
    source_path: Path
    scope: str
    description: str = ""

    def with_scope(self, scope: str) -> ThemeFile:
        return ThemeFile(
            name=self.name,
            palette=self.palette,
            syntax_palette=self.syntax_palette,
            source_path=self.source_path,
            scope=scope,
            description=self.description,
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            "syntax_palette": self.syntax_palette,
            "description": self.description,
            "colors": self.palette.to_dict(),
        }

    @classmethod
    def from_dict(cls, name: str, data: dict[str, Any], source_path: Path, scope: str) -> ThemeFile:
        if "colors" not in data:
            raise ValueError(
                f"theme file {source_path} is missing required [colors] section"
            )
        syntax_palette = data.get("syntax_palette", "dark")
        if syntax_palette not in VALID_SYNTAX_PALETTES:
            raise ValueError(
                f"invalid syntax_palette '{syntax_palette}' in {source_path}; "
                f"must be one of {VALID_SYNTAX_PALETTES}"
            )
        return cls(
            name=name,
            palette=ThemePalette.from_dict(data["colors"]),
            syntax_palette=syntax_palette,
            source_path=source_path,
            scope=scope,
            description=str(data.get("description", "")),
        )


def load_theme_file(path: Path, scope: str) -> ThemeFile:
    if not path.exists():
        raise FileNotFoundError(f"theme file not found: {path}")
    try:
        with open(path, "rb") as f:
            data = tomllib.load(f)
    except Exception as e:
        raise ValueError(f"failed to parse theme TOML {path}: {e}") from e
    if not isinstance(data, dict):
        raise ValueError(f"theme TOML {path} must be a top-level table")
    name = path.stem
    theme = ThemeFile.from_dict(name, data, source_path=path, scope=scope)
    return theme


def load_themes_from_dir(path: Path, scope: str) -> dict[str, ThemeFile]:
    out: dict[str, ThemeFile] = {}
    if not path.exists():
        return out
    for child in sorted(path.iterdir()):
        if not child.is_file():
            continue
        if child.suffix.lower() != ".toml":
            continue
        try:
            theme = load_theme_file(child, scope=scope)
        except (FileNotFoundError, ValueError) as e:
            print(f"warning: {e}", file=sys.stderr)
            continue
        out[theme.name] = theme
    return out


def load_themes_from_toml(path: Path, scope: str) -> dict[str, ThemeFile]:
    out: dict[str, ThemeFile] = {}
    if not path.exists():
        return out
    try:
        with open(path, "rb") as f:
            data = tomllib.load(f)
    except Exception as e:
        print(f"warning: failed to parse {path}: {e}", file=sys.stderr)
        return out
    if not isinstance(data, dict):
        return out
    themes_sec = data.get("themes", {})
    if not isinstance(themes_sec, dict):
        return out
    for name, theme_data in themes_sec.items():
        if not isinstance(theme_data, dict):
            continue
        try:
            theme = ThemeFile.from_dict(name, theme_data, source_path=path, scope=scope)
        except ValueError as e:
            print(f"warning: {name}: {e}", file=sys.stderr)
            continue
        out[name] = theme
    return out
  • Step 2.6: Run the test to verify it passes
cd C:\projects\manual_slop; uv run pytest tests/test_theme_models.py -v --timeout=30

Expected: 5 passed.

  • Step 2.7: Commit
git -C C:\projects\manual_slop add src/theme_models.py tests/test_theme_models.py tests/fixtures/themes/
git -C C:\projects\manual_slop commit -m "feat(theme-models): add ThemePalette/ThemeFile schema with TOML loader"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "Schema for theme TOML files. ThemePalette is a dataclass of imgui.Col_ RGB tuples; ThemeFile wraps it with syntax_palette (one of dark/light/mariana/retro_blue) and scope. load_themes_from_dir and load_themes_from_toml cover both per-file (themes/) and per-project (project_themes.toml) layouts. Raises clear ValueError on missing required [colors] or invalid syntax_palette." $h

Task 3: Refactor src/theme_2.py to load from TOML

Files:

  • Modify: src/theme_2.py

  • Test: tests/test_theme.py

  • Step 3.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 3.2: Add the failing test for TOML loading

Append to tests/test_theme.py:

import os
import tempfile
from src import theme_2 as theme


def test_themes_load_from_toml(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "solarized_dark.toml").write_text(
        'syntax_palette = "dark"\n'
        '[colors]\n'
        'window_bg = [0, 43, 54]\n'
        'text = [147, 161, 161]\n'
    )
    (themes_dir / "broken.toml").write_text('syntax_palette = "dark"\n')

    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    names = theme.get_palette_names()
    assert "solarized_dark" in names
    assert "broken" not in names


def test_get_syntax_palette_for_theme(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "solarized_light.toml").write_text(
        'syntax_palette = "light"\n[colors]\nwindow_bg = [253, 246, 227]\n'
    )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    assert theme.get_syntax_palette_for_theme("solarized_light") == "light"
    assert theme.get_syntax_palette_for_theme("ImGui Dark") == "dark"


def test_get_syntax_palette_for_unknown_theme_returns_default(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    assert theme.get_syntax_palette_for_theme("NonExistent") == "dark"
  • Step 3.3: Run the test to verify it fails
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "themes_load_from_toml or get_syntax_palette"

Expected: 3 failed (functions not defined).

  • Step 3.4: Add the loader plumbing in src/theme_2.py

At the top of src/theme_2.py after the imports, add:

from src.paths import get_global_themes_path
from src.theme_models import ThemeFile, load_themes_from_dir, load_themes_from_toml

Replace the entire _PALETTES: dict[str, dict[int, tuple]] = { ... } block with:

# Hardcoded fallback palettes (preserved for backward compatibility and
# as defaults when no TOML is present). Themes loaded from TOML override
# these on load.
_BUILTIN_PALETTES: dict[str, dict[int, tuple]] = {
    "ImGui Dark": {},
    "NERV": {},
}


def _get_themes_dir() -> Path:
    return get_global_themes_path().parent


def _hex(rgb: tuple[int, int, int]) -> tuple[float, float, float, float]:
    return (rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0)


_TOML_PALETTES: dict[str, ThemeFile] = {}
_TOML_COLOUR_CACHE: dict[str, dict[int, tuple[float, float, float, float]]] = {}


def _color_key_to_imgui_enum(name: str):
    from imgui_bundle import imgui
    attr = name.replace("_", " ")
    attr = attr.replace("text selected bg", "text_selected_bg")
    if not hasattr(imgui.Col_, attr):
        return None
    val = getattr(imgui.Col_, attr)
    if isinstance(val, int):
        return val
    return None


def _build_imgui_colour_dict(theme: ThemeFile) -> dict[int, tuple[float, float, float, float]]:
    from imgui_bundle import imgui
    out: dict[int, tuple[float, float, float, float]] = {}
    palette_dict = theme.palette.to_dict()
    for col_name, rgb in palette_dict.items():
        enum_name = col_name
        if hasattr(imgui.Col_, enum_name):
            enum_val = getattr(imgui.Col_, enum_name)
            if isinstance(enum_val, int):
                out[enum_val] = _hex(rgb)
    return out

Then replace the existing get_palette_names function with:

def get_palette_names() -> list[str]:
    """Returns a list of all available palettes, including TOML-loaded themes
    and the hello_imgui built-in themes."""
    names = list(_BUILTIN_PALETTES.keys())
    names.extend(sorted(_TOML_PALETTES.keys()))
    from imgui_bundle import hello_imgui
    hi_themes = [
        n for n in dir(hello_imgui.ImGuiTheme_)
        if not n.startswith("_") and n != "count" and not hasattr(int, n)
    ]
    names.extend(sorted(hi_themes))
    return names

Add the new public functions (place them right after get_palette_names):

def load_themes_from_disk() -> None:
    """Load all themes from the global themes directory and from a single
    multi-theme TOML file (themes.toml). Warns on parse errors and
    skips broken entries. Idempotent — safe to call repeatedly."""
    global _TOML_PALETTES, _TOML_COLOUR_CACHE
    themes_dir = _get_themes_dir()
    loaded: dict[str, ThemeFile] = {}
    if themes_dir.exists():
        loaded.update(load_themes_from_dir(themes_dir, scope="global"))
    themes_file = get_global_themes_path()
    if themes_file.exists() and themes_file.is_file():
        loaded.update(load_themes_from_toml(themes_file, scope="global"))
    _TOML_PALETTES = loaded
    _TOML_COLOUR_CACHE = {name: _build_imgui_colour_dict(t) for name, t in loaded.items()}


def get_syntax_palette_for_theme(theme_name: str) -> str:
    """Return the syntax palette name (one of dark/light/mariana/retro_blue)
    associated with the given UI theme. Falls back to 'dark' for unknown
    themes and for non-TOML built-ins."""
    if theme_name in _TOML_PALETTES:
        return _TOML_PALETTES[theme_name].syntax_palette
    return "dark"


def apply_syntax_palette(palette_name: str) -> None:
    """Set the default imgui_color_text_edit palette. palette_name must
    be one of: dark, light, mariana, retro_blue. No-op for unknown names."""
    from imgui_bundle import imgui_color_text_edit as ed
    if not hasattr(ed.TextEditor, "PaletteId"):
        return
    palette_id = getattr(ed.TextEditor.PaletteId, palette_name, None)
    if palette_id is None:
        return
    ed.TextEditor.set_default_palette(palette_id)

Modify the existing apply function so that, after applying the built-in palette and tweaks, it ALSO applies the syntax palette for the current theme name:

In apply, after the block that sets the font_scale_main (i.e., at the end), add:

    syntax_palette_id = get_syntax_palette_for_theme(palette_name)
    apply_syntax_palette(syntax_palette_id)

Add the call to load_themes_from_disk at module load time. Insert right after _TOML_PALETTES: dict[str, ThemeFile] = {} declaration:

load_themes_from_disk()
  • Step 3.5: Run the test to verify it passes
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "themes_load_from_toml or get_syntax_palette"

Expected: 3 passed.

  • Step 3.6: Run the full existing test suite to make sure we haven't regressed
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py tests/test_theme_nerv.py tests/test_theme_nerv_fx.py tests/test_theme_nerv_alert.py -v --timeout=30

Expected: all previously-passing tests still pass.

  • Step 3.7: Commit
git -C C:\projects\manual_slop add src/theme_2.py tests/test_theme.py
git -C C:\projects\manual_slop commit -m "feat(theme): load themes from TOML and apply syntax palette mapping"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "Replaces the hardcoded _PALETTES dict with a TOML loader. Public API (apply, get_palette_names, get_current_palette) preserved for backward compat. New API: load_themes_from_disk, get_syntax_palette_for_theme, apply_syntax_palette. The apply() function now also calls apply_syntax_palette so code blocks re-tint when the UI theme changes." $h

Task 4: Author the four new theme TOML files

Files:

  • Create: themes/solarized_dark.toml

  • Create: themes/solarized_light.toml

  • Create: themes/gruvbox_dark.toml

  • Create: themes/moss.toml

  • Test: tests/test_theme.py

  • Step 4.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 4.2: Add the failing test for new themes

Append to tests/test_theme.py:

def test_all_four_new_themes_registered(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    for name in ["solarized_dark", "solarized_light", "gruvbox_dark", "moss"]:
        (themes_dir / f"{name}.toml").write_text(
            'syntax_palette = "dark"\n[colors]\nwindow_bg = [0, 0, 0]\n'
        )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    names = theme.get_palette_names()
    for name in ["solarized_dark", "solarized_light", "gruvbox_dark", "moss"]:
        assert name in names, f"missing theme: {name}"


def test_solarized_light_uses_light_syntax_palette(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "solarized_light.toml").write_text(
        'syntax_palette = "light"\n[colors]\nwindow_bg = [253, 246, 227]\n'
    )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    assert theme.get_syntax_palette_for_theme("solarized_light") == "light"
  • Step 4.3: Create themes/solarized_dark.toml
# Solarized Dark — Ethan Schoonover's canonical dark palette
syntax_palette = "dark"
description = "Solarized Dark by Ethan Schoonover"

[colors]
window_bg         = [  0,  43,  54]   # base03
child_bg          = [  7,  54,  66]   # base02
popup_bg          = [  0,  43,  54]
border            = [ 88, 110, 117]   # base01
frame_bg          = [  7,  54,  66]
frame_bg_hovered  = [ 88, 110, 117]
frame_bg_active   = [101, 123, 131]
title_bg          = [  7,  54,  66]
title_bg_active   = [ 88, 110, 117]
menu_bar_bg       = [  0,  43,  54]
scrollbar_bg      = [  7,  54,  66]
scrollbar_grab    = [ 88, 110, 117]
scrollbar_grab_hovered = [131, 148, 150]
scrollbar_grab_active  = [253, 246, 227]
button            = [  7,  54,  66]
button_hovered    = [ 38, 139, 210]   # blue
button_active     = [ 38, 139, 210]
header            = [  7,  54,  66]
header_hovered    = [ 38, 139, 210]
header_active     = [ 38, 139, 210]
separator         = [ 88, 110, 117]
separator_hovered = [ 38, 139, 210]
separator_active  = [203,  75,  22]   # orange
tab               = [  7,  54,  66]
tab_hovered       = [ 38, 139, 210]
tab_selected      = [ 88, 110, 117]
text              = [147, 161, 161]   # base1
text_disabled     = [ 88, 110, 117]   # base01
text_selected_bg  = [ 38, 139, 210]
check_mark        = [ 38, 139, 210]
slider_grab       = [ 38, 139, 210]
slider_grab_active = [ 38, 139, 210]
table_header_bg   = [  7,  54,  66]
  • Step 4.4: Create themes/solarized_light.toml
# Solarized Light — Ethan Schoonover's canonical light palette
syntax_palette = "light"
description = "Solarized Light by Ethan Schoonover"

[colors]
window_bg         = [238, 232, 213]   # base2
child_bg          = [253, 246, 227]   # base3
popup_bg          = [253, 246, 227]
border            = [147, 161, 161]   # base1
frame_bg          = [253, 246, 227]
frame_bg_hovered  = [238, 232, 213]
frame_bg_active   = [238, 232, 213]
title_bg          = [238, 232, 213]
title_bg_active   = [147, 161, 161]
menu_bar_bg       = [238, 232, 213]
scrollbar_bg      = [238, 232, 213]
scrollbar_grab    = [147, 161, 161]
scrollbar_grab_hovered = [131, 148, 150]
scrollbar_grab_active  = [  7,  54,  66]
button            = [253, 246, 227]
button_hovered    = [ 38, 139, 210]   # blue
button_active     = [ 38, 139, 210]
header            = [253, 246, 227]
header_hovered    = [ 38, 139, 210]
header_active     = [ 38, 139, 210]
separator         = [147, 161, 161]
separator_hovered = [181, 137,   0]   # yellow
separator_active  = [203,  75,  22]   # orange
tab               = [238, 232, 213]
tab_hovered       = [ 38, 139, 210]
tab_selected      = [147, 161, 161]
text              = [  7,  54,  66]   # base02
text_disabled     = [147, 161, 161]   # base1
text_selected_bg  = [ 38, 139, 210]
check_mark        = [ 38, 139, 210]
slider_grab       = [ 38, 139, 210]
slider_grab_active = [ 38, 139, 210]
table_header_bg   = [238, 232, 213]
  • Step 4.5: Create themes/gruvbox_dark.toml
# Gruvbox Dark — Pavel Pertsev's warm retro palette
syntax_palette = "retro_blue"
description = "Gruvbox Dark by Pavel Pertsev (github.com/morhetz/gruvbox)"

[colors]
window_bg         = [ 40,  40,  40]   # bg
child_bg          = [ 50,  48,  47]   # bg1
popup_bg          = [ 50,  48,  47]
border            = [ 60,  56,  54]
frame_bg          = [ 50,  48,  47]
frame_bg_hovered  = [ 80,  80,  80]
frame_bg_active   = [ 90,  90,  90]
title_bg          = [ 40,  40,  40]
title_bg_active   = [ 80,  80,  80]
menu_bar_bg       = [ 40,  40,  40]
scrollbar_bg      = [ 40,  40,  40]
scrollbar_grab    = [ 80,  80,  80]
scrollbar_grab_hovered = [251, 241, 199]
scrollbar_grab_active  = [251, 241, 199]
button            = [ 60,  56,  54]
button_hovered    = [180, 120,  40]   # orange
button_active     = [200, 140,   0]   # bright orange
header            = [ 60,  56,  54]
header_hovered    = [180, 120,  40]
header_active     = [251,  73,  52]   # red
separator         = [ 80,  80,  80]
separator_hovered = [180, 120,  40]
separator_active  = [251, 241, 199]
tab               = [ 60,  56,  54]
tab_hovered       = [180, 120,  40]
tab_selected      = [ 80,  80,  80]
text              = [251, 241, 199]   # fg
text_disabled     = [146, 131, 116]   # comment
text_selected_bg  = [180, 120,  40]
check_mark        = [184, 187,  38]   # green
slider_grab       = [184, 187,  38]
slider_grab_active = [184, 187,  38]
table_header_bg   = [ 60,  56,  54]
  • Step 4.6: Create themes/moss.toml
# Moss — green-tinted dark theme
syntax_palette = "mariana"
description = "Moss — green-tinted dark theme"

[colors]
window_bg         = [ 40,  47,  49]   # green-gray
child_bg          = [ 24,  32,  30]
popup_bg          = [ 20,  35,  35]
border            = [ 60,  80,  90]
frame_bg          = [ 50,  70,  80]
frame_bg_hovered  = [ 60,  90, 100]
frame_bg_active   = [ 70, 100, 110]
title_bg          = [ 40,  47,  49]
title_bg_active   = [ 42,  77,  50]   # mossy green
menu_bar_bg       = [ 40,  47,  49]
scrollbar_bg      = [ 40,  47,  49]
scrollbar_grab    = [ 80,  80,  80]
scrollbar_grab_hovered = [100, 100, 100]
scrollbar_grab_active  = [120, 120, 120]
button            = [ 60,  80,  90]
button_hovered    = [105, 101, 255]   # blue accent
button_active     = [120,  80, 200]
header            = [ 60,  80,  90]
header_hovered    = [120, 160, 130]   # green
header_active     = [ 42,  77,  50]   # mossy green
separator         = [ 60,  80,  90]
separator_hovered = [120, 160, 130]
separator_active  = [105, 101, 255]
tab               = [ 60,  80,  90]
tab_hovered       = [ 80, 100, 110]
tab_selected      = [ 42,  77,  50]   # mossy green
text              = [255, 255, 255]
text_disabled     = [208, 208, 208]
text_selected_bg  = [105, 101, 255]
check_mark        = [120, 160, 130]   # green
slider_grab       = [120, 160, 130]
slider_grab_active = [120, 160, 130]
table_header_bg   = [ 50,  70,  80]
  • Step 4.7: Run the new test to verify it passes
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "all_four or solarized_light_uses_light"

Expected: 2 passed.

  • Step 4.8: Commit
git -C C:\projects\manual_slop add themes/ tests/test_theme.py
git -C C:\projects\manual_slop commit -m "feat(themes): add Solarized Dark/Light, Gruvbox Dark, Moss TOML themes"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "Adds 4 TOML themes sourced from the 10x editor ColorSchemes: Solarized Dark/Light (Ethan Schoonover), Gruvbox Dark (Pavel Pertsev), Moss (green-tinted dark). Each TOML declares a syntax_palette mapping to one of the 4 built-in imgui_color_text_edit palettes (dark/light/mariana/retro_blue) so code blocks re-tint with the active UI theme. Other themes can be added by dropping a .toml in the themes/ directory — no recompile needed." $h

Task 5: Wire markdown_helper to apply the syntax palette on render

Files:

  • Modify: src/markdown_helper.py

  • Test: tests/test_markdown_render_robust.py

  • Step 5.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 5.2: Add the failing test

Append to tests/test_markdown_render_robust.py:

def test_render_applies_current_syntax_palette(monkeypatch):
    """When a theme is active, the render path should call set_default_palette
    so new code-block editors pick up the theme's syntax palette."""
    import sys
    from src import theme_2 as theme

    class _FakeEditor:
        PaletteId = type("PaletteId", (), {"dark": 0, "light": 1, "mariana": 2, "retro_blue": 3})()
        @staticmethod
        def set_default_palette(palette_id):
            _FakeEditor.last_palette = palette_id
            _FakeEditor.last_call = True

    monkeypatch.setitem(sys.modules, "imgui_bundle.imgui_color_text_edit", type("M", (), {"TextEditor": _FakeEditor}))
    monkeypatch.setattr(theme, "_current_palette", "solarized_dark")
    monkeypatch.setattr(theme, "get_syntax_palette_for_theme", lambda n: "dark")

    from src.markdown_helper import MarkdownRenderer
    with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui:
        _mock_table_calls(mock_table_imgui)
        MarkdownRenderer().render("hello world", context_id="p")
        assert getattr(_FakeEditor, "last_call", False), "expected set_default_palette to be called"
  • Step 5.3: Run the test to verify it fails
cd C:\projects\manual_slop; uv run pytest tests/test_markdown_render_robust.py -v --timeout=30 -k "applies_current_syntax_palette"

Expected: FAIL.

  • Step 5.4: Wire apply_syntax_palette into the renderer's code-block path

In src/markdown_helper.py, modify the __init__ of MarkdownRenderer to capture the current syntax palette on construction, and modify _render_code_block to apply it. Replace:

    # Language mapping for ImGuiColorTextEdit
    self._lang_map = {

with:

    # Apply the current theme's syntax palette on construction so new
    # editors we create pick up the right colors. The renderer is re-created
    # when the theme changes (see theme_2 module-load behavior).
    from src import theme_2
    palette_id = theme_2.get_syntax_palette_for_theme(theme_2.get_current_palette())
    theme_2.apply_syntax_palette(palette_id)

    # Language mapping for ImGuiColorTextEdit
    self._lang_map = {
  • Step 5.5: Run the test to verify it passes
cd C:\projects\manual_slop; uv run pytest tests/test_markdown_render_robust.py -v --timeout=30 -k "applies_current_syntax_palette"

Expected: PASS.

  • Step 5.6: Run the full markdown test suite to make sure we haven't regressed
cd C:\projects\manual_slop; uv run pytest tests/test_markdown_render_robust.py tests/test_markdown_helper_bullets.py tests/test_markdown_table.py tests/test_markdown_table_render.py tests/test_markdown_table_columns.py tests/test_markdown_table_wrapped.py -v --timeout=30

Expected: all previously-passing tests still pass.

  • Step 5.7: Commit
git -C C:\projects\manual_slop add src/markdown_helper.py tests/test_markdown_render_robust.py
git -C C:\projects\manual_slop commit -m "feat(markdown): apply active theme syntax palette to code blocks"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "MarkdownRenderer now calls theme_2.apply_syntax_palette on construction, using get_syntax_palette_for_theme(get_current_palette()). When the user changes themes, new code-block editors pick up the new palette; cached editors keep their previous palette until the next block renders. set_default_palette is the upstream API that affects future TextEditor instances." $h

Task 6: Phase Completion Verification and Docs

Files:

  • Create: docs/guide_themes.md

  • Test: tests/test_theme.py

  • Step 6.1: Pre-edit checkpoint

git -C C:\projects\manual_slop add .
  • Step 6.2: Add the final integration test

Append to tests/test_theme.py:

def test_solarized_dark_apply_does_not_raise(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "solarized_dark.toml").write_text(
        'syntax_palette = "dark"\n[colors]\nwindow_bg = [0, 43, 54]\n'
    )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    try:
        theme.apply("solarized_dark")
    except Exception as e:
        pytest.fail(f"apply(solarized_dark) raised: {e}")
    assert theme.get_current_palette() == "solarized_dark"


def test_gruvbox_dark_apply_does_not_raise(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "gruvbox_dark.toml").write_text(
        'syntax_palette = "retro_blue"\n[colors]\nwindow_bg = [40, 40, 40]\n'
    )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    theme.apply("gruvbox_dark")
    assert theme.get_current_palette() == "gruvbox_dark"


def test_moss_apply_does_not_raise(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "moss.toml").write_text(
        'syntax_palette = "mariana"\n[colors]\nwindow_bg = [40, 47, 49]\n'
    )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    theme.apply("moss")
    assert theme.get_current_palette() == "moss"


def test_solarized_light_apply_does_not_raise(tmp_path, monkeypatch):
    themes_dir = tmp_path / "themes"
    themes_dir.mkdir()
    (themes_dir / "solarized_light.toml").write_text(
        'syntax_palette = "light"\n[colors]\nwindow_bg = [253, 246, 227]\n'
    )
    monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
    theme.load_themes_from_disk()
    theme.apply("solarized_light")
    assert theme.get_current_palette() == "solarized_light"
  • Step 6.3: Run the test to verify all 4 themes apply without error
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "apply_does_not_raise"

Expected: 4 passed.

  • Step 6.4: Create the authoring guide

Create docs/guide_themes.md:

# Themes — Authoring Guide

## File Layout

- Global themes: `themes.toml` (single multi-theme file) OR `themes/<name>.toml` (one file per theme)
- Project-specific overrides: `<project>/project_themes.toml`

Both layouts are scanned and merged; project themes with the same name as a global theme override it.

Override the global path via the `SLOP_GLOBAL_THEMES` env var.

## Schema

```toml
# human-readable label
description = "Solarized Dark by Ethan Schoonover"

# one of: dark | light | mariana | retro_blue
# selects which built-in imgui_color_text_edit palette to apply
syntax_palette = "dark"

[colors]
# RGB triples, 0-255
window_bg    = [  0,  43,  54]
text         = [147, 161, 161]
button_hovered = [ 38, 139, 210]
# ... any imgui.Col_ key is accepted
```

`[colors]` is required. Missing required section is a hard error (logged to stderr, theme skipped).

## Available Color Keys

All keys are imgui `Col_` enum members in snake_case. The loader does best-effort mapping; unknown keys are silently ignored. Common ones: `window_bg`, `child_bg`, `popup_bg`, `border`, `frame_bg`, `title_bg`, `menu_bar_bg`, `scrollbar_bg`, `button`, `header`, `separator`, `tab`, `text`, `text_disabled`, `check_mark`, `slider_grab`, `table_header_bg`.

## Syntax Palette Mapping

`imgui-bundle` ships four built-in `imgui_color_text_edit` palettes and exposes no API to define new ones. We pick the closest match per theme:

| UI Theme | Syntax Palette |
|---|---|
| Solarized Dark | `dark` |
| Solarized Light | `light` |
| Gruvbox Dark | `retro_blue` |
| Moss | `mariana` |
| (anything else) | `dark` |

You can override the mapping per theme by setting the `syntax_palette` field in the TOML.

## Hot Reload

Theme TOMLs are loaded once at module init. To pick up a new file, call `theme.load_themes_from_disk()` (or restart the app).
  • Step 6.5: Commit
git -C C:\projects\manual_slop add docs/guide_themes.md tests/test_theme.py
git -C C:\projects\manual_slop commit -m "docs(themes): add authoring guide for TOML theme system"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "Authoring guide for the new TOML theme system. Covers file layout, schema, available color keys, syntax palette mapping, hot-reload behavior, and the upstream imgui_color_text_edit constraint that limits us to 4 built-in syntax palettes." $h

Task 7: Phase Completion Verification and Checkpoint

  • Step 7.1: Run the full theme-related test suite
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py tests/test_theme_models.py tests/test_theme_nerv.py tests/test_theme_nerv_fx.py tests/test_theme_nerv_alert.py tests/test_markdown_render_robust.py -v --timeout=30

Expected: all tests pass.

  • Step 7.2: Smoke-test that the four new theme names appear in get_palette_names
cd C:\projects\manual_slop; uv run python -c "from src import theme_2; print(theme_2.get_palette_names())"

Expected: the list includes "solarized_dark", "solarized_light", "gruvbox_dark", "moss".

  • Step 7.3: Create the checkpoint commit
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Theme & syntax modularization complete"
$h = git -C C:\projects\manual_slop log -1 --format='%H'
git -C C:\projects\manual_slop notes add -m "Track complete. Theme TOML loading shipped, 4 new themes (Solarized D/L, Gruvbox D, Moss) authored, syntax palette mapping wired through MarkdownRenderer. New public API: load_themes_from_disk, get_syntax_palette_for_theme, apply_syntax_palette. Existing apply/get_palette_names API preserved. Upstream imgui_color_text_edit limit (4 built-in palettes, no custom color override) documented in spec and guide." $h

Self-Review

  • Spec coverage: All goals covered — TOML schema (Task 2), paths (Task 1), loader (Task 3), 4 new themes (Task 4), syntax palette wiring (Task 5), authoring guide (Task 6), checkpoint (Task 7). ✓
  • Placeholder scan: No "TBD", "fill in later", "implement error handling" without code. ✓
  • Type consistency: ThemeFile, ThemePalette, load_themes_from_disk, get_syntax_palette_for_theme, apply_syntax_palette, get_current_palette used consistently across all tasks. ✓
  • Backward compat: All existing palette names still resolve; apply() still works; get_palette_names() still returns the same shape. ✓
  • imgui_color_text_edit constraint: Documented in spec under "Constraints" and in guide. The plan accepts the limit rather than trying to work around it. ✓
  • No regressions: Tasks 3.6, 5.6, 7.1 each run the existing test suite to catch regressions. ✓