Private
Public Access
0
0
Files
manual_slop/docs/superpowers/plans/2026-06-10-prior-session-sepia.md
T

56 KiB

Prior-Session Sepia Tint Implementation Plan

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

Goal: Add a per-palette sepia tint for prior-session views in Manual Slop, with a per-palette slider in the Theme Settings panel that mirrors the existing Tone Mapping section. All math is float (no integer truncation) per the user's explicit requirement.

Architecture: A1 from the brainstorming design — per-render explicit transform. theme_2.apply_prior_tint(rgba, palette) is a pure float helper. Each of the 6 prior-session render sites in src/gui_2.py wraps its theme.get_color() call with one expression. Per-palette state lives in _prior_session_amount: dict[str, float] mirroring the existing _brightness/_contrast/_gamma dicts.

Tech Stack: Python 3.11+, imgui_bundle 1.92.5 (no per-instance Palette struct — see HONEST DISCLOSURE), pytest, tomli_w, existing live_gui / isolate_workspace / reset_paths fixtures.

HONEST DISCLOSURE (carried from spec §1.1.1): This plan does NOT make code blocks (TextEditor syntax tokens) tonemap-aware. The upstream imgui_bundle 1.92.5 API only exposes get_palette() -> PaletteId (4-value enum) and set_palette(PaletteId). There is no Palette struct with mutable per-color slots. The pre-existing "code blocks not tonemap-aware" bug persists; fixing it requires forking the library (a separate, larger effort). The plan is honest about this in the verification criteria and the final track report.

Spec: conductor/tracks/prior_session_sepia_20260610/spec.md (504 lines) Design doc: docs/superpowers/specs/2026-06-10-prior-session-sepia-design.md (112 lines)


File Structure

File Action Purpose
src/theme_2.py Modify Add _prior_session_amount dict + 3 accessors; add _desaturate, _lerp_rgba, _imvec4_to_rgba, _rgba_to_imvec4, apply_prior_tint; add 3 keys to fallback dict; extend save_to_config / load_from_config
src/theme_models.py Modify Add 3 fields to ThemePalette; update from_dict / to_dict / validator
src/gui_2.py Modify 2 bubble_vendorprior_session_bg swaps; 4 apply_prior_tint wraps; 1 new Theme panel section
themes/10x_dark.toml Modify Add 3 new keys
themes/binks.toml Modify Add 3 new keys
themes/gruvbox_dark.toml Modify Add 3 new keys
themes/monokai.toml Modify Add 3 new keys
themes/moss.toml Modify Add 3 new keys
themes/nord_dark.toml Modify Add 3 new keys
themes/solarized_dark.toml Modify Add 3 new keys
themes/solarized_light.toml Modify Add 3 new keys
tests/test_prior_session_amount.py Create Per-palette dict semantics
tests/test_prior_session_tint.py Create Math contract: identity/pure/monotonic/alpha
tests/test_prior_session_toml.py Create Round-trip + validation
tests/test_prior_session_render.py Create ImVec4 ↔ tuple round-trip
tests/test_prior_session_persistence.py Create save_to_config / load_from_config round-trip

The live_gui test fixtures in tests/conftest.py are reused as-is; no new fixtures are needed for this track.


Conventions

Code style (per conductor/workflow.md):

  • 1-space indentation for ALL Python code. No tabs, no 4-space indents.
  • CRLF line endings on Windows.
  • No comments in source code (type hints + docstrings only). Comments go in /docs.
  • Type hints required for all public functions and module-level state.

Float math (per user requirement):

  • All transform math in this plan uses float (not int).
  • set_prior_session_amount coerces inputs to float and clamps to [0.0, 1.0].
  • The slider is imgui.slider_float, not slider_int.
  • The TOML key prior_session_amount is parsed as float by tomllib (which yields float for any number with a decimal point, regardless of whether the user wrote 0.3 or 0.30).

Per-task commit discipline (per conductor/workflow.md §9):

  • One atomic commit per task. Use git add <exact-paths> and git commit -m "..." with a 1-3 sentence message.
  • No diagnostic sys.stderr.write("[XYZ_DIAG] ...") in production code. Test instrumentation goes to tests/artifacts/.
  • HARD BAN: git restore, git checkout -- <file>, git reset are FORBIDDEN without explicit user permission.

TDD Red→Green→Refactor:

  • Each non-trivial task has a failing test (Step 1), a run-to-fail (Step 2), a minimal implementation (Step 3), a run-to-pass (Step 4), and a commit (Step 5).
  • Trivial mechanical tasks (TOML file edits, swap bubble_vendor for prior_session_bg) skip the TDD cycle but still get their own commit.

Phase 1: Theme model + state + helpers

Task 1: Failing test for per-palette state dict semantics

Files:

  • Create: tests/test_prior_session_amount.py

  • Step 1: Write the failing test

Create the file tests/test_prior_session_amount.py with the following content:

"""Unit tests for _prior_session_amount per-palette state dict.

Mirrors the per-palette semantics of _brightness/_contrast/_gamma.
"""
import pytest


@pytest.fixture(autouse=True)
def _reset_module_state():
    """Reset the module-level state dict before and after each test."""
    from src import theme_2
    saved = dict(theme_2._prior_session_amount)
    theme_2._prior_session_amount.clear()
    yield
    theme_2._prior_session_amount.clear()
    theme_2._prior_session_amount.update(saved)


def test_default_amount_is_0p3_for_unknown_palette():
    """get_prior_session_amount returns 0.3 for palettes not in the dict."""
    from src.theme_2 import get_prior_session_amount
    assert get_prior_session_amount("Some Unknown Palette") == 0.3


def test_set_prior_session_amount_stores_float_in_dict():
    """set stores a float; the dict is keyed by palette name."""
    from src.theme_2 import (
        _prior_session_amount,
        get_prior_session_amount,
        set_prior_session_amount,
    )
    set_prior_session_amount("10x Dark", 0.7)
    assert _prior_session_amount["10x Dark"] == 0.7
    assert get_prior_session_amount("10x Dark") == 0.7


def test_set_prior_session_amount_clamps_to_unit_interval():
    """Values below 0.0 clamp to 0.0; above 1.0 clamp to 1.0."""
    from src.theme_2 import get_prior_session_amount, set_prior_session_amount
    set_prior_session_amount("P1", -0.5)
    assert get_prior_session_amount("P1") == 0.0
    set_prior_session_amount("P1", 1.5)
    assert get_prior_session_amount("P1") == 1.0


def test_set_prior_session_amount_coerces_int_to_float():
    """Integer inputs are promoted to float (user requirement: float-only math)."""
    from src.theme_2 import (
        _prior_session_amount,
        set_prior_session_amount,
    )
    set_prior_session_amount("P2", 1)  # int input
    assert isinstance(_prior_session_amount["P2"], float)
    assert _prior_session_amount["P2"] == 1.0


def test_palettes_have_independent_state():
    """Setting one palette does not affect another."""
    from src.theme_2 import get_prior_session_amount, set_prior_session_amount
    set_prior_session_amount("A", 0.2)
    set_prior_session_amount("B", 0.8)
    assert get_prior_session_amount("A") == 0.2
    assert get_prior_session_amount("B") == 0.8
    assert get_prior_session_amount("C") == 0.3


def test_reset_prior_session_removes_palette_from_dict():
    """reset_prior_session removes the key; the palette reverts to default."""
    from src.theme_2 import (
        _prior_session_amount,
        get_prior_session_amount,
        reset_prior_session,
        set_prior_session_amount,
    )
    set_prior_session_amount("Solarized Dark", 0.6)
    assert "Solarized Dark" in _prior_session_amount
    reset_prior_session("Solarized Dark")
    assert "Solarized Dark" not in _prior_session_amount
    assert get_prior_session_amount("Solarized Dark") == 0.3


def test_reset_prior_session_is_idempotent_for_missing_palette():
    """reset_prior_session on a palette not in the dict is a no-op."""
    from src.theme_2 import reset_prior_session
    reset_prior_session("Never Set")
  • Step 2: Run test to verify it fails

Run:

uv run pytest tests/test_prior_session_amount.py -v

Expected: FAIL with ImportError: cannot import name 'get_prior_session_amount' from 'src.theme_2'.

  • Step 3: Implement minimal accessors in src/theme_2.py

Open src/theme_2.py. Locate the existing tonemap state block. The relevant lines are around line 75-77:

_brightness: dict[str, float] = {}
_contrast:   dict[str, float] = {}
_gamma:      dict[str, float] = {}

Insert the new state + accessors immediately after the _gamma: dict[str, float] = {} line (so the three new symbols are co-located with the tonemap state they mirror):

_prior_session_amount: dict[str, float] = {}

def _get_psa(palette: str, default: float) -> float:
    return _prior_session_amount.get(palette, default)

def get_prior_session_amount(palette: str) -> float:
    """Return the per-palette prior-session sepia amount in [0.0, 1.0]."""
    return _get_psa(palette, 0.3)

def set_prior_session_amount(palette: str, val: float) -> None:
    """Clamp val to [0.0, 1.0], coerce to float, store in the per-palette dict."""
    val = max(0.0, min(1.0, float(val)))
    _prior_session_amount[palette] = val

def reset_prior_session(palette: str) -> None:
    """Remove the per-palette override; the default (0.3) takes effect."""
    _prior_session_amount.pop(palette, None)
  • Step 4: Run test to verify it passes

Run:

uv run pytest tests/test_prior_session_amount.py -v

Expected: PASS (7 tests).

  • Step 5: Commit
git add tests/test_prior_session_amount.py src/theme_2.py
git commit -m "feat(theme): add per-palette _prior_session_amount state dict"

Task 2: Failing test for apply_prior_tint math contract

Files:

  • Create: tests/test_prior_session_tint.py

  • Step 1: Write the failing test

Create the file tests/test_prior_session_tint.py:

"""Unit tests for the apply_prior_tint pure helper.

Math contract:
  result = lerp(desaturate(input), tint_color, amount)
where:
  desaturate uses BT.709 luma: 0.2126*R + 0.7152*G + 0.0722*B
  lerp(a, b, t) = a + (b - a) * t  per channel
  amount in [0.0, 1.0]
  alpha is passed through unchanged
"""
import pytest


@pytest.fixture(autouse=True)
def _reset_module_state():
    from src import theme_2
    saved = dict(theme_2._prior_session_amount)
    theme_2._prior_session_amount.clear()
    yield
    theme_2._prior_session_amount.clear()
    theme_2._prior_session_amount.update(saved)


def test_identity_at_amount_zero():
    """At amount=0.0, apply_prior_tint returns the input unchanged."""
    from src.theme_2 import apply_prior_tint
    rgba = (0.4, 0.5, 0.6, 0.9)
    assert apply_prior_tint(rgba, "Any Palette") == rgba


def test_alpha_preserved_at_amount_zero():
    """Alpha is passed through unchanged even when RGB is altered."""
    from src.theme_2 import apply_prior_tint
    rgba = (1.0, 0.0, 0.0, 0.25)
    out = apply_prior_tint(rgba, "Any Palette")
    assert out[3] == 0.25


def test_pure_sepia_at_amount_one():
    """At amount=1.0, output RGB equals the prior_session_tint color (with input alpha)."""
    from src.theme_2 import set_prior_session_amount, apply_prior_tint
    set_prior_session_amount("Tint Test", 1.0)
    out = apply_prior_tint((0.3, 0.8, 0.5, 0.7), "Tint Test")
    # The output RGB should equal prior_session_tint (whatever that is) and alpha=0.7
    assert out[3] == 0.7
    # RGB is fully replaced by tint; input RGB is irrelevant
    assert 0.0 <= out[0] <= 1.0
    assert 0.0 <= out[1] <= 1.0
    assert 0.0 <= out[2] <= 1.0


def test_monotonic_in_amount():
    """As amount increases from 0.0 to 1.0, output moves toward tint monotonically."""
    from src.theme_2 import (
        apply_prior_tint,
        set_prior_session_amount,
    )
    input_rgba = (1.0, 0.0, 0.0, 1.0)  # pure red
    distances_to_tint = []
    for amt in (0.0, 0.25, 0.5, 0.75, 1.0):
        set_prior_session_amount("Mono Test", amt)
        out = apply_prior_tint(input_rgba, "Mono Test")
        # The output should be on the lerp path; compute distance from input
        d = sum(abs(out[i] - input_rgba[i]) for i in range(3))
        distances_to_tint.append(d)
    # Distances should be non-decreasing as amount increases
    for i in range(1, len(distances_to_tint)):
        assert distances_to_tint[i] >= distances_to_tint[i - 1] - 1e-9


def test_output_clamped_to_unit_interval():
    """For any input, output components are in [0.0, 1.0]."""
    from src.theme_2 import apply_prior_tint
    for rgba in (
        (-0.5, 1.5, 0.5, 1.0),  # out-of-range input
        (1.0, 1.0, 1.0, 1.0),
        (0.0, 0.0, 0.0, 1.0),
        (0.5, 0.5, 0.5, 0.5),
    ):
        out = apply_prior_tint(rgba, "Any Palette")
        for c in out:
            assert 0.0 <= c <= 1.0


def test_intermediate_amount_blends_desaturated_with_tint():
    """At amount=0.5, output is roughly the midpoint of desaturate(input) and tint."""
    from src.theme_2 import apply_prior_tint, set_prior_session_amount
    set_prior_session_amount("Blend Test", 0.5)
    # Pure red: desaturate -> (0.2126, 0.2126, 0.2126)
    # The output should be somewhere on the line between gray and the tint
    out = apply_prior_tint((1.0, 0.0, 0.0, 1.0), "Blend Test")
    # All three channels should be roughly equal at 0.5 (gray + tint blend)
    spread = max(out[0], out[1], out[2]) - min(out[0], out[1], out[2])
    assert spread < 0.15  # tight spread, since both endpoints are similar-ish


def test_no_op_for_zero_palette_amount():
    """When the palette's amount is 0.0 (default), apply_prior_tint returns input."""
    from src.theme_2 import apply_prior_tint
    rgba = (0.3, 0.7, 0.2, 0.6)
    assert apply_prior_tint(rgba, "Zero Amount Palette") == rgba


def test_alpha_preserved_at_intermediate_amount():
    """Alpha is passed through unchanged at any amount."""
    from src.theme_2 import apply_prior_tint, set_prior_session_amount
    set_prior_session_amount("Alpha Test", 0.6)
    for alpha in (0.0, 0.3, 0.5, 0.9, 1.0):
        out = apply_prior_tint((0.5, 0.5, 0.5, alpha), "Alpha Test")
        assert out[3] == alpha
  • Step 2: Run test to verify it fails

Run:

uv run pytest tests/test_prior_session_tint.py -v

Expected: FAIL with ImportError: cannot import name 'apply_prior_tint' from 'src.theme_2'.

  • Step 3: Implement apply_prior_tint and friends in src/theme_2.py

Locate def _tone_map(...) in src/theme_2.py (around line 100). Insert the following helpers immediately after the _tone_map function definition (so the new helpers are co-located with the existing color transform):

def _desaturate(rgba: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
    """Convert to grayscale using BT.709 luma. All float math."""
    r, g, b, a = rgba
    luma: float = 0.2126 * r + 0.7152 * g + 0.0722 * b
    return (luma, luma, luma, a)

def _lerp_rgba(
    a: tuple[float, float, float, float],
    b: tuple[float, float, float, float],
    t: float,
) -> tuple[float, float, float, float]:
    """Linear interpolation per channel. All float math; t clamped to [0.0, 1.0]."""
    t = max(0.0, min(1.0, t))
    return (
        a[0] + (b[0] - a[0]) * t,
        a[1] + (b[1] - a[1]) * t,
        a[2] + (b[2] - a[2]) * t,
        a[3] + (b[3] - a[3]) * t,
    )

def apply_prior_tint(
    rgba: tuple[float, float, float, float],
    palette_name: str,
) -> tuple[float, float, float, float]:
    """Apply the per-palette prior-session sepia blend to a color.

    result = lerp(desaturate(input), get_prior_session_tint(palette), get_prior_session_amount(palette))
    Identity at amount=0.0; pure tint at amount=1.0. Alpha preserved.
    Output clamped to [0.0, 1.0] for safety.
    """
    amount: float = get_prior_session_amount(palette_name)
    if amount <= 0.0:
        return rgba
    tint_rgba = get_color("prior_session_tint")
    tint: tuple[float, float, float, float] = (
        float(tint_rgba.x),
        float(tint_rgba.y),
        float(tint_rgba.z),
        float(rgba[3]),
    )
    blended = _lerp_rgba(_desaturate(rgba), tint, amount)
    return (
        max(0.0, min(1.0, blended[0])),
        max(0.0, min(1.0, blended[1])),
        max(0.0, min(1.0, blended[2])),
        blended[3],
    )
  • Step 4: Run test to verify it passes

Run:

uv run pytest tests/test_prior_session_tint.py -v

Expected: PASS (7 tests).

  • Step 5: Commit
git add tests/test_prior_session_tint.py src/theme_2.py
git commit -m "feat(theme): add apply_prior_tint pure helper (float-only math)"

Task 3: Failing test for ThemePalette new fields and TOML round-trip

Files:

  • Create: tests/test_prior_session_toml.py

  • Step 1: Write the failing test

Create the file tests/test_prior_session_toml.py:

"""TOML round-trip + validation tests for the 3 new prior-session theme slots.

The new keys in every themes/*.toml file:
  prior_session_bg      = [R, G, B]      # 3-tuple, 0-255
  prior_session_tint    = [R, G, B]      # 3-tuple, 0-255
  prior_session_amount  = 0.3            # float, [0.0, 1.0]
"""
import tomllib
from pathlib import Path

import pytest

THEMES_DIR = Path("themes")


def test_all_themes_have_three_new_keys():
    """Every themes/*.toml must declare the 3 new keys after the upgrade."""
    theme_files = sorted(THEMES_DIR.glob("*.toml"))
    assert theme_files, "no themes/*.toml files found"
    required_keys = {"prior_session_bg", "prior_session_tint", "prior_session_amount"}
    for path in theme_files:
        with path.open("rb") as f:
            data = tomllib.load(f)
        missing = required_keys - set(data.keys())
        assert not missing, f"{path.name} is missing keys: {missing}"


def test_prior_session_bg_is_3tuple_of_ints():
    """prior_session_bg is a 3-element list of ints in 0-255."""
    theme_files = sorted(THEMES_DIR.glob("*.toml"))
    for path in theme_files:
        with path.open("rb") as f:
            data = tomllib.load(f)
        v = data["prior_session_bg"]
        assert isinstance(v, list) and len(v) == 3, f"{path.name}: not a 3-list"
        for c in v:
            assert isinstance(c, int) and 0 <= c <= 255, f"{path.name}: bad channel {c}"


def test_prior_session_tint_is_3tuple_of_ints():
    """prior_session_tint is a 3-element list of ints in 0-255."""
    theme_files = sorted(THEMES_DIR.glob("*.toml"))
    for path in theme_files:
        with path.open("rb") as f:
            data = tomllib.load(f)
        v = data["prior_session_tint"]
        assert isinstance(v, list) and len(v) == 3, f"{path.name}: not a 3-list"
        for c in v:
            assert isinstance(c, int) and 0 <= c <= 255, f"{path.name}: bad channel {c}"


def test_prior_session_amount_is_float_in_unit_interval():
    """prior_session_amount is a float in [0.0, 1.0]."""
    theme_files = sorted(THEMES_DIR.glob("*.toml"))
    for path in theme_files:
        with path.open("rb") as f:
            data = tomllib.load(f)
        v = data["prior_session_amount"]
        # tomllib parses any decimal number as float
        assert isinstance(v, float), f"{path.name}: not a float (got {type(v).__name__})"
        assert 0.0 <= v <= 1.0, f"{path.name}: amount {v} out of [0.0, 1.0]"


def test_themepalette_dataclass_has_new_fields():
    """ThemePalette (src/theme_models.py) declares the 3 new fields with defaults."""
    from src.theme_models import ThemePalette
    pal = ThemePalette()  # uses defaults
    assert hasattr(pal, "prior_session_bg")
    assert hasattr(pal, "prior_session_tint")
    assert hasattr(pal, "prior_session_amount")
    # Defaults match the fallback dict in theme_2
    assert pal.prior_session_bg == (60, 50, 35)
    assert pal.prior_session_tint == (112, 66, 20)
    assert pal.prior_session_amount == 0.3


def test_themefile_round_trip_preserves_new_fields():
    """ThemeFile.to_dict() and from_dict() round-trip the 3 new fields."""
    from src.theme_models import ThemeFile
    src_path = THEMES_DIR / "10x_dark.toml"
    with src_path.open("rb") as f:
        data = tomllib.load(f)
    tf = ThemeFile.from_dict(data, source_path=src_path)
    dumped = tf.to_dict()
    assert dumped["prior_session_bg"] == data["prior_session_bg"]
    assert dumped["prior_session_tint"] == data["prior_session_tint"]
    assert dumped["prior_session_amount"] == data["prior_session_amount"]


def test_themepalette_validator_rejects_amount_out_of_range():
    """ThemePalette rejects prior_session_amount outside [0.0, 1.0]."""
    from src.theme_models import ThemePalette
    with pytest.raises((ValueError, AssertionError)):
        ThemePalette(prior_session_amount=1.5)
    with pytest.raises((ValueError, AssertionError)):
        ThemePalette(prior_session_amount=-0.1)
  • Step 2: Run test to verify it fails

Run:

uv run pytest tests/test_prior_session_toml.py -v

Expected: FAIL — themes are missing the new keys AND/OR ThemePalette is missing the new fields. The most likely first failure is test_all_themes_have_three_new_keys failing on the first theme because the TOML key is not present.

  • Step 3: Add new fields to ThemePalette in src/theme_models.py

Open src/theme_models.py. Locate the ThemePalette dataclass (around line 86). The current shape is roughly:

class ThemePalette:
    bubble_user:       tuple[int, int, int] = (30, 45, 75)
    bubble_ai:         tuple[int, int, int] = (35, 65, 45)
    bubble_vendor:     tuple[int, int, int] = (65, 55, 30)
    ...

Add the 3 new fields at the end of the dataclass (so existing fields are unchanged):

    prior_session_bg:      tuple[int, int, int] = (60, 50, 35)
    prior_session_tint:    tuple[int, int, int] = (112, 66, 20)
    prior_session_amount:  float                = 0.3

Locate the validator method (if any) on ThemePalette. If a __post_init__ or validator function exists, add a check:

    def __post_init__(self) -> None:
        if not (0.0 <= self.prior_session_amount <= 1.0):
            raise ValueError(
                f"prior_session_amount must be in [0.0, 1.0]; got {self.prior_session_amount}"
            )

(If ThemePalette is a plain @dataclass without __post_init__, add one. If the validation is in a separate validate_theme_file() function, add the check there. Inspect the file's existing structure before adding — match the project style.)

  • Step 4: Add 3 keys to fallback dict in src/theme_2.py

In src/theme_2.py, locate the fallbacks dict inside get_color() (around line 184-201). The current shape is:

fallbacks = {
    "text":              (200, 200, 200),
    "text_disabled":     (130, 130, 130),
    "status_success":    (80, 255, 80),
    "status_warning":    (255, 152, 48),
    ...
    "bubble_vendor":     (65, 55, 30),
    "bubble_system":     (25, 25, 25),
    ...
}

Add the 3 new keys (with the same defaults as ThemePalette):

    "prior_session_bg":     (60, 50, 35),
    "prior_session_tint":   (112, 66, 20),
    "prior_session_amount": 0.3,
  • Step 5: Add the 3 keys to all 8 themes/*.toml files

For each file below, locate the bubble_vendor = ... line and add the 3 new keys immediately after it (or in a logical spot near the other bubble_* entries). Per-theme defaults are in spec §4.7. Use 1-space indentation (matches the existing project style for these TOML files).

themes/10x_dark.toml:

bubble_vendor            = [ 65,  55,  30]
prior_session_bg         = [ 60,  50,  35]
prior_session_tint       = [112,  66,  20]
prior_session_amount     = 0.3

themes/binks.toml:

bubble_vendor            = [255, 240, 200]
prior_session_bg         = [235, 220, 190]
prior_session_tint       = [140,  80,  30]
prior_session_amount     = 0.3

themes/gruvbox_dark.toml:

bubble_vendor            = [ 65,  55,  30]
prior_session_bg         = [ 60,  50,  35]
prior_session_tint       = [112,  66,  20]
prior_session_amount     = 0.3

themes/monokai.toml:

bubble_vendor            = [ 65,  55,  30]
prior_session_bg         = [ 60,  50,  35]
prior_session_tint       = [112,  66,  20]
prior_session_amount     = 0.3

themes/moss.toml:

bubble_vendor            = [ 65,  55,  30]
prior_session_bg         = [ 60,  50,  35]
prior_session_tint       = [112,  66,  20]
prior_session_amount     = 0.3

themes/nord_dark.toml:

bubble_vendor            = [ 65,  55,  30]
prior_session_bg         = [ 60,  50,  35]
prior_session_tint       = [112,  66,  20]
prior_session_amount     = 0.3

themes/solarized_dark.toml:

bubble_vendor            = [ 65,  55,  30]
prior_session_bg         = [ 60,  50,  35]
prior_session_tint       = [112,  66,  20]
prior_session_amount     = 0.3

themes/solarized_light.toml:

bubble_vendor            = [255, 240, 200]
prior_session_bg         = [235, 220, 190]
prior_session_tint       = [140,  80,  30]
prior_session_amount     = 0.3
  • Step 6: Run test to verify it passes

Run:

uv run pytest tests/test_prior_session_toml.py -v

Expected: PASS (7 tests).

  • Step 7: Run audit scripts to confirm no regression

Run:

uv run python scripts/audit_weak_types.py
uv run python scripts/audit_main_thread_imports.py

Expected: both exit 0.

  • Step 8: Commit
git add tests/test_prior_session_toml.py src/theme_models.py src/theme_2.py themes/
git commit -m "feat(theme): add prior_session_bg/tint/amount to ThemePalette + 8 themes"

Phase 2: Call-site wraps

Task 4: Failing test for ImVec4 ↔ tuple helpers

Files:

  • Create: tests/test_prior_session_render.py

  • Step 1: Write the failing test

Create the file tests/test_prior_session_render.py:

"""Unit tests for the ImVec4 <-> tuple[float, float, float, float] helpers.

These are used to wrap theme.get_color() output with apply_prior_tint
at prior-session render sites in src/gui_2.py.
"""
import pytest


def test_imvec4_to_rgba_returns_floats():
    """_imvec4_to_rgba converts an ImVec4 to a 4-tuple of floats."""
    from src.theme_2 import _imvec4_to_rgba
    v = type("V", (), {"x": 0.5, "y": 0.25, "z": 0.125, "w": 0.75})()
    out = _imvec4_to_rgba(v)
    assert out == (0.5, 0.25, 0.125, 0.75)
    for c in out:
        assert isinstance(c, float)


def test_imvec4_to_rgba_coerces_int_attributes():
    """_imvec4_to_rgba coerces int attributes to float (defensive)."""
    from src.theme_2 import _imvec4_to_rgba
    v = type("V", (), {"x": 1, "y": 0, "z": 0, "w": 1})()  # int attributes
    out = _imvec4_to_rgba(v)
    for c in out:
        assert isinstance(c, float)


def test_rgba_to_imvec4_round_trip():
    """_rgba_to_imvec4 produces an ImVec4-like object that round-trips."""
    from src.theme_2 import _imvec4_to_rgba, _rgba_to_imvec4
    original = (0.3, 0.6, 0.9, 1.0)
    vec = _rgba_to_imvec4(original)
    back = _imvec4_to_rgba(vec)
    assert back == original


def test_apply_prior_tint_via_helpers_round_trip():
    """Compose the helpers with apply_prior_tint; the round-trip is stable."""
    from src.theme_2 import (
        _imvec4_to_rgba,
        _rgba_to_imvec4,
        apply_prior_tint,
        set_prior_session_amount,
    )
    set_prior_session_amount("Round Trip", 0.4)
    input_rgba = (0.2, 0.4, 0.6, 0.8)
    vec = _rgba_to_imvec4(input_rgba)
    out_vec = _rgba_to_imvec4(apply_prior_tint(_imvec4_to_rgba(vec), "Round Trip"))
    out_rgba = _imvec4_to_rgba(out_vec)
    # Alpha is preserved
    assert out_rgba[3] == 0.8
    # RGB changed (input is colorful, output is sepia-graded)
    assert out_rgba[:3] != input_rgba[:3]
  • Step 2: Run test to verify it fails

Run:

uv run pytest tests/test_prior_session_render.py -v

Expected: FAIL with ImportError: cannot import name '_imvec4_to_rgba' from 'src.theme_2'.

  • Step 3: Implement the helpers in src/theme_2.py

Locate the apply_prior_tint function added in Task 2 (just after _tone_map). Add the 2 helpers immediately after apply_prior_tint:

def _imvec4_to_rgba(v: "imgui.ImVec4") -> tuple[float, float, float, float]:
    """Convert an imgui.ImVec4 to a 4-tuple of floats. Defensive: coerces int to float."""
    return (float(v.x), float(v.y), float(v.z), float(v.w))

def _rgba_to_imvec4(rgba: tuple[float, float, float, float]) -> "imgui.ImVec4":
    """Convert a 4-tuple of floats to an imgui.ImVec4."""
    from imgui_bundle import imgui
    return imgui.ImVec4(*rgba)
  • Step 4: Run test to verify it passes

Run:

uv run pytest tests/test_prior_session_render.py -v

Expected: PASS (4 tests).

  • Step 5: Commit
git add tests/test_prior_session_render.py src/theme_2.py
git commit -m "feat(theme): add ImVec4 <-> tuple[float,float,float,float] helpers"

Task 5: Swap bubble_vendor for prior_session_bg in the 2 bg sites

Files:

  • Modify: src/gui_2.py:1027-1028 (window_bg wrap in _gui_func)
  • Modify: src/gui_2.py:3960-3961 (child_bg wrap in render_prior_session_view)

This task is a mechanical name swap; it does not require a failing test first (the new theme slot is consumed in subsequent tasks, and end-to-end verification happens in Phase 4).

  • Step 1: Verify the current text at src/gui_2.py:1027-1028

Open src/gui_2.py and navigate to line 1027. The current text should be:

  if self.is_viewing_prior_session:
   with imscope.style_color(imgui.Col_.window_bg, theme.get_color("bubble_vendor")):
    render_main_interface(self)
  • Step 2: Replace "bubble_vendor" with "prior_session_bg" on line 1028

Use manual-slop_edit_file with old_string: theme.get_color("bubble_vendor") and new_string: theme.get_color("prior_session_bg") at the line-1028 call site. (If the call site has other bubble_vendor references on adjacent lines that should NOT change, include enough surrounding context to make the match unique.)

  • Step 3: Verify the current text at src/gui_2.py:3960-3961

Open src/gui_2.py and navigate to line 3960. The current text should be:

def render_prior_session_view(app: App) -> None:
 with imscope.style_color(imgui.Col_.child_bg, theme.get_color("bubble_vendor")):
  • Step 4: Replace "bubble_vendor" with "prior_session_bg" on line 3960

Same manual-slop_edit_file operation. Make the surrounding context unique to this site (e.g., include def render_prior_session_view in old_string).

  • Step 5: Verify no other bubble_vendor references remain in the 2 lines

Run:

uv run python -c "from src import gui_2; import inspect; src = inspect.getsource(gui_2); lines = src.split(chr(10)); print(chr(10).join(f'{i+1}: {l}' for i, l in enumerate(lines) if 1025 <= i+1 <= 1030)); print('---'); print(chr(10).join(f'{i+1}: {l}' for i, l in enumerate(lines) if 3958 <= i+1 <= 3963))"

Expected output: the 2 bubble_vendor references are now prior_session_bg.

  • Step 6: Run the existing prior-session tests to confirm no regression

Run:

uv run pytest tests/test_prior_session_no_pop_imbalance.py -v

Expected: PASS (no regression from the bg color swap).

  • Step 7: Commit
git add src/gui_2.py
git commit -m "refactor(gui): swap bubble_vendor for prior_session_bg at 2 prior-session sites"

Task 6: Wrap the "HISTORICAL VIEW" banner with apply_prior_tint

Files:

  • Modify: src/gui_2.py:5140-5145 (the "HISTORICAL VIEW - READ ONLY" banner in render_mma_dashboard)
  • Modify: src/gui_2.py:5438-5442 (the same banner in render_tier_stream_panel)

This is the first of 4 content-tint wraps. The pattern is identical at both sites; the existing test for _render_mma_dashboard (in tests/test_gui_progress.py) should not regress because the wrap is conditional on app.is_viewing_prior_session.

  • Step 1: Write a failing test for the banner tint

Add the following test to the bottom of tests/test_prior_session_render.py (the file from Task 4):

def test_historical_banner_text_color_is_sepia_tinted_in_prior_mode():
    """When is_viewing_prior_session is True and amount > 0, the banner
    text color is sepia-tinted (alpha preserved, RGB shifted toward tint)."""
    from unittest.mock import MagicMock, patch
    from src.theme_2 import (
        _imvec4_to_rgba,
        apply_prior_tint,
        get_color,
        get_prior_session_amount,
        set_prior_session_amount,
    )
    set_prior_session_amount("Banner Test", 0.7)
    # Simulate a non-prior color (status_warning yellow)
    raw = get_color("status_warning")
    raw_rgba = _imvec4_to_rgba(raw)
    # Apply the same transform the call site uses
    out_rgba = apply_prior_tint(raw_rgba, "Banner Test")
    # Alpha preserved
    assert out_rgba[3] == raw_rgba[3]
    # At amount=0.7, output should differ from input (sepia blend happened)
    assert out_rgba != raw_rgba
    # The amount is read from the per-palette dict
    assert get_prior_session_amount("Banner Test") == 0.7


def test_historical_banner_text_color_unchanged_when_amount_is_zero():
    """When the palette amount is 0.0, the banner text is not sepia-tinted."""
    from src.theme_2 import (
        _imvec4_to_rgba,
        apply_prior_tint,
        get_color,
        set_prior_session_amount,
    )
    set_prior_session_amount("Banner Zero", 0.0)
    raw = get_color("status_warning")
    raw_rgba = _imvec4_to_rgba(raw)
    out_rgba = apply_prior_tint(raw_rgba, "Banner Zero")
    assert out_rgba == raw_rgba

Run:

uv run pytest tests/test_prior_session_render.py -v

Expected: 6 tests pass (the 4 from Task 4 + the 2 new ones).

  • Step 2: Wrap line 5140-5145 (MMA dashboard banner)

Navigate to src/gui_2.py:5140. The current code is:

  if app.is_viewing_prior_session:
   c = theme.get_color("status_warning") if theme.is_nerv_active() else theme.get_color("status_warning")
   imgui.text_colored(c, "HISTORICAL VIEW - READ ONLY")
   if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_mma_dashboard")
   return

Replace the c = ... line with a sepia-tinted version using the helpers from Task 4. The new code (preserving the existing if/else for nerv-active branch):

  if app.is_viewing_prior_session:
   c_raw = theme.get_color("status_warning")
   if app.is_viewing_prior_session and theme.get_prior_session_amount(theme.get_current_palette()) > 0.0:
    c = _rgba_to_imvec4(apply_prior_tint(_imvec4_to_rgba(c_raw), theme.get_current_palette()))
   else:
    c = c_raw
   imgui.text_colored(c, "HISTORICAL VIEW - READ ONLY")
   if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_mma_dashboard")
   return

(Imports: _rgba_to_imvec4, _imvec4_to_rgba, apply_prior_tint are already imported via the from src import theme_2 at the top of gui_2.py. If the existing module uses theme.apply_prior_tint style, use that.)

Use manual-slop_edit_file with enough surrounding context to make the match unique. Verify the change with py_check_syntax after:

uv run python -c "from src import gui_2"

Expected: imports cleanly.

  • Step 3: Wrap line 5438-5442 (tier stream banner)

Navigate to src/gui_2.py:5438. The current code (in render_tier_stream_panel) is:

 if app.is_viewing_prior_session:
  imgui.text_colored(theme.get_color("status_warning"), "HISTORICAL VIEW - READ ONLY")
  if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_tier_stream_panel")
  return

Replace with:

 if app.is_viewing_prior_session:
  c_raw = theme.get_color("status_warning")
  if theme.get_prior_session_amount(theme.get_current_palette()) > 0.0:
   c = theme._rgba_to_imvec4(theme.apply_prior_tint(theme._imvec4_to_rgba(c_raw), theme.get_current_palette()))
  else:
   c = c_raw
  imgui.text_colored(c, "HISTORICAL VIEW - READ ONLY")
  if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_tier_stream_panel")
  return

(Or use the local-import style if _rgba_to_imvec4 is not in the module namespace. Match the project's existing call style.)

  • Step 4: Run the existing prior-session tests

Run:

uv run pytest tests/test_prior_session_no_pop_imbalance.py -v
uv run pytest tests/test_mma_dashboard_streams.py -v

Expected: PASS (the wraps are conditional and don't break existing flows when is_viewing_prior_session is False).

  • Step 5: Commit
git add src/gui_2.py tests/test_prior_session_render.py
git commit -m "feat(gui): sepia-tint the 2 HISTORICAL VIEW banners in prior mode"

Task 7: Wrap the comms and tool-log render functions

Files:

  • Modify: src/gui_2.py:4087-4193 (render_comms_history_panel)

  • Modify: src/gui_2.py:4591+ (render_tool_calls_panel)

  • Step 1: Audit the color calls in render_comms_history_panel

Read src/gui_2.py:4087-4193 and locate every theme.get_color(...) call inside the panel that renders log_to_render (line 4115). The audit should list:

  • Row text color
  • Role badge color (e.g., for "User" / "AI" / "Vendor API" role chips)
  • Background swatches

There should be 3-6 theme.get_color(...) calls inside the per-entry rendering loop. Note the exact lines.

  • Step 2: Add the sepia wrap helper at the top of the render function

In render_comms_history_panel, immediately after the if app.perf_profiling_enabled: app.perf_monitor.start_component(...) line, add:

 _pal = theme.get_current_palette()
 _apply_psa = theme.get_prior_session_amount(_pal) > 0.0 and app.is_viewing_prior_session

This caches the palette name and the "is the amount nonzero + in prior mode" decision once per frame.

  • Step 3: Wrap the color calls inside the per-entry loop

For each theme.get_color(name) call in the per-entry loop, replace with:

c_raw = theme.get_color(name)
if _apply_psa:
    c = theme._rgba_to_imvec4(theme.apply_prior_tint(theme._imvec4_to_rgba(c_raw), _pal))
else:
    c = c_raw

Use manual-slop_edit_file per call site. Match the project's existing import style (from src import theme_2 vs import src.theme_2 as theme).

  • Step 4: Repeat for render_tool_calls_panel

Repeat the audit + helper + wrap for render_tool_calls_panel at line 4591+.

  • Step 5: Run all comms/tool-log related tests

Run:

uv run pytest tests/test_comms_history_panel.py tests/test_tool_log_panel.py -v

(Adjust test file names to match the project's actual test naming convention. If a unified test file covers both, run that.)

Expected: PASS.

  • Step 6: Commit
git add src/gui_2.py
git commit -m "feat(gui): sepia-tint comms/tool-log entries in prior mode"

Phase 3: Theme panel UI + persistence

Task 8: Add the "Prior Session Sepia" section to the Theme Settings panel

Files:

  • Modify: src/gui_2.py:5007+ (immediately after the "Reset Tone Mapping" button)

  • Step 1: Locate the "Reset Tone Mapping" button

Read src/gui_2.py:5007-5025. The end of the existing tone-mapping section looks like:

  if imgui.button("Reset Tone Mapping"):
   theme.reset_tone_mapping(curr_p)
   app._flush_to_config()
   app.save_config()
  
 imgui.end()
  • Step 2: Add the new section between the reset button and imgui.end()

Insert the following code immediately after the if imgui.button("Reset Tone Mapping"): block (and before the imgui.end() that closes the Theme panel window):

  imgui.separator()
  imgui.text("Prior Session Sepia (Per-Palette)")
  ch_p, p = imgui.slider_float("##ps_amount", theme.get_prior_session_amount(curr_p), 0.0, 1.0, "%.2f")
  if ch_p:
   theme.set_prior_session_amount(curr_p, p)
   app._flush_to_config()
   app.save_config()
  if imgui.button("Reset Prior Session Sepia"):
   theme.reset_prior_session(curr_p)
   app._flush_to_config()
   app.save_config()
  • Step 3: Verify the panel renders without error

Run the GUI in headless mode (if available) or rely on the existing live_gui tests:

uv run python sloppy.py --enable-test-hooks

In a separate terminal:

curl -s http://127.0.0.1:8999/status | python -m json.tool

Expected: HTTP 200 with "status": "ready". If the GUI fails to start due to a syntax error in the new section, fix the indentation (must be 1-space, not 4-space).

  • Step 4: Commit
git add src/gui_2.py
git commit -m "feat(gui): add Prior Session Sepia slider to Theme Settings panel"

Task 9: Failing test for save_to_config / load_from_config round-trip

Files:

  • Create: tests/test_prior_session_persistence.py

  • Step 1: Write the failing test

Create the file tests/test_prior_session_persistence.py:

"""Persistence tests for the prior-session sepia amount slider.

Mirrors the existing [theme.tone_mapping.<palette>] persistence pattern
in src/theme_2.py:save_to_config() / load_from_config().
"""
import pytest


@pytest.fixture(autouse=True)
def _reset_module_state():
    from src import theme_2
    saved_amt = dict(theme_2._prior_session_amount)
    theme_2._prior_session_amount.clear()
    yield
    theme_2._prior_session_amount.clear()
    theme_2._prior_session_amount.update(saved_amt)


def test_save_to_config_writes_prior_session_amount_per_palette():
    """save_to_config writes a [theme.prior_session_amount.<palette>] table."""
    from src import theme_2
    theme_2.set_prior_session_amount("10x Dark", 0.55)
    theme_2.set_prior_session_amount("Solarized Light", 0.85)
    config: dict = {}
    theme_2.save_to_config(config)
    psa = config["theme"]["prior_session_amount"]
    assert isinstance(psa, dict)
    assert psa["10x Dark"] == 0.55
    assert psa["Solarized Light"] == 0.85


def test_load_from_config_restores_prior_session_amount_per_palette():
    """load_from_config reads [theme.prior_session_amount.<palette>] back into the dict."""
    from src import theme_2
    config = {
        "theme": {
            "palette": "10x Dark",
            "prior_session_amount": {
                "10x Dark": 0.45,
                "Solarized Light": 0.95,
            },
        }
    }
    theme_2.load_from_config(config)
    assert theme_2.get_prior_session_amount("10x Dark") == 0.45
    assert theme_2.get_prior_session_amount("Solarized Light") == 0.95


def test_round_trip_preserves_value():
    """save -> load -> get returns the same value the user set."""
    from src import theme_2
    theme_2.set_prior_session_amount("Round Trip Palette", 0.42)
    config: dict = {}
    theme_2.save_to_config(config)
    # Simulate a restart: clear in-memory state
    theme_2._prior_session_amount.clear()
    theme_2.load_from_config(config)
    assert theme_2.get_prior_session_amount("Round Trip Palette") == 0.42


def test_save_skips_palettes_at_default_value():
    """Only palettes with non-default values are written to config (mirrors tonemap pattern)."""
    from src import theme_2
    theme_2.set_prior_session_amount("Custom", 0.6)  # non-default
    # Do NOT set "Untouched" — leaves it at default 0.3
    config: dict = {}
    theme_2.save_to_config(config)
    psa = config["theme"]["prior_session_amount"]
    assert "Custom" in psa
    assert "Untouched" not in psa  # not written because at default
  • Step 2: Run test to verify it fails

Run:

uv run pytest tests/test_prior_session_persistence.py -v

Expected: FAIL — config["theme"] doesn't have a "prior_session_amount" key yet. The first failure should be on test_save_to_config_writes_prior_session_amount_per_palette.

  • Step 3: Extend save_to_config in src/theme_2.py

Open src/theme_2.py. Locate save_to_config (around line 301-317). The current end of the function is:

def save_to_config(config: dict) -> None:
 """Persist theme settings into the config dict."""
 config.setdefault("theme", {})
 config["theme"]["palette"]            = _current_palette
 ...
 tm = {}
 for p in set(list(_brightness.keys()) + list(_contrast.keys()) + list(_gamma.keys())):
  tm[p] = {
   "brightness": _brightness.get(p, 1.0),
   "contrast":   _contrast.get(p, 1.0),
   "gamma":      _gamma.get(p, 1.0)
  }
 config["theme"]["tone_mapping"] = tm

Add the prior-session persistence after the tone_mapping block, at the end of the function:

 psa = {p: float(v) for p, v in _prior_session_amount.items() if v != 0.3}
 if psa:
  config["theme"]["prior_session_amount"] = psa

(We skip writing palettes that are at the default 0.3 to keep the config.toml clean. If the user later moves a palette to 0.3 (the default), it will simply not be persisted.)

  • Step 4: Extend load_from_config in src/theme_2.py

Open src/theme_2.py. Locate load_from_config (around line 319-335). The current end of the function is:

 tm          = t.get("tone_mapping", {})
 _brightness = {p: float(v.get("brightness", 1.0)) for p, v in tm.items()}
 _contrast   = {p: float(v.get("contrast",   1.0)) for p, v in tm.items()}
 _gamma      = {p: float(v.get("gamma",      1.0)) for p, v in tm.items()}

Add the prior-session restoration after the gamma restoration:

 psa = t.get("prior_session_amount", {})
 _prior_session_amount = {p: float(v) for p, v in psa.items()}

(Add _prior_session_amount to the global declaration on the def load_from_config line that lists the modified globals.)

  • Step 5: Run test to verify it passes

Run:

uv run pytest tests/test_prior_session_persistence.py -v

Expected: PASS (4 tests).

  • Step 6: Commit
git add tests/test_prior_session_persistence.py src/theme_2.py
git commit -m "feat(theme): persist prior_session_amount per-palette in config"

Phase 4: Verify + checkpoint

Task 10: Run the full new-test suite

Files: (no file changes; verification only)

  • Step 1: Run all new tests

Run:

uv run pytest tests/test_prior_session_amount.py tests/test_prior_session_tint.py tests/test_prior_session_toml.py tests/test_prior_session_render.py tests/test_prior_session_persistence.py -v

Expected: 30 tests pass (7+7+7+6+4).

  • Step 2: Confirm no regressions in the existing prior-session tests

Run:

uv run pytest tests/test_prior_session_no_pop_imbalance.py -v

Expected: PASS.


Task 11: Run the full live_gui test batch

Files: (no file changes; verification only)

Per conductor/workflow.md "Isolated-Pass Verification Fallacy" — the only verification that matters for live_gui tests is the batch run in the suite the test ships in. Do NOT rely on isolated passes.

  • Step 1: Run the prior-session live_gui test batch (≤ 4 test files at a time)

Run:

uv run pytest tests/test_prior_session_no_pop_imbalance.py tests/test_prior_session_visual.py -v --timeout=60

(Adjust the test file list to match the project's actual prior-session test files. Per the workflow rule, max 4 test files per batch to avoid random timeouts.)

Expected: PASS.

  • Step 2: Run the theme/mma dashboard live_gui batch

Run:

uv run pytest tests/test_theme_panel.py tests/test_mma_dashboard_streams.py -v --timeout=60

Expected: PASS.

  • Step 3: Run a broader sweep to catch indirect regressions

Run:

uv run pytest tests/test_gui_progress.py tests/test_mma_approval_indicators.py -v --timeout=60

Expected: PASS. (These tests cover render_mma_dashboard which we wrapped in Task 6.)

  • Step 4: Run the full test suite as a final smoke (optional, large)

If time permits, run the full suite:

uv run pytest tests/ --timeout=120

Expected: No new failures compared to the pre-track baseline (273+ existing tests pass). If failures occur, STOP and report to the user (per AGENTS.md "Surrender" rule — do not loop).


Task 12: Manual smoke test

Files: (no file changes; verification only)

This is the empirical visual verification. The user can also do this themselves, but the implementer should do it first to catch obvious issues.

  • Step 1: Launch the GUI with hooks enabled
uv run python sloppy.py --enable-test-hooks
  • Step 2: Open the Theme Settings panel

In the GUI, navigate to the Theme Settings panel. Verify:

  • A new "Prior Session Sepia (Per-Palette)" section is visible, below the existing "Tone Mapping (Per-Palette)" section.

  • A slider showing 0.30 (the default).

  • A "Reset Prior Session Sepia" button.

  • Step 3: Open a prior session

Navigate to a prior session (via the Session Analysis / Historical Replay UI). Verify:

  • The "HISTORICAL VIEW - READ ONLY" banner has a subtle sepia tint (not full color, not fully grayed out — about 30% desaturated, 30% tinted toward warm brown).

  • The prior discussion entries have the same subtle sepia tint.

  • The "Exit Prior Session" button bg uses the new prior_session_bg color (warm brown, not the old orange bubble_vendor).

  • The slider value in the Theme panel snaps to 0.30 (the per-palette default for the active theme).

  • Step 4: Adjust the slider

Drag the slider from 0.30 → 0.00 → 1.00. Verify:

  • At 0.00, no sepia (colors are full saturation).

  • At 1.00, full sepia (everything is warm brown with high desaturation).

  • The transition is smooth (no integer-stepping artifacts; the user requirement was float-only math for smooth calculations).

  • The slider does NOT stutter or hang (it is a per-frame UI call, no I/O).

  • Step 5: Switch themes and confirm the slider snaps

Switch from 10x Dark to Solarized Light. Verify:

  • The slider snaps to the Solarized Light per-palette default (0.30).

  • The prior-session bg color switches from dark brown (60, 50, 35) to cream (235, 220, 190).

  • Step 6: Restart the app and confirm persistence

Quit the app. Move the slider to 0.75 before quitting (or use the API hook to set it programmatically). Restart. Verify:

  • The slider shows 0.75 (the persisted value, not the default).

  • The prior-session look matches the saved amount.

  • Step 7: Capture before/after screenshots (optional)

If the user wants visual documentation, capture 2 screenshots:

  1. tests/artifacts/prior_session_sepia_default.png — at default 0.30
  2. tests/artifacts/prior_session_sepia_max.png — at slider value 1.00

Save them under docs/reports/prior_session_sepia_<date>/ if reporting.

  • Step 8: ESCALATION CHECK — scope (ii) vs scope (iii)

If the prior-session look at 0.30 default is NOT "obviously old" (per the user's intent), escalate to scope (iii) — wrap the entire _gui_func window bg with the prior-session tint instead of just the prior-session views. This is a 1-line change:

# In src/gui_2.py:_gui_func, change the conditional from:
if self.is_viewing_prior_session:
    with imscope.style_color(imgui.Col_.window_bg, theme.get_color("prior_session_bg")):
        render_main_interface(self)
# To:
if self.is_viewing_prior_session:
    c_raw = theme.get_color("window_bg")
    if theme.get_prior_session_amount(theme.get_current_palette()) > 0.0:
        c_raw = _rgba_to_imvec4(apply_prior_tint(_imvec4_to_rgba(c_raw), theme.get_current_palette()))
    with imscope.style_color(imgui.Col_.window_bg, c_raw):
        render_main_interface(self)

(Or a simpler approach: change prior_session_bg itself in the TOML to be more saturated.)

This escalation is a follow-up commit. Do not commit it as part of this track unless the user explicitly asks.


Task 13: Phase checkpoint commit + git note

Files: (no file changes; git operations only)

  • Step 1: Stage any remaining uncommitted changes
git status

Expected: clean working tree (or only the pre-existing modified files from the user's prior session, which we MUST NOT touch per the HARD BAN).

  • Step 2: Create the checkpoint commit

If there are no uncommitted changes, create an empty checkpoint commit:

git commit --allow-empty -m "conductor(checkpoint): end of prior_session_sepia_20260610 Phase 4

All 30 new tests pass; no regressions in 273+ existing tests; manual smoke
confirms the prior-session look is 'obviously old' at the default 0.30 amount.

Verified:
- [x] 3 new theme slots present in ThemePalette, fallback dict, and all 8 themes/*.toml
- [x] _prior_session_amount per-palette dict with get/set/reset; persists to config.toml
- [x] apply_prior_tint: identity at 0.0, pure tint at 1.0, monotonic, alpha-preserved, all-float
- [x] 6 prior-session render sites use prior_session_bg (not bubble_vendor); content sepia-tinted
- [x] Theme Settings panel has working slider + reset button
- [x] Persistence round-trip works (save -> quit -> restart -> value restored)

HONEST DISCLOSURE: code-block tonemap-awareness is NOT fixed by this track.
Upstream imgui_bundle 1.92.5 API does not expose per-instance Palette struct;
only 4-value enum. The pre-existing 'code blocks not tonemap-aware' bug persists.
Fix requires forking the library (separate track)."

(If uncommitted changes from earlier tasks remain, stage them and adjust the message to be a real non-empty commit.)

  • Step 3: Get the checkpoint commit hash
git log -1 --format="%H"
  • Step 4: Attach a git note
git notes add -m "Track prior_session_sepia_20260610 — Phase 4 checkpoint.

PHASE 4 VERIFICATION REPORT:
- 30 new tests pass (test_prior_session_amount, _tint, _toml, _render, _persistence)
- No regressions in 273+ existing live_gui tests (batch-verified)
- Manual smoke confirms prior-session look is 'obviously old' at default 0.30
- Slider scales smoothly with no integer stepping artifacts (float-only math)
- Persistence: save -> restart -> value restored

FILES CHANGED:
- src/theme_2.py (added _prior_session_amount, _desaturate, _lerp_rgba, _imvec4_to_rgba, _rgba_to_imvec4, apply_prior_tint, extended save/load_from_config)
- src/theme_models.py (added 3 fields to ThemePalette)
- src/gui_2.py (2 bubble_vendor swaps, 4 sepia-tint wraps, 1 Theme panel section)
- themes/*.toml x 8 (added 3 new keys each)
- tests/test_prior_session_*.py x 5 (new)

OUT OF SCOPE (HONEST DISCLOSURE):
- Code-block (TextEditor) tonemap-awareness: NOT fixable in this track
  because upstream imgui_bundle 1.92.5 API does not expose per-instance
  Palette struct (only 4-value enum). Pre-existing bug persists.
  Fix requires forking the library (separate track).

NEXT: archive the track (conductor/tracks.md update + move to conductor/archive_completed_tracks_20260603/)." $(git log -1 --format="%H")
  • Step 5: Update conductor/tracks.md to mark the track as completed

Open conductor/tracks.md and add the new track to the "Completed" section. Follow the format used by the most recently completed tracks (e.g., unused_scripts_cleanup_20260607 or license_cve_audit_20260607).

Insert the entry under the most recent "Completed" header. The entry should include:

  • [x] checkbox prefix

  • Track name + checkpoint hash

  • Link to ./tracks/prior_session_sepia_20260610/

  • One-paragraph "Goal:" summary

  • Step 6: Commit the tracks.md update

git add conductor/tracks.md
git commit -m "conductor(tracks): mark prior_session_sepia_20260610 as completed"
  • Step 7: Move the track to the archive directory (optional)

The project's archive convention is conductor/archive_completed_tracks_<date>/. If the user wants the track archived:

mkdir -p conductor/archive_completed_tracks_20260610
git mv conductor/tracks/prior_session_sepia_20260610 conductor/archive_completed_tracks_20260610/prior_session_sepia_20260610
git commit -m "conductor(archive): move prior_session_sepia_20260610 to archive"

If the user prefers to keep active tracks in conductor/tracks/, skip this step.


End-of-Plan Checklist

Confirm before reporting completion to the user:

  • All 13 tasks above are checked off.
  • All 30 new tests pass.
  • No regressions in 273+ existing live_gui tests.
  • Manual smoke confirms the prior-session look is "obviously old" at default 0.30.
  • Slider scales smoothly with no integer-stepping artifacts.
  • Persistence works (save → restart → restore).
  • The "HONEST DISCLOSURE" appears in the final track report: code-block tonemap-awareness is NOT fixed by this track.
  • conductor/tracks.md is updated.
  • The checkpoint commit has a git note attached.
  • No diagnostic sys.stderr.write("[XYZ_DIAG] ...") lines in production code.
  • git restore / git checkout -- / git reset were NOT used without explicit user permission (HARD BAN per AGENTS.md).
  • No float → int truncation in the transform pipeline (user requirement).
  • All commits are atomic per-task (no batched commits).
  • Each commit message is 1-3 sentences (not a 200-line report per AGENTS.md "Verbose-Commit-Message Pattern" rule).