# Renderer parity

What each target actually does, verified rather than intended. If a row here is
wrong, the row is the bug.

The **spec** — `kluuu-spec.json`, generated from `packages/mascot/src` — is
platform-neutral and identical everywhere. What differs is the renderer: the
code that turns rig numbers into drawing commands.

There are four of them since 1.9.0. React Native ships two renderers of the
same component — one drawing with `react-native-svg`, one with Skia — because
the SVG one cannot hold 60fps on a phone and the reason has nothing to do with
the mascot. See [Two React Native renderers](#two-react-native-renderers).

| | Web (DOM/SVG) | RN (SVG) | RN (Skia) | SwiftUI |
|---|---|---|---|---|
| Source | `packages/mascot/src` | same, via `@kluuu/mascot/react-native` | same, via `@kluuu/mascot/react-native-skia` | `native/swiftui` (port) |
| Drawing target | SVG DOM, patched per frame | `react-native-svg`, a view per node, via state | one `SkPicture` per frame, via state | `Canvas` / `GraphicsContext` |
| Peer it needs | none | `react-native-svg` | `@shopify/react-native-skia` | none |
| Distributed as | npm tarball, or the embed bundle | the same tarball, or a source zip | the same tarball, or the same source zip | a source zip |

## The character

| | Web | RN (SVG) | RN (Skia) | SwiftUI |
|---|---|---|---|---|
| 90 moods | ✅ | ✅ | ✅ | ✅ |
| Rig (56 numeric + 7 discrete channels) | ✅ | ✅ | ✅ | ✅ |
| Springs + mood routing | ✅ | ✅ | ✅ | ✅ |
| Idle: breath, drift, lean | ✅ | ✅ | ✅ | ✅ |
| Per-mood oscillators + jitter | ✅ | ✅ | ✅ | ✅ |
| Blink scheduler (+ KO never blinks) | ✅ | ✅ | ✅ | ✅ |
| Velocity-driven squash | ✅ | ✅ | ✅ | ✅ |
| Baseline asymmetry | ✅ | ✅ | ✅ | ✅ |
| 48 props | ✅ | ✅ | ✅ | ✅ |
| Prop layering (behind / worn / front) | ✅ | ✅ | ✅ | ✅ |
| Prop morph in-out on swap | ✅ | ✅ | ✅ | ✅ |
| Prop variants (20, `propVariant`) | ✅ | ✅ | ✅ | ✅ |
| Eye glyphs (11) | ✅ | ✅ | ✅ | ✅ |
| Mouth glyphs (6) | ✅ | ✅ | ✅ | ✅ |
| Body patterns (4) | ✅ | ✅ | ✅ | ✅ |
| `colourMode: brand` | ✅ | ✅ | ✅ | ✅ |
| `theme` — props drawn for a dark ground | ✅ | ✅ | ✅ | ✅ |

**`PROPS` has 49 entries and 48 of them are props.** The 49th is the sentinel
`none`. Of those 48, `buildProp` draws 47: `shades` is returned as `nil` by the
prop layer in both renderers and drawn by the eye layer instead, because it
replaces the eyes rather than sitting on top of them. Quote 48 when you mean
"things you can ask for" and 47 when you mean "cases in the prop switch"; the
studio and this file both mean 48.


## Driving it

| | Web | RN (SVG) | RN (Skia) | SwiftUI |
|---|---|---|---|---|
| Reduced motion, followed from the OS and re-read at runtime | ✅ | ✅ | ✅ | ✅ |
| …overridable in both directions | ✅ | ✅ | ✅ | ✅ |
| `paused` without unmounting | ✅ | ✅ | ✅ | ✅ |
| `fps` cap | ✅ | ✅ | ✅ | ✅ |
| `background` | ✅ | ✅ | ✅ | ✅ |
| Pauses off screen | ✅ | ❌ | ❌ | ✅ |
| Settled-mood callback | ✅ | ✅ | ✅ | ✅ |
| Imperative handle (`setMood`, `setParams`, `blink`) | ✅ | ✅ | ✅ | ✅ |
| Options changeable after mount | ✅ | ✅ | ✅ | ✅ |
| Per-instance seed by default | ✅ | ✅ | ✅ | ✅ |
| Sustains 60fps on a mid-range phone | n/a | ❌ | ✅ | ✅ |

The two React Native columns are identical by construction rather than by
inspection: since 1.9.0 everything in this table except the drawing itself lives
once — in `core/rn-runtime.ts` for the mascot, `core/rn-clouds-runtime.ts` for
the band and `core/rn-os.ts` for the two settings both read — and every adapter
delegates to it. `adapter-parity.test.ts` asserts that none of them has grown a
second ticker, a second controller or a second reading of the OS settings.

Three of these need their footnote read rather than their tick:

- **Off screen.** The web gets it from `IntersectionObserver`, in all four
  adapters. SwiftUI gets it free, because `TimelineView(.animation)` stops
  ticking when the view is not on screen and when the app is backgrounded.
  React Native has no equivalent — there is no observer and no scheduler that
  knows — so both ❌s are real, and `fps` plus `paused` are what a list cell has
  instead. Do not read the web's ✅ as "every instance, everywhere": it is a
  browser API, and a `<canvas>`-less server render has nothing to observe.
- **The imperative handle** is a class instance on the custom element, a
  `useImperativeHandle` ref on all three React adapters, `expose` on Vue, and on
  SwiftUI a second initialiser, `MascotView(driver:)`, that renders into a
  `MascotDriver` the host owns and can call. Before 1.8.0 SwiftUI had the
  methods and no way to reach them.
- **Pinning a discrete channel** differs by necessity. `setParams` takes
  `Partial<RigParams>` on the web, which carries `eyeGlyph`, `prop` and the rest
  alongside the numerics. Swift's `setParams` is typed
  `[RigParams.NumericKey: Double]` and cannot express them, so the one method
  splits into `setParams` and `setGlyphs`. Same capability, two names.
  Likewise `sceneAt(timeMs, stepMs)` on the web is `seek(to:stepMs:)` in Swift —
  identical body, different spelling, and neither is a gap.

## Scenery

| | Web | RN (SVG) | RN (Skia) | SwiftUI |
|---|---|---|---|---|
| Cloud band | ✅ | ✅ | ✅ | ✅ |
| 27 parameters, 7 presets | ✅ | ✅ | ✅ | ✅ |
| Flying items (books, pencils) | ✅ | ✅ | ✅ | ✅ |
| Rocket, and the unsprung `launch` channel | ✅ | ✅ | ✅ | ✅ |
| Reduced motion + `paused` + `fps` | ✅ | ✅ | ✅ | ✅ |

`items` defaults to 0 since 1.7.0: a band is a background, and a background that
throws objects across itself competes with whatever is laid over it.

## Over the air

| | Web | RN (SVG) | RN (Skia) | SwiftUI |
|---|---|---|---|---|
| Fetch, verify, store, roll back | ✅ | ✅ | ✅ | ✅ |
| Install into the renderer | ✅ | ✅ | ✅ | ✅ |
| Live swap on an already-mounted instance | ✅ | ✅ | ✅ | ❌ |

All three verify an Ed25519 signature over canonical JSON and bind the bundle by
SHA-256. What differs is where the arithmetic comes from: WebCrypto on the web,
CryptoKit on Apple platforms, and on React Native a pure-TypeScript SHA-256 /
SHA-512 / Ed25519 in `packages/mascot/src/client`, because `crypto.subtle` is
not there and asking every host app to install a polyfill is not a fallback. The
web build prefers WebCrypto and drops to the same TypeScript when the runtime
has `crypto.subtle` but not the `Ed25519` algorithm — Node before 18.4 and
Safari before 17 both look like that, and neither means the signature is bad.
`cryptoBackend()` reports which one answered rather than leaving it to be
guessed, and the reason strings distinguish `unsupported` from `forged`.

Measured cost of the pure-TypeScript path on a JIT-less engine, which is a fair
proxy for Hermes: the signature is ~2.6 ms here and ~8–13 ms on a mid-range
phone; the digest of today's 85 KB bundle is ~46 ms here and ~150–230 ms there.
`sha256Async` slices it and hands the thread back between slices, so no frame is
dropped. The policy's 2 MB ceiling is 24× the real bundle and is the one number
worth lowering.

Two deliberate asymmetries, both fail-closed:

- **A live swap** is web and React Native only. `onSpecChange` exists there
  because a page keeps long-lived instances mounted inside a `[]`-effect, and a
  spec that installed while a mascot was on screen would otherwise be ignored
  until a remount. SwiftUI promotes on cold start: `MascotSpec.install` sets the
  spec every view built afterwards reads, and nothing observes it changing
  mid-session. That is a smaller surface, not a missing one, and it is the
  safer default of the two.
- **A lone surrogate escape** in the index (`"\ud800"` with no pair) verifies on
  web and React Native and is refused on iOS with "index is not JSON". A Swift
  `String` cannot hold one, so the canonicaliser would have to substitute
  U+FFFD and hash bytes that are not the bytes that were signed. It refuses
  instead. No signer in this repo can emit one.

The backend also shows up in the reason string: `signature verified (cryptokit)`
on Apple platforms against `(webcrypto)` or `(pure-js)` on the others.

## Authoring, which is web-only by design

| | Web | RN (SVG) | RN (Skia) | SwiftUI |
|---|---|---|---|---|
| Animated-SVG export | ✅ | n/a | n/a | n/a |
| Bulk raster export | ✅ | n/a | n/a | n/a |

Baking an animated SVG and rasterising eighty moods are studio tools. They run
where the studio runs and there is nothing for a device to do with them.

## Two React Native renderers

They are the same component. Same props, same defaults, same imperative handle,
same frames off the same controller; the import path is the whole difference,
and `adapter-parity.test.ts` compares the two prop interfaces key for key so it
stays that way.

What differs is the last inch, and it is worth being precise about it, because
the gap it closes has been misread as an implementation bug more than once.

**The SVG renderer turns a scene into a host component per node.** A busy mood
like `goldfish` is 194 of them; each is a shadow node with a native view behind
it, and the whole tree is rebuilt and re-committed through Fabric on every
frame. That is the ceiling. It is not the rig, which is a few hundred flops, and
it is not `buildScene`, which is the same function the web runs uncapped at 60 —
it is the view tree, and there is no way to make it cheaper while it exists. The
web adapter avoids the equivalent cost by rendering once through React and then
writing attributes imperatively; React Native has no such escape hatch under the
New Architecture, because `setNativeProps` is deprecated. So `fps={30}` on
Android is not a workaround for a defect, it is the correct use of the prop that
exists for it.

**The Skia renderer has no view tree.** A frame is recorded into one `SkPicture`
— a list of draw calls — and replayed on the render thread, so React sees a
single element whose single prop changed. 194 view commits become 194
`drawPath`s into a display list, which is the same order of work the web does
writing 194 attributes, and it holds 60 on both platforms.

Two things it deliberately does not do. It does not move the rig onto the UI
thread: worklets would mean a second worklet-compiled copy of the controller,
the spec registry and 6,600 lines of prop geometry — a fork of the library to
save the cheapest part of the frame. And it does not cap itself by default,
because there is no longer a reason to.

Everything that is not drawing lives once and both adapters delegate to it: the
controller's lifetime, the ticker, the frame-rate cap, the spec subscription,
the seed and the handle in `core/rn-runtime.ts`, the two OS settings in
`core/rn-os.ts`. The two renderers cannot drift on any of it, which is a
stronger guarantee than the one this file was previously able to make about
them.

Split across three modules rather than one for a reason worth stating, because
the obvious arrangement is wrong: Metro does no tree shaking, so anything in the
import graph is bytes in the app. Holding the mascot's runtime beside the cloud
band's put `core/clouds.ts`, `clouds-rocket.ts`, `clouds-items.ts` and
`spec/scenery.ts` — 51 kB a mascot can never reach — into
`@kluuu/mascot/react-native`, where the equivalent web entry point carries none
of it. The dependency runs one way only: the band needs the mascot's scene
builder, because the rocket carries a passenger built from it.

The drawing itself is `core/skia-paint.ts`, and it is checked rather than
trusted — see "Running the checks": every frame the SwiftUI comparison uses is
also rendered through real Skia and diffed against the SVG renderer's pixels.

## Why React Native is not a port

It is the same TypeScript. Both React Native adapters swap the leaf primitives —
for `react-native-svg` in one case and for Skia draw calls in the other — and
share the rig, the scene builder, the props and the controller with the web
build. There is no second copy to drift.

Sharing the TypeScript is not the same as sharing the *rendering*, and 1.8.0
found two places where that gap had opened. The React Native cloud band never
mounted the `defs` its drawing declared, so the rocket's porthole clip was never
defined and the passenger drew unclipped; and its node renderer had no branch
for circles, so the eleven circle nodes the passenger contributes — pupils,
catchlights, blush — were each emitted as a `<Path>` with no `d` and drew
nothing. Both were invisible to every test that compared *data*, because the
data was right. `adapter-parity.test.ts` now drives a real `buildClouds` with a
rocket in it and asserts the adapter mounts what it declares.

## Why SwiftUI is a port

Swift cannot run the TypeScript, so `native/swiftui/Sources/KluuuMascot` is a
hand port of the same algorithms. The risk is drift, and the mitigations are:

- **The spec is not ported.** Geometry, the rig table, motion constants and all
  90 moods are decoded from the same generated `kluuu-spec.json` the web build
  emits. Regenerate with `node native/swiftui/Tools/gen-spec.mjs`.

  That is also why the eight rank-tier moods needed no Swift beyond the three
  new `Prop` cases and the drawings behind them: the ladder itself — every
  colour, pose, oscillator and pip count — is data.
- **The pictures are compared, not trusted.** `npm run parity` renders every
  mood and every cloud preset through all three drawing implementations and
  diffs the pixels; see "Running the checks".
- **The controller is compared frame by frame.** The same command steps both
  controllers on one 60 Hz schedule and asserts all 63 channels agree to 1e-6,
  which is the only way a drift in the blink envelope or the idle layer shows
  up at all — none of it changes a still frame.
- **The integrator is pinned.** Semi-implicit Euler, fixed 1/240 s substep, `dt`
  clamped to 32 ms, same rest thresholds. `ParityTests` asserts the damping
  ratios stay under-damped, because a spec bump that quietly critically damps
  the body springs would make the character stop feeling playful and fail
  nothing else.
- **The particle hash is pinned.** Same constants as `props.ts`. If it drifts,
  the two platforms scatter confetti differently — obvious side by side and
  invisible in review.
- **The canonical JSON is compared across languages.** Swift's
  `JSONEncoder(.sortedKeys)` is not byte-identical to `JSON.stringify` — it
  escapes `/`, prints `1e-07` for `1e-7`, `1e+20` for `100000000000000000000`
  and `-0` for `0` — so `CanonicalJSON` re-implements the ECMA-262 rules
  instead. A signature that fails on iOS only is the failure mode, and it is
  invisible to any test that signs with the same encoder it verifies with, so
  the two implementations were diffed against each other over a hand-built
  corpus of awkward values, 44,366 doubles and 4,000 random documents.

Structural difference: the TypeScript emits SVG `d` strings and clip-path ids;
Swift builds `Path` values and carries the clip inline. SVG needs `<defs>` to
avoid repeating a path, `Canvas` does not, and an id registry would be dead
weight.

## Known gaps

Three, and all of them are in the tables above rather than hidden here: **neither
React Native renderer pauses off screen**, **SwiftUI does not swap a spec on a
mounted view**, and **the SVG renderer on React Native does not hold 60fps**.
Everything else the tables mark ✅ has been exercised.

One divergence is not in the tables because it is not a feature row, and it
should be recorded rather than discovered: **the web mascot paints outside the
artboard and the three native renderers crop it.** `element.ts`, `react.tsx` and
`vue.ts` all set `overflow: visible` on the `<svg>`, and parts of the drawing do
lie outside the 1024 box — confetti by up to 113 units, embers by 112, smoke by
102, which at `size={160}` is about 18dp. `react-native-svg` crops at the view
bounds, SwiftUI's `Canvas` crops at its frame, and a Skia `<Canvas>` crops at
its view bounds, so all three agree with each other and with `sceneToSvg`, whose
standalone `<svg>` crops by default — every exported PNG and every frame in the
parity harness is the cropped picture. It is the live web element that is the
outlier. Changing it would visibly clip artwork that ships today, so it is
stated here instead of quietly fixed.

This section used to be much longer, and most of it was the OTA story: the
transport existed without the install, the install existed without the
transport, and React Native could do neither because it has no WebCrypto. All
three are closed in 1.8.0. One thing worth recording, because it was invisible
for several releases: nothing in this repo ever *signed* the index, so
`verifyIndex` returned false at its first guard for every integrator who
followed the documentation and passed `publicKeys` — on the web as much as on a
phone. A verification path is only as real as the signature it checks.

## Running the checks

```bash
# Web + both React Native renderers
npm test                                             # node --test, 188 tests

# SwiftUI
cd native/swiftui && swift test                      # 109 tests

# Cross-renderer: pixels and controller frames, every direction
npm run parity

# Just the Skia leg. Needs no Xcode.
npm run parity:skia
```

`npm run parity` is the one that answers the question this file is about. It
renders all 90 moods and all 7 cloud presets through the TypeScript build (via
resvg), through the SwiftUI build (via `ImageRenderer`) and through
`core/skia-paint.ts` (via CanvasKit), diffs them pixel by pixel, and separately
steps both controllers on one 60 Hz schedule to compare every rig channel frame
by frame. A shape in the wrong place, a colour off by a step, a prop that never
appears, a blink that runs long: all of it fails here rather than in review.

The Skia leg deserves its own note, because it is not obvious that it can exist
at all. `@shopify/react-native-skia` is a native module and wants a GPU, so the
renderer looks untestable off a device — and a unit test cannot stand in for it:
the recorder in `skia-paint.test.ts` snapshots the paint at each draw call, so
it *models* Skia's copy-on-draw semantics by construction and would pass a frame
in which every shape took the last node's colour. CanvasKit closes that: it is
the same Skia C++ the phone links, compiled to WebAssembly, with the same
`SkCanvas`, the same `SkPaint` and the same path parser. `skia-paint.ts` takes
its Skia binding as a constructor argument for exactly this reason — the module
imports nothing, so the backend can be substituted.

The last run compared 138 frames through Skia and every one was within
antialiasing tolerance — worst case 0.185% of pixels differing by more than the
hard threshold (486 of 262,144, worst channel delta 82), on `mood-tierRainbow`,
against a 0.5% budget. Those differences are the thin rim along every contour
where resvg and Skia disagree about subpixel coverage; the shapes, the colours,
the clips, the group opacities and the transforms are the same picture.

`swift test` needs a real Xcode, not the Command Line Tools. If it reports
`no such module 'XCTest'`, that is what has happened:

```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
```

`tools/parity/run.mjs` already looks for `Xcode.app` and `Xcode-beta.app` and
sets this itself, which is why `npm run parity` works where a bare `swift test`
does not.
