diff --git a/.opencode/themes/dreamcoder.json b/.opencode/themes/dreamcoder.json index 2e9db67..441a962 100644 --- a/.opencode/themes/dreamcoder.json +++ b/.opencode/themes/dreamcoder.json @@ -17,12 +17,12 @@ }, "theme": { "background": "none", - "backgroundPanel": "#fff7ea", - "backgroundElement": "#e3d2bc", + "backgroundPanel": "none", + "backgroundElement": "#e6d7c4", "backgroundHover": "#decbb1", "backgroundSelected": "#decbb1", "textSelected": "#17120d", - "backgroundCode": "#fff7ea", + "backgroundCode": "#e6d7c4", "backgroundSearch": "#a1895c", "backgroundLine": "#e6d7c4", "backgroundAssistant": "#d7d7ce", @@ -79,12 +79,12 @@ "markdownImage": "#7d3e64", "markdownImageText": "#17120d", "markdownCodeBlock": "#17120d", - "markdownCodeBlockBg": "#fff7ea", + "markdownCodeBlockBg": "#e6d7c4", "markdownInlineCodeBg": "#e7e1d2", - "syntaxComment": "#725e4c", + "syntaxComment": "#5d4d3e", "syntaxKeyword": "#824f16", - "syntaxFunction": "#a7471c", - "syntaxMethod": "#a7471c", + "syntaxFunction": "#893a17", + "syntaxMethod": "#893a17", "syntaxVariable": "#352e22", "syntaxParameter": "#9e441b", "syntaxProperty": "#0d4864", @@ -97,7 +97,7 @@ "syntaxClass": "#224b59", "syntaxInterface": "#0d4a68", "syntaxEnum": "#134d5e", - "syntaxOperator": "#a7471c", + "syntaxOperator": "#893a17", "syntaxPunctuation": "#352e22", "syntaxTag": "#824f16", "syntaxAttribute": "#0d4864", @@ -105,7 +105,7 @@ "syntaxEscape": "#654300", "syntaxNamespace": "#0d4a68", "syntaxModule": "#0d4a68", - "syntaxDecorator": "#a7471c", + "syntaxDecorator": "#893a17", "syntaxBuiltin": "#7d3e64", "syntaxSpecial": "#654300", "syntaxTodo": "#5c3d02", diff --git a/src/dreamcoder_theme/renderers_opencode.py b/src/dreamcoder_theme/renderers_opencode.py index 3df5b7a..e812903 100644 --- a/src/dreamcoder_theme/renderers_opencode.py +++ b/src/dreamcoder_theme/renderers_opencode.py @@ -96,6 +96,15 @@ def opencode_content(c: dict[str, str], transparent_background: bool = False) -> muted = c["muted"] subtle = guard(c["subtle"], c["bg"], mode_name, minimum=5.5) placeholder = c["comment"] + if transparent_background: + # OpenCode shares the terminal canvas; only functional blocks get a fill. + # Shared syntax tokens stay unchanged; rendered Light roles guard below. + panel_bg = "none" + element_bg = code_bg = line_bg = c["bg_soft"] + for surface in (c["bg"], c["bg_soft"]): + muted = guard(muted, surface, mode_name) + subtle = guard(subtle, surface, mode_name) + placeholder = guard(placeholder, surface, mode_name) added, removed = c["sage"], c["error"] hunk, fold, markdown_code = c["lavender"], c["comment"], c["sage"] if mode_name == "light": diff --git a/tests/test_opencode_visual_coherence.py b/tests/test_opencode_visual_coherence.py new file mode 100644 index 0000000..d2f6ac9 --- /dev/null +++ b/tests/test_opencode_visual_coherence.py @@ -0,0 +1,112 @@ +"""OpenCode-only surface hierarchy and readable secondary text.""" + +import json +from pathlib import Path + +import pytest + +from dreamcoder_theme.palette import contrast, detect_mode, guard, mix +from dreamcoder_theme.renderers_opencode import opencode_content + +TOKENS_FILE = Path(__file__).resolve().parents[1] / "DreamcoderThemes/dreamcoder/tokens.json" +VARIANTS = json.loads(TOKENS_FILE.read_text())["modes"] + + +@pytest.fixture(params=list(VARIANTS)) +def palette(request): + return VARIANTS[request.param].copy() + + +def test_transparent_panel_inherits_terminal_canvas(palette): + theme = json.loads(opencode_content(palette, transparent_background=True))["theme"] + assert theme["background"] == "none" + assert theme["backgroundPanel"] == "none" + + +@pytest.mark.parametrize( + "role", + ["backgroundElement", "backgroundCode", "backgroundLine", "markdownCodeBlockBg", "diffFoldBg"], +) +def test_transparent_functional_surfaces_use_canonical_soft_background(palette, role): + theme = json.loads(opencode_content(palette, transparent_background=True))["theme"] + assert theme[role] == palette["bg_soft"] + + +@pytest.mark.parametrize("role", ["text", "textMuted", "textSubtle", "textPlaceholder"]) +@pytest.mark.parametrize( + "surface", ["background", "backgroundPanel", "backgroundElement", "backgroundCode"] +) +def test_transparent_text_is_readable_on_each_surface(palette, role, surface): + theme = json.loads(opencode_content(palette, transparent_background=True))["theme"] + background = palette["bg"] if theme[surface] == "none" else theme[surface] + assert contrast(theme[role], background) >= 4.5 + + +@pytest.mark.parametrize( + "token,role", [("muted", "textMuted"), ("subtle", "textSubtle"), ("comment", "textPlaceholder")] +) +def test_transparent_secondary_text_is_guarded_without_changing_palette(palette, token, role): + palette[token] = palette["bg_soft"] + original = palette.copy() + theme = json.loads(opencode_content(palette, transparent_background=True))["theme"] + for background in (palette["bg"], palette["bg_soft"]): + assert contrast(theme[role], background) >= 4.5 + assert palette == original + + +def test_transparent_hover_selection_and_syntax_are_preserved(palette): + opaque = json.loads(opencode_content(palette)) + transparent = json.loads(opencode_content(palette, transparent_background=True)) + assert transparent.keys() == opaque.keys() + assert transparent["defs"] == opaque["defs"] + assert transparent["theme"].keys() == opaque["theme"].keys() + for key, value in opaque["theme"].items(): + corrected = detect_mode(palette) == "light" and key in ( + "syntaxComment", + "syntaxFunction", + "syntaxMethod", + "syntaxOperator", + "syntaxDecorator", + ) + if corrected: + assert transparent["theme"][key] == guard( + value, + transparent["theme"]["backgroundCode"], + "light", + minimum=4.5, + ) + elif key.startswith("syntax") or key in ( + "backgroundHover", + "backgroundSelected", + "textSelected", + ): + assert transparent["theme"][key] == value + assert transparent["theme"]["backgroundHover"] != palette["bg_soft"] + assert transparent["theme"]["backgroundSelected"] != palette["bg_soft"] + + +def test_nontransparent_surface_and_text_formulas_are_preserved(palette): + theme = json.loads(opencode_content(palette))["theme"] + dark = detect_mode(palette) == "dark" + assert theme["background"] == palette["bg"] + assert theme["backgroundPanel"] == palette["surface1" if dark else "surface0"] + assert theme["backgroundElement"] == ( + palette["surface1"] if dark else mix(palette["bg_soft"], palette["surface1"], 0.4) + ) + assert theme["backgroundCode"] == palette["surface1" if dark else "surface0"] + assert theme["backgroundLine"] == palette["surface1" if dark else "bg_soft"] + assert theme["text"] == palette["text"] + assert theme["textMuted"] == palette["muted"] + assert theme["textSubtle"] == guard( + palette["subtle"], palette["bg"], detect_mode(palette), minimum=5.5 + ) + assert theme["textPlaceholder"] == ( + palette["comment"] + if dark + else guard( + palette["comment"], + theme["backgroundElement"], + "light", + minimum=4.5, + ) + ) diff --git a/tests/test_pi_theme_generation.py b/tests/test_pi_theme_generation.py index cc30fd5..2cc0aa6 100644 --- a/tests/test_pi_theme_generation.py +++ b/tests/test_pi_theme_generation.py @@ -282,7 +282,8 @@ def test_opencode_preserves_terminal_transparent_background(self): theme = json.loads(theme_path.read_text())["theme"] self.assertEqual(theme["background"], "none") - self.assertEqual(theme["backgroundPanel"], "#0D0D0F") + self.assertEqual(theme["backgroundPanel"], "none") + self.assertEqual(theme["backgroundElement"], "#0B0B0B") self.assertEqual(theme["primary"], "#A5B4FC") self.assertEqual(theme["secondary"], "#D4B5FD")