The one-screen rule¶
Nothing in this app scrolls. Whatever a senior needs is on the screen in front of them, whole, at either text size the app offers.
That is a promise about layout, and layout can only keep it if the text it lays out has a known ceiling. So the rule has two halves, and both are enforced on every pull request:
- Every screen fits the display window, at both text sizes, in all three font families, on the smallest display the app supports.
- Every authored string stays inside a character budget for the place it appears. A question that grows past its budget fails the build rather than pushing an answer button off someone's screen.
Why scrolling is the thing to remove¶
A senior with memory difficulties who cannot see the answer buttons does not think "I should swipe up". They think the app is broken, or that there is nothing there. A scroll bar is a piece of knowledge the interface assumes, and this app assumes as little as it can.
Bigger displays get a bigger layout¶
The budgets are written against the smallest display, but the app is not drawn at that size everywhere. Every screen is scaled to the window it is actually in: an iPhone SE draws the base layout, a Pro Max about 1.15x, an iPad about 2x. A senior on an iPad gets the same screen with everything larger, not a phone-sized card marooned in the middle of a big display.
One number does it, the display scale:
| Window | Scale | What the senior sees |
|---|---|---|
| iPhone SE, 375x667 | 1.00 | The reference layout |
| iPhone 15, 393x852 | 1.05 | |
| iPhone 15 Pro Max, 430x932 | 1.15 | |
| iPad mini, 744x1133 | 1.70 | |
| iPad Pro 13", 1024x1366 | 2.00 | Body text at 34pt |
| iPad, half Split View | 1.35 | Scales like the pane it has |
It is the smaller of the two ratios, window width over reference width and usable height over reference height, clamped between 1 and 2. That is what makes it safe rather than merely bigger: scaling a screen's whole stack by a factor no larger than either ratio, in a window that grew by at least that much, cannot push anything off. A layout that fits the reference display fits every larger one, and CI checks exactly that for every screen on every device in the list.
It comes from the window, not from a device model. Hardware that does not exist yet gets the right treatment, and an iPad dragged into a Split View pane or resized under Stage Manager re-scales as it happens, which a model name could never do.
Everything proportional moves together: type, padding and gaps, the icons
beside labels, the round Speak control, the floor a picture may shrink to,
and the number of rows a page holds. useDisplayScale, useScaledSpacing,
useMediaFloor and useTypeContext in
src/hooks/use-display-scale.ts
are how a component asks.
Because both the text and the width it wraps in grow together, a question that took two lines on a phone takes two lines on an iPad — which is why one set of character budgets holds on every device.
The reference display¶
Every budget is worked out against the smallest display the app supports:
| Device | iPhone SE (2nd and 3rd generation), portrait |
| Window | 375 x 667 points |
| Content width | 343 pt, after the 16pt side padding |
| Content height, standard text | 547 pt |
| Content height, extra large text | 540 pt |
The app is portrait-only, so the short edge is never the width. Anything that fits here fits every other phone and tablet, at that display's own scale.
The height already has the Back and Home bar subtracted (src/components/pane.tsx,
the two buttons at the top of every screen but home), and that bar grows with the
text size, which is why extra large has less room even before a word is laid out.
What the budget covers¶
The rule is enforced for the two text sizes the app itself offers, Large and Extra large, across all three font families (sans-serif, serif, and monospace). Budgets are set for the tightest combination — extra large in monospace — so a senior can change either setting without a screen coming apart.
Beyond that sits the platform's own Dynamic Type, which the app never opts out
of (allowFontScaling stays on: see accessibility). A
senior running iOS accessibility text sizes on top of extra large can exceed
any fixed layout. At that point the frame turns scrolling on rather than
clipping, because losing content at a large text size is a WCAG 1.4.4 failure
and by far the worse outcome. Development builds warn when it happens, so the
layout gets fixed instead of leaning on the safety net.
The character budgets¶
The longest a string may be, in characters, in the tightest appearance a
senior can choose. These come from
src/lib/content-budgets.ts
and are checked by tests/one-screen-fit.test.ts.
| Role | Characters | Where |
|---|---|---|
screenTitle |
25 | a screen title |
questionPrompt |
59 | a question prompt |
choiceLabel |
26 | an answer choice |
artistChoiceLabel |
45 | a music quiz artist |
feedbackLine |
64 | answer feedback |
captionCue |
64 | a video caption cue |
audioDescription |
235 | an audio description |
sourceQuote |
102 | a story quote |
memoryContext |
140 | a memory context reminder |
photoCaption |
45 | a photo caption |
faceName |
20 | a Faces name |
faceRelationship |
45 | a Faces relationship |
faceCue |
59 | a Faces memory cue |
faceRemember |
45 | a Faces remember line |
reminderTitle |
33 | a reminder |
destinationLabel |
20 | a home destination |
mediaTitle |
33 | a media title |
settingLabel |
33 | a settings row |
settingDescription |
64 | a settings explanation |
A budget is a number of lines the screen can spare, turned into characters
by the fit model. Raising one is a layout decision, not a formatting one: the
screen has to give those lines back somewhere else, and the screen stacks in
tests/one-screen-fit.test.ts will say so.
Two notes on the tighter ones:
- Answer choices get one line. Four choices, a prompt, and the voice dock only coexist if no choice wraps. Write "A government chemist", not "A customs chemist for the government".
- Artist names are the exception. They come off the Apple Music preview data and cannot be shortened without misnaming the artist, so they get two lines, and the music quiz gives up cover art height to pay for it.
How a screen stays inside it¶
Text and controls are fixed; media is elastic. The question, the answers
and the buttons take the height they need at the senior's text size, and the
picture or video stage takes whatever is left, down to a floor of 120pt
(mediaFloor). That is why the album cover and the video stage no longer
have a fixed square aspect: at extra large they shrink, and the answers stay
on screen.
Long lists are paged, not scrolled. A list the app cannot bound — saved
questions, a stats history, the vibration catalog, the settings themselves —
would be the one thing the rule cannot promise. PagedList hands out exactly
as many rows as fit and puts large "Earlier" and "Later" controls under them,
so no row is ever below a fold, because there is no fold. The page size is
computed from the same fit model, so it shrinks by itself at extra large.
Settings pages by section, so a group of related choices never splits across pages.
Diagnostics do not sit on senior-facing screens. The voice log used to take 54pt on both quiz screens, which was 54pt the answers needed. It lives under Settings > Diagnostics now.
The two pieces of code¶
src/theme/fit.ts
is the model: the reference viewport, how many characters fit a line, how text
wraps, how tall a stack of blocks is. It is deliberately pessimistic — it
predicts slightly more lines than a device renders, never fewer — and it runs
in CI over the real content.
src/components/screen.tsx
is the runtime: the frame every screen lays out in. It measures itself and its
content and enables scrolling only when the content is genuinely taller.
Within the budget that never happens, so there is nothing to swipe.
Working inside the rule¶
Adding content (a question, a caption, a media description): write it, run
pnpm test:mobile tests/one-screen-fit.test.ts. A failure names the item, its
length, and its budget.
Adding a screen: build it out of Screen, and add its stack to the
screens list in tests/one-screen-fit.test.ts. The duplication there is
deliberate — it is the one place a screen's layout budget is written down as
arithmetic, and reviewing a change to it is the point. The same list is
replayed against every supported device, so one entry covers both halves.
Writing a fixed number into a style (an icon size, a minimum height, a
maximum width): multiply it by useDisplayScale(), or take spacing from
useScaledSpacing(), or it will stay phone-sized on an iPad. Numbers that
are accessibility floors rather than proportions — the 44pt minimum hit
target — stay as they are.
Testing anything that measures: tests render on the reference display
unless they say otherwise. setDisplay(deviceNamed('iPad Pro 13"')) from
tests/helpers/display.ts moves them.
Adding a list that can grow: use PagedList (or PagedBlocks for a
screen built out of mixed blocks). Never a ScrollView.
When something does not fit: the options, in order — shorten the text, make the media elastic, split the content into steps, or page it. Turning scrolling back on is not one of them.