Appearance
Parity verification
How to prove a change works on every surface it applies to, before calling it done.
The rule
Android native · iOS native · Web PWA. A feature enters a release only when it is validated on every surface it applies to. There is no "web-first, mobile later" and no "Android now, iOS next sprint". Per-platform divergence is allowed only where the OS's design guidelines require it, and only when it was approved before implementation and written down. See ADR-0011 (amended 2026-07-26, iOS native moved into R1).
Why the checklist is short and the automation matters more
A hand-run matrix of every screen × platform × theme × language × motion setting is not a control — it is a document that decays. The parity guarantee comes from three things, in descending order of reliability:
- Structure — one RN codebase renders all three surfaces, so most parity holds by construction. Divergence can only enter at a seam.
- Tests — a seam that a test can observe should be pinned by a test, not by a checklist item.
- The manual pass — reserved for what neither structure nor tests can cover: how it actually looks and feels on a real device.
So the discipline is: know where the seams are, automate what you can, and hand-check the rest.
Where parity can actually break
Everything else is shared code. These are the only places drift originates.
| Seam | Why it diverges | Current state |
|---|---|---|
| Press feedback | RN maps it onto three different mechanisms; android_ripple is a silent no-op on iOS | Unified as of QRS-207 — scale + opacity on all three, haptic on both natives. The Android ripple is gone: overlay is the modal-scrim token and RN masks a bounded ripple to the view rect, so it painted a square grey block over rounded-3xl controls. RN has no corner-radius option there |
| CSS dark-mode compilation | The web has a cascade; React Native does not. css-interop parses the stylesheet with darkMode defaulting to media (NativeWind never passes the option), and in media mode no class-based dark block registers. It needs an @cssInterop set darkMode class dark; at-rule first, and the block declared on .dark:root | Fixed + pinned (QRS-206). Fails silently — dark observables carry fallback: light, so native renders light tokens while useThemeColors() returns dark. Both halves asserted by tokens-theme-parity.test.ts; a @media (prefers-color-scheme) block had been carrying native by accident for months |
Elevation + overflow: 'hidden' | iOS clips a view's own shadow when overflow is hidden; Android draws elevation outside the clip | Audited. Fixed in SetuCardPreview (outer shadow view + inner clipping view). Re-audit whenever a clipped view gains elevation |
| Safe-area insets | Native reads them from the OS; the web needs viewport-fit=cover or env(safe-area-inset-*) is 0 | Fixed in src/app/+html.tsx. This was the whole iOS PWA drift |
| Installability / app chrome | Native gets it from app.json; the web needs a manifest + apple-* meta | Fixed — public/manifest.json, generated icons, +html.tsx |
| Viewport height | iOS Safari's collapsing toolbars make 100% resolve against the large viewport | Fixed — 100dvh chain in +html.tsx |
| Document head | Only the web has one. expo-router/head looks web-only but on iOS it is ExpoHead.ios.js (Handoff/Spotlight) and throws at render without an origin in the expo-router plugin — it red-boxed every screen on the Simulator | Fixed — @/ui DocumentTitle is platform-split (.web.tsx real, native no-op) so native never imports it. Pinned by a test asserting the native file has no such import |
| SVG text metrics | Per-platform text measurement differs, so measured gradient words can mis-size | GradientText/GradientHeadline/FitBox — manual check required |
| Fonts | Native blocks on useFonts; the web paints then swaps | Splash holds until fonts resolve; verify no flash on Safari |
| Layout animations | reanimated layout animations are the least reliable under RNW | Verify Sheet drag, Toast enter/exit, PressableScale on Safari |
| Native modules | Camera/QR, push, storage, share, clipboard, deep links, offline | Each needs a working impl or an approved fallback per surface |
A fix is a change to shared code — re-verify it everywhere
This is now a standing rule, added because it was broken three times in two days. A bug reported on one platform gets fixed in code that all three platforms share, so the fix's blast radius is all three — not the one that reported it. Run the checklist below on the fix, not only on the original feature.
| Fix | Verified on | Broke on |
|---|---|---|
QRS-203 — register cssInterop on PressableScale | web (measured, correct) | both natives — every PressableScale rendered with no style at all |
QRS-206 — remove the OS-keyed media query from theme.css | web (6-combo e2e matrix, correct) | both natives — reintroduced the exact two-channel split it was fixing |
| QRS-207 — Android material ripple per product spec | iOS + web (no-op there) | Android only — square grey block over rounded controls |
Each was a correct fix for the platform it was checked on. The failure was never the diagnosis; it was treating one surface's verification as the whole verification. Note the pattern in the middle row especially: the media query was simultaneously a bug on web and load-bearing on native, so removing it needed a native-side replacement in the same change — and only a native build would have said so.
Highest-risk change classes — the native builds are the gate, not npm test / npm run e2e:packages/tokens/** · apps/*/src/ui/** · anything touching NativeWind/cssInterop/className plumbing · theme or colour-scheme resolution · gesture and press handling · Reanimated wrappers.
The checklist
Run this for any change that touches UI or a seam above.
1 · Android native — npm run -w @qrsetu/mobile build:android (or expo run:android), on a real device. Light and dark.
The arm64/x86_64 trap — it is why "check it on native" kept not happening
The shippable local build is arm64-only (single ABI keeps the release build inside the commit limit and the APK inside the size budget). The standard Android emulator is x86_64, so that APK installs and then dies immediately with SoLoaderDSONotFoundError: couldn't find DSO to load: libreactnative.so. That reads like a broken build and is actually just an ABI mismatch — it is easy to conclude "native can't be checked here" and fall back to the web gates, which is exactly the failure mode above. Use npm run -w @qrsetu/mobile build:android:emulator (--abi=x86_64) for emulator verification, and a physical arm64 device for anything you intend to ship.
Token changes need --reset-metro, or a native build proves nothing
Metro keys a transform on the file it transforms. NativeWind compiles Tailwind from global.css, which @imports packages/tokens/src/theme.css — Metro never learns theme.css is an input, so editing a token leaves global.css byte-identical and the stale compiled stylesheet is silently reused. During the QRS-206 investigation this manufactured a completely convincing false negative: expo export -p web had run with --clear (correct web bundle) while the Android build reused a pre-fix stylesheet, reproducing "fixed on the PWA, still broken on native" from cache alone. Use npm run -w @qrsetu/mobile build:android -- --reset-metro after any packages/tokens change.
2 · iOS native — expo run:ios on the Simulator and a physical iPhone (step-by-step: iOS Build & Device Testing). Light and dark. Additionally check what only iOS has: swipe-back against custom headers, the home-indicator inset under the tab bar and FAB, keyboard avoidance, and the status-bar style in both themes.
3 · Web PWA — npm run -w @qrsetu/mobile web:export, serve dist/, then check desktop Chrome and mobile Safari. Install it to the home screen and confirm it opens standalone with the brand icon and correct insets.
4 · Seams — for each seam the change touches, confirm a working implementation or an approved fallback on every surface.
5 · Consistency — theme (light + dark), i18n, gestures, reduced motion, and the shared-token visual seam (the merchant's in-app card preview vs the live public card).
6 · Interaction states — press/focus/disabled/loading feedback on all three surfaces. Specifically: press a rounded control (card, pill, tab, FAB) and confirm the feedback is clipped to its shape with no square artifact; confirm the scale + opacity animation runs; confirm the haptic fires on both natives and nothing visual diverges between them. Its own step because this is where parity has actually broken twice.
7 · Exceptions — any gap is listed with its QRS-### and its prior approval reference. A gap discovered after the fact is a bug, not an exception.
Record the result in the touched folder's README.md and in the feature note. A feature note that does not state its parity status is incomplete.
What is automated today
src/ui/__tests__/PressableScale.test.tsx— asserts the haptic fires on both natives, stays silent on web, and respectsdisabled+ the per-control opt-out. The visual half is deliberately not asserted: RN resolvesandroid_rippleat module load on Android only, so under the test renderer any assertion would be checking the mock rather than the behaviour. That blind spot is exactly what let QRS-207 ship — the square-cornered ripple was only observable by building the APK and looking at it.src/app/__tests__/web-manifest.test.ts— the manifest stays installable, its colors track the tokens, and its icons exist. A broken manifest fails silently (the app just stops being installable), so there is no runtime signal to rely on.src/app/__tests__/native-splash.test.ts— the native splash background equals the surface token on both natives, so the handoff toBrandSplashhas no visible jump.src/ui/__tests__/tokens-theme-parity.test.ts— the token layer itself, including the two selector invariants that each caused a whole-app theme bug: no@mediablock (QRS-201) and dark variables on.dark:root, never a bare.dark(QRS-206).e2e/theme-consistency.spec.ts— all six OS × preference combinations, asserting the resolved class, the rendered luminance and 4.5:1 text contrast. Web only — see the limits below.
What the automated gates CANNOT see
Read this before treating a green run as parity. All three defects in the re-verify table passed every gate below.
| Gate | Blind spot |
|---|---|
npm run e2e (Playwright) | Runs the web bundle only. Nothing about Android or iOS rendering is observed. Two of the three defects were native-only |
npm test (jest-expo) | Mocks Reanimated's createAnimatedComponent to identity, so AnimatedPressable === Pressable under test. Native-only wrapper behaviour — forwarding, interop registration, android_ripple — does not exist to be asserted |
tokens-theme-parity | Compares the stylesheet against the tokens. It cannot know how css-interop's parser treats a selector, which is precisely what QRS-206 turned on — hence the explicit selector assertions now sitting beside it |
check:design | Enforces that a drift row exists, not that the design is right |
The honest summary: automated coverage is a floor. For the high-risk change classes listed above, the native builds are the gate.
What is automated to close those gaps
ADR-0017 layers the automation by what each layer can physically observe, because the four defects above all passed npm test and npm run e2e — adding assertions there would have caught none of them.
| Layer | What | When | Ceiling |
|---|---|---|---|
| 0 · Claude Code hooks | Blocks the bypasses that caused incidents; runs the parity gate the instant a systemic file is edited; reports at session end whether the work is committed and pushed | at the edit | sees intent, not rendering |
1 · npm run check:parity | 7 static rules, each citing its incident, all mutation-tested | pre-commit · pre-push · CI | encodes yesterday's defects only |
| 2 · Playwright | layout invariants + the 6-combo theme matrix | CI | web bundle only |
| 3 · native probe | specified, not built — a /__parity route the app renders so one text assertion covers all three surfaces | CI + device | the layer that would catch 3 of the 4 |
| 4 · weekly audit | specified — reports coverage gaps; never defers a fix that could be gated | scheduled | reporting only |
Run the fast layers yourself any time:
bash
npm run check:parity # ~200ms, whole-tree
npm run test:hooks # the hooks are controls; this proves they still behaveWhat is not automated yet
Tracked, not forgotten:
- Snapshot tests on the primitive layer, rendered per platform.
- Maestro flows run on both natives (the topology decision is already made).
- A macOS CI runner compiling iOS on release branches — without it, an iOS build break is only found by hand.
Until those exist, the manual pass above is the control, which is why it is scoped to the seams rather than to every screen.
Free iOS provisioning — the constraints
Development and verification need no Apple Developer Program membership. Plan around these:
- The free profile expires every 7 days; the installed app then refuses to launch. Re-run
expo run:iosto re-sign. This makes free signing fine for dev, unusable for sustained parallel QA. - 3 apps per device.
- No TestFlight. Push notifications and Associated Domains (iOS universal links) are paid-only — which is why
qrsetu://is the iOS interim deep-link scheme.
Buy the $99 membership when distribution is needed, not before.