2026-06-04 19:52:32 -07:00
|
|
|
#!/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
|
feat(@projects/@magic-civilization): 🎨 token aliasing + tier the tech tokens (B cluster-1)
Make the design-token system genuinely layered instead of flat single-tier.
- build-ui-theme.py: add W3C-style alias resolution. A token $value may now be
a reference `{color.x.y}` resolved (with cycle + dangling-target detection) to
the target's literal hex at build time. Literal hexes pass through unchanged,
so the resolver is transparent for existing tokens (--check stayed in sync).
- design-tokens.json: introduce a primitive `palette.*` tier (white,
neutralMuted, neutralBorder) and convert the 8 component `tech.*` tokens from
bespoke hex into ALIASES: researched→semantic.positive, available→accent.gold,
available border→accent.goldBright, current→accent.science, locked→palette
neutrals, selected→palette.white. tech.* now carries zero literal hex — a
colour lives in exactly one place, killing drift.
Rationale: the prior `tech.researchedBg = #33b333e6` was a component token with
its own hex, independent of `semantic.positive` — the duplication the token
system exists to prevent. Now component → semantic → primitive.
Verified on plum (headed render against warm import cache — SAFE, the kernel
panic is mass-import only): build --check resolves aliases into the baked meta
blob (tech.researchedBg→66e666 etc.); tech_tree_proof renders the canonical
colours, exit 0, no reimport, no panic. Screenshot reviewed in conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:47:21 -05:00
|
|
|
import re
|
2026-06-04 19:52:32 -07:00
|
|
|
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"
|
2026-06-23 09:28:05 -04:00
|
|
|
GUIDE_COLORS_TS_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "guide" / "src" / "theme" / "generated-guide-colors.ts"
|
2026-06-18 20:26:20 -05:00
|
|
|
# Player colours have a SINGLE source: palettes.json (which also owns the
|
|
|
|
|
# colourblind variants + drives runtime player rendering via ThemeAssets).
|
|
|
|
|
# The `player.*` UI tokens are GENERATED from its default variant here so the
|
|
|
|
|
# 12 colours are authored once and can never drift. Order ↔ default array index.
|
|
|
|
|
PALETTES_PATH = REPO_ROOT / "public" / "games" / "age-of-dwarves" / "data" / "palettes.json"
|
|
|
|
|
PLAYER_COLOR_NAMES = [
|
|
|
|
|
"blue", "red", "green", "yellow", "purple", "orange",
|
|
|
|
|
"cyan", "magenta", "brown", "gray", "sage", "navy",
|
|
|
|
|
]
|
2026-06-04 19:52:32 -07:00
|
|
|
|
|
|
|
|
# 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:
|
feat(@projects/@magic-civilization): 🎨 token aliasing + tier the tech tokens (B cluster-1)
Make the design-token system genuinely layered instead of flat single-tier.
- build-ui-theme.py: add W3C-style alias resolution. A token $value may now be
a reference `{color.x.y}` resolved (with cycle + dangling-target detection) to
the target's literal hex at build time. Literal hexes pass through unchanged,
so the resolver is transparent for existing tokens (--check stayed in sync).
- design-tokens.json: introduce a primitive `palette.*` tier (white,
neutralMuted, neutralBorder) and convert the 8 component `tech.*` tokens from
bespoke hex into ALIASES: researched→semantic.positive, available→accent.gold,
available border→accent.goldBright, current→accent.science, locked→palette
neutrals, selected→palette.white. tech.* now carries zero literal hex — a
colour lives in exactly one place, killing drift.
Rationale: the prior `tech.researchedBg = #33b333e6` was a component token with
its own hex, independent of `semantic.positive` — the duplication the token
system exists to prevent. Now component → semantic → primitive.
Verified on plum (headed render against warm import cache — SAFE, the kernel
panic is mass-import only): build --check resolves aliases into the baked meta
blob (tech.researchedBg→66e666 etc.); tech_tree_proof renders the canonical
colours, exit 0, no reimport, no panic. Screenshot reviewed in conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:47:21 -05:00
|
|
|
"""Recursively flatten the `color.*` subtree into dotted -> raw-value strings.
|
2026-06-04 19:52:32 -07:00
|
|
|
|
|
|
|
|
A token leaf is a dict carrying a `$value`. Intermediate groups recurse.
|
feat(@projects/@magic-civilization): 🎨 token aliasing + tier the tech tokens (B cluster-1)
Make the design-token system genuinely layered instead of flat single-tier.
- build-ui-theme.py: add W3C-style alias resolution. A token $value may now be
a reference `{color.x.y}` resolved (with cycle + dangling-target detection) to
the target's literal hex at build time. Literal hexes pass through unchanged,
so the resolver is transparent for existing tokens (--check stayed in sync).
- design-tokens.json: introduce a primitive `palette.*` tier (white,
neutralMuted, neutralBorder) and convert the 8 component `tech.*` tokens from
bespoke hex into ALIASES: researched→semantic.positive, available→accent.gold,
available border→accent.goldBright, current→accent.science, locked→palette
neutrals, selected→palette.white. tech.* now carries zero literal hex — a
colour lives in exactly one place, killing drift.
Rationale: the prior `tech.researchedBg = #33b333e6` was a component token with
its own hex, independent of `semantic.positive` — the duplication the token
system exists to prevent. Now component → semantic → primitive.
Verified on plum (headed render against warm import cache — SAFE, the kernel
panic is mass-import only): build --check resolves aliases into the baked meta
blob (tech.researchedBg→66e666 etc.); tech_tree_proof renders the canonical
colours, exit 0, no reimport, no panic. Screenshot reviewed in conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:47:21 -05:00
|
|
|
The raw `$value` is preserved verbatim (literal hex OR a `{color.x.y}`
|
|
|
|
|
alias reference) — alias resolution happens in `_resolve_aliases`.
|
2026-06-04 19:52:32 -07:00
|
|
|
"""
|
|
|
|
|
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:
|
feat(@projects/@magic-civilization): 🎨 token aliasing + tier the tech tokens (B cluster-1)
Make the design-token system genuinely layered instead of flat single-tier.
- build-ui-theme.py: add W3C-style alias resolution. A token $value may now be
a reference `{color.x.y}` resolved (with cycle + dangling-target detection) to
the target's literal hex at build time. Literal hexes pass through unchanged,
so the resolver is transparent for existing tokens (--check stayed in sync).
- design-tokens.json: introduce a primitive `palette.*` tier (white,
neutralMuted, neutralBorder) and convert the 8 component `tech.*` tokens from
bespoke hex into ALIASES: researched→semantic.positive, available→accent.gold,
available border→accent.goldBright, current→accent.science, locked→palette
neutrals, selected→palette.white. tech.* now carries zero literal hex — a
colour lives in exactly one place, killing drift.
Rationale: the prior `tech.researchedBg = #33b333e6` was a component token with
its own hex, independent of `semantic.positive` — the duplication the token
system exists to prevent. Now component → semantic → primitive.
Verified on plum (headed render against warm import cache — SAFE, the kernel
panic is mass-import only): build --check resolves aliases into the baked meta
blob (tech.researchedBg→66e666 etc.); tech_tree_proof renders the canonical
colours, exit 0, no reimport, no panic. Screenshot reviewed in conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:47:21 -05:00
|
|
|
out[path] = str(value["$value"])
|
2026-06-04 19:52:32 -07:00
|
|
|
else:
|
|
|
|
|
_walk_colors(value, path, out)
|
|
|
|
|
|
|
|
|
|
|
feat(@projects/@magic-civilization): 🎨 token aliasing + tier the tech tokens (B cluster-1)
Make the design-token system genuinely layered instead of flat single-tier.
- build-ui-theme.py: add W3C-style alias resolution. A token $value may now be
a reference `{color.x.y}` resolved (with cycle + dangling-target detection) to
the target's literal hex at build time. Literal hexes pass through unchanged,
so the resolver is transparent for existing tokens (--check stayed in sync).
- design-tokens.json: introduce a primitive `palette.*` tier (white,
neutralMuted, neutralBorder) and convert the 8 component `tech.*` tokens from
bespoke hex into ALIASES: researched→semantic.positive, available→accent.gold,
available border→accent.goldBright, current→accent.science, locked→palette
neutrals, selected→palette.white. tech.* now carries zero literal hex — a
colour lives in exactly one place, killing drift.
Rationale: the prior `tech.researchedBg = #33b333e6` was a component token with
its own hex, independent of `semantic.positive` — the duplication the token
system exists to prevent. Now component → semantic → primitive.
Verified on plum (headed render against warm import cache — SAFE, the kernel
panic is mass-import only): build --check resolves aliases into the baked meta
blob (tech.researchedBg→66e666 etc.); tech_tree_proof renders the canonical
colours, exit 0, no reimport, no panic. Screenshot reviewed in conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:47:21 -05:00
|
|
|
_ALIAS_RE = re.compile(r"^\{(.+)\}$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_aliases(raw: dict[str, str]) -> dict[str, str]:
|
|
|
|
|
"""Resolve W3C-style `{color.x.y}` alias references to literal `rrggbb[aa]`.
|
|
|
|
|
|
|
|
|
|
Tiered tokens (component -> semantic -> primitive) reference each other so a
|
|
|
|
|
colour lives in exactly one place. Literal hexes pass through unchanged.
|
|
|
|
|
Detects cycles and dangling targets so a typo fails the build loudly.
|
|
|
|
|
"""
|
|
|
|
|
resolved: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
def resolve(name: str, seen: frozenset[str]) -> str:
|
|
|
|
|
if name in resolved:
|
|
|
|
|
return resolved[name]
|
|
|
|
|
if name not in raw:
|
|
|
|
|
raise ValueError(f"alias target not found: '{name}'")
|
|
|
|
|
if name in seen:
|
|
|
|
|
raise ValueError(f"alias cycle through '{name}'")
|
|
|
|
|
match = _ALIAS_RE.match(raw[name].strip())
|
|
|
|
|
if match:
|
|
|
|
|
target = match.group(1).strip()
|
|
|
|
|
if target.startswith("color."):
|
|
|
|
|
target = target[len("color."):]
|
|
|
|
|
value = resolve(target, seen | {name})
|
|
|
|
|
else:
|
|
|
|
|
value = raw[name].lstrip("#").lower()
|
|
|
|
|
resolved[name] = value
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
for token_name in raw:
|
|
|
|
|
resolve(token_name, frozenset())
|
|
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 19:52:32 -07:00
|
|
|
def flatten_color_tokens(tokens: dict) -> dict[str, str]:
|
feat(@projects/@magic-civilization): 🎨 token aliasing + tier the tech tokens (B cluster-1)
Make the design-token system genuinely layered instead of flat single-tier.
- build-ui-theme.py: add W3C-style alias resolution. A token $value may now be
a reference `{color.x.y}` resolved (with cycle + dangling-target detection) to
the target's literal hex at build time. Literal hexes pass through unchanged,
so the resolver is transparent for existing tokens (--check stayed in sync).
- design-tokens.json: introduce a primitive `palette.*` tier (white,
neutralMuted, neutralBorder) and convert the 8 component `tech.*` tokens from
bespoke hex into ALIASES: researched→semantic.positive, available→accent.gold,
available border→accent.goldBright, current→accent.science, locked→palette
neutrals, selected→palette.white. tech.* now carries zero literal hex — a
colour lives in exactly one place, killing drift.
Rationale: the prior `tech.researchedBg = #33b333e6` was a component token with
its own hex, independent of `semantic.positive` — the duplication the token
system exists to prevent. Now component → semantic → primitive.
Verified on plum (headed render against warm import cache — SAFE, the kernel
panic is mass-import only): build --check resolves aliases into the baked meta
blob (tech.researchedBg→66e666 etc.); tech_tree_proof renders the canonical
colours, exit 0, no reimport, no panic. Screenshot reviewed in conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:47:21 -05:00
|
|
|
"""All `color.*` tokens as flat {dotted_name: 'rrggbb[aa]'}, aliases resolved."""
|
|
|
|
|
raw: dict[str, str] = {}
|
|
|
|
|
_walk_colors(tokens.get("color", {}), "", raw)
|
|
|
|
|
return dict(sorted(_resolve_aliases(raw).items()))
|
2026-06-04 19:52:32 -07:00
|
|
|
|
|
|
|
|
|
2026-06-18 20:26:20 -05:00
|
|
|
def generate_player_tokens() -> dict[str, str]:
|
|
|
|
|
"""Derive `player.<name>` colour tokens from palettes.json's default variant.
|
|
|
|
|
|
|
|
|
|
Single source of truth: palettes.json owns the player palette (+ colourblind
|
|
|
|
|
variants); these UI tokens are generated, never hand-authored, so they cannot
|
|
|
|
|
drift from what `ThemeAssets.get_player_color()` renders.
|
|
|
|
|
"""
|
|
|
|
|
with PALETTES_PATH.open(encoding="utf-8") as fh:
|
|
|
|
|
palettes = json.load(fh)
|
|
|
|
|
colours = palettes["default"]["player_colors"]
|
|
|
|
|
if len(colours) < len(PLAYER_COLOR_NAMES):
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"palettes.json default has {len(colours)} player colours; "
|
|
|
|
|
f"expected >= {len(PLAYER_COLOR_NAMES)}"
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
f"player.{name}": str(colours[i]).lstrip("#").lower()
|
|
|
|
|
for i, name in enumerate(PLAYER_COLOR_NAMES)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 19:52:32 -07:00
|
|
|
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)
|
2026-06-18 20:26:20 -05:00
|
|
|
# Merge in player.* tokens generated from palettes.json (single source).
|
|
|
|
|
colors.update(generate_player_tokens())
|
|
|
|
|
colors = dict(sorted(colors.items()))
|
2026-06-04 19:52:32 -07:00
|
|
|
|
|
|
|
|
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}",
|
|
|
|
|
]
|
|
|
|
|
|
2026-06-19 02:45:59 -05:00
|
|
|
# Type variations (p2-87): inheritance targets so widgets can set
|
|
|
|
|
# theme_type_variation instead of add_theme_color_override. Skips _doc.
|
|
|
|
|
variations = tokens.get("typeVariations", {})
|
|
|
|
|
for vname in sorted(variations):
|
|
|
|
|
if vname.startswith("_"):
|
|
|
|
|
continue
|
|
|
|
|
spec: dict = variations[vname]
|
|
|
|
|
resource_lines.append('%s/base_type = &"%s"' % (vname, spec["base"]))
|
2026-06-19 11:03:08 -05:00
|
|
|
if "fontColor" in spec:
|
|
|
|
|
resource_lines.append("%s/colors/font_color = %s" % (vname, c(spec["fontColor"])))
|
|
|
|
|
if "panel" in spec:
|
|
|
|
|
pan: dict = spec["panel"]
|
|
|
|
|
sub_id: str = "StyleBoxFlat_%s" % vname
|
|
|
|
|
margins = tuple(pan["margins"]) if "margins" in pan else None
|
|
|
|
|
subs.append(
|
|
|
|
|
stylebox(
|
|
|
|
|
sub_id,
|
|
|
|
|
bg=c(pan["bg"]),
|
|
|
|
|
border=c(pan["border"]),
|
|
|
|
|
border_width=int(pan["borderWidth"]),
|
|
|
|
|
corner=int(pan["corner"]),
|
|
|
|
|
margins=margins,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
resource_lines.append('%s/styles/panel = SubResource("%s")' % (vname, sub_id))
|
2026-06-19 02:45:59 -05:00
|
|
|
|
2026-06-04 19:52:32 -07:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 09:28:05 -04:00
|
|
|
def generate_guide_colors_ts(tokens: dict) -> str:
|
|
|
|
|
"""Emit TS module with resolved guide + accent + semantic colours (p2-87 cluster-4).
|
|
|
|
|
All values from the token table (aliases resolved). No raw hex in consumer (guide de-hex).
|
|
|
|
|
"""
|
|
|
|
|
resolved = flatten_color_tokens(tokens) # resolves {aliases} to hex
|
|
|
|
|
lines: list[str] = [
|
|
|
|
|
"// GENERATED from .project/designs/design-tokens.json by build-ui-theme.py -- p2-87",
|
|
|
|
|
"// Single source of truth. Edit tokens, re-run. Do not hand-edit.",
|
|
|
|
|
"export const GUIDE_COLORS = {",
|
|
|
|
|
]
|
|
|
|
|
# All guide.* (resolved hex)
|
|
|
|
|
for key in sorted(k for k in resolved if k.startswith("guide.")):
|
|
|
|
|
short = key[len("guide."):]
|
|
|
|
|
val = resolved[key]
|
|
|
|
|
lines.append(f' "{short}": "#' + val + '",')
|
|
|
|
|
# Supporting for guide pages (accent etc)
|
|
|
|
|
for group in ("accent", "semantic", "text"):
|
|
|
|
|
for key in sorted(k for k in resolved if k.startswith(group + ".")):
|
|
|
|
|
val = resolved[key]
|
|
|
|
|
lines.append(f' "{key}": "#' + val + '",')
|
|
|
|
|
lines.append("} as const;")
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("export type GuideColorKey = keyof typeof GUIDE_COLORS;")
|
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 19:52:32 -07:00
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# 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)
|
2026-06-23 09:28:05 -04:00
|
|
|
guide_ts = generate_guide_colors_ts(tokens)
|
2026-06-04 19:52:32 -07:00
|
|
|
|
|
|
|
|
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 ""
|
2026-06-23 09:28:05 -04:00
|
|
|
guide_current = GUIDE_COLORS_TS_PATH.read_text(encoding="utf-8") if GUIDE_COLORS_TS_PATH.exists() else ""
|
|
|
|
|
if current == text and guide_current == guide_ts:
|
|
|
|
|
print(f"OK: {OUTPUT_PATH.relative_to(REPO_ROOT)} and guide colors up to date.")
|
2026-06-04 19:52:32 -07:00
|
|
|
return 0
|
|
|
|
|
print(
|
2026-06-23 09:28:05 -04:00
|
|
|
"DRIFT: ui_theme.tres or generated-guide-colors.ts stale.\n"
|
|
|
|
|
"Run tools/build-ui-theme.py to regenerate.",
|
2026-06-04 19:52:32 -07:00
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
OUTPUT_PATH.write_text(text, encoding="utf-8")
|
2026-06-23 09:28:05 -04:00
|
|
|
GUIDE_COLORS_TS_PATH.write_text(guide_ts, encoding="utf-8")
|
2026-06-04 19:52:32 -07:00
|
|
|
print(f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} ({len(text)} bytes).")
|
2026-06-23 09:28:05 -04:00
|
|
|
print(f"Wrote {GUIDE_COLORS_TS_PATH.relative_to(REPO_ROOT)} ({len(guide_ts)} bytes).")
|
2026-06-04 19:52:32 -07:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|