Skip to content

Text size accessibility option (design)

Issue: GitHub #79. Approved: Chris, 2026-10-20 (feature approved; the details below are this spec’s calls, listed for veto at the end). Plan: docs/superpowers/plans/2026-10-20-text-size.md. Target release: 1.2. The 1.1 feature freeze was 2026-10-17, so this is not a 1.1 item unless Chris grants an exception.

A player can make the game’s UI text bigger from Settings, on every platform we ship (iOS, iPadOS, tvOS, macOS, Android), and see it change at once, without a restart. Every store screen still fits at the largest size on every device profile: nothing is clipped, nothing leaves the safe area, and the bigger text is not quietly shrunk back by a panel’s own fit-to-screen scale.

A new Settings row, under “Reduce shake & flashes”:

Text size: Default → Text size: Large → Text size: Larger

Step Name Text scale
0 Default 1.00
1 Large 1.15
2 Larger 1.30
  • Pad / keyboard / Siri Remote: left / right step the value (clamped, like the volume rows); A / Enter / a click cycles it, wrapping from Larger back to Default (like a toggle flips).
  • Touch / mouse: a tap on the row cycles it, the same as A.
  • The change applies the moment it is made. The Settings panel itself re-lays out at the new size, so the player sees the result before closing it. The choice is saved with the other Settings prefs.
  • Default is exactly what ships today, pixel for pixel. zsh scripts/ui-sweep.sh at Default must match the current blessed baselines with no re-bless.

On touch devices the scale stacks on the existing whole-view zoom (Tokens.TOUCH_SCALE = 1.3), so Larger text on an iPhone is 1.69× the 1280×720 design size. That is the hardest case for fit and the one the tests are written against.

The scale is a token, applied through a new “user layer”

Section titled “The scale is a token, applied through a new “user layer””

The token store already has a design dial: every font.* token is N*@type.scale (ui/tokens/defaults.json), and type.scale is 1.0. The player’s choice must not be stored as an ordinary token override, because overrides belong to the dev console: LiveTokens.reset() clears them and LiveTokens.export_patch() would bake a developer’s own text size into a published patch.

So:

  1. addons/live_tokens/ gains a user layer (game-agnostic, so it stays reusable): LiveTokensStore.set_user_value(key, raw), clear_user_value(key), user_values(). Layering becomes defaults < user < overrides. The user layer survives reset(), apply() and load_defaults() (a reload of the file must not forget the player’s choice), is cleared only by reset_to_unloaded() (the test helper), and never appears in export_patch(). Setting a value invalidates every key and emits changed, so every TokenBind binding, the NeonTheme and every TokenBind.redraw node re-apply on the next frame, the same path the HDR output filter already uses.
  2. Two new tokens. type.user (1.0, written only through the user layer) and type.text = "@type.scale*@type.user". Every font.* token changes from N*@type.scale to N*@type.text. type.scale stays the design dial the console can tune; a console override of it multiplies with the player’s choice instead of replacing it.
  3. Tokens façade: Tokens.set_text_scale(f), Tokens.text_scale(), and Tokens.text_px(px) (roundi(px × type.text)) for the few sizes that are design numbers in code rather than tokens: console-tunable static vars like LevelUpPanel.ROLE_FONT, and the shop’s fit floors.
  4. ui/text_size.gd (TextSize): the steps, the names, apply(step), label(step). The scales are consts, not remote-config tunables, for the same reason ComfortFx’s are: they are the player’s own accessibility choice, and a balance patch must never be able to shrink it.
  5. Persistence: MetaState.text_size: int (0..2), additive in to_dict / from_dict (no schema bump; older saves load Default), clamped with _sane_int like the volumes. main._ready applies it at boot next to ComfortFx.reduce; main._on_setting_changed("text_size", step) applies it and MetaStore.save_prefs. /sim never reads it.

At Default type.text resolves to exactly 1.0, so every font token resolves to the same integer it does today: that is what keeps the Default build byte-identical.

Only text. Spacing (space.*), margins, radii, outlines, row heights and icons stay as they are. Containers already grow to their content’s minimum size, so a Button whose label grows past its custom_minimum_size grows with it. Button glyphs (ButtonPrompt, ButtonGlyph) are sized from the hint’s font size already, so they grow with the hint text.

  • Bound text follows at once. Most UI text is bound (TokenBind.theme_font_size, the NeonTheme default_font_size), so it re-sizes on the next frame.
  • Layouts re-fit on a token change. Panels whose fit runs only on _ready / size_changed (Settings _fit, pause _fit_column, start menu _fit, results _fit) also bind a deferred refit with TokenBind.bind (through a weakref, never a strong self capture).
  • Build-time reads are migrated, not tolerated. A size read once at build time (Tokens.i(&"font.base") handed to ButtonPrompt.build, the start menu’s literal sizes × _s) does not follow a change. Screens that are rebuilt on every open (level-up, results, shop, codex, dimension choice) get the new size on their next open, which is enough: Settings cannot be opened while they are up. The screens that are visible around Settings (the start menu and the pause menu, both hidden under it and shown again when it closes) and Settings itself must follow live, so their build-time reads become bindings.

In scope (fit-guaranteed at every step on every profile)

Section titled “In scope (fit-guaranteed at every step on every profile)”

The glass-look store screens: start menu, Settings, pause menu, Ship stats sheet (pause and level-up hosts), level-up cards (incl. the tutorial objective strip), meta shop + drone bay + ship config, results / death recap, codex / bestiary cards, dialogue box, dimension choice + dimension chip, the objective panel, HUD text (HP number, boss name, banner, aim readout, story label), control hints, tutorial skip chip, patch toast, feedback panel.

Out of scope (they follow the tokens, but carry no fit guarantee)

Section titled “Out of scope (they follow the tokens, but carry no fit guarantee)”
  • In-world floating text (fx/damage_numbers.gd, the reaction-name labels in fx/fx_manager.gd): world-space combat feedback drawn over the swarm, not text a player reads to make a decision; bigger numbers mean more of the playfield covered on the hottest render path. Their literal 36 / 20 / 19 sizes stay. Follow-up if players ask.
  • World-space renderers (render/elite_bar_renderer.gd names, render/weapon_pickup_renderer.gd, render/story_map_renderer.gd NPC names): they live on the world canvas and zoom with the camera.
  • Touch control button labels (ui/touch_controls.gd): fixed-size circular buttons sized to the thumb, not to the text.
  • App Clip flow (ui/clip_flow.gd): the clip has no Settings screen.
  • Dev-only screens (Debug Settings, persona overlay, debug overlay, learning-run tag, console change notes) and the legacy (non-glass) look (LegacyLevelUpPanel and the legacy branches).
  • LAN / online lobby (co-op is still gated off).

Every in-scope screen is tested at Larger on each ShopLayoutFixture profile: TV 1280 (with the 5% title-safe band, Platform.TV_TITLE_SAFE), iPhone 1204, iPad 985, driven in a SubViewport with the profile’s safe insets forced (Platform.force_safe_insets_pt) and with worst-case content (Chalice + Chronowell stats rows 9-10, the longest recap names, “Reduce shake & flashes: On”, a six-weapon late run). Each test asserts:

  1. Nothing leaves the safe area and nothing is clipped (size >= get_combined_minimum_size() for every visible text control).
  2. The text really grew. A panel that shrinks itself to fit (_center.scale = k) or fits its font to a box (MetaShopPanel.fit_font_size) could pass rule 1 by cancelling the player’s choice. So the test builds the panel at Default and at Larger and compares each text control’s effective size (font size × its canvas transform scale): at Larger every one must be at least 1.2× its Default size, at Large at least 1.1×. A whole-panel fit-scale may absorb a little of the growth (about 8%), never all of it.
  3. The overflow strategy is the sanctioned one. When a screen cannot fit, it does one of these, in this order of preference:
    • Reflow: wrap, or drop optional lines. The level-up card already caps its sentence and change lines (SENTENCE_LINES, CHANGE_LINES) because the Ship stats sheet carries everything; at the larger steps those budgets shrink until the card fits the strip.
    • Scroll: a ScrollContainer driven by the d-pad selection, with the right stick as the tvOS fallback. That is valid only where a pad is guaranteed (pause, level-up, the stats sheet: the Siri Remote cannot play the game). Settings and the start menu must fit without scrolling, because both can be driven by a Siri Remote alone.
    • Compact: the start menu’s existing compaction ladder (gaps, then rows to the tap floor, …) runs as it does today; the touch row floor (TOUCH_ROW_MIN_PT) and the TV text floor stay.

Setting type.scale to 1.3 and 1.15 in defaults.json and running the existing fit tests (2026-10-20, this worktree, reverted after) gives the real overflow to fix. At Larger (1.3):

Screen What broke Size of the gap
Level-up strip cards grow past the strip on every profile iPhone 517 vs 336 units, TV 517 vs 461
Level-up + objective panel (tutorial) cards do not fit under the objective panel on iPhone 237 vs 178-235
Pause menu (glass, Dev rows) column taller than 720 minus the margin 689 vs 660
Results / death recap recap columns overflow; TV card fit-scaled to 0.79 names up to 353 px vs 252-276
Codex card widest card wider than the iPad column 601 vs 561
HUD aim readout overlaps the drone dock (all profiles) and the objective panel (iPad) —
Control hints iPhone hint lines lifted over the play area top 300-393 vs > 443
Feedback panel iPhone panel taller than the safe area; chip rows rewrap 581 vs 529
Stats sheet rungs (level-up) iPhone rung numbers closer than a space 1-6 vs 10.5
Shop buy toast off-centre; tutorial tab overlaps the header text 768 vs 739; 9 units
Patch toast taller than its placement budget; only one long note fits on iPhone 73 vs 64
Ship config skin row TV skin row overlaps the status line —

At Large (1.15) the same tests fail except ship config and the clip countdown, with smaller gaps (level-up iPhone 358 vs 336, pause 668 vs 660, results TV fit-scale 0.87).

These numbers understate the work. The start menu, Settings, the stats sheet and the shop passed, and the reasons are warnings, not reassurance: the start menu’s sizes bypass the tokens completely (literal 48 / 64 / 28 / 17 / 15 / 14 / 13 / 12 × _s), Settings and the results card shrink themselves to fit (k clamped 0.5..1), and the shop fits each card’s text down to a literal 15 px floor. Rule 2 above is what turns those into real tests.

Hard-coded font sizes (the literal ratchet)

Section titled “Hard-coded font sizes (the literal ratchet)”

tests/test_ui_literal_ratchet.gd is at {}, but its font pattern (font_size_override\([^,]+,\s*[0-9] / font_size\s*=\s*[0-9]) only catches a literal straight after the comma. Real bypasses in ui/ + main.gd that it misses today:

  • ui/start_menu.gd: every size is a literal × _s (int(round((48 if touch else 64) * ... * _s)), int(round(15 * _s)), _label(text, 28 / 17 / 14 / 13, ...)).
  • ui/meta_shop_panel.gd: NAME_FONT_MIN := 15, DESC_FONT := 16, DESC_FONT_MIN := 15, PRICE_FONT := 15, ROLE_FONT := 15, fit_font_size(cat.to_upper(), tf, name_w, 1, 16, 13).
  • ui/level_up_panel.gd: static var ROLE_FONT: int = 17 (a console tunable).
  • Default parameters: ButtonPrompt.row/build(..., font_size: int = 14, ...), StartMenu._hint_label(..., font_size: int = 14), StartMenu.start_hint(..., font_size: int = 14); SettingsPanel.HINT_MIN_PX := 15 (a floor, harmless: maxi with a scaled token).
  • Build-time token reads handed to non-bound builders (Tokens.i(&"font.base") into ButtonPrompt.build in the dialogue box, dimension choice, level-up, shop and pause hints; results_panel.gd _killed_by).

Decision: widen the scanner, then drive the allowlist back to {}.

  • The font pattern also counts a literal at the start of a size expression (font_size_override(<item>, int(round((48 ... and ... int(round(15 * ...).
  • A new font_const category counts integer font consts and statics (const NAME_FONT_MIN := 15, static var ROLE_FONT: int = 17) unless the line carries # text-px: <why>, the marker for a design size that every use passes through Tokens.text_px().
  • The scanner change lands with an allowlist holding today’s counts, so the gate stays green, and each migration task lowers its file’s count; the last one asserts {} again.

Migration per site: a screen-specific size that sits between ramp steps becomes a named token at its own value (the existing font.pause.title / font.shop.name precedent): the start menu gets font.menu.*. A console tunable stays a tunable and is read through Tokens.text_px() at the use site. A build-time read handed to a builder becomes a binding, or the builder re-runs on a token change.

The behaviour test (rule 2) is the real backstop, because it catches any bypass however it is spelled: a text control that does not grow fails its screen’s test.

  • Unit: the addon user layer (a local LiveTokensStore, never fake defaults in the global store), the Tokens façade, TextSize, MetaState round-trip + old-save + corrupt-value clamp, the Settings row’s input behaviour, main boot + Settings wiring.
  • Default is byte-identical: every font.* token resolves to the same integer as before.
  • Per-screen fit tests at Larger (and the growth check at Large) on all three profiles, as above. They use one shared helper, tests/text_size_fixture.gd, and restore Default in after_each (the user layer is global state: a leaked Larger would break every pixel-pinned test after it).
  • tests/test_ui_literal_ratchet.gd with the widened scanner.
  • Windowed: tools/ui_capture gains UI_CAP_TEXT_SIZE=0|1|2; a bh-visual-qa pass at Larger on iphone, ipad and tv over every in-scope scene; zsh scripts/ui-sweep.sh at Default unchanged (no --bless).
  • Device: Chris checks Larger on an iPhone (the 1.69× case) and the Apple TV from the sofa.
  • Following iOS Dynamic Type / Android font scale as the starting value: reading UIContentSizeCategory needs a native bridge in the custom engine shell. Follow-up.
  • Telemetry of the chosen step: no new data kind, so the privacy page and the ASC privacy label do not change.
  • More than three steps or a free slider: three steps are three fit targets; a slider makes every value between them one.
  1. Three steps, Default / Large / Larger = 1.0 / 1.15 / 1.3, the same on every platform, including iPhone, where Larger stacks on the 1.3× touch zoom to 1.69×.
  2. Text only: spacing, row heights and icons do not scale; containers grow to the text.
  3. Stored in a new game-agnostic user layer in addons/live_tokens, not as a token override, so the console’s reset / export never touch it.
  4. The step scales are consts, not remote-config tunables (the ComfortFx precedent).
  5. Saved as MetaState.text_size (0..2), additive, no schema bump; older saves start at Default.
  6. The row sits under “Reduce shake & flashes”; left / right step it (clamped), A / tap cycles it (wrapping).
  7. Live: bound text changes at once; the start menu, pause menu and Settings follow live; the other screens pick it up on their next open.
  8. In-world damage numbers and reaction labels stay at their current size.
  9. Out of the fit guarantee: world-space labels, touch control button labels, the App Clip, Dev-only screens, the legacy look and the co-op lobbies.
  10. A panel’s fit-to-screen shrink may absorb at most about 8% of the growth: at Larger every text control’s effective size must be at least 1.2× its Default size.
  11. When a screen overflows: reflow (drop optional card lines, wrap) first, scroll second (only where a pad is guaranteed), never shrinking the text back down. Settings and the start menu must fit without scrolling.
  12. At the larger steps the level-up card shows fewer sentence / change lines on short canvases; the Ship stats sheet still has everything.
  13. The literal ratchet is widened to catch int(round(N ... sizes and integer font consts, and is driven back to {}; the start menu’s sizes become font.menu.* tokens.
  14. Ships in 1.2, not as a 1.1 freeze exception.
  15. No telemetry of the chosen step; no Dynamic Type follow-through in this version.