Skip to content

Live UI tokens — Phase 1 implementation plan (foundation + colour/type migration)

Live UI tokens — Phase 1 Implementation Plan

Section titled “Live UI tokens — Phase 1 Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. In this repo also consult bh-sdd-concurrent before Task 1 and dispatch implementers (bh-implementer, model opus) into their own worktree under .claude/worktrees/.

Goal: Every UI colour, font size and theme constant (separation/outline/margin) in ui/ and the UI parts of main.gd resolves through a live, data-driven token store, so a value change at runtime restyles open screens without a rebuild.

Architecture: A game-agnostic addon (addons/live_tokens/) holds the token store (JSON defaults, OKLCH ramps, references, platform variants, overrides, changed signal) and a binding registry that re-applies declared property derivations on change. Dark Cosmos supplies ui/tokens/defaults.json (11 seeds → ~31 semantic roles fitted to today’s literals) and a thin Tokens façade; NeonTheme repopulates its single shared Theme in place on change. Panels migrate from literals to Tokens.c() reads plus TokenBind declarations; a ratchet lint and a probe test stop regressions.

Tech Stack: Godot 4.7, typed GDScript, GUT 9.7.1 (unexpected push_error FAILS a test — use assert_push_error), Python not used at runtime.

Spec: docs/superpowers/specs/2026-10-04-live-ui-tokens-design.md

  • /sim is never touched. Gameplay content colours (weapon/enemy/element hues, FX, world) stay out of scope; when such a colour lives in a UI file, keep it and mark the line # content-colour: <reason>.
  • addons/live_tokens/ must contain ZERO references to Dark Cosmos classes (Tokens, NeonTheme, Platform, VisualLook, main, Sim*). Enforced by Task 5’s test.
  • Token keys always contain a dot (text.muted, font.sm); theme item names (&"font_color") never do. Task 6’s key-existence test relies on this.
  • No push_error on a normal path. Resolution errors on apply() are returned, not pushed.
  • Determinism baselines must not move (UI is render-only; if a determinism test changes, something touched /sim — stop).
  • Phase 1 does NOT tokenise positions/sizes (.position, .size, custom_minimum_size, anchors) — that is Phase 2.
  • Every new docs/**/*.md needs Starlight title: frontmatter.
  • Run the suite only via scripts/check-test-count.sh (never pull a raw GUT run into context); merge gate is bash scripts/gate.sh.
  • After any windowed Godot run: git checkout -- project.godot audio/bus_layout.tres.
  • The legacy Tokens.ACCENT/GOLD/… consts and NeonTheme.CYAN/TEXT stay compiling until Task 16 removes them.
  • Commit messages via git commit --file=<file>; end with the session’s Co-Authored-By / Claude-Session lines.
File Responsibility
addons/live_tokens/lt_color.gd (LtColor) sRGB ↔ OKLab/OKLCH, gamut-clipped ramp generation
addons/live_tokens/live_tokens_bus.gd (LiveTokensBus) changed signal + binding registry + batched flush (instance, so callables are plain methods)
addons/live_tokens/live_tokens_store.gd (LiveTokensStore) one token set: defaults, grammar resolution, cache, platform variants, overrides, apply/reset, schema, export, probe mode
addons/live_tokens/live_tokens.gd (LiveTokens) static façade over the game’s single store instance
addons/live_tokens/token_bind.gd (TokenBind) static helpers declaring bound properties
addons/live_tokens/PROTOCOL.md value grammar + schema + console command contract
platform/tvos/addons/live_tokens symlink → ../../../addons/live_tokens (tvOS/iOS/Android project)
ui/tokens/defaults.json Dark Cosmos seeds, roles, numbers
ui/tokens.gd (Tokens) façade: lazy load, platform detection, c/n/i/font/space
ui/theme/neon_theme.gd populate the shared Theme from tokens, in place
tools/live_tokens/literal_scan.gd (UiLiteralScan) counts hardcoded literals per file
tools/live_tokens/write_literal_baseline.gd writes the ratchet allowlist
tools/live_tokens/nearest_role.gd migration aid: literal → nearest role + ΔE
tools/ui_sweep/sweep.gd, tools/live_tokens/probe_diff.gd, scripts/ui-probe.sh windowed probe sweep
tests/rebind_probe.gd (RebindProbe) helper: snapshot Color properties, find unbound ones
tests/ui_literal_allowlist.json ratchet allowlist
tests/fixtures/ui_role_targets.json role → target colour + tolerance
docs/architecture/ui-tokens.md current-architecture doc (Task 16)

Files:

  • Create: addons/live_tokens/lt_color.gd
  • Test: tests/test_lt_color.gd

Interfaces:

  • Produces: LtColor.to_oklab(c: Color) -> Vector3, LtColor.to_oklch(c: Color) -> Vector3 (L 0..1, C, H degrees), LtColor.from_oklch(l: float, c: float, h: float, a: float = 1.0) -> Color (gamut-clipped by reducing chroma), LtColor.ramp(seed: Color, step: float, cmul: float = 1.0) -> Color (L = 1 − step/1000; hue/chroma from seed), LtColor.delta_e(a: Color, b: Color) -> float (OKLab Euclidean).

  • Step 1: Write the failing test

extends GutTest
# LtColor — OKLCH maths behind the live-token colour ramps (addons/live_tokens).
func _near(a: Color, b: Color, eps := 0.004) -> bool:
return absf(a.r - b.r) < eps and absf(a.g - b.g) < eps and absf(a.b - b.b) < eps
func test_white_and_black_lightness() -> void:
assert_almost_eq(LtColor.to_oklch(Color.WHITE).x, 1.0, 0.001)
assert_almost_eq(LtColor.to_oklch(Color.BLACK).x, 0.0, 0.001)
func test_round_trip() -> void:
for c: Color in [Color(0.2, 0.9, 1.0), Color(1.0, 0.35, 0.35), Color(0.07, 0.08, 0.12)]:
var v := LtColor.to_oklch(c)
assert_true(_near(LtColor.from_oklch(v.x, v.y, v.z), c), "round trip %s" % c)
func test_ramp_at_seed_lightness_returns_seed() -> void:
var seed := Color(0.2, 0.9, 1.0)
var step := (1.0 - LtColor.to_oklch(seed).x) * 1000.0
assert_true(_near(LtColor.ramp(seed, step), seed), "ramp at the seed's own step = seed")
func test_ramp_lightness_is_monotonic_and_hue_kept() -> void:
var seed := Color(1.0, 0.84, 0.3)
var h := LtColor.to_oklch(seed).z
var prev := 2.0
for step in [50, 200, 400, 600, 800, 950]:
var c := LtColor.ramp(seed, step)
var v := LtColor.to_oklch(c)
assert_lt(v.x, prev, "darker as step rises")
prev = v.x
if v.y > 0.02:
assert_almost_eq(v.z, h, 3.0, "hue preserved at step %d" % step)
func test_out_of_gamut_is_clipped_not_nan() -> void:
var c := LtColor.ramp(Color(0.2, 0.9, 1.0), 50, 3.0)
for ch in [c.r, c.g, c.b]:
assert_false(is_nan(ch))
assert_between(ch, 0.0, 1.0)
func test_delta_e_zero_for_same() -> void:
assert_almost_eq(LtColor.delta_e(Color(0.3, 0.4, 0.5), Color(0.3, 0.4, 0.5)), 0.0, 0.0001)
  • Step 2: Run to verify it fails

Run: godot --headless --path . --import >/dev/null 2>&1; scripts/check-test-count.sh Expected: FAIL — test_lt_color.gd parse error (LtColor not declared).

  • Step 3: Implement
class_name LtColor
extends RefCounted
# OKLab / OKLCH maths (Björn Ottosson's matrices) for the live-token colour ramps. Game-agnostic:
# never reference game classes from addons/live_tokens/.
static func _lin(c: float) -> float:
return c / 12.92 if c <= 0.04045 else pow((c + 0.055) / 1.055, 2.4)
static func _gam(c: float) -> float:
c = clampf(c, 0.0, 1.0)
return 12.92 * c if c <= 0.0031308 else 1.055 * pow(c, 1.0 / 2.4) - 0.055
static func _cbrt(x: float) -> float:
return signf(x) * pow(absf(x), 1.0 / 3.0)
static func to_oklab(c: Color) -> Vector3:
var r := _lin(clampf(c.r, 0.0, 1.0))
var g := _lin(clampf(c.g, 0.0, 1.0))
var b := _lin(clampf(c.b, 0.0, 1.0))
var l := _cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
var m := _cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
var s := _cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
return Vector3(
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s)
static func to_oklch(c: Color) -> Vector3:
var lab := to_oklab(c)
return Vector3(lab.x, Vector2(lab.y, lab.z).length(), fposmod(rad_to_deg(atan2(lab.z, lab.y)), 360.0))
# Linear-light sRGB for an OKLab point (may be outside 0..1 = out of gamut).
static func _lab_to_linear(lv: float, a: float, b: float) -> Vector3:
var l := pow(lv + 0.3963377774 * a + 0.2158037573 * b, 3.0)
var m := pow(lv - 0.1055613458 * a - 0.0638541728 * b, 3.0)
var s := pow(lv - 0.0894841775 * a - 1.2914855480 * b, 3.0)
return Vector3(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
static func _in_gamut(v: Vector3) -> bool:
const E := 0.0001
return v.x >= -E and v.x <= 1.0 + E and v.y >= -E and v.y <= 1.0 + E and v.z >= -E and v.z <= 1.0 + E
static func from_oklch(l: float, c: float, h: float, a: float = 1.0) -> Color:
var hr := deg_to_rad(h)
var lin := _lab_to_linear(l, c * cos(hr), c * sin(hr))
if not _in_gamut(lin):
var lo := 0.0
var hi := c
for _i in 16:
var mid := (lo + hi) * 0.5
if _in_gamut(_lab_to_linear(l, mid * cos(hr), mid * sin(hr))):
lo = mid
else:
hi = mid
lin = _lab_to_linear(l, lo * cos(hr), lo * sin(hr))
return Color(_gam(lin.x), _gam(lin.y), _gam(lin.z), a)
# Tailwind-style ramp, linear in lightness: step 0 = white-ish, 1000 = black. Seed sets hue + chroma.
static func ramp(seed: Color, step: float, cmul: float = 1.0) -> Color:
var s := to_oklch(seed)
return from_oklch(clampf(1.0 - step / 1000.0, 0.0, 1.0), s.y * cmul, s.z, seed.a)
static func delta_e(a: Color, b: Color) -> float:
return to_oklab(a).distance_to(to_oklab(b))
  • Step 4: Run to verify it passes

Run: godot --headless --path . --import >/dev/null 2>&1; scripts/check-test-count.sh Expected: PASS (count = number of tests/test_*.gd).

  • Step 5: Commit — git add addons/live_tokens/lt_color.gd tests/test_lt_color.gd and commit feat(live-tokens): OKLCH colour maths + ramps.

Task 2: Token store — defaults, grammar, resolution (LiveTokens, LiveTokensBus)

Section titled “Task 2: Token store — defaults, grammar, resolution (LiveTokens, LiveTokensBus)”

Files:

  • Create: addons/live_tokens/live_tokens_bus.gd, addons/live_tokens/live_tokens_store.gd, addons/live_tokens/live_tokens.gd (static façade)
  • Test: tests/test_live_tokens_resolve.gd

Interfaces:

  • Consumes: LtColor.ramp, LtColor.from_oklch.
  • Produces: instance methods on LiveTokensStore (tests use their own instance), mirrored as statics on the LiveTokens façade, which wraps ONE game instance LiveTokens.store — LiveTokens.bus is LiveTokens.store.bus. Names: load_defaults(d: Dictionary), LiveTokens.load_defaults_file(path: String), LiveTokens.is_loaded() -> bool, LiveTokens.has(key: StringName) -> bool, LiveTokens.keys() -> PackedStringArray, LiveTokens.value(key: StringName) -> Variant (Color, float, or null), LiveTokens.color(key: StringName, alpha: float = -1.0) -> Color (unknown/bad → LiveTokens.SENTINEL_COLOR magenta), LiveTokens.num(key) -> float, LiveTokens.int_val(key) -> int (roundi), LiveTokens.set_platform(p: String) ("", "touch", "tv"), LiveTokens.bus: LiveTokensBus with signal changed(keys: PackedStringArray).

Value grammar (copy verbatim into PROTOCOL.md in Task 5):

  • JSON number → number.

  • "#rrggbb" / "#rrggbbaa" → colour.

  • "@<seed>.<step>[ mods]" → LtColor.ramp(seed.<seed>, step, c) when key seed.<seed> exists and <step> is an integer.

  • "@<key>[ mods]" → the resolved value of another token.

  • mods (colour only, space-separated, any order): a<alpha> sets alpha, c<mul> chroma multiplier (ramp refs only), x<mul> multiplies RGB (HDR glow).

  • "<operand>*<operand>" → number product, operand = number literal or @key (e.g. "14*@type.scale", "@space.unit*3").

  • Platform variant entry: {"default": v, "touch": v, "tv": v} (any subset beyond default).

  • Step 1: Write the failing test

extends GutTest
# LiveTokens resolution: grammar, references, ramps, platform variants, cycles, unknown keys.
const D := {
"seed.cyan": "#33e6ff",
"seed.grey": "#738099",
"accent": "@cyan.150 c1.1",
"accent.glow": "@accent x1.4",
"accent.half": "@accent a0.5",
"text.white": "#ffffff",
"surface.scrim": "#00000080",
"space.unit": 4,
"space.md": "@space.unit*3",
"type.scale": 1.0,
"font.sm": "14*@type.scale",
"font.title": {"default": 32, "touch": 28},
"loop.a": "@loop.b",
"loop.b": "@loop.a",
}
var s: LiveTokensStore
func before_each() -> void:
s = LiveTokensStore.new()
s.load_defaults(D)
func test_hex_and_alpha_hex() -> void:
assert_eq(s.color(&"text.white"), Color(1, 1, 1))
assert_almost_eq(s.color(&"surface.scrim").a, 128.0 / 255.0, 0.002)
func test_ramp_reference_close_to_seed_at_its_lightness() -> void:
var c := s.color(&"accent")
assert_lt(LtColor.delta_e(c, Color(0.2, 0.9, 1.0)), 0.03)
func test_modifiers() -> void:
var base := s.color(&"accent")
var glow := s.color(&"accent.glow")
assert_almost_eq(glow.g, base.g * 1.4, 0.001, "x multiplies rgb")
assert_almost_eq(s.color(&"accent.half").a, 0.5, 0.0001, "a sets alpha")
assert_almost_eq(s.color(&"accent", 0.25).a, 0.25, 0.0001, "alpha arg overrides")
func test_numeric_products() -> void:
assert_eq(s.num(&"space.md"), 12.0)
assert_eq(s.int_val(&"font.sm"), 14)
func test_platform_variant() -> void:
assert_eq(s.int_val(&"font.title"), 32)
s.set_platform("touch")
assert_eq(s.int_val(&"font.title"), 28)
s.set_platform("tv")
assert_eq(s.int_val(&"font.title"), 32, "tv falls back to default")
func test_unknown_key_is_loud_sentinel() -> void:
assert_eq(s.color(&"nope.missing"), LiveTokensStore.SENTINEL_COLOR)
assert_push_error("unknown token 'nope.missing'")
func test_cycle_is_detected() -> void:
assert_eq(s.color(&"loop.a"), LiveTokensStore.SENTINEL_COLOR)
assert_push_error("reference cycle")
func test_has_and_keys() -> void:
assert_true(s.has(&"accent"))
assert_false(s.has(&"nope"))
assert_true(s.keys().has("space.md"))
  • Step 2: Run to verify it fails — scripts/check-test-count.sh → FAIL (LiveTokens undeclared).

  • Step 3: Implement the bus

class_name LiveTokensBus
extends RefCounted
# Instance half of LiveTokens: the changed signal plus the TokenBind registry. An instance (not
# statics) so connections and call_deferred use ordinary methods.
signal changed(keys: PackedStringArray)
var _bindings: Array = [] # each entry: [WeakRef, Callable]
var _pending := false
var _live_after_prune := 0
func _init() -> void:
changed.connect(_on_changed)
func bind(owner: Object, apply: Callable) -> void:
apply.call()
_bindings.append([weakref(owner), apply])
if _bindings.size() > maxi(256, _live_after_prune * 2):
_prune()
func binding_count() -> int:
_prune()
return _bindings.size()
func _on_changed(_keys: PackedStringArray) -> void:
if _pending:
return
_pending = true
if Engine.get_main_loop() != null:
flush_now.call_deferred()
else:
flush_now()
# Re-applies every live binding once. Tests call this directly instead of waiting a frame.
func flush_now() -> void:
_pending = false
var keep: Array = []
for b: Array in _bindings:
if (b[0] as WeakRef).get_ref() == null:
continue
(b[1] as Callable).call()
keep.append(b)
_bindings = keep
func _prune() -> void:
_bindings = _bindings.filter(func(b: Array) -> bool: return (b[0] as WeakRef).get_ref() != null)
_live_after_prune = _bindings.size()
  • Step 4: Implement the store (resolution half) — addons/live_tokens/live_tokens_store.gd
class_name LiveTokensStore
extends RefCounted
# Game-agnostic live design-token store (one instance = one token set). The static LiveTokens façade
# wraps the game's instance; tests build their own instance so fake defaults never reach live bindings.
# Grammar + console contract: addons/live_tokens/PROTOCOL.md. NEVER reference game classes here.
const SENTINEL_COLOR := Color(1, 0, 1)
var bus := LiveTokensBus.new()
var _defaults: Dictionary = {} # key -> raw value or {"default":…, "touch":…, "tv":…}
var _overrides: Dictionary = {"": {}} # platform ("" = all) -> {key: raw}
var _cache: Dictionary = {} # key -> resolved Variant
var _platform := ""
var _resolving: Dictionary = {}
var _warned: Dictionary = {}
func load_defaults(d: Dictionary) -> void:
_defaults = d.duplicate(true)
_overrides = {"": {}}
_warned.clear()
_invalidate(PackedStringArray(_defaults.keys()))
func load_defaults_file(path: String) -> void:
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if typeof(parsed) != TYPE_DICTIONARY:
push_error("LiveTokens: %s is not a JSON object" % path)
return
load_defaults(parsed)
# Test helper: back to the never-loaded state so a game façade lazy-loads its real defaults again.
func reset_to_unloaded() -> void:
_defaults = {}
_overrides = {"": {}}
_cache.clear()
_warned.clear()
_platform = ""
func is_loaded() -> bool:
return not _defaults.is_empty()
func has(key: StringName) -> bool:
return _defaults.has(String(key))
func keys() -> PackedStringArray:
return PackedStringArray(_defaults.keys())
func set_platform(p: String) -> void:
if p == _platform:
return
_platform = p
_invalidate(keys())
func platform() -> String:
return _platform
func _invalidate(changed_keys: PackedStringArray) -> void:
_cache.clear()
bus.changed.emit(changed_keys)
func _raw(k: String) -> Variant:
if _platform != "":
var po: Dictionary = _overrides.get(_platform, {})
if po.has(k):
return po[k]
var go: Dictionary = _overrides[""]
if go.has(k):
return go[k]
return _default_raw(k)
func _default_raw(k: String) -> Variant:
var d: Variant = _defaults.get(k)
if typeof(d) == TYPE_DICTIONARY:
var dd: Dictionary = d
if _platform != "" and dd.has(_platform):
return dd[_platform]
return dd.get("default")
return d
func value(key: StringName) -> Variant:
var k := String(key)
if _cache.has(k):
return _cache[k]
if not _defaults.has(k):
if not _warned.has(k):
_warned[k] = true
push_error("LiveTokens: unknown token '%s'" % k)
return null
if _resolving.has(k):
push_error("LiveTokens: reference cycle at '%s'" % k)
return null
_resolving[k] = true
var errs: Array = []
var v: Variant = _resolve(_raw(k), errs)
_resolving.erase(k)
if not errs.is_empty():
push_error("LiveTokens: '%s': %s" % [k, "; ".join(PackedStringArray(errs))])
_cache[k] = v
return v
func color(key: StringName, alpha: float = -1.0) -> Color:
var v: Variant = value(key)
if typeof(v) != TYPE_COLOR:
return SENTINEL_COLOR
var c: Color = v
if alpha >= 0.0:
c.a = alpha
return c
func num(key: StringName) -> float:
var v: Variant = value(key)
return float(v) if typeof(v) == TYPE_FLOAT else 0.0
func int_val(key: StringName) -> int:
return roundi(num(key))
# Resolves a raw grammar value. Errors are appended to errs (never pushed) so apply() can validate silently.
func _resolve(raw: Variant, errs: Array) -> Variant:
match typeof(raw):
TYPE_INT, TYPE_FLOAT:
return float(raw)
TYPE_STRING:
return _resolve_str(String(raw).strip_edges(), errs)
errs.append("unsupported value %s" % str(raw))
return null
func _operand(s: String, errs: Array) -> Variant:
if s.begins_with("@"):
return _ref(s.substr(1), errs)
if s.is_valid_float():
return s.to_float()
errs.append("bad operand '%s'" % s)
return null
func _ref(k: String, errs: Array) -> Variant:
if not _defaults.has(k):
errs.append("unknown token '%s'" % k)
return null
if _resolving.has(k):
errs.append("reference cycle at '%s'" % k)
return null
return value(StringName(k))
func _resolve_str(s: String, errs: Array) -> Variant:
if s.begins_with("#"):
if not Color.html_is_valid(s):
errs.append("bad hex '%s'" % s)
return null
return Color.html(s)
if s.contains("*"):
var f := s.split("*")
if f.size() != 2:
errs.append("bad product '%s'" % s)
return null
var a: Variant = _operand(f[0].strip_edges(), errs)
var b: Variant = _operand(f[1].strip_edges(), errs)
if typeof(a) != TYPE_FLOAT or typeof(b) != TYPE_FLOAT:
errs.append("product needs numbers '%s'" % s)
return null
return float(a) * float(b)
var parts := s.split(" ", false)
if parts.is_empty() or not parts[0].begins_with("@"):
errs.append("bad value '%s'" % s)
return null
var ref := parts[0].substr(1)
var cmul := 1.0
var alpha := -1.0
var energy := 1.0
for i in range(1, parts.size()):
var m := parts[i]
var arg := m.substr(1)
if not arg.is_valid_float():
errs.append("bad modifier '%s'" % m)
return null
match m[0]:
"a": alpha = arg.to_float()
"c": cmul = arg.to_float()
"x": energy = arg.to_float()
_:
errs.append("bad modifier '%s'" % m)
return null
var v: Variant
var dot := ref.rfind(".")
var seed_key := "seed." + ref.substr(0, dot) if dot > 0 else ""
if dot > 0 and ref.substr(dot + 1).is_valid_int() and _defaults.has(seed_key):
var seed_v: Variant = _ref(seed_key, errs)
if typeof(seed_v) != TYPE_COLOR:
errs.append("seed '%s' is not a colour" % seed_key)
return null
v = LtColor.ramp(seed_v, ref.substr(dot + 1).to_float(), cmul)
else:
v = _ref(ref, errs)
if typeof(v) == TYPE_COLOR:
var c: Color = v
if energy != 1.0:
c = Color(c.r * energy, c.g * energy, c.b * energy, c.a)
if alpha >= 0.0:
c.a = alpha
return c
return v

Note on the cycle test: value("loop.a") → _ref("loop.b") → value("loop.b") → _ref("loop.a") finds loop.a in _resolving and appends “reference cycle”; loop.b pushes one error and loop.a pushes one. If GUT reports two errors, change the assertion to assert_push_error_count(2) — do not suppress either error.

  • Step 4b: Implement the façade — addons/live_tokens/live_tokens.gd. Every method Task 3 adds to the store also gets a one-line forwarder here (add them in Task 3).
class_name LiveTokens
extends RefCounted
# Static façade over the game's single LiveTokensStore. Game code and TokenBind use this; tests of the
# grammar build their own LiveTokensStore so fake defaults never reach live bindings. Game-agnostic.
const SENTINEL_COLOR := Color(1, 0, 1)
static var store := LiveTokensStore.new()
static var bus: LiveTokensBus = store.bus
static func load_defaults(d: Dictionary) -> void: store.load_defaults(d)
static func load_defaults_file(path: String) -> void: store.load_defaults_file(path)
static func reset_to_unloaded() -> void: store.reset_to_unloaded()
static func is_loaded() -> bool: return store.is_loaded()
static func has(key: StringName) -> bool: return store.has(key)
static func keys() -> PackedStringArray: return store.keys()
static func set_platform(p: String) -> void: store.set_platform(p)
static func platform() -> String: return store.platform()
static func value(key: StringName) -> Variant: return store.value(key)
static func color(key: StringName, alpha: float = -1.0) -> Color: return store.color(key, alpha)
static func num(key: StringName) -> float: return store.num(key)
static func int_val(key: StringName) -> int: return store.int_val(key)

Add one façade test to tests/test_live_tokens_resolve.gd:

func test_facade_wraps_one_store() -> void:
assert_same(LiveTokens.bus, LiveTokens.store.bus)
  • Step 5: Run to verify it passes — godot --headless --path . --import >/dev/null 2>&1; scripts/check-test-count.sh → PASS.

  • Step 6: Commit — feat(live-tokens): token store with grammar, refs, ramps, platform variants.


Task 3: Store mutation — apply / reset / schema / export / probe

Section titled “Task 3: Store mutation — apply / reset / schema / export / probe”

Files:

  • Modify: addons/live_tokens/live_tokens_store.gd (append), addons/live_tokens/live_tokens.gd (forwarders)
  • Test: tests/test_live_tokens_apply.gd

Interfaces:

  • Produces (store methods + identically named LiveTokens static forwarders): apply(values: Dictionary, platform: String = "") -> PackedStringArray (errors; valid entries still apply; emits bus.changed with applied keys), LiveTokens.reset(), LiveTokens.reset_key(key: StringName, platform: String = ""), LiveTokens.overrides() -> Dictionary ({platform: {key: raw}}, deep copy), LiveTokens.export_patch() -> Dictionary (defaults.json-shaped: plain key → raw for "" overrides, {"default": …, "touch": …} merged entries when platform overrides exist), LiveTokens.schema() -> Array (of Dictionary: key, type "color"/"number", tier "seed"/"token", group (text before first dot), default raw, value raw current, resolved ("#rrggbbaa" or number), variants Dictionary), LiveTokens.set_screen(id: String), LiveTokens.screen() -> String, LiveTokens.set_probe(gen: int) (0 = off; >0 makes every color() return a per-key hash colour that changes with each gen).

  • Step 1: Write the failing test

extends GutTest
const D := {
"seed.cyan": "#33e6ff",
"accent": "@cyan.150",
"accent.soft": "@accent a0.5",
"space.unit": 4,
"space.md": "@space.unit*3",
"font.title": {"default": 32, "touch": 28},
}
var _seen: Array = []
var s: LiveTokensStore
func before_each() -> void:
s = LiveTokensStore.new()
s.load_defaults(D)
_seen.clear()
s.bus.changed.connect(_on_changed)
func _on_changed(keys: PackedStringArray) -> void:
_seen.append(keys)
func test_apply_changes_dependents_and_emits() -> void:
var errs := s.apply({"seed.cyan": "#ff0000"})
assert_eq(errs.size(), 0)
assert_gt(s.color(&"accent").r, 0.5, "dependent role follows the seed")
assert_almost_eq(s.color(&"accent.soft").a, 0.5, 0.001)
assert_eq(_seen.size(), 1)
func test_apply_rejects_unknown_and_wrong_type_but_keeps_valid() -> void:
var errs := s.apply({"nope.x": 1, "accent": 5, "space.unit": 8})
assert_eq(errs.size(), 2)
assert_eq(s.num(&"space.md"), 24.0, "valid entry applied")
assert_lt(LtColor.delta_e(s.color(&"accent"), Color(0.2, 0.9, 1.0)), 0.05, "invalid entry ignored")
func test_platform_override_only_on_that_platform() -> void:
s.apply({"space.unit": 5}, "touch")
assert_eq(s.num(&"space.unit"), 4.0)
s.set_platform("touch")
assert_eq(s.num(&"space.unit"), 5.0)
func test_reset_and_reset_key() -> void:
s.apply({"space.unit": 8, "seed.cyan": "#ff0000"})
s.reset_key(&"space.unit")
assert_eq(s.num(&"space.unit"), 4.0)
assert_gt(s.color(&"accent").r, 0.5)
s.reset()
assert_lt(s.color(&"accent").r, 0.5)
func test_export_patch_shapes() -> void:
s.apply({"space.unit": 6})
s.apply({"font.title": 26}, "touch")
var p := s.export_patch()
assert_eq(p["space.unit"], 6)
assert_eq(p["font.title"], {"default": 32, "touch": 26})
func test_schema_lists_every_key_typed() -> void:
var by_key := {}
for e: Dictionary in s.schema():
by_key[e["key"]] = e
assert_eq(by_key.size(), D.size())
assert_eq(by_key["accent"]["type"], "color")
assert_eq(by_key["seed.cyan"]["tier"], "seed")
assert_eq(by_key["space.md"]["type"], "number")
assert_eq(by_key["space.md"]["resolved"], 12.0)
assert_eq(by_key["font.title"]["variants"], {"touch": 28})
func test_probe_changes_every_colour_per_generation() -> void:
var real := s.color(&"accent")
s.set_probe(1)
var p1 := s.color(&"accent")
s.set_probe(2)
var p2 := s.color(&"accent")
assert_ne(p1, real)
assert_ne(p1, p2)
s.set_probe(0)
assert_eq(s.color(&"accent"), real)
func test_screen_id() -> void:
s.set_screen("shop")
assert_eq(s.screen(), "shop")
  • Step 2: Run to verify it fails — scripts/check-test-count.sh → FAIL (apply not found).

  • Step 3: Implement (append to live_tokens.gd; also change color() to check probe first)

At the top with the other statics add var _screen := "" and var _probe_gen := 0. Replace the first line of color()’s body so it reads:

func color(key: StringName, alpha: float = -1.0) -> Color:
if _probe_gen > 0 and _defaults.has(String(key)):
return _probe_color(String(key), alpha)
var v: Variant = value(key)
if typeof(v) != TYPE_COLOR:
return SENTINEL_COLOR
var c: Color = v
if alpha >= 0.0:
c.a = alpha
return c

Append:

func apply(values: Dictionary, p: String = "") -> PackedStringArray:
var errors := PackedStringArray()
var applied := PackedStringArray()
if not _overrides.has(p):
_overrides[p] = {}
for key_v: Variant in values.keys():
var k := String(key_v)
if not _defaults.has(k):
errors.append("unknown token '%s'" % k)
continue
var errs: Array = []
var got: Variant = _resolve(values[key_v], errs)
if not errs.is_empty() or got == null or typeof(got) != _type_of_default(k):
errors.append("bad value for '%s': %s" % [k, str(values[key_v])])
continue
(_overrides[p] as Dictionary)[k] = values[key_v]
applied.append(k)
if not applied.is_empty():
_invalidate(applied)
return errors
func _type_of_default(k: String) -> int:
var errs: Array = []
var saved := _platform
var v: Variant = _resolve(_default_raw(k), errs)
_platform = saved
return typeof(v)
func reset() -> void:
_overrides = {"": {}}
_invalidate(keys())
func reset_key(key: StringName, p: String = "") -> void:
var o: Dictionary = _overrides.get(p, {})
if o.erase(String(key)):
_invalidate(PackedStringArray([String(key)]))
func overrides() -> Dictionary:
return _overrides.duplicate(true)
func export_patch() -> Dictionary:
var out := {}
var touched := {}
for p: String in _overrides.keys():
for k: String in (_overrides[p] as Dictionary).keys():
touched[k] = true
for k: String in touched.keys():
var d: Variant = _defaults[k]
var entry: Dictionary = (d as Dictionary).duplicate() if typeof(d) == TYPE_DICTIONARY else {"default": d}
for p: String in _overrides.keys():
var o: Dictionary = _overrides[p]
if o.has(k):
entry["default" if p == "" else p] = o[k]
out[k] = entry["default"] if entry.size() == 1 else entry
return out
func schema() -> Array:
var rows: Array = []
for k: String in _defaults.keys():
var d: Variant = _defaults[k]
var variants := {}
if typeof(d) == TYPE_DICTIONARY:
variants = (d as Dictionary).duplicate()
variants.erase("default")
var v: Variant = value(StringName(k))
rows.append({
"key": k,
"type": "color" if typeof(v) == TYPE_COLOR else "number",
"tier": "seed" if k.begins_with("seed.") else "token",
"group": k.get_slice(".", 0),
"default": _default_raw(k),
"value": _raw(k),
"resolved": _resolved_repr(v),
"variants": variants,
})
return rows
func _resolved_repr(v: Variant) -> Variant:
if typeof(v) == TYPE_COLOR:
return "#" + (v as Color).to_html(true)
return v
func set_screen(id: String) -> void:
_screen = id
func screen() -> String:
return _screen
func set_probe(gen: int) -> void:
if gen == _probe_gen:
return
_probe_gen = gen
_invalidate(keys())
func _probe_color(k: String, alpha: float) -> Color:
var h := fposmod(float(absi(hash(k)) % 1000) / 1000.0 + 0.37 * _probe_gen, 1.0)
var c := Color.from_hsv(h, 0.85, 0.95)
if alpha >= 0.0:
c.a = alpha
return c

Append forwarders to live_tokens.gd:

static func apply(values: Dictionary, p: String = "") -> PackedStringArray: return store.apply(values, p)
static func reset() -> void: store.reset()
static func reset_key(key: StringName, p: String = "") -> void: store.reset_key(key, p)
static func overrides() -> Dictionary: return store.overrides()
static func export_patch() -> Dictionary: return store.export_patch()
static func schema() -> Array: return store.schema()
static func set_screen(id: String) -> void: store.set_screen(id)
static func screen() -> String: return store.screen()
static func set_probe(gen: int) -> void: store.set_probe(gen)

If test_export_patch_shapes fails because JSON-shaped ints arrive as float in export_patch (they will not here — values are stored as given), compare with int() rather than changing storage.

  • Step 4: Run to verify it passes — scripts/check-test-count.sh → PASS.

  • Step 5: Commit — feat(live-tokens): apply/reset/export/schema/probe.


Files:

  • Create: addons/live_tokens/token_bind.gd
  • Test: tests/test_token_bind.gd

Interfaces:

  • Consumes: LiveTokens.bus.bind/flush_now, LiveTokens.color/int_val/value.

  • Produces (all static; src is either a token key StringName or a Callable returning the value):

    • TokenBind.bind(owner: Object, apply: Callable) -> void — call now + on every change.
    • TokenBind.theme_color(c: Control, item: StringName, src: Variant) -> void
    • TokenBind.theme_font_size(c: Control, item: StringName, src: Variant) -> void (key → int_val)
    • TokenBind.theme_constant(c: Control, item: StringName, src: Variant) -> void (key → int_val)
    • TokenBind.stylebox(c: Control, item: StringName, make: Callable) -> void
    • TokenBind.prop(o: Object, property: StringName, src: Variant) -> void (key → LiveTokens.value)
    • TokenBind.redraw(ci: CanvasItem) -> void
    • TokenBind.flush_now() -> void, TokenBind.binding_count() -> int
  • Step 1: Write the failing test

extends GutTest
# Uses the REAL game defaults (never fake ones in the global store: other live bindings, e.g. NeonTheme's
# Theme, would re-run against missing keys). Every test resets overrides afterwards.
func before_each() -> void:
Tokens.ensure_loaded()
func after_each() -> void:
LiveTokens.reset()
TokenBind.flush_now()
func test_theme_color_follows_key() -> void:
var l: Label = autofree(Label.new())
TokenBind.theme_color(l, &"font_color", &"text.bright")
assert_eq(l.get_theme_color(&"font_color"), Color(1, 1, 1))
# needs Task 6's defaults.json — this task is implemented AFTER Task 6 (see note below)
LiveTokens.apply({"text.bright": "#ff0000"})
TokenBind.flush_now()
assert_eq(l.get_theme_color(&"font_color"), Color(1, 0, 0))
func test_callable_source_recomputes_derived_value() -> void:
var l: Label = autofree(Label.new())
TokenBind.theme_color(l, &"font_color", func() -> Color: return LiveTokens.color(&"text.bright").lerp(Color.BLACK, 0.5))
LiveTokens.apply({"text.bright": "#ff0000"})
TokenBind.flush_now()
assert_almost_eq(l.get_theme_color(&"font_color").r, 0.5, 0.01)
func test_font_size_constant_prop_stylebox() -> void:
var b: VBoxContainer = autofree(VBoxContainer.new())
var l: Label = autofree(Label.new())
var p: PanelContainer = autofree(PanelContainer.new())
TokenBind.theme_font_size(l, &"font_size", &"font.sm")
TokenBind.theme_constant(b, &"separation", &"outline.md")
TokenBind.prop(l, &"modulate", &"accent")
TokenBind.stylebox(p, &"panel", func() -> StyleBox:
var sb := StyleBoxFlat.new()
sb.bg_color = LiveTokens.color(&"accent")
return sb)
LiveTokens.apply({"font.sm": 20, "outline.md": 3, "seed.accent": "#ff0000"})
TokenBind.flush_now()
assert_eq(l.get_theme_font_size(&"font_size"), 20)
assert_eq(b.get_theme_constant(&"separation"), 3)
assert_gt(l.modulate.r, 0.5)
assert_gt((p.get_theme_stylebox(&"panel") as StyleBoxFlat).bg_color.r, 0.5)
func test_freed_owner_is_dropped_without_error() -> void:
var before := TokenBind.binding_count()
var l := Label.new()
TokenBind.theme_color(l, &"font_color", &"text.bright")
assert_eq(TokenBind.binding_count(), before + 1)
l.free()
LiveTokens.apply({"text.bright": "#00ff00"})
TokenBind.flush_now()
assert_eq(TokenBind.binding_count(), before)
func test_redraw_binding_queues_redraw() -> void:
var n: Control = add_child_autofree(Control.new())
TokenBind.redraw(n)
LiveTokens.apply({"text.bright": "#00ff00"})
TokenBind.flush_now()
pass_test("redraw binding re-ran without error")
  • Step 2: Run to verify it fails — FAIL (TokenBind undeclared).

Ordering: these tests read the real ui/tokens/defaults.json through Tokens.ensure_loaded(), so execute Task 6 before Task 4 (order: 1, 2, 3, 5, 6, 4, 7…). Task 6’s own tests do not need TokenBind.

  • Step 3: Implement
class_name TokenBind
extends RefCounted
# Declarative bindings: a property is DERIVED from tokens and re-derived on every LiveTokens change.
# Panels never write restyle functions. src = token key (StringName) or a Callable returning the value
# (use a Callable for anything derived: lerps, alpha mixes, conditionals). Game-agnostic.
static func bind(owner: Object, apply: Callable) -> void:
LiveTokens.bus.bind(owner, apply)
static func flush_now() -> void:
LiveTokens.bus.flush_now()
static func binding_count() -> int:
return LiveTokens.bus.binding_count()
static func _color_of(src: Variant) -> Color:
if src is Callable:
return (src as Callable).call()
return LiveTokens.color(src)
static func _int_of(src: Variant) -> int:
if src is Callable:
return int((src as Callable).call())
return LiveTokens.int_val(src)
static func _value_of(src: Variant) -> Variant:
if src is Callable:
return (src as Callable).call()
return LiveTokens.value(src)
static func theme_color(c: Control, item: StringName, src: Variant) -> void:
bind(c, func() -> void: c.add_theme_color_override(item, _color_of(src)))
static func theme_font_size(c: Control, item: StringName, src: Variant) -> void:
bind(c, func() -> void: c.add_theme_font_size_override(item, _int_of(src)))
static func theme_constant(c: Control, item: StringName, src: Variant) -> void:
bind(c, func() -> void: c.add_theme_constant_override(item, _int_of(src)))
static func stylebox(c: Control, item: StringName, make: Callable) -> void:
bind(c, func() -> void: c.add_theme_stylebox_override(item, make.call()))
static func prop(o: Object, property: StringName, src: Variant) -> void:
bind(o, func() -> void: o.set(property, _value_of(src)))
static func redraw(ci: CanvasItem) -> void:
bind(ci, func() -> void: ci.queue_redraw())

If GDScript rejects calling the static _color_of from inside a lambda created in a static function, inline its body in each lambda — do not move the registry out of LiveTokensBus.

  • Step 4: Run to verify it passes — PASS.

  • Step 5: Commit — feat(live-tokens): TokenBind declarative bindings.


Section titled “Task 5: Addon isolation, protocol doc, tvOS symlink”

Files:

  • Create: addons/live_tokens/PROTOCOL.md, symlink platform/tvos/addons/live_tokens
  • Test: tests/test_live_tokens_isolation.gd

Interfaces: none new.

  • Step 1: Write the failing test
extends GutTest
# addons/live_tokens must be reusable by another Godot game: no Dark Cosmos identifiers inside it,
# and the tvOS/iOS/Android project must see it through its addons symlink.
const FORBIDDEN := ["Tokens.", "NeonTheme", "Platform.", "VisualLook", "Sim_Const", "MetaStore", "main."]
func test_addon_references_no_game_classes() -> void:
var dir := DirAccess.open("res://addons/live_tokens")
assert_not_null(dir)
for f in dir.get_files():
if not f.ends_with(".gd"):
continue
var src := FileAccess.get_file_as_string("res://addons/live_tokens/" + f)
for bad: String in FORBIDDEN:
# "LiveTokens." contains "Tokens." — strip it before checking.
assert_false(src.replace("LiveTokens", "").contains(bad), "%s references %s" % [f, bad])
func test_protocol_doc_exists() -> void:
assert_true(FileAccess.file_exists("res://addons/live_tokens/PROTOCOL.md"))
func test_tvos_project_symlink() -> void:
var p := ProjectSettings.globalize_path("res://platform/tvos/addons/live_tokens/live_tokens.gd")
assert_true(FileAccess.file_exists(p), "platform/tvos/addons/live_tokens symlink missing")
  • Step 2: Run to verify it fails — FAIL on the doc + symlink tests.

  • Step 3: Create symlink — ln -s ../../../addons/live_tokens platform/tvos/addons/live_tokens (same shape as the existing gut link).

  • Step 4: Write addons/live_tokens/PROTOCOL.md with this content:

---
title: "live_tokens protocol (v1)"
---
# live_tokens protocol — v1
A game-agnostic live design-token store. Games ship a defaults JSON; tools/consoles read a schema and
send value changes. Nothing here is specific to one game.
## Defaults file
A flat JSON object `key -> value`. Keys always contain a dot (`text.muted`, `seed.accent`).
Keys starting `seed.` are primitive colours that ramps derive from.
A value may instead be `{"default": v, "touch": v, "tv": v}` (platform variants; any subset beyond default).
## Value grammar
- number → number
- `"#rrggbb"` / `"#rrggbbaa"` → colour
- `"@<seed>.<step>[ mods]"` → OKLCH ramp of `seed.<seed>`: lightness = 1 − step/1000, hue + chroma from the seed
- `"@<key>[ mods]"` → another token's value
- colour mods, space-separated: `a<alpha>` alpha, `c<mul>` chroma multiplier (ramps only), `x<mul>` RGB multiplier (HDR)
- `"<a>*<b>"` → number product; each operand a number or `@key`
## Schema (`LiveTokens.schema()`)
Array of `{key, type: "color"|"number", tier: "seed"|"token", group, default, value, resolved, variants}`.
`resolved` is `#rrggbbaa` for colours. The current screen id is `LiveTokens.screen()`.
## Commands (console → game)
- `{"kind": "ui_tokens", "set": {key: value}, "platform": ""|"touch"|"tv"}` → `LiveTokens.apply`; reply lists rejected keys
- `{"kind": "ui_tokens_reset"}` / `{"kind": "ui_tokens_reset", "key": k, "platform": p}`
- `{"kind": "ui_tokens_schema"}` → reply `{schema, screen, platform, overrides}`
Export (`LiveTokens.export_patch()`) returns a defaults-shaped patch of everything overridden.
## Versioning
Breaking grammar/schema changes bump the version in this file's title and in the schema reply.

(Commands are documented now; the console implements them in Phase 3.)

  • Step 5: Run to verify it passes — PASS.
  • Step 6: Commit — feat(live-tokens): isolation test, PROTOCOL.md, tvOS addon symlink.

Task 6: Dark Cosmos defaults + Tokens façade

Section titled “Task 6: Dark Cosmos defaults + Tokens façade”

Files:

  • Create: ui/tokens/defaults.json, tests/fixtures/ui_role_targets.json
  • Modify: ui/tokens.gd (add the new API; KEEP every existing const/func unchanged until Task 16)
  • Test: tests/test_tokens_defaults.gd, tests/test_token_keys_exist.gd

Interfaces:

  • Consumes: LiveTokens.*.
  • Produces: Tokens.c(key: StringName, alpha: float = -1.0) -> Color, Tokens.n(key) -> float, Tokens.i(key) -> int, Tokens.space(steps: float) -> int (= roundi(steps * n(&"space.unit")), Tailwind spacing: 1 = 4px, 2.5 = 10px), Tokens.ensure_loaded() -> void, Tokens.DEFAULTS_PATH.

Palette provenance: the 295 colour literals in ui/ + main.gd cluster (OKLab ΔE < 0.06) into 55 groups, 24 with ≥5 uses. Seeds are the most chromatic member of each hue family; each role’s step/chroma was fitted to the cluster’s most common literal (fit ΔE in the fixture). Fonts follow Tailwind’s named scale at today’s in-use sizes (10–120); type.scale multiplies all text.

  • Step 1: Create ui/tokens/defaults.json
{
"seed.neutral": "#738099",
"seed.accent": "#33e6ff",
"seed.blue": "#408cff",
"seed.violet": "#b873ff",
"seed.magenta": "#ff40d9",
"seed.red": "#ff5959",
"seed.orange": "#ff9966",
"seed.gold": "#ffd64c",
"seed.green": "#73ff8c",
"seed.lime": "#b2ff4c",
"seed.teal": "#47ffd1",
"text.bright": "#ffffff",
"text.body": "@accent.40 c0.7",
"text.primary": "@neutral.120 c0.5",
"text.secondary": "@neutral.180 c1.2",
"text.muted": "@neutral.240 c1.2",
"text.dim": "@neutral.300 c0.7",
"text.disabled": "@neutral.440 c0.4",
"text.faint": "@neutral.510 c0.4",
"surface.plate": "@neutral.800 c0.5",
"surface.void": "@neutral.910 c0.5",
"surface.scrim": "#000000",
"accent": "@accent.150 c1.1",
"accent.soft": "@accent.130 c0.6",
"accent.pale": "@accent.80 c0.5",
"accent.deep": "@accent.190 c0.7",
"success": "@green.110",
"success.pale": "@green.70 c1.1",
"success.dim": "@green.150 c0.6",
"hp": "@success",
"xp": "@lime.80",
"danger": "@red.310",
"danger.pale": "@red.60 c0.4",
"warn": "@orange.220",
"warn.pale": "@orange.180 c1.1",
"gold": "@gold.120",
"gold.bright": "@gold.100",
"gold.glow": "@gold.100 x1.35",
"gold.deep": "@gold.860 c0.6",
"violet": "@violet.310 c1.4",
"violet.soft": "@violet.260 c1.3",
"magenta": "@magenta.300",
"blue": "@blue.350",
"teal": "@teal.100",
"type.scale": 1.0,
"font.2xs": "10*@type.scale",
"font.xs": "12*@type.scale",
"font.sm": "14*@type.scale",
"font.base": "16*@type.scale",
"font.lg": "18*@type.scale",
"font.xl": "20*@type.scale",
"font.2xl": "24*@type.scale",
"font.3xl": "30*@type.scale",
"font.4xl": "40*@type.scale",
"font.5xl": "44*@type.scale",
"font.6xl": "64*@type.scale",
"font.7xl": "120*@type.scale",
"space.unit": 4,
"outline.sm": 3,
"outline.md": 6,
"outline.lg": 8,
"outline.xl": 10,
"outline.2xl": 14,
"radius.panel": 12,
"radius.plate": 9,
"border.thin": 1,
"border.thick": 2,
"panel.fill_a": 0.55,
"panel.fill_hover_a": 0.65,
"panel.fill_pressed_a": 0.75,
"glow.size": 7,
"nova.pale_mix": 0.7
}

(.json cannot hold comments; the provenance note above is the comment.)

  • Step 2: Create tests/fixtures/ui_role_targets.json — the colour each role must reproduce (the cluster’s representative literal) and its tolerance. Roles marked 0.06 are deliberate merges.
{
"text.bright": ["#ffffff", 0.03], "text.body": ["#d9f7ff", 0.03],
"text.primary": ["#ccd9e6", 0.03], "text.secondary": ["#b3c7e6", 0.03],
"text.muted": ["#9eb3d1", 0.03], "text.dim": ["#999eb3", 0.03],
"text.disabled": ["#737380", 0.03], "text.faint": ["#5c616b", 0.03],
"surface.plate": ["#12141f", 0.03], "surface.void": ["#000308", 0.03],
"surface.scrim": ["#000000", 0.03],
"accent": ["#33e6ff", 0.03], "accent.soft": ["#8ce0ff", 0.03],
"accent.pale": ["#b3f2ff", 0.03], "accent.deep": ["#73ccff", 0.06],
"success": ["#73ff8c", 0.03], "success.pale": ["#b3ffb3", 0.03],
"success.dim": ["#9ee09e", 0.03], "hp": ["#4dff80", 0.06], "xp": ["#b3ff4d", 0.03],
"danger": ["#ff5959", 0.03], "danger.pale": ["#ffe6e6", 0.03],
"warn": ["#ff9966", 0.03], "warn.pale": ["#ffb86b", 0.06],
"gold": ["#ffd140", 0.03], "gold.bright": ["#ffeb40", 0.06],
"gold.deep": ["#0d0900", 0.03],
"violet": ["#b873ff", 0.03], "violet.soft": ["#cc8cff", 0.03],
"magenta": ["#ff40d9", 0.03], "blue": ["#408cff", 0.03], "teal": ["#47ffd1", 0.03]
}
  • Step 3: Write the failing tests

tests/test_tokens_defaults.gd:

extends GutTest
# Dark Cosmos token defaults: every role reproduces the literal it replaced (within tolerance), and the
# façade's numeric helpers match the old ramps so Phase 1 is a near-identical restyle.
func before_all() -> void:
LiveTokens.reset_to_unloaded()
Tokens.ensure_loaded()
func test_roles_match_targets() -> void:
var targets: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://tests/fixtures/ui_role_targets.json"))
for role: String in targets.keys():
var t: Array = targets[role]
var de := LtColor.delta_e(Tokens.c(StringName(role)), Color.html(t[0]))
assert_lt(de, float(t[1]), "%s ΔE %.3f vs %s" % [role, de, t[0]])
func test_every_default_resolves() -> void:
for k in LiveTokens.keys():
assert_not_null(LiveTokens.value(StringName(k)), "token %s resolves" % k)
func test_font_and_space_scales() -> void:
assert_eq(Tokens.i(&"font.base"), 16)
assert_eq(Tokens.i(&"font.lg"), 18)
assert_eq(Tokens.space(1), 4)
assert_eq(Tokens.space(2.5), 10)
assert_eq(Tokens.space(3), 12)
func test_type_scale_moves_all_fonts() -> void:
LiveTokens.apply({"type.scale": 1.5})
assert_eq(Tokens.i(&"font.base"), 24)
LiveTokens.reset()

tests/test_token_keys_exist.gd:

extends GutTest
# Every token key literal used in game code exists in ui/tokens/defaults.json — the structural guard
# against typos (token keys always contain a dot; theme item names never do).
func _gd_files(root: String, out: PackedStringArray) -> void:
var d := DirAccess.open(root)
for f in d.get_files():
if f.ends_with(".gd"):
out.append(root.path_join(f))
for sub in d.get_directories():
_gd_files(root.path_join(sub), out)
func test_all_token_keys_exist() -> void:
Tokens.ensure_loaded()
var files := PackedStringArray(["res://main.gd"])
_gd_files("res://ui", files)
var re := RegEx.create_from_string("&\"([a-z0-9_]+(?:\\.[a-z0-9_]+)+)\"")
var missing := PackedStringArray()
for f in files:
var src := FileAccess.get_file_as_string(f)
for m in re.search_all(src):
if not LiveTokens.has(StringName(m.get_string(1))):
missing.append("%s: %s" % [f, m.get_string(1)])
assert_eq(missing.size(), 0, "unknown token keys:\n" + "\n".join(missing))
  • Step 4: Run to verify they fail — FAIL (Tokens.ensure_loaded missing).

  • Step 5: Add the façade API to ui/tokens.gd — insert below the header comment, leaving every existing const and ui_scale() untouched:

# ---- Live tokens (2026-10-04, docs/superpowers/specs/2026-10-04-live-ui-tokens-design.md) -----------
# The values below this block are LEGACY consts kept compiling during the Phase 1 migration (removed in
# its final task). New code reads tokens through these accessors; keys live in ui/tokens/defaults.json.
const DEFAULTS_PATH := "res://ui/tokens/defaults.json"
static func ensure_loaded() -> void:
if LiveTokens.is_loaded():
return
LiveTokens.load_defaults_file(DEFAULTS_PATH)
LiveTokens.set_platform("tv" if OS.get_name() == "tvOS" else ("touch" if Platform.is_touch() else ""))
static func c(key: StringName, alpha: float = -1.0) -> Color:
ensure_loaded()
return LiveTokens.color(key, alpha)
static func n(key: StringName) -> float:
ensure_loaded()
return LiveTokens.num(key)
static func i(key: StringName) -> int:
ensure_loaded()
return LiveTokens.int_val(key)
# Tailwind-style spacing: steps × space.unit (1 = 4px, 2.5 = 10px). Fractional steps are fine.
static func space(steps: float) -> int:
return roundi(steps * n(&"space.unit"))

Also call Tokens.ensure_loaded() as the first statement of main._ready() (before the existing get_window().content_scale_factor = Tokens.ui_scale() line ~317’s block) so platform detection happens once at boot.

  • Step 6: Run to verify they pass — PASS. If a role misses its tolerance, adjust only that role’s step (±10) or c multiplier in defaults.json until it passes; never widen a tolerance. The three 0.06 merges (accent.deep, warn.pale, gold.bright) differ in HUE from their seed and were fitted at ΔE 0.045–0.055; if 8-bit seed rounding pushes one over, set that role to its fixture hex (a raw hex role is still a live token) and note it in the commit.
  • Step 7: Commit — feat(ui): Dark Cosmos token defaults + Tokens live accessors.

Task 7: NeonTheme populated from tokens, in place

Section titled “Task 7: NeonTheme populated from tokens, in place”

Files:

  • Modify: ui/theme/neon_theme.gd (get_theme, _build → _populate, _box, nova_box, draw_nova_hex_slot)
  • Test: tests/test_neon_theme.gd (add tests; keep the existing ones)

Interfaces:

  • Consumes: Tokens.c/i/n, TokenBind.bind.

  • Produces: NeonTheme.get_theme() returns ONE Theme instance for the whole session whose contents are repopulated on every token change (Controls holding it restyle automatically). NeonTheme.nova_box(accent: Color, focus: float = 0.0, dead: bool = false) keeps its signature; its internal literals become tokens.

  • Step 1: Add failing tests to tests/test_neon_theme.gd

func after_each() -> void:
LiveTokens.reset()
TokenBind.flush_now()
func test_theme_repopulates_in_place_on_token_change() -> void:
var t := NeonTheme.get_theme()
var l: Label = add_child_autofree(Label.new())
l.theme = t
LiveTokens.apply({"text.body": "#ff0000"})
TokenBind.flush_now()
assert_same(NeonTheme.get_theme(), t, "same Theme instance")
assert_eq(l.get_theme_color(&"font_color"), Color(1, 0, 0), "label restyled live")
func test_theme_default_font_size_follows_type_scale() -> void:
LiveTokens.apply({"type.scale": 2.0})
TokenBind.flush_now()
assert_eq(NeonTheme.get_theme().default_font_size, 36)
func test_nova_box_uses_tokens() -> void:
LiveTokens.apply({"radius.plate": 3, "glow.size": 11})
var sb := NeonTheme.nova_box(Tokens.c(&"accent.main"))
assert_eq(sb.corner_radius_top_left, 3)
assert_eq(sb.shadow_size, 11)
  • Step 2: Run — FAIL.

  • Step 3: Implement. Replace get_theme() / _build():

static func get_theme() -> Theme:
if _theme == null:
_theme = Theme.new()
# Repopulated IN PLACE on every token change: every Control already holding this Theme
# restyles itself, so screens built before a live tweak still update.
TokenBind.bind(_theme, func() -> void: _populate(_theme))
return _theme
static func _populate(t: Theme) -> void:
t.clear()
t.default_font = title_font()
t.default_font_size = Tokens.i(&"font.lg")
t.set_stylebox("normal", "Button", _box(Tokens.n(&"panel.fill_a"), Tokens.i(&"border.thin")))
t.set_stylebox("hover", "Button", _box(Tokens.n(&"panel.fill_hover_a"), Tokens.i(&"border.thick")))
t.set_stylebox("pressed", "Button", _box(Tokens.n(&"panel.fill_pressed_a"), Tokens.i(&"border.thick")))
t.set_stylebox("focus", "Button", _box(Tokens.n(&"panel.fill_hover_a"), Tokens.i(&"border.thick")))
t.set_color("font_color", "Button", Tokens.c(&"text.body"))
t.set_color("font_hover_color", "Button", Tokens.c(&"text.bright"))
t.set_color("font_color", "Label", Tokens.c(&"text.body"))

Keep any further lines the current _build() has after line 91 (read the whole function first) — translate each literal with Task 8’s mapping table. Then in _box, nova_box, draw_nova_hex_slot replace literals:

Current Replace with
Color(0.045, 0.055, 0.09, 0.94) (plate) Tokens.c(&"surface.plate", 0.94)
accent.lerp(Color.WHITE, 0.7) accent.lerp(Tokens.c(&"text.bright"), Tokens.n(&"nova.pale_mix"))
Color(0.3, 0.32, 0.36, 0.6) (dead edge) Tokens.c(&"text.faint", 0.6)
set_corner_radius_all(9) set_corner_radius_all(Tokens.i(&"radius.plate"))
shadow_size = 7 shadow_size = Tokens.i(&"glow.size")
Tokens.SPACE_MD Tokens.space(3)
Tokens.RADIUS / Tokens.ACCENT / Tokens.BORDER Tokens.i(&"radius.panel") / Tokens.c(&"accent.main") / Tokens.i(&"border.thin")
Color(0.0, 0.0, 0.0, fill_a) Tokens.c(&"surface.scrim", fill_a)
hex plate Color(0.045, 0.055, 0.09, 0.9) Tokens.c(&"surface.plate", 0.9)
col.lerp(Color.WHITE, 0.65) col.lerp(Tokens.c(&"text.bright"), 0.65)

Change const CYAN := Tokens.ACCENT / const TEXT := Tokens.TEXT — leave them (legacy, removed in Task 16).

  • Step 4: Run — PASS, plus test_tokens.gd (legacy assertions still hold because legacy consts are untouched; if test_neon_theme_is_built_from_tokens now fails on Tokens.FONT_MD/Tokens.TEXT, update those two assertions to Tokens.i(&"font.lg") / Tokens.c(&"text.body")).
  • Step 5: Commit — feat(ui): NeonTheme repopulates live from tokens.

Task 8: Literal ratchet lint + migration aid

Section titled “Task 8: Literal ratchet lint + migration aid”

Files:

  • Create: tools/live_tokens/literal_scan.gd (UiLiteralScan), tools/live_tokens/write_literal_baseline.gd, tools/live_tokens/nearest_role.gd, tests/ui_literal_allowlist.json (generated)
  • Test: tests/test_ui_literal_ratchet.gd

Interfaces:

  • Produces: UiLiteralScan.files() -> PackedStringArray (every .gd under res://ui except res://ui/tokens.gd, plus res://main.gd), UiLiteralScan.scan_file(path: String) -> Dictionary {"color": int, "font": int, "const": int} (occurrence counts; lines whose code part starts with #, and lines containing # content-colour, are skipped).

  • Step 1: Implement the scanner (tools/live_tokens/literal_scan.gd)

class_name UiLiteralScan
extends RefCounted
# Counts hardcoded UI styling literals — the ratchet behind tests/test_ui_literal_ratchet.gd.
# A colour that is genuinely gameplay content (element/weapon hue) may stay: mark its line
# `# content-colour: <reason>` and it is not counted.
const PATTERNS := {
"color": "Color\\(\\s*[0-9.\"]|Color\\.(?!TRANSPARENT\\b)[A-Z][A-Z_]{2,}\\b",
"font": "font_size_override\\([^,]+,\\s*[0-9]|font_size\\s*=\\s*[0-9]",
"const": "constant_override\\([^,]+,\\s*[0-9]",
}
static func files() -> PackedStringArray:
var out := PackedStringArray(["res://main.gd"])
_walk("res://ui", out)
return out
static func _walk(root: String, out: PackedStringArray) -> void:
var d := DirAccess.open(root)
for f in d.get_files():
if f.ends_with(".gd") and root.path_join(f) != "res://ui/tokens.gd":
out.append(root.path_join(f))
for sub in d.get_directories():
_walk(root.path_join(sub), out)
static func scan_file(path: String) -> Dictionary:
var res := {"color": 0, "font": 0, "const": 0}
var regs := {}
for k: String in PATTERNS:
regs[k] = RegEx.create_from_string(PATTERNS[k])
for line in FileAccess.get_file_as_string(path).split("\n"):
var code := line.strip_edges()
if code.begins_with("#") or line.contains("# content-colour"):
continue
for k: String in regs:
res[k] += (regs[k] as RegEx).search_all(line).size()
return res
  • Step 2: Implement the baseline writer (tools/live_tokens/write_literal_baseline.gd)
extends SceneTree
# godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd
# Writes tests/ui_literal_allowlist.json from the current code. Run ONLY to lower counts after a
# migration (the ratchet test fails if a count went down and the allowlist wasn't lowered).
func _init() -> void:
var out := {}
for f in UiLiteralScan.files():
var r := UiLiteralScan.scan_file(f)
if r["color"] + r["font"] + r["const"] > 0:
out[f] = r
var fa := FileAccess.open("res://tests/ui_literal_allowlist.json", FileAccess.WRITE)
fa.store_string(JSON.stringify(out, " ", true))
fa.close()
print("[literal-baseline] wrote %d files" % out.size())
quit()
  • Step 3: Write the failing test (tests/test_ui_literal_ratchet.gd)
extends GutTest
# Ratchet: hardcoded UI colours/font sizes/theme constants may only DECREASE. Over the allowlist =
# new literal (use a token). Under it = progress — lower the allowlist by running
# godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd
func test_ratchet() -> void:
var allow: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://tests/ui_literal_allowlist.json"))
var problems := PackedStringArray()
for f in UiLiteralScan.files():
var got := UiLiteralScan.scan_file(f)
var cap: Dictionary = allow.get(f, {"color": 0, "font": 0, "const": 0})
for k: String in got:
var c := int(cap.get(k, 0))
if got[k] > c:
problems.append("%s: %d %s literals (allowed %d) — use a token" % [f, got[k], k, c])
elif got[k] < c:
problems.append("%s: %s down to %d (allowlist %d) — rerun write_literal_baseline.gd" % [f, k, got[k], c])
assert_eq(problems.size(), 0, "\n".join(problems))
  • Step 4: Run — FAIL (allowlist missing). Generate it: godot --headless --path . --import >/dev/null 2>&1; godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd. Run scripts/check-test-count.sh → PASS.

  • Step 5: Implement the migration aid (tools/live_tokens/nearest_role.gd)

extends SceneTree
# godot --headless --path . -s res://tools/live_tokens/nearest_role.gd -- res://ui/hud.gd
# For every Color(r, g, b[, a]) literal in the file: line, literal, nearest role (seeds excluded) and
# OKLab ΔE. ΔE >= 0.06 is flagged NEW-ROLE? — decide: content-colour marker, or add a role (+ fixture).
func _init() -> void:
Tokens.ensure_loaded()
var roles: Array = []
for k in LiveTokens.keys():
var v: Variant = LiveTokens.value(StringName(k))
if typeof(v) == TYPE_COLOR and not k.begins_with("seed."):
roles.append([k, v])
var re := RegEx.create_from_string("Color\\(\\s*([0-9.]+)\\s*,\\s*([0-9.]+)\\s*,\\s*([0-9.]+)\\s*(?:,\\s*([0-9.]+))?\\s*\\)")
var path: String = OS.get_cmdline_user_args()[0]
var n := 0
for line in FileAccess.get_file_as_string(path).split("\n"):
n += 1
for m in re.search_all(line):
var c := Color(m.get_string(1).to_float(), m.get_string(2).to_float(), m.get_string(3).to_float())
var best := ""
var best_d := 99.0
for r: Array in roles:
var d := LtColor.delta_e(c, r[1])
if d < best_d:
best_d = d
best = r[0]
var a := m.get_string(4)
print("%4d %-32s -> %-16s dE %.3f%s%s" % [n, m.get_string(0), best, best_d,
(" alpha " + a) if a != "" else "", " NEW-ROLE?" if best_d >= 0.06 else ""])
quit()

Also handles Color.WHITE → text.bright and Color.BLACK → surface.scrim by hand (the tool lists only numeric literals). HDR literals (any channel > 1, e.g. Color(1.35, 1.15, 0.55)) → gold.glow; Color(1.6, 0.55, 1.9) → Tokens.c(&"magenta.main") with an x multiplier — add role magenta.glow: "@magenta.main x1.8" to defaults + fixture target if it is UI.

  • Step 6: Commit — test(ui): literal ratchet + nearest-role migration aid.

Task 9: Rebind probe (headless) + probe sweep (windowed)

Section titled “Task 9: Rebind probe (headless) + probe sweep (windowed)”

Files:

  • Create: tests/rebind_probe.gd (RebindProbe), tools/live_tokens/probe_diff.gd, scripts/ui-probe.sh
  • Modify: tools/ui_sweep/sweep.gd (probe mode)
  • Test: tests/test_rebind_probe.gd

Interfaces:

  • Produces: RebindProbe.unbound(root: Node) -> PackedStringArray — runs probe gen 1 and gen 2 (flushing bindings each time), snapshots every Color reachable via theme_override_colors/*, modulate, self_modulate, color (ColorRect), and bg_color/border_color/shadow_color of theme_override_styles/* StyleBoxFlats, returns "<node path>:<prop>" for values identical across both gens (skipping exact Color(1,1,1,1) on modulate/self_modulate, the untouched default), then restores probe 0.

  • Step 1: Write the failing test

extends GutTest
func after_each() -> void:
LiveTokens.set_probe(0)
TokenBind.flush_now()
func test_bound_label_passes_and_literal_label_is_caught() -> void:
var root: Control = add_child_autofree(Control.new())
var good := Label.new()
good.name = "Good"
root.add_child(good)
TokenBind.theme_color(good, &"font_color", &"text.muted")
var bad := Label.new()
bad.name = "Bad"
root.add_child(bad)
bad.add_theme_color_override(&"font_color", Color(0.62, 0.7, 0.82))
var found := RebindProbe.unbound(root)
assert_eq(found.size(), 1, str(found))
assert_string_contains(found[0], "Bad")
  • Step 2: Run — FAIL.

  • Step 3: Implement tests/rebind_probe.gd

class_name RebindProbe
extends RefCounted
# Finds colours that do NOT follow live tokens: under probe mode every token colour changes per
# generation, so any Color property identical across two generations is a literal or a frozen copy.
static func unbound(root: Node) -> PackedStringArray:
Tokens.ensure_loaded()
LiveTokens.set_probe(1)
TokenBind.flush_now()
var a := _snapshot(root)
LiveTokens.set_probe(2)
TokenBind.flush_now()
var b := _snapshot(root)
LiveTokens.set_probe(0)
TokenBind.flush_now()
var out := PackedStringArray()
for k: String in a.keys():
if b.has(k) and a[k] == b[k]:
out.append(k)
return out
static func _snapshot(root: Node) -> Dictionary:
var snap := {}
_walk(root, root, snap)
return snap
static func _walk(root: Node, n: Node, snap: Dictionary) -> void:
var path := str(root.get_path_to(n))
for p: Dictionary in n.get_property_list():
var name: String = p["name"]
var v: Variant = n.get(name)
if name.begins_with("theme_override_colors/") or (name in ["color"] and typeof(v) == TYPE_COLOR):
snap["%s:%s" % [path, name]] = v
elif name in ["modulate", "self_modulate"] and v != Color(1, 1, 1, 1):
snap["%s:%s" % [path, name]] = v
elif name.begins_with("theme_override_styles/") and v is StyleBoxFlat:
var sb: StyleBoxFlat = v
snap["%s:%s.bg" % [path, name]] = sb.bg_color
snap["%s:%s.border" % [path, name]] = sb.border_color
if sb.shadow_size > 0:
snap["%s:%s.shadow" % [path, name]] = sb.shadow_color
for ch in n.get_children():
_walk(root, ch, snap)

Note: a property whose value is transparent by design (Color(0,0,0,0) border, alpha-only literal) will show up — fix by binding it to a token with alpha 0 (Tokens.c(&"surface.scrim", 0.0)), not by skipping.

  • Step 4: Run — PASS.

  • Step 5: Probe sweep. In tools/ui_sweep/sweep.gd, after the existing await _capture("res://tools/ui_sweep/out/%s.png" % id) inside the screens loop, add:

if "--probe" in OS.get_cmdline_user_args():
for gen in [1, 2]:
LiveTokens.set_probe(gen)
TokenBind.flush_now()
await _frames(20)
await _capture("res://tools/ui_sweep/out/%s_probe%d.png" % [id, gen])
LiveTokens.set_probe(0)
TokenBind.flush_now()
await _frames(5)

Create tools/live_tokens/probe_diff.gd:

extends SceneTree
# godot --headless --path . -s res://tools/live_tokens/probe_diff.gd
# For each <id>_probe1.png/<id>_probe2.png pair in tools/ui_sweep/out: pixels identical across the two
# probe generations and not near-black are UNBOUND (or world/gameplay). Writes <id>_probe_mask.png
# (unbound pixels red over a dimmed capture) and prints the percentage per screen.
const OUT := "res://tools/ui_sweep/out/"
func _init() -> void:
var d := DirAccess.open(OUT)
for f in d.get_files():
if not f.ends_with("_probe1.png"):
continue
var id := f.trim_suffix("_probe1.png")
var a := Image.load_from_file(ProjectSettings.globalize_path(OUT + f))
var b := Image.load_from_file(ProjectSettings.globalize_path(OUT + id + "_probe2.png"))
var mask := Image.create(a.get_width(), a.get_height(), false, Image.FORMAT_RGB8)
var bad := 0
var lit := 0
for y in a.get_height():
for x in a.get_width():
var ca := a.get_pixel(x, y)
var cb := b.get_pixel(x, y)
var luma := ca.get_luminance()
var same := absf(ca.r - cb.r) < 0.01 and absf(ca.g - cb.g) < 0.01 and absf(ca.b - cb.b) < 0.01
if luma > 0.08:
lit += 1
if same and luma > 0.08:
bad += 1
mask.set_pixel(x, y, Color(1, 0, 0))
else:
mask.set_pixel(x, y, ca * 0.35)
mask.save_png(ProjectSettings.globalize_path(OUT + id + "_probe_mask.png"))
print("[probe] %-22s unbound %.1f%% of lit pixels" % [id, 100.0 * bad / maxf(1.0, lit)])
quit()

Create scripts/ui-probe.sh:

#!/bin/zsh
# Live-token probe sweep: every screen captured under two probe palettes; pixels that don't change are
# unbound UI (or gameplay/world). Windowed + foreground only (same rules as scripts/ui-sweep.sh).
cd "$(dirname "$0")/.."
if pgrep -f 'ui_sweep/sweep.tscn' >/dev/null; then
echo "ERROR: a ui_sweep godot is already running" >&2; exit 1
fi
godot --path . --rendering-method mobile --always-on-top res://tools/ui_sweep/sweep.tscn -- --probe 2>&1 | grep '\[ui_sweep\]'
godot --headless --path . -s res://tools/live_tokens/probe_diff.gd 2>&1 | grep '\[probe\]'
git checkout -- project.godot audio/bus_layout.tres
echo "masks: tools/ui_sweep/out/*_probe_mask.png (red = did not follow tokens)"

chmod +x scripts/ui-probe.sh. Run it once now (windowed, foreground) and record the per-screen baseline percentages in the commit message — before migration most UI pixels are red; that is expected.

  • Step 6: Commit — test(ui): rebind probe + windowed probe sweep.

Tasks 10–15: Migrate UI files (one recipe, six batches)

Section titled “Tasks 10–15: Migrate UI files (one recipe, six batches)”

Each batch is one task, one worktree, one commit series, own reviewer gate. Before starting a batch, re-run the per-file counts (godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd on a scratch copy, or read tests/ui_literal_allowlist.json) — another session may have changed the files.

Recipe (apply to every file in the batch):

  1. Run godot --headless --path . -s res://tools/live_tokens/nearest_role.gd -- res://<file> and work through its list top to bottom.
  2. For each colour literal, choose by how the value is USED:
    • Set once on a node at build time (add_theme_color_override, .modulate =, .color =, label_settings.font_color =): replace with a binding — TokenBind.theme_color(lbl, &"font_color", &"text.muted") / TokenBind.prop(node, &"modulate", &"accent.main"). For LabelSettings, bind the settings object: TokenBind.prop(ls, &"font_color", &"gold.main").
    • Derived (alpha changed, lerp, darkened, conditional on state): bind a Callable that computes it — TokenBind.theme_color(lbl, &"font_color", func() -> Color: return Tokens.c(&"gold.main").lerp(Tokens.c(&"text.bright"), 0.3)). Alpha-only change: Tokens.c(&"accent.main", 0.55).
    • Inside _draw(): read Tokens.c(&"role") inline in _draw (hoist into locals at the top of _draw when used in a loop) and add TokenBind.redraw(self) once in _ready().
    • StyleBoxFlat built in code: move construction into a function and bind it — TokenBind.stylebox(panel, &"panel", _make_panel_box) (method reference) or a lambda.
    • const colour / member var initialised once / dictionary or array of colours: delete it; replace uses with Tokens.c(&"role") at the point of use (or a static func _rarity_color(r) -> Color that returns Tokens.c(...) per case).
    • Tweens/pulses: tween a 0..1 float (tween_method) and compute the colour from tokens in the setter each step — never tween_property(node, "modulate", <captured Color>, …) with a colour captured from a token.
    • Gameplay content (element/weapon/enemy hue tables mirrored from content, world-space FX drawn by main.gd): leave it, append # content-colour: <why> to the line.
    • ΔE ≥ 0.06 (NEW-ROLE?) and not content: add a role to ui/tokens/defaults.json (prefer a ramp ref of an existing seed) and its target to tests/fixtures/ui_role_targets.json (tolerance 0.03), in the same commit.
  3. Font sizes → Tokens.i(&"font.<size>") via the nearest scale step: 10→2xs, 11–12→xs, 13–14→sm, 15–17→base, 18–19→lg, 20–22→xl, 23–26→2xl, 28–32→3xl, 38–40→4xl, 44→5xl, 64→6xl, 120→7xl. Set-once overrides use TokenBind.theme_font_size(lbl, &"font_size", &"font.sm"). Legacy Tokens.FONT_XS/SM/MD/LG/XL/DISPLAY → font.xs/sm/lg/2xl/3xl/5xl.
  4. Theme constants → TokenBind.theme_constant(box, &"separation", func() -> int: return Tokens.space(2)) with n = px / 4 (0→0, 1→0.25, 2→0.5, 4→1, 6→1.5, 8→2, 10→2.5, 12→3, 14→3.5, 16→4, 20→5, 24→6, 28→7). outline_size 3/6/8/10/14 → outline.sm/md/lg/xl/2xl. margin_* → Tokens.space(px/4). Legacy Tokens.SPACE_XS/SM/MD/LG/XL → Tokens.space(1/2/3/5/8).
  5. Legacy colour consts used in the file → roles: Tokens.ACCENT/NeonTheme.CYAN→accent.main, Tokens.TEXT/NeonTheme.TEXT→text.body, TEXT_DIM→text.muted, GOLD→gold.main, DANGER→danger.main, WARN→warn.main, HP→bar.hp, XP→bar.xp, SUCCESS→success.main, BG→surface.scrim a0.55. Tokens.RADIUS→radius.panel, BORDER/BORDER_THICK→border.thin/thick, PANEL_FILL_A/PANEL_FILL_HOVER_A→panel.fill_a/fill_hover_a.
  6. Add a rebind case to the file’s existing test (table below): construct the panel exactly as that test already does, then assert_eq(RebindProbe.unbound(panel).size(), 0, str(RebindProbe.unbound(panel))). For _draw-only nodes (no Color properties) assert instead that TokenBind.binding_count() grew after _ready. Files with no test: create tests/test_<file>_tokens.gd with that one case.
  7. Run godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd; the file’s counts must now be 0 (or only # content-colour lines, which are not counted). Run scripts/check-test-count.sh → PASS.
  8. Commit per file: refactor(ui): <file> reads live tokens.

At the end of each batch: bash scripts/gate.sh green, zsh scripts/ui-sweep.sh (expect small RMSE shifts from merged colours — do not re-bless; collect the diffs), zsh scripts/ui-probe.sh (record per-screen unbound % — must fall for this batch’s screens), merge to main, git push origin main.

Per-file facts (verified 2026-10-04: lines with colour literals / numeric font sizes / numeric theme constants; existing test):

Batch File col font const Test
10 ui/theme/glass_panel.gd 2 0 0 test_glass_panel.gd
10 ui/theme/gold_pulse.gd 1 0 0 none
10 ui/button_prompt.gd 1 0 0 test_button_prompt.gd
10 ui/speaker_emblem.gd 1 0 0 test_speaker_emblem.gd
10 ui/objective_panel.gd 1 0 2 test_objective_panel.gd
10 ui/region_title.gd 2 2 1 test_region_title.gd
10 ui/debug_overlay.gd 2 1 0 test_debug_overlay.gd
10 ui/weapon_panel.gd 2 3 0 test_weapon_panel.gd
10 ui/neon_backdrop.gd 3 0 0 test_neon_backdrop.gd
10 ui/threat_radar.gd 3 0 0 test_threat_radar_v2.gd
10 ui/boss_edge_indicator.gd 5 1 0 test_boss_edge_indicator.gd
10 ui/shop_icons.gd 5 0 0 test_shop_icons.gd
11 ui/hud.gd 8 5 1 test_hud.gd
11 ui/drone_dock.gd 11 1 0 test_drone_dock.gd
11 ui/drone_panel.gd 7 0 0 test_drone_panel.gd
11 ui/weapon_icon.gd 10 0 0 test_weapon_icon.gd
11 ui/button_glyph.gd 13 0 0 none
11 ui/touch_controls.gd 22 0 0 test_touch_controls.gd
12 ui/start_menu.gd 16 0 0 test_start_menu_modes.gd
12 ui/pause_menu.gd 16 5 0 test_pause_menu.gd
12 ui/dialogue_box.gd 7 4 0 test_dialogue_box.gd
12 ui/codex_card.gd 5 5 2 test_codex_card.gd
12 ui/codex_overlay.gd 6 3 4 test_codex_overlay.gd
12 ui/feedback_panel.gd 6 5 6 test_feedback_panel.gd (also delete its _restyle() in favour of bindings)
13 ui/meta_shop_panel.gd 22 5 9 test_meta_shop_panel.gd
13 ui/ship_config_panel.gd 15 1 0 test_ship_config_panel.gd
13 ui/drone_bay_panel.gd 3 2 2 test_drone_bay_panel.gd
13 ui/debug_settings_panel.gd 6 4 2 test_debug_settings_panel.gd
13 ui/weapon_detail_view.gd 6 6 1 none
14 ui/level_up_panel.gd 20 2 3 test_level_up_panel.gd
14 ui/stats_sheet.gd 13 0 8 test_stats_sheet_rows.gd
14 ui/results_panel.gd 14 10 13 test_results_panel.gd
14 ui/dimension_choice_panel.gd 4 0 2 test_dimension_choice_panel.gd
15 ui/level_up_panel_legacy.gd 34 19 11 test_level_up_panel_legacy.gd (live fallback when VisualLook.glass_ui is false — migrate it; tests/test_font_coverage.gd’s glyph list for it must still pass)
15 main.gd 31 5 3 test_main.gd — most of main.gd’s colours are world/FX: migrate only UI-building code (panels, labels, overlays it constructs); mark the rest # content-colour

Batch 11 is draw-heavy HUD code that runs every frame on the Apple TV: hoist token reads to locals at the top of each _draw, and in the batch’s commit message record HUD _draw time before/after from the perf overlay on a desktop windowed run.


Task 16: Remove the legacy API, document, sign-off

Section titled “Task 16: Remove the legacy API, document, sign-off”

Files:

  • Modify: ui/tokens.gd (delete legacy consts FONT_*, TEXT, TEXT_DIM, ACCENT, BG, DANGER, WARN, GOLD, HP, XP, SUCCESS, SPACE_*, RADIUS, BORDER*, PANEL_FILL_*; keep TOUCH_SCALE, ui_scale()), ui/theme/neon_theme.gd (delete CYAN, TEXT), every remaining reference (from the 2026-10-04 inventory: tools/vfx_library/insitu.gd 7, tools/vfx_library/library.gd 5, tools/shop_lab/shop_lab.gd 3, tools/vfx_library/library_manifest.gd 1, fx/fx_manager.gd 2, tests/test_tokens.gd, tests/test_hud.gd, tests/test_start_menu.gd, tests/shop_layout_fixture.gd, tests/test_level_up_panel.gd), CLAUDE.md (one table row), docs/architecture/rendering-and-performance.md (pointer)

  • Create: docs/architecture/ui-tokens.md

  • Step 1: git grep -nE "NeonTheme\.(CYAN|TEXT)\b|Tokens\.(FONT_|SPACE_|TEXT|ACCENT|BG\b|DANGER|WARN|GOLD|HP\b|XP\b|SUCCESS|RADIUS|BORDER|PANEL_FILL)" -- '*.gd' — replace each with the Task 10–15 step 5 mapping (fx/fx_manager.gd uses are FX: map to Tokens.c(&"accent.main") / Tokens.c(&"text.body") — tools may read tokens). Rewrite tests/test_tokens.gd around the new API (font scale monotonic via Tokens.i, space via Tokens.space, ui_scale unchanged).

  • Step 2: delete the legacy consts; godot --headless --path . --import; scripts/check-test-count.sh → PASS; tests/ui_literal_allowlist.json regenerated must contain only main.gd-style files whose remaining lines are all # content-colour (i.e. effectively {}).

  • Step 3: Write docs/architecture/ui-tokens.md (Starlight title: frontmatter): data flow (defaults.json → LiveTokens → TokenBind/NeonTheme → screens), the tiers (seeds → roles → numbers), the grammar pointer (PROTOCOL.md), the binding rules (recipe step 2 verbatim), the ratchet + # content-colour rule, probe tools (RebindProbe, scripts/ui-probe.sh), and “adding a role” (defaults + fixture target). Add a row to the CLAUDE.md subsystem table: | UI styling — colours, type, spacing tokens (live-tweakable), TokenBind, the literal ratchet | docs/architecture/ui-tokens.md |.

  • Step 4: Sign-off evidence for Chris: zsh scripts/ui-sweep.sh and keep tools/ui_sweep/out/*.png; build a side-by-side (baseline | new) for each of the 9 screens at iPhone, iPad and ATV sizes via bh-visual-qa; zsh scripts/ui-probe.sh and list per-screen unbound % (target: < 2% on menu screens; hud_in_run reports world pixels and is informational). Do NOT --bless until Chris approves the side-by-sides.

  • Step 5: bash scripts/gate.sh green → commit refactor(ui): drop legacy token consts; ui-tokens architecture doc → merge → git push origin main. Add a What’s new item (control/src/whats_new.js, build: null) only if Chris’s sign-off accepts a visible colour change: “UI colours unified into one palette (groundwork for live theme tuning)”.


  • Spec coverage (Phase 1): store/grammar/ramps/variants (T1–T3), bindings (T4), isolation/protocol (T5), defaults + façade (T6), NeonTheme live (T7), lint ratchet (T8), probe test + sentinel sweep (T9), colour/type migration (T10–15), docs + legacy removal (T16). Deliberately deferred to Phase 2 per the spec’s phasing: Ui.stack/row/inset/grid/text helpers and TokenBind.layout (layout migration). Phase 3: console Theme tab + Debug Settings parity (protocol already fixed in T5).
  • Deviation from spec, recorded: the façade uses string keys (Tokens.c(&"text.muted")) instead of one GDScript const per key; typo safety comes from test_token_keys_exist.gd instead of a parse error. Reason: the legacy const names (TEXT, ACCENT, GOLD…) collide with role names during migration, and ~70 keys as consts would duplicate defaults.json.
  • Theme constants (separation/outline/margins) moved into Phase 1 because they are bound exactly like font sizes; positions/sizes remain Phase 2.

Written 2026-10-04 at the end of the brainstorm/planning session, for an agent with no conversation context.

Chris’s decisions (in conversation, beyond the spec text):

  • He asked for “all UI elements changeable through variables rather than hardcoded”; then chose live-tweakable over a code-only refactor, because “live changes like this would make it much easier for us to make small adjustments to perfect the look without having to keep rebuilding”.
  • He chose the robust reactive approach (bindings, no hand-written _restyle()), over “restyle on next open”.
  • He chose a semantic palette over one variable per literal, then asked to collapse further into shared variables. That led to the Tailwind-like 3-tier model (seeds → roles → numbers), which he endorsed: “it sounds more like using tailwind for html styling. Lets go ahead”.
  • Layout positioning is IN scope (“that still needs work”). It is Phase 2 (its own plan), with per-screen visual sign-off by Chris. Small layout shifts from snapping to the spacing scale are accepted.
  • Reusable for the next Godot game: the addon must stay free of game classes. The console side is schema-driven. No separate repo or Swift client yet.
  • Store remote-config restyling was picked initially (“Dev + store via remote config”), then explicitly deferred to Phase 4 on Claude’s recommendation (console-first). Layout must NEVER be remote-patchable. Don’t build Phase 4 without asking him.
  • Library survey: WoldUI (model copied, not a dependency) and ThemeGen (editor-only). Rejected: depending on either.

Authorised: implementing this plan in worktrees, merging each green task/batch to main, and git push origin main after every merge (repo rule). Needs Chris’s same-day OK: re-blessing ui_sweep baselines (Task 16 Step 4 side-by-sides first), any device install, any What’s new entry claiming a visible change, and Phase 2/3/4 starts.

Already done: spec committed (401df64e), this plan committed with it. Nothing else changed. The palette fit (seeds, role steps/chroma, ΔE) was computed in a throwaway Python script mirroring LtColor. Its results are the defaults.json and fixture in Task 6.

Gotchas hit while planning:

  • zsh echo == errors (= not found). Quote it.
  • grep is a ugrep shim; use /usr/bin/grep or git grep.
  • GUT 9.7.1 fails a test on any unexpected push_error.
  • Never put fake defaults into the GLOBAL LiveTokens store in tests (live NeonTheme bindings would re-run against missing keys). Use a local LiveTokensStore.

Task order: 1, 2, 3, 5, 6, 4, 7, 8, 9, 10–15, 16. Task 4’s tests need Task 6’s real defaults.

Next action: consult bh-sdd-concurrent (check other sessions/branches touching ui/). Then dispatch Task 1 to a bh-implementer (model opus) in .claude/worktrees/live-tokens-t1 off origin/main.