Live UI tokens — design
Live UI tokens — design
Section titled “Live UI tokens — design”Brainstormed with Chris 2026-10-04. Status: spec, awaiting review. Next step: implementation plan (writing-plans).
Every UI styling and layout value in Dark Cosmos comes from a small set of shared, named tokens instead of hardcoded literals, and those tokens can be changed live on a running Dev build from the Remote Console — so the look can be perfected by small adjustments on a real device without a rebuild per nudge. Tuned values are exported back as the new bundled defaults.
Secondary goal: the generic machinery is a drop-in Godot addon reusable by the next Godot game, and the console side is engine-neutral (schema-driven) so even a non-Godot client could speak it.
The model is Tailwind’s: a small primitive config (colour ramps 50–950, a numeric spacing scale, a type scale) that everything else derives from, plus utility-style helpers at the call site.
Current state (2026-10-04, measured)
Section titled “Current state (2026-10-04, measured)”ui/tokens.gd(Tokens) exists with ~25consts;NeonThemebuilds the GodotThemefrom it. Adoption is partial:ui/*.gd+main.gdhold ~387Color(...)literals (226 distinct, mostly near-duplicates) against 55Tokens.references, ~89 numeric font sizes, and ~430 layout statements (114.position =, 90.size =, 80custom_minimum_size, 68 anchor calls, 54 separations, 21 offsets). All UI is code-built — no.tscnscreens.- Precedent for live render switches:
VisualLookstatic vars flipped from Debug Settings. - Precedent for runtime overrides: remote config
Tunables(numeric/bool, bounds, manifest). - Libraries surveyed: WoldUI (closest model — 3-tier tokens, runtime; too new and component-bound
to depend on, no
_drawor remote path), ThemeGen and others (editor-only.tresgeneration, no runtime). Godot itself has no token layer (proposal #13947). Decision: build it, copy WoldUI’s three-tier structure.
In: colours, font sizes/fonts-by-role, spacing, radii, borders, glow/shadow, shared dimensions
(button height, slot/icon/card sizes), and layout — gaps, paddings, anchors/margins, and the
positions of one-off elements — for every file in ui/ and the UI-building parts of main.gd.
Out: gameplay content colours (weapon/enemy/element hues from bible.json and FX), anything in
/sim, shaders, NebulaBg/world rendering, audio.
Architecture
Section titled “Architecture”Three tiers
Section titled “Three tiers”- Primitives (~10–15 knobs). About 6 seed colours (accent, plate, text, danger, warn, gold;
plus hp/xp/success if they don’t derive cleanly) — each generates a 50–950 ramp in OKLCH (perceptual,
as Tailwind v4 does: the seed fixes hue + chroma, the step fixes lightness). One spacing base
unit (
space.unit, default 4 design px; spacing step n = n × unit). A type scale (base size- ratio, giving
xs…display). Radius, border width, glow size.
- ratio, giving
- Semantic roles. Named roles whose default is a reference or formula over primitives, not
a raw value:
text.muted = text@400,panel.edge = accent@200 a0.55,pad.panel = space.3. Changing a seed moves every role derived from it consistently. Any role can be overridden with a raw value (the escape hatch when a derived value isn’t right). - Screen tokens. Only for genuine one-offs (HUD dock anchor, radar placement, shop grid columns)
— named
<screen>.<thing>(hud.dock.margin,shop.card.gap). Defaults should themselves reference the spacing scale wherever possible.
Token store (dictionary-backed, data defaults)
Section titled “Token store (dictionary-backed, data defaults)”addons/live_tokens/token_store.gd—LiveTokens: a key → value store with typed accessors (color(key),num(key),vec2(key)), reference/formula resolution with cycle detection, achanged(keys)signal,apply(dict),reset(), andschema()(every key with type, tier, group/screen, bounds, default, current value, platform variants).- Defaults live in data, not code:
ui/tokens/defaults.json(primitives, roles, screen tokens, per-platform variants). Loaded at boot. This makes Export a JSON diff, not code generation, and keeps the schema introspectable without relying on GDScript static-var reflection. ui/tokens.gd(Tokens) stays as the game’s typed façade:StringNamekey constants plus convenience accessors, so call sites readTokens.color(Tokens.TEXT_MUTED)and a typo is a parse error, not a silent miss. A test asserts every façade key exists indefaults.jsonand vice versa.- Unknown key at runtime:
push_errorand return a loud sentinel (magenta / 0) — visible, never a crash. - Platform variants: any token may carry
touch/tvoverrides alongside its default. Resolution: platform variant → default. The console edits the variant for the device it is connected to.
Binding — no hand-written restyle functions
Section titled “Binding — no hand-written restyle functions”The robustness rule: panels never write _restyle() code. They declare how a property is derived,
and the addon re-applies it.
TokenBind.color(node, prop, fn),TokenBind.stylebox(node, name, fn),TokenBind.font_size(node, fn),TokenBind.layout(node, fn)(the whole positioning calculation, re-run on token change and on viewport resize),TokenBind.redraw(node)(for_draw()nodes, which must read tokens inside_draw).- Bindings are closures, so derived values (lerps, alpha mixes) recompute on every change. They auto-release when the node leaves the tree. Re-application is batched to once per frame.
- Theme-driven controls get updates via
NeonThemerebuilding and reassigning theThemeonchanged. - Tweens/pulses (
gold_pulseetc.) read tokens per frame, never capture a colour at tween start. - Utility helpers (the Tailwind-utility tier) in
addons/live_tokens/ui.gd:Ui.stack(gap),Ui.row(gap),Ui.inset(pad),Ui.grid(cols, gap),Ui.text(label, role, size)— each builds or configures a container/label already bound to tokens. Lists and grids built from absolute positions are converted to these; only true one-offs keep aTokenBind.layout.
Live control path
Section titled “Live control path”- Remote Console “Theme” tab (
control/), schema-driven: the game postsLiveTokens.schema()plus the currently open screen id; the console renders pickers/sliders from it with no Dark-Cosmos-specific code. Shows the open screen’s tokens first, then roles, then primitives. Sends{"kind": "ui_tokens", "set": {key: value}, "platform": "touch"|"tv"|null}through the existingControlClient→main.apply_dev_commandpath; alsoresetandreset_key. - Export: the console holds the session’s changed values and produces a
defaults.jsonpatch (download + copy). A dev commits it. (The game can’t writeres://on device, so export is console-side.) - Debug Settings parity (per memory rule): a Debug Settings row reaches the same commands — at minimum “Reset UI tokens” and a live/sentinel toggle; full editing stays console-only.
- Dev builds only for the console path (
DevFlags). Applies instantly anywhere, including the HUD mid-run. - Screens report their id (
LiveTokens.set_screen(id)) on open so the console can scope the list.
Store remote config (Phase 4, deferred)
Section titled “Store remote config (Phase 4, deferred)”Optional later: a ui section in a remote-config patch, colour and type tokens only — never
layout (a bad position can push a button off-screen for every player). Applied at boot and on return
to menu, never mid-run. Validated before apply and in publish-config.mjs: hex format, alpha floor,
font-size bounds, and a minimum contrast ratio of text roles against the panel plate; any failure
rejects the whole UI section and the bundled look stays. Not built until Chris asks for it.
Reusability boundary
Section titled “Reusability boundary”addons/live_tokens/contains zero references to Dark Cosmos classes (Tokens,NeonTheme,Platform,main). A test loads the addon in an empty project to enforce it. Platform detection is injected (LiveTokens.set_platform("touch")).addons/live_tokens/PROTOCOL.md: the versioned schema + command contract the console speaks.- Game-specific:
ui/tokens/defaults.json,ui/tokens.gdfaçade,NeonTheme, the screens, and theControlClientwiring. - Lifting it into its own repo or writing a Swift client waits until a second game adopts it.
Migration
Section titled “Migration”Phases, each merged separately:
- Foundation. Addon (store, ramps, references, bindings,
Uihelpers),defaults.jsonwith primitives + roles derived from today’s palette,NeonThemerebuilt onchanged, the lint ratchet, and the sentinel sweep harness. Migrate colours + type across all UI files (mechanical: each literal maps to its nearest role). Near-duplicate colours merge — expected small shifts. - Layout, screen by screen. Convert each screen’s lists/grids to
Uihelpers snapped to the spacing scale; remaining one-offs become screen tokens bound viaTokenBind.layout. Expected small position changes. Each screen is its own visual sign-off by Chris (before/after at iPhone, iPad, ATV). Parallelisable per screen in separate worktrees (bh-sdd-concurrentrules). - Console Theme tab. Schema endpoint, screen scoping, pickers/sliders, platform variants, live apply, reset, export; Debug Settings parity row; What’s new entry.
- (Deferred) store remote-config UI section.
Verification
Section titled “Verification”- Lint ratchet (gate): a GUT test scans
ui/**/*.gdand the UI parts ofmain.gdand fails on aColor(literal,Color.<NAMED>other thanTRANSPARENT, numeric font-size override, numericadd_theme_constant_override, or aVector2(literal assigned to position/size/min-size, outside the allowlist. The allowlist starts as today’s counts per file and may only shrink; Phase 2 ends with it empty for migrated screens. - Sentinel-palette sweep: extend
tools/ui_sweepto open each screen, then apply a sentinel palette while it is open (each colour role set to a distinct unnatural hue). Any UI pixel still in the real palette means a value didn’t rebind (frozen value, missingqueue_redraw, bypassed token). Windowed, at iPhone/iPad/ATV sizes. - Headless rebind test (GUT): for each panel, instantiate, apply a perturbed palette, walk the tree,
fail if any
Colortheme override still equals a pre-change value. - Layout live test: perturb
space.unitand assert bound containers’ separations/margins changed. - Store tests: reference resolution, cycle detection, platform-variant precedence, reset, schema
completeness, façade ↔
defaults.jsonparity, ramp generation reproduces the seeds. - Coverage gap to close first:
ui_sweeptoday captures 9 screens (one with known self-variance,hud_in_run). Phase 2 adds a capture for every screen it migrates before migrating it, so each before/after diff exists. - After any Export:
bh-visual-qaat all three sizes (a value tuned on one device is untested on the other two). - Headless gate never compiles shaders and never renders colours faithfully — sentinel sweep and
sign-offs are windowed runs; restore
project.godot+audio/bus_layout.tresafter.
- Performance: many closures re-running on change is fine (change is rare), but bindings must not
re-run per frame. HUD
_drawreading tokens per frame is a dictionary lookup per value; hoist into locals at the top of_draw(same hot-loop rule as tunables). Measure HUD draw time before/after on the Apple TV. - Visual drift during migration: mitigated by per-screen sign-off; Phase 1 colour merges get one combined before/after review.
- Concurrent work on UI files: Phase 2 touches every screen; coordinate with Toby and check in-flight branches before each screen.
- OKLCH ramps may not reproduce today’s exact hues: roles can hold a raw override; the goal is consistency, not bit-identical colours.