magicciv/tools/build-ui-theme.py
autocommit cea53e1ee4 feat(p2-73): 🎨 generate ui_theme.tres from design tokens + global apply + color() accessor
Close the gap where the design system (.project/designs/design-tokens.json)
drove the React guide but not the Godot game.

- tools/build-ui-theme.py: compiles the W3C/style-dictionary token SoT into a
  complete Godot Theme (7 StyleBoxFlat sub-resources, Button/Label/Panel/
  PanelContainer/ItemList/RichTextLabel colors + font sizes + corner radii/
  border widths per UI_DESIGN_SYSTEM.md §3/§4/§6). ui_theme.tres is now a
  GENERATED artifact; tokens are the single source of truth. Deterministic
  output (sorted keys, fixed float fmt, preserved uid://ui_theme_fantasy) with
  a --check drift gate. Idempotent; --import does not rewrite it.
- project.godot [gui] theme/custom: applies ui_theme.tres at viewport level so
  every non-overriding default Control renders the copper fantasy styling.
- ThemeAssets.color(name) -> Color: resolves dotted token names (accent.gold,
  semantic.positive, text.primary, …) against the metadata/tokens JSON blob
  baked into the .tres by the generator. Fully data-driven from the SoT, no
  hardcoded color map. (Godot rejects dots in Theme color item names, so the
  token table ships as resource metadata.) Unknown names return an explicit
  fallback. This is the API p2-74 will de-hardcode 45 scripts onto.
- ui_theme_proof.{tscn,gd}: bare-widget + color()-swatch proof scene.
  test_theme_assets_color.gd: GUT accessor coverage (5/5 headless).

Proof captured on apricot under weston, reviewed in conversation:
.project/screenshots/p2-73-ui-theme-proof.png. Workspace green — full unit
(16==16) and integration (18==18) suites show identical HEAD-baseline-vs-patch
failure counts, zero regressions; patch adds +5 passing tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:42:01 -07:00

334 lines
12 KiB
Python
Executable file

#!/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())