Skip to content

Text size accessibility option (implementation plan)

AMENDMENTS (Chris, 2026-10-20, accepted in chat) — these OVERRIDE any task text below that disagrees.

  1. Text-size steps are console tunables, not consts. TextSize.SCALE_LARGE (1.15) and TextSize.SCALE_LARGER (1.3) are registered static var LOCAL tunables (net/tunables_registry.gd + a tools/tunable_docs/docs.d/ label + LiveScope hook that re-applies TextSize.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/NAMES stay const.
  2. 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-concurrent before 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.

  • Work in the plan’s worktree (.claude/worktrees/<name> off origin/main), never the main checkout. Give a dispatched implementer that absolute path and do NOT also set isolation: "worktree".
  • bh-implementer runs on Sonnet by default: dispatch WITHOUT a model param. Pass model: "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 run godot --headless --path . --import once, then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<test_name> -gexit. Then git 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 set GUT_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 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_value does not exist; the file fails to parse, so check scripts/check-test-count.sh / the -gselect output 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 tests test_live_tokens_apply, _resolve, _filter, _lazy_load, _shutdown and _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_scale missing).

  • 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_size after --import: fails (no TextSize).

  • Step 3: Create ui/text_size.gd:

class_name TextSize
extends 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 := 0
const 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.reduce

NEW:

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 := 3
var text_size: int = 0

OLD (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.gd boot. 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_size and -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, no test_ 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 TextSizeFixture
extends 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 := 1
const LARGER := 2
const GROWTH_LARGE := 1.1
const GROWTH_LARGER := 1.2
const 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_fixture after --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 add TextSizeFixture.restore() as the first line of its after_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 := false
var drone_nudge := true

NEW:

var reduce_fx := false
var text_size := TextSize.DEFAULT
var drone_nudge := true

OLD:

var _reduce_btn: Button
var _nudge_btn: Button

NEW:

var _reduce_btn: Button
var _text_btn: Button
var _nudge_btn: Button

OLD:

_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.reduce

NEW:

p.reduce_fx = ComfortFx.reduce
p.text_size = TextSize.step

OLD (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 for ui/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 way tests/test_start_menu_fit.gd does (copy its builder: the menu needs its _items set before add_child; use the longest row set it uses, with progressed + Dev rows), inside TextSizeFixture.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’s Title label 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.json after font.shop.name (named tokens at the menu’s own values, the font.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_size takes a Callable; capture _s in a local, never self):

    • _label(text: String, font_size: int, font: Font) becomes _label(text: String, size_key: StringName, font: Font): OLD:
      l.add_theme_font_size_override("font_size", int(round(font_size * _s)))
      NEW:
      var s := _s
      TokenBind.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 the touch branch).
    • Line 232 int(round(15 * _s)) → &"font.menu.kicker"; line 257 int(round(14 * _s)) → &"font.menu.notice"; line 274 int(round(12 * _s)) → &"font.menu.link".
    • In _apply_compact, OLD:
      _title.add_theme_font_size_override("font_size", int(round((48 if touch else 64) * (0.7 if shrink_title else 1.0) * _s)))
      NEW:
      _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) and start_hint(..., font_size: int = 14): default to -1 meaning Tokens.i(&"font.sm") (14 at Default), and have _rebuild_hint re-run on a token change.
    • At the end of _ready, bind a deferred _fit re-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.gd leaves 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 at Tokens.i(&"font.sm") of the CURRENT scale (today the default is a literal 14).
    • The pause menu’s back hint (pause_menu.gd builds it with Tokens.i(&"font.xl")) re-sizes when the step changes while the menu is open (build a PauseMenu, set Larger, flush, 3 frames, the hint labels grew >= 1.25×). The pause menu is shown again (not rebuilt) after Settings closes.
    • LevelUpPanel.ROLE_FONT chips: show_for at Larger builds role chips at Tokens.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, ...); -1 resolves to Tokens.i(&"font.sm") at build. Callers that pass a Tokens.i(...) keep working.
    • ui/level_up_panel.gd: OLD static var ROLE_FONT: int = 17 → NEW static var ROLE_FONT: int = 17 # text-px: a console tunable design size; every use is Tokens.text_px(ROLE_FONT), and OLD ArchetypeTags.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 := 15 gets the same # text-px: marker and its uses go through Tokens.text_px (legacy look: no fit guarantee, but no unscaled size either).
    • ui/pause_menu.gd (the ButtonPrompt.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 a TokenBind.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 way tests/test_shop_card_text_fit.gd and tests/test_meta_shop_panel.gd do (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.gd test_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.gd test_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.gd failed 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 through Tokens.text_px(...) (the floors scale with the text, so fit_font_size cannot undo the choice). The category header’s fit_font_size(..., 16, 13) becomes Tokens.text_px(16), Tokens.text_px(13).
    • card_metrics / layout_for budget 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.
  • Step 4: Lower the ratchet; ui/meta_shop_panel.gd leaves 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 existing test_role_chips_wrap_inside_every_card_on_every_device set-up in tests/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 in tests/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 at test_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.
  • Step 2: Run, confirm failures.
  • Step 3: Implement the line-budget ladder (spec decision 12: reflow, never shrink). Today _make_card caps the sentence at SENTENCE_LINES (4) and the change lines at CHANGE_LINES (3) / CHANGE_LINES_WITH_SENTENCE (2), each line wrapping to LINE_MAX_LINES (2). Add, after the cards are built and whenever strip_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 lowering max_lines_visible below a label’s real line count, no dropping the sentence, no hiding change lines, and at Large/Larger the existing SENTENCE_LINES/CHANGE_LINES*/LINE_MAX_LINES caps 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 (check ObjectivePanel for an existing one before adding one); (b) card padding/gutters shrink toward their touch/focus minimums (registered console tunables with docs.d labels); (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 print lessons: 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 LOADOUTS and _hosted(dev, read_only) set-up copied from tests/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 (the place_numbers slide 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_column re-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_FONT rows) 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 from test_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 by HP_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.gd content) 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 through text_px.
  • Step 3: tools/ui_capture/capture.gd. OLD:
main.meta.tutorial_done = true

NEW:

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-qa with: UI_CAP_TEXT_SIZE=2 UI_CAP_SIZES=iphone,ipad,tv over 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 at UI_CAP_TEXT_SIZE=0 as 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.sh at 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_px and # text-px:, the widened ratchet, and the fit rule for new UI (a new panel’s fit test runs at Larger with TextSizeFixture). Put the feature on the next release’s work queue (docs/RELEASE-1.2.md if 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.
  • 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 /sim logic (MetaState is a save field read only by main).
  • Written 2026-10-20 by a planning subagent in worktree .claude/worktrees/spec-text-size (branch spec-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.scale to 1.15 / 1.3 in ui/tokens/defaults.json and 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.