Skip to content

Bullet Heaven — UI tokens (live-tweakable styling)

UI tokens — colours, type, spacing (live-tweakable)

Section titled “UI tokens — colours, type, spacing (live-tweakable)”

This is the current source of truth for how UI styling values reach the screen. Every UI colour, font size and theme constant (separation, margins, outline, radius, border, panel alpha) is a live token: a named value that can be changed while the game runs, and every bound property follows it without a rebuild or a hand-written _restyle(). Positions/sizes/anchors are NOT tokens yet (Phase 2, layout). Design: docs/superpowers/specs/2026-10-04-live-ui-tokens-design.md. The colour families’ rationale, the gameplay identity palettes and the output-conditioning layers are consolidated in docs/architecture/colour-system.md (this file stays the TokenBind data-flow truth).

ui/tokens/defaults.json ──lazy load──▶ LiveTokens (global LiveTokensStore)
│ changed(keys) via LiveTokensBus
▼
TokenBind registry (weakref owner + apply Callable, coalesced per frame)
│
┌────────────────────────┼─────────────────────────┐
▼ ▼ ▼
NeonTheme (Theme repopulated per-node bindings _draw() nodes
in place: every Control (theme_color / prop / (TokenBind.redraw →
holding it restyles) stylebox / font_size…) queue_redraw, read inline)
  • addons/live_tokens/ is game-agnostic (reusable for the next Godot game): LiveTokensStore (grammar, resolution, overrides, platform variants), the static LiveTokens façade over the game’s one store, LiveTokensBus (the changed signal + binding registry), LtColor (OKLCH ramps) and TokenBind. It must never reference a Dark Cosmos class; a test enforces this.
  • The addon lazy-loads the defaults on its first read from the ProjectSettings key live_tokens/defaults_path (project.godot [live_tokens]). Game code never calls Tokens.ensure_loaded() / LiveTokens.load_defaults_file() itself.
  • ui/tokens.gd (Tokens) is the game façade: Tokens.c(&"role", alpha := -1) colour, Tokens.n() number, Tokens.i() int, Tokens.space(steps) = roundi(steps × space.unit). It also picks the platform variant (tv / touch / default) after every store load. TOUCH_SCALE / ui_scale() (whole-view zoom) are plain consts, not tokens. There are no legacy colour/font/space consts any more (removed 2026-10-04); keys are StringNames, and typo safety comes from tests/test_token_keys_exist.gd (every dotted &"..." literal in main.gd, ui/, fx/ and tools/ must exist in defaults.json).
  • ui/theme/neon_theme.gd builds the one code-built Theme from tokens and binds _populate to it, so the Theme is refilled in place on every token change.
  1. Seeds (seed.accent, seed.gold, seed.neutral, …): a handful of primitive colours.
  2. Roles (text.body, text.muted, accent.main, gold.main, danger.main, surface.void, bar.hp, …): what UI code uses. Mostly OKLCH ramp steps of a seed ("@accent.150 c1.1"), so moving one seed retunes every role derived from it. Roles never appear as raw hex in code.
  3. Numbers: font.2xs…font.7xl (driven by type.text, see Text size below), space.unit, outline.sm…2xl, radius.panel/plate, border.thin/thick, panel.fill_a/fill_hover_a/fill_pressed_a, glow.size. The one platform variant today is font.sm: {"default": "14*@type.scale", "tv": "15*@type.scale"}, holding the 15px TV text floor (rift threat lines, captions). Level-up cards (1.1 big cards, 2026-10-16) use their own font.levelup.name/line/kind/title (24/20/17/20) on a strip that fills the screen below the header (LevelUpPanel.full_strip_h; all layout numbers are LevelUpPanel.* console tunables); opening Ship stats collapses the cards to one title line so the sheet takes the height. The strip-fit tests run at every ShopLayoutFixture profile WITH its safe insets forced. Screen-specific sizes that sit between ramp steps are named tokens at their own value (font.pause.title/font.pause.time and their _legacy pair, font.shop.name), so they are live-tunable without snapping a tuned layout to the ramp.

Key names always contain a dot; Godot theme item names (&"font_color") never do. The value grammar (ramps, a/c/x mods, @key refs, products, platform variants) and the console contract are in addons/live_tokens/PROTOCOL.md.

For each colour, 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.

Font sizes use Tokens.i(&"font.<step>") / TokenBind.theme_font_size(...); theme constants use TokenBind.theme_constant(box, &"separation", func() -> int: return Tokens.space(2)) (n = px / 4); outline_size uses outline.*. tools/live_tokens/nearest_role.gd -- res://<file> lists a file’s literals with the nearest role and its ΔE.

Rules learned in the migration (binding):

  • # content-colour covers gameplay attribute scales, never chrome: rarity, dimension danger tiers, element / weapon / drone-class colours, sprite identity tints, world FX. Panel frames, text, buttons, bars and scrims are chrome and always use roles.
  • Choosing between candidates: when two roles are both under ΔE 0.06, the role’s meaning decides (a muted caption is text.muted even if text.dim is numerically closer), not the smaller ΔE.
  • New role only when the nearest role is over ΔE 0.06 and a seed ramp step lands within 0.03; otherwise use the nearest role and leave a comment on the line noting the ΔE of the merge.
  • Animated nodes own their tweens (create_tween() on the node, so it dies with it), or the bound lambda captures the node through a weakref. A lambda that strongly captures a node or a RefCounted (StyleBox, Theme) keeps it alive in the binding registry forever.
  • The registry is released when the SceneTree root leaves the tree (LiveTokensBus.release_bindings, hooked on the first bind). It is a static, so without that its Callables were destroyed during GDScript’s static teardown after other scripts were gone, and every quit aborted with recursive_mutex lock failed (exit 134, swallowed by the gate’s || true). tests/test_live_tokens_shutdown.gd.
  • GlassPanel.veil_color() must be bound live (a Callable source or read in _draw), never captured into a member or const: it is Tokens.c(&"surface.void") and changes with the palette.
  • FX code (fx/fx_manager.gd) may read tokens for UI-semantic FX (the LEVEL UP ring is accent.main): read the token once per event/frame into a local, never per particle.

HDR Display P3: the output filter (Chris 2026-10-12)

Section titled “HDR Display P3: the output filter (Chris 2026-10-12)”

With HDR output on a P3-capable engine, the UI widens toward the Display P3 edge (more saturated reds, magentas, oranges, golds; the cyan accent gains only ~7% chroma, P3 barely extends past sRGB there). SDR, 8-bit, web and stock-engine builds are bit-exact sRGB. Architecture and the “UI is not graded” evidence: docs/architecture/rendering-and-performance.md § Display P3 output.

  • ONE sanctioned path: ColorGamut.ui_out(c, path := PATH_A) (render/color_gamut.gd). While ColorGamut.active() and ColorProfileHdr.GAMUT_UI_WIDEN > 0 it is installed as the LiveTokens output filter (LiveTokens.set_output_filter, game-agnostic in the addon): every public colour read (Tokens.c, LiveTokens.color/value, TokenBind key sources, NeonTheme) returns the widened value, cached per key. Installing/removing it emits changed for every key, so the Theme, GlassPanels, bound controls and TokenBind.redraw nodes restyle live on an HDR flip or a dial change.
  • Plain reads need nothing. Alpha-only changes (Tokens.c(k, a), Color(c, a)) are safe on a widened value: alpha passes through.
  • Derived colours mix in sRGB, then widen once. A widened value is encoded with negative channels on the 12.92 segment, so a lerp / lightened / darkened / multiply on it shifts lightness and hue (measured ΔE up to 0.13 for magenta→white, more than the widening itself). Read the sources with Tokens.c_src(k, a) (never filtered), mix, then pass the result through ColorGamut.ui_out: ColorGamut.ui_out(Tokens.c_src(&"gold.main").lerp(Tokens.c_src(&"text.bright"), 0.3)). NeonTheme.nova_box(accent) and draw_nova_hex_slot(col) take an sRGB source and widen inside. WeaponGlyph.hue() returns the sRGB source; the glyph widens in _draw.
  • Content colours (# content-colour: weapon/drone/element hues, dimension tints, speaker tints, boss tint, face-button hues) are sRGB literals: wrap them in ColorGamut.ui_out where they reach a draw/override. Ones set inside _draw of a TokenBind.redraw node, or bound with a callable (TokenBind.theme_color(lbl, &"font_color", func() -> Color: return ColorGamut.ui_out(hue)), the weapon-panel Aim stat), follow a flip live; ones set once at build (dimension cards, stats-sheet element rows, Codex names, region title) update on the next build/open, which is safe only because those screens rebuild on every open.
  • A colour that changes every frame (an HP fraction, a tween, a pulse) snaps its mix amount to 1/32 while ColorGamut.ui_active() (and stays unsnapped off, so SDR is unchanged), so ui_out’s cache holds it: a cache miss is a full OKLCH widen, ~42 us on an M5 Mac. HUD HP band, drone-panel low-HP pulse, level-up STATS pulse.
  • Encode for the path the colour actually takes. A token read is PATH_A (canvas colours, theme overrides, source_color uniforms). A uniform the shader runs through cs_in() is PATH_B: push ColorGamut.ui_out(Tokens.c_src(k), HdrTier.PATH_B) (GlassPanel.veil_shader_color() for glass_snapshot’s veil_color).
  • World-canvas FX that read a token (the LEVEL UP ring and label in fx_manager.gd) are gameplay, not UI: ColorGamut.out(Tokens.c_src(k), path), so they follow GAMUT_WIDEN, not GAMUT_UI_WIDEN.
  • Never widen twice: never pass a Tokens.c read (or anything derived from one) into ui_out.
  • Guards (tests/test_color_gamut.gd): ui/ never calls ColorGamut.widen/out; a file that reads Tokens.c_src also calls ui_out (or hands it straight to nova_box); only render/color_gamut.gd installs a token filter. tests/test_ui_gamut.gd also bans a mix (lerp/lightened/darkened/ blend/_from_identity) on a direct Tokens.c( read in ui/ and main.gd (a same-line regex: a value captured in a var first and mixed later is not caught; review it). Behaviour: tests/test_ui_gamut.gd, tests/test_live_tokens_filter.gd.
  • Not widened (left on purpose): neutrals (white/grey text, OKLCH chroma < 1e-4, never move; pale tinted text like text.body moves ~8% in chroma at constant lightness, so contrast holds); shader literals in UI shaders (glass_clear’s dark plate vec3(0.03, 0.04, 0.07), the threat-radar aggregate glow_color); the console’s #rrggbb web colours.

tests/test_ui_literal_ratchet.gd scans main.gd + every ui/**/*.gd (except ui/tokens.gd) with UiLiteralScan (tools/live_tokens/literal_scan.gd) for colour literals, numeric font sizes and numeric theme constants, and compares the counts with tests/ui_literal_allowlist.json. The allowlist is now {}: any new literal fails the gate. A line containing # content-colour: <reason> (or a comment line) is not counted — that is the sanctioned escape for gameplay content (above). One narrower category covers chrome: artwork-bound colours, which must equal a baked image’s pixels or an engine project setting, so a palette edit would open a visible seam (the launch cover ui/boot_splash.gd: its black is the splash PNG’s black and boot_splash/bg_color, its loader cyan is the baked wordmark’s). Mark those # artwork-colour: <reason>; the scanner honours that marker only in UiLiteralScan.ARTWORK_FILES (pinned by tests/test_ui_literal_scan.gd), so adding a file there is a reviewed decision, never a habit. After a deliberate decrease, regenerate with godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd (it refuses to raise a count unless passed -- --allow-raise; don’t).

Font-size forms counted (Text size, #79). Besides add_theme_font_size_override(k, 14), the font count includes a literal at the start of a derived expression (int(round((48 if touch else 64) * _s))) and an integer const/static var whose name contains FONT (NAME_FONT_MIN := 15): sizes like these bypass the font tokens and so never follow the player’s Text size. A design size whose every use goes through Tokens.text_px() is exempt with # text-px: <why> on its line. The allowlist is {} again (pinned by test_the_allowlist_is_empty_again): every size either is a font.* token or goes through Tokens.text_px. Never mark a line # text-px: that does not.

Settings has a Text size row: Default / Large / Larger (TextSize.NAMES), saved as MetaState.text_size, applied at boot and live from Settings (main calls TextSize.apply).

  • User layer. addons/live_tokens has a third value tier: defaults < user < overrides. The user layer holds exactly one key, type.user (the chosen scale), set by Tokens.set_text_scale. It is never an override, so the console’s reset/export never touch it. A console edit or token patch that sets type.user / type.text is clamped by a generic per-key floor (LiveTokens.set_floor, configured in Tokens.ensure_loaded: type.user >= 1.0, type.text >= type.scale), so text never drops below Default.
  • type.text = type.scale (the design dial) x type.user (the player’s choice). Every font.* token is N*@type.text, so every bound font follows the choice on the next frame. Spacing does not scale.
  • Steps are console tunables, not consts: TextSize.SCALE_LARGE (1.15) and SCALE_LARGER (1.3) (registry + tools/tunable_docs/docs.d label + LiveScope hook that re-applies the step). The accessibility guarantee is structural: TextSize.scale_for clamps every non-Default step to >= 1.0 (and Larger to >= Large), so no tuned value can shrink a player’s chosen text below Default.
  • Tokens.text_px(px) scales a design size that lives in code (a console-tunable static, a fit floor) like the font tokens. Fit floors must scale too, or a fitter can shrink text back below Default.
  • Fit rule for new UI. A new panel gets a fit test at Large and Larger on every ShopLayoutFixture profile using tests/text_size_fixture.gd (TextSizeFixture): rule 1 nothing outside the safe area or clipped, rule 2 the text really grew on screen (a panel’s own fit-scale must not cancel it). Restore Default in after_each (TextSizeFixture.restore()); the user layer is global. No text is dropped at a larger size: wrap, reflow or scroll (a pad is always present on tvOS).
  • Looking at it. scripts/dev/ui-capture-store.sh --profile iphone|iphone_se|ipad|tv --out DIR captures the store view (no Dev chrome) with each device booted under its own insets and token platform, so the HUD weapon dock and drone dock sit where the device puts them; every PNG carries a sidecar saying which build, device and view it is. For a text size or another scene list drive tools/ui_capture/capture.tscn yourself: UI_CAP_STORE=1 UI_CAP_TEXT_SIZE=0|1|2 UI_CAP_SIZES=iphone,ipad,tv. The text size is applied after boot, so anything that lays itself out once at boot stays at Default in a capture: read the fit tests for those. Usage, the sidecar fields and what a capture still cannot show: docs/reference/fidelity-ui-capture.md.
  • RebindProbe.unbound(root) (tests/rebind_probe.gd): flips the store into probe mode (every token colour changes per generation), snapshots every Color property under root twice, and returns the paths whose colour did not change — i.e. literals or frozen copies. It skips what can’t show a colour: zero-width borders, draw_center = false fills and zero-size shadows. Each migrated screen’s test asserts RebindProbe.unbound(panel).size() == 0; for _draw-only nodes assert that TokenBind.binding_count() grew after _ready. It walks the whole panel tree, so a child added on a branch fails the PARENT screen’s test until that child’s colours go through TokenBind — and a frozen Tokens.c() copy captured at build time counts as unbound, not as a token read. (2026-10-04.)
  • zsh scripts/ui-probe.sh (windowed, foreground): captures all ui_sweep screens under two probe palettes, then tools/live_tokens/probe_diff.gd prints per-screen unbound % (pixels identical in both and not near-black) and writes red masks to tools/ui_sweep/out/probe/*_probe_mask.png. Target < 2% on menu screens; hud_in_run is mostly world pixels and is informational.
  • zsh scripts/ui-sweep.sh is the visual regression check (RMSE vs blessed baselines); never --bless without Chris’s sign-off on the side-by-sides.
  1. Add the key to ui/tokens/defaults.json, preferably as a ramp of an existing seed (the real "danger.soft": "@red.260 c0.9") or an alpha/ref of an existing role, not raw hex.
  2. Add its target colour + tolerance to tests/fixtures/ui_role_targets.json (["#rrggbb", 0.03]) in the same commit; tests/test_tokens_defaults.gd checks every role resolves within tolerance.
  3. Use it as Tokens.c(&"danger.soft"); test_token_keys_exist.gd catches typos.

Roles added during the Phase 1 migration, as examples of the pattern: magenta.soft, violet.deep, blue.bright, blue.deep, blue.soft, danger.hot, danger.soft, warn.deep, success.deep, gold.plate, surface.track, surface.edge, surface.edge_dim.

tests/test_tokens.gd (façade API, font ramp monotonic, space = steps × unit, NeonTheme built from tokens), test_tokens_defaults.gd, test_token_keys_exist.gd, test_live_tokens_*.gd (grammar, apply, isolation of the addon, lazy load), test_token_bind.gd, test_rebind_probe.gd, test_ui_literal_scan.gd, test_ui_literal_ratchet.gd. Tests of the grammar build a local LiveTokensStore; never put fake defaults into the global LiveTokens store (live NeonTheme bindings would re-run against missing keys).

Colour-blind roles (HP bands, stats sheet)

Section titled “Colour-blind roles (HP bands, stats sheet)”

Under a colour-blind preset the HP bar bands (HP_HI/MID/LO) and the stats-sheet roles UI_GOOD / UI_EVOLVE come from CvdPalette.ui_srgb(role, fallback); the tokens (accent.main, gold.main, danger.hot, success.main) are untouched and remain the Off values. In the stats sheet _role() returns a drawn Color (content_colour meta) under a preset and the token name otherwise. Changed rows and the legend carry a marker (▸, or > when the font lacks it) for EVERYONE, so hue is never the only carrier. See “Colour-blind mode” in rendering-and-performance.md.