Skip to content

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:

  1. Every screen fits the display window, at both text sizes, in all three font families, on the smallest display the app supports.
  2. 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.