From e425880fd73178e0eb9628ec85c2d361a24dd979 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Mon, 26 Oct 2026 15:50:45 -0500 Subject: [PATCH] fix(theme): guard rendered code and diff readability --- .opencode/themes/dreamcoder.json | 16 +- CHANGELOG.md | 1 + .../Dreamcoder-Dark.codex-theme.json | 10 +- .../Dreamcoder-Light.codex-theme.json | 16 +- .../Dreamcoder.codex-theme.json | 16 +- src/dreamcoder_theme/renderers_opencode.py | 48 +++- tests/test_dark_surface_readability.py | 81 +++++++ tests/test_light_surface_readability.py | 229 ++++++++++++++++++ tests/test_renderer_output.py | 12 +- 9 files changed, 386 insertions(+), 43 deletions(-) create mode 100644 tests/test_dark_surface_readability.py create mode 100644 tests/test_light_surface_readability.py diff --git a/.opencode/themes/dreamcoder.json b/.opencode/themes/dreamcoder.json index 3a0b6c1a..2e9db671 100644 --- a/.opencode/themes/dreamcoder.json +++ b/.opencode/themes/dreamcoder.json @@ -31,7 +31,7 @@ "text": "#17120d", "textMuted": "#352e22", "textSubtle": "#554638", - "textPlaceholder": "#725e4c", + "textPlaceholder": "#5d4d3e", "textAssistant": "#0f4058", "textUser": "#724615", "textTool": "#0d4a68", @@ -51,17 +51,17 @@ "diffAdded": "#315b31", "diffRemoved": "#842f24", "diffContext": "#352e22", - "diffHunkHeader": "#57478b", + "diffHunkHeader": "#473a72", "diffHighlightAdded": "#315b31", "diffHighlightRemoved": "#842f24", - "diffAddedBg": "#a2ae94", - "diffRemovedBg": "#c49b8f", + "diffAddedBg": "#f9f2e4", + "diffRemovedBg": "#fbf1e4", "diffContextBg": "none", "diffLineNumber": "#554638", - "diffAddedLineNumberBg": "#a2ae94", - "diffRemovedLineNumberBg": "#c49b8f", + "diffAddedLineNumberBg": "#f9f2e4", + "diffRemovedLineNumberBg": "#fbf1e4", "diffHunkHeaderBg": "#b8acbd", - "diffFold": "#725e4c", + "diffFold": "#5d4d3e", "diffFoldBg": "#e6d7c4", "markdownText": "#17120d", "markdownHeading": "#824f16", @@ -80,7 +80,7 @@ "markdownImageText": "#17120d", "markdownCodeBlock": "#17120d", "markdownCodeBlockBg": "#fff7ea", - "markdownInlineCodeBg": "#547550", + "markdownInlineCodeBg": "#e7e1d2", "syntaxComment": "#725e4c", "syntaxKeyword": "#824f16", "syntaxFunction": "#a7471c", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eae4069..1f0db3a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ ### Fixed +- OpenCode surfaces follow the active mode with readable syntax and interface states. - 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/DreamcoderCodexApp/Dreamcoder-Dark.codex-theme.json b/DreamcoderCodexApp/Dreamcoder-Dark.codex-theme.json index 05c38e8e..f96b03d4 100644 --- a/DreamcoderCodexApp/Dreamcoder-Dark.codex-theme.json +++ b/DreamcoderCodexApp/Dreamcoder-Dark.codex-theme.json @@ -54,12 +54,12 @@ "diffHunkHeader": "#D4B5FD", "diffHighlightAdded": "#34D399", "diffHighlightRemoved": "#FB8585", - "diffAddedBg": "#235241", - "diffRemovedBg": "#5e3c3c", + "diffAddedBg": "#09261c", + "diffRemovedBg": "#2d1818", "diffContextBg": "#000000", "diffLineNumber": "#A7A7A7", - "diffAddedLineNumberBg": "#235241", - "diffRemovedLineNumberBg": "#5e3c3c", + "diffAddedLineNumberBg": "#09261c", + "diffRemovedLineNumberBg": "#2d1818", "diffHunkHeaderBg": "#4f4759", "diffFold": "#D0D0D0", "diffFoldBg": "#0D0D0F", @@ -80,7 +80,7 @@ "markdownImageText": "#E6E6E6", "markdownCodeBlock": "#E6E6E6", "markdownCodeBlockBg": "#0D0D0F", - "markdownInlineCodeBg": "#52a989", + "markdownInlineCodeBg": "#0D0D0F", "syntaxComment": "#D0D0D0", "syntaxKeyword": "#A5B4FC", "syntaxFunction": "#D4B5FD", diff --git a/DreamcoderCodexApp/Dreamcoder-Light.codex-theme.json b/DreamcoderCodexApp/Dreamcoder-Light.codex-theme.json index c153439b..9a31cf48 100644 --- a/DreamcoderCodexApp/Dreamcoder-Light.codex-theme.json +++ b/DreamcoderCodexApp/Dreamcoder-Light.codex-theme.json @@ -31,7 +31,7 @@ "text": "#17120d", "textMuted": "#352e22", "textSubtle": "#554638", - "textPlaceholder": "#725e4c", + "textPlaceholder": "#5d4d3e", "textAssistant": "#0f4058", "textUser": "#724615", "textTool": "#0d4a68", @@ -51,17 +51,17 @@ "diffAdded": "#315b31", "diffRemoved": "#842f24", "diffContext": "#352e22", - "diffHunkHeader": "#57478b", + "diffHunkHeader": "#473a72", "diffHighlightAdded": "#315b31", "diffHighlightRemoved": "#842f24", - "diffAddedBg": "#a2ae94", - "diffRemovedBg": "#c49b8f", + "diffAddedBg": "#f9f2e4", + "diffRemovedBg": "#fbf1e4", "diffContextBg": "#f3eadc", "diffLineNumber": "#554638", - "diffAddedLineNumberBg": "#a2ae94", - "diffRemovedLineNumberBg": "#c49b8f", + "diffAddedLineNumberBg": "#f9f2e4", + "diffRemovedLineNumberBg": "#fbf1e4", "diffHunkHeaderBg": "#b8acbd", - "diffFold": "#725e4c", + "diffFold": "#5d4d3e", "diffFoldBg": "#e6d7c4", "markdownText": "#17120d", "markdownHeading": "#824f16", @@ -80,7 +80,7 @@ "markdownImageText": "#17120d", "markdownCodeBlock": "#17120d", "markdownCodeBlockBg": "#fff7ea", - "markdownInlineCodeBg": "#547550", + "markdownInlineCodeBg": "#e7e1d2", "syntaxComment": "#725e4c", "syntaxKeyword": "#824f16", "syntaxFunction": "#a7471c", diff --git a/DreamcoderCodexApp/Dreamcoder.codex-theme.json b/DreamcoderCodexApp/Dreamcoder.codex-theme.json index c153439b..9a31cf48 100644 --- a/DreamcoderCodexApp/Dreamcoder.codex-theme.json +++ b/DreamcoderCodexApp/Dreamcoder.codex-theme.json @@ -31,7 +31,7 @@ "text": "#17120d", "textMuted": "#352e22", "textSubtle": "#554638", - "textPlaceholder": "#725e4c", + "textPlaceholder": "#5d4d3e", "textAssistant": "#0f4058", "textUser": "#724615", "textTool": "#0d4a68", @@ -51,17 +51,17 @@ "diffAdded": "#315b31", "diffRemoved": "#842f24", "diffContext": "#352e22", - "diffHunkHeader": "#57478b", + "diffHunkHeader": "#473a72", "diffHighlightAdded": "#315b31", "diffHighlightRemoved": "#842f24", - "diffAddedBg": "#a2ae94", - "diffRemovedBg": "#c49b8f", + "diffAddedBg": "#f9f2e4", + "diffRemovedBg": "#fbf1e4", "diffContextBg": "#f3eadc", "diffLineNumber": "#554638", - "diffAddedLineNumberBg": "#a2ae94", - "diffRemovedLineNumberBg": "#c49b8f", + "diffAddedLineNumberBg": "#f9f2e4", + "diffRemovedLineNumberBg": "#fbf1e4", "diffHunkHeaderBg": "#b8acbd", - "diffFold": "#725e4c", + "diffFold": "#5d4d3e", "diffFoldBg": "#e6d7c4", "markdownText": "#17120d", "markdownHeading": "#824f16", @@ -80,7 +80,7 @@ "markdownImageText": "#17120d", "markdownCodeBlock": "#17120d", "markdownCodeBlockBg": "#fff7ea", - "markdownInlineCodeBg": "#547550", + "markdownInlineCodeBg": "#e7e1d2", "syntaxComment": "#725e4c", "syntaxKeyword": "#824f16", "syntaxFunction": "#a7471c", diff --git a/src/dreamcoder_theme/renderers_opencode.py b/src/dreamcoder_theme/renderers_opencode.py index 5412534e..3df5b7a6 100644 --- a/src/dreamcoder_theme/renderers_opencode.py +++ b/src/dreamcoder_theme/renderers_opencode.py @@ -57,7 +57,7 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> user_bg = mix(c["bg"], c["accent"], 0.12) tool_bg = mix(c["bg"], c["lavender"], 0.12) accent_muted = mix(c["accent"], c["bg"], 0.25) - inline_code_bg = mix(c["sage"], c["border_ui"], 0.45) + inline_code_bg = c["surface1"] mix_base = c["border_ui"] else: panel_bg = c["surface0"] @@ -70,7 +70,7 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> user_bg = mix(c["bg"], c["accent"], 0.15) tool_bg = mix(c["bg"], c["lavender"], 0.12) accent_muted = mix(c["accent"], c["bg"], 0.3) - inline_code_bg = mix(c["sage"], c["bg"], 0.18) + inline_code_bg = mix(c["bg"], c["sage"], 0.06) mix_base = c["bg"] diff_mix = 0.35 if mode_name == "dark" else 0.58 @@ -81,9 +81,33 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> c["bg"], mode_name, ) + if mode_name == "dark": + # Quiet tinted canvases preserve syntax and gutter ink without whitening. + added_bg = surface_guard(mix(c["bg"], c["sage"], 0.18), c["bg"], mode_name) + removed_bg = surface_guard(mix(c["bg"], c["error"], 0.18), c["bg"], mode_name) + if mode_name == "light": + # Diff bodies retain syntax ink: use pale canonical code canvases rather + # than surface_guard, whose surface delta makes Light fills too dark. + added_bg = mix(c["surface0"], c["sage"], 0.03) + removed_bg = mix(c["surface0"], c["error"], 0.03) assistant = guard(mix(c["diagnostic"], c["text"], 0.18), c["bg"], mode_name) user = guard(mix(c["accent"], c["text"], 0.15), c["bg"], mode_name) background = "none" if transparent_background else c["bg"] + muted = c["muted"] + subtle = guard(c["subtle"], c["bg"], mode_name, minimum=5.5) + placeholder = c["comment"] + added, removed = c["sage"], c["error"] + hunk, fold, markdown_code = c["lavender"], c["comment"], c["sage"] + if mode_name == "light": + # Body-text roles must clear their actual rendered fill, not just canvas. + placeholder = guard(placeholder, element_bg, mode_name, minimum=4.5) + markdown_code = guard(markdown_code, inline_code_bg, mode_name, minimum=4.5) + added = guard(added, added_bg, mode_name, minimum=4.5) + removed = guard(removed, removed_bg, mode_name, minimum=4.5) + hunk = guard(hunk, hunk_bg, mode_name, minimum=4.5) + fold = guard(fold, line_bg, mode_name, minimum=4.5) + for role in ("comment", "function", "method", "operator"): + t[role] = guard(t[role], code_bg, mode_name, minimum=4.5) return f"""{{ "$schema": "https://opencode.ai/theme.json", "defs": {{ @@ -115,9 +139,9 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> "backgroundUser": "{user_bg}", "backgroundTool": "{tool_bg}", "text": "{c["text"]}", - "textMuted": "{c["muted"]}", - "textSubtle": "{guard(c["subtle"], c["bg"], mode_name, minimum=5.5)}", - "textPlaceholder": "{c["comment"]}", + "textMuted": "{muted}", + "textSubtle": "{subtle}", + "textPlaceholder": "{placeholder}", "textAssistant": "{assistant}", "textUser": "{user}", "textTool": "{t["type"]}", @@ -134,12 +158,12 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> "borderSubtle": "{c["border"]}", "borderFocus": "{c["focus"]}", "shadow": "{mix(c["bg"], "#000000", 0.25)}", - "diffAdded": "{c["sage"]}", - "diffRemoved": "{c["error"]}", + "diffAdded": "{added}", + "diffRemoved": "{removed}", "diffContext": "{c["muted"]}", - "diffHunkHeader": "{c["lavender"]}", - "diffHighlightAdded": "{c["sage"]}", - "diffHighlightRemoved": "{c["error"]}", + "diffHunkHeader": "{hunk}", + "diffHighlightAdded": "{added}", + "diffHighlightRemoved": "{removed}", "diffAddedBg": "{added_bg}", "diffRemovedBg": "{removed_bg}", "diffContextBg": "{background}", @@ -147,13 +171,13 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> "diffAddedLineNumberBg": "{added_bg}", "diffRemovedLineNumberBg": "{removed_bg}", "diffHunkHeaderBg": "{hunk_bg}", - "diffFold": "{c["comment"]}", + "diffFold": "{fold}", "diffFoldBg": "{line_bg}", "markdownText": "{c["text"]}", "markdownHeading": "{c["accent"]}", "markdownLink": "{c["diagnostic"]}", "markdownLinkText": "{c["accent"]}", - "markdownCode": "{c["sage"]}", + "markdownCode": "{markdown_code}", "markdownBlockQuote": "{c["accent_2"]}", "markdownEmph": "{c["diagnostic"]}", "markdownStrong": "{c["accent"]}", diff --git a/tests/test_dark_surface_readability.py b/tests/test_dark_surface_readability.py new file mode 100644 index 00000000..fe15dfeb --- /dev/null +++ b/tests/test_dark_surface_readability.py @@ -0,0 +1,81 @@ +"""Dark emitted text/surface regressions; no speculative token cross-product.""" + +import json + +import pytest + +from dreamcoder_theme.palette import apca_lc, contrast +from dreamcoder_theme.palette_tokens import VARIANTS +from dreamcoder_theme.renderers_opencode import opencode_content, opencode_tokens + +DARK = VARIANTS["dark"] + + +def readable(theme, fg, bg, floor=4.5): + background = DARK["bg"] if theme[bg] == "none" else theme[bg] + ratio = contrast(theme[fg], background) + assert ratio >= floor, f"{fg}/{bg}: {ratio:.3f} < {floor}" + + +@pytest.fixture(params=(False, True), ids=("opaque", "transparent")) +def theme(request): + original = DARK.copy() + result = json.loads(opencode_content(DARK, request.param))["theme"] + assert original == DARK + return result + + +def test_dark_inline_code_preserves_sage_on_subtle_fill(theme): + assert theme["markdownCode"] == DARK["sage"] + readable(theme, "markdownCode", "markdownInlineCodeBg") + readable(theme, "markdownInlineCodeBg", "background", 1.05) + + +@pytest.mark.parametrize("kind", ("Added", "Removed")) +def test_dark_diff_semantics_syntax_and_gutter(theme, kind): + bg = f"diff{kind}Bg" + fields = [key for key in theme if key.startswith("syntax")] + assert len(fields) == 29 + fields += [f"diff{kind}", f"diffHighlight{kind}", "diffLineNumber"] + failures = [ + (fg, contrast(theme[fg], theme[bg])) + for fg in fields + if contrast(theme[fg], theme[bg]) < 4.5 + ] + assert not failures, failures + assert theme[bg] == theme[f"diff{kind}LineNumberBg"] + assert theme["diffAddedBg"] != theme["diffRemovedBg"] + # Retain green/red identity as well as the app's existing +/- markers. + channels = tuple(int(theme[bg][i : i + 2], 16) for i in (1, 3, 5)) + assert channels[1 if kind == "Added" else 0] == max(channels) + for surface in (bg, f"diff{kind}LineNumberBg", "diffHunkHeaderBg"): + readable(theme, surface, "background", 1.05) + assert contrast(theme[surface], DARK["bg"]) <= 2.4 + readable(theme, "diffHunkHeader", "diffHunkHeaderBg") + + +def test_dark_existing_main_messages_and_selection(theme): + assert theme["background"] in ("none", "#000000") + for bg in ( + "background", + "backgroundPanel", + "backgroundElement", + "backgroundCode", + "backgroundAssistant", + "backgroundUser", + "backgroundTool", + ): + readable(theme, "text", bg, 7.0) + for role in ("Assistant", "User", "Tool"): + readable(theme, f"text{role}", f"background{role}") + readable(theme, "textSelected", "backgroundSelected", 7.0) + assert abs(apca_lc(theme["text"], DARK["bg"])) >= 50 + tokens = opencode_tokens(DARK) + for field, token in ( + ("syntaxKeyword", "keyword"), + ("syntaxTag", "keyword"), + ("syntaxDeprecated", "deprecated"), + ): + assert theme[field] == tokens[token] + for surface in ("backgroundElement", "backgroundCode", "backgroundLine"): + readable(theme, surface, "background", 1.05) diff --git a/tests/test_light_surface_readability.py b/tests/test_light_surface_readability.py new file mode 100644 index 00000000..6e1567f1 --- /dev/null +++ b/tests/test_light_surface_readability.py @@ -0,0 +1,229 @@ +"""Light-only checks of emitted text/surface pairs, not token cross-products. + +The contract schema requires every matrix row to cover all three modes, and +its evaluator validates structure, not numeric contrast. These light-only +checks therefore remain local rather than claiming new contract coverage. +OpenCode includes opaque and terminal-transparent output, measured against +its actual functional surfaces (or the palette canvas for inherited fills). +""" + +import copy +import json +import re +import tomllib +from pathlib import Path + +import pytest + +from dreamcoder_theme._math import contrast +from dreamcoder_theme.design_system import load_contract, load_tokens, render_target +from dreamcoder_theme.renderers_kitty import kitty_content +from dreamcoder_theme.renderers_opencode import opencode_content +from dreamcoder_theme.renderers_starship import starship_content +from dreamcoder_theme.renderers_tmux import tmux_content + +THEME = Path(__file__).resolve().parents[1] / "DreamcoderThemes" / "dreamcoder" +TOKENS = load_tokens(THEME / "tokens.json") +LIGHT = TOKENS["modes"]["light"] +BODY = TOKENS["guardrails"]["minimum_text_contrast"] +MAIN = TOKENS["guardrails"]["preferred_main_text_contrast"] +SURFACE_PAIRS = ( + ("opencode", "text", "panel", MAIN), + ("opencode", "muted", "panel", BODY), + ("tmux", "text", "surface", MAIN), +) + + +def assert_readable(name, foreground, background, floor): + ratio = contrast(foreground, background) + assert ratio >= floor, f"{name}: {foreground}/{background} contrast {ratio:.3f} < {floor}" + + +@pytest.mark.parametrize("target,fg,bg,floor", SURFACE_PAIRS) +def test_light_surface_pairs(target, fg, bg, floor): + contract = load_contract(THEME / "design-system.json") + output = render_target(target, "light", LIGHT, contract["targets"][target]) + assert_readable(target, output.fields[fg], output.fields[bg], floor) + + # Public rendering of a bad palette must fail this same numeric gate. + broken = copy.deepcopy(LIGHT) + broken["text" if fg == "text" else "muted"] = output.fields[bg] + output = render_target(target, "light", broken, contract["targets"][target]) + with pytest.raises(AssertionError, match=r"contrast 1.000"): + assert_readable(target, output.fields[fg], output.fields[bg], floor) + + +def opencode_pairs(content): + theme = json.loads(content)["theme"] + # Explicit renderer fields with associated surfaces (renderers_opencode.py). + for fg, bg, floor in ( + ("text", "backgroundElement", MAIN), + ("textPlaceholder", "backgroundElement", BODY), + ("markdownCode", "markdownInlineCodeBg", BODY), + ("diffAdded", "diffAddedBg", BODY), + ("diffHighlightAdded", "diffAddedBg", BODY), + ("diffRemoved", "diffRemovedBg", BODY), + ("diffHighlightRemoved", "diffRemovedBg", BODY), + ("diffHunkHeader", "diffHunkHeaderBg", BODY), + ("diffFold", "diffFoldBg", BODY), + ("syntaxFunction", "backgroundCode", BODY), + ("syntaxMethod", "backgroundCode", BODY), + ("syntaxOperator", "backgroundCode", BODY), + ("syntaxDecorator", "backgroundCode", BODY), + ("markdownCodeBlock", "markdownCodeBlockBg", MAIN), + ("syntaxComment", "backgroundCode", BODY), + ("syntaxString", "backgroundCode", BODY), + ("diffContext", "diffContextBg", BODY), + ("textAssistant", "backgroundAssistant", BODY), + ("textUser", "backgroundUser", BODY), + ("textTool", "backgroundTool", BODY), + ): + background = LIGHT["bg"] if theme[bg] == "none" else theme[bg] + yield f"{fg}/{bg}", theme[fg], background, floor + + # Diff bodies retain code syntax highlighting, not just semantic +/- ink. + for bg in ("diffAddedBg", "diffRemovedBg"): + for fg in theme: + if fg.startswith("syntax"): + yield f"{fg}/{bg}", theme[fg], theme[bg], BODY + for bg in ("diffAddedLineNumberBg", "diffRemovedLineNumberBg"): + yield f"diffLineNumber/{bg}", theme["diffLineNumber"], theme[bg], BODY + + +def starship_pairs(content): + doc = tomllib.loads(content) + palette = doc["palettes"]["dreamcoder"] + # Read text styles, not powerline separator glyphs (non-text decoration). + for module, field in ( + ("username", "style_user"), + ("username", "style_root"), + ("directory", "style"), + ("git_branch", "style"), + ("git_status", "style"), + ("status", "style"), + ("cmd_duration", "style"), + ("time", "style"), + ): + style = dict(re.findall(r"(fg|bg):([\w]+)", doc[module][field])) + fg, bg = style["fg"], style.get("bg", "bg") + yield f"{module}.{field}:{fg}/{bg}", palette[fg], palette[bg], BODY + + +def tmux_pairs(content): + for line in content.splitlines(): + match = re.search( + r"(message-style|message-command-style|mode-style|window-status-bell-style) " + r'"fg=(#[\da-fA-F]+),bg=(#[\da-fA-F]+)', + line, + ) + if match: + name, fg, bg = match.groups() + yield name, fg, bg, MAIN if name.startswith("message") else BODY + + +def kitty_pairs(content): + fields = dict( + line.split() + for line in content.splitlines() + if len(line.split()) == 2 and not line.startswith("#") + ) + for prefix in ("active_tab", "inactive_tab", "mark1", "mark2", "mark3"): + yield prefix, fields[f"{prefix}_foreground"], fields[f"{prefix}_background"], BODY + + +@pytest.mark.parametrize( + "renderer,pairs,count", + ( + (opencode_content, opencode_pairs, 80), + (lambda c: opencode_content(c, transparent_background=True), opencode_pairs, 80), + (starship_content, starship_pairs, 8), + (tmux_content, tmux_pairs, 4), + (kitty_content, kitty_pairs, 5), + ), +) +def test_emitted_light_surface_pairs_and_bad_output(renderer, pairs, count): + content = renderer(LIGHT) + measurements = list(pairs(content)) + assert len(measurements) == count # A parser must not silently drop a style. + for name, fg, bg, floor in measurements: + assert_readable(name, fg, bg, floor) + # Corrupt emitted foregrounds, then parse again: no token-only proof. + mutated = {entry[0]: entry for entry in pairs(content.replace(fg, bg))} + _, bad_fg, bad_bg, _ = mutated[name] + with pytest.raises(AssertionError, match=r"contrast 1.000"): + assert_readable(name, bad_fg, bad_bg, floor) + + +@pytest.mark.parametrize("transparent", (False, True), ids=("opaque", "transparent")) +@pytest.mark.parametrize( + "family,pairs", + ( + ("inline", (("markdownCode", "markdownInlineCodeBg"),)), + ( + "diff", + ( + ("diffAdded", "diffAddedBg"), + ("diffHighlightAdded", "diffAddedBg"), + ("diffRemoved", "diffRemovedBg"), + ("diffHighlightRemoved", "diffRemovedBg"), + ("diffHunkHeader", "diffHunkHeaderBg"), + ("diffFold", "diffFoldBg"), + ), + ), + ( + "syntax", + tuple( + (role, "backgroundCode") + for role in ( + "syntaxComment", + "syntaxFunction", + "syntaxMethod", + "syntaxOperator", + "syntaxDecorator", + ) + ), + ), + ("placeholder", (("textPlaceholder", "backgroundElement"),)), + ), +) +def test_measured_light_rule_families(family, pairs, transparent): + original = LIGHT.copy() + theme = json.loads(opencode_content(LIGHT, transparent_background=transparent))["theme"] + for fg, bg in pairs: + assert_readable(f"{family}:{fg}/{bg}", theme[fg], theme[bg], BODY) + assert original == LIGHT + + +@pytest.mark.parametrize("transparent", (False, True), ids=("opaque", "transparent")) +@pytest.mark.parametrize("kind", ("Added", "Removed")) +@pytest.mark.parametrize("role", ("syntax", "line-number")) +def test_light_diff_code_and_gutter(kind, role, transparent): + theme = json.loads(opencode_content(LIGHT, transparent_background=transparent))["theme"] + fields = ( + [key for key in theme if key.startswith("syntax")] + if role == "syntax" + else ["diffLineNumber"] + ) + bg = f"diff{kind}Bg" if role == "syntax" else f"diff{kind}LineNumberBg" + for fg in fields: + assert_readable(f"{fg}/{bg}", theme[fg], theme[bg], BODY) + # Preserve the health check's canvas distinction floor alongside text contrast. + canvas = LIGHT["bg"] if theme["background"] == "none" else theme["background"] + assert_readable(f"{bg}/canvas", theme[bg], canvas, 1.05) + # Body and gutter share a tint, with distinct addition/removal identities. + assert theme["diffAddedBg"] != theme["diffRemovedBg"] + assert theme[f"diff{kind}Bg"] == theme[f"diff{kind}LineNumberBg"] + + +@pytest.mark.parametrize("target", ("kitty", "ghostty", "warp", "starship", "tmux", "opencode")) +def test_existing_main_and_selection_pairs_remain_readable(target): + contract = load_contract(THEME / "design-system.json") + output = render_target(target, "light", LIGHT, contract["targets"][target]) + assert_readable(target, output.fields["text"], output.fields["background"], MAIN) + if "selection.text" in output.fields: + assert_readable( + target + ":selection", + output.fields["selection.text"], + output.fields["selection.background"], + TOKENS["guardrails"]["minimum_terminal_selection_contrast"], + ) diff --git a/tests/test_renderer_output.py b/tests/test_renderer_output.py index b214c338..ef4c0b8e 100644 --- a/tests/test_renderer_output.py +++ b/tests/test_renderer_output.py @@ -6,7 +6,7 @@ import pytest -from dreamcoder_theme.palette import guard, make_guard +from dreamcoder_theme.palette import detect_mode, guard, make_guard from dreamcoder_theme.renderers_codex import codex_tmtheme_content from dreamcoder_theme.renderers_extra_nvim import nvim_content from dreamcoder_theme.renderers_kitty import kitty_content, kitty_ui_content @@ -141,7 +141,15 @@ def test_syntax_colors_match_opencode_tokens(self, variant): data = json.loads(content) theme = data["theme"] assert theme["syntaxKeyword"] == t["keyword"] - assert theme["syntaxFunction"] == t["function"] + expected_function = t["function"] + if detect_mode(variant) == "light": + expected_function = guard( + expected_function, + theme["backgroundCode"], + "light", + minimum=4.5, + ) + assert theme["syntaxFunction"] == expected_function assert theme["syntaxVariable"] == t["variable"] assert theme["syntaxString"] == t["string"] assert theme["syntaxType"] == t["type"]