Skip to content

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.

  • ui/tokens.gd (Tokens) exists with ~25 consts; NeonTheme builds the Godot Theme from it. Adoption is partial: ui/*.gd + main.gd hold ~387 Color(...) literals (226 distinct, mostly near-duplicates) against 55 Tokens. references, ~89 numeric font sizes, and ~430 layout statements (114 .position =, 90 .size =, 80 custom_minimum_size, 68 anchor calls, 54 separations, 21 offsets). All UI is code-built — no .tscn screens.
  • Precedent for live render switches: VisualLook static 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 _draw or remote path), ThemeGen and others (editor-only .tres generation, 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.

  1. 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.
  2. 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).
  3. 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, a changed(keys) signal, apply(dict), reset(), and schema() (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: StringName key constants plus convenience accessors, so call sites read Tokens.color(Tokens.TEXT_MUTED) and a typo is a parse error, not a silent miss. A test asserts every façade key exists in defaults.json and vice versa.
  • Unknown key at runtime: push_error and return a loud sentinel (magenta / 0) — visible, never a crash.
  • Platform variants: any token may carry touch / tv overrides 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 NeonTheme rebuilding and reassigning the Theme on changed.
  • Tweens/pulses (gold_pulse etc.) 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 a TokenBind.layout.
  • Remote Console “Theme” tab (control/), schema-driven: the game posts LiveTokens.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 existing ControlClient → main.apply_dev_command path; also reset and reset_key.
  • Export: the console holds the session’s changed values and produces a defaults.json patch (download + copy). A dev commits it. (The game can’t write res:// 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.

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.

  • 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.gd façade, NeonTheme, the screens, and the ControlClient wiring.
  • Lifting it into its own repo or writing a Swift client waits until a second game adopts it.

Phases, each merged separately:

  1. Foundation. Addon (store, ramps, references, bindings, Ui helpers), defaults.json with primitives + roles derived from today’s palette, NeonTheme rebuilt on changed, 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.
  2. Layout, screen by screen. Convert each screen’s lists/grids to Ui helpers snapped to the spacing scale; remaining one-offs become screen tokens bound via TokenBind.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-concurrent rules).
  3. Console Theme tab. Schema endpoint, screen scoping, pickers/sliders, platform variants, live apply, reset, export; Debug Settings parity row; What’s new entry.
  4. (Deferred) store remote-config UI section.
  • Lint ratchet (gate): a GUT test scans ui/**/*.gd and the UI parts of main.gd and fails on a Color( literal, Color.<NAMED> other than TRANSPARENT, numeric font-size override, numeric add_theme_constant_override, or a Vector2( 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_sweep to 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, missing queue_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 Color theme override still equals a pre-change value.
  • Layout live test: perturb space.unit and assert bound containers’ separations/margins changed.
  • Store tests: reference resolution, cycle detection, platform-variant precedence, reset, schema completeness, façade ↔ defaults.json parity, ramp generation reproduces the seeds.
  • Coverage gap to close first: ui_sweep today 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-qa at 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.tres after.
  • Performance: many closures re-running on change is fine (change is rare), but bindings must not re-run per frame. HUD _draw reading 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.