Appearance
UI quality assurance — catching layout regressions before merge
Status: 🟡 Proposal (2026-07-26) · Trigger: clipped text shipped to a device on both platforms and was caught by eye (QRS-181, QRS-182) · Owner decision required on which tiers to adopt
First, the honest answer: nothing was validating layout
The question "why didn't our E2E tests catch this?" has an uncomfortable answer.
When this page was written there were no Playwright tests. Not failing ones — none at all.
There was no playwright.config.*, no e2e/ directory, and no e2e:* script anywhere in the repo. Playwright appeared in CLAUDE.md as a target ("still to be added by the plan"), not as something wired. So the answer to "what are our Playwright tests validating?" was: nothing existed to validate anything.
✅ Resolved 2026-07-26 — tiers 1–2 and the tier-4 foundation are live (QRS-189)
npm run e2e now runs 334 assertions across 4 viewports × en/hi, wired as an e2e-web CI job. It found four real defects on its first run (QRS-190 className dropped on PressableScale, QRS-191 sub-44pt unlabelled controls, QRS-192 a React hydration mismatch discarding the prerendered HTML) — plus one lesson worth more than any of them, in "What we got wrong" below. Setup and conventions: apps/mobile/e2e/README.md.
The complete inventory of what actually runs before a merge:
| Layer | What exists | Could it have caught clipped text? |
|---|---|---|
| Jest (294 tests, 65 suites) | Logic, hooks, contracts, component mount smoke | No. Assertions are on props, state and rendered strings. A clipped glyph is a paint-time fact with no representation in the tree |
| Maestro | One flow (.maestro/smoke.yaml) — launch the app, wait for id: welcome-story to be visible | No. It asserts a single testID exists. It never opens Profile, Settings or a picker |
| Playwright | Does not exist | — |
| type-check / eslint / prettier | Types, rules, formatting | No. lineHeight: 22 on a 40px glyph is perfectly valid TypeScript |
| Visual regression | Does not exist | This is the layer that would have caught it |
So this was not a testing failure — the tests worked as designed. It is a coverage gap: we have no layer whose job is "does the UI look right", and three of the four gates above are structurally incapable of having that job.
Two things made it worse than a missing layer
AppTexthad no test at all. Every string in the app flows through it; 24 othersrc/uiprimitives have co-located tests, and the one with the widest blast radius had none. Both bugs live in ~6 lines of it.- Nothing asserted the resolved style. Even the tests we had asked "is the text there?", never "with what line height?". The new
AppText.test.tsxasserts the flattened style, which is the only place either defect was ever observable — and both new tests fail against the old code, which is the bar for a regression test.
What to adopt, in order of value per unit of effort
Ranked deliberately. Tier 1 is cheap and catches the class of bug we just hit; tier 4 is the expensive one that catches everything else. Adopting tier 4 first is the classic mistake — screenshot suites built before the invariants are pinned produce hundreds of diffs nobody reads.
Tier 1 · Invariant tests on the primitive layer — recommend now
Not screenshots: assertions on the resolved style, which are fast, deterministic, and run in the existing jest gate with no new infrastructure.
- Done already (this change):
AppText.test.tsxpins line-height clamping andfontSize-override coupling;font-metrics.test.tsre-derives every font's natural line height from the shipped.ttfbinaries, so a font swap cannot silently reintroduce clipping. - Next: extend the same shape to the rest of
src/ui— a control's touch target is ≥ 44×44 (Apple HIG) / 48×48 (Material); a container that setsoverflow: hiddennever also carries elevation (the iOS shadow-clipping seam); no component hard-codes a colour.
Why first: it turns a whole bug class into a compile-time-ish guarantee, costs no CI minutes, and needs no device. The clipping bug becomes impossible rather than detectable.
Tier 2 · A lint guardrail against the coverage gap — recommend now
The haptics complaint had the same shape as the layout one: the primitive was correct, and ~30 call sites bypassed it. PressableScale was used by 6 files while raw <Pressable> appeared in ~30.
Add an ESLint rule banning raw Pressable/TouchableOpacity in apps/mobile/src/** outside src/ui, with a documented allowlist for the legitimate non-button uses (a sheet backdrop, a focus proxy over a text input). This is the same pattern as tools/check-sql-grants.js — the enforcement layer that exists because review demonstrably missed it.
Why: a "use the primitive" convention that is not machine-checked decays back to 6-of-36 within weeks.
Tier 3 · Real Maestro coverage — recommend now (cheap, already installed)
One flow that asserts one testID is not E2E coverage. Add a flow per screen that navigates to it and asserts its key elements are visible. Maestro's assertVisible is genuinely useful here: it fails when an element is not visible, which catches a control pushed off-screen or under the notch — a real subset of layout bugs, at near-zero cost, on both platforms.
Limits, stated honestly: Maestro cannot see clipped glyphs inside a visible view. It would not have caught this bug. It catches overflow and off-screen, not truncation.
Tier 4 · Visual regression (screenshot diffing) — the layer that actually catches truncation
This is what you are really asking for, and it is the only mechanism that would have caught these screenshots automatically.
| Option | Fit | Cost / risk |
|---|---|---|
Playwright + toHaveScreenshot() on the RNW web export | Runs on Linux CI, free, on the existing expo export -p web output. Catches type-scale, spacing, truncation and overflow regressions on every PR | Does not test the natives. Diffs on the surface where clipping is least likely, since CSS does not clip. Would have caught the fontSize desync (visibly wrong everywhere) but not the ratio bug |
| Maestro screenshots + a diff step | Real Android + real iOS pixels | Needs a device/emulator in CI; iOS needs a macOS runner we do not have (QRS-118); notoriously flaky across OS versions |
| Storybook + Chromatic / Percy | Best-in-class review UX, per-component | Paid at any real volume; conflicts with the zero-burn constraint |
react-native-owl / native snapshot libs | Native pixels | Immature, needs prebuild wiring on both platforms |
Recommendation: start with Playwright on the web export — free, Linux-only, and it establishes the screenshot-baseline workflow (review, approve, commit) which is the part teams get wrong. Then extend to native screenshots once a macOS runner exists, since without one the iOS baseline can only ever be generated by hand on your Mac mini, and a baseline nobody can regenerate in CI is a baseline that will be deleted within a month.
Be clear-eyed about what web screenshots buy us
Because CSS lets glyphs overflow instead of clipping, the web surface is the least sensitive place to detect exactly this bug. Web visual regression is worth doing for spacing, wrapping, overflow and theme regressions — but it must not be sold internally as "we now catch text clipping", or it will create false confidence in precisely the area that just failed. The tier-1 invariant tests are what close this hole.
Tier 5 · Responsive + accessibility sweeps — recommend, scoped
- Responsive: run the web-export screenshots at a fixed set of widths (360 / 390 / 414 small-phone, 768 tablet, 1280 desktop). Catches the tablet and desktop-RNW cases nobody opens by hand.
- Font scaling: RN honours the OS text-size setting. A user at 200% text size is the highest-yield truncation case in the product and nothing tests it today. Cheap version: render key screens with a forced
fontScalein jest and assert nothing setsnumberOfLines={1}on a growable label. - Accessibility:
eslint-plugin-react-native-a11yfor missing roles/labels, plus Playwright +axe-coreon the web export. Note the touch-target check belongs in tier 1, not here — it is a style assertion.
What we got wrong building it (2026-07-26)
Kept because it is the most transferable thing this exercise produced.
A flaky wait made assertions pass vacuously. The suite initially measured after networkidle. On /profile that is before hydration — 18 nodes, zero text. So the touch-target check found zero controls, therefore zero offenders, therefore passed — while eight real offenders sat on that screen. Fixing the wait turned a green test red and revealed them.
That is the green-no-op pattern (QRS-013) reappearing inside the gate built to prevent it. The generalisable rule: a test that measures nothing passes. Any check that enumerates offenders must also prove it was looking at something — which is why the readiness wait keys on a settled tree (two identical non-zero samples) rather than a network event, and why timeouts are never the fix for a race.
Two temptations refused, and worth naming so they stay refused:
- Widening the touch-target threshold below 44 to get green. That would have permanently hidden QRS-190.
- Exempting the clipped-text check's failing case without understanding it. The wrapper case turned out to be genuinely unmeasurable at that level on RNW (a ScrollView compiles to an
overflow: hiddenbox around a scrollable inner), so the check was narrowed to leaf text elements for correctness — with the consequence stated in the code, since that is exactly the bug class it exists for. Narrowing with a written reason is legitimate; silencing is not.
What this does NOT fix, and what would
Worth stating so the proposal is not oversold:
- No macOS CI runner (QRS-118) means iOS is the one surface where every automated layer is unavailable. It remains manual until that is funded. This is the single biggest structural weakness in the parity guarantee.
- Devanagari (hi/mr) clips worse than Latin — Noto Devanagari needs 1.304× vs Plus Jakarta's 1.26×. English-only manual testing cannot surface it. Any screenshot suite must include a non-English locale, or it will keep passing while hi/mr is broken.
- Physical-device rendering (real OS font rendering, real notch, Low Power Mode suppressing haptics) is not reproducible in CI at any tier. The device checklist in parity-verification stays mandatory.
Proposed decision
| Adopt now (this sprint) | Adopt next | Gate on a macOS runner |
|---|---|---|
| Tier 1 invariant tests · Tier 2 lint guardrail · Tier 3 Maestro per-screen flows | Tier 4 Playwright web screenshots (incl. a hi/mr locale) · Tier 5 responsive widths + a11y lint | Native iOS/Android screenshot diffing |
Tracked as QRS-184. Tiers 1 and 2 are partially delivered by the change that prompted this page; the rest needs your call on scope before it is built.