From bd0622c1a638d7f20d04ded93042263d2a6ece8d Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Mon, 26 Oct 2026 15:56:34 -0500 Subject: [PATCH] fix(theme): validate dark cross-app diagnostic states --- CHANGELOG.md | 1 + DreamcoderShell/.config/starship-dark.toml | 1 + docs/configuration/dark-theme-validation.md | 48 ++++ docs/configuration/theme-system.md | 2 + src/dreamcoder_theme/renderers_starship.py | 4 +- tests/test_dark_cross_app_states.py | 263 ++++++++++++++++++++ 6 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 docs/configuration/dark-theme-validation.md create mode 100644 tests/test_dark_cross_app_states.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0db3a8..35e76c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ ### Fixed - OpenCode surfaces follow the active mode with readable syntax and interface states. +- Dark Starship diagnostic styles resolve through the palette. - Repository sync regression tests derive the current upstream pin instead of relying on a historical hash. - Zsh sources Bun completions only from nonempty regular files, preserving support for symlinks to regular files. - Pi and Gentle Shell Light use cream chrome surfaces and brown focus roles, including the medium-thinking editor border, without changing Dark. diff --git a/DreamcoderShell/.config/starship-dark.toml b/DreamcoderShell/.config/starship-dark.toml index ca848bb2..83d6f20d 100644 --- a/DreamcoderShell/.config/starship-dark.toml +++ b/DreamcoderShell/.config/starship-dark.toml @@ -41,6 +41,7 @@ prompt_accent_2 = "#D4B5FD" sage = "#34D399" success = "#34D399" diagnostic = "#7DD3FC" +diag = "#7DD3FC" info = "#7DD3FC" lavender = "#D4B5FD" mauve = "#D8B4FE" diff --git a/docs/configuration/dark-theme-validation.md b/docs/configuration/dark-theme-validation.md new file mode 100644 index 00000000..94f24672 --- /dev/null +++ b/docs/configuration/dark-theme-validation.md @@ -0,0 +1,48 @@ +# Validate Dreamcoder Dark states + +Use this reference to distinguish generated-color checks from actual application rendering. Passing contrast checks is not a visual approval or a claim of superiority over other themes. Palette ownership and regeneration are documented in the [theme system](theme-system.md). + +## Run the read-only checks + +From the repository root: + +```bash +python scripts/verify-theme-health.py +python -m pytest tests/test_dark_cross_app_states.py -q +``` + +The [cross-application audit](../../tests/test_dark_cross_app_states.py) checks 62 resolved text/background pairs: Kitty/Ghostty, Pi, tmux, Starship and Dunst. It checks Pi variable references, quiet-text APCA and all 18 Starship text styles. A mutation check verifies that unreadable 1:1 text fails. These commands do not deploy themes or change the active mode. + +Dark Starship supplies `diag` as a compatibility alias for `diagnostic`, used by Python, Go, Docker and the AI-session label. This does not change the diagnostic color. Light and the internal transition mode retain their previous output; the alias correction does not apply to them. + +## Capture representative states + +Keep the application version, font, font size, terminal opacity, wallpaper and display scaling constant. Record those settings with each capture. Prefer isolated demonstration sessions rather than modifying an active project or exposing sensitive paths. + +| Application | Capture | Check visually | +| --- | --- | --- | +| Kitty/Ghostty | Main text, selected text, cursor; active/inactive tabs where supported | Selection remains readable; active state and keyboard focus are distinguishable. | +| Pi | Normal text, thinking text, user/custom message, pending/success/error tool cards | Secondary text remains legible; diagnostic meaning is not conveyed by color alone. | +| OpenCode | Syntax-highlighted code, inline code, added/removed lines and line numbers | Actual syntax remains readable over diff fills; body and gutter agree. | +| tmux | Active/inactive windows, a message and copy mode | Copy selection is clear; inactive windows do not look disabled. | +| Starship | Directory/git context and an applicable Python, Go or Docker segment | Runtime segments resolve their color and do not fall back unexpectedly. | +| Desktop | Rofi selected/normal entries, Waybar active/inactive states, notification urgency levels | Evaluate the composed appearance, not an opaque substitute for a transparent fill. | + +Use the same short content for code and diff captures, including comments, strings, numbers and punctuation: + +```python +# A comment should remain secondary but readable. +name = "Dreamcoder" +count = 42 +print(f"{name}: {count}") +``` + +For a diff, show the same line once as added and once as removed. Include the input area and its focus indicator in the capture. A shell-output-only screenshot does not establish syntax or diff readability. + +## Evidence boundaries + +- Rofi and Waybar emit alpha backgrounds. Their final colors depend on the compositor backdrop; this audit does not guess a wallpaper or flatten them onto black. +- Herdr profile fields are checked against supported schemas, but those fields alone do not identify which component paints a screenshot's sidebar. +- Focus borders are not body text. Do not apply a 4.5:1 text threshold to decorations and claim a non-text accessibility result. +- The audit assumes matching canonical terminal colors when an application inherits terminal foreground/background. It does not certify arbitrary terminal themes or transparency. +- Generated values do not prove application support, live reload, font rendering, comfort over long sessions or complete accessibility compliance. Those require runtime evidence and user evaluation. diff --git a/docs/configuration/theme-system.md b/docs/configuration/theme-system.md index 67c3c3b5..82c9c370 100644 --- a/docs/configuration/theme-system.md +++ b/docs/configuration/theme-system.md @@ -108,6 +108,8 @@ documentation gap. ## Quality gates +Use the [Dark state validation reference](dark-theme-validation.md) for cross-application checks, repeatable capture scenarios and limits of generated-color evidence. + - WCAG 2.2: body text ≥ 4.5:1 (main text ≥ 7:1) - APCA: body Lc ≥ 75 light / ≥ 50 dark; `on_accent` Lc ≥ 54 on filled accent - CI validates **both** light and dark Kitty, Starship, Ghostty, Waybar, Hypr, Rofi, Btop, Dunst, Fzf diff --git a/src/dreamcoder_theme/renderers_starship.py b/src/dreamcoder_theme/renderers_starship.py index 619d8bd0..421c121a 100644 --- a/src/dreamcoder_theme/renderers_starship.py +++ b/src/dreamcoder_theme/renderers_starship.py @@ -22,6 +22,8 @@ def starship_content(c: dict[str, str]) -> str: lavender_col = c["lavender"] mauve_col = c["mauve"] palette_id = "dreamcoder" + # Dark compatibility alias for the existing runtime/AI styles; other modes stay unchanged. + diag_alias = f'diag = "{diag}"\n' if mode == "dark" else "" return f'''# ======================================================== # {c["name"]} — Starship prompt @@ -66,7 +68,7 @@ def starship_content(c: dict[str, str]) -> str: sage = "{c["sage"]}" success = "{c["success"]}" diagnostic = "{diag}" -info = "{c["info"]}" +{diag_alias}info = "{c["info"]}" lavender = "{lavender_col}" mauve = "{mauve_col}" error = "{error}" diff --git a/tests/test_dark_cross_app_states.py b/tests/test_dark_cross_app_states.py new file mode 100644 index 00000000..d3ed4b26 --- /dev/null +++ b/tests/test_dark_cross_app_states.py @@ -0,0 +1,263 @@ +"""T1/S1-S2: emitted Dark states, not token cross-products or live UI proof. + +Rofi/Waybar alpha surfaces need a known compositor background; Matugen bridges +only define colors. Herdr profiles establish schema, not exact text pairing. +None of these establishes ownership of the user's screenshot sidebar. +""" + +import configparser +import json +import re +import tomllib +from hashlib import sha256 +from pathlib import Path + +import pytest + +from dreamcoder_theme.herdr_contract import SUPPORTED_PROFILES +from dreamcoder_theme.palette import apca_lc, contrast, load_guardrails +from dreamcoder_theme.palette_tokens import VARIANTS +from dreamcoder_theme.renderers_extra_notify import dunst_content +from dreamcoder_theme.renderers_ghostty_warp import ghostty_content +from dreamcoder_theme.renderers_herdr import herdr_content +from dreamcoder_theme.renderers_hypr_waybar_rofi import rofi_content, waybar_content +from dreamcoder_theme.renderers_kitty import kitty_content +from dreamcoder_theme.renderers_pi import pi_theme_content +from dreamcoder_theme.renderers_starship import starship_content +from dreamcoder_theme.renderers_tmux import tmux_content + +DARK = VARIANTS["dark"] +GUARDS = load_guardrails( + Path(__file__).resolve().parents[1] / "DreamcoderThemes/dreamcoder/tokens.json" +) + + +def _hex(value): + assert re.fullmatch(r"#[0-9a-fA-F]{6}", value), f"unresolved color: {value!r}" + return value + + +def _check(rows): + failures = [] + for label, fg, bg, floor in rows: + ratio = contrast(_hex(fg), _hex(bg)) + if ratio < floor: + failures.append(f"{label}: {fg}/{bg} WCAG={ratio:.3f} < {floor}") + assert not failures, "\n".join(failures) + + +def terminal_pairs(): + # renderers_kitty.py:16-60; renderers_ghostty_warp.py:16-22. + kitty = dict(re.findall(r"^(\w+)\s+(#[\da-fA-F]{6})$", kitty_content(DARK), re.M)) + ghost = dict(re.findall(r"^([\w-]+) = (#[\da-fA-F]{6})$", ghostty_content(DARK), re.M)) + assert len(kitty) == 39 and len(ghost) == 6 + rows = [] + for label, data, pairs in ( + ( + "kitty", + kitty, + ( + ("foreground", "background"), + ("active_tab_foreground", "active_tab_background"), + ("inactive_tab_foreground", "inactive_tab_background"), + ("cursor_text_color", "cursor"), + *((f"mark{i}_foreground", f"mark{i}_background") for i in range(1, 4)), + ("selection_foreground", "selection_background"), + ), + ), + ( + "ghostty", + ghost, + ( + ("foreground", "background"), + ("cursor-text", "cursor-color"), + ("selection-foreground", "selection-background"), + ), + ), + ): + for fg, bg in pairs: + floor = ( + GUARDS["minimum_terminal_selection_contrast"] + if "selection" in fg + else GUARDS["minimum_text_contrast"] + ) + rows.append((f"{label}.{fg}/{bg}", data[fg], data[bg], floor)) + assert len(rows) == 11 + return rows + + +def pi_pairs(): + # renderers_pi.py:15-20,49-76: empty foreground inherits terminal text. + theme = json.loads(pi_theme_content(DARK)) + colors, variables = theme["colors"], theme["vars"] + terminal = dict(re.findall(r"^(\w+)\s+(#[\da-fA-F]{6})$", kitty_content(DARK), re.M)) + + def resolve(value): + seen = set() + while value in variables: + assert value not in seen, f"cyclic Pi variable: {value}" + seen.add(value) + value = variables[value] + return _hex(value or terminal["foreground"]) + + assert len(colors) == 51 and len(variables) == 12 + rows = [] + associations = { + "terminal": ("text", "thinkingText", "muted", "dim", "mdLink", "syntaxComment"), + "selectedBg": ("text",), + "userMessageBg": ("userMessageText",), + "customMessageBg": ("customMessageText", "customMessageLabel"), + "toolPendingBg": ("toolTitle", "toolOutput", "toolDiffContext"), + "toolSuccessBg": ("toolTitle", "toolOutput", "toolDiffContext"), + "toolErrorBg": ("toolTitle", "toolOutput", "toolDiffContext", "error"), + } + for surface, roles in associations.items(): + bg = terminal["background"] if surface == "terminal" else resolve(colors[surface]) + for role in roles: + rows.append( + (f"pi.{role}/{surface}", resolve(colors[role]), bg, GUARDS["minimum_text_contrast"]) + ) + assert len(rows) == 20 + return rows + + +def tmux_pairs(): + # renderers_tmux.py:48-84: tab styles inherit status-style background. + styles = dict(re.findall(r'^(?:set|setw) -g ([\w-]+) "([^"]*)"$', tmux_content(DARK), re.M)) + bar = re.search(r"bg=(#[\da-fA-F]{6})", styles["status-style"])[1] + rows = [] + for name in ( + "window-status-style", + "window-status-current-style", + "message-style", + "message-command-style", + "mode-style", + "window-status-bell-style", + ): + fields = dict(re.findall(r"(fg|bg)=(#[\da-fA-F]{6})", styles[name])) + rows.append( + (f"tmux.{name}", fields["fg"], fields.get("bg", bar), GUARDS["minimum_text_contrast"]) + ) + for name in ("status-left", "status-right"): + foregrounds = re.findall(r"fg=(#[\da-fA-F]{6})", styles[name]) + assert len(foregrounds) == 2 + rows.extend( + (f"tmux.{name}.{i}", fg, bar, GUARDS["minimum_text_contrast"]) + for i, fg in enumerate(foregrounds) + ) + assert len(rows) == 10 + return rows + + +def starship_pairs(): + # renderers_starship.py:86-197: only text styles, not powerline separators. + theme = tomllib.loads(starship_content(DARK)) + palette = theme["palettes"][theme["palette"]] + styles = [] + for name, module in theme.items(): + if isinstance(module, dict) and name not in {"palettes", "custom"}: + styles.extend( + (f"{name}.{key}", value) + for key, value in module.items() + if key.startswith("style") or (name == "character" and key.endswith("_symbol")) + ) + styles.append(("custom.ai_session.style", theme["custom"]["ai_session"]["style"])) + assert len(styles) == 18 + rows, unresolved = [], [] + for label, style in styles: + fields = dict(re.findall(r"(fg|bg):([\w_]+)", style)) + assert "fg" in fields, label + fg, bg = fields["fg"], fields.get("bg", "bg") + if fg not in palette or bg not in palette: + unresolved.append(f"{label}: fg:{fg} bg:{bg}") + else: + rows.append( + (f"starship.{label}", palette[fg], palette[bg], GUARDS["minimum_text_contrast"]) + ) + assert len(rows) + len(unresolved) == 18 + return rows, unresolved + + +def dunst_pairs(): + # renderers_extra_notify.py:26-42: notification foreground on urgency fill. + parsed = configparser.ConfigParser() + parsed.read_string(dunst_content(DARK)) + assert parsed.sections() == ["urgency_low", "urgency_normal", "urgency_critical"] + return [ + ( + f"dunst.{name}.foreground/background", + parsed[name]["foreground"].strip('"'), + parsed[name]["background"].strip('"'), + GUARDS["minimum_text_contrast"], + ) + for name in parsed.sections() + ] + + +@pytest.mark.parametrize("pairs", [terminal_pairs, pi_pairs, tmux_pairs, dunst_pairs]) +def test_emitted_dark_text_pairs(pairs): + _check(pairs()) + + +def test_starship_emitted_text_pairs(): + rows, unresolved = starship_pairs() + _check(rows) + assert not unresolved, "Unresolved emitted Starship colors: " + "; ".join(unresolved) + assert len(rows) == 18 + palette = tomllib.loads(starship_content(DARK))["palettes"]["dreamcoder"] + light_palette = tomllib.loads(starship_content(VARIANTS["light"]))["palettes"]["dreamcoder"] + assert set(palette) == set(light_palette) | {"diag"} + assert palette["diag"] == palette["diagnostic"] == DARK["diagnostic"] + + +@pytest.mark.parametrize( + "mode, expected", + [ + ("light", "93f971c4ad02eef751a3e169f5f8e4a796c0910310bdeb8a90d610e174553b1e"), + ("dusk", "581c6c4b0b90ea880c2cd94af6cffd1f660b45555a07c037af1f8128679340df"), + ], +) +def test_starship_non_dark_output_preserved(mode, expected): + output = starship_content(VARIANTS[mode]) + # Fixed pre-fix bytes: no history access, extra aliases, or blank lines. + assert sha256(output.encode()).hexdigest() == expected + theme = tomllib.loads(output) + palette = theme["palettes"][theme["palette"]] + assert "diag" not in palette + assert palette["diagnostic"] == VARIANTS[mode]["diagnostic"] + for module in ( + theme["python"], + theme["golang"], + theme["docker_context"], + theme["custom"]["ai_session"], + ): + assert module["style"] == "fg:diag bold" + + +def test_pi_quiet_text_apca_on_emitted_surfaces(): + for label, fg, bg, _ in pi_pairs(): + if any( + f"pi.{role}/" in label + for role in ("thinkingText", "muted", "dim", "syntaxComment", "toolDiffContext") + ): + assert abs(apca_lc(fg, bg)) >= GUARDS["minimum_apca_quiet"], label + + +def test_one_to_one_mutation_is_rejected(): + row = terminal_pairs()[0] + with pytest.raises(AssertionError, match=r"WCAG=1.000"): + _check([(row[0], row[2], row[2], row[3])]) + + +def test_optional_outputs_retain_explicit_proof_boundaries(): + # No guessed wallpaper compositing; borders/focus are not body text. + for output in (rofi_content(DARK), waybar_content(DARK)): + assert DARK["panel_rgba"] in output + assert DARK["active_rgba"] in output + assert len(SUPPORTED_PROFILES) == 7 + for profile in SUPPORTED_PROFILES: + custom = tomllib.loads(herdr_content(profile, "dark", DARK))["theme"]["custom"] + assert set(custom) == set(profile.evidence.allowed_custom_fields) + assert len(custom) in (16, 19) + for value in custom.values(): + _hex(value)