From cd24c43f8fdc8de7c9fe805d18c4616b195efdb1 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Thu, 4 Jun 2026 22:20:58 -0400 Subject: [PATCH] conductor(plan): theme + syntax modularization - 7-task plan --- .../tracks/multi_themes_20260604/plan.md | 1114 +++++++++++++++++ .../tracks/multi_themes_20260604/spec.md | 105 ++ 2 files changed, 1219 insertions(+) create mode 100644 conductor/tracks/multi_themes_20260604/plan.md create mode 100644 conductor/tracks/multi_themes_20260604/spec.md diff --git a/conductor/tracks/multi_themes_20260604/plan.md b/conductor/tracks/multi_themes_20260604/plan.md new file mode 100644 index 00000000..5d99e456 --- /dev/null +++ b/conductor/tracks/multi_themes_20260604/plan.md @@ -0,0 +1,1114 @@ +# 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/.toml` (global) and `/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:** `(): `. +- **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** + +```powershell +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`: + +```python +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")) + + +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** + +```powershell +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: /themes/ directory) and get_project_themes_path(project_root) (default: /project_themes.toml file). Global is a directory of per-theme .toml files; project is a single bundled .toml with a [themes] table. 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** + +```powershell +git -C C:\projects\manual_slop add . +``` + +- [ ] **Step 2.2: Create the test fixtures** + +Create `tests/fixtures/themes/minimal.toml`: + +```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`: + +```toml +# missing [colors] section +syntax_palette = "dark" +``` + +- [ ] **Step 2.3: Create the failing test file** + +Create `tests/test_theme_models.py`: + +```python +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** + +```powershell +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`: + +```python +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** + +```powershell +cd C:\projects\manual_slop; uv run pytest tests/test_theme_models.py -v --timeout=30 +``` + +Expected: 5 passed. + +- [ ] **Step 2.7: Commit** + +```powershell +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** + +```powershell +git -C C:\projects\manual_slop add . +``` + +- [ ] **Step 3.2: Add the failing test for TOML loading** + +Append to `tests/test_theme.py`: + +```python +import os +import tempfile +from src import theme_2 as theme +from src import paths as paths_mod + + +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(paths_mod, "get_global_themes_path", 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(paths_mod, "get_global_themes_path", 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(paths_mod, "get_global_themes_path", 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** + +```powershell +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: + +```python +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: + +```python +# 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 _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 _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(): + if hasattr(imgui.Col_, col_name): + enum_val = getattr(imgui.Col_, col_name) + if isinstance(enum_val, int): + out[enum_val] = _hex(rgb) + return out +``` + +Then replace the existing `get_palette_names` function with: + +```python +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`): + +```python +def load_themes_from_disk() -> None: + """Load all themes from the global themes directory. Each `*.toml` + file in the directory is one theme. Idempotent — safe to call repeatedly. + Broken entries are logged and skipped.""" + global _TOML_PALETTES, _TOML_COLOUR_CACHE + themes_dir = get_global_themes_path() + loaded: dict[str, ThemeFile] = {} + if themes_dir.exists() and themes_dir.is_dir(): + loaded.update(load_themes_from_dir(themes_dir, 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: + +```python + 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: + +```python +load_themes_from_disk() +``` + +- [ ] **Step 3.5: Run the test to verify it passes** + +```powershell +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** + +```powershell +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** + +```powershell +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** + +```powershell +git -C C:\projects\manual_slop add . +``` + +- [ ] **Step 4.2: Add the failing test for new themes** + +Append to `tests/test_theme.py`: + +```python +def test_all_four_new_themes_registered(tmp_path, monkeypatch): + from src import paths as paths_mod + + 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(paths_mod, "get_global_themes_path", 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): + from src import paths as paths_mod + + 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(paths_mod, "get_global_themes_path", 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`** + +```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`** + +```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`** + +```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`** + +```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** + +```powershell +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** + +```powershell +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** + +```powershell +git -C C:\projects\manual_slop add . +``` + +- [ ] **Step 5.2: Add the failing test** + +Append to `tests/test_markdown_render_robust.py`: + +```python +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** + +```powershell +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: + +```python + # Language mapping for ImGuiColorTextEdit + self._lang_map = { +``` + +with: + +```python + # 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** + +```powershell +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** + +```powershell +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** + +```powershell +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** + +```powershell +git -C C:\projects\manual_slop add . +``` + +- [ ] **Step 6.2: Add the final integration test** + +Append to `tests/test_theme.py`: + +```python +def test_solarized_dark_apply_does_not_raise(tmp_path, monkeypatch): + from src import paths as paths_mod + + 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(paths_mod, "get_global_themes_path", 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): + from src import paths as paths_mod + + 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(paths_mod, "get_global_themes_path", 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): + from src import paths as paths_mod + + 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(paths_mod, "get_global_themes_path", 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): + from src import paths as paths_mod + + 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(paths_mod, "get_global_themes_path", 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** + +```powershell +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`: + +````markdown +# Themes — Authoring Guide + +## File Layout + +- Global themes: `themes.toml` (single multi-theme file) OR `themes/.toml` (one file per theme) +- Project-specific overrides: `/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** + +```powershell +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** + +```powershell +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`** + +```powershell +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** + +```powershell +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. ✓ + diff --git a/conductor/tracks/multi_themes_20260604/spec.md b/conductor/tracks/multi_themes_20260604/spec.md new file mode 100644 index 00000000..ce083e56 --- /dev/null +++ b/conductor/tracks/multi_themes_20260604/spec.md @@ -0,0 +1,105 @@ +# Theme & Syntax Highlighting Modularization + +## Problem + +The current theming system in `src/theme_2.py` has three limitations: + +1. **Themes are hardcoded as a Python dict.** Users cannot author new themes without editing Python source and recompiling. This is inconsistent with the rest of the project (presets, personas, tool_presets, context_presets, bias profiles, workspace profiles all use TOML). + +2. **Syntax highlighting is hardcoded.** The `MarkdownRenderer._lang_map` in `src/markdown_helper.py` uses `imgui-bundle`'s `imgui_color_text_edit` language definitions whose token colors are baked into the C++ library. There is no way to align syntax token colors with the active UI theme. + +3. **No way to bundle new themes with a release or share them between projects.** + +## Goals + +- **TOML-based theme authoring.** Themes live in `themes/.toml` (global) and `/project_themes.toml` (project override). Schema mirrors the existing `_PALETTES` dict shape. + +- **Authoring without recompiling.** Drop a new `.toml` file in `themes/` and it appears in the palette selector after the next load (or hot-reload, future). + +- **Syntax palette mapping.** Each theme TOML declares a `syntax_palette` field that maps to one of the four built-in `imgui_color_text_edit` palettes (`dark`, `light`, `mariana`, `retro_blue`). The renderer calls `editor.set_default_palette(...)` whenever the active theme changes. + +- **Scope-based merging** matches the existing pattern: project themes override global themes with the same name. + +## Constraints + +- `imgui-bundle` only ships 4 built-in syntax palettes and exposes no API to define new ones or override individual token colors. This is a hard upstream limit. The plan accepts the limit and works around it via palette mapping. + +- We do NOT attempt to wrap or shadow `imgui_color_text_edit`. The C++ library owns the per-language token regexes and default token colors. We pick the closest of the 4 palettes for each theme and let users override the mapping per theme. + +## Out of scope + +- Defining new `imgui_color_text_edit` palettes or overriding token colors per language (blocked by upstream API). +- Hot-reload of theme changes (the user can re-apply from the selector). +- Per-language color customization (e.g., Python `keyword` color distinct from C `keyword`). + +## File structure + +| File | Action | Responsibility | +|---|---|---| +| `src/theme_2.py` | Modify | Replace hardcoded `_PALETTES` dict with a load-from-TOML pipeline. Keep `apply()` public API. Expose new helpers `get_syntax_palette_for_theme(name)` and `apply_syntax_palette(palette_id)`. | +| `src/paths.py` | Modify | Add `get_global_themes_path()` returning `/themes/` (directory) and `get_project_themes_path(project_root)` returning `/project_themes.toml` (file). Override `get_global_themes_path()` via the `SLOP_GLOBAL_THEMES` env var. | +| `src/theme_models.py` | Create | `ThemePalette` dataclass + `ThemeFile` schema; `from_dict()` / `to_dict()` round-trip; imgui.Col_ key normalization; loaders for both per-file (`themes/*.toml`) and bundled (`project_themes.toml`) layouts. | +| `themes/solarized_dark.toml` | Create | Authoring artifact. RGB triples in standard 0-255 form. | +| `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` (both per-file and bundled layouts). | +| `tests/test_theme.py` | Modify | Add tests for the 4 new palettes, TOML loading, scope merge, and syntax palette mapping. | +| `tests/fixtures/themes/minimal.toml` | Create | Minimal valid TOML fixture for loader tests. | +| `tests/fixtures/themes/missing_required.toml` | Create | TOML missing required keys — should raise a clear error. | +| `tests/fixtures/themes/bundled_project.toml` | Create | Multi-theme project override fixture (bundled format). | +| `docs/guide_themes.md` | Create | Authoring guide: schema, file locations, scope rules, syntax palette mapping, env vars. | + +## Theme TOML schema (reference, not implementation in this plan) + +```toml +# theme name (informational) +name = "Solarized Dark" + +# optional: which built-in imgui_color_text_edit palette to use +# one of: dark | light | mariana | retro_blue +syntax_palette = "dark" + +# which imgui style colors this theme overrides +# any key not listed falls back to the base imgui dark/light defaults +[colors] +window_bg = [ 0, 43, 54] # 0x002b36 base03 +child_bg = [ 7, 54, 66] # 0x073642 base02 +text = [147, 161, 161] # 0x93a1a1 base1 +text_disabled = [ 88, 110, 117] # 0x586e75 base01 +button_hovered = [ 38, 139, 210] # 0x268bd2 blue +check_mark = [ 38, 139, 210] +slider_grab = [ 38, 139, 210] +tab_selected = [ 88, 110, 117] +tab_hovered = [ 38, 139, 210] +# ... remaining colors omitted +``` + +Values are 3-element RGB arrays (0-255) for the body and the syntax palette is a string identifier. + +## Syntax palette mapping (built-in only) + +| Theme | Syntax palette | +|---|---| +| Solarized Dark | `dark` (closest dark base) | +| Solarized Light | `light` | +| Gruvbox Dark | `retro_blue` (warm retro feel) | +| Moss | `mariana` (deep blue-green base) | +| 10x Dark | `dark` | +| Nord Dark | `dark` | +| Monokai | `dark` | +| Binks | `light` | +| ImGui Dark | `dark` | +| NERV | `dark` (NERV's own custom palette via `theme_nerv.apply_nerv()`) | + +The mapping lives in `src/theme_2.py` as a small dict and is overridable per theme via the TOML `syntax_palette` field. + +## Public API + +Existing `src.theme_2` callsites must continue to work. New surface: + +- `theme.get_palette_names() -> list[str]` — already exists, now also returns TOML-loaded themes +- `theme.apply(name) -> None` — already exists, applies the named theme (built-in OR TOML) +- `theme.get_syntax_palette_for_theme(name) -> PaletteId` — new +- `theme.apply_syntax_palette(palette_id) -> None` — new, calls `editor.set_default_palette(palette_id)` +- `theme.load_themes_from_disk() -> None` — new, public for hot-reload