Text size accessibility option (implementation plan)
AMENDMENTS (Chris, 2026-10-20, accepted in chat) — these OVERRIDE any task text below that disagrees.
- Text-size steps are console tunables, not consts.
TextSize.SCALE_LARGE(1.15) andTextSize.SCALE_LARGER(1.3) are registeredstatic varLOCAL tunables (net/tunables_registry.gd+ atools/tunable_docs/docs.d/label + LiveScope hook that re-appliesTextSize.apply(TextSize.step)live). The accessibility guarantee is kept structurally instead:TextSize.scale_for(step)clamps every non-Default step to>= 1.0(and Larger to>= Large), with a test, so no value can ever shrink a player’s chosen text below Default.DEFAULT/NAMESstay const.- No level-up card text is ever dropped or truncated at Large/Larger. Task 10’s line-budget ladder steps that cap lines, drop the sentence or hide change lines are REMOVED (see the rewritten Task 10 Step 3). Every card keeps its full name, why-tag, sentence and every change line at every size.
Text size accessibility option — implementation plan
Section titled “Text size accessibility option — implementation plan”For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (and
bh-sdd-concurrentbefore Task 1) or superpowers:executing-plans to run this plan task by task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: a Settings “Text size” row (Default / Large / Larger = 1.0 / 1.15 / 1.3) that scales every UI font token live, is saved with the Settings prefs, and leaves every store screen fitting at Larger on the TV, iPhone and iPad profiles.
Spec: docs/superpowers/specs/2026-10-20-text-size-design.md (read it first: the fit policy, the
scope list and the measured overflow table drive Tasks 9-16). Issue: #79. Target: 1.2.
Architecture: a game-agnostic user layer in addons/live_tokens holds type.user; every
font.* token becomes N*@type.text where type.text = @type.scale*@type.user; TextSize (ui/)
maps the saved step to the scale; main applies it at boot and from Settings; panels whose sizes
bypass the tokens are migrated, and each in-scope screen gets a Larger fit + growth test.
Tech stack: Godot 4.7 typed GDScript (warnings as errors), GUT 9.7, scripts/gate.sh.
Conventions for every task
Section titled “Conventions for every task”- Work in the plan’s worktree (
.claude/worktrees/<name>offorigin/main), never the main checkout. Give a dispatched implementer that absolute path and do NOT also setisolation: "worktree". bh-implementerruns on Sonnet by default: dispatch WITHOUT amodelparam. Passmodel: "opus"only for tasks marked opus (cross-cutting). Ledger model + review rounds.- New test file:
extends GutTest, tabs,assert_lte/assert_gte(never_le/_ge); after creating it rungodot --headless --path . --importonce, thengodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<test_name> -gexit. Thengit checkout -- project.godot audio/bus_layout.tres. - Any test that changes the text size restores Default in
after_each(TextSizeFixture.reset()): the user layer is global, and a leaked Larger breaks every pixel-pinned test that runs after it. - Verify each task with
bash scripts/gate.sh(never pull a whole GUT run into context; on failure grep$TMPDIR/bh-gut-last.log, or setGUT_LOG=<own path>when gates run in parallel worktrees). - Commit with
git commit --file=<msgfile>; end the message with the session’s attribution lines. - Re-verify every OLD block below against the live file before editing (a concurrent session may have moved it). If it no longer matches, stop and report; do not improvise a different edit.
File map
Section titled “File map”| File | Change |
|---|---|
addons/live_tokens/live_tokens_store.gd, live_tokens.gd, PROTOCOL.md |
user layer |
ui/tokens/defaults.json |
type.user, type.text, font tokens on @type.text, font.menu.* |
ui/tokens.gd |
set_text_scale, text_scale, text_px |
ui/text_size.gd (new) |
TextSize steps / names / apply |
sim/meta_state.gd |
text_size pref |
main.gd |
boot apply, Settings wiring |
ui/settings_panel.gd |
the row, live refit |
tools/live_tokens/literal_scan.gd, tests/ui_literal_allowlist.json |
widened ratchet |
ui/start_menu.gd, ui/meta_shop_panel.gd, ui/level_up_panel.gd, ui/level_up_panel_legacy.gd, ui/button_prompt.gd, ui/pause_menu.gd, ui/results_panel.gd, ui/stats_sheet.gd, ui/hud.gd, ui/weapon_panel.gd, ui/control_hints.gd, ui/patch_toast.gd, ui/objective_panel.gd, ui/codex_card.gd, ui/codex_overlay.gd, ui/dialogue_box.gd, ui/dimension_choice_panel.gd, ui/dimension_chip.gd, ui/feedback_panel.gd, ui/ship_config_panel.gd, ui/drone_bay_panel.gd, ui/tutorial_skip_chip.gd |
migrations + fit |
tests/text_size_fixture.gd (new), tests/test_live_tokens_user_layer.gd (new), tests/test_text_size.gd (new), tests/test_text_size_fit_*.gd (new, one per task) |
tests |
tools/ui_capture/capture.gd |
UI_CAP_TEXT_SIZE |
docs/architecture/ui-tokens.md |
document the layer, type.text, the widened ratchet |
Task order: 1 → 2 → 3 → 4 → 5 → 6 are sequential. Tasks 7-16 each touch their own files and can run in any order (one merger at a time), but each depends on 1-6. Task 17 is last.
Task 1: User layer in the live-tokens addon — model: sonnet
Section titled “Task 1: User layer in the live-tokens addon — model: sonnet”Files:
-
Modify:
addons/live_tokens/live_tokens_store.gd,addons/live_tokens/live_tokens.gd,addons/live_tokens/PROTOCOL.md -
Test:
tests/test_live_tokens_user_layer.gd(new) -
Step 1: Write the failing test
tests/test_live_tokens_user_layer.gd:
extends GutTest
# The store's user layer (spec 2026-10-20-text-size-design.md): defaults < user < overrides. It holds a# player's own choice (Dark Cosmos: the Settings text size), so reset()/apply()/load_defaults() keep it# and export_patch() never contains it. A LOCAL store: never put fake defaults into the global one.
const DEFAULTS := { "type.scale": 1.0, "type.user": 1.0, "type.text": "@type.scale*@type.user", "font.base": "16*@type.text", "font.sm": {"default": "14*@type.text", "tv": "15*@type.text"},}
func _store() -> LiveTokensStore: var s := LiveTokensStore.new() s.load_defaults(DEFAULTS) return s
func test_a_user_value_scales_every_derived_token() -> void: var s := _store() assert_eq(s.int_val(&"font.base"), 16) assert_true(s.set_user_value(&"type.user", 1.3)) assert_eq(s.int_val(&"font.base"), 21, "16 x 1.3 = 20.8") s.set_platform("tv") assert_eq(s.int_val(&"font.sm"), 20, "the TV variant scales too: 15 x 1.3 = 19.5")
func test_an_override_multiplies_with_the_user_value_through_type_text() -> void: var s := _store() s.set_user_value(&"type.user", 1.3) s.apply({"type.scale": 1.1}) assert_almost_eq(s.num(&"type.text"), 1.43, 0.0001)
func test_an_override_of_the_same_key_beats_the_user_layer() -> void: var s := _store() s.set_user_value(&"type.user", 1.3) s.apply({"type.user": 1.0}) assert_almost_eq(s.num(&"type.user"), 1.0, 0.0001) s.reset() assert_almost_eq(s.num(&"type.user"), 1.3, 0.0001, "reset() drops the override, the user value is back")
func test_the_user_layer_survives_a_defaults_reload_but_not_reset_to_unloaded() -> void: var s := _store() s.set_user_value(&"type.user", 1.15) s.load_defaults(DEFAULTS) assert_almost_eq(s.num(&"type.user"), 1.15, 0.0001) s.reset_to_unloaded() assert_eq(s.user_values(), {})
func test_the_user_layer_is_never_exported() -> void: var s := _store() s.set_user_value(&"type.user", 1.3) assert_eq(s.export_patch(), {}, "nothing overridden") s.apply({"type.user": 1.15}, "tv") assert_eq(s.export_patch(), {"type.user": {"default": 1.0, "tv": 1.15}}, "a platform override exports over the FILE default, not the user value")
func test_a_change_emits_once_and_the_same_value_does_not() -> void: var s := _store() var n := [0] s.bus.changed.connect(func(_k: PackedStringArray) -> void: n[0] += 1) assert_true(s.set_user_value(&"type.user", 1.3)) assert_eq(n[0], 1) assert_true(s.set_user_value(&"type.user", 1.3)) assert_eq(n[0], 1, "same value: no restyle of every binding") s.clear_user_value(&"type.user") assert_eq(n[0], 2) assert_almost_eq(s.num(&"type.user"), 1.0, 0.0001) s.clear_user_value(&"type.user") assert_eq(n[0], 2, "clearing an unset key is a no-op")
func test_unknown_keys_and_wrong_types_are_refused_silently() -> void: var s := _store() assert_false(s.set_user_value(&"nope.key", 1.0)) assert_false(s.set_user_value(&"type.user", "#ff0000")) assert_eq(s.user_values(), {})-
Step 2: Run it, confirm it fails (
set_user_valuedoes not exist; the file fails to parse, so checkscripts/check-test-count.sh/ the-gselectoutput for the parse error). -
Step 3: Implement. In
addons/live_tokens/live_tokens_store.gd:
OLD:
var _overrides: Dictionary = {"": {}} # platform ("" = all) -> {key: raw}NEW:
var _overrides: Dictionary = {"": {}} # platform ("" = all) -> {key: raw}# The user layer (key -> raw): a player's own setting, between the defaults file and the overrides.# reset() / apply() / load_defaults() keep it, export_patch() never contains it; only# reset_to_unloaded() (tests) clears it. Dark Cosmos: the Settings text size on type.user.var _user: Dictionary = {}OLD (in reset_to_unloaded):
func reset_to_unloaded() -> void: _defaults = {} _overrides = {"": {}}NEW:
func reset_to_unloaded() -> void: _defaults = {} _overrides = {"": {}} _user = {}OLD:
func _raw(k: String) -> Variant: var entry := _effective_entry(k) return entry[_platform] if _platform != "" and entry.has(_platform) else entry.get("default")
# The one place layering is decided, shared by _raw (runtime) and export_patch (so an exported patch# reloads to exactly what the live store showed): defaults-file entry, then a global ("") override# replaces it WHOLE (shadowing the file's platform variants, as "set for all platforms" means), then# each platform override sets its own variant.func _effective_entry(k: String) -> Dictionary: var d: Variant = _defaults.get(k) var entry: Dictionary = (d as Dictionary).duplicate() if typeof(d) == TYPE_DICTIONARY else {"default": d} var go: Dictionary = _overrides[""]NEW:
func _raw(k: String) -> Variant: var entry := _effective_entry(k) return entry[_platform] if _platform != "" and entry.has(_platform) else entry.get("default")
# The one place layering is decided, shared by _raw (runtime) and export_patch (so an exported patch# reloads to exactly what the live store showed): defaults-file entry, then the user layer replaces it# WHOLE (runtime only: export_patch passes with_user = false, a player's choice is never published),# then a global ("") override replaces it WHOLE (shadowing the file's platform variants, as "set for# all platforms" means), then each platform override sets its own variant.func _effective_entry(k: String, with_user := true) -> Dictionary: var d: Variant = _defaults.get(k) var entry: Dictionary = (d as Dictionary).duplicate() if typeof(d) == TYPE_DICTIONARY else {"default": d} if with_user and _user.has(k): entry = {"default": _user[k]} var go: Dictionary = _overrides[""]OLD (in export_patch):
for k: String in touched.keys(): var entry := _effective_entry(k)NEW:
for k: String in touched.keys(): var entry := _effective_entry(k, false)Add after export_patch():
# The user layer. False (nothing changed) for an unknown key or a value whose type differs from the# key's default; never pushes an error (a settings path must not fail a run). Same value = no emit,# so re-applying a saved setting at boot does not restyle every binding.func set_user_value(key: StringName, raw: Variant) -> bool: var k := String(key) if not has(key): return false var errs: Array = [] var v: Variant = _resolve(raw, errs) if not errs.is_empty() or typeof(v) != _type_of_default(k): return false if _user.has(k) and typeof(_user[k]) == typeof(raw) and _user[k] == raw: return true _user[k] = raw _invalidate(keys()) # derived keys (every font.* on type.text) change too return true
func clear_user_value(key: StringName) -> void: if _user.erase(String(key)): _invalidate(keys())
func user_values() -> Dictionary: return _user.duplicate(true)In addons/live_tokens/live_tokens.gd, after the export_patch line add:
static func set_user_value(key: StringName, raw: Variant) -> bool: return store.set_user_value(key, raw)static func clear_user_value(key: StringName) -> void: store.clear_user_value(key)static func user_values() -> Dictionary: return store.user_values()In addons/live_tokens/PROTOCOL.md, add a short “User layer” paragraph next to the export
description: layering defaults < user < overrides; kept by reset/apply/load; never exported; a console
should show it read-only (it is the player’s).
- Step 4: Run the test (
-gselect=test_live_tokens_user_layer), then the existing addon teststest_live_tokens_apply,_resolve,_filter,_lazy_load,_shutdownand_isolation(the addon must name no game class). - Step 5: Gate:
bash scripts/gate.sh. - Step 6: Commit:
live_tokens: user layer between defaults and overrides (text size, #79).
Task 2: type.user / type.text tokens and the Tokens façade — model: sonnet
Section titled “Task 2: type.user / type.text tokens and the Tokens façade — model: sonnet”Files:
-
Modify:
ui/tokens/defaults.json,ui/tokens.gd,tests/test_tokens.gd -
Step 1: Failing tests, appended to
tests/test_tokens.gd:
func after_each() -> void: Tokens.set_text_scale(1.0) TokenBind.flush_now()
# Text size (spec 2026-10-20): Default must resolve every font token to exactly today's integer.func test_default_text_scale_keeps_every_font_size() -> void: assert_eq(Tokens.text_scale(), 1.0) var want := {&"font.2xs": 10, &"font.xs": 12, &"font.sm": 14, &"font.base": 16, &"font.lg": 18, &"font.xl": 20, &"font.2xl": 24, &"font.3xl": 30, &"font.4xl": 40, &"font.5xl": 44, &"font.6xl": 64, &"font.7xl": 120, &"font.pause.title": 36, &"font.pause.title_legacy": 54, &"font.pause.time": 26, &"font.pause.time_legacy": 30, &"font.shop.name": 17, &"font.levelup.name": 24, &"font.levelup.line": 20, &"font.levelup.kind": 17, &"font.levelup.title": 20} for k: StringName in want: assert_eq(Tokens.i(k), int(want[k]), String(k))
func test_text_scale_multiplies_every_font_token() -> void: var base := {} for k: String in LiveTokens.keys(): if k.begins_with("font."): base[k] = Tokens.n(StringName(k)) Tokens.set_text_scale(1.3) assert_almost_eq(Tokens.text_scale(), 1.3, 0.0001) for k: String in base: assert_almost_eq(Tokens.n(StringName(k)), float(base[k]) * 1.3, 0.001, "%s reads type.text" % k) assert_eq(Tokens.i(&"font.base"), 21) assert_eq(Tokens.text_px(15), 20, "a design px number in code scales the same way (19.5 rounds up)")
func test_text_scale_is_a_user_value_not_an_override() -> void: Tokens.set_text_scale(1.15) assert_false((LiveTokens.overrides()[""] as Dictionary).has("type.user"), "never an override") assert_false(LiveTokens.export_patch().has("type.user"), "never exported") Tokens.set_text_scale(1.0) assert_eq(LiveTokens.user_values(), {}, "Default clears the layer")-
Step 2: Run
-gselect=test_tokens: the last two fail (set_text_scalemissing). -
Step 3:
ui/tokens/defaults.json. OLD:
"type.scale": 1.0, "font.2xs": "10*@type.scale",NEW:
"type.scale": 1.0, "type.user": 1.0, "type.text": "@type.scale*@type.user", "font.2xs": "10*@type.text",Then change every remaining *@type.scale" in the font.* lines (lines font.xs … font.levelup.title,
including both halves of the font.sm variant) to *@type.text". Check:
grep -c '@type.scale' ui/tokens/defaults.json prints 1 (only type.text itself) and
grep -c '@type.text' ui/tokens/defaults.json prints 22.
- Step 4:
ui/tokens.gd. OLD:
# 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"))NEW:
# 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"))
# ---- Player text size (Settings "Text size": ui/text_size.gd) --------------------------------------# Every font.* token is N*@type.text, type.text = type.scale (the design dial) × type.user (the# player's choice). type.user lives ONLY in the store's user layer, never as an override, so the# console's reset/export never touch it. Spacing does not scale: only text.static func set_text_scale(f: float) -> void: ensure_loaded() if is_equal_approx(f, 1.0): LiveTokens.clear_user_value(&"type.user") else: LiveTokens.set_user_value(&"type.user", f)
static func text_scale() -> float: return n(&"type.user")
# A design px size that lives in code, not in a token (a console-tunable static such as# LevelUpPanel.ROLE_FONT, a fit floor), scaled exactly like the font tokens.static func text_px(px: float) -> int: return roundi(px * n(&"type.text"))- Step 5: Run
-gselect=test_tokens,-gselect=test_tokens_defaults,-gselect=test_token_keys_exist: all green. - Step 6: Gate (the whole suite must be unchanged at Default).
- Step 7: Commit:
tokens: type.text = type.scale x type.user drives every font token (#79).
Task 3: TextSize, the saved pref and boot apply — model: sonnet
Section titled “Task 3: TextSize, the saved pref and boot apply — model: sonnet”Files:
-
Create:
ui/text_size.gd -
Modify:
sim/meta_state.gd,main.gd -
Test:
tests/test_text_size.gd(new) -
Step 1: Failing test
tests/test_text_size.gd:
extends GutTest
# Settings "Text size" (spec 2026-10-20-text-size-design.md): the steps, the saved pref, boot apply.
func after_each() -> void: TextSize.apply(TextSize.DEFAULT) TokenBind.flush_now() await get_tree().process_frame
func test_steps_names_and_labels() -> void: assert_eq([TextSize.scale_for(0), TextSize.scale_for(1), TextSize.scale_for(2)], [1.0, 1.15, 1.3]) TextSize.SCALE_LARGE = 0.8 TextSize.SCALE_LARGER = 0.5 assert_eq(TextSize.scale_for(1), 1.0, "a tunable can never shrink Large below Default") assert_eq(TextSize.scale_for(2), 1.0, "Larger is never below Large") TextSize.SCALE_LARGE = 1.15 TextSize.SCALE_LARGER = 1.3 assert_eq(TextSize.label(0), "Text size: Default") assert_eq(TextSize.label(1), "Text size: Large") assert_eq(TextSize.label(2), "Text size: Larger") assert_eq(TextSize.NAMES.size(), MetaState.TEXT_SIZE_STEPS, "the save's range matches the steps")
func test_apply_sets_the_token_scale_and_clamps() -> void: TextSize.apply(2) assert_eq(TextSize.step, 2) assert_almost_eq(Tokens.text_scale(), 1.3, 0.0001) TextSize.apply(9) assert_eq(TextSize.step, 2, "clamped") TextSize.apply(-1) assert_eq(TextSize.step, 0) assert_eq(Tokens.text_scale(), 1.0)
func test_fresh_and_older_saves_are_default() -> void: assert_eq(MetaState.new().text_size, 0) var m := MetaState.new() m.from_dict({"schema": MetaState.SCHEMA_VERSION, "banked_gold": 10, "levels": {}}) assert_eq(m.text_size, 0)
func test_text_size_survives_a_save_load_round_trip() -> void: var m := MetaState.new() m.text_size = 2 var m2 := MetaState.new() m2.from_dict(JSON.parse_string(JSON.stringify(m.to_dict())) as Dictionary) assert_eq(m2.text_size, 2)
func test_a_corrupt_text_size_is_default_not_trusted() -> void: var m := MetaState.new() m.from_dict({"schema": MetaState.SCHEMA_VERSION, "text_size": 7}) assert_eq(m.text_size, 0, "out of range") m.from_dict({"schema": MetaState.SCHEMA_VERSION, "text_size": "huge"}) assert_eq(m.text_size, 0, "not a number")
func test_boot_applies_the_saved_text_size() -> void: TextSize.apply(2) # a stale static from an earlier run var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame assert_eq(TextSize.step, 0, "fresh save: boot applied meta.text_size over the stale static") assert_eq(Tokens.text_scale(), 1.0)-
Step 2: Run
-gselect=test_text_sizeafter--import: fails (noTextSize). -
Step 3: Create
ui/text_size.gd:
class_name TextSizeextends RefCounted
# The player's "Text size" setting (Settings panel; spec docs/superpowers/specs/2026-10-20-text-size-design.md).# Render-only: main copies MetaState.text_size in at boot and on every change. The scales are# live console tunables (Chris, 2026-10-20) with a structural floor: scale_for() never returns less than# Default (1.0), and Larger never less than Large, so no tunable or patch can shrink the player's choice.
const DEFAULT := 0const NAMES := ["Default", "Large", "Larger"]static var SCALE_LARGE: float = 1.15 # tunable (local): text scale at "Large"static var SCALE_LARGER: float = 1.3 # tunable (local): text scale at "Larger"
static var step := DEFAULT
static func clamp_step(s: int) -> int: return clampi(s, 0, NAMES.size() - 1)
static func scale_for(s: int) -> float: var large := maxf(SCALE_LARGE, 1.0) match clamp_step(s): 1: return large 2: return maxf(SCALE_LARGER, large) return 1.0
static func apply(s: int) -> void: step = clamp_step(s) Tokens.set_text_scale(scale_for(step))
static func label(s: int) -> String: return "Text size: %s" % String(NAMES[clamp_step(s)])- Step 4:
sim/meta_state.gd. OLD:
var reduce_fx: bool = false # mirrors ComfortFx.reduceNEW:
var reduce_fx: bool = false # mirrors ComfortFx.reduce# Settings "Text size" step (0 Default, 1 Large, 2 Larger; ui/text_size.gd TextSize.scale_for). Render only.const TEXT_SIZE_STEPS := 3var text_size: int = 0OLD (in to_dict):
"music_volume": music_volume, "sfx_volume": sfx_volume, "reduce_fx": reduce_fx, "drone_nudge": drone_nudge, "fullscreen": fullscreen,NEW:
"music_volume": music_volume, "sfx_volume": sfx_volume, "reduce_fx": reduce_fx, "drone_nudge": drone_nudge, "fullscreen": fullscreen, "text_size": text_size,OLD (in from_dict):
reduce_fx = bool(d.get("reduce_fx", false))NEW:
reduce_fx = bool(d.get("reduce_fx", false)) text_size = _sane_int(d.get("text_size", 0), 0, 0, TEXT_SIZE_STEPS - 1) # older saves: Default- Step 5:
main.gdboot. OLD:
ComfortFx.reduce = meta.reduce_fx # Settings "Reduce shake & flashes"NEW:
ComfortFx.reduce = meta.reduce_fx # Settings "Reduce shake & flashes" TextSize.apply(meta.text_size) # Settings "Text size" (every font token, live)- Step 6: Run
-gselect=test_text_sizeand-gselect=test_settings_panel. - Step 7: Gate. Step 8: Commit:
text size: TextSize steps, MetaState pref, boot apply (#79).
Task 4: TextSizeFixture, the shared fit + growth helper — model: sonnet
Section titled “Task 4: TextSizeFixture, the shared fit + growth helper — model: sonnet”Files:
-
Create:
tests/text_size_fixture.gd(helper, notest_prefix),tests/test_text_size_fixture.gd -
Step 1: Failing test
tests/test_text_size_fixture.gd:
extends GutTest
# Pins the helper every text-size fit test uses (Tasks 5-16).
func after_each() -> void: TextSizeFixture.restore() await get_tree().process_frame
func _label(text: String, key: StringName) -> Label: var l := Label.new() l.text = text TokenBind.theme_font_size(l, &"font_size", key) return l
func test_font_map_tracks_growth_and_a_frozen_size_is_reported() -> void: var root := VBoxContainer.new() add_child_autofree(root) root.add_child(_label("bound", &"font.base")) var frozen := Label.new() frozen.text = "frozen" frozen.add_theme_font_size_override(&"font_size", Tokens.i(&"font.base")) # a build-time read root.add_child(frozen) await get_tree().process_frame var before := TextSizeFixture.font_map(root) TextSizeFixture.set_step(TextSizeFixture.LARGER) await get_tree().process_frame var after := TextSizeFixture.font_map(root) var bad := TextSizeFixture.shrunk(before, after, TextSizeFixture.GROWTH_LARGER) assert_eq(bad.size(), 1, str(bad)) assert_string_contains(bad[0], "frozen")
func test_effective_px_includes_the_canvas_scale() -> void: var c := Control.new() add_child_autofree(c) var l := _label("x", &"font.base") c.add_child(l) c.scale = Vector2(0.5, 0.5) await get_tree().process_frame assert_almost_eq(TextSizeFixture.effective_px(l), 8.0, 0.01, "16 px drawn at half scale")
func test_outside_or_clipped_reports_a_label_off_the_safe_rect() -> void: var sv := TextSizeFixture.use_profile(self, "iphone") var l := _label("off screen", &"font.base") l.position = Vector2(-50, 10) sv.add_child(l) await get_tree().process_frame var bad := TextSizeFixture.outside_or_clipped(sv, TextSizeFixture.safe_rect("iphone")) assert_eq(bad.size(), 1, str(bad))-
Step 2: Run, confirm the parse failure (no
TextSizeFixture). -
Step 3: Create
tests/text_size_fixture.gd:
class_name TextSizeFixtureextends RefCounted
# Text size fit tests (spec 2026-10-20-text-size-design.md, "Fit policy"). Not a test script.# The user layer is GLOBAL: a test that calls set_step() calls restore() in after_each.# rule 1: outside_or_clipped(root, safe_rect(dev)) is empty at Larger# rule 2: shrunk(font_map at Default, font_map at Larger, GROWTH_LARGER) is empty: the text really# grew on screen, a panel's own fit-scale (or fit_font_size) did not cancel it# Profiles: ShopLayoutFixture (TV 1280 with the 5% title-safe band, iPhone 1204, iPad 985).
const LARGE := 1const LARGER := 2const GROWTH_LARGE := 1.1const GROWTH_LARGER := 1.2const DEVICES: Array[String] = ["tv", "iphone", "ipad"]
static func set_step(step: int) -> void: TextSize.apply(step) TokenBind.flush_now()
static func min_growth(step: int) -> float: return GROWTH_LARGER if step == LARGER else GROWTH_LARGE
# Forces the profile's touch flag, screen scale and safe insets and returns a SubViewport of its canvas# size, freed with the test. The SubViewport is 1:1 (canvas == px), so Platform.units_per_pt() returns# the forced screen scale itself: pass the profile's canvas units per point (test_settings_panel.gd# test_fits_every_device_profile). If a panel's existing fit test uses a different convention, keep# that test's convention for that panel and say so in the test.static func use_profile(t: GutTest, dev: String) -> SubViewport: var pr := ShopLayoutFixture.profile(dev) Platform.force_safe_insets_pt = (pr["insets"] as Vector4) / float(pr["upt"]) Platform.force_screen_scale = float(pr["upt"]) Platform.force_touch = bool(pr["touch"]) LiveTokens.set_platform("tv" if dev == "tv" else ("touch" if bool(pr["touch"]) else "")) TokenBind.flush_now() var sv := SubViewport.new() sv.size = Vector2i((pr["vp"] as Vector2).round()) t.add_child_autofree(sv) return sv
static func safe_rect(dev: String) -> Rect2: var pr := ShopLayoutFixture.profile(dev) var vp: Vector2 = pr["vp"] var ins: Vector4 = pr["insets"] return Rect2(Vector2(ins.x, ins.y), vp - Vector2(ins.x + ins.z, ins.y + ins.w))
static func restore() -> void: Platform.force_safe_insets_pt = Vector4(-1, -1, -1, -1) Platform.force_screen_scale = 0.0 Platform.force_touch = false LiveTokens.set_platform("") set_step(TextSize.DEFAULT)
# Visible text controls under root, in tree order: Labels and Buttons with text, RichTextLabels.static func text_controls(root: Node) -> Array[Control]: var out: Array[Control] = [] _collect(root, out) return out
static func _collect(n: Node, out: Array[Control]) -> void: for c in n.get_children(): var ctl := c as Control if ctl != null and not ctl.is_visible_in_tree(): continue if (c is Label and (c as Label).text != "") or (c is Button and (c as Button).text != "") or c is RichTextLabel: out.append(ctl) _collect(c, out)
static func _size_item(c: Control) -> StringName: return &"normal_font_size" if c is RichTextLabel else &"font_size"
# Font size x the scale it is drawn at (a parent's fit-scale included).static func effective_px(c: Control) -> float: return float(c.get_theme_font_size(_size_item(c))) * c.get_global_transform_with_canvas().get_scale().y
# {"<class>|<text>|<n-th occurrence>": effective px}: the same control across two builds of a panel.static func font_map(root: Node) -> Dictionary: var out := {} var seen := {} for c in text_controls(root): var t: Variant = c.get("text") var base := "%s|%s" % [c.get_class(), str(t) if t != null else ""] var n := int(seen.get(base, 0)) seen[base] = n + 1 out["%s|%d" % [base, n]] = effective_px(c) return out
# Every control (present in both maps) that grew by less than min_ratio: "<key> x<ratio>".static func shrunk(before: Dictionary, after: Dictionary, min_ratio: float) -> PackedStringArray: var bad := PackedStringArray() for k: String in before: if after.has(k): var r := float(after[k]) / maxf(float(before[k]), 0.001) if r < min_ratio - 0.001: bad.append("%s x%.2f" % [k, r]) return bad
# Rule 1: every visible text control inside `safe` and at least its minimum size. Do not use it on the# children of a ScrollContainer (they are legitimately outside while scrolled): assert the scroll box# itself instead, plus that it can reach its last row.static func outside_or_clipped(root: Node, safe: Rect2) -> PackedStringArray: var bad := PackedStringArray() for c in text_controls(root): var r := c.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, c.size) if not safe.grow(0.5).encloses(r): bad.append("%s '%s' outside: %s" % [c.get_class(), str(c.get("text")), r]) var m := c.get_combined_minimum_size() if c.size.x + 0.5 < m.x or c.size.y + 0.5 < m.y: bad.append("%s '%s' clipped: %s < min %s" % [c.get_class(), str(c.get("text")), c.size, m]) return bad- Step 4: Run
-gselect=test_text_size_fixtureafter--import;bash scripts/check-test-count.sh. - Step 5: Gate. Step 6: Commit:
tests: TextSizeFixture (fit + growth at Larger) (#79).
Task 5: The Settings row, live refit, main wiring — model: sonnet
Section titled “Task 5: The Settings row, live refit, main wiring — model: sonnet”Files:
-
Modify:
ui/settings_panel.gd,main.gd,tests/test_settings_panel.gd -
Test:
tests/test_text_size_fit_settings.gd(new) -
Step 1: Failing tests. Append to
tests/test_settings_panel.gd(and addTextSizeFixture.restore()as the first line of itsafter_each):
func test_text_size_row_steps_cycles_and_emits() -> void: var p := await _panel() assert_true(p.row_keys().has("text_size")) assert_eq(p._text_btn.text, "Text size: Default") var got: Array = [] p.setting_changed.connect(func(k: String, v: Variant) -> void: got.append([k, v])) p._sel = p.row_keys().find("text_size") _press(p, _joy(JOY_BUTTON_DPAD_RIGHT)) assert_eq(p._text_btn.text, "Text size: Large") _press(p, _joy(JOY_BUTTON_DPAD_RIGHT)) _press(p, _joy(JOY_BUTTON_DPAD_RIGHT)) assert_eq(p._text_btn.text, "Text size: Larger", "right clamps at Larger") _press(p, _joy(JOY_BUTTON_A)) assert_eq(p._text_btn.text, "Text size: Default", "A cycles and wraps") assert_eq(got, [["text_size", 1], ["text_size", 2], ["text_size", 0]])
func test_start_menu_text_size_applies_live_and_persists() -> void: var m := await _boot() m._return_to_menu() await get_tree().process_frame m.start_menu._activate("settings", "action") await get_tree().process_frame var p: SettingsPanel = m.settings_panel var before := float(p._done_btn.get_theme_font_size(&"font_size")) p._sel = p.row_keys().find("text_size") p._text_btn.emit_signal("pressed") p._text_btn.emit_signal("pressed") assert_eq(m.meta.text_size, 2, "persisted") assert_eq(TextSize.step, 2, "applied") TokenBind.flush_now() await get_tree().process_frame assert_gte(float(p._done_btn.get_theme_font_size(&"font_size")), before * 1.25, "the open panel re-sized live")Create tests/test_text_size_fit_settings.gd:
extends GutTest
# Settings at Larger on every profile (spec fit policy): inside the safe area, nothing clipped, and the# text grows on screen by >= 1.2x even though _fit() may shrink the panel. Settings must fit WITHOUT a# scroll: a Siri Remote alone can drive it.
func after_each() -> void: TextSizeFixture.restore() await get_tree().process_frame
func _build(dev: String) -> SettingsPanel: var sv := TextSizeFixture.use_profile(self, dev) var p := SettingsPanel.new() p.show_drone_nudge = dev != "tv" p.show_display = true # the Mac row: the most rows any profile carries p.reduce_fx = true # "Reduce shake & flashes: On", the widest label p.hp_bar_always = true p.text_size = TextSizeFixture.LARGER sv.add_child(p) return p
func test_fits_and_grows_at_every_step_on_every_profile() -> void: for dev: String in TextSizeFixture.DEVICES: TextSizeFixture.set_step(TextSize.DEFAULT) var p0 := _build(dev) for _f in 5: await get_tree().process_frame var base := TextSizeFixture.font_map(p0) p0.get_parent().queue_free() for step: int in [TextSizeFixture.LARGE, TextSizeFixture.LARGER]: TextSizeFixture.set_step(step) var p := _build(dev) for _f in 5: await get_tree().process_frame var bad := TextSizeFixture.outside_or_clipped(p, TextSizeFixture.safe_rect(dev)) assert_eq(bad.size(), 0, "%s step %d:\n%s" % [dev, step, "\n".join(bad)]) var small := TextSizeFixture.shrunk(base, TextSizeFixture.font_map(p), TextSizeFixture.min_growth(step)) assert_eq(small.size(), 0, "%s step %d did not grow:\n%s" % [dev, step, "\n".join(small)]) p.get_parent().queue_free() await get_tree().process_frame
func test_an_open_panel_refits_when_the_size_changes() -> void: TextSizeFixture.set_step(TextSize.DEFAULT) var p := _build("iphone") for _f in 5: await get_tree().process_frame var k0 := p._center.scale.x TextSizeFixture.set_step(TextSizeFixture.LARGER) for _f in 3: await get_tree().process_frame assert_lt(p._center.scale.x, k0, "_fit re-ran on the token change (the panel grew, the iPhone canvas did not)") assert_eq(TextSizeFixture.outside_or_clipped(p, TextSizeFixture.safe_rect("iphone")).size(), 0)Measured before this task (2026-10-20, type.scale forced): Settings needs 570×523 at 1.3 against
481×507 at 1.0; _fit gives k = 0.909 vs 0.938 on iPhone, so growth is about 1.26× and rule 2 should
pass once the row is added. If it does not, shorten the row labels before touching _fit.
-
Step 2: Run both files, confirm the new tests fail.
-
Step 3:
ui/settings_panel.gd.
OLD:
var reduce_fx := falsevar drone_nudge := trueNEW:
var reduce_fx := falsevar text_size := TextSize.DEFAULTvar drone_nudge := trueOLD:
var _reduce_btn: Buttonvar _nudge_btn: ButtonNEW:
var _reduce_btn: Buttonvar _text_btn: Buttonvar _nudge_btn: ButtonOLD:
_reduce_btn = _row_btn("reduce_fx", box) if show_drone_nudge:NEW:
_reduce_btn = _row_btn("reduce_fx", box) _text_btn = _row_btn("text_size", box) if show_drone_nudge:OLD:
_sel = 0 _focus() _center.resized.connect(_fit) get_viewport().size_changed.connect(_fit) _fit.call_deferred()NEW:
_sel = 0 _focus() _center.resized.connect(_fit) get_viewport().size_changed.connect(_fit) _fit.call_deferred() # Text size (and any token change): the rows re-size through their bindings; the panel must refit # and the hint (built from a size read at build time) must be rebuilt. weakref: never capture self. var w := weakref(self) TokenBind.bind(self, func() -> void: var o := w.get_ref() as SettingsPanel if o != null and o.is_inside_tree(): o._fit.call_deferred() o._rebuild_hint.call_deferred())OLD:
_hp_btn, _haptics_btn, _reduce_btn, _nudge_btn, _display_btn, _done_btn]:NEW:
_hp_btn, _haptics_btn, _reduce_btn, _text_btn, _nudge_btn, _display_btn, _done_btn]:OLD:
_reduce_btn.text = on_off_label("Reduce shake & flashes", reduce_fx)NEW:
_reduce_btn.text = on_off_label("Reduce shake & flashes", reduce_fx) _text_btn.text = TextSize.label(text_size)OLD:
"reduce_fx": reduce_fx = not reduce_fx _emit(key, reduce_fx)NEW:
"reduce_fx": reduce_fx = not reduce_fx _emit(key, reduce_fx) "text_size": text_size = (text_size + 1) % TextSize.NAMES.size() # A / tap cycles, wrapping like a toggle _emit(key, text_size)OLD:
else: var k := _keys[_sel] if k == "music_volume" or k == "sfx_volume": _step(k, h)NEW:
else: var k := _keys[_sel] if k == "music_volume" or k == "sfx_volume": _step(k, h) elif k == "text_size": _step_text(h)Add after _step:
# Left / right on the Text size row: clamped like a volume (never wraps), A cycles instead.func _step_text(d: int) -> void: var nxt := TextSize.clamp_step(text_size + d) if nxt == text_size: return text_size = nxt _ui_nav() _emit("text_size", nxt)Update the header comment’s list of rows (“… Reduce shake & flashes, Text size, …”).
- Step 4:
main.gd. OLD (in_open_settings):
p.reduce_fx = ComfortFx.reduceNEW:
p.reduce_fx = ComfortFx.reduce p.text_size = TextSize.stepOLD (in _on_setting_changed):
"reduce_fx": ComfortFx.reduce = bool(value) if meta != null: meta.reduce_fx = ComfortFx.reduce MetaStore.save_prefs(meta)NEW:
"reduce_fx": ComfortFx.reduce = bool(value) if meta != null: meta.reduce_fx = ComfortFx.reduce MetaStore.save_prefs(meta) "text_size": TextSize.apply(int(value)) # every bound font re-sizes next frame, the open panel included if meta != null: meta.text_size = TextSize.step MetaStore.save_prefs(meta)- Step 5: Run
-gselect=test_settings_panel,-gselect=test_text_size_fit_settings. - Step 6: Gate. Step 7: Commit:
settings: Text size row, live refit (#79).
Task 6: Widen the literal ratchet to the bypass forms — model: sonnet
Section titled “Task 6: Widen the literal ratchet to the bypass forms — model: sonnet”Files:
- Modify:
tools/live_tokens/literal_scan.gd,tests/test_ui_literal_scan.gd,tests/ui_literal_allowlist.json
The current font pattern only sees a literal right after the comma. Real bypasses it misses are listed in the spec (“Hard-coded font sizes”).
- Step 1: Failing test, appended to
tests/test_ui_literal_scan.gd:
# Text size (#79): sizes that bypass the font tokens in the forms the old pattern missed.func test_derived_literal_sizes_and_integer_font_consts_are_counted() -> void: var r := _scan("\n".join([ "_t.add_theme_font_size_override(\"font_size\", int(round((48 if touch else 64) * _s)))", "sub.add_theme_font_size_override(\"font_size\", int(round(15 * _s)))", "const NAME_FONT_MIN := 15", "static var ROLE_FONT: int = 17", "const KIND_FONT := &\"font.levelup.kind\"", "l.add_theme_font_size_override(\"font_size\", int(round(Tokens.n(&\"font.menu.row\") * _s)))", "static var ROLE_FONT: int = 17 # text-px: every use goes through Tokens.text_px()", ])) assert_eq(r["font"], 4, "two derived literals + two integer font consts; tokens and text-px lines are not counted")-
Step 2: Run
-gselect=test_ui_literal_scan: fails (counts 0). -
Step 3:
tools/live_tokens/literal_scan.gd. OLD:
"font": "font_size_override\\([^,]+,\\s*[0-9]|font_size\\s*=\\s*[0-9]",NEW:
# A literal size straight after the comma, or at the start of a derived expression # (int(round((48 if touch ...) * _s))), or an integer *FONT* const/static (text size #79: a size # that bypasses the font tokens never follows the player's Text size). A design size that every use # passes through Tokens.text_px() is marked `# text-px: <why>` on its line. "font": "font_size_override\\([^,]+,\\s*(int\\(round\\(\\(?\\s*)?[0-9]|font_size\\s*=\\s*[0-9]|(const|static var)\\s+[A-Z_]*FONT[A-Z_]*\\s*(:\\s*int\\s*)?:?=\\s*[0-9]",OLD (in scan_file):
for k: String in regs: res[k] += (regs[k] as RegEx).search_all(line).size()NEW:
for k: String in regs: if k == "font" and line.contains("# text-px:"): continue res[k] += (regs[k] as RegEx).search_all(line).size()Update the header comment and docs/architecture/ui-tokens.md § The literal ratchet to describe the
new forms and the # text-px: marker.
- Step 4: Record today’s counts (a deliberate raise: the scanner learned new forms):
godot --headless --path . -s res://tools/live_tokens/write_literal_baseline.gd -- --allow-raise. Expect entries forui/start_menu.gd,ui/meta_shop_panel.gd,ui/level_up_panel.gd,ui/level_up_panel_legacy.gd(and any others the scan finds: list them in the commit message). Tasks 7-9 lower these to zero; Task 17 asserts the file is{}again. - Step 5: Run
-gselect=test_ui_literal_scan,-gselect=test_ui_literal_ratchet. - Step 6: Gate. Step 7: Commit:
ratchet: count derived literal font sizes and integer font consts (#79).
Task 7: Start menu sizes onto font.menu.* tokens, live refit, Larger fit — model: sonnet
Section titled “Task 7: Start menu sizes onto font.menu.* tokens, live refit, Larger fit — model: sonnet”Files:
- Modify:
ui/tokens/defaults.json,ui/start_menu.gd,tests/ui_literal_allowlist.json - Test:
tests/test_text_size_fit_start_menu.gd(new)
Today every start-menu size is a literal × _s (the menu’s own viewport fit factor,
StartMenu._scale()), which is why test_start_menu_fit passed at 1.3: the text never grew.
-
Step 1: Failing test
tests/test_text_size_fit_start_menu.gd. Build the menu the waytests/test_start_menu_fit.gddoes (copy its builder: the menu needs its_itemsset beforeadd_child; use the longest row set it uses, with progressed + Dev rows), insideTextSizeFixture.use_profile(self, dev). For each device and for Large and Larger: rule 1 (outside_or_clipped) and rule 2 (shrunk(...)vs the Default build) are empty, and every row is still at least the touch floor (StartMenu.TOUCH_ROW_MIN_PT * Platform.units_per_pt(vp)) on touch. Add a live test: build at Default,TextSizeFixture.set_step(LARGER), wait 3 frames, and assert the Survival row’sTitlelabel font size grew by >= 1.25× without a rebuild (the menu is shown again, not rebuilt, when Settings closes). -
Step 2: Run, confirm rule 2 fails (every label reports x1.00).
-
Step 3: Tokens. Add to
ui/tokens/defaults.jsonafterfont.shop.name(named tokens at the menu’s own values, thefont.pause.*precedent; the title has a touch variant):
"font.menu.title": {"default": "64*@type.text", "touch": "48*@type.text"}, "font.menu.kicker": "15*@type.text", "font.menu.notice": "14*@type.text", "font.menu.link": "12*@type.text", "font.menu.row_big": "28*@type.text", "font.menu.row": "17*@type.text", "font.menu.desc_big": "14*@type.text", "font.menu.desc": "13*@type.text", "font.menu.chip": "14*@type.text",Extend Task 2’s Default pin test with these nine at their values (64 / 48 on touch, 15, 14, 12, 28, 17, 14, 13, 14).
-
Step 4:
ui/start_menu.gd. Replace each literal-size site with its token, bound so it follows live (TokenBind.theme_font_sizetakes a Callable; capture_sin a local, neverself):_label(text: String, font_size: int, font: Font)becomes_label(text: String, size_key: StringName, font: Font): OLD:NEW:l.add_theme_font_size_override("font_size", int(round(font_size * _s)))var s := _sTokenBind.theme_font_size(l, &"font_size", func() -> int: return roundi(Tokens.n(size_key) * s))- Callers:
_label(String(it[1]), (28 if big else 17), ...)→(&"font.menu.row_big" if big else &"font.menu.row");_label(String(it[2]), (14 if big else 13), ...)→(&"font.menu.desc_big" if big else &"font.menu.desc"); the three chip labels_label("NORMAL" / "‹ ›" / "HARD", 14, ...)→&"font.menu.chip". - Line 219
title.add_theme_font_size_override("font_size", int(round((48 if touch else 64) * _s)))→ a binding on&"font.menu.title"(the touch variant replaces thetouchbranch). - Line 232
int(round(15 * _s))→&"font.menu.kicker"; line 257int(round(14 * _s))→&"font.menu.notice"; line 274int(round(12 * _s))→&"font.menu.link". - In
_apply_compact, OLD:NEW:_title.add_theme_font_size_override("font_size", int(round((48 if touch else 64) * (0.7 if shrink_title else 1.0) * _s)))_title.add_theme_font_size_override("font_size", roundi(Tokens.n(&"font.menu.title") * (0.7 if shrink_title else 1.0) * _s)) _hint_label(..., font_size: int = 14)andstart_hint(..., font_size: int = 14): default to-1meaningTokens.i(&"font.sm")(14 at Default), and have_rebuild_hintre-run on a token change.- At the end of
_ready, bind a deferred_fitre-run (weakref, as Task 5) so the compaction ladder re-runs when the text grows. Re-verify each quoted line against the live file first (line numbers drift).
-
Step 5: Lower the ratchet:
write_literal_baseline.gd(no--allow-raise);ui/start_menu.gdleaves the allowlist. -
Step 6: Run
-gselect=test_text_size_fit_start_menu,-gselect=test_start_menu_fit,-gselect=test_start_menu,-gselect=test_tokens. -
Step 7: Gate. Step 8: Commit:
start menu: font.menu.* tokens, live text size (#79).
Task 8: Build-time size reads become bindings or text_px — model: sonnet
Section titled “Task 8: Build-time size reads become bindings or text_px — model: sonnet”Files:
-
Modify:
ui/button_prompt.gd,ui/level_up_panel.gd,ui/level_up_panel_legacy.gd,ui/pause_menu.gd,ui/dialogue_box.gd,ui/dimension_choice_panel.gd,ui/results_panel.gd,ui/control_hints.gd,ui/tutorial_skip_chip.gd,tests/ui_literal_allowlist.json -
Test:
tests/test_text_size_live_reads.gd(new) -
Step 1: Failing test
tests/test_text_size_live_reads.gd:ButtonPrompt.build(parts, style)with no size argument at Larger returns labels atTokens.i(&"font.sm")of the CURRENT scale (today the default is a literal 14).- The pause menu’s back hint (
pause_menu.gdbuilds it withTokens.i(&"font.xl")) re-sizes when the step changes while the menu is open (build aPauseMenu, set Larger, flush, 3 frames, the hint labels grew >= 1.25×). The pause menu is shown again (not rebuilt) after Settings closes. LevelUpPanel.ROLE_FONTchips:show_forat Larger builds role chips atTokens.text_px(LevelUpPanel.ROLE_FONT)(assert one chip label’s font size ==Tokens.text_px(17)).
-
Step 2: Run, confirm failures.
-
Step 3: Implement.
ui/button_prompt.gd:row(parts, font_size: int = -1, ...)/build(parts, style, font_size: int = -1, ...);-1resolves toTokens.i(&"font.sm")at build. Callers that pass aTokens.i(...)keep working.ui/level_up_panel.gd: OLDstatic var ROLE_FONT: int = 17→ NEWstatic var ROLE_FONT: int = 17 # text-px: a console tunable design size; every use is Tokens.text_px(ROLE_FONT), and OLDArchetypeTags.chips(ArchetypeTags.for_card(_sim.content if _sim != null else null, id, eff), ROLE_FONT)→ NEW... , Tokens.text_px(ROLE_FONT)).ui/level_up_panel_legacy.gd:const ROLE_FONT := 15gets the same# text-px:marker and its uses go throughTokens.text_px(legacy look: no fit guarantee, but no unscaled size either).ui/pause_menu.gd(theButtonPrompt.build(..., Tokens.i(&"font.xl"), _back_hint_color)hint),ui/dialogue_box.gd:461,ui/dimension_choice_panel.gd:252,ui/control_hints.gd:256,ui/tutorial_skip_chip.gd:143: the hint/glyph is built from a size read once. For a screen that stays open across a Settings change (pause), rebuild it from aTokenBind.bind(weakref) the way Settings rebuilds its hint in Task 5. For screens rebuilt per open (dialogue, dimension choice), a rebuild on open is enough: leave the read and add a one-line comment saying so.ui/results_panel.gd_killed_by.add_theme_font_size_override("font_size", Tokens.i(_killed_role)): rebuilt per open; leave it, comment.
-
Step 4: Lower the ratchet (
write_literal_baseline.gd); level-up files leave the allowlist. -
Step 5: Run the new test plus
-gselect=test_level_up_panel,-gselect=test_pause_menu,-gselect=test_control_hints. -
Step 6: Gate. Step 7: Commit:
ui: build-time font reads follow text size (#79).
Task 9: Shop, drone bay, ship config at Larger — model: sonnet
Section titled “Task 9: Shop, drone bay, ship config at Larger — model: sonnet”Files:
- Modify:
ui/meta_shop_panel.gd,ui/drone_bay_panel.gd,ui/ship_config_panel.gd,tests/ui_literal_allowlist.json - Test:
tests/test_text_size_fit_shop.gd(new)
The shop fits each card’s name/description down to literal floors (NAME_FONT_MIN := 15,
DESC_FONT := 16, DESC_FONT_MIN := 15, PRICE_FONT := 15, ROLE_FONT := 15, and
fit_font_size(cat.to_upper(), tf, name_w, 1, 16, 13) for the category header), so at Larger it
silently fits the text back down: test_shop_card_text_fit passed at 1.3 for that reason.
- Step 1: Failing test
tests/test_text_size_fit_shop.gd: build the shop the waytests/test_shop_card_text_fit.gdandtests/test_meta_shop_panel.gddo (worst-case defs: the longest names and descriptions, career chips), per device, at Default / Large / Larger. Rule 1 on the card grid’s visible rows (the grid scrolls: assert the scroll mask and that the last card is reachable, not every child), rule 2 on every card’s name, description, price and chips. Also the two shop assertions that broke at 1.3 (2026-10-20 measurement): the buy toast stays centred on the stage (test_meta_shop_panel.gdtest_buy_toast_sits_on_a_plate_inside_the_stage: 768 vs 739 at 1.3) and the tutorial RECOMMENDED tab clears the header text (test_levelup_stats_polish.gdtest_recommended_tab_clears_the_header_in_the_tutorial_too: 9 units short on every profile). Ship config: the TV skin row stays clear of the status line at Larger (test_ship_config_skin_row.gdfailed for all six hulls at 1.3). Drone bay: rule 1 + rule 2 on its cards. - Step 2: Run, confirm failures.
- Step 3: Implement.
- Every integer font const gets the
# text-px:marker and every use goes throughTokens.text_px(...)(the floors scale with the text, sofit_font_sizecannot undo the choice). The category header’sfit_font_size(..., 16, 13)becomesTokens.text_px(16), Tokens.text_px(13). card_metrics/layout_forbudget the taller text: cards get taller (the grid already scrolls), never narrower than today’s minimum width.- Toast: centre from the toast’s measured width, not a width captured before the font changed.
- Header / tab: place the tab from the header label’s real minimum height.
- Ship config: place the status line from the skin row’s real bottom.
- Every integer font const gets the
- Step 4: Lower the ratchet;
ui/meta_shop_panel.gdleaves the allowlist. - Step 5: Run the new test plus
-gselect=test_meta_shop_panel,-gselect=test_shop_card_text_fit,-gselect=test_shop_card_polish,-gselect=test_ship_config_skin_row,-gselect=test_levelup_stats_polish. - Step 6: Gate. Step 7: Commit:
shop: fit floors scale with text size; Larger fit (#79).
Task 10: Level-up cards and the tutorial objective strip — model: opus (cross-cutting)
Section titled “Task 10: Level-up cards and the tutorial objective strip — model: opus (cross-cutting)”Cross-cutting: LevelUpPanel strip geometry, the card line budgets, the objective panel above it,
the touch STATS button, and the tutorial lesson gating that reads the cards.
Files:
- Modify:
ui/level_up_panel.gd,ui/objective_panel.gd - Test:
tests/test_text_size_fit_levelup.gd(new)
Measured at 1.3 (2026-10-20): every card grows past the strip on every profile (iPhone 517 vs 336, TV 517 vs 461), and in the tutorial the cards do not fit under the objective panel on iPhone (237 vs 178-235). At 1.15 iPhone is 358 vs 336.
- Step 1: Failing test
tests/test_text_size_fit_levelup.gd. Reuse the existingtest_role_chips_wrap_inside_every_card_on_every_deviceset-up intests/test_level_up_panel.gd(the worst-case offers:crystals:lightning=5,kinetic=5,light=5,crystals:void=18,wm:chronowell:heal) and the tutorial cases intests/test_objective_panel.gd(“Open SHIP STATS”, “Pick an upgrade”, “Pick the crystal card”, “Defeat the Ember Warden”), at Large and Larger on each profile:- every card’s height <=
strip_h()and the strip inside the viewport (the asserts attest_level_up_panel.gd:1045-1056), the STATS button clear of the cards (touch); - rule 2 on every card label vs the Default build (the cards must not be scaled down to fit);
- the card still shows its name, why-tag, full sentence and EVERY change line (no truncation, amendment 2); the Ship stats sheet (Y) still lists every change.
- every card’s height <=
- Step 2: Run, confirm failures.
- Step 3: Implement the line-budget ladder (spec decision 12: reflow, never shrink). Today
_make_cardcaps the sentence atSENTENCE_LINES(4) and the change lines atCHANGE_LINES(3) /CHANGE_LINES_WITH_SENTENCE(2), each line wrapping toLINE_MAX_LINES(2). Add, after the cards are built and wheneverstrip_h()changes or a token change re-sizes them, a fit pass. AMENDED (Chris, 2026-10-20): it may NOT cap, truncate or hide any card text — no loweringmax_lines_visiblebelow a label’s real line count, no dropping the sentence, no hiding change lines, and at Large/Larger the existingSENTENCE_LINES/CHANGE_LINES*/LINE_MAX_LINEScaps are lifted so text that wraps further stays fully visible. Allowed levers, in order: (a) the objective panel uses its compact form while a level-up is up (checkObjectivePanelfor an existing one before adding one); (b) card padding/gutters shrink toward their touch/focus minimums (registered console tunables withdocs.dlabels); (c) the strip grows into free vertical space above it; (d) last resort, the strip scrolls (right stick on pad, drag on touch) with the focused card scrolled into view. Add a test that at Larger on every profile every card label’s visible line count equals its wrapped line count (nothing elided) and every change line of the worst-case offers is present. - Step 4: Run the new test plus
-gselect=test_level_up_panel,-gselect=test_objective_panel,-gselect=test_level_up_card_level,-gselect=test_tutorial_skip; and the story trace (the gate runs it: it must still printlessons: ok). - Step 5: Gate. Step 6: Commit:
level-up: card line ladder keeps Larger text inside the strip (#79).
Task 11: Ship stats sheet at Larger — model: sonnet
Section titled “Task 11: Ship stats sheet at Larger — model: sonnet”Files:
- Modify:
ui/stats_sheet.gd - Test:
tests/test_text_size_fit_stats_sheet.gd(new)
The sheet already scrolls (right stick + d-pad follow), and test_stats_sheet_fit passed at 1.3. The
one break: the level-up rung numbers on iPhone sit closer than a space at 1.3
(test_levelup_stats_polish.gd test_every_rung_amount_keeps_its_number_a_space_apart: gaps 1-6 vs
10.5). The rung numbers are drawn with draw_string in _draw (stats_sheet.gd ~line 1018), so the
growth probe cannot see them: test them directly.
- Step 1: Failing test: with the
LOADOUTSand_hosted(dev, read_only)set-up copied fromtests/test_stats_sheet_fit.gd(Chalice + Chronowell = 10 crystal rows), at Larger per profile and host: every section box inside the glass, the glass inside the safe area, each overflowing column scrolls to its last row, rule 2 on the sheet’s labels, and the rung numbers keep a space apart. - Step 2: Run, confirm the rung failure.
- Step 3: Implement: place the rung numbers from their measured string widths at the current
fs(theplace_numbersslide already exists: give it the scaled minimum gap). - Step 4: Run plus
-gselect=test_stats_sheet_fit,-gselect=test_stats_sheet_view,-gselect=test_levelup_stats_polish. - Step 5: Gate. Step 6: Commit:
stats sheet: rung numbers keep their gap at Larger (#79).
Task 12: Pause menu at Larger — model: sonnet
Section titled “Task 12: Pause menu at Larger — model: sonnet”Files:
- Modify:
ui/pause_menu.gd - Test:
tests/test_text_size_fit_pause.gd(new)
Measured: the glass column with the Dev rows is 689 tall at 1.3 (668 at 1.15) against the 660 budget
(test_pause_menu.gd FIT_MAX_H). _fit_column shrinks the column (k 0.5..1) only on
size_changed, so it neither refits live nor protects the growth.
- Step 1: Failing test: per profile, at Large / Larger, in a store build’s row set and in the Dev row set: rule 1, rule 2 vs Default, and a live test (open at Default, set Larger, flush, 3 frames: the column refit and rule 1 holds).
- Step 2: Run, confirm failures.
- Step 3: Implement: bind a deferred
_fit_columnre-run on token changes (weakref); if rule 2 fails on the Dev row set only, move the Dev rows into the existing paired half-row buttons (GLASS_DEV_BTN_FONTrows) before reducing any store row. The pause menu always has a pad on tvOS, so the list may scroll as the last resort (d-pad follows focus). - Step 4: Run plus
-gselect=test_pause_menu,-gselect=test_pause_settings_button. - Step 5: Gate. Step 6: Commit:
pause: refit on text size, Larger fit (#79).
Task 13: Results / death recap at Larger — model: sonnet
Section titled “Task 13: Results / death recap at Larger — model: sonnet”Files:
- Modify:
ui/results_panel.gd,ui/death_recap.gd(if the recap columns live there) - Test:
tests/test_text_size_fit_results.gd(new)
Measured at 1.3: recap names overflow their columns ('Event Horizon (Orbit Shards)' is 353 px at 21 vs 252-276), and the TV card is fit-scaled to k = 0.79 (0.87 at 1.15), which would cancel the
text size (growth 1.3 × 0.79 ≈ 1.03).
- Step 1: Failing test: with the heavy-run fixture from
tests/test_results_panel.gd(test_recap_text_fits_its_columns_on_a_heavy_run,test_every_recap_name_fits_its_column_in_full) and the death-cause variants fromtest_card_is_not_shrunk_where_it_already_fits, per profile at Large / Larger: rule 1, rule 2, and every recap name readable in full (wrapping to a second line is allowed: the rows are two-line rows already). - Step 2: Run, confirm failures.
- Step 3: Implement: let the recap name wrap to its second line before the value instead of
needing a wider column; if the card is still taller than the TV safe area, stack the two recap
columns vertically (one column) at the step where they no longer fit side by side, so
fit_to’s k stays >= 0.92. Rebuilt per open: no live binding needed. - Step 4: Run plus
-gselect=test_results_panel,-gselect=test_results_polish. - Step 5: Gate. Step 6: Commit:
results: recap reflows at Larger instead of shrinking (#79).
Task 14: HUD, aim readout, control hints, patch toast, tutorial skip chip — model: sonnet
Section titled “Task 14: HUD, aim readout, control hints, patch toast, tutorial skip chip — model: sonnet”Files:
- Modify:
ui/hud.gd,ui/weapon_panel.gd,ui/drone_dock.gd,ui/control_hints.gd,ui/patch_toast.gd,ui/tutorial_skip_chip.gd - Test:
tests/test_text_size_fit_hud.gd(new)
Measured at 1.3: the aim readout (WeaponPanel._aim_stat) overlaps the drone dock on every profile
and the objective panel on iPad (test_ui_polish_legibility.gd
test_aim_readout_clears_boss_objective_panel_at_every_profile); the iPhone control-hint lines are
lifted over the play area (test_control_hints.gd
test_every_line_fits_every_device_clear_of_the_touch_controls: tops at 300-393 vs > 443); the
patch toast is taller than its 64-unit placement budget (73) and only one long note fits on iPhone
(test_patch_toast.gd). Hud.HP_LABEL_W (120) is a fixed width for the HP number.
- Step 1: Failing test: the three existing assertions above, re-run at Large and Larger on
every profile (copy each test’s set-up), plus: the HP number (
HP 1000 / 1000) not clipped byHP_LABEL_W; the boss name and banner inside the safe area; rule 2 on the HUD’s labels. The HUD is live during a run and stays drawn under the pause veil: add a live test (build at Default, set Larger, flush, 3 frames, the layout re-ran: no overlaps). - Step 2: Run, confirm failures.
- Step 3: Implement: derive the stacking offsets (aim readout → drone dock row, the objective
panel’s top clear, the patch toast’s placement budget, the HP label width) from the labels’ measured
minimum sizes instead of fixed constants, re-run on a token change (weakref bind). Keep any constant
that remains a registered tunable (
Hud.HP_*are already) and note which stay const and why. The control-hint lines may wrap to two lines on iPhone rather than rise over the play area. - Step 4: Run plus
-gselect=test_ui_polish_legibility,-gselect=test_control_hints,-gselect=test_patch_toast,-gselect=test_tutorial_skip,-gselect=test_hp_bar_heal_gate. - Step 5: Gate. Step 6: Commit:
hud: layout follows text size, Larger fit (#79).
Task 15: Codex / bestiary, dialogue, dimension choice + chip, objective panel — model: sonnet
Section titled “Task 15: Codex / bestiary, dialogue, dimension choice + chip, objective panel — model: sonnet”Files:
- Modify:
ui/codex_card.gd,ui/codex_overlay.gd,ui/dialogue_box.gd,ui/dimension_choice_panel.gd,ui/dimension_chip.gd,ui/objective_panel.gd - Test:
tests/test_text_size_fit_codex_dialogue.gd(new)
Measured at 1.3: the widest codex card is 601 wide against the iPad column’s 561
(test_ui_polish_legibility.gd test_codex_card_min_width_fits_every_profile; 570 at 1.15). The
dialogue box, dimension chip and dimension choice passed at 1.3 (test_dialogue_fit,
test_dimension_chip), but the dimension choice root scales itself (_root.scale), so rule 2 is
what will tell.
- Step 1: Failing test: for each screen, the existing fit test’s set-up at Large / Larger on
every profile: rule 1, rule 2; the codex card’s minimum width <= the column on iPad; the dialogue
box’s longest line (
tests/test_dialogue_fit.gdcontent) wraps inside the box; the dimension cards’ threat lines wrap (the autowrap-needs-a-width gotcha:docs/architecture/godot-gotchas.md). - Step 2: Run, confirm failures.
- Step 3: Implement: the codex card’s name/description wrap instead of setting the card’s minimum width; the dimension choice keeps its fit-scale but must pass rule 2 (reflow its cards to two rows if needed). Dialogue and dimension choice are rebuilt per open.
- Step 4: Run plus
-gselect=test_ui_polish_legibility,-gselect=test_dialogue_fit,-gselect=test_dimension_chip,-gselect=test_objective_panel. - Step 5: Gate. Step 6: Commit:
codex, dialogue, dimension: Larger fit (#79).
Task 16: Feedback panel at Larger — model: sonnet
Section titled “Task 16: Feedback panel at Larger — model: sonnet”Files:
- Modify:
ui/feedback_panel.gd - Test:
tests/test_text_size_fit_feedback.gd(new)
Measured at 1.3: on iPhone the panel is 581 tall against a 529 safe area and its Send/Cancel buttons
fall outside it; the category chips re-wrap so the existing row assertions in
test_feedback_bay_polish.gd test_fits_and_balances_on_every_device fail on every profile.
- Step 1: Failing test: that test’s set-up at Large / Larger per profile: rule 1, rule 2, buttons inside the safe area, and the chips laid out in balanced rows (the row counts may change with the size: assert balance, not the Default row split).
- Step 2: Run, confirm failures.
- Step 3: Implement: the text field’s height and the chip rows budget from measured sizes; the panel is opened from pause (pad present on tvOS), so the chip area may scroll as the last resort.
- Step 4: Run plus
-gselect=test_feedback_bay_polish. - Step 5: Gate. Step 6: Commit:
feedback: Larger fit (#79).
Task 17: Ratchet to {}, capture tool, visual QA, docs — model: sonnet
Section titled “Task 17: Ratchet to {}, capture tool, visual QA, docs — model: sonnet”Files:
-
Modify:
tests/ui_literal_allowlist.json,tools/ui_capture/capture.gd,docs/architecture/ui-tokens.md -
Test:
tests/test_ui_literal_ratchet.gd -
Step 1: Failing test, appended to
tests/test_ui_literal_ratchet.gd:
func test_the_allowlist_is_empty_again() -> void: var allow: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://tests/ui_literal_allowlist.json")) assert_eq(allow, {}, "every literal font size migrated (text size #79): use a token or Tokens.text_px")- Step 2: Run. If it fails, the remaining files are bypasses Tasks 7-16 did not reach: migrate
them here (token, binding, or
# text-px:+Tokens.text_px), never by marking a line that does not go throughtext_px. - Step 3:
tools/ui_capture/capture.gd. OLD:
main.meta.tutorial_done = trueNEW:
main.meta.tutorial_done = true # UI_CAP_TEXT_SIZE=0|1|2: capture at a Settings text size (#79). Applied after boot (boot applies # the fresh save's Default). if OS.get_environment("UI_CAP_TEXT_SIZE") != "": main.meta.text_size = TextSize.clamp_step(int(OS.get_environment("UI_CAP_TEXT_SIZE"))) TextSize.apply(main.meta.text_size)Add UI_CAP_TEXT_SIZE=0|1|2 to the header’s env list.
- Step 4: Visual QA (windowed, human-visible). Dispatch
bh-visual-qawith:UI_CAP_TEXT_SIZE=2 UI_CAP_SIZES=iphone,ipad,tvover every in-scope scene (menu,menu_notice,settings,pause,pause_pad,stats,stats_full,levelup,levelup_boss,tutorial,shop,shoptip,drones,ship_prerun,ship_run,results,codex,dialogue,dimchoice,dimension,hud,feedback), then the same atUI_CAP_TEXT_SIZE=0as the before set. It flags clipping, safe-area, legibility and overlap; fix every finding (a new failing fit test first) before merge. After any windowed run:git checkout -- project.godot audio/bus_layout.tres. - Step 5: Default unchanged:
zsh scripts/ui-sweep.shat Default must match the blessed baselines. Never--bless(a diff at Default is a bug in this branch). - Step 6: Docs.
docs/architecture/ui-tokens.md: the user layer (tiers section: defaults < user < overrides),type.user/type.text,Tokens.text_pxand# text-px:, the widened ratchet, and the fit rule for new UI (a new panel’s fit test runs at Larger withTextSizeFixture). Put the feature on the next release’s work queue (docs/RELEASE-1.2.mdif it exists; else tell the controller, who asks Chris where it goes). - Step 7: Gate. Step 8: Commit:
text size: ratchet back to {}, capture at a text size, docs (#79). - Step 9: Device check (Chris): Dev install, Settings → Text size: Larger, then the start menu, a run with a level-up, pause + Ship stats, the shop and the results screen on an iPhone (the 1.69× case) and the Apple TV from the sofa.
Self-review notes
Section titled “Self-review notes”- Spec coverage: steps / row / live / persistence (Tasks 1-5); ratchet (6, 7, 8, 9, 17); every in-scope screen has a Larger fit + growth task (Settings 5, start menu 7, shop/bay/ship config 9, level-up + objective 10, stats sheet 11, pause 12, results 13, HUD/hints/toast/skip chip 14, codex/dialogue/dimension 15, feedback 16); capture + visual QA + ui-sweep (17).
- Out of scope by decision: in-world damage numbers, world-space labels, touch control labels, App Clip, Dev-only screens, legacy look, lobbies (spec). Their tests keep running at Default.
- Determinism: nothing here touches
/simlogic (MetaStateis a save field read only bymain).
Execution handoff
Section titled “Execution handoff”- Written 2026-10-20 by a planning subagent in worktree
.claude/worktrees/spec-text-size(branchspec-text-size, off origin/main at 73328c66). Nothing executed: no code changed, nothing pushed, no device installs, no remote-config patch. - Chris approved the feature on 2026-10-20 (issue #79) and delegated the details. The calls made on his behalf are the spec’s “Decisions for Chris’s veto” list: show it to him before Task 1 is dispatched.
- The gaps quoted in Tasks 5 and 9-16 were measured by temporarily forcing
type.scaleto 1.15 / 1.3 inui/tokens/defaults.jsonand running the existing suite (reverted). Re-measure if main has moved far, since the numbers are reference points, not assertions. - Target is 1.2, not 1.1 (1.1 is past feature freeze). Every merge to main follows
docs/reference/builds-and-releases.md; pushes and device installs need Chris’s same-day OK. - Next action: Chris reads the veto list, then execute subagent-driven from Task 1 in a fresh worktree.