Skip to content

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:

  1. Structure — one RN codebase renders all three surfaces, so most parity holds by construction. Divergence can only enter at a seam.
  2. Tests — a seam that a test can observe should be pinned by a test, not by a checklist item.
  3. 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.

SeamWhy it divergesCurrent state
Press feedbackRN maps it onto three different mechanisms; android_ripple is a silent no-op on iOSUnified 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 compilationThe 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:rootFixed + 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 clipAudited. Fixed in SetuCardPreview (outer shadow view + inner clipping view). Re-audit whenever a clipped view gains elevation
Safe-area insetsNative reads them from the OS; the web needs viewport-fit=cover or env(safe-area-inset-*) is 0Fixed in src/app/+html.tsx. This was the whole iOS PWA drift
Installability / app chromeNative gets it from app.json; the web needs a manifest + apple-* metaFixed — public/manifest.json, generated icons, +html.tsx
Viewport heightiOS Safari's collapsing toolbars make 100% resolve against the large viewportFixed — 100dvh chain in +html.tsx
Document headOnly 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 SimulatorFixed — @/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 metricsPer-platform text measurement differs, so measured gradient words can mis-sizeGradientText/GradientHeadline/FitBoxmanual check required
FontsNative blocks on useFonts; the web paints then swapsSplash holds until fonts resolve; verify no flash on Safari
Layout animationsreanimated layout animations are the least reliable under RNWVerify Sheet drag, Toast enter/exit, PressableScale on Safari
Native modulesCamera/QR, push, storage, share, clipboard, deep links, offlineEach 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.

FixVerified onBroke on
QRS-203 — register cssInterop on PressableScaleweb (measured, correct)both natives — every PressableScale rendered with no style at all
QRS-206 — remove the OS-keyed media query from theme.cssweb (6-combo e2e matrix, correct)both natives — reintroduced the exact two-channel split it was fixing
QRS-207 — Android material ripple per product speciOS + 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 nativenpm 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 nativeexpo 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 PWAnpm 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 respects disabled + the per-control opt-out. The visual half is deliberately not asserted: RN resolves android_ripple at 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 to BrandSplash has 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 @media block (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.

GateBlind 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-parityCompares 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:designEnforces 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.

LayerWhatWhenCeiling
0 · Claude Code hooksBlocks 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 pushedat the editsees intent, not rendering
1 · npm run check:parity7 static rules, each citing its incident, all mutation-testedpre-commit · pre-push · CIencodes yesterday's defects only
2 · Playwrightlayout invariants + the 6-combo theme matrixCIweb bundle only
3 · native probespecified, not built — a /__parity route the app renders so one text assertion covers all three surfacesCI + devicethe layer that would catch 3 of the 4
4 · weekly auditspecified — reports coverage gaps; never defers a fix that could be gatedscheduledreporting 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 behave

What 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:ios to 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.