Text size accessibility option (design)
Text size accessibility option — design
Section titled “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.
What the player sees
Section titled “What the player sees”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.shat 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.
How it works
Section titled “How it works”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:
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 survivesreset(),apply()andload_defaults()(a reload of the file must not forget the player’s choice), is cleared only byreset_to_unloaded()(the test helper), and never appears inexport_patch(). Setting a value invalidates every key and emitschanged, so everyTokenBindbinding, the NeonTheme and everyTokenBind.redrawnode re-apply on the next frame, the same path the HDR output filter already uses.- Two new tokens.
type.user(1.0, written only through the user layer) andtype.text = "@type.scale*@type.user". Everyfont.*token changes fromN*@type.scaletoN*@type.text.type.scalestays the design dial the console can tune; a console override of it multiplies with the player’s choice instead of replacing it. Tokensfaçade:Tokens.set_text_scale(f),Tokens.text_scale(), andTokens.text_px(px)(roundi(px × type.text)) for the few sizes that are design numbers in code rather than tokens: console-tunablestatic vars likeLevelUpPanel.ROLE_FONT, and the shop’s fit floors.ui/text_size.gd(TextSize): the steps, the names,apply(step),label(step). The scales are consts, not remote-config tunables, for the same reasonComfortFx’s are: they are the player’s own accessibility choice, and a balance patch must never be able to shrink it.- Persistence:
MetaState.text_size: int(0..2), additive into_dict/from_dict(no schema bump; older saves load Default), clamped with_sane_intlike the volumes.main._readyapplies it at boot next toComfortFx.reduce;main._on_setting_changed("text_size", step)applies it andMetaStore.save_prefs./simnever 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.
What scales, and what does not
Section titled “What scales, and what does not”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.
Live application
Section titled “Live application”- Bound text follows at once. Most UI text is bound (
TokenBind.theme_font_size, the NeonThemedefault_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 withTokenBind.bind(through aweakref, never a strongselfcapture). - Build-time reads are migrated, not tolerated. A size read once at build time
(
Tokens.i(&"font.base")handed toButtonPrompt.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 infx/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.gdnames,render/weapon_pickup_renderer.gd,render/story_map_renderer.gdNPC 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 (
LegacyLevelUpPaneland the legacy branches). - LAN / online lobby (co-op is still gated off).
Fit policy (the hard part)
Section titled “Fit policy (the hard part)”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:
- Nothing leaves the safe area and nothing is clipped (
size >= get_combined_minimum_size()for every visible text control). - 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. - 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
ScrollContainerdriven 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.
- Reflow: wrap, or drop optional lines. The level-up card already caps its sentence and change
lines (
Measured starting point
Section titled “Measured starting point”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:maxiwith a scaled token). - Build-time token reads handed to non-bound builders (
Tokens.i(&"font.base")intoButtonPrompt.buildin 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
fontpattern 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_constcategory 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 throughTokens.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.
Testing and QA
Section titled “Testing and QA”- Unit: the addon user layer (a local
LiveTokensStore, never fake defaults in the global store), theTokensfaçade,TextSize,MetaStateround-trip + old-save + corrupt-value clamp, the Settings row’s input behaviour,mainboot + 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 inafter_each(the user layer is global state: a leaked Larger would break every pixel-pinned test after it). tests/test_ui_literal_ratchet.gdwith the widened scanner.- Windowed:
tools/ui_capturegainsUI_CAP_TEXT_SIZE=0|1|2; abh-visual-qapass at Larger on iphone, ipad and tv over every in-scope scene;zsh scripts/ui-sweep.shat Default unchanged (no--bless). - Device: Chris checks Larger on an iPhone (the 1.69× case) and the Apple TV from the sofa.
Not doing
Section titled “Not doing”- Following iOS Dynamic Type / Android font scale as the starting value: reading
UIContentSizeCategoryneeds 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.
Decisions for Chris’s veto
Section titled “Decisions for Chris’s veto”- 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×.
- Text only: spacing, row heights and icons do not scale; containers grow to the text.
- 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. - The step scales are consts, not remote-config tunables (the
ComfortFxprecedent). - Saved as
MetaState.text_size(0..2), additive, no schema bump; older saves start at Default. - The row sits under “Reduce shake & flashes”; left / right step it (clamped), A / tap cycles it (wrapping).
- 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.
- In-world damage numbers and reaction labels stay at their current size.
- 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.
- 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.
- 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.
- At the larger steps the level-up card shows fewer sentence / change lines on short canvases; the Ship stats sheet still has everything.
- 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 becomefont.menu.*tokens. - Ships in 1.2, not as a 1.1 freeze exception.
- No telemetry of the chosen step; no Dynamic Type follow-through in this version.