From cad2183c51df8f17e19c4586ebe00919c96d6dbb Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 4 Jun 2026 19:52:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(themes):=20=E2=9C=A8=20Introduce=20semanti?= =?UTF-8?q?c=20design=20tokens=20system=20for=20UI=20theming=20with=20them?= =?UTF-8?q?e=20assets,=20resource=20files,=20build=20integration,=20and=20?= =?UTF-8?q?unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- public/games/age-of-dwarves/ui_theme.tres | 84 ++--- src/game/engine/src/autoloads/theme_assets.gd | 67 ++++ .../tests/unit/test_theme_assets_color.gd | 49 +++ tools/build-ui-theme.py | 334 ++++++++++++++++++ 4 files changed, 493 insertions(+), 41 deletions(-) create mode 100644 src/game/engine/tests/unit/test_theme_assets_color.gd create mode 100644 tools/build-ui-theme.py diff --git a/public/games/age-of-dwarves/ui_theme.tres b/public/games/age-of-dwarves/ui_theme.tres index c93c7198..a33fabc7 100644 --- a/public/games/age-of-dwarves/ui_theme.tres +++ b/public/games/age-of-dwarves/ui_theme.tres @@ -1,68 +1,69 @@ [gd_resource type="Theme" format=3 uid="uid://ui_theme_fantasy"] -[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel_dark"] -bg_color = Color(0.09, 0.07, 0.14, 0.96) + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"] +bg_color = Color(0.090196, 0.070588, 0.117647, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.45, 0.35, 0.12, 0.8) +border_color = Color(0.45098, 0.34902, 0.121569, 0.8) corner_radius_top_left = 4 corner_radius_top_right = 4 corner_radius_bottom_left = 4 corner_radius_bottom_right = 4 -content_margin_left = 8.0 -content_margin_top = 6.0 -content_margin_right = 8.0 -content_margin_bottom = 6.0 +content_margin_left = 12 +content_margin_top = 8 +content_margin_right = 12 +content_margin_bottom = 8 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_normal"] -bg_color = Color(0.12, 0.09, 0.2, 1.0) +bg_color = Color(0.121569, 0.090196, 0.2, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.4, 0.3, 0.1, 0.9) +border_color = Color(0.45098, 0.34902, 0.121569, 0.8) corner_radius_top_left = 3 corner_radius_top_right = 3 corner_radius_bottom_left = 3 corner_radius_bottom_right = 3 -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 +content_margin_left = 12 +content_margin_top = 8 +content_margin_right = 12 +content_margin_bottom = 8 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"] -bg_color = Color(0.2, 0.15, 0.08, 1.0) +bg_color = Color(0.2, 0.101961, 0.05098, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.85, 0.7, 0.25, 1.0) +border_color = Color(0.85098, 0.701961, 0.247059, 1) corner_radius_top_left = 3 corner_radius_top_right = 3 corner_radius_bottom_left = 3 corner_radius_bottom_right = 3 -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 +content_margin_left = 12 +content_margin_top = 8 +content_margin_right = 12 +content_margin_bottom = 8 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_pressed"] -bg_color = Color(0.28, 0.2, 0.06, 1.0) +bg_color = Color(0.278431, 0.184314, 0.058824, 1) border_width_left = 2 border_width_top = 2 border_width_right = 2 border_width_bottom = 2 -border_color = Color(1.0, 0.82, 0.3, 1.0) +border_color = Color(1, 0.819608, 0.301961, 1) corner_radius_top_left = 3 corner_radius_top_right = 3 corner_radius_bottom_left = 3 corner_radius_bottom_right = 3 -content_margin_left = 12.0 -content_margin_top = 6.0 -content_margin_right = 12.0 -content_margin_bottom = 6.0 +content_margin_left = 12 +content_margin_top = 8 +content_margin_right = 12 +content_margin_bottom = 8 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_focus"] bg_color = Color(0, 0, 0, 0) @@ -70,57 +71,58 @@ border_width_left = 2 border_width_top = 2 border_width_right = 2 border_width_bottom = 2 -border_color = Color(0.85, 0.7, 0.25, 0.7) +border_color = Color(0.85098, 0.701961, 0.25098, 1) corner_radius_top_left = 3 corner_radius_top_right = 3 corner_radius_bottom_left = 3 corner_radius_bottom_right = 3 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_item_list_bg"] -bg_color = Color(0.07, 0.05, 0.12, 1.0) +bg_color = Color(0.070588, 0.054902, 0.117647, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.3, 0.25, 0.08, 0.7) +border_color = Color(0.301961, 0.25098, 0.078431, 0.698039) corner_radius_top_left = 2 corner_radius_top_right = 2 corner_radius_bottom_left = 2 corner_radius_bottom_right = 2 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_item_list_selected"] -bg_color = Color(0.25, 0.18, 0.05, 1.0) +bg_color = Color(0.247059, 0.176471, 0.05098, 1) border_width_left = 1 border_width_top = 1 border_width_right = 1 border_width_bottom = 1 -border_color = Color(0.85, 0.7, 0.25, 0.8) +border_color = Color(0.85098, 0.701961, 0.25098, 0.8) corner_radius_top_left = 2 corner_radius_top_right = 2 corner_radius_bottom_left = 2 corner_radius_bottom_right = 2 [resource] -Button/colors/font_color = Color(0.88, 0.82, 0.6, 1.0) -Button/colors/font_hover_color = Color(1.0, 0.92, 0.5, 1.0) -Button/colors/font_pressed_color = Color(1.0, 1.0, 0.7, 1.0) -Button/colors/font_focus_color = Color(1.0, 0.92, 0.5, 1.0) -Button/colors/font_disabled_color = Color(0.5, 0.5, 0.4, 0.6) +metadata/tokens = "{\"accent.gold\":\"d9a020\",\"accent.goldBright\":\"d9b33f\",\"accent.goldPress\":\"ffd14d\",\"accent.goldResource\":\"f2d133\",\"accent.ping\":\"ffd973\",\"accent.sage\":\"66b866\",\"accent.science\":\"66bfff\",\"background.base\":\"1a1410\",\"background.deepest\":\"171219\",\"background.happiness\":\"0f0d07\",\"background.hud\":\"00000099\",\"background.list\":\"120e1e\",\"background.listSelected\":\"3f2d0d\",\"background.menu\":\"0e0a17\",\"background.overlay\":\"0000009e\",\"background.panel\":\"17121e\",\"background.raised\":\"2a2018\",\"background.surface\":\"221a14\",\"border.divider\":\"99731f80\",\"border.focus\":\"d9b340ff\",\"border.happiness\":\"b39940d9\",\"border.list\":\"4d4014b2\",\"border.listSelected\":\"d9b340cc\",\"border.panel\":\"73591fcc\",\"button.bgHover\":\"331a0d\",\"button.bgNormal\":\"1f1733\",\"button.bgPressed\":\"472f0f\",\"climate.cold\":\"1a4dff\",\"climate.hot\":\"ff260d\",\"climate.textCold\":\"66b3ff\",\"climate.textNeutral\":\"d9e0d9\",\"climate.textWarming\":\"ff731a\",\"climate.warm\":\"26cc40\",\"fog.explored\":\"000000b2\",\"fog.unexplored\":\"000000e5\",\"guide.bgPrimary\":\"1a1410\",\"guide.bgSecondary\":\"221a14\",\"guide.bgTertiary\":\"2a2018\",\"guide.dwarfAccent\":\"8b6a1a\",\"guide.dwarfPrimary\":\"c07040\",\"guide.dwarfPrimaryDark\":\"8a4a28\",\"guide.dwarfPrimaryLight\":\"e09868\",\"guide.textMuted\":\"7a6048\",\"guide.textPrimary\":\"f0e4d0\",\"guide.textSecondary\":\"b8a078\",\"player.blue\":\"3366ff\",\"player.brown\":\"806659\",\"player.cyan\":\"1accd9\",\"player.gray\":\"999999\",\"player.green\":\"33cc4d\",\"player.magenta\":\"cc4d80\",\"player.navy\":\"4d4d99\",\"player.orange\":\"e6801a\",\"player.purple\":\"b24de6\",\"player.red\":\"e63333\",\"player.sage\":\"66b366\",\"player.yellow\":\"e6cc1a\",\"semantic.diplomacy\":\"e68c73\",\"semantic.goldenAge\":\"ffeb66\",\"semantic.negative\":\"d95940\",\"semantic.positive\":\"66e666\",\"semantic.trade\":\"ccbf73\",\"semantic.warning\":\"e69933\",\"text.button\":\"e0d199\",\"text.buttonHover\":\"ffeb80\",\"text.buttonPressed\":\"ffffb3\",\"text.disabled\":\"80806680\",\"text.muted\":\"b2b2b2\",\"text.primary\":\"e0d8c8\",\"text.secondary\":\"bfb7a6\",\"text.title\":\"f2d973\"}" +Button/colors/font_color = Color(0.878431, 0.819608, 0.6, 1) +Button/colors/font_hover_color = Color(1, 0.921569, 0.501961, 1) +Button/colors/font_pressed_color = Color(1, 1, 0.701961, 1) +Button/colors/font_focus_color = Color(1, 0.921569, 0.501961, 1) +Button/colors/font_disabled_color = Color(0.501961, 0.501961, 0.4, 0.501961) Button/font_sizes/font_size = 15 Button/styles/normal = SubResource("StyleBoxFlat_button_normal") Button/styles/hover = SubResource("StyleBoxFlat_button_hover") Button/styles/pressed = SubResource("StyleBoxFlat_button_pressed") Button/styles/focus = SubResource("StyleBoxFlat_button_focus") Button/styles/disabled = SubResource("StyleBoxFlat_button_normal") -Label/colors/font_color = Color(0.878, 0.847, 0.784, 1.0) +Label/colors/font_color = Color(0.878431, 0.847059, 0.784314, 1) Label/font_sizes/font_size = 15 -PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel_dark") -Panel/styles/panel = SubResource("StyleBoxFlat_panel_dark") -ItemList/colors/font_color = Color(0.878, 0.847, 0.784, 1.0) -ItemList/colors/font_selected_color = Color(1.0, 0.92, 0.5, 1.0) +PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel") +Panel/styles/panel = SubResource("StyleBoxFlat_panel") +ItemList/colors/font_color = Color(0.878431, 0.847059, 0.784314, 1) +ItemList/colors/font_selected_color = Color(1, 0.921569, 0.501961, 1) ItemList/font_sizes/font_size = 14 ItemList/styles/panel = SubResource("StyleBoxFlat_item_list_bg") ItemList/styles/selected = SubResource("StyleBoxFlat_item_list_selected") ItemList/styles/selected_focus = SubResource("StyleBoxFlat_item_list_selected") -RichTextLabel/colors/default_color = Color(0.878, 0.847, 0.784, 1.0) +RichTextLabel/colors/default_color = Color(0.878431, 0.847059, 0.784314, 1) RichTextLabel/font_sizes/normal_font_size = 14 diff --git a/src/game/engine/src/autoloads/theme_assets.gd b/src/game/engine/src/autoloads/theme_assets.gd index 37e3b3f0..b3175647 100644 --- a/src/game/engine/src/autoloads/theme_assets.gd +++ b/src/game/engine/src/autoloads/theme_assets.gd @@ -12,11 +12,20 @@ const VALID_VARIANTS: Array[String] = [ "default", "deuteranopia", "protanopia", "tritanopia", ] +## Theme id used to locate ui_theme.tres for color() before set_theme() has run +## (e.g. a proof scene launched directly, where no menu calls set_theme()). +const DEFAULT_THEME_ID: String = "age-of-dwarves" +const UI_THEME_REL: String = "ui_theme.tres" + var _active_theme: String = "" var _base_path: String = "" var _texture_cache: Dictionary = {} var _palettes: Dictionary = {} var _active_palette_variant: String = DEFAULT_VARIANT +## Parsed `metadata/tokens` blob from ui_theme.tres: { "accent.gold": "d9a020", ... }. +## Populated lazily on first color() call; key never re-parsed. +var _tokens: Dictionary = {} +var _tokens_loaded: bool = false func set_theme(theme_id: String) -> void: @@ -102,6 +111,64 @@ func get_active_theme() -> String: return _active_theme +# -- Semantic design tokens ----------------------------------------------- +## Resolves a named design token to a Color, e.g. color("accent.gold"), +## color("text.primary"), color("semantic.positive"). The token table is the +## `metadata/tokens` blob baked into ui_theme.tres by tools/build-ui-theme.py +## (whose source of truth is .project/designs/design-tokens.json) — there is no +## hardcoded color map here. This is the API GDScript references instead of raw +## Color() literals. Returns `fallback` (default magenta) for unknown names so a +## typo is visible rather than silently black. + + +func color(name: String, fallback: Color = Color(1.0, 0.0, 1.0, 1.0)) -> Color: + _ensure_tokens_loaded() + if not _tokens.has(name): + push_warning("ThemeAssets: unknown design token '%s'" % name) + return fallback + var hex: String = str(_tokens[name]) + return Color.html(hex) + + +## True when the named token exists in the loaded token table. +func has_color(name: String) -> bool: + _ensure_tokens_loaded() + return _tokens.has(name) + + +## All known token names (sorted), for tooling / debug enumeration. +func token_names() -> Array: + _ensure_tokens_loaded() + var names: Array = _tokens.keys() + names.sort() + return names + + +func _ensure_tokens_loaded() -> void: + if _tokens_loaded: + return + _tokens_loaded = true + var theme_id: String = _active_theme if not _active_theme.is_empty() else DEFAULT_THEME_ID + var path: String = "res://public/games/%s/%s" % [theme_id, UI_THEME_REL] + if not ResourceLoader.exists(path): + push_warning("ThemeAssets: ui_theme.tres not found at %s" % path) + return + var theme: Theme = load(path) as Theme + if theme == null: + push_warning("ThemeAssets: failed to load Theme at %s" % path) + return + if not theme.has_meta("tokens"): + push_warning( + "ThemeAssets: ui_theme.tres has no 'tokens' metadata — rerun tools/build-ui-theme.py" + ) + return + var parsed: Dictionary = JSON.parse_string(str(theme.get_meta("tokens"))) + if parsed != null: + _tokens = parsed + else: + push_error("ThemeAssets: tokens metadata is not a JSON object") + + # -- Palette variants ----------------------------------------------------- diff --git a/src/game/engine/tests/unit/test_theme_assets_color.gd b/src/game/engine/tests/unit/test_theme_assets_color.gd new file mode 100644 index 00000000..a8856260 --- /dev/null +++ b/src/game/engine/tests/unit/test_theme_assets_color.gd @@ -0,0 +1,49 @@ +extends GutTest +## p2-73 — ThemeAssets.color() semantic token accessor. +## +## The accessor resolves dotted token names (e.g. "accent.gold") against the +## `metadata/tokens` blob baked into ui_theme.tres by tools/build-ui-theme.py +## (SoT: .project/designs/design-tokens.json). These assertions are headless-safe: +## color() only loads the Theme resource and parses the blob — no display server. + + +func before_all() -> void: + ThemeAssets.set_theme("age-of-dwarves") + + +func test_resolves_known_rgb_token() -> void: + # accent.gold = #d9a020 → fully opaque. + var c: Color = ThemeAssets.color("accent.gold") + assert_almost_eq(c.r, 0xd9 / 255.0, 0.004, "accent.gold red") + assert_almost_eq(c.g, 0xa0 / 255.0, 0.004, "accent.gold green") + assert_almost_eq(c.b, 0x20 / 255.0, 0.004, "accent.gold blue") + assert_almost_eq(c.a, 1.0, 0.004, "accent.gold alpha (opaque)") + + +func test_resolves_alpha_token_rrggbbaa() -> void: + # border.panel = #73591fcc → alpha 0xcc/255 ≈ 0.8 (RRGGBBAA byte order). + var c: Color = ThemeAssets.color("border.panel") + assert_almost_eq(c.a, 0xcc / 255.0, 0.004, "border.panel alpha is the AA byte") + assert_almost_eq(c.r, 0x73 / 255.0, 0.004, "border.panel red is the RR byte") + + +func test_resolves_semantic_and_text_tokens() -> void: + # semantic.positive = #66e666, text.primary = #e0d8c8 — both present. + assert_true(ThemeAssets.has_color("semantic.positive"), "semantic.positive exists") + assert_true(ThemeAssets.has_color("text.primary"), "text.primary exists") + var pos: Color = ThemeAssets.color("semantic.positive") + assert_almost_eq(pos.g, 0xe6 / 255.0, 0.004, "semantic.positive is bright green") + + +func test_unknown_token_returns_fallback() -> void: + # Illustrative names from the spec (accent.copper / semantic.success) are NOT + # real tokens — the accessor returns the explicit fallback, never silent black. + assert_false(ThemeAssets.has_color("accent.copper"), "accent.copper is not a token") + var sentinel: Color = Color(0.123, 0.456, 0.789, 1.0) + var c: Color = ThemeAssets.color("does.not.exist", sentinel) + assert_eq(c, sentinel, "unknown token returns the provided fallback") + + +func test_token_table_is_nonempty() -> void: + # Guards against an empty/missing metadata blob (stale or unbuilt .tres). + assert_gt(ThemeAssets.token_names().size(), 50, "token table populated from .tres") diff --git a/tools/build-ui-theme.py b/tools/build-ui-theme.py new file mode 100644 index 00000000..c7a190af --- /dev/null +++ b/tools/build-ui-theme.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Generate the Godot in-game Theme (`ui_theme.tres`) from the design-token SoT. + +The single source of truth for the Age of Dwarves visual language is +`.project/designs/design-tokens.json` (W3C / style-dictionary format). This +script compiles those tokens into a complete Godot `Theme` resource: + + * StyleBoxFlat sub-resources for panels, buttons (5 states), and list rows, + with token-driven background / border colors, corner radii (§6) and + border widths. + * Default theme colors + font sizes for Button / Label / Panel / + PanelContainer / ItemList / RichTextLabel (§3 / §4). + * A `metadata/tokens` JSON blob carrying the entire `color.*` token tree as + flat dotted keys (e.g. `accent.gold`, `text.primary`, `semantic.positive`). + `ThemeAssets.color(name)` resolves names against this blob at runtime — the + accessor is therefore data-driven from the SoT with no hardcoded color map. + +`ui_theme.tres` is a GENERATED artifact. Never hand-edit it; edit the tokens +and re-run. The output is deterministic (sorted keys, fixed float formatting, +stable sub-resource ids, preserved uid) so `--check` is a meaningful drift gate +for CI. + +Usage: + tools/build-ui-theme.py # regenerate ui_theme.tres in place + tools/build-ui-theme.py --check # exit 1 if the .tres is stale (no write) + tools/build-ui-theme.py --print # write nothing, dump to stdout +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Repo root = two levels up from tools/build-ui-theme.py +REPO_ROOT = Path(__file__).resolve().parent.parent +TOKENS_PATH = REPO_ROOT / ".project" / "designs" / "design-tokens.json" +OUTPUT_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "ui_theme.tres" + +# Preserve the existing resource uid so path/uid references stay stable. +THEME_UID = "uid://ui_theme_fantasy" + + +# --------------------------------------------------------------------------- # +# Token access +# --------------------------------------------------------------------------- # +def load_tokens() -> dict: + with TOKENS_PATH.open(encoding="utf-8") as fh: + return json.load(fh) + + +def _walk_colors(node: dict, prefix: str, out: dict[str, str]) -> None: + """Recursively flatten the `color.*` subtree into dotted -> hex strings. + + A token leaf is a dict carrying a `$value`. Intermediate groups recurse. + """ + for key, value in node.items(): + if key.startswith("$"): + continue + if not isinstance(value, dict): + continue + path = f"{prefix}.{key}" if prefix else key + if "$value" in value: + out[path] = str(value["$value"]).lstrip("#").lower() + else: + _walk_colors(value, path, out) + + +def flatten_color_tokens(tokens: dict) -> dict[str, str]: + """All `color.*` tokens as a flat {dotted_name: 'rrggbb[aa]'} dict.""" + out: dict[str, str] = {} + _walk_colors(tokens.get("color", {}), "", out) + return dict(sorted(out.items())) + + +def _px(value) -> float: + """'14px' / 14 / '14' -> 14.0.""" + s = str(value).strip().lower().removesuffix("px") + return float(s) + + +def font_size(tokens: dict, name: str) -> int: + return int(_px(tokens["typography"]["fontSize"][name]["$value"])) + + +def radius(tokens: dict, name: str) -> int: + return int(_px(tokens["borderRadius"][name]["$value"])) + + +def border_w(tokens: dict, name: str) -> int: + return int(_px(tokens["borderWidth"][name]["$value"])) + + +def spacing(tokens: dict, name: str) -> float: + return _px(tokens["spacing"][name]["$value"]) + + +# --------------------------------------------------------------------------- # +# Hex -> Godot Color +# --------------------------------------------------------------------------- # +def hex_to_color(hex_str: str) -> str: + """Convert an SoT hex token to a Godot `Color(r, g, b, a)` literal string. + + Godot's `Color.html()` treats 8-digit hex as RRGGBBAA (verified on the + target runtime), so this generator uses the identical byte order to keep + the .tres and the runtime `color()` accessor in agreement. + """ + h = hex_str.lstrip("#").lower() + if len(h) == 6: + h += "ff" + if len(h) != 8: + raise ValueError(f"unsupported hex token: {hex_str!r}") + r = int(h[0:2], 16) / 255.0 + g = int(h[2:4], 16) / 255.0 + b = int(h[4:6], 16) / 255.0 + a = int(h[6:8], 16) / 255.0 + return "Color(%s, %s, %s, %s)" % tuple(_fmt_float(v) for v in (r, g, b, a)) + + +def _fmt_float(v: float) -> str: + """Deterministic float formatting: trim trailing zeros, keep at least 0/1.""" + s = f"{v:.6f}".rstrip("0").rstrip(".") + return s if s else "0" + + +def color_of(colors: dict[str, str], dotted: str) -> str: + return hex_to_color(colors[dotted]) + + +# --------------------------------------------------------------------------- # +# StyleBoxFlat emission +# --------------------------------------------------------------------------- # +def stylebox( + sub_id: str, + *, + bg: str, + border: str, + border_width: int, + corner: int, + margins: tuple[float, float, float, float] | None = None, +) -> str: + """Render one [sub_resource type="StyleBoxFlat"] block.""" + lines = [ + f'[sub_resource type="StyleBoxFlat" id="{sub_id}"]', + f"bg_color = {bg}", + f"border_width_left = {border_width}", + f"border_width_top = {border_width}", + f"border_width_right = {border_width}", + f"border_width_bottom = {border_width}", + f"border_color = {border}", + f"corner_radius_top_left = {corner}", + f"corner_radius_top_right = {corner}", + f"corner_radius_bottom_left = {corner}", + f"corner_radius_bottom_right = {corner}", + ] + if margins is not None: + ml, mt, mr, mb = (_fmt_float(m) for m in margins) + lines += [ + f"content_margin_left = {ml}", + f"content_margin_top = {mt}", + f"content_margin_right = {mr}", + f"content_margin_bottom = {mb}", + ] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # +# Theme assembly +# --------------------------------------------------------------------------- # +def build_theme_text(tokens: dict) -> str: + colors = flatten_color_tokens(tokens) + + r_panel = radius(tokens, "panel") + r_button = radius(tokens, "button") + r_list = radius(tokens, "list") + bw_default = border_w(tokens, "default") + bw_emphasis = border_w(tokens, "emphasis") + + # Margins from the spacing scale (§5): panels 12/6, buttons 12/6. + pad_h = spacing(tokens, "3") # 12px horizontal + pad_v = spacing(tokens, "2") # 8px vertical + btn_h = spacing(tokens, "3") # 12px + btn_v = spacing(tokens, "2") # 8px + + fs_base = font_size(tokens, "base") + fs_sm = font_size(tokens, "sm") + + c = lambda name: color_of(colors, name) # noqa: E731 + + subs = [ + stylebox( + "StyleBoxFlat_panel", + bg=c("background.panel"), + border=c("border.panel"), + border_width=bw_default, + corner=r_panel, + margins=(pad_h, pad_v, pad_h, pad_v), + ), + stylebox( + "StyleBoxFlat_button_normal", + bg=c("button.bgNormal"), + border=c("border.panel"), + border_width=bw_default, + corner=r_button, + margins=(btn_h, btn_v, btn_h, btn_v), + ), + stylebox( + "StyleBoxFlat_button_hover", + bg=c("button.bgHover"), + border=c("accent.goldBright"), + border_width=bw_default, + corner=r_button, + margins=(btn_h, btn_v, btn_h, btn_v), + ), + stylebox( + "StyleBoxFlat_button_pressed", + bg=c("button.bgPressed"), + border=c("accent.goldPress"), + border_width=bw_emphasis, + corner=r_button, + margins=(btn_h, btn_v, btn_h, btn_v), + ), + stylebox( + "StyleBoxFlat_button_focus", + bg="Color(0, 0, 0, 0)", + border=c("border.focus"), + border_width=bw_emphasis, + corner=r_button, + ), + stylebox( + "StyleBoxFlat_item_list_bg", + bg=c("background.list"), + border=c("border.list"), + border_width=bw_default, + corner=r_list, + ), + stylebox( + "StyleBoxFlat_item_list_selected", + bg=c("background.listSelected"), + border=c("border.listSelected"), + border_width=bw_default, + corner=r_list, + ), + ] + + # metadata/tokens — entire color tree, flat dotted keys, sorted, compact. + tokens_blob = json.dumps(colors, separators=(",", ":"), sort_keys=True) + tokens_blob_escaped = tokens_blob.replace("\\", "\\\\").replace('"', '\\"') + + resource_lines = [ + "[resource]", + f'metadata/tokens = "{tokens_blob_escaped}"', + f"Button/colors/font_color = {c('text.button')}", + f"Button/colors/font_hover_color = {c('text.buttonHover')}", + f"Button/colors/font_pressed_color = {c('text.buttonPressed')}", + f"Button/colors/font_focus_color = {c('text.buttonHover')}", + f"Button/colors/font_disabled_color = {c('text.disabled')}", + f"Button/font_sizes/font_size = {fs_base}", + 'Button/styles/normal = SubResource("StyleBoxFlat_button_normal")', + 'Button/styles/hover = SubResource("StyleBoxFlat_button_hover")', + 'Button/styles/pressed = SubResource("StyleBoxFlat_button_pressed")', + 'Button/styles/focus = SubResource("StyleBoxFlat_button_focus")', + 'Button/styles/disabled = SubResource("StyleBoxFlat_button_normal")', + f"Label/colors/font_color = {c('text.primary')}", + f"Label/font_sizes/font_size = {fs_base}", + 'PanelContainer/styles/panel = SubResource("StyleBoxFlat_panel")', + 'Panel/styles/panel = SubResource("StyleBoxFlat_panel")', + f"ItemList/colors/font_color = {c('text.primary')}", + f"ItemList/colors/font_selected_color = {c('text.buttonHover')}", + f"ItemList/font_sizes/font_size = {fs_sm}", + 'ItemList/styles/panel = SubResource("StyleBoxFlat_item_list_bg")', + 'ItemList/styles/selected = SubResource("StyleBoxFlat_item_list_selected")', + 'ItemList/styles/selected_focus = SubResource("StyleBoxFlat_item_list_selected")', + f"RichTextLabel/colors/default_color = {c('text.primary')}", + f"RichTextLabel/font_sizes/normal_font_size = {fs_sm}", + ] + + parts = [ + f'[gd_resource type="Theme" format=3 uid="{THEME_UID}"]', + "", + ] + for sub in subs: + parts.append("") + parts.append(sub) + parts.append("") + parts.append("\n".join(resource_lines)) + return "\n".join(parts).lstrip("\n") + "\n" + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="exit 1 if ui_theme.tres differs from the regenerated output (no write)", + ) + parser.add_argument( + "--print", + dest="print_only", + action="store_true", + help="print the generated .tres to stdout, write nothing", + ) + args = parser.parse_args() + + tokens = load_tokens() + text = build_theme_text(tokens) + + if args.print_only: + sys.stdout.write(text) + return 0 + + if args.check: + current = OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else "" + if current == text: + print(f"OK: {OUTPUT_PATH.relative_to(REPO_ROOT)} is up to date.") + return 0 + print( + f"DRIFT: {OUTPUT_PATH.relative_to(REPO_ROOT)} is stale.\n" + "Run tools/build-ui-theme.py to regenerate it.", + file=sys.stderr, + ) + return 1 + + OUTPUT_PATH.write_text(text, encoding="utf-8") + print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({len(text)} bytes).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())