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).
Data flow
Section titled “Data flow”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 staticLiveTokensfaçade over the game’s one store,LiveTokensBus(thechangedsignal + binding registry),LtColor(OKLCH ramps) andTokenBind. 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 callsTokens.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 areStringNames, and typo safety comes fromtests/test_token_keys_exist.gd(every dotted&"..."literal inmain.gd,ui/,fx/andtools/must exist indefaults.json).ui/theme/neon_theme.gdbuilds the one code-built Theme from tokens and binds_populateto it, so the Theme is refilled in place on every token change.
Tiers: seeds → roles → numbers
Section titled “Tiers: seeds → roles → numbers”- Seeds (
seed.accent,seed.gold,seed.neutral, …): a handful of primitive colours. - 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. - Numbers:
font.2xs…font.7xl(driven bytype.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 isfont.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 ownfont.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 areLevelUpPanel.*console tunables); opening Ship stats collapses the cards to one title line so the sheet takes the height. The strip-fit tests run at everyShopLayoutFixtureprofile 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.timeand their_legacypair,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.
Binding rules (the migration recipe)
Section titled “Binding rules (the migration recipe)”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"). ForLabelSettings, 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(): readTokens.c(&"role")inline in_draw(hoist into locals at the top of_drawwhen used in a loop) and addTokenBind.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. constcolour / membervarinitialised once / dictionary or array of colours: delete it; replace uses withTokens.c(&"role")at the point of use (or astatic func _rarity_color(r) -> Colorthat returnsTokens.c(...)per case).- Tweens/pulses: tween a 0..1 float (
tween_method) and compute the colour from tokens in the setter each step — nevertween_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 toui/tokens/defaults.json(prefer a ramp ref of an existing seed) and its target totests/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-colourcovers 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.mutedeven iftext.dimis 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 aweakref. 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 withrecursive_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 isTokens.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 isaccent.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). WhileColorGamut.active()andColorProfileHdr.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 emitschangedfor every key, so the Theme, GlassPanels, bound controls andTokenBind.redrawnodes 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 withTokens.c_src(k, a)(never filtered), mix, then pass the result throughColorGamut.ui_out:ColorGamut.ui_out(Tokens.c_src(&"gold.main").lerp(Tokens.c_src(&"text.bright"), 0.3)).NeonTheme.nova_box(accent)anddraw_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 inColorGamut.ui_outwhere they reach a draw/override. Ones set inside_drawof aTokenBind.redrawnode, 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), soui_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_coloruniforms). A uniform the shader runs throughcs_in()is PATH_B: pushColorGamut.ui_out(Tokens.c_src(k), HdrTier.PATH_B)(GlassPanel.veil_shader_color()for glass_snapshot’sveil_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 followGAMUT_WIDEN, notGAMUT_UI_WIDEN. - Never widen twice: never pass a
Tokens.cread (or anything derived from one) intoui_out. - Guards (
tests/test_color_gamut.gd):ui/never callsColorGamut.widen/out; a file that readsTokens.c_srcalso callsui_out(or hands it straight tonova_box); onlyrender/color_gamut.gdinstalls a token filter.tests/test_ui_gamut.gdalso bans a mix (lerp/lightened/darkened/blend/_from_identity) on a directTokens.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.bodymoves ~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 aggregateglow_color); the console’s#rrggbbweb colours.
The literal ratchet
Section titled “The literal ratchet”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.
Text size (player option, #79)
Section titled “Text size (player option, #79)”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_tokenshas a third value tier: defaults < user < overrides. The user layer holds exactly one key,type.user(the chosen scale), set byTokens.set_text_scale. It is never an override, so the console’s reset/export never touch it. A console edit or token patch that setstype.user/type.textis clamped by a generic per-key floor (LiveTokens.set_floor, configured inTokens.ensure_loaded:type.user>= 1.0,type.text>=type.scale), so text never drops below Default. type.text=type.scale(the design dial) xtype.user(the player’s choice). Everyfont.*token isN*@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) andSCALE_LARGER(1.3) (registry +tools/tunable_docs/docs.dlabel + LiveScope hook that re-applies the step). The accessibility guarantee is structural:TextSize.scale_forclamps 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
ShopLayoutFixtureprofile usingtests/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 inafter_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 DIRcaptures 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 drivetools/ui_capture/capture.tscnyourself: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.
Probes: proving a screen follows tokens
Section titled “Probes: proving a screen follows tokens”RebindProbe.unbound(root)(tests/rebind_probe.gd): flips the store into probe mode (every token colour changes per generation), snapshots every Color property underroottwice, 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 = falsefills and zero-size shadows. Each migrated screen’s test assertsRebindProbe.unbound(panel).size() == 0; for_draw-only nodes assert thatTokenBind.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 throughTokenBind— and a frozenTokens.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, thentools/live_tokens/probe_diff.gdprints per-screen unbound % (pixels identical in both and not near-black) and writes red masks totools/ui_sweep/out/probe/*_probe_mask.png. Target < 2% on menu screens;hud_in_runis mostly world pixels and is informational.zsh scripts/ui-sweep.shis the visual regression check (RMSE vs blessed baselines); never--blesswithout Chris’s sign-off on the side-by-sides.
Adding a role
Section titled “Adding a role”- 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. - Add its target colour + tolerance to
tests/fixtures/ui_role_targets.json(["#rrggbb", 0.03]) in the same commit;tests/test_tokens_defaults.gdchecks every role resolves within tolerance. - Use it as
Tokens.c(&"danger.soft");test_token_keys_exist.gdcatches 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.