From 5d5fda41278436386aa2aa049cf0213b08faaf6c Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 18 Jun 2026 19:47:21 -0500 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8E=A8=20token=20aliasing=20+=20tier=20the=20tech=20token?= =?UTF-8?q?s=20(B=20cluster-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .project/designs/design-tokens.json | 93 ++++++++++++++--------- public/games/age-of-dwarves/ui_theme.tres | 2 +- tools/build-ui-theme.py | 50 ++++++++++-- 3 files changed, 100 insertions(+), 45 deletions(-) diff --git a/.project/designs/design-tokens.json b/.project/designs/design-tokens.json index b9651cda..16bfe7d3 100644 --- a/.project/designs/design-tokens.json +++ b/.project/designs/design-tokens.json @@ -11,46 +11,63 @@ ] }, "color": { - "tech": { - "researchedBg": { - "$value": "#33b333e6", - "$type": "color", - "$description": "Knowledge-tree node card — already-researched fill" - }, - "researchedBorder": { - "$value": "#4de64d", - "$type": "color", - "$description": "Knowledge-tree node card — already-researched border" - }, - "availableBg": { - "$value": "#d9bf1ae6", - "$type": "color", - "$description": "Knowledge-tree node card — available-to-research fill" - }, - "availableBorder": { - "$value": "#ffe64d", - "$type": "color", - "$description": "Knowledge-tree node card — available-to-research border" - }, - "lockedBg": { - "$value": "#666666b3", - "$type": "color", - "$description": "Knowledge-tree node card — locked/unreachable fill" - }, - "lockedBorder": { - "$value": "#808080", - "$type": "color", - "$description": "Knowledge-tree node card — locked/unreachable border" - }, - "currentBg": { - "$value": "#4d80e6e6", - "$type": "color", - "$description": "Knowledge-tree node card — currently-researching fill" - }, - "selectedBorder": { + "palette": { + "white": { "$value": "#ffffffff", "$type": "color", - "$description": "Knowledge-tree node card — selected/current highlight border" + "$description": "Primitive — pure white (selection highlights, contrast strokes)" + }, + "neutralMuted": { + "$value": "#666666b3", + "$type": "color", + "$description": "Primitive — muted neutral fill (disabled/locked surfaces)" + }, + "neutralBorder": { + "$value": "#808080ff", + "$type": "color", + "$description": "Primitive — neutral border (disabled/locked outlines)" + } + }, + "tech": { + "researchedBg": { + "$value": "{color.semantic.positive}", + "$type": "color", + "$description": "Knowledge-tree node card — researched fill (alias → success green)" + }, + "researchedBorder": { + "$value": "{color.semantic.positive}", + "$type": "color", + "$description": "Knowledge-tree node card — researched border (alias → success green)" + }, + "availableBg": { + "$value": "{color.accent.gold}", + "$type": "color", + "$description": "Knowledge-tree node card — available fill (alias → actionable gold)" + }, + "availableBorder": { + "$value": "{color.accent.goldBright}", + "$type": "color", + "$description": "Knowledge-tree node card — available border (alias → bright gold)" + }, + "lockedBg": { + "$value": "{color.palette.neutralMuted}", + "$type": "color", + "$description": "Knowledge-tree node card — locked fill (alias → muted neutral)" + }, + "lockedBorder": { + "$value": "{color.palette.neutralBorder}", + "$type": "color", + "$description": "Knowledge-tree node card — locked border (alias → neutral border)" + }, + "currentBg": { + "$value": "{color.accent.science}", + "$type": "color", + "$description": "Knowledge-tree node card — researching fill (alias → science blue)" + }, + "selectedBorder": { + "$value": "{color.palette.white}", + "$type": "color", + "$description": "Knowledge-tree node card — selection highlight (alias → white)" } }, "unlockAccent": { diff --git a/public/games/age-of-dwarves/ui_theme.tres b/public/games/age-of-dwarves/ui_theme.tres index a1925f53..de13335e 100644 --- a/public/games/age-of-dwarves/ui_theme.tres +++ b/public/games/age-of-dwarves/ui_theme.tres @@ -102,7 +102,7 @@ corner_radius_bottom_left = 2 corner_radius_bottom_right = 2 [resource] -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\":\"1a160fff\",\"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\",\"tech.availableBg\":\"d9bf1ae6\",\"tech.availableBorder\":\"ffe64d\",\"tech.currentBg\":\"4d80e6e6\",\"tech.lockedBg\":\"666666b3\",\"tech.lockedBorder\":\"808080\",\"tech.researchedBg\":\"33b333e6\",\"tech.researchedBorder\":\"4de64d\",\"tech.selectedBorder\":\"ffffffff\",\"text.button\":\"e0d199\",\"text.buttonHover\":\"ffeb80\",\"text.buttonPressed\":\"ffffb3\",\"text.disabled\":\"80806680\",\"text.muted\":\"b2b2b2\",\"text.primary\":\"e0d8c8\",\"text.secondary\":\"bfb7a6\",\"text.title\":\"f2d973\",\"throne.court\":\"665947\",\"throne.default\":\"40382e\",\"throne.forge\":\"804714\",\"throne.garden\":\"1f522e\",\"throne.mapTable\":\"264d59\",\"throne.pedestal\":\"8c7a33\",\"throne.provisions\":\"4d6626\",\"throne.seat\":\"8c6b1a\",\"throne.shrine\":\"334766\",\"throne.special\":\"73528c\",\"throne.structure\":\"4d3824\",\"throne.trophy\":\"802e1a\",\"unlockAccent.building\":\"8b6914\",\"unlockAccent.dim\":\"ffffff8c\",\"unlockAccent.improvement\":\"4a7c3f\",\"unlockAccent.lens\":\"2d5a8b\",\"unlockAccent.mechanic\":\"6b3fa0\",\"unlockAccent.resource\":\"a0522d\",\"unlockAccent.unit\":\"c9a84c\",\"unlockAccent.wonder\":\"a06a3f\"}" +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\":\"1a160fff\",\"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\",\"palette.neutralBorder\":\"808080ff\",\"palette.neutralMuted\":\"666666b3\",\"palette.white\":\"ffffffff\",\"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\",\"tech.availableBg\":\"d9a020\",\"tech.availableBorder\":\"d9b33f\",\"tech.currentBg\":\"66bfff\",\"tech.lockedBg\":\"666666b3\",\"tech.lockedBorder\":\"808080ff\",\"tech.researchedBg\":\"66e666\",\"tech.researchedBorder\":\"66e666\",\"tech.selectedBorder\":\"ffffffff\",\"text.button\":\"e0d199\",\"text.buttonHover\":\"ffeb80\",\"text.buttonPressed\":\"ffffb3\",\"text.disabled\":\"80806680\",\"text.muted\":\"b2b2b2\",\"text.primary\":\"e0d8c8\",\"text.secondary\":\"bfb7a6\",\"text.title\":\"f2d973\",\"throne.court\":\"665947\",\"throne.default\":\"40382e\",\"throne.forge\":\"804714\",\"throne.garden\":\"1f522e\",\"throne.mapTable\":\"264d59\",\"throne.pedestal\":\"8c7a33\",\"throne.provisions\":\"4d6626\",\"throne.seat\":\"8c6b1a\",\"throne.shrine\":\"334766\",\"throne.special\":\"73528c\",\"throne.structure\":\"4d3824\",\"throne.trophy\":\"802e1a\",\"unlockAccent.building\":\"8b6914\",\"unlockAccent.dim\":\"ffffff8c\",\"unlockAccent.improvement\":\"4a7c3f\",\"unlockAccent.lens\":\"2d5a8b\",\"unlockAccent.mechanic\":\"6b3fa0\",\"unlockAccent.resource\":\"a0522d\",\"unlockAccent.unit\":\"c9a84c\",\"unlockAccent.wonder\":\"a06a3f\"}" 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) diff --git a/tools/build-ui-theme.py b/tools/build-ui-theme.py index c7a190af..cb56a21a 100644 --- a/tools/build-ui-theme.py +++ b/tools/build-ui-theme.py @@ -30,6 +30,7 @@ from __future__ import annotations import argparse import json +import re import sys from pathlib import Path @@ -51,9 +52,11 @@ def load_tokens() -> dict: def _walk_colors(node: dict, prefix: str, out: dict[str, str]) -> None: - """Recursively flatten the `color.*` subtree into dotted -> hex strings. + """Recursively flatten the `color.*` subtree into dotted -> raw-value strings. A token leaf is a dict carrying a `$value`. Intermediate groups recurse. + The raw `$value` is preserved verbatim (literal hex OR a `{color.x.y}` + alias reference) — alias resolution happens in `_resolve_aliases`. """ for key, value in node.items(): if key.startswith("$"): @@ -62,16 +65,51 @@ def _walk_colors(node: dict, prefix: str, out: dict[str, str]) -> None: continue path = f"{prefix}.{key}" if prefix else key if "$value" in value: - out[path] = str(value["$value"]).lstrip("#").lower() + out[path] = str(value["$value"]) else: _walk_colors(value, path, out) +_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 + + 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())) + """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())) def _px(value) -> float: