Mobile device QA
Lighthouse loads a page nobody touches; the scroll test scrolls it in desktop
Chrome with a phone's viewport and a throttled CPU. Neither has Safari's
collapsing toolbar, a 120 Hz panel, a finger, a gyroscope, iOS's memory
pressure or a phone in dark mode. Every item below passed both instruments
on production sites and was then found by a person holding an iPhone. This skill
is that person's checklist, with the mechanism, the fix and a way to prove it
without the phone — and an honest line for what still needs one.
Each item: symptom (what a reviewer saw) → cause → fix → prove it.
Rule = held on ≥ 3 sites; observed = 1–2.
Setup. yarn build && yarn start, yarn qa:setup once. Every tool takes
--url (tools/qa/README.md). Probes run with a person's UA — the default
headless UA gets the robot form (no motion, no scene, no cursor) and "proves"
a bug gone that a person still sees. Look at every screenshot (Read the image).
Copy-paste probes and code shapes: references/recipes.md.
When the bug is iOS-only, reproduce it in WebKit first. Twice a fix passed
every headless-Chrome probe and failed on the phone (a hero scene vanishing
after scroll). node tools/qa/webkit-probe.mjs --url … runs Playwright's WebKit
with an iPhone profile and touch. Reproduce → fix → the same probe passes. If
WebKit can't reproduce it either, ship a fix that removes the mechanism and a
self-check that recovers the state, and say plainly it is unverified on iOS.
Symptom: "the scene flickers when I scroll", "the hero re-renders when I
change scroll direction", "the section resizes on iOS", balls in a physics scene
jump. Rule (5 sites).
Cause: Safari's URL bar collapses and expands as you scroll: innerHeight
changes and resize fires. Boxes sized 100dvh / 100vh-via-JS /
fixed inset-0 / innerHeight change height; a WebGL renderer that follows
reallocates its drawing buffer (cleared → a blank frame) and redraws; code that
re-randomises or re-lays-out on resize visibly jumps. r3f's <Canvas frameloop="never"> re-applies the prop on each re-render — after the second
resize one scene stayed black.
Fix:
- Scene boxes at the large viewport:
src/components/common/scene-viewport.tsx
/ src/utils/stable-viewport.ts — 100lvh measured once, re-measured on touch
devices only when the width changes (rotation), a rotation settled ~300 ms
later. Desktop windows still follow every resize.
- Renderers: skip a resize whose CSS size and DPR didn't change; on a real one,
draw immediately (
setSize clears the buffer).
- Keep listening for
resize — never only orientationchange on touch: a
width change (split screen, a DevTools preset back to desktop) then left the
canvas phone-sized (observed).
- Worker scenes resize in the worker too — one OffscreenCanvas worker
cleared its own buffer on every height step; guard it the same way.
- Anything else that reads
innerHeight per resize — a scroll-scrubbed section's
fit(), visualViewport listeners, ResizeObservers on scene wrappers,
ScrollTrigger.refresh / Lenis recomputes — reads the stable height instead.
- Static UI is the opposite (§4): menus use
dvh.
Prove it: node tools/qa/ios-toolbar-probe.mjs --url … --scroll 0.3 — an
iPhone viewport, height stepped 844 → 760 → 844 → 700 → 844 while scrolled: no
canvas buffer or box change, no worker size message, no blank screenshot, the
loop still drawing; then a rotation that does resize. FAIL on production →
PASS on the fix, for every site this was applied to. Headless height steps shrink
lvh sections with the window (a real iPhone doesn't) — compare positions
relative to the section, not the page.
2. The scene disappears (and never comes back)
Symptom: "after scrolling the whole site and back, the hero scene is gone";
"the scene disappears after a little scroll on iOS". Observed (1 site, three
review rounds).
Causes, in the order they were found:
- A visible canvas stopped drawing. A "freeze the hero after 10 % scroll"
optimisation trusted the browser to keep the last frame; iOS drops it after a
toolbar resize / re-composite and a stopped scene never repaints. Rule now:
never stop drawing a canvas that is on screen — pause only when off screen.
- A lost WebGL context. iOS drops contexts under memory pressure (the page
decoded three 3× stills while the hero was off screen); three.js
preventDefaults the loss but nothing rebuilt the scene.
Fix: keepSceneAlive in src/lib/scene/webgl-context.ts — preventDefault the
webglcontextlost; on approach (IntersectionObserver with lead), visibilitychange
and pageshow, check gl.isContextLost() and rebuild a lost or restored scene
on a fresh context at rest (no intro replay), ≥ 1 s apart, 3 retries;
forceContextLoss() on every teardown (a rebuild never holds two contexts); don't
allocate desktop-only render targets on phones; a live scene re-sizes only if its
buffer is actually stale.
Prove it: node tools/qa/context-loss-probe.mjs --url … — scroll bottom ↔
top cycles, WEBGL_lose_context.loseContext() off screen (with and without a
restore) and on screen, assert the hero draws again with the same lit-pixel
count. Then tools/qa/webkit-probe.mjs with touch momentum, since Chrome did not
reproduce the original bug. Say "unverified on a real iPhone" if it is.
3. Frame rate and speed on 120 Hz phones
Symptom A — "low fps, looks really bad": a scene capped "to save the
phone". Rule (5+ sites). A t - last <= 1000/30 throttle draws every 3rd
frame on 60 Hz (20 fps) and every 5th on a 120 Hz iPhone (26 fps). A cap
can also trip the scene's own fps → DPR fallback: one hero dropped every phone to
DPR 0.75 within 2 s ("too noisy").
Fix: no fixed phone frame caps. Draw at the display rate and pay with a
cheaper frame (DPR 1–1.5, fewer samples/particles, lighter bloom). Lifting caps
took scenes 26 → 120 fps with the phone scroll test unchanged or better.
Prove it: node tools/qa/fps-probe.mjs --url … — the scene's draws/s vs the
page's rAF/s at rest and touch-scrolling; draws ≈ rAF/3 or rAF/5 is a leftover
cap. Grep: 1000 / 30, 1000/30, MOBILE_FRAME, frameBudget, frameloop.
Symptom B — "animates 2–4× too fast and shakes" before the first scroll.
Causes: per-frame increments (x += 0.02, lerp(a, b, 0.1)) not scaled by
delta time run 2× at 120 Hz — 4× when two loops tick (StrictMode double mount, a
loop started on mount and resize, a worker loop + a page loop); a worker fed
the clock in milliseconds where the page path used seconds ran every
timed motion ~1000× fast, aliased into jitter (observed).
Fix: src/lib/scene/per-frame.ts — createFrameClock() (one clock in
seconds shared by every path, dt clamped), += k * dt * 60,
x += (target - x) * perFrame(k, dt) or damp(...); one loop per scene.
Prove it: speed is invisible to Lighthouse and the scroll test (they count
frames). Simulate 120 Hz by replacing requestAnimationFrame with an 8 ms timer
(references/recipes.md) and compare pixel change per 1/60 s at rest against
60 Hz; log the scene's time value on both paths for one second.
Desktop is 120 Hz too — cap a GPU-bound WebGL scene's draw at 60 fps there
(optimize-3d-scene §5). That is the one cap that is a rule.
Symptoms: "make it a proper full-screen immersive menu"; "it appears
instantly"; "the bottom button is cut off by the iOS bar"; "the text has no
contrast, content not seen"; "the cross doesn't match the burger"; "focus is
lost a second after opening". Rule (10+ menus built).
Causes and fixes:
- Height.
inset-0 / h-lvh / 100vh puts the menu's foot under Safari's
bottom toolbar. Menus are static UI: top-0 h-dvh, bottom padding
max(<pad>, calc(env(safe-area-inset-bottom) + <pad>)). (viewport-fit=cover
only where no text sits in the landscape notch gutter.) Canvases are the
opposite — lvh (§1).
- It doesn't cover the screen. A
fixed overlay inside a transformed or
separately layered header is fixed to that box. Portal it under <body>.
A portal loses CSS variables set on wrappers (a scene colour) — render it
inside the element that carries them, or copy them over.
- Focus can't move in / is lost after ~1 s. Show the panel in the same render
that opens it, then focus; a portal returned straight from a react-spring
useTransition render callback remounts (focus dropped ~1 s after
opening) — create the portal inside the panel component instead.
- Dark mode. A menu on a theme token (
bg-background) turns near-black on a
dark-mode phone while a fixed-colour page stays light — one read ~1:1. Give the
menu its own tokens; check with prefers-color-scheme: dark emulated. (Assume
the reviewer's phone is in dark mode.)
- Behaviour: scroll locked while open (stop Lenis / the scroll API; restore
after —
src/hooks/use-scroll-lock.ts), Escape closes, a link closes then
scrolls to its target, focus moves in and back to the toggle, Tab stays inside,
aria-expanded + aria-controls, the page behind inert, the closed menu
inert/unmounted (A11y must stay 100), reduced motion = a plain fade. Keep a
scene behind it paused or running — never remount it.
- Motion (springs only): the panel enters by clip-path / scale / translate (a
circle or wipe from the toggle, a curtain), links stagger in
40–60 ms apart
with transform + mask, the toggle morphs burger ↔ cross in place (same box,
same lines), the exit is the reverse and faster. Reuse the site's eases,
colours and display face; large links (clamp(2.5rem, 11vw, 4rem)),
secondary info (CTA, contact) at the foot. Burger lines sized against the
wordmark's stroke and aligned to the header padding — "the closed burger looks
off" was 1 px rules at 3× next to a 2 px stem.
- Before redesigning, find which menu is mounted — one site carried an unused
full-screen menu next to the live dropdown.
Prove it: screenshots at 390×844 — closed, mid-open, open at rest, closing,
after a link — in light and dark scheme, in WebKit (webkit-probe.mjs);
check focus lands on the first link and returns to the toggle, scrollY
unchanged after a swipe while open, the foot clear of a 34 px safe-area inset.
Lighthouse A11y stays 100.