pyreon

Multi-Platform Pyreon

Status: PMTC (Pyreon Multi-Target Compiler) is experimental — demo-quality, not production-ready. The single number for "how much is device-proven" is the gated weighted matrix at the bottom of this page (check-multiplatform-matrix fails CI when its headline disagrees with its table) — do not quote a score from memory or from an older revision of this page. The 15-primitive canonical vocabulary spans all three targets — every primitive has a real web DOM runtime AND emits SwiftUI + Jetpack Compose. Be precise about what "validated" means, because the layers differ sharply in strength:

  • (1) Per-PR gate — SYNTAX + STUB TYPE-CHECK. swiftc -parse (syntax, accepts unresolved types) runs on every emit; kotlinc type-resolves against Compose stubs; and swiftc -typecheck type-resolves against SwiftUI+PyreonRuntime stubs (swift-stubs.ts, the Swift sibling of kotlin-stubs.ts) — the stubs' generic constraints mirror the real SDK EXACTLY (animation<V: Equatable>, so a [Todo] value is correctly REJECTED) so this catches the type-corruption class that pure -parse waves through. Scope: the stub -typecheck now covers the 2 example apps + all 37 compiler fixtures — canonical primitives, common modifiers, i18n/machine/permissions/link/webview, the rx fixtures, the router-hook surface (PyreonRouter/useNavigate/useParams/matchPath/RouterProvider), the PyreonForm binding surface, and every @Observable fixture including the two LARGE showcase apps (showcase-finance/showcase-tasks, closed by M-gate.1f with PyreonAuth/PyreonDatabase/PyreonFetch + LazyVStack/Color/Font.custom/.foregroundColor/.task/navigationDestination stubs, all mirrored from runtime-swift). It also compiles an example's app-provided useNativeModule classes alongside the emit — the framework cannot stub a class the app owns, and a synthesized stand-in would mask a mismatched method name. The gate keeps earning its keep by SURFACING real emitter bugs that were then FIXED so the emitted native code actually compiles: rx .first/.last/.min/.average now infer Optional/Double bindings; a null-returning component emits EmptyView() not body { nil }; a top-level object-shape interface is SYNTHESIZED into a struct/data-class; and — found while closing the showcase apps — useDatabase's get/delete/find emitted Swift WITHOUT the argument labels the runtime declares (delete(_ collection:id:)), so those calls had never compiled. Argument labels are a TYPE-level concern, which is exactly why only a -typecheck gate could see it — see the silent-failure cliff below.

  • (2) Real-toolchain BUILD of the gated example appsxcodebuild (full Swift compile) + gradle assembleDebug — via the native-device workflow (auto-runs on native-path PRs; REQUIRED — both build jobs are branch-protection required checks, promoted after the 14-green-nightly streak gate passed). This is the only place type-checking against the REAL SDK (not stubs) happens, and only for those apps.

  • (3) Launch + interaction UI smokes — XCUITest (iOS Simulator) + Compose-instrumented-test (Android Emulator). Strong for the Tasks/Counter/Router examples (login validation, store mutation, typed-params nav, content-asserted fetch, Suspense/ErrorBoundary all assert real behaviour); launch-only for TodoMVC; absent for native-analytics.

Layers 2–3 run automatically on every PR that touches the native pipeline (packages/native/** / examples/native-*/**, via the workflow's fail-closed changes job — the repo is public, so the macOS runner is free; the native-device label remains the manual opt-in for path-external changes) plus the nightly schedule. Their conclusion is REQUIRED: iOS — xcodebuild (Simulator SDK), Android — gradlew assembleDebug, Validate emitted Swift + Kotlin, and Validate emitted Swift (real-SDK typecheck, macOS) are all in branch protection's required checks (promoted after the encoded check-native-device-streak.ts gate — 14 consecutive green nightlies — passed; the workflow shape is required-check-safe by construction: job-level skips, no trigger-level path filter, so a non-native PR satisfies the checks via skip). So a PR that touches the native pipeline cannot merge unless its emit builds and runs its UI assertions on a real iOS Simulator and Android Emulator. What stays weaker: only the gated example apps get that treatment — a capability no gated app exercises still ships on stub-typecheck strength alone.

The pitch

Write your app once. Run it on the web, iOS, and Android — each rendered with the platform's native primitives. (Web runs live today; iOS/Android are emitted as SwiftUI/Compose and build cleanly for the example apps — read "What runs on native" and the validation Status above for the honest scope, including which packages are web-only.)

// examples/native-todomvc-ios/src/TodoApp.tsx — single source, three targets
import { signal, computed } from '@pyreon/reactivity'
import { useStorage } from '@pyreon/storage'
import { Stack, Inline, Text, Field, Button } from '@pyreon/primitives'

export function TodoApp() {
  const todos = useStorage<Todo[]>('todos', [])
  const draft = signal('')

  return (
    <Stack gap="md">
      <Field
        value={draft}
        onChangeText={(text) => draft.set(text)}
        placeholder="What needs to be done?"
      />
      <For each={todos} by={(t) => t.id}>
        {(t) => (
          <Inline gap="sm">
            <Text>{t.text}</Text>
            <Button onPress={() => /* ... */}>Remove</Button>
          </Inline>
        )}
      </For>
    </Stack>
  )
}

This single file compiles to:

  • Web via @pyreon/runtime-dom<Stack> becomes <div style="display:flex;flex-direction:column">, <Field> becomes <input>, etc.

  • iOS via PMTC → SwiftUI — <Stack> becomes VStack, <Field> becomes TextField("", text: $draft), etc.

  • Android via PMTC → Jetpack Compose — <Stack> becomes Column, <Field> becomes TextField(value, onValueChange), etc.

Same source. Three idiomatic outputs (web rendered live; iOS/Android emitted as SwiftUI/Compose — see the validation reality in the Status note above).

What runs on native — and what's web-only (read this first)

PMTC compiles your component source — the 16 canonical primitives, signal/computed/effect, and a fixed set of hooks — to SwiftUI/Compose. It does NOT transpile npm packages to native. So a package runs on iOS/Android only if it is pure reactive logic with a Swift/Kotlin runtime port the compiler recognizes. Anything bound to the DOM, a <canvas>, the CSSOM, or a JS-only rendering vendor is web-only by architecture — no compiler setting changes that.

The fixed hook set is not a ceiling on platform capability. For anything the framework does not ship — Bluetooth, ARKit, a vendor SDK — useNativeModule lowers to a Swift/Kotlin class you provide, so adding a platform API is an app-level change rather than a framework PR.

✅ Runs on web + iOS + Android today (real runtime ports + compiler emit):

  • @pyreon/reactivity (signal/computed/effect), @pyreon/primitives (the 16 canonical primitives)

  • @pyreon/store, @pyreon/machine, @pyreon/state-tree, @pyreon/i18n, @pyreon/permissions

  • @pyreon/form (validated forms — device-proven), @pyreon/storage (platform-storage backend)

  • the native @pyreon/router port (useNavigate/useParams/useLoaderData, nested routes, beforeEnter)

  • the subset of @pyreon/hooks with ports: useFetch, useOnline/useNetworkStatus, useAppState, useClipboard, useColorScheme

    • useFetch needs its response type on iOS: useFetch<Resp>(url). Without the generic the Swift emit decodes into Any, which cannot conform to Decodable, so the iOS build fails — Android compiles either way. The compiler now warns rather than letting you find out at build time.

    • useParams() must be destructured: const { id } = useParams() lowers per key. Binding the whole object (const p = useParams(); p.id) reads a property off a native dictionary/map and compiles on neither target.

❌ Web-only by design (a hard DOM/canvas/vendor dependency the compiler has no path for — these will NOT be native-rendered as SwiftUI/Compose; PMTC can't compile echarts/CodeMirror/ProseMirror/etc. But several CAN be hosted in a <WebView> — see the bridge escape hatch right after this table):

PackageBlocking dependency
@pyreon/flow (the <Flow> JSX host)SVG/CSS-transform pan-zoom + DOM pointer events; createFlow itself DOES lower (PyreonFlowState on both targets — see the tier table), the host/gestures/layout/chrome do not yet
@pyreon/charts (the echarts facade: Chart, useChart, <OptionChart>)echarts (renders to <canvas>) — but @pyreon/charts/plot is NOT in this table: Pyreon's own plot engine and its hosts render natively (see the Charts row of the capability matrix)
@pyreon/codeCodeMirror 6 (DOM editor) + a <canvas> minimap
@pyreon/dnd@atlaskit/pragmatic-drag-and-drop (HTML5 drag events on HTMLElement)
@pyreon/document / @pyreon/document-primitivespdfmake/docx/exceljs/pptxgenjs + Blob/document download
@pyreon/queryTanStack Query core + SSE/WebSocket hooks (use useFetch for simple data on native)
@pyreon/table / @pyreon/virtualTanStack headless cores bound to DOM cells / scroll containers
@pyreon/hotkeyswindow keyboard listeners
@pyreon/elements, @pyreon/styler, @pyreon/rocketstyle, @pyreon/coolgrid, @pyreon/kinetic, @pyreon/unistyle, @pyreon/ui-core, @pyreon/ui-componentsthe web CSS-in-JS / DOM stack (Layer 3b) — native apps use @pyreon/primitives (Layer 3a) instead

🌉 Escape hatch — host a web-only component in a <WebView> (the bridge). The "❌" packages can't be native-rendered, but a <WebView> embeds a real browser engine (WKWebView on iOS, Android WebView), so the web component runs inside it — echarts' canvas, flow's SVG host, CodeMirror, a document preview. The bridge is bidirectional:

  • Forwarddata={metrics()} is pushed into the page as window.__pyreonData (+ a pyreondata event) so the hosted component updates live, no reload.

  • Reverse — the page calls window.pyreonPostMessage(payload) → your native onMessage={(m) => …} closure.

// examples/native-analytics — a chart hosted natively, both directions:
<WebView html={CHART_HTML} data={metrics()} onMessage={(m) => selected.set(m)} />

This is the right answer for charts / diagrams / code editors / doc previews (you wouldn't reimplement echarts in SwiftUI anyway). Caveats: it's a hosted web view (web look-and-feel, not native widgets), pays WebView boot + bundle weight (echarts ~1 MB), and is best for self-contained panes — not your whole app. The bridge runtime (PyreonWebView.swift/.kt) is real + swift build-clean and native-analytics demonstrates it, but a chart-in-WebView device test isn't in the nightly gate yet (the mechanism is proven; the on-device run isn't CI-gated). For your core app UI (nav/forms/lists/layout) use the native primitives; reach for the WebView only for the rich web-island pieces.

📦 Shipping a local viz bundle — <WebView src="…"> + the web/ staging step. Inline html= embeds a self-contained string; for a multi-file bundle (an index.html + its chart.js/chart.css), use <WebView src="chart.html"> and drop the files in a web/ directory at your project root. The scaffold's build scripts run pyreon-native stage-web, which copies that flat bundle into the exact app location each runtime resolves src against — iOS WebContent/ (an XcodeGen type: group → the files flatten to the app bundle's resource root, found by Bundle.main.url(forResource:)) and Android assets/ (served at file:///android_asset/). This keeps the whole bundle on-device (no remote fetch — the policy-safe path Apple 4.2 / Google's webview policy prefer) and lets chart.html's relative <script src="chart.js"> resolve. v1 is flat-only — a nested subdirectory is skipped with a build warning (the shipped Swift runtime resolves src by bare name with no subdirectory:; nested support is a runtime follow-up), and the on-device render rides the same nightly device rung as the rest of iOS — the staging layout itself is unit-locked, but "the WKWebView actually paints the staged file" is device-gated, not CI-proven.

🟡 Logic could port, but no native runtime exists yet: @pyreon/rx, @pyreon/url-state, @pyreon/toast (the store is pure-logic; the <Toaster> renderer is DOM), and @pyreon/sync's engine-neutral core (the Yjs engine + IndexedDB/WebSocket transports are web/Node-only).

🟢 Schema validation lowers via compile-time codegen (no runtime port needed): @pyreon/validate's s.object({ … }) DSL and @pyreon/validation's zodSchema/valibotSchema/arktypeSchema adapters compile a schema to a native Codable struct / @Serializable data class with a type-checking parse/safeParse. Both the top-level declaration form (const X = s.object({ … }), used by @pyreon/form) and the STANDALONE inline form (s.object({ n: s.number() }).safeParse(x).success — the shape feature code writes to validate data) lower on iOS + Android, verified against real swiftc + kotlinc. Only a LITERAL s.object({ … }) shape lowers; the runtime helper surface (custom .refine() predicates, the async validate path, standardSchemaToValidator) stays web.

The supported-TypeScript-surface ceiling (and the silent-failure cliff)

PMTC compiles a deliberately narrow, declarative subset of TypeScript in component bodies: signal/computed/effect declarations, typed props, the canonical-primitive JSX, <For>/<Show>, if/ternary control flow, array/string method calls, and two type-alias shapes (string-literal union → enum, object literal → struct/data class). Inside that lane it emits real, idiomatic SwiftUI/Compose.

Outside that lane the failure mode is now almost always a NAMED warning — the silent-drop surface is nearly exhausted. The Map/Set collection vocabulary and Date.now() LOWER end-to-end (the rest of Date warns by name), and generics in logic WARN by name; the one enumerated remaining silent shape is — until its in-flight fix lands — component-scope flat array destructure (see the Statements section for the current state); the global router-guard inline-arrow forms now WARN by name (a dropped inline guard would leave the route ungated on native — a security foot-gun — so it can no longer vanish silently; closure-emit stays a documented follow-up); everything else in this paragraph either lowers or warns. Imperative control-flow statements at a component-body top level — for / for…of / for…in / while / do…while / switch / try / throw, and an imperative if (a mutating body) — now fail with a NAMED warning pointing at the escape hatch (move the logic into a helper function that takes parameters — loops + switch DO lower there — compute the value with array methods .map/.reduce/.filter, or render conditionally/iteratively in JSX via <Show> / <For>), no longer a silent drop that left the render on stale values. An EARLY-RETURN conditional render — if (cond) return <JSX> (optionally else return <JSX>, and chained: if (a) return <A>; if (b) return <B>; return <C>) — now LOWERS: it folds to a ternary the emitter emits as a native result-builder conditional view — a SwiftUI @ViewBuilder if cond { A } else { B } (NOT the ? : operator, which swiftc rejects between DIFFERENT view types: sizeClass == "regular" ? HStack {…} : VStack {…} fails "result values in '? :' expression have mismatching types HStack<Text> and VStack<Text>" — the adaptive Stack↔Inline case), Compose if (cond) A else B. Both a DIRECT view-branch ternary (sizeClass() === 'regular' ? <Inline> : <Stack> — the size-class-driven adaptive-layout idiom) and the folded early-return lower this way, verified to swiftc -typecheck + kotlinc (imperative control flow can't sit in a SwiftUI var body result builder — only a conditional VIEW can — so this is the one control-flow shape that lowers directly in a component body). A VALUE ternary (cond ? "a" : "b") stays a ? : expression. Pre-fix the if was dropped (rendered only the fallthrough branch), or — with an else-return and no fallthrough — the WHOLE component was skipped. instanceof/in already warn. (Destructuring of locals is now lane-precise: flat const {a} = obj / const [a, b] = xs LOWER; nested / rest / default patterns — const {a:{b}} = o, const {a, ...r}, const [a, ...r], const {a = 1} — now fail with a NAMED warning pointing at the escape hatch, no longer a silent drop. try/throw and class/new already emit a named "unsupported" warning. A top-level pure-logic helper functionfunction dbl(x: number): number { return x * 2 }, the L1 "shared pure logic" layer — is now EMITTED at file scope as a native func / fun (reusing the same function emitter store methods use), where before it was silently misclassified as a component and mis-emitted as a broken struct dbl: View (its value params dropped, the body referencing an unbound name → a cryptic cannot find 'x' in scope). A call computed(() => dbl(21)) infers the helper's return type (a helperReturns registry threaded into the computeds pre-inference), so the Swift computed annotates Int, not Any (String(dbl(21)) typechecks). The classifier is narrow — only a function that takes value parameters and returns no JSX (a function OF ITS INPUTS) is a helper, so a no-param function C() { …; return out } component-returning-a-value emits unchanged, and a component (return resolves to JSX — directly or through a cond ? <A/> : <B/> / && root) is never misread. BOTH declaration forms are recognized: a function dbl(){} AND a top-level ARROW-CONST const dbl = (x: number) => x * 2 (tryHelperFnFromArrowConst routes the arrow-const into the same helperFns path — before, it fell through to a mis-scoped private let dbl = { x in … } closure + Any, a silent uncompilable mis-emit); a JSX-returning arrow-const (a component), a no-param arrow-const, and a plain module const (const APP = '1.0') fall through unchanged. A helper with NO return-type annotation (function dbl(x: number) { return x * 2 }) also emits — its return type is INFERRED from the body (refineHelperReturns seeds the params + walks for the first return, reusing the same inferReturnType util the emitters use for un-annotated function signatures), so the emit signature AND the call-site helperReturns registry both get the real type. A helper with a FRACTIONAL body (function scale(x: number) { return x * 1.5 }, x / 2, Math.sqrt(x)) also emits: emitSwiftFunction seeds the helper's Int params into the coercion ctx (the same _activeInferCtx.locals the element-callback coercion uses) so x * 1.5Double(x) * 1.5, and refineHelperReturns refines the number return IntDouble so the signature matches (Kotlin auto-promotes Int×Double, needing only the Double return). Division lowers to Double(x) / Double(2) (JS-fractional 4.5, not integer 4). ONE shape keeps a NAMED warning (deferred, never a broken emit): a GENERIC helper (function first<T>(…) — the native IR can't represent <T>); a body whose type still can't be inferred (an untyped param the return depends on) also warns + is dropped. The emit + call-site inference are proven on both real toolchains (swiftc -typecheck + kotlinc). A JSX spread on a canonical primitive<Stack {...cfg()}> — now fails with a NAMED warning too (its layout props were silently dropped before: a runtime prop-bag can't apply to a static SwiftUI view / Compose composable, so pass props explicitly — <Stack gap="md">); a spread on a USER component still expands against its declared props.) (Recently CLOSED — these now LOWER and are no longer dropped/mis-emitted: template literals `Hi ${name}` → native interpolation; for…of/while/switch + reassignment (t = t + x, +=) + multi-declarator; optional chaining a?.b (member); object destructuring of locals const {a} = obj (body-local + hook-result, e.g. const { data } = useFetch(url)); untyped object / array-of-object signals → a synthesized struct with a precise type annotation (signal({x:1}) / signal([{id:1,name:"a"}]) emitted a broken Any annotation on Swift, now an inferred struct/[Struct]); nested-array signals (signal([[1,2],[3,4]]) / [[[1]]] / [["a"]]) — inferTypeFromInitial had scalar-array + flat-object-array cases but NO array-OF-arrays case, so a grid/matrix signal degraded to Any (the value [[1,2],…] was valid Swift but the annotation failed swiftc, and a downstream grid()[0][1] then also degraded). Now recurses into the element ([[Int]] / [[[Int]]] / [[String]]); a fractional leaf flags EVERY nested integer literal float ([[1.5],[2]][[Double]] = [[1.5], [2.0]]) so Kotlin's List<List<Double>> accepts it. Swift-only in effect — Kotlin infers List<List<…>> on its own; .sort + chained array-method element inference ([...xs()].sort(cmp).slice(0, n).sort was inferred Any, breaking the chained .slice); .flatMap result-type inference.flatMap already EMITTED natively (arr.flatMap({…}) on both targets), but inferType had no case, so the computed typed Any on Swift and a chained .length failed. Now arr.flatMap(x => [E]) flattens one level → [E] (the callback's ARRAY body type ITSELF, unlike .map(x => [E]) → [[E]]). This surfaced a sibling array-literal homogeneity bug: n * 2 returned { number, float: false } while a bare n returned { number }, so [n, n * 2] was treated as heterogeneous → unknown[Any]; fixed to follow the "float ONLY when true" convention (a non-float result is now byte-identical to { number }), which ALSO fixes any array literal mixing a value + arithmetic ([a, a * 2]). (Swift-only — Kotlin infers on its own. A String()-constructor body + the Int * Double coercion are separate pre-existing gaps.) seedless .reduce(fn) — JS's no-initial-value reduce (arr.reduce((a, b) => …) seeds the accumulator with the FIRST element) had no Swift lowering: the bare arr.reduce({…}) bound to reduce(into:) ("missing argument for parameter 'into'"). Now lowers to arr.dropFirst().reduce(arr[0], fn) (Kotlin's 1-arg .reduce {…} already matches JS). The lowering names the receiver TWICE, so it only fires when the receiver is RE-READABLE (identifier / signal / store read, via isReReadableExpr); a receiver with a chained method call (filter(…)) that would re-run work emits a NAMED build-failing warning + defers. The seedless result type is the array's ELEMENT type (JS seeds with arr[0]), so the max idiom (a, b) => a > b ? a : b infers correctly instead of Any. object-literal → declared-struct inference — a computed RETURNING an object literal (() => ({ x, y })), or a .reduce with an OBJECT accumulator (reduce((a, b) => ({ sum, count }), { sum: 0, count: 0 })), now infers the DECLARED struct whose field-set matches the literal (mirroring the emit's _structFieldsToName first-wins lookup) instead of degrading the computed to Any — so a downstream out().sum / out().x field read resolves (was Swift "value of type 'Any' has no member 'sum'"). A literal matching NO declared type X = { … } stays Any (the emit synthesizes an anonymous struct for it, but inference can't name that without the emitter's per-run registry — a follow-up). field access on an inline object literal (({ count: nums().length, label: s() }).count) — a partial answer to the naming follow-up just above: an INLINE object literal infers unknown (there is deliberately no general object-literal inference case — a nameless literal has no struct NAME for an annotation), so ({…}).count degraded the computed to Any — the emit (__Obj0(…)).count is a valid Int EXPRESSION, but the Any annotation fails swiftc once consumed (String(out())). Now the FIELD'S type resolves directly from the literal's own fields (unwrapping the paren node ({…}) wraps it in) → out: Int / String / Double, WITHOUT the struct name (a field access needs the field type, not the name). A SPREAD literal ({ ...base, y }) bails (stays Any); the intermediate-const form (const o = {…}; return o.count) is a separate multi-statement-dataflow gap. field access on a TERNARY of two object literals ((cond ? { v: 1 } : { v: 2 }).v) — completes the field-access-on-object-producing-expr class the inline-object-literal case opened: a TERNARY operand also infers unknown, so (cond ? {v:1} : {v:2}).v degraded the computed to Any even though both branches synthesize the SAME struct (emit (cond ? __Obj0(v:1) : __Obj0(v:2)).v, a valid Int expression). Now the field's type resolves from a branch (paren-unwrapping each), gated on the field existing in BOTH branches → out: Int / String; a MIXED ternary (cond ? {v:1} : {w:2}, different fields) bails to Any. intermediate-const object-field access (const o = { count: nums().length }; return o.count inside a computed) — the COMMON const o = compute(); return o.field shape. findFirstReturnExpr already seeds a computed body's locals into the infer ctx, but an object-literal initializer infers unknown (the no-nameless-object-annotation rule), so o was seeded unknown and o.count couldn't resolve → the computed degraded to Any. Now object-literal consts are ALSO recorded in ctx.objectLocals (name → the literal), and the member case resolves o.count from the recorded fields → out: Int / String — WITHOUT changing o's own type in locals, so a bare return o (whole local object) is UNCHANGED (still Any, not a new tuple emit). Only a FIELD access newly resolves. Object.keys() / Object.values() over a known object shape — keys → a static key array (["a","b"] / listOf("a","b")), NOW including a DECLARED-struct arg (Object.keys(p()) where p: signal<P> — the typeRef resolution gap that made the dominant real shape degrade-warn); values → a static member-access array ([p.a, p.b] / listOf(p.a, p.b), field order = declaration order) gated on ALL field types identical (JS's mixed values array has no native analog) + a re-readable receiver (named once per field). .entries, mixed shapes, chained receivers, and non-struct args keep the typed-empty degrade + NAMED warning — never the old silently-uncompilable Object.keys(...); Int×Double coercion inside an element-callback body — Swift has no implicit Int→Double conversion, so nums().map(x => x * 1.5) (an Int-array param × a fractional literal) emitted a bare x * 1.5 INSIDE the closure → "cannot convert value of type 'Int' to expected argument type 'Double'". Component-scope coercion (n * 1.5Double(n) * 1.5) already worked, but an element-callback PARAM is neither a signal nor a const, so inside the closure it inferred unknown and the coercion never fired. Now the emit binds a .map/.filter/.forEach/.find/.some/.every/.flatMap callback's first param to the receiver's element type while emitting the closure, so +/-/* on that param coerce (Double(x) * 1.5 / - 0.5 / + 0.5) — including inside a filter predicate; nested/sibling closures each see their own element type (restore via try/finally). (Swift-only — Kotlin auto-promotes Int×Double arithmetic. A .flatMap whose body is itself a .map still infers Any — a separate flatMap-of-map inference gap.); 2-param index-callback Int×Double + mixed-comparison coercion — completes the element-callback coercion family: the 2-param (el, idx) form lowers through the separate enumerated() path, which skipped the element-type scoping, so .map((x, i) => x * 1.5) emitted the bare x * 1.5 ("cannot convert value of type 'Int' to expected argument type 'Double'"); the indexed-closure emit now binds the element param to the receiver's element type + the index param to Int (try/finally-restored). Fixing that exposed the sibling gap: Swift also requires same-type operands for COMPARISONS (x * 1.5 > i failed at Double > Int), so the comparison emit now coerces the Int side when exactly one operand is Double — non-literal operands only (Swift self-types integer literals in a Double context, so > 2 stays bare and every existing emit is byte-identical); enum/string/bool comparisons untouched (numericFloatness returns 'other'). (Swift-only — Kotlin allows Int↔Double comparison + auto-promotes arithmetic.) ternary empty-array branch unificationcond ? [x] : [] (the conditional filter-map idiom, esp. as a .flatMap body: nums().flatMap(x => x > 1 ? [x] : [])) and the mirrored cond ? [] : [x] degraded the whole ternary to Any (a bare [] carries no element type → unknown → the branch-kind equality check failed), breaking any typed consumer. The ternary inference now unifies to the array branch's type when the OTHER branch's expr is an untyped empty array LITERAL (never on a mere unknown type); both emits already compiled under the unified annotation (Swift types [] bidirectionally from context; Kotlin's listOf() is List<Nothing>, a subtype) — an inference-only fix; mixed-type ternaries (cond ? 1 : "x") still degrade honestly. Date.now() — emitted VERBATIM on both targets (a clean-parse silent mis-emit: Swift "cannot call value of non-function type 'Date'" — Foundation's Date resolves as a TYPE; Kotlin "unresolved reference 'Date'"). Now lowers to epoch-ms as a DOUBLE (Swift Date().timeIntervalSince1970 * 1000 / Kotlin System.currentTimeMillis().toDouble()) — Double because ms-since-epoch (~1.7e12) OVERFLOWS Kotlin's 32-bit Int (PMTC's number→Int default) and Double is exact below 2^53; the float inference composes with the Int×Double coercion (Date.now() - start()) and the Math return-type work (Math.floor(Date.now()/1000) → Int). Other Date.* statics (Date.parse, …) → a NAMED build-failing warning; new Date() already warns via the class/new path. KNOWN pre-existing limit (general, tracked): .set(<float expr>) into an Int-typed signal (signal<number>(0) + start.set(Date.now()) — the stopwatch shape; same for price.set(1.5)) fails LOUD with a native type error — write-site float-WIDENING of the signal's declared type is the next inference gap. <For by={(x) => x}> identity keying — the by resolver only understood the member shape ((i) => i.id) and SILENTLY fell back to .id for everything else, so an identity key over a plain string list (<For each={names} by={(n) => n}> — the tags/subjects shape) emitted the uncompilable ForEach(names, id: \.id) / key = { it.id } with zero warnings. Identity now lowers to id: \.self (String/Int are Hashable) / key = { it }; member keys unchanged; any OTHER by-shape (computed keys) emits a NAMED build-failing warning — never silent. Surfaced by the StatsPage device example (the realistic-app discovery pattern, 6th instance); the example now rides the auto-firing device gate in examples/native-tasks exercising this sprint's vocabulary end-to-end on real toolchains (Object.keys/values over a declared struct, seeded reduce, Double division, the filter-map idiom, 2-param indexed Int×Double + mixed comparison). the idiom-sweep canary + its five first-run finds — a permanent regression corpus (native-idiom-sweep.test.ts) asserts common JS idioms are LOUD-OR-TYPECHECKS on BOTH targets (a warning-free emit must pass real swiftc -typecheck AND kotlinc — a Swift-clean / Kotlin-broken emit, or vice-versa, can no longer slip through; a future silent MIS-emit regression fails the canary before shipping). It also locks a statement-context corpus (handler bodies): a ||= b / a &&= b / a ??= b stay LOUD because a naive parse-time desugar to a = a || b is UNSOUND — JS ||/&& are truthiness ops over any type, but Swift/Kotlin ||/&& are Bool-only, so n &&= 5n = n && 5 fails both toolchains (a faithful lowering needs type-aware emitter gating, not a parse desugar). Its first run caught five SILENT fails, all fixed: arr.join(sep) on a NON-String array (Swift joined(separator:) is [String]-only — now element-type-aware, mapping String.init first); arr.lastIndexOf(x) (now lastIndex(of:) ?? -1, indexOf's mirror; non-array receivers warn NAMED); arr.flat() (the emit existed but no inference case → Any — now array-of-array → inner array); Number.isInteger(x) (raw emit failed BOTH targets — now Int arg → true, Double → the parenthesized remainder check, unknown → NAMED warning; infers boolean); Math.max(...arr)/Math.min(...arr) (the SPREAD form bypassed the fixed-arity mapping on BOTH targets — now the collection max()/min() with JS's empty-array sentinel analog: Int.min/Int.max for Int arrays, ±infinity for Double). Kotlin default WebSocket transport (OkHttp)PyreonWebSocket's Android side previously required a HOST-SUPPLIED transport (connect(register: (WebSocketHandlers) -> WebSocketSender) — the documented URLSession-vs-no-JDK-socket asymmetry). The runtime now ships PyreonWebSocketOkHttp.kt: a connect(url: String) extension wiring a shared OkHttp client's WebSocketListener into the container's pure state machine (onOpen/onMessage/onFailure/onClosed → the reactive fields) + a WebSocketSender (send/close(1000)), matching Swift's connect(to: URL) one-for-one. The extension is the ONLY runtime source importing okhttp3 (the core container stays dependency-free per its design contract); every Android example's gradle gains the okhttp dep (the srcDir compiles all runtime sources); the per-service kotlinc verify gains an okhttp3 stub set mirroring the real 4.x surface EXACTLY (typecheck-only — the semantic proof is the device build with real OkHttp, which the auto-firing device gate runs on the PR). UNBLOCKS: the compiler flipping Android's ws.connect() from the named transport warning to the faithful connect(url) emit, and lifecycle AUTO-START on both targets (a synthesized onMount(connect)) — both land with the onMount-lowering PR once it merges. !x/!!x truthiness + isNaN + two loud-guard conversions (idiom-sweep batch 2 — five more SILENT fails): !x on a non-Boolean is JS truthiness negation ("type 'Int' cannot be used as a boolean") and !!x was doubly broken (juxtaposed unary is a Swift parse error) — both now lower by the arg's inferred type on BOTH targets (number → == 0/!= 0, string → isEmpty/non-empty, boolean verbatim, optional → nil/null check; unknown stays raw-loud). isNaN(x): Int arg → statically false, Double → the native .isNaN/.isNaN(), infers boolean. String .at(i) (the ARRAY lowering emitted uncompilable garbage on a String — Swift String indices aren't Int; Kotlin getOrNull yields Char?) → gated to arrays + a NAMED warning on strings. A multi-statement .sort comparator (silently dropped via the block-body sentinel; Swift's < 0 Bool conversion can't wrap a block) → NAMED warning both targets, expression bodies unchanged. onMount(fn) lifecycle lowering — THE documented lifecycle escape hatch ("call .start()/.connect() from an onMount") was a SILENT drop: the component-body walker only handled declarations + return, so onMount(() => ws.connect()) compiled clean with zero warnings and did nothing on device (the worst class: silent + documented). Now lowers to SwiftUI .onAppear { … } on the stable-identity ZStack host (the fetch-arc .task trap applies to .onAppear too) / Compose LaunchedEffect(Unit) { … }. Companions: ws.connect() (0-arg TS surface) threads the useWebSocket(url) decl's url into Swift's connect(to: URL(string: …)!); on Kotlin it emits a NAMED warning + the loud raw call (the runtime's connect(register:) needs a HOST-SUPPLIED transport — the default-OkHttp-transport is the tracked follow-up, after which compiler AUTO-START becomes a synthesized onMount). A returned cleanup fn → NAMED warning (mount body still emitted; unmount cleanup is v2). await hook.method() in an async event handler (M4.5) — an async-RESULT service call awaited inside onPress={async () => { const ok = await bio.authenticate('…'); status.set(ok ? 'ok' : 'denied') }} now LOWERS (before, any await in a component was a named "use useFetch" warning that DROPPED the call): a synchronous SwiftUI/Compose action slot can't await, so the handler body is wrapped in a native async scope — SwiftUI Button { Task { let ok = await bio.authenticate('…'); … } }, Compose onClick = { pyreonAsyncScope.launch { val ok = bio.authenticate('…'); … } } with a composable-top val pyreonAsyncScope = rememberCoroutineScope() hoisted once (a Kotlin suspend call carries NO await keyword — the coroutine provides the context) — so the post-await statements run when the async result resolves. A SYNC handler is untouched (no Task/launch wrap). The first async-result consumer is useBiometrics() (recognized natively → @State private var bio = PyreonBiometrics() / remember { PyreonBiometrics() }; iOS LAContext biometrics-gate runtime ships, and the @pyreon/hooks web hook now ships too (useBiometrics() — web feature-detects PublicKeyCredential + resolves false, a real WebAuthn assertion needs a server challenge; the Android BiometricPrompt/FragmentActivity runtime is a follow-up, the v1 scaffold resolves false). Proven on both real toolchains — swiftc -typecheck (the Task { await … } shape) + kotlinc (the scope.launch { … } shape) — AND now DEVICE-PROVEN (M3.5): the shared counter's Unlock button awaits the gate; on an UNENROLLED Simulator/emulator it resolves false with NO prompt (biometrics-only policy, canEvaluatePolicy guard), so the observable outcome flips Lock: idleLock: denied, and the iOS XCUITest + Android Compose test assert that flip — proving the async scope RUNS at runtime (the post-await re-render fired), not just compiles. Any OTHER bare component-body statement (bare effect(...), stray calls) now warns NAMED — the whole silent-expression-statement class is closed. component-body top-level reassignment (let a = 1; a = 5; / a += 2 / a++ at the component top level) — completes the above: a reassignment is an ExpressionStatement whose expression is an AssignmentExpression/UpdateExpression (NOT a CallExpression), so it fell past the bare-call warn branch into the intentional no-op drop meant for harmless void x discards → a REAL mutating reassignment was silently dropped (the render used the initial value). Now a NAMED warning: a component body emits declarations + the return JSX, not setup-time statements — components run ONCE (compute the final value directly with const x = …, or use a signal). Harmless void x / bare-ref / unary / logical discards stay silent (only genuine reassignments warn — the documented over-eager-regression guard holds). component props via a NAMED local typetype CardProps = { qty: number; label?: string } + function Card(props: CardProps) (the DOMINANT component shape) parsed to EMPTY props with NO warning: the emitted component declared no stored properties (Swift) / parameters (Kotlin) while its body referenced them bare and call sites passed args — uncompilable on BOTH targets (only the INLINE annotation props: { … } and destructured-inline shapes worked). A pre-pass now registers every local object-shape type alias and the props annotation resolves it regardless of declaration order (destructured NAMED refs too); an UNRESOLVABLE ref (imported type, interface) fails with a NAMED warning instead of the silent garbage emit. FOUR sibling gaps closed in the same class: (1) optional fields dropped ? everywherelabel?: string now parses as the union-with-undefined convention, so structs/data-classes emit var label: String? = nil / var label: String? = null and component props var label: String? = nil / label: String? = null, the explicit default making the memberwise/named-arg parameter OMITTABLE (<Card qty={2}/> compiles; before, the field emitted REQUIRED and every omitting site failed); (2) Swift call-site arg order — JSX attrs emitted in AUTHOR order, but Swift's memberwise init hard-errors out of DECLARATION order (argument 'qty' must precede argument 'label') — args now re-sort against the target's declared props (the old spread spec had CODIFIED an uncompilable order); (3) props never seeded inference — a computed over props.qty (member form) or a bare destructured prop annotated Any on Swift, breaking String(total())/arithmetic; both read shapes now resolve the declared prop type; (4) bare optional in Text rendered Optional(x) (Swift debug description) / literal null (Kotlin) where JSX renders EMPTY — an optional-typed interpolation now emits \((x).map { "\($0)" } ?? "") / ${x ?: ""} (a ??-collapsed read keeps the plain byte-shape). Whole-app emit proven by real swiftc -typecheck (multi-component + omitted optionals + out-of-order attrs). Known follow-ups: an optional inside a TEMPLATE literal still renders the raw value; a FUNCTION-typed field still derives Codable/@Serializable (uncompilable — the optional-callback-prop arc). function-typed struct fields — a declared type carrying a callback (type RowActions = { label: string; onDone: () => void }) emitted struct RowActions: Codable — closures aren't Codable, a HARD swiftc error ("does not conform to protocol 'Decodable'") — and @Serializable data class — the kotlinx serialization plugin rejects function properties on the REAL Compose build while the kotlinc validate STUBS mask it (a device-gate-only red). The conformance is now GATED: any field containing a function (directly, in a union branch, or as an array element — typeContainsFunction) drops : Codable / @Serializable (named structs AND synthesized anon-object data classes); function-free structs are byte-identical. Faithful, not a limitation: a type carrying functions can't JSON-round-trip in any language. TWO siblings in the same class: (() => void) | undefined hit the parser's unknown default (TSParenthesizedType was unhandled) → the whole union silently degraded to Any? (compiles for assignment, uncompilable the moment the callback is CALLED) — parens now unwrap; and with that fixed, the optional-function emit needed PARENS on both targets — a bare () -> Void? / (Int) -> Unit? is a function RETURNING an optional, not an optional function; now (() -> Void)? / ((Int) -> Unit)?. Gated + parenthesized shapes proven by real swiftc -typecheck (construction + cb?() invocation). The fn?.() optional-CALL lowering builds on this optional-type preservation and lands as its follow-up (see below). sweep batch 4 — dynamic a11y/testids, enum switches, JSON, destructured callback params — four finds, three fully SILENT. (1) A template-literal (or ANY dynamic) value in data-testid / accessibilityLabel — the a11y/e2e-critical shape inside For rows (data-testid={``row-${i}``}) — was silently DROPPED on both targets (the modifier emits read static-only): both native slots accept dynamic string exprs, so it now lowers — Swift .accessibilityIdentifier("row-\(n)") / .accessibilityLabel("item \(n)"), Compose .testTag("row-${n}") / semantics { contentDescription = … }; templates splice natively, any other expr string-interpolates (JS coercion); static values byte-identical incl. the container .contain gate. (2) A switch over an ENUM-typed subject emitted raw STRING case labels — case "busy": against a Status enum is a swiftc type error, "busy" -> in a when a kotlinc incompatible-types error — with zero warnings; case labels now map through the existing active-enum context (case .busy: / Status.busy ->; labels only — case BODIES keep string literals; non-enum switches byte-identical). (3) JSON.parse/JSON.stringify emitted VERBATIM — JSON doesn't exist natively, an unresolved reference with no warning — now a NAMED build-failing warning (a Codable/kotlinx serialization bridge is the tracked follow-up, gated on the function-field conformance PR). (4) A DESTRUCTURED callback parameter (.map(([k, v]) => k) / ({ id }) =>) emitted a closure over UNBOUND names — now a NAMED warning (take a plain param, read fields/indices), and the tuple-type annotation warning now names the fix (use an object type) instead of the generic "Unknown type annotation". Dynamic-attr + enum-switch shapes proven by real swiftc -typecheck. regex literals (/pat/flags) — a JS construct with no native form (Swift uses Regex/.firstMatch(of:), Kotlin Regex(...); neither has /…/ literal syntax + .match/.test/regex-.replace), so s.match(/x/) / /x/.test(s) / .replace(/x/g, …) emitted the raw /…/ VERBATIM on both targets — uncompilable, with ZERO warnings (a silent-drop the opening list above didn't even name). Faithful regex lowering (flags, capture groups, differing match APIs) is a tracked follow-up; for now the silent mis-emit is a NAMED warning + a safe "" fallback (never the uncompilable verbatim regex). A STRING-arg .replace("x", …) is a separate path — unaffected. computed object keys ({ [k]: v }) — a computed-key property carries the key EXPRESSION, not a static name, but the object parser matched an identifier-keyed computed prop via p.key?.name and used the VARIABLE NAME as the struct field: { [k]: 1 } (k a var) emitted __Obj0(k: 1) and a downstream o.a / o[k] read missed — a clean-PARSE mis-emit (swiftc -parse accepts it; only -typecheck / device catches the wrong field). A native struct/data-class needs static field names, so a computed key has no faithful lowering → now a NAMED warning (never the silent wrong field); static keys + object spreads are untouched. call-argument spreads (f(...args) / o.h(...args)) — DISTINCT from the array-literal / object spreads above (those lower): a spread ARGUMENT in a call reached the expr emitter's case 'spread' fallthrough and degraded to the bare argument, silently passing the whole array/list as ONE scalar arg (f(xs)) — uncompilable, since Swift/Kotlin calls take a fixed argument list (no variadic spread), with ZERO warnings. Now a NAMED warning at the EMITTER — the correct layer, because the parser can't distinguish a call-arg spread from an array-ELEMENT spread (both are SpreadElement), so the disambiguating context only exists downstream where every faithful consumer (array-concat, object partial-update, Math.max/Math.min) has already extracted its spread. Those consumers are untouched. dynamic disabled on <Field> / <Toggle> — the disable-a-form-control-during-submit shape (disabled={busy()}). <Button> was fixed (Round-1 audit) to use the shared swiftDisabledModifier/kotlinEnabledArg helper, but <Field> + <Toggle> still read disabled via readStaticAttr (static-only) — so a DYNAMIC value was SILENTLY DROPPED on both targets (the control stayed enabled/interactive regardless). All three now route through the shared helper: disabled is a runtime boolean (not a compile-time token), so a dynamic value lowers directly — Swift .disabled(busy), Compose enabled = !busy — no warning. Static byte-identical (.disabled(true) / enabled = false). Proven by real swiftc -typecheck + kotlinc. dynamic placeholder on <Field> — a reactive hint (placeholder={hint()}, or placeholder={terse() ? "Search" : "Search products by name…"}) read placeholder STATIC-only (the same readStaticAttr/readStaticAttrKotlin class as disabled/color), so a dynamic value was SILENTLY DROPPED — Swift fell back to "", Compose omitted the placeholder = arg entirely (the field rendered no hint). UNLIKE the compile-time token props (gap/color/align/level), a placeholder is a RUNTIME String — SwiftUI's TextField(_:text:) accepts a LocalizedStringKey (literal) OR a StringProtocol (runtime String), and Compose's Text(text: String) takes any runtime String — so like the Image dims, a dynamic value lowers DIRECTLY with no warning (both a signal read and a two-literal ternary): Swift TextField(hint, text: $draft) / TextField(terse ? "Search" : "…", text: $draft), Compose placeholder = { Text(hint) } / placeholder = { Text(if (terse) "Search" else "…") }. Static byte-identical (TextField("name", …) / Text("name")). Proven by real swiftc -typecheck + kotlinc. dynamic kind on <Field> (show/hide-password toggle)kind="password" renders a masked field (Swift SecureField, Compose visualTransformation = PasswordVisualTransformation()); the DYNAMIC toggle kind={reveal() ? "text" : "password"} read kind STATIC-only, so it SILENTLY fell back to the PLAIN field on both targets — the password rendered in CLEARTEXT regardless of the toggle (a SECURITY silent-drop, worse than a dropped modifier). On Swift kind switches the VIEW TYPE (SecureField vs TextField are distinct types, so a bare ternary of the two won't typecheck), so the branches are erased through AnyView into ONE well-typed conditional the modifier chain still binds to: (reveal ? AnyView(TextField(ph, text: $draft)) : AnyView(SecureField(ph, text: $draft))); Compose keeps ONE TextField and toggles the parameter: visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(). A ternary of two literal kinds where one branch is "password" lowers; a fully-dynamic (non-ternary) kind → a NAMED warning + a plain-field fallback. Static byte-identical. This ALSO closed a latent STATIC-password device-build bug: no example had used kind="password", so PasswordVisualTransformation (in androidx.compose.ui.text.input, outside the unconditional import set) shipped UNIMPORTED — masked by the kotlinc validate stub (validate-green / gradle-red); the CLI now conditionally imports it + VisualTransformation, and the stub mirrors the real base-type surface. Proven by real swiftc -typecheck + kotlinc. dynamic styling-attr values (gap/padding(X/Y)/background/radius) — a non-static value in a styling attr SILENTLY dropped the WHOLE modifier on both targets with zero warnings (gap={dense() ? "sm" : "lg"} emitted a bare VStack { / Column { — the binary-density idiom just lost its spacing). Styling tokens resolve at COMPILE time (the numeric forms are token INDICES on the 4px scale, not pixels — a runtime number can't map), so the faithful dynamic form is a ternary of two literal tokens: both branches compile-resolve and the condition emits natively — Swift VStack(spacing: (dense ? 8 : 16)) / .padding((dense ? 4 : 12)) / .background((dense ? Color(…) : Color(…))), Compose Arrangement.spacedBy((if (dense) 8 else 16).dp) etc. (shared IR classification + per-emitter emit; conditions run through the optional-truthiness-aware condition helpers). Any OTHER dynamic value (a signal read, an arbitrary expression) now fails with a NAMED per-attr warning pointing at the two supported forms — never the silent drop. Static values are byte-identical. Ternary shapes proven by real swiftc -typecheck (spacing/padding/background/cornerRadius). dynamic Icon color/size styling — the state-driven icon (<Icon color={active() ? "primary" : "muted"}>) read its color/size STATIC-only, so a dynamic value SILENTLY dropped the modifier (no .foregroundColor/.imageScale on Swift, no tint/.size on Compose, zero warnings) — the same class as the gap/padding gap above, on the Icon primitive. Now routed through the same swiftStylingValue/kotlinStylingValue ternary-of-two-literal-tokens machinery: static byte-identical, a ternary of two literal tokens → a native conditional (Swift .foregroundColor((on ? Color(…) : Color(…))) / .imageScale((on ? .large : .small)), Compose tint = (if (on) … else …) / .size((if (on) 24.dp else 16.dp))), any other dynamic value → a NAMED per-attr warning (never the silent drop). Ternary shapes proven by real swiftc -typecheck + kotlinc. dynamic Image width/height (runtime pixels) — Image dims are RAW pixels, not compile-time tokens, so UNLIKE the token props (gap/color/align) a dynamic dim isn't a "ternary of tokens" — it's a runtime numeric. Pre-fix the emit read them STATIC-only, so ANY dynamic dim (a ternary OR a signal read) SILENTLY dropped the .frame / .width modifier. Now ANY dynamic value lowers to the runtime numeric — Swift .frame(width: CGFloat(<expr>)) (SwiftUI's .frame(width:) takes CGFloat?; an Int/Double runtime value or an Int-literal ternary needs the explicit init), Compose Modifier.width((<expr>).dp) (the .dp extension applies to Int/Double) — NO warning (a pixel dim IS a runtime value, so width={size()} lowers rather than warning; the token props still warn on a fully-dynamic value because a compile-time token can't map to one). Static byte-identical. Ternary + signal shapes proven by real swiftc -typecheck + kotlinc. dynamic Stack/Layer align — cross-axis alignment lives in the container CONSTRUCTOR arg (VStack(alignment:) / Column(horizontalAlignment =) / ZStack(alignment:) / Box(contentAlignment =)), not the modifier chain, and it read STATIC-only — so a dynamic value (align={rtl() ? "end" : "start"}) SILENTLY dropped the alignment (a bare VStack { / Column {, zero warnings). Now routed through the same swiftStylingValue/kotlinStylingValue ternary-of-two-literal-tokens machinery: static byte-identical, a ternary → a native conditional INSIDE the constructor arg (Swift VStack(alignment: (rtl ? .trailing : .leading)) / ZStack(alignment: (rtl ? .topLeading : .bottomTrailing)), Compose Column(horizontalAlignment = (if (rtl) Alignment.End else Alignment.Start)) / Box(contentAlignment = (if (rtl) Alignment.TopStart else Alignment.BottomEnd))), any other dynamic value → a NAMED warning (never the silent drop). Ternary shapes proven by real swiftc -typecheck + kotlinc. dynamic Heading level — the level maps to a font/typography size; a DYNAMIC level (level={compact() ? 3 : 1}) SILENTLY DEFAULTED to level 1 (typeof levelRaw === 'number' ? … : 1) — a silent MIS-emit (the heading rendered largeTitle regardless, not a drop). The level is a compile-time token (a font-map index), so the faithful dynamic form is a ternary of two literal levels — each branch resolves to its font/style — Swift .font((compact ? .title2 : .largeTitle)).bold(), Compose style = (if (compact) MaterialTheme.typography.h6 else MaterialTheme.typography.h4); a fully-dynamic level warns NAMED + falls back to largeTitle/h4 (the map isn't runtime-indexable). Routed through the same swiftStylingValue/kotlinStylingValue machinery. Static byte-identical. Ternary shapes proven by real swiftc -typecheck + kotlinc. dynamic Heading color — a state-driven heading (color={err() ? "danger" : "text"}) read color STATIC-only, so a dynamic value SILENTLY dropped the .foregroundColor (Swift) / color = (Compose) — the same readStaticAttr class as Icon color. Now routed through swiftStylingValue/kotlinStylingValue: static byte-identical, a ternary of two literal tokens → a native conditional (Swift .foregroundColor((err ? Color(…) : Color(…))), Compose color = (if (err) Color(…) else Color(…))), any other dynamic value → a NAMED warning (never the silent drop). Ternary shapes proven by real swiftc -typecheck + kotlinc. break/continue, labeled loops, and the comma-operator handler bodybreak and continue statements warn-DROPPED, a SEMANTIC mis-emit (the emitted loop ran EVERY iteration where JS would exit/skip — a for…of with if (x === 3) break summed ALL elements, not the JS prefix); a LABELED loop (outer: for … break outer, the standard nested-scan idiom) dropped the WHOLE handler body ("Unsupported statement: LabeledStatement"). Both targets support all of it natively, so it now lowers faithfully: Swift outer: for … { break outer / continue outer }, Kotlin outer@ for … { break@outer / continue@outer }, plain break/continue verbatim; the switch-case fall-through strip now removes only UNLABELED breaks (a break outer inside a case exits the enclosing loop — real semantics, previously stripped). A labeled NON-loop statement warns by name. And the comma-operator ARROW body — onPress={() => (a.set(1), b.set(2))}, the compact multi-write handler — emitted a ("") junk body dropping BOTH writes; in statement position the sequence value is discarded, so each sub-expression now lowers to its own statement (arrow bodies AND block statement position; VALUE-position sequences still warn). Labeled/plain loop-control proven by real swiftc -typecheck. C-style for + do…while — completing the loop vocabulary: EVERY ForStatement and DoWhileStatement warn-dropped the WHOLE loop (the do-while residue was semantically wrong — post-loop reads saw initial values). The canonical COUNT-loop (for (let i = 0; i < n; i++), <=, or i += k with a positive literal step) now lowers to a native RANGE — Swift for i in 0..<n / 1...n / stride(from:to:by:), Kotlin for (i in 0 until n) / 1..n / step k — chosen over a while-desugar because ranges keep break/continue semantics intact (the desugar skips the update on continue → infinite loop). Non-canonical shapes (decrement, non-literal step, a counter REASSIGNED in the body — Swift's range binding is immutable, checked by an AST walk) warn by NAME with the rewrite hint. do…while maps directly: Swift repeat { } while / Kotlin do { } while ( ). Range + repeat shapes proven by real swiftc -typecheck. String(x) / Boolean(x) coercion constructors as valuesString(x)'s emit was already valid on both targets, but with no inference case the RESULT typed Any, degrading any typed consumer (["v", String(n())][Any] → a chained .length failed Swift); it now infers string (the array stays [String]). Boolean(x) was doubly broken — no inference AND no emit mapping (the raw Boolean(n) fails BOTH targets: Swift "cannot find 'Boolean' in scope", Kotlin "unresolved reference") — and now lowers by the arg's inferred type, JS-exact for scalars: bool → identity, number → != 0, string → non-empty, optional number/string → inner-value check ((x ?? 0) != 0 — JS Boolean(undefined)=Boolean(0)=false), other optionals → presence (!= nil/!= null, matching the optional-truthiness condition lowering). An unresolvable arg type keeps the raw emit + a NAMED build-failing warning (never a silent drop). NaN edge documented (no native analog — same simplification the parseInt ?? 0 mapping makes). write-site float widening — JS has ONE number type; PMTC splits Int/Double from the declared generic + initializer, so signal<number>(0) declared Int even when every WRITE was fractional: price.set(1.5) / price.update(v => v + 0.5) emitted an Int @State/mutableStateOf(0) receiving a Double (a loud native type error on both targets). widenFloatSignals now walks the whole component IR (a generic structural walk — no node-kind enumeration, no missable shapes) for .set/.update writes against Int-typed number signals; a float-inferring written value widens the DECLARED type to Double and an integer-literal initializer emits 0.0 (Kotlin's mutableStateOf(0) carries no annotation — the initializer IS the type there); runs to fixpoint (b.set(1.5); a.set(b()) widens both). Int-written signals stay Int; a write the pass can't prove float keeps the loud native error (fail-safe, never silent truncation). Composes with the Date.now() lowering: signal<number>(0) + start.set(Date.now()) — the stopwatch shape — widens once both land. optional index a?.[i] (safe-index) — the optional COMPUTED link was chain-bailed to the "" fallback (a chained find(...)?.tags?.[0] ?? "none" even COMPILED with a semantically wrong value — always ""). JS returns undefined out-of-bounds (and nil-propagates an optional receiver), so it now lowers to the guarded idioms: Swift (a.indices.contains(i) ? a[i] : nil) — both operands named twice so both must be re-readable (scalar literals now count) + the receiver non-optional; other Swift shapes emit the nil-propagating subscript + a NAMED warning (OOB traps, the warning says so). Kotlin getOrNull(i) composes on EVERY shape (single-eval — chained receivers need no guard; optional-link receivers get ?.getOrNull via the syntactic exprHasOptionalLink check, since the type layer doesn't wrap optional-member results in a union). The optional form infers element | undefined so ?? fallback collapses (#1957); a bare a?.[0] annotates Int?. fn?.() (optional call) now lowers too (see below). Map/Set collection vocabularynew Map/Set were warn-dropped to the "" sentinel, breaking the accumulator + dedup idioms. Now lowered end-to-end: new Map<K,V>()[K: V]()/mutableMapOf, new Set<T>()/new Set(arr)Set<T>()/Set(arr) · mutableSetOf/.toMutableSet(); m.set(k,v)m[k] = v (the SIGNAL-set rewrite is now gated to ONE argument — a 2-arg .set is never a signal write); m.get(k)m[k] (V? both targets — faithful to JS's V | undefined, infers the union so ?? fallback collapses); has/delete/add/clear/.size map per target (Swift [k] != nil/removeValue/insert/removeAll/.count; Kotlin containsKey/remove/add/clear/.size). Swift collection LOCALS force var (subscript-assign + insert are mutating; the reassignment tracker only sees =). Bare new Map() (no generics) + other new X() keep NAMED warnings. SIBLING FIND (8th instance): the dedup idiom exposed the PLAIN 1-param multi-statement callback silently dropping its body (the block-body sentinel — the indexed path was fixed in #1954, the plain path never was): Swift now emits the block; Kotlin emits return-free blocks and NAMED-warns on return-bearing bodies (labeled-return call-site wiring is the tracked follow-up — previously a SILENT drop). Kotlin labeled-return plain callbacks — a bare return inside a Kotlin lambda is prohibited (it targets the enclosing function); the labeled return@<method> form is required and only the CALL SITE knows the emitted method name. The indexed (2-param) path has had this since #1954; the PLAIN (1-param) path never did — a multi-statement predicate (filter(x => { if (cond) return false; return x > 0 })) silently dropped its body. Now wired at the plain call sites — filter/map/forEach/flatMap/find/findLast (same-name labels) + some→any / every→all (their emitted Kotlin names) — via emitKotlinPlainCallback, reusing the indexed path's labeled-body machinery with a 1-param head; expression bodies + indexed callbacks unchanged. SIBLING FIND (9th): the forEach ACCUMULATE idiom (let acc = 0; nums().forEach(x => { acc = acc + x })) exposed the mutability tracker missing reassignments inside CALLBACK arrows (it walked statement bodies, never expression trees) — the outer let acc stayed immutable ("'val' cannot be reassigned"); a generic structural walk now finds every nested arrow's statements (over-marking → a harmless never-mutated-var warning). the Swift optional-chain propagation bug (idiom-sweep batch 3, + three companions): the member emit propagated ?. down chains (a?.b.ca?.b?.c) — correct for Kotlin (which REQUIRES it) but WRONG for Swift: after the first ?. Swift auto-propagates, and a redundant ?. on a chain-unwrapped NON-optional field is an ERROR — find(...)?.name?.length ?? 0 (the master-detail field-length shape) was a SILENT fail, and the codified p?.addr?.city spec was itself broken, masked by an emit-shape-only assertion with NO Swift compile proof (the missing-rung lesson: every codified emit needs a compile proof on BOTH targets). Swift now emits ?. only on the first optional link (genuinely-optional mid-chain fields keep it via the declared-union check); Kotlin unchanged. Companions: switch-in-computed now infers its case-return type (findFirstReturnExpr never entered switch/loop bodies → Any); num.toString() → Swift String(x); a DESTRUCTURED callback param (({name}) => …) warns NAMED (was silently filtered → unbound names; the binding prelude is a tracked follow-up). optional call f?.() (invoke-if-present) — the last optional-chaining shape to lower (member a?.b and index a?.[i] already did). Invoking a function-typed prop/field only when non-nil — props.onDone?.(), props.fmt?.(5) — used to warn-fall-back (the "index/call" explicit-guard diagnostic); it now lowers faithfully: Swift f?(args) (the optional-function field type (() -> Void)? already parenthesizes, so f?() is valid) / Kotlin f?.invoke(args) (a nullable functional type is invoked via ?.invoke — a bare f() is a type error). The callee is a MEMBER (a function-typed prop/field), so the Swift emit short-circuited in the generic member-call branch BEFORE the bare-identifier tail — that branch dropped the ? (emitting onDone(), invoking a nil closure at runtime); fixed to mirror the tail's optional lowering. Kotlin routes both callee shapes through one tail (already correct — this locks the Swift parity). Handler + computed-value shapes proven by real swiftc -typecheck + kotlinc. Closes the optional-chaining class — no optional shape (member / index / call) warns anymore. fractional/Double mathsignal<number>(9.99) now emits var price: Double = 9.99, not Int; **/bitwise operators; Math.* functions; ?? optional-collapse inference — the idiomatic optional-consumption shape opt ?? fallback (nums().at(-1) ?? 0, .find(…) ?? def, data() ?? []) now infers the NON-optional result type (Int, not Int?), so consuming it (String(out()), arithmetic, a typed position) no longer fails Swift's "value of optional type 'Int?' must be unwrapped". The bug was a JS ?? used on the two inferred types — but inferType never returns null, so it ALWAYS kept the LEFT's optional branch; the fix unwraps the left's optional (unwrapOptionalType) and falls back to the fallback's type. .at() itself already lowered — the SIBLING ?? inference was the real bug (root-caused from an .at() probe). Array.from / Array.isArrayArray.from(x) → Swift Array(x) / Kotlin x.toList() (shallow copy); Array.from(x, fn)x.map(fn) (the map form, lowered via a shared .map-rewrite so the callback element-type inference is reused); Array.isArray(x) → the literal true (a typed source IS statically an array). Both had NO native Array member — the generic emit was uncompilable (no member from / Element could not be inferred, verified). The Array.from({ length: n }, (_, i) => expr) numeric-RANGE form NOW lowers → Swift (0..<n).map { i in expr } / Kotlin (0 until n).map { i -> expr } (index-map form). The always-undefined element param can't map to a native value, so a callback that REFERENCES it (guarded by exprReferencesIdent), the 1-arg { length: n } form (no callback), and block-body callbacks stay a NAMED build-failing warning (never a silent drop); Math.* computed RETURN-TYPE — a computed RETURNING a Math.* call had no inference case, so it typed Any on Swift (private var pageCount: Any { ceil(…) }) and a downstream String(pageCount()) / arithmetic / page() < pageCount() failed ("no exact matches in call to initializer" / "cannot convert 'Any' to 'Int'"). Now inferred by JS semantics: ceil/floor/round/trunc are INTEGER-VALUED (page counts / indices) → Int, and the Swift emit wraps the Double free-function result Int(ceil(Double(x))) so it stays an Int usable in page < pageCount and prints "4" not "4.0" (inferring Double — matching the old ceil(Double(x)) emit — was a HALF-fix, Int < Double then failed); sqrt/pow + the trig/log/exp free functions → Double; abs preserves the arg's numeric type; min/max return the args' common type. inferMathCall (infer-type.ts) is the shared inference; Kotlin's derivedStateOf infers on its own (and allows Int↔Double comparison) so it needed no change — a realistic paginated DATA-TABLE now compiles end-to-end both targets. as cast + typed-empty array — TS type-operators in expression position (x as T, x satisfies T, x!) now PARSE (unwrap to the inner expression) instead of hitting the parser's unsupportedExpr fallback, which emitted the literal string "" (so [] as number[] — the idiomatic typed-empty seed for a .reduce with an ARRAY accumulator — mis-emitted as "": Swift failed loudly, Kotlin FALSE-PASSED via "" + listOf(x) coercing to a String). An EMPTY array carries no element type, so [] as T[] threads the cast's element type onto the array IR → a typed-empty emit ([Int]() / emptyList<Int>()) + inference [T], so an array-accumulator reduce (reduce((a, b) => [...a, …], [] as T[])) compiles + types correctly on both targets. array-literal spreads[...a, ...b] / [...a, 9] / [9, ...a] / [...a, 9, ...b] (any spread count / position; merge-arrays + the add-to-list idiom) now lower to a parenthesised native concat — Swift (a + b) / (a + [9]), Kotlin (a + b) / (a + listOf(9)), with the () so a method on the literal binds to the whole concat ([...a, ...b].map { … }(a + b).map { … }). Pre-fix only a single LEADING spread emitted, MULTI-spread wrapped the 2nd arg (a + [b] / a + listOf(b) → a type error), and nothing was parenthesised ([...a, 9].lengtha + [9].count, binding .count to the tail); the add-to-list set([...items(), x]) happened to work (single leading spread, bare arg) so the bug hid until a 2nd spread or a chained method; negative-index .slicearr.slice(-m) (last m) / arr.slice(0, -n) (drop last n) now lower to the native count-from-the-end methods (Swift suffix(m) / dropLast(n), Kotlin takeLast(m) / dropLast(n)); the existing front-counting slice (dropFirst/prefix · drop/take) explicitly bailed on a unary-minus arg so the raw .slice(-1) survived → "no member 'slice'". The COMBOS now lower too: slice(s, -n) (positive-literal start) → Swift dropFirst(s).dropLast(n) / Kotlin drop(s).dropLast(n), and slice(-m, -n)suffix(m).dropLast(n) / takeLast(m).dropLast(n) (the native ops clamp like JS, so no bounds guard is needed). A NON-literal (variable) start with a negative end can't be proven front-anchored, so it still falls through — an honest follow-up.) 2-param array-method callbacksarr.map((el, idx) => …) / arr.forEach((el, idx) => …) (the index form, ubiquitous for enumeration) lowered the bare 2-arg closure ({ x, i in … } / { x, i -> … }) which both targets reject (Swift "expects 1 argument, but 2 were used"; Kotlin "cannot infer type parameter R"); now lowers to the index-aware native variant — Swift enumerated().map { (idx, el) in … } / .forEach, Kotlin mapIndexed { idx, el -> … } / forEachIndexed, both index-FIRST so the params bind swapped from JS's (el, idx); 1-param callbacks fall through unchanged; the array PREDICATE methods with a 2-param index callback.filter/.some/.every/.findIndex ((el, idx) => …) — completing the family #1934 began: a 2-param arrow is still ONE argument, so the pre-existing if (args.length === 1) 1-arg branch fired first and emitted the bare 2-param closure both compilers reject ("expects 1 argument, but 2 were used" / Kotlin "argument type mismatch"); the shared indexedArrayCallback gate is now checked BEFORE the 1-arg branch and lowers to the index-aware native form — .filter → Swift enumerated().filter{ (i,x) in … }.map{ $0.element } / Kotlin filterIndexed{ i,x -> … }; .some → Swift enumerated().contains(where:{ (i,x) in … }) / Kotlin withIndex().any{ (i,x) -> … }; .every → Swift enumerated().allSatisfy{ (i,x) in … } / Kotlin withIndex().all{ (i,x) -> … }; .findIndex → Swift enumerated().first(where:{ (i,x) in … })?.offset ?? -1 / Kotlin withIndex().firstOrNull{ (i,x) -> … }?.index ?: -1 (all index-FIRST, params swapped from JS, matching the map/forEach convention; 1-param forms unchanged); a component-level value-const (const pageSize = 2 / const steps = ["a","b","c"]) referenced from a COMPUTED or a HANDLER (Swift-only — Kotlin's const val, derivedStateOf computed and handler lambdas share the Composable body). Two sub-gaps: (a) TYPE — value-consts were never seeded into the inference ctx, so a computed referencing one inferred Any and Swift emitted private var x: Any { … }, breaking a downstream String(x()) ("no exact matches in call to initializer"); most visible for a FLOAT const (const factor = 2.5) where the binary otherwise types Int but the emit is Double(…) * (2.5) → "cannot convert Double to Int". Fixed by a persistent valueConsts map in InferenceCtx (populated in buildInferenceCtx, checked in inferType's identifier case). (b) SCOPE — a value-const emits as a body-local let in the ViewBuilder, which a private func handler AND an inline handler closure both sit OUTSIDE (if step < steps.length → "cannot find 'steps' in scope"); fixed by inlining value-consts into handler bodies (the same inline the computed path already uses). A REASSIGNED binding (let nextId = 1; nextId++) is excluded from inlining (collectMutatedComponentVars) — substituting its initial value would emit the broken (1) += 1 — so it stays body-local, read by name. A realistic WIZARD (array-const in a computed + two handlers) now compiles both targets. NOTE a realistic paginated DATA-TABLE additionally needs Math.ceil(…) to infer a numeric type instead of Any (a separate Math.*-return-type gap — deferred); JS truthiness on an OPTIONAL value in a conditionconst t = todos.find(…) used as t ? a : b / {t && <X/>} / <Show when={t}> / if (t) / while (t), AND the NEGATED !t form — now lowers to an explicit t != nil / !t → t == nil (Swift) and t != null / !t → t == null (Kotlin) at every condition site (the bare optional, and !optional itself, were rejected as a non-Bool condition — a clean-parse but uncompilable silent mis-emit). The if/while STATEMENT positions lower any optional condition — inline (if (todos.find(…))), component-level (if (optionalComputed())), AND handler-LOCAL (const t = …find(…); if (t)): a handler / function body's const/let types are now seeded into the infer ctx during statement emission (seedHandlerLocals, wired into all THREE statement-body emit paths — inline handlers, named const onTap arrow decls, AND computed bodies; the computed-body path was the last hole — a MULTI-computed app emits each computed against a SHARED infer ctx that retains the LAST computed's locals, so a summary computed's const found = todos().find(...) read as unknown and found ? stayed un-lowered). A companion fix lowers the find-then-field idiom opt ? opt.prop : else to optional-chaining on BOTH targets — Swift (opt?.prop ?? else), Kotlin (opt?.prop ?: else) (optionalMemberTernary, matching any cond structurally-equal to the then-branch's member object — a bare identifier, a computed-READ selected(), or a member chain). Neither target narrows the optional in a ternary then-branch the JS way: Swift rejects opt != nil ? opt.prop : … ("value of optional type 'T?' must be unwrapped"); Kotlin smart-casts a bare-val local but NOT a selected() read (a by remember { derivedStateOf } DELEGATED property — "smart cast … impossible … delegated property"), the dominant master-detail shape (const selected = computed(() => items().find(…))). A third companion resolves the member-ternary's TYPE: member access on selected()'s T | undefined union missed the field lookup, so detailQty = computed(() => selected() ? selected().qty : 0) inferred Any and String(detailQty()) failed ("no exact matches in call to initializer") — unwrapOptionalType unwraps the union to its non-nullish branch so the field type resolves (→ Int). With all of these, BOTH a realistic TodoMVC-shape CRUD app (signal<Todo[]>, draft / nextId, find-then-field summary, index-callback labels, add / toggle / remove handlers, Field / Button) AND a realistic master-detail app (a selected computed over .find, two computed-read detail-field ternaries, a total reduce, select / bump handlers) compile end-to-end through real swiftc -typecheck AND kotlinc (the first realistic multi-feature components proven on both targets). The optional-truthiness class is now fully closed — every condition site, both forms, all binding scopes. See the supported-surface tables below for the exact set.) The per-PR validation gate is swiftc -parse (syntax-only — it does not typecheck) + kotlinc against Compose stubs, so it cannot catch this class of type-level corruption; the full real-compiler build only runs for the example apps in the advisory native-device workflow. Treat native PMTC as demo-quality (the project self-rates it 66/100): write components in the restricted declarative style, keep data-structure manipulation in pure-logic helpers, and verify on a real Simulator/Emulator before trusting native output.

Architecture overview

Pyreon's multi-platform story is built on a four-layer model. Code in lower layers is reused unchanged across platforms; code in higher layers gets per-platform implementations behind a shared API.

Layer 4: <NativeIOS> / <NativeAndroid> / <Web>  (escape hatches, opt-in)
Layer 3b: @pyreon/elements                       (web-only rich primitives)
Layer 3a: @pyreon/primitives                     (canonical multi-platform primitives)
Layer 2: useStorage / useRouter / useFetch       (ServiceBackend pattern)
Layer 1: useDebounce / useToggle / ...           (pure-logic hooks, 100% shared)
Layer 0: signal / computed / effect              (reactive core, 100% shared)

Layer 0 — Reactive core (100% shared)

signal(), computed(), effect(), batch(), onCleanup() — these are the same on every platform. PMTC maps them to @State / @Observable on iOS and mutableStateOf / derivedStateOf on Android. On web they're native Pyreon.

Layer 1 — Pure-logic hooks (100% shared)

Custom hooks composed entirely of signals + business logic. useDebounce, useToggle, usePrevious, useControllableState — no DOM, no platform APIs. They work identically on every target.

Layer 2 — Platform-abstracted services

Services with a shared API surface + per-platform implementation. Established by @pyreon/storage:

// Same code on all three platforms
import { useStorage } from '@pyreon/storage'
const todos = useStorage<Todo[]>('todos', [])

Behind the scenes:

  • Web: backed by localStorage via @pyreon/storage

  • iOS: backed by UserDefaults via @PyreonAppStorage (from @pyreon/native-runtime-swift)

  • Android: backed by an in-memory or DataStore backend via rememberPyreonStorage (from @pyreon/native-runtime-kotlin)

The PMTC compiler rewrites useStorage<T>('key', default) to the platform-native one-liner on iOS / Android. On web it stays as the standard @pyreon/storage call.

Same pattern extends to: @pyreon/router (iOS NavigationStack + Android NavHost runtimes are Phase C), network fetching, permissions, lifecycle hooks.

Layer 3 — UI primitives (the architectural fork)

Two separate primitive layers serve different needs:

Layer 3a: @pyreon/primitives — canonical multi-platform

The cross-platform vocabulary. 15 semantic primitives designed for fundamentally the easiest DX across all three targets:

CategoryPrimitives
Layout<Stack>, <Inline>, <Layer>, <Scroll>, <Spacer>
Content<Text>, <Heading>, <Image>, <Icon>, <Video>
Interaction<Button>, <Press>, <Link>
Input<Field>, <Toggle>, <Modal>
Control flowLowers to native: <For>, <Show>, <Suspense>, <ErrorBoundary> (verified against the Swift stub type-check). Does NOT: <Switch>/<Match>, <Dynamic>, <Portal> — and <Index> — which fall through to the generic component emit and reproduce the tag verbatim (Switch { Match(when:) { … } }), so the native build fails with "cannot find 'Switch' in scope". Each now WARNS at compile time with a concrete alternative (nested <Show>; <Modal>; <For each by>). This row previously listed all eight as supported, which is how four of them stayed silently broken

Designed for cross-platform from scratch. Semantic names (<Stack> not <View> / <VStack> / <div>). One canonical event name per concept (onPress everywhere). Tokens-first styling (padding={4} resolves via theme).

Layer 3b: @pyreon/elements — web-only rich

The existing web primitive layer (Element, Text, List, Overlay, Portal). Built on rocketstyle + styler + unistyle — rich responsive props, extendCss, full DOM-coupled styling. Stays as-is. Web-only.

Cross-platform apps use @pyreon/primitives. Web-only apps that need rocketstyle's rich features use @pyreon/elements. The two coexist — no naming collision because imports are explicit.

Layer 4 — Platform escape hatches

Two different escape hatches, for two different problems. Reach for the right one:

1. Per-platform BRANCHING — <NativeIOS> / <NativeAndroid> / <Web>. When one target needs a different arrangement of canonical primitives than another:

<NativeIOS>
  {/* Rendered on iOS; Compose + web targets render nothing here */}
  <Stack gap="md"><Text>iOS-specific layout</Text></Stack>
</NativeIOS>

Note what this is not: the children are ordinary PMTC JSX compiled through the normal canonical-primitive path, so this is a platform conditional — it does not let you write raw SwiftUI or Kotlin inline. For that, use the second hatch.

2. User-defined NATIVE MODULES — useNativeModule. When you need a platform capability the framework does not ship (Bluetooth, ARKit, a payments or analytics SDK, any vendor library). PMTC lowers the call to an instance of a class you provide, and passes member calls through verbatim:

import { defineNativeModule, useNativeModule } from '@pyreon/primitives'

type Bluetooth = {
  isSupported(): boolean
  connect(id: string): Promise<boolean>
}

// The WEB implementation — native targets never run this.
defineNativeModule<Bluetooth>('Bluetooth', {
  isSupported: () => 'bluetooth' in navigator,
  connect: async (id) => { /* Web Bluetooth */ return true },
})

function Pairing() {
  const bt = useNativeModule<Bluetooth>('Bluetooth')
  //  iOS     → @State private var bt = Bluetooth()
  //  Android → val btCtx = LocalContext.current
  //            val bt = remember { Bluetooth(btCtx) }
  return <Button onPress={() => { void bt.connect('cuff') }}>Connect</Button>
}

You supply the platform halves as ordinary app code:

// ios/Bluetooth.swift — a no-argument initialiser; @Observable if its state drives the view
final class Bluetooth {
  func isSupported() -> Bool { true }
  func connect(_ id: String) async -> Bool { /* CoreBluetooth */ true }
}
// Android — a SINGLE Context parameter (the emit injects LocalContext.current).
// Declare it in the package the generated sources use (your `--kotlin-package`),
// so the emit's unqualified reference resolves.
class Bluetooth(private val context: Context) {
  fun isSupported(): Boolean = true
  suspend fun connect(id: String): Boolean = true
}

The contract is small and checked by the platform compiler, not by PMTC: the class name matches the string, the initialiser matches the shape above, and method names/arities match what the shared source calls. A mismatch is a normal swiftc / kotlinc error in your own build. await bt.connect(id) works with no extra machinery — an awaited module method rides the same async lowering the built-in services use.

Because the module name is emitted verbatim as a native type name and PMTC resolves one file at a time, it must be a string literal at the call site (not an imported constant) and a valid identifier; anything else is a named compiler warning and the declaration is skipped rather than mis-emitted.

Why this matters. Before this hatch existed, platform services were recognised by hard-coded hook name inside the compiler, so every new capability required a framework PR — an app that needed Bluetooth simply had no path. useNativeModule is what makes the platform layer extensible by applications. Device-proven: the counter reference app renders a value produced by an app-provided DeviceInfo class on both iOS and Android.

Canonical primitive vocabulary (Layer 3a)

Layout

PrimitiveWebiOSAndroid
<Stack direction?="column"|"row" gap? align? justify?><div style="display:flex">VStack / HStackColumn / Row
<Inline gap?> (sugar for <Stack direction="row">)flex rowHStackRow
<Layer> (z-stack)position:relative + absZStackBox
<Scroll axis?>overflow:autoScrollViewColumn(verticalScroll)
<Spacer />flex:1Spacer()Spacer(weight=1)

<For> inside <Stack> behaves differently per target

A <For> lowers to Compose's LazyColumn, which scrolls on its own axis and renders lazily. Inside <Scroll> that is exactly right and the targets agree — SwiftUI gets ScrollView { LazyVStack { ForEach } }, Compose gets a bareLazyColumn (the wrapper is dropped there deliberately: nesting a lazy list inside Column(Modifier.verticalScroll()) throws at measure time).

Inside a plain <Stack> they diverge:

EmitBehaviour
iOSVStack { ForEach }fixed, eager
AndroidColumn { LazyColumn { items } }scrolls, lazy

For a short list this is not observable — a LazyColumn shorter than its viewport does not scroll. It becomes visible with a long one: Android scrolls where iOS clips, and iOS materialises every row where Android does not.

Use <Scroll> when the list should scroll. That is the primitive both targets agree on. <Stack> is a layout box, not a scroller, and the compiler does not warn here on purpose: the shape is ordinary and usually harmless, and a warning on every list would be noise.

Content

PrimitiveWebiOSAndroid
<Text><span>TextText
<Heading level={1|...6}><h1>..<h6>Text(.font(...))Text(style=...)
<Image src alt fit?><img>Image / AsyncImageAsyncImage
<Icon name><svg>Image(systemName:)Icon

Interaction

PrimitiveWebiOSAndroid
<Button onPress> (styled CTA)<button>ButtonButton
<Press onPress> (un-styled wrapper)<div onClick role=button>Button { } no chromeBox(clickable)
<Link to external?> (router-agnostic)<a href> + SPA-nav when init({ navigate }) is wiredNavigationLinkBox(clickable + navigate)

Input

PrimitiveWebiOSAndroid
<Field value onChangeText kind?><input>TextField / SecureFieldTextField
<Toggle value onChange><input type=checkbox>ToggleSwitch
<Modal open onClose><dialog>.sheet(isPresented:)Dialog

Event model

One canonical event name per concept; the compiler maps it to the platform-native handler:

ConceptPyreon canonicalWebiOSAndroid
TaponPressonClickaction:onClick =
Long pressonLongPresspolyfill.onLongPressGesturecombinedClickable(onLongClick)
SwipeonSwipeLeft / onSwipeRightpointer-delta polyfill.highPriorityGesture(DragGesture)pointerInput { detectHorizontalDragGestures }
Text changeonChangeTextonInputtext bindingonValueChange
SubmitonSubmitform onSubmit.onSubmit { }keyboardActions onDone
Focus / bluronFocus / onBlursame.focused()onFocusChanged
Appear / disappearonAppear / onDisappearIntersectionObserver.onAppearLaunchedEffect

Hover events are deferred (mobile platforms don't have hover).

Handlers may be multi-statementonPress={() => { a.set(1); b.set(2) }} emits every statement (including if blocks) into the native closure on both targets. (Earlier the compiler silently kept only the first statement.) A single-expression handler (onPress={() => a.set(1)}) keeps the compact one-line form.

Style system (v1)

Tokens-first. No raw pixels in cross-platform code.

PropTypeResolves to
padding, margin, gapnumber (theme.space index) OR "sm" | "md" | "lg"Web: inline style px; iOS: .padding(); Android: Modifier.padding()
color"text" | "surface" | "primary" | ... (theme key)Per-platform color resolution
backgroundtheme keyPer-platform background
align"start" | "center" | "end"Per-platform alignment
justify"start" | "center" | "end" | "between"Per-platform main-axis
radius"none" | "sm" | "md" | "lg" | "full"Per-platform corner radius

No responsive props in v1. Web has media queries, iOS has size classes, Android has configuration changes — unifying these as per-primitive responsive props is a multi-week design problem deferred to a future arc. Apps that need responsive web layouts use @pyreon/elements directly (it has full responsive prop support). The one adaptive primitive that HAS landed is the useSizeClass() READ hook (M2.2) — a single 'compact' | 'regular' signal that lowers to iOS @Environment(\.horizontalSizeClass) / Android LocalConfiguration width / web matchMedia — so shared code can branch on width today; the size-class-driven layout primitive (Stack↔Inline) is the M2.2b follow-up.

No animation primitives in v1. Same reasoning.

Escape hatch. <NativeIOS style={...}> / <Web className="..."> for per-platform overrides when the canonical style system doesn't reach.

Inline style={{ … }} on a canonical primitive

A raw inline style — <Stack style={{ padding: 16, backgroundColor: '#2563eb', borderRadius: 8 }}> — now lowers to native modifiers on both targets (it was silently dropped before — no reader, no warning). This is the CSS-in-JS connector core: one flat CSS-in-JS object → SwiftUI .modifier() / Compose Modifier.x() from a single source, applied at the same cross-cutting seam as the token props above, so it reaches every primitive.

Inline CSS (camelCase)iOSAndroid
padding (number, '8px 16px' shorthand), paddingTop/Right/Bottom/Left, paddingX/paddingY (paddingHorizontal/paddingVertical).padding(…)Modifier.padding(…)
backgroundColor / background.background(Color(…)).background(Color(0x…))
borderRadius.cornerRadius(n).clip(RoundedCornerShape(n.dp))
borderWidth+borderColor, or border shorthand ('1px solid #ccc').overlay(RoundedRectangle(cornerRadius: r).stroke(…)).border(BorderStroke(…), RoundedCornerShape(r.dp))
opacity.opacity(n).alpha(nf)
width, height.frame(width:/height:).width(n.dp) / .height(n.dp)
minWidth/maxWidth/minHeight/maxHeightone combined .frame(minWidth:…, maxHeight:…).widthIn(min=,max=) / .heightIn(…)
aspectRatio (number or '16 / 9').aspectRatio(r, contentMode: .fit).aspectRatio(rf)
color.foregroundColor(Color(…))warns — set on <Text> / LocalContentColor, not a Modifier

Colors accept #hex / #rgb / #rrggbbaa / rgb() / rgba() (alpha carried through); dimensions accept a unitless number or "Npx". The chain order is idiomatic per target (SwiftUI .padding().background().cornerRadius(); Compose .clip().background().padding() — clip-first so the fill is rounded).

Web-only declarations are stripped with no warningcursor, userSelect, pointerEvents, transition, outline, appearance, … are a correct no-op on a touch target. Any other property is dropped with a NAMED warning (margin, boxShadow, border, flex, transform, positioning, font*, … — nothing vanishes silently), pointing at the canonical prop or a Layer-4 adapter.

Reactive style={cond ? {A} : {B}} lowers too. A ternary of two object literals lowers each shared property to a reactive conditional-value modifier.background(active ? A : B) on iOS, .background(if (active) A else B) on Android. Because cond reads a signal (→ SwiftUI @State / Compose mutableStateOf), the style flips on state change with no extra machinery — the exact reactive mechanism the canonical-prop ternary emit already ships and the counter example device-proves (tap → re-render). This is the plan's make-or-break "dynamic resolution → reactive emit", proven on a primitive's own inline style before it rides on rocketstyle's dimension props.

  • A property present in only ONE branch (asymmetric), or a padding box that isn't a single all-sides value in both branches, can't fold into one conditional value — it's emitted from the first branch statically with a named warning (never a silent drop). Give both branches the same property with two literal values for a reactive flip.

  • Still out of scope (warned, not silently dropped): a non-ternary dynamic style (style={obj}, cond && {A}, nested ternary) and any non-literal field value (style={{ padding: n() }}). Reactive resolution beyond a two-literal ternary is the tracked follow-up.

Both the static and dynamic emits are toolchain-validated (swiftc -typecheck against the real SwiftUI SDK + kotlinc against the Compose stubs) — the dual-toolchain gate is what caught a Kotlin float-suffix bug (.alpha((if (c) 1 else 0.7)f) → invalid; fixed to 1f/0.7f per branch).

styled(Prim) components lower too

The connector reaches component-level styling, not just inline style. A styled() wrapping a canonical primitive

const Card = styled(Stack)`
  background: #2563eb;
  padding: 16px;
  border-radius: 8px;
`
// <Card><Text>Hi</Text></Card>

— lowers each <Card> use-site to <Stack> with the captured CSS injected as a style, so the whole inline-style connector lowers it unchanged: VStack{…}.padding(16).background(…).cornerRadius(8) on iOS, Column(Modifier.clip(…).background(…).padding(…)) on Android. kebab-case CSS is normalized to the connector's keys (border-radiusborderRadius); use-site children/props (gap, onPress, …) are preserved.

styled(Prim) is a real styler pattern (styled's tag is string | ComponentFn) — it's this component-lowering foundation that rocketstyle's dimension resolution builds on. Scope (v1): only styled(<canonical primitive>) lowers — styled('div') / styled(NonPrimitive) warn (no native primitive); a template interpolation that is a theme token (${(p) => p.theme.color.primary}) is resolved to its value, while any other (a runtime expression) warns + drops; a use-site inline style on a styled component is a v1 gap. Both emits are toolchain-validated.

rocketstyle multi-dimensional resolution

The architecture is per-package native frontends over a shared backend: each ui-system package owns a module that lowers its constructs to a style-object IR, which the connector (the shared backend) lowers to native — mirroring how the runtimes compose. The rocketstyle-native frontend is the first non-styler one.

A rocketstyle component over a canonical-primitive base —

const Btn = rocketstyle()({ name: 'Btn', component: Stack })
  .theme(() => ({ padding: '8px 16px', borderRadius: '8px' }))
  .states({ primary: { backgroundColor: '#2563eb' }, danger: { backgroundColor: '#dc2626' } })
  .sizes({ medium: { padding: '12px' }, large: { padding: '16px' } })
// <Btn state="primary" size="large">

resolves at compile time: at each use-site the frontend reads the state/size/variant attrs, merges base ∪ matched-dims into ONE style object (the rocketstyle cascade — dims override base), and reuses the styled rewrite (→ <Stack style={merged}> → connector). So <Btn state="primary" size="large">VStack{}.padding(16).background(…blue).cornerRadius(8) (size=large's padding overrode the base). This is what makes user-authored multiplatform components real: build your own on ui-system over the primitive bases and it lowers — primitives are the compiler's internal native target, not your authoring constraint.

Reactive dimension flips

A dimension prop can be dynamic — driven by a signal at runtime:

function App() {
  const active = signal(false)
  return <Btn state={active() ? 'primary' : 'danger'} size="large"></Btn>
}

<Btn state={active() ? 'primary' : 'danger'}> resolves both branches (base ∪ static-dims ∪ each state's set) into a ternary style value handed to the connector's reactive path — so each property that differs across the branches lowers to a conditional-value modifier: SwiftUI .background((active) ? Color(…blue) : Color(…red)), Compose .background(if (active) Color(0xFF2563EB) else Color(0xFFDC2626)). Any static dimension (size="large") + the theme merge into both branches. This is what makes a native rocketstyle component reactive rather than static-only — the runtime state flip re-styles in place, no remount.

Scope: a canonical-primitive base (component: Stack); static string dimensions (the useBooleans: false default) merge into one object; one dynamic dimension (a ternary of two DECLARED dimension values) lowers to the reactive flip. Declaration values may be literals OR theme tokens (backgroundColor: t.color.primary). Remaining follow-up: ≥2 simultaneous dynamic dimensions (a switch over the dimension-set product) → warns + falls back to the first branch; a ternary whose branches aren't both declared dimension values → warns + drops. Both emits are toolchain-validated.

Dark mode

Dark mode needs no new mechanism — it composes from useColorScheme() (which lowers to SwiftUI @Environment(\.colorScheme) / Compose isSystemInDarkTheme() as a reactive "dark"/"light" string) + the reactive dimension flip above:

const Card = rocketstyle()({ name: 'Card', component: Stack })
  .states({ onLight: { backgroundColor: '#ffffff' }, onDark: { backgroundColor: '#111827' } })
function App() {
  const scheme = useColorScheme()
  return <Card state={scheme === 'dark' ? 'onDark' : 'onLight'}></Card>
}

→ SwiftUI .background((scheme == "dark") ? Color(…#111827) : Color(…#ffffff)) with @Environment(\.colorScheme) injected; Compose .background(if (scheme == "dark") Color(0xFF111827) else Color(0xFFFFFFFF)). The system color-scheme flip re-styles in place.

Responsive (size class)

CSS pixel breakpoints ([xs, sm, md, lg]) don't map to native, but native has a 2-bucket width class — SwiftUI @Environment(\.horizontalSizeClass) (compact/regular), Compose LocalConfiguration.current.screenWidthDp. useSizeClass() lowers both to a reactive "compact"/"regular" string, so — same composition as dark mode — a dimension flip gives a real mobile-vs-expanded responsive layout that re-flows on rotation / split-screen:

const Panel = rocketstyle()({ name: 'Panel', component: Stack })
  .states({ mobile: { padding: '12px' }, expanded: { padding: '32px' } })
function App() {
  const size = useSizeClass()
  return <Panel state={size === 'regular' ? 'expanded' : 'mobile'}></Panel>
}

→ SwiftUI .padding((size == "regular") ? 32 : 12); Compose .padding(if (size == "regular") 32 else 12). It's two buckets, not per-pixel breakpoints — matching how native size classes actually work.

Theme-token resolution

The mainline styler/rocketstyle value is a theme token, not a literal — background: ${(t) => t.color.primary}, .states({ primary: { backgroundColor: t.color.primary } }). The theme-native frontend resolves such a token at compile time to a concrete value the connector lowers (#hex colors → Color(.sRGB, …) / Color(0xFF…); spacing / radius → numbers).

Resolution is against your app's own theme, declared with defineTheme:

const theme = defineTheme({
  color:   { primary: '#ff3b30', danger: '#dc2626' },
  spacing: { md: 20, lg: 24 },
  radius:  { sm: 6 },
})

const Card = styled(Stack)`
  background: ${(t) => t.color.primary};   /* → your #ff3b30, not a guess */
  padding: ${(t) => t.spacing.md};          /* → 20 */
`

defineTheme({ … }) is a compile-time declaration — the compiler parses its literal tokens and drops the declaration from the native output (there is no native defineTheme; the runtime helper is identity on web). The parsed theme is merged over the bundled defaults per entry, so overriding only color.primary keeps every other default token, and a zero-config app (no defineTheme) resolves standard tokens against the defaults (which mirror @pyreon/ui-theme + the primitive defaults). Both the styler (p) => p.theme.color.primary (props) and rocketstyle (t) => t.color.primary (theme-directly) shapes resolve; group aliases (colors/space/borderRadius), flat and nested paths are accepted.

Scope (v1): the color / spacing / radius groups with literal leaf values (a native theme must be static — a runtime-computed token can't be baked). An unknown token (t.color.doesNotExist) or a non-token interpolation warns + drops.

Device-proven (first for the styling track). The gated counter app carries arocketstyle()({ component: Text }) badge whose state flips with a signal; the iOS XCUITest and the Android Compose test both assert it renders and re-renders on the flip. Note precisely what that covers: neither harness can read a COLOUR, and a missing colour still compiles — so colour presence is locked by the emit test (native-text-reactive-color-parity), while the device tests cover what a unit test cannot, that the lowering survives into a real app and reacts. Building the Android app additionally proves the ComposeText(color = …) constructor arg is type-correct.

Building ui-system components — coverage

You build your own components on the ui-system styling frontends (styled + rocketstyle + theme tokens) — over the canonical primitives or over @pyreon/elements' Element (the base the 67 @pyreon/ui-components use). <Element> maps to the canonical <Stack> (direction/alignX/alignY/gap translated), so a rocketstyle()({ component: Element }) component — the ui-components authoring pattern — lowers to SwiftUI and Compose. (PMTC compiles your source; an imported pre-built @pyreon/ui-components component doesn't lower — re-author the pattern.) The Element / PyreonUI / Container / Row / Col alias lowerings are import-source-aware: a tag is intercepted only when it is imported from its expected @pyreon package (Element@pyreon/elements, Container/Row/Col@pyreon/coolgrid, PyreonUI@pyreon/ui-core), so a same-named user component (import { Row } from './my-components') is never mis-lowered as a coolgrid Row. All examples are real swiftc/kotlinc-validated end-to-end:

  • Interactiverocketstyle()({ component: Button }) with theme tokens + a reactive state={sig() ? 'a' : 'b'} flip + disabled + onPress + size.

  • Containerrocketstyle()({ component: Stack }) (or Element) with theme tokens + dark mode (state={scheme === 'dark' ? 'onDark' : 'onLight'}).

Styling featureiOS + AndroidNotes
styled(Prim) componentstatic CSS + theme tokens
@pyreon/elements Element base<Element><Stack> (direction/alignX/alignY/gap); unlocks rocketstyle-over-Element = the ui-components pattern
@pyreon/ui-core <PyreonUI> providertransparent on native (theme is compile-time-resolved, dark mode is a system read) — renders children, so a whole app root lowers
@pyreon/coolgrid Container/Row/ColContainer→vertical Stack, Row→horizontal Stack (raw-px gap → scale); Col→a fractional span for a literal size (SwiftUI iOS-17 .containerRelativeFrame(.horizontal, count: 12, span: n), Compose Modifier.fillMaxWidth(n/12f) — same size/12 absolute fraction, partial rows leave the rest empty), else an equal-fill child. Caveats: only a LITERAL integer size (responsive/non-literal → equal + warn); assumes 12 columns (custom columns on the Row not threaded); SwiftUI is container-relative (≈ the Row when the Container is full-width) + gutter-agnostic
@pyreon/attrs attrs({ name, component }).attrs({…})default-prop HOC — rewrites <X …use-site> to <Base …use-site …defaults> (use-site wins); literal + theme-token default attrs. The options-object form is the only one that lowers, because it is the only one the WEB runtime acceptsattrs(Base) throws at mount (Parameter \component` is missing in params!`). This row previously claimed the bare form was "the documented" one and that both lower; that was wrong in a way worth recording, because a 2026-07 change taught the compiler to accept the bare form on the strength of it. The README and the package manifest have always shown the options object; the belief came from a PROSE shorthand ("attrs(Base) default-prop HOC lowers…", meaning attrs over a base) being read as a call signature. The compiler now REFUSES the bare form with a named warning naming the fix — accepting it produced two green native targets and a blank web page, which is the one outcome a multi-target compiler must never produce silently
rocketstyle static dimensionsstate/size/variant cascade → one style
rocketstyle reactive dimension flipstate={sig ? 'a' : 'b'} → conditional-value modifier. A flip that changes text colour works on both targets: SwiftUI takes .foregroundColor(cond ? A : B) (a modifier applies to any View), Compose takes color = if (cond) A else B as a Text() constructor arg (it has no text-colour modifier). Those are different mechanisms, and the Compose side used to DROP a reactive colour with a warning while iOS rendered it — a parity break behind this ✅, since fixed
Theme tokens (defineTheme + t.color.…)resolved to the app's real values at compile time
Dark modeuseColorScheme() + a dimension flip (composition)
disabled / onPressneeds an interactive base (Button/Press), not a layout Stack
Text typographyfontSize/fontWeight/color/textAlign/fontStyle in a style object on a Text → SwiftUI .font(.system(size:weight:))/.foregroundColor/.multilineTextAlignment modifiers; Compose Text(fontSize=…, fontWeight=…, …) constructor args. <Heading level={1..6}> still gives the semantic scale
Layout (direction/gap/align)via the canonical primitive
Responsive (2-bucket)useSizeClass() + a dimension flip → compact/regular (mobile vs expanded), re-flows on rotation/split-screen
Responsive breakpoint arrays ([xs,sm,md,lg])full CSS pixel breakpoints have no native map — use the 2-bucket size class above
hover / focus pseudo-statespointer-only — desktop-web semantics
CSS animations / keyframesweb-only ("no animations v1")
@pyreon/elements Overlay / Portalweb-only-rich (Layer 3b) — native uses sheets/dialogs, a separate model

The ❌ rows are architectural boundaries, not tracked bugs — they're bound to the DOM / CSSOM / a pointer model that doesn't exist natively. The ✅ rows are what makes user-authored ui-system components run on iOS + Android + web from one source.

Per-platform import resolution

The DX-critical question: how does import { Stack } from '@pyreon/primitives' resolve on each target?

  • Web: @pyreon/primitives is a real npm package with real implementations. Stack is a ComponentFn that renders DOM. Standard module resolution.

  • iOS / Android (via PMTC): The PMTC compiler INTERCEPTS JSX with <Stack> etc. at compile time and emits platform-native code BEFORE the runtime is involved. The import is type-anchor only — the JSX never calls into @pyreon/primitives's runtime.

The same source file works on all three targets. The compiler-side handling for each target is different but the developer doesn't see it.

Migration

@pyreon/primitives is a NEW package. Adding it breaks nothing.

Existing PMTC source using SwiftUI-flavored names (<VStack>, <HStack>, <TextField>) continues to work via the existing per-target emit. The TodoMVC migration to canonical vocabulary is Phase E — a deliberate, additive port. After migration is proven, deprecation warnings land on SwiftUI-flavored tags. Removal happens in a major-version bump LATER.

Current state + roadmap

The 5-phase implementation roadmap:

Foundation rollout (A–E):

PhaseScopeStatus
AArchitectural foundation: canonical primitives package + web runtimes✅ Done — all 15 primitives have web DOM runtimes
BPMTC compiler emit for iOS + Android (extends canonical-primitives.ts table)✅ iOS (Swift) 15/15; Android (Compose) emit completing via the P2.2 series
C@pyreon/native-router-{swift,kotlin} runtime adapters + routes emit (path + component)✅ Done
DWeb target for PMTC + examples/native-todomvc-web/ consuming the shared source✅ Done
ETodoMVC migration to canonical vocab — closes the cross-platform contract✅ Done

ONE TodoApp.tsx source → THREE example apps (web, iOS, Android), all typecheck-clean.

Beyond the foundation — toward production-grade

The vocabulary is multiplatform; the road to shipping real production apps continues:

StepScopeStatus
Real-device CICompile the full apps on real Xcode/Gradle (native-device workflow), then boot Simulator/Emulator + assert render🟡 build gate + iOS XCUITest + Android Compose-instrumented-test landed (opt-in native-device label); promote to required once green across nightly runs
Router matchingredirects, :param* splat, :param? optional, */(.*) whole-route wildcard 404, leading/trailing-slash tolerance✅ landed (see Native routing)
Router parity (advanced)per-route guards (beforeEnter), nested routes (layout-wrapping), useParams destructuring, loader-data runtime (useLoaderData), per-route loader auto-emit, global beforeEach/afterEach guards, throw-redirect pattern✅ guards, nested routes, useParams destructure, loaderData/useLoaderData runtime, global guards (#1108), router.redirect() re-entry-safe throw-pattern (#1109), and per-route loader auto-emit (v1 — zero-param expression-body loaders; see note) all landed
Data + formsuseFetch / useForm / usePermissions / useOnline / useClipboard / useColorScheme as per-service native runtime ports (runtime + emit)✅ six hooks landed — useForm v2 is device-proven (validators + runtime Field bindings + submit gating; the tasks login's error-path smoke); useFetch is device-proven end-to-end (the tasks Quotes screen fetches + decodes + renders a real HTTP fixture on the CI Simulator/Emulator; web runs the same call through @pyreon/hooks); usePermissions incl. web-parity can.not; useOnline; useClipboard; useColorScheme emit-only (no runtime port) but now DEVICE-PROVEN (dark mode, M2.5 — the counter renders Theme: {colorScheme} and its iOS XCUITest asserts "Theme: light"/"Theme: dark" track the live Simulator appearance). useValidation planned
Compiler diagnosticsSurface silent-drop shapes as parser warnings instead of failing-silent at runtime✅ Round-1 (#1094 — Icon/Image/Link missing required props) + Round-2 (#1099 — Press without onPress, Link prefetch={…} on native, Stack/Inline/Layer align="<typo>") landed; both routes ship as result.warnings, emit shape unchanged. Round-3 replaces the hand-maintained required-prop LIST with an outcome check at the generic-emit bail: any canonical primitive reaching generic emit warns, because that emit writes a constructor call for a type neither target has. The list had been missing <Field> without onChangeText, <Toggle> without onChange and <Modal> without open — all three uncompilable, all three silent. The guard is locked against BOTH real toolchains as an equivalence (it fires iff the emit does not build), so it cannot drift into noise or into a false sense of safety
Lifecycle<Transition> + <TransitionGroup> (landed); <Suspense> / <ErrorBoundary> (real semantics, Phase 2); <KeepAlive>✅ transitions + real <Suspense> / <ErrorBoundary> — both compile to an INLINE conditional in the component body (Swift Group { if <pending/errored> { fallback } else { children } }, Kotlin if (…) { … } else { … }) reading every useFetch container's isPending / error in that component: Suspense shows its fallback until the fetched data settles, ErrorBoundary swaps to its fallback when a container rejects (the realistic native error surface — SwiftUI/Compose have no try/catch around view construction). The read is inline (not passed to a wrapper) so SwiftUI Observation / Compose recomposition tracks it; on iOS the body wraps in a concrete ZStack so the fetch .task attaches to a stable host (a transparent Group makes SwiftUI cancel+restart the task on every flip → the fetch never settles). Device-proven (the tasks Lifecycle screen: good-fetch content + a deliberately-failing fetch's ErrorBoundary fallback both render on a real Simulator). <KeepAlive> stays a graceful pass-through (cache semantics inert; the hardest of the three).
DXpyreon create-multiplatform scaffold (✅), asset pipeline🟡 scaffold landed and produces buildable, launchable native apps end-to-end — the four @pyreon/native-* runtimes wire in as SPM (iOS) / Gradle srcDir (Android) deps so the emitted import PyreonRuntime / com.pyreon.runtime.* resolve; proven scaffold → emit → gradle assembleDebug / xcodebuild → install → launch (RUNNING) on both an Android emulator and an iOS Simulator (#1570, which fixed eight project-wiring bugs a real local build surfaced that compile-only validation could not — web-entry-skip, --kotlin-package, serialization-plugin version, ComponentActivity, SPM ../ paths, source-path nesting, App.swift collision, SwiftUI.App shadow). image asset pipeline landed (pyreon-native assets — the shared assets/ dir materializes to Assets.xcassets / res/drawable-* density buckets / public/assets, and <Image src="name.png"> dispatches bundled-vs-remote per target; device-proven via the tasks branded header); SF-Symbols/Material icon mapping + fonts are the next arc

Loader auto-emit — v1 landed (zero-param, expression-body). A route's loader: () => <expr> now compiles to a runtimePyreonRouteLoader host wrapping the route's component: its .task(SwiftUI) / LaunchedEffect (Compose) fires the loader ONCE on the route's appear and stores the result via router.setLoaderData(<active path>, …), where the already-shipped useLoaderData<T>() reads it (keyed by the runtime active path, so it matches for both literal and:param routes; the home route keys by its literal path = currentPathat launch). The store is guarded (loaderData[path] == nil) so re-renders never re-run it. Realistic native loader bodies are signal / store reads (() => globalCache()) and sync expressions(() => buildInitialData()); both emit correctly.

Still deferred (each leaves loader undefined + WARNS, so the route renders with no loader rather than mis-emitting): a param-usingloader ((ctx) => fetchUser(ctx.params.id)ctx has no value source in the load closure yet), a block-body loader (() => { … } — needs statement emit), and a truly-async body (async () => await fetch(…) — no await lowering / typed-decode generic, the useFetch<T>territory). ctx.params threading + async-loader decode are the next arc; until then, fetch-style loaders populate loaderData from native code (or via a useFetch container) as before.

Production capability matrix (weighted, rung-labeled)

This table is the denominator for every "N% of usages" claim. Real mobile/tablet apps decompose into the weighted usage categories below (weights = editorial judgment of a typical app's surface, documented so they can be argued with). Each row states its verification rungR1 emit · R2 swiftc/kotlinc typecheck · R3 unit/spec ·R4 local simulator/emulator build+run · R5 the nightly device gate (XCUITest + Compose connectedCheck) — and the fraction of the category with an ASSERTED behavior at R4+ (exercised-but-never-asserted counts as 0; strictness is the point). Coverage numbers are recomputed whenever a row changes — and GATED: scripts/check-multiplatform-matrix.ts(validate-fast + the CI Fast Gates job) recomputes Σ(weight × fraction) from this table and fails when the headline disagrees. Edit the table, then run the gate; never hand-edit the headline to a number the table does not produce.

CategoryWeightR4+ fractionRung + evidence
Core UI & layout (15 primitives)101.0R4 — Stack/Text/Button/Field/Press/List/Image/Icon asserted across the device apps, and Modal/Toggle/Scroll/Link are now individually device-asserted (the four this row previously named as gaps). Toggle flips an observable text, Modal presents + dismisses a sheet body, Scroll's container is queryable with its child still individually queryable, and Link navigates through PyreonLink in native-router-demo-ios. Two of the four were BLOCKED by emit bugs only a device could surface: <Modal> anchored .sheet to EmptyView(), which contributes nothing to the render tree, so the sheet NEVER presented on iOS (valid Swift, typechecked clean — invisible below R4); and <Link> is a special-case emitter that returned before the generic modifier tail, so data-testid never became .accessibilityIdentifier and the element was unqueryable, i.e. structurally unassertable. The Android halves are now device-asserted too (Compose instrumented tests, local pixel_6 emulator pass + the nightly gate): the Switch click flips the derived state text, the Dialog body exists only between open and close (composed inside if (open) — no anchoring requirement, so the Kotlin Modal emit was already correct where the SwiftUI one was not), the Scroll container exposes a LIVE scroll action in the semantics tree (hasScrollAction — a dropped verticalScroll modifier is visible) with its child individually queryable, and PyreonLink's tagged Box click navigates home → about. Closing them surfaced the THIRD emit bug in this row: emitKotlinToggle dropped data-testid (a special-case emitter returning before the generic modifier tail — the exact <Link> class in its Toggle sibling; the Swift half chained its modifiers), so the Switch was unselectable by onNodeWithTag at all. Fixed + device-bisect-verified (reverted emit → could not find … TestTag = 'core-toggle' / 'home-link-about'; restored → 19/19 + 4/4). Layer/Spacer/Heading are now behaviourally device-asserted on BOTH platforms too — by GEOMETRY, not existence (they live in native-router-demo, whose home screen holds everything in the first screenful; the counter's overflowing non-scrollable column measures past-the-fold children at ZERO height on Compose, making vertical geometry unassertable there — an empirically-confirmed Compose measure fact, not a test convenience): Heading's glyph box is measurably TALLER than a body-size Text (the level→font/typography lowering is the only source of that delta), Spacer pushes its Inline siblings to the row's edges (gap > 100pt/dp; a dropped Spacer leaves them adjacent), and Layer's children OVERLAP (frame/bounds intersection — a linear-container mis-emit lays them out disjoint). Behaviourally bisect-verified on BOTH platforms in one cycle: swapping Heading→Text, dropping the Spacer, and Layer→Stack in the shared source fails exactly those three tests on iOS AND Android; restored → router-demo 7/7 both platforms. The row's fraction is 1.0: every one of the 15 canonical primitives now has a dedicated behavioural device assertion
Lists & keyed rendering80.85R5 — todomvc list mutations asserted. 10,000-row list device-proven on both platforms (router-demo BigListPage, <Scroll><For> over Array.from({length:10000},…)): creation at scale (Row 0 renders inside the timeout — an eager build hangs), LAZINESS (a deep row is NOT in the semantics/a11y tree at launch: iOS's LazyVStack wrap + Android's LazyColumn compose only visible rows; an eager ForEach-in-ScrollView materializes all 10k), and reachability (Android performScrollToNode drives the LazyColumn all the way to Row 9999; iOS asserts scroll advances — XCUITest has no deep-jump primitive, disclosed). Building the page found + fixed a REAL emitter bug: the Array.from range-map lowering never seeded its index param into the emit-time inference ctx, so the object-literal record bailed struct synthesis and emitted a labelled TUPLE — uncompilable on BOTH targets (Swift tuple key paths break ForEach(id:\.id); Kotlin named-tuple syntax doesn't exist) while the emit-only probe was green (the wrong-transform-masks class). AND the Android device run caught a SECOND real emitter bug the compile gates structurally cannot see: a data-testid on the <Scroll> bailed the lazyOnly unwrap, silently emitting Column(verticalScroll){LazyColumn} — the documented MEASURE-time IllegalStateException — now fixed by keeping the wrapper's layout WITHOUT the scroll modifier (and the mixed-children nested-lazy warning the code had only ever promised in a comment is now actually emitted). Also surfaced + documented: the helper-body frontier is REAL (a top-level helper's const rows: T[] = [] + .push loop emits untyped/immutable/untranslated on both targets — the tracked warn→native-emit arc). Still absent: reorder-at-scale timing, iOS deep-jump
Navigation & routing80.8R5 — nav, typed params, guards, loaders asserted (router-demo); deep links absent
State (signals/computed/stores)91.0R5 — counter increment + store mutation asserted both platforms; createMachine state transition device-asserted (M2.6) — the counter's Toggle button drives an offon transition that re-renders on both platforms (iOS PyreonMachine is @Observable, Compose mutableStateOf), proving a Tier-2 state machine actually transitions + reacts on-device, not just compiles. Row already at 1.0 (state machines are a state construct), so this deepens the evidence without moving the fraction
Forms & validation60.8R5 partial — useForm v2 device-proven (validators, bindings, submit gating). useFieldArray (dynamic form lists) is now DEVICE-PROVEN on both platforms — and it was built end-to-end, not merely asserted: the vocabulary did not exist natively (the hook was the parser's canonical UN-lowered example). Shipped: PyreonFieldArray runtimes (Swift @Observable, Kotlin SnapshotStateList) with STABLE monotone keys mirroring the web @pyreon/form surface one-for-one (append/prepend/insert/remove/update/move/swap/replace/values — byte-aligned contract suites on both platforms pin key stability: a removal never re-keys survivors, replace always re-keys); the PMTC lowering incl. the ACCESSOR-UNWRAP seam (web signal CALLS tags.items()/tags.length()/item.value() are native PROPERTIES — the paren-keeping emit fails both toolchain gates by construction, since the stubs mirror the property shape) and keyed <For by={i=>i.key}> integration (ForEach(_, id: \.key) / items(_, key = { it.key })). Device proof (router-demo about page): add renders the appended row, REMOVE-FIRST drops exactly row 0 with the survivor still rendered, count text tracks length — bisect-verified on BOTH platforms by no-oping the runtime remove() (iOS count did not drop after remove-first; Android Text + EditableText = [Tags: 1] assert — the first Android bisect attempt failed VACUOUSLY on a dead emulator (0 of 1 devices compatible) and was re-run for real, which is why the failure REASON is always read). Honest scope: text-level device assertions prove the mutation→re-render chain; key IDENTITY is pinned at the runtime-contract layer. Residual 0.25: dynamic per-field registration (registerField/unregisterField) has no native vocabulary, and the field-array rows are not yet driven through <Field> inputs on device (display-list proof, not input-list) Newly recorded open gap (found by the auth-rehydration arc, Kotlin only): an onSubmit body that references the form ITSELF — onSubmit: (values) => form.setFieldValue(...), the "clear the field after submit" shape — does not compile on Android: the emit passes onSubmit as a CONSTRUCTOR argument, so form is a self-reference in its own initializer ("unresolved reference 'form'"). Swift is unaffected; it assigns form.onSubmit post-init from .onAppear precisely to avoid this, and the Kotlin fix is to mirror that (a var onSubmit assigned after remember). Recorded rather than fixed in the same pass so the arc's regression tests each lock ONE bug. Reading a field OFF the submit payload (values.username) IS fixed and locked on both targets. The Kotlin-only onSubmit self-reference gap recorded above is now FIXED and device-asserted. onSubmit: () => form.setFieldValue(…) — clearing the field after submit, the most common reason a submit handler exists — did not COMPILE on Android: the emit passed onSubmit as a constructor argument inside remember { PyreonForm(…) }, so the body was a self-reference in the form's own initializer. The emit now assigns form.onSubmit AFTER the declaration, mirroring what Swift already did from .onAppear, and PyreonForm.onSubmit is a settable var (the validate stub too — a stub stricter than the runtime rejects correct code). Bisect-verified at the level that matters: restoring the constructor path reproduces the recorded error verbatim (unresolved reference 'form' at the handler's first line) and the fix compiles. The device assertion is deliberately placed on the finance dashboard's add-transaction form rather than the login form, and the first draft that used the login form is the reason: a handler that NAVIGATES away can only be asserted after a round trip, where a remount with fresh initial values satisfies the assertion just as well as the clear does — it would have passed with the handler deleted. The add form clears IN PLACE, so an empty field is reachable only through the self-reference; both platforms assert it (Android reads EditableText, because an empty Compose TextField publishes its PLACEHOLDER in Text and the text-equality form fails on a correctly-empty field; iOS accepts empty-or-placeholder for the same reason). Residual, unchanged: dynamic per-field registration has no native vocabulary.
Networking (fetch/ws/http)80.8fetch R5 (success + error path asserted). useWebSocket is now DEVICE-PROVEN on both platforms — a full frame ROUND TRIP through the real network stacks (iOS URLSessionWebSocketTask, Android OkHttp) against a loopback echo server (router-demo/scripts/ws-echo-server.ts; iOS shares the host loopback, Android reaches it via adb reverse so the shared-source URL stays ONE literal). The ECHO is the load-bearing assertion — deliberately not the isConnected gate, because the Swift runtime marks connected OPTIMISTICALLY on task.resume() before any handshake (a dead server still reads open for a beat; a disclosed runtime-accuracy follow-up), while Kotlin's flips in OkHttp's real onOpen. Bisected by KILLING the server: both platforms fail at the echo (iOS's diagnostic even names the live state WS: closed / Echo: none); restored → full unions 10/10 both. Getting here surfaced TWO real gaps: the Swift emit produced ws.isConnected()/ws.lastMessage() CALLS against runtime PROPERTIES (uncompilable — Kotlin had the unwrap since the hook landed, Swift never did; invisible to the typecheck matrix because its usage only ever SENT), and the Android manifest lacked INTERNET + cleartext (ws:// is cleartext; Android 9+ blocks it by default, so the OkHttp handshake failed silently). http VERBS are DEVICE-PROVEN on both platforms (R4). useFetch(url, { method, headers, body }) is the shape an author reaches for, and every field of that init object was previously READ BY NOBODY: the parser only looked at arguments[0], so both targets emitted a plain GET and the app silently performed the WRONG VERB with no diagnostic — worse than not supporting verbs, because nothing said so. It now lowers to PyreonHttp, a runtime that had shipped on BOTH targets with full verb support and nothing calling it (Swift had a live URLSession edge no emit reached; Android had an executor INTERFACE whose real OkHttp implementation this arc had to write). Verified: both emits compile under swiftc/kotlinc, 13 compiler specs, and non-literal method/body/headers now WARN loudly instead of degrading to GET. Device-run on a real iOS Simulator and a real Android emulator against a fixture that REFLECTS the request, so a degraded GET renders Method: GET instead of passing quietly. Bisected on BOTH: reverting the Swift emit fails with Method: GET / Body: <empty> / Id: srv-1 — the request succeeded and decoded, only the verb was silently wrong — and reverting the Kotlin decode fails with Method: none / Id: none; restored → both pass. The Android run earned its keep: everything below R4 was green there (emit correct, executor typechecks against exactly-mirrored okhttp3 stubs, whole app compiles) and it STILL failed, because kotlinx.serialization's default Json THROWS on a JSON key the target type does not declare while Swift's JSONDecoder ignores it — so the same shared source against the same server worked on iOS and threw on Android. That is PRE-EXISTING and hit the plain GET path too; it stayed invisible because the only device-proven fetch fixture is a hand-written quotes.json whose shape matches its type exactly, i.e. the one case that never trips it. Fixed by decoding through PyreonFetchJson (ignoreUnknownKeys = true). Isolating it needed a CONTROL — the websocket test on the same emulator, same port, same adb reverse — to prove the network path before suspecting the code. Probing it surfaced two PRE-EXISTING breaks in the same container, both invisible because every device-proven example fetches an ARRAY and reads data() ?? []: a single-object data()?.field read emitted data.field on Swift (uncompilable — the inference reported the container's data as non-optional when all three layers declare it optional, so the member emit stripped the author's ?.), and error() in CALL form inferred unknown, so {f.error() ? … } emitted a bare Throwable? as a Kotlin condition. Residual 0.35: the device runs for the above, wss/TLS + reconnect/backoff undemonstrated
Storage (kv/secure/db)70.7kv persistence ASSERTED (M1.2a): iOS terminate+relaunch (R4, local Simulator pass) + Android activity-recreation. The Android kv half was passing over a hole: PyreonStorageRegistry defaulted to an in-memory map, the DataStoreBackend the docs pointed at for "actual cross-launch persistence" did not exist in the repo, and no app assigned the registry — so nothing persisted past process death, and the gated test (activity recreation, which keeps the PROCESS) could not see it. Fixed 2026-07: FileStorageBackend installs itself on first rememberPyreonStorage, unless the app chose its own backend. secure-storage still R2. useDatabase is now DEVICE-PROVEN — iOS taps Save Note, asserts the rendered count advanced (so db.insert ran and the record landed), then TERMINATES the app, relaunches, and asserts the count survived; on the relaunched process onMount's db.count() is the only source of that number, so an in-memory backend renders 0 (bisect-verified: it does). Android asserts the same write plus the DISK round trip (a cold PyreonDatabase over the app's filesDir reads what the UI wrote). useSecureStorage is now DEVICE-PROVEN on both platforms — and it took the whole vertical to get there, because the sub-capability was three-quarters missing, not merely unasserted: the emit was a warn-drop (deferred v1), the Kotlin runtime had NO real backend (only in-memory, with an app-injection requirement PMTC could not satisfy), and the web half did not exist (the shared import resolved on neither web app — the useGeolocation gap again). Closed end-to-end: a real KeystoreSecureBackend(context) (AndroidKeyStore AES-256-GCM over app-private prefs, no new gradle dep — androidx security-crypto is deprecated and wrapped exactly this surface) resolves the stated blocker the same way FileStorageBackend did; the API went KEY-FIRST everywhere (write(key, value) — the old write(value, key) was a live hazard: both params are String, so a positional emit of the natural TS call would have COMPILED with the arguments crossed; the Swift emit now labels write(key:value:), making the crossed call unrepresentable); and useSecureStorage() lowers on both targets (Swift Keychain default; Kotlin Context-threaded, the PyreonDatabase shape). Device proof (router-demo): iOS writes then survives a genuine terminate+relaunch — on the fresh process the mounted read is the only source of the rendered secret, so only the real Keychain explains it; Android proves the round trip PLUS a cold PyreonSecureStorage(context) decrypting what the UI wrote PLUS encryption at rest (the raw SharedPreferences value is iv:ciphertext, asserted NOT to contain the plaintext — a plaintext-persisting backend would pass every other check and fail exactly there). Bisect-verified on both platforms: swapping the defaults to InMemorySecureBackend fails exactly the durability halves (iOS post-relaunch reads none; Android cold-instance reads null) while the same-process round trips still pass — the discriminator between works and persists. Fraction 0.45 → 0.7 = (kv ≈0.9: iOS genuine relaunch, Android activity-recreation only) + (secure-storage ≈0.75: iOS relaunch-proven, Android cold-instance + encrypted-at-rest, no Android process death) + (database ≈0.5: iOS relaunch-proven, Android disk-proven but no cold-launch re-read), over three sub-capabilities. Still owed: real process death on Android — its instrumented tests share the app process, so an am force-stop kills the runner
Auth50.55gate/login flow R5. useAuth device-proven — the finance real-app gate renders the PyreonAuth container's live status and drives beginSignIn() through a sign-in that reaches the guarded dashboard. SESSION REHYDRATION is now device-proven on both platforms too, in both directions: signing in persists to the platform secret store, and a relaunch restores the session through the whole chain (secret store → auth container → the store flag the route guard reads → the dashboard) with no typing — iOS across REAL process death (app.terminate() then a cold launch), Android across an activity relaunch PLUS a cold PyreonSecureStorage instance decrypting what the UI wrote (the documented in-process ceiling; the iOS half carries the cross-process claim). The INVERSE is asserted with it: sign-out must clear the persisted token, or a "signed-out" user is silently signed back in on the next launch — bisected by disabling the clear, which turns out to be visible immediately (the login screen bounces straight back to the dashboard as the launch read re-authenticates). Building this found THREE real emit bugs, none visible to any prior gate: (1) secrets.read() is String? on both runtimes but inference had no model for service METHOD returns, so the natural if (token) emitted a bare optional as a condition and compiled on NEITHER target; (2) Swift additionally needed the if let BINDING, not just a nil-test, or the then-body still read String? where String was expected; (3) onSubmit: (values) => values.username — reading a field off the submit payload — passed the member access through verbatim against a string-keyed dictionary, uncompilable on both targets, hidden because every gated app named the parameter _values and never read it. Real IdP / token flows and signInSucceeded/signInFailed against a real provider remain R1–R2 (blocked on provider credentials, not on the framework)
Platform APIs (haptics/share/link/notifs/camera/biometrics/files/deep links/lifecycle)100.85clipboard/geolocation/push/payments/permissions/haptics/share/link/notifs exist at R2+. share (useShare(), M3.2) + link (useLinking(), M3.2b) each reach a BEHAVIORAL R4 (XCUITest asserts the share sheet appears / the app leaves the foreground on UIApplication.shared.open). haptics (useHaptics(), M3.1) + notifs (useNotifications(), M3.3 — LOCAL notifications, iOS UNUserNotificationCenter / Android NotificationManager+channel+POST_NOTIFICATIONS) each reach a NON-BEHAVIORAL R4 (the tap fires the call without crashing; haptics have no observable UI on the Simulator, and a notification's permission-prompt + auto-dismissing banner make a reliable springboard assert infeasible — so the honest ceiling is build+run+tap-no-crash). Android: Intent.createChooser(ACTION_SEND) / Intent.ACTION_VIEW / NotificationManagerCompat. biometrics (useBiometrics(), M3.5) reaches a BEHAVIORAL R4 on the deterministic path — the counter's Unlock button awaits bio.authenticate(...) inside an async handler that PMTC wraps in a native Task/coroutine scope (the M4.5 async-lowering, the FIRST async-result service); on an unenrolled Simulator/emulator the gate resolves false with NO prompt (canEvaluatePolicy guard), so the observable outcome flips Lock: idleLock: denied (iOS XCUITest + Android Compose test assert it), proving the async scope RUNS on-device + the post-await re-render fires — but the biometric SUCCESS path (enrolled → unlocked) and the Android real BiometricPrompt/FragmentActivity runtime are follow-ups (the Kotlin v1 scaffold resolves false). camera/photo-picker (useImagePicker(), M3.4) reaches a BEHAVIORAL R4 on iOS — the counter's Pick Photo button awaits picker.pick() in an async handler (the second async-result service, riding the same M4.5 lowering); the XCUITest taps it, asserts the system PHPickerViewController PRESENTS, dismisses it, and asserts Photo: idlePhoto: cancelled, proving the picker presented AND its async result flowed back across the dismissal into a re-render. Needs NO photo-library permission on either platform (both system pickers run out of process). The Android emit is REAL, not a scaffold (rememberLauncherForActivityResult + PickVisualMedia + a CompletableDeferred callback→suspend bridge), but its device test asserts registration + render onlyPickVisualMedia launches a separate system activity the Compose test framework cannot drive or dismiss, so the pick ROUND TRIP is iOS-proven only. Picking a real asset (vs cancelling) is not device-asserted on either platform. files/documents (useFilePicker(), M3.8) reaches a BEHAVIORAL R4 on iOS, the document sibling of the photo picker (any file — PDF/csv/zip — not just photos): the counter's Pick File button awaits files.pick() in an async handler (the THIRD async-result service), the XCUITest taps it, asserts the system UIDocumentPickerViewController PRESENTS, dismisses it, and asserts File: idleFile: cancelled, proving the picker presented AND its async result flowed back across the dismissal. Needs NO storage permission (both system pickers run out of process). The Android emit is REAL (rememberLauncherForActivityResult + SAF OpenDocument + a CompletableDeferred bridge) but — like the image picker — its device test asserts registration + render only (OpenDocument launches a separate system activity the Compose test framework cannot drive), so the round trip is iOS-proven only; picking a real file (vs cancelling) is device-asserted on neither platform, and SAVING/exporting a file is a separate native flow that is a tracked follow-up (M3.8b). deep-links/lifecycle ABSENT INBOUND deep links now exist and are device-proven on both platforms — previously there was no vocabulary at all: useLinking() is OUTBOUND only (openUrl), so an app could not be opened at a route, which rules out universal links, notification taps and share targets. Both routers already accepted an initialPath; the missing piece was a channel from the platform URL callback to the router, so the fix is RUNTIME-ONLY — PyreonDeepLink (a single-slot listener plus a pending value) feeds PyreonRouter.init through a DEFAULT ARGUMENT, needing no compiler change and no change to emitted code. Both arrival shapes are asserted, because they take different paths and a cold-only test would pass on an implementation that drops every link after launch (the more common real interaction): COLD (launched BY the URL, no router yet, the pending path is consumed by the first router) and WARM (already running, delivered to the live router). iOS drives the FULL OS path — a real Safari address-bar hand-off — for both shapes. Android splits the claim by instrument: the manifest intent-filter is asserted by resolving the real VIEW intent through PackageManager (a missing filter means the OS can never deliver), and the store→router chain by two successive warm links (one link would pass on a listener that fires once and detaches). Disclosed: Android drives the chain through the store rather than activity.onNewIntent, because calling that from the test leaves ActivityScenario unable to destroy its activity and reds the run with every assertion already passed; the two-line host forwarding it skips is covered by the routing assertion plus the iOS end-to-end. Single-slot listener by design — an append-only list on a global is the unbounded-growth shape, and one slot encodes "the newest live router owns inbound links" App LIFECYCLE is now DEVICE-PROVEN — and it was the THIRD member of the never-wired class (after useOnline's NWPathMonitor and usePush's notification-center delegate, all found in one sweep of the remaining start(register) seams): PyreonAppState.start() wired real UIApplication notifications from inception and NO emit called it; the Kotlin container had an injected seam and no Android edge at all — useAppState() reported its initial "active" forever on both targets. The emit now self-starts observation (.onAppear { app.start() } on the stable host; rememberPyreonAppState()'s LifecycleEventObserver on the hosting Activity — stated plainly: in a single-Activity app the Activity lifecycle IS the app lifecycle for these transitions, and a multi-Activity app that needs process-level semantics wires the seam itself). The assertion surface is a STICKY wasBackgrounded flag — an end-state a frozen container can never reach, independent of how many transition events a backgrounding path fires: iOS backgrounds via a real Home press and re-activates (BG: falseBG: true + Phase: active, local Simulator); Android drives the activity CREATED→RESUMED through the scenario rule (rides this PR's required gate). The flag is a native-only member read (the web hook is a bare accessor) — same footing as the legacy net.isOnline shape, disclosed on the page.
Animations & transitions60.6<Transition show> device-proven (M2.7) — the counter's Toggle Box flips a signal and the animated child ("Animated Box") shows/hides through the platform animation path (iOS .transition(.opacity) on an if show gate + .animation(.default, value:) on a stable ZStack; Android AnimatedVisibility(visible = show)); the device gate asserts it disappears then reappears (waitForNonExistence / assertDoesNotExist), bisect-verified. BEHAVIORAL on the show/hide — the fade TIMING itself is not asserted (an opacity curve isn't queryable). Plus <TransitionGroup> (animated keyed list) device-proven (M2.8) — todomvc wraps its todo <For> in <TransitionGroup>, lowered to an animated list (iOS VStack { ForEach }.animation(.default, value: list.count); Android Column(Modifier.animateContentSize())); the device gate adds a todo (a row ENTERs the animated list) then removes it (LEAVEs). This ALSO fixed a real emit bug the device gate caught: the Swift emit drove .animation off the whole list, which is uncompilable (a PMTC struct isn't Equatable) — fixed to drive off .count (Equatable, changes on enter/leave). Like the show/hide: behavioral on the enter/leave + compile-load-bearing, NOT on the animation timing. Still absent: configurable duration/easing, enter≠leave, spring/keyframe, gesture-driven, shared-element + reorder/layout animations Configurable duration/easing now LOWER on both targets and are DEVICE-TIMING-PROVEN on Android<Transition show duration={2500} easing="linear"> emits .animation(.linear(duration: 2.5), value:) / AnimatedVisibility(enter/exit = fade(tween(2500, LinearEasing))) (CSS easings mapped to the canonical curves: ease-in→FastOutLinearInEasing, ease-out→LinearOutSlowInEasing, ease-in-out/default→FastOutSlowInEasing; absent props emit byte-identically to the M2.7 shape, spec-locked). The Android proof runs on the compose rule's VIRTUAL clock (deterministic): mid-exit the slow box still exists at +1000ms — bisected by stripping the config, which fails exactly that assertion (default ≈300ms has removed it) — and is gone once the configured duration elapses. iOS timing is honestly NOT device-assertable: measured, SwiftUI drops the view from the ACCESSIBILITY tree the moment the if gate flips — the fade is visual-only, the same instrument class as rendered colours (screenshot-diff stays the tracked follow-up); the iOS device test pins the configured path's show/hide behaviour, and the config emit is locked by emit specs + the real-SDK typecheck gate. Closing this also hit the stub-masked-symbol class AGAIN on cue (fadeIn/tween/easings are sub-package symbols; the real gradle build caught the missing conditional imports, now in the CLI table with specs) AND the <Inline> overflow gotcha (a 4th nav button clipped off-screen, its click silently no-oping — split into two rows, documented in-source). ASYMMETRIC enter/leave now has vocabulary on ALL THREE targets and is device-provenenterDuration / leaveDuration (+ enterEasing / leaveEasing), each falling back to the symmetric duration / easing. "Quick in, slow out" is the common real shape and had no expression anywhere before this. Swift lowers to .transition(.asymmetric(insertion:removal:)) with a per-side AnyTransition.animation(_:); Compose to separate fadeIn/fadeOut tween specs. The WEB half was the bigger gap: kinetic never typed duration/easing at all, so the numeric timing that both native targets had honoured since the config arc was silently ignored in a browser — one source, two behaviours (the documented web-defineTheme-did-not-exist class). kinetic now types the timing vocabulary and synthesizes the CSS shorthand from it, with an explicit enterTransition/leaveTransition still winning. The Android proof is TWO boxes with OPPOSITE configs driven by ONE signal, read at ONE instant on the virtual clock: 1000ms into the exit the 2500ms box is present and the 200ms box is gone — an outcome no symmetric emit can produce whichever duration it picks (bisected: disabling the branch collapses both to 300ms and the assertion fires). iOS asserts the BEHAVIOUR of the .asymmetric shape (mounts and unmounts); its fade timing stays unreadable through the accessibility tree, as this row already records. Known iOS limitation, measured in BOTH directions and disclosed rather than papered over: on a page with ONE <Transition>, hide-then-immediately-re-show works. On a page with THREE driven by the same signal, a re-show issued while a leave is still in flight leaves every transition child absent from the iOS accessibility tree and it does not return (15s). Verified by removing and restoring the extra boxes: the pre-existing symmetric-duration gate PASSES at one transition and FAILS at three, so this is not specific to the asymmetric emit. Compose recovers from the identical interruption on the virtual clock (reShowingDuringAnInFlightLeaveRecovers), so the shared source and both emits are sound — this is SwiftUI transition-interruption behaviour. Whether the iOS view is truly gone or merely dropped from the a11y tree is not determinable with XCUITest, and the screenshot-diff instrument this row already tracks is what would settle it. The asymmetry proof therefore lives on its OWN route (/anim) so the single-transition gate on /motion stays honest instead of being loosened to accommodate the wedge; a real app hitting this shape is a tracked follow-up, not a closed question. Still absent: spring/keyframe curves, gesture-driven, shared-element, and reorder/layout animations.
Gestures40.8tap R5; long-press <Press onLongPress> R4 (M2.3 — counter reset via a simultaneous LongPressGesture, iOS Simulator pass; Android combinedClickable(onLongClick) proven by the device run); swipe R4 both platforms<Press onSwipeLeft/onSwipeRight> lowers to a HIGH-priority DragGesture(minimumDistance: 20) with a horizontal-dominance guard (iOS) / pointerInput { detectHorizontalDragGestures } with a per-gesture accumulator reset (Android) / a pointer-delta polyfill that suppresses the same gesture's click (web, unit-locked). Device tests inject REAL gestures (XCUITest swipeLeft() / Compose performTouchInput) against a three-way-separable status ('left'/'right' vs 'tap' vs 'none'), and that separability caught a REAL bug on the first run: a .simultaneousGesture drag let the Button's touch-up-inside action fire on a real swipe (status read 'tap') — a SwiftUI Button fires on up-inside regardless of drag distance, so swipe must be .highPriorityGesture (claims drags >20pt; taps fail the drag and pass through — the final tap step proves coexistence). Emit-neuter bisected on both platforms. Still absent: continuous drag/pan (positional), pinch, vertical swipe
Adaptive / tablet layout50.6useSizeClass() READ (M2.2) + adaptive LAYOUT (M2.2b). The size-class READ is a BEHAVIORAL R4 (counter XCUITest asserts Size: compact on iPhone, Size: regular on iPad — reflects the REAL environment). The size-class-driven Stack↔Inline switch now works: a view-branch ternary (sizeClass() === 'regular' ? <Inline> : <Stack>) lowered to ? : was INVALID Swift (ViewBuilder rejects a ternary between different view types), now lowers to if cond { HStack } else { VStack } (swiftc -typecheck-proven FAIL→PASS; Kotlin already emitted if/else). Device-proven: the counter carries an adaptive Stack↔Inline and its iOS XCUITest COMPILES + renders the compact branch (a pre-fix ? : emit would fail xcodebuild). iOS @Environment(\.horizontalSizeClass) / Android LocalConfiguration.screenWidthDp / web matchMedia. Full responsive PROPS + tablet-optimized components still absent RESPONSIVE PROP VALUES are now DEVICE-PROVENgap={sizeClass() === 'regular' ? 6 : 2} (any value-level per-class prop; the ternary-of-literals form lowers on both targets with the scale tokens applied: compact 2→8dp/pt, regular 6→24). The Android proof is the strongest shape yet: a live CLASS FLIP on ONE device — measure the gap at phone width (8dp), wm size the display to tablet width, LocalConfiguration recomposes, the SAME nodes re-measure 24dp (the emulator restored in a finally). iOS asserts the compact half on the iPhone sim (three-way separable: 8 = compact token, 24 = wrong class, ~12 = adaptive prop dropped for the Stack default); the regular-class READ on iPad was already device-proven (M2.2). Branch-swap bisected: both platforms read exactly 24 at their compact gates. Residual 0.4: tablet-optimized COMPONENTS and a declarative responsive-prop vocabulary (arrays/objects) remain absent
Media (image display/picker/AV)40.6bundled image display R5 (tasks branded header); remote image now DEVICE-PROVEN at the PIXEL level on both platforms<Image src="http…"> lowers to SwiftUI AsyncImage(url:) / Coil AsyncImage(model=), and the router-demo tests render a solid-red fixture PNG served over real HTTP (host loopback on the iOS sim; adb reverse on Android) then assert the RENDERED pixels are red (iOS: element screenshot averaged to 1×1; Android: captureToImage center pixel) — fetched, decoded, drawn, not merely composed. Bisects: iOS fixture-server-down + app-uninstalled (URLSession cache cleared) → "never rendered red"; Android AsyncImage-emit-stripped → the generated code does not even compile. Building the page surfaced TWO missing infra pieces that made remote images dead-on-arrival in real apps: iOS had no ATS exception (NSAllowsLocalNetworking — URLSession cleartext is ATS-gated even to localhost; the ws test never saw it because the ws runtime rides Network.framework, outside ATS) and the Android example lacked the io.coil-kt:coil-compose ARTIFACT (the conditional import resolved against nothing — the dependency half of the stub-masked-symbol class). Remaining: fit/contentMode un-mapped on native (typed no-op, disclosed in the emit docs); load/error state has NO vocabulary (an onLoad/onError arc); picker is tracked under Platform-APIs (useImagePicker); AV playback absent AV playback now EXISTS and is DEVICE-PROVEN at the playback-STATE level: the <Video> canonical primitive (web <video> / AVKit VideoPlayer over AVPlayer / Media3 ExoPlayer in an AndroidView) plays the fixture server's 1s clean-room clip with autoPlay+muted+loop, and onStatusChange — KVO timeControlStatus on iOS, Player.Listener on Android, the media events on web, ONE three-value vocabulary — drives a status text both device tests assert flips to "playing": bytes fetched over real HTTP, buffered, playback entered. Stated plainly what that is NOT: rendered video FRAMES are asserted on NEITHER platform — video draws on a surface layer XCUITest cannot capture and captureToImage cannot read; picker round-trips (pick-a-real-file, Android) and AV audio remain open. Building it also surfaced that the create-multiplatform Android template was MISSING the okhttp artifact the runtime srcDir has required since the networking arc — masked because scaffolds install the runtime from npm, which lagged the workspace; the next release would have shipped every scaffolded Android app uncompilable (fixed in the same PR, with media3 added alongside).
Accessibility30.55roles + hidden now DEVICE-PROVEN. accessibilityRole="button" on a PLAIN <Text> — the discriminating shape, since a Button carries the trait natively — is queryable under XCUIApplication.buttons by its accessibilityLabel on iOS (XCUITest derives an element's TYPE from its traits, so the query succeeds only if .isButton actually landed; bisect: trait stripped from the emit → "not queryable as a button") AND carries Role.Button + the contentDescription on the SAME semantics node on Android. accessibilityRole="header" device-asserted on Android (SemanticsProperties.Heading key defined — the TalkBack rotor signal). accessibilityHidden device-asserted on Android: the decorative text is ABSENT from the semantics tree BY TEXT (clearAndSetSemantics clears text semantics too) with a visible sibling as the positive control proving the query works (bisect: emit stripped → Failed: assertDoesNotExist). accessibilityLabel was already proven on both platforms. Disclosed limits: iOS .isHeader + .accessibilityHidden stay emit-locked (XCUITest surfaces neither as a queryable property — tooling, not an emit gap); image role emit-locked on both; focus order + live announcements have NO vocabulary yet (an announce()/focus-request API is a design arc — tracked follow-up), so they remain unproven on either platform
i18n30.55createI18n translation DEVICE-PROVEN (M2.4) — the counter renders <Text>Greeting: {i18n.t('hello')}</Text> with createI18n({ locale: 'de', … }); its iOS XCUITest asserts the render tree shows the CONFIGURED-locale value "Greeting: Hallo!" (NOT the raw key "hello", NOT the English "Hello!"), so PyreonI18n.t genuinely resolved messages["de"]["hello"] at runtime AND honored the configured locale (bisect-verified: flip locale to 'en' → the render becomes "Hello!" and the query fails with "did not look up messages['de']['hello']"). Behavioral R4 on iOS (local Simulator pass); the Android half asserts the same node in Compose (onNodeWithText("Greeting: Hallo!"), CI-gated). Single-arg t(key) + locale selection are the device-proven core; interpolation (t(key, { name })), plurals, setLocale writes, <Trans>, and async namespaces stay R2 Interpolation + plural-rule selection are now DEVICE-PROVEN on both platforms (counter, driven by the EXISTING count signal): t('welcome', { name }) renders the substituted configured-locale string ("Hallo Vit!" — a dropped interpolation shows the raw {{name}} template, a wrong-locale lookup the English), and the plural text follows Increment across the _other→_one→_other boundary ("0 Stücke" → "1 Stück" → "2 Stücke"). Bisected by no-oping the runtimes' _one selection — both platforms fail exactly at the count==1 assertion (NOTE: the FIRST bisect attempt replaced count == 1 in the DOC COMMENTS and passed vacuously — caught because an expected-fail that passes is always investigated; the script now asserts on the code-line strings). No emit change was needed: the object-literal→dictionary lowering for i18n.t already existed (component-scope createI18n is the v1 contract; module-scope declarations are un-lowered). Still R2: setLocale writes, <Trans>, async namespaces, full Intl plural categories (few/many/zero — the runtimes ship the en-style one/other pair).
Styling & design system60.75PARTIAL, and worth reading precisely — this row exists because its ABSENCE was silently inflating every percentage on this page. styled(), @pyreon/elements Element, @pyreon/coolgrid, @pyreon/attrs, rocketstyle static + reactive dimensions, defineTheme tokens and text typography all LOWER (see the styling table above — 14 supported features, 4 architectural non-goals). What IS device-proven: the rocketstyle-over-Text authoring pattern with a REACTIVE dimension flip — the counter's StatusBadge takes state={count() > 2 ? 'warn' : 'ok'}, and both device gates assert the badge RE-RENDERS across the threshold (Badge:okBadge:warn), so a reactive dimension genuinely resolves and re-renders in a real app. Note precisely what that is NOT: neither gate asserts the rendered COLOUR — XCUITest and the Compose test tree cannot read it, and the device tests say so in their own comments rather than implying otherwise; colour PRESENCE is locked by an emit test (native-text-reactive-color-parity), which is a compile-level claim. The STATIC cascade is now asserted too, but on ANDROID only: a size dimension driving width lowers to Modifier.width(120.dp) / width(240.dp) and Compose's getBoundsInRoot() reads real layout bounds, so a dropped or ignored modifier is visible. iOS cannot make that assertion — XCUITest exposes an element's ACCESSIBILITY frame, which hugs the content: the same shape measured 52.7/36.0pt with the ids on Texts (the glyph widths of "narrow" and "wide") and 9.7/13.0pt on Stacks, never the 120/240 the modifier requested, so any band wide enough to pass would admit a dropped modifier. That limitation is recorded in the iOS test file rather than papered over, and a screenshot-diff instrument is the tracked follow-up. So two of roughly five substantial mechanisms have device evidence — reactive dimension re-render on both platforms, static cascade geometry on Android — hence 0.2, not 0. What is NOT: styled(), Element, coolgrid, attrs, static-only dimension cascades, defineTheme tokens and typography have no native example and no device assertion at all, so theme-token resolution and the static style pipeline have never rendered on a device. Compile-proven is the weaker claim here: a token resolving to the wrong colour compiles perfectly. Weight 6 = less than Core UI (10, the primitives themselves), equal to Forms and Animation defineTheme tokens + styled(Prim) are now DEVICE-PROVEN on both platforms by GEOMETRY (router-demo /styles): two styled cards take their padding from DIFFERENT token leaves (spacing.sm=8, spacing.xl=40), and the assertions pin the token VALUES — Android measures the start-aligned child offsets (delta = xl−sm = 32dp exactly); iOS measures the stack's VERTICAL gaps (title→sm = spacing+smPad = 20pt; sm→xl = smPad+spacing+xlPad = 60pt), because a live frame dump showed iOS a11y frames HUG glyphs — container padding is horizontally invisible to XCUITest (the #2593 lesson, remeasured before asserting), while padded boxes consume vertical space exactly. Token-flip bisected on BOTH platforms (xl 40→8: Android delta collapses to 0.0dp, iOS gap reads exactly 28 = 8+12+8); restored → full unions 11/11 both. Closing this also surfaced that the documented web defineTheme ("identity on web") did not EXIST — no package exported it, so a shared source using the styling vocabulary could not build for web at all (the useGeolocation resolvability class); it now ships from @pyreon/styler, typed.s @pyreon/coolgrid and @pyreon/elements Element are now DEVICE-PROVEN by geometry on both platforms — the two named gaps that had no native example at all. The router demo's /styles page lays out an ASYMMETRIC 3/9 twelve-column split (asymmetric on purpose: a 6/6 split cannot distinguish a correct grid from a dropped or defaulted span). Android reads the columns' real widths as fractions of the row (0.25 / 0.75); iOS reads each column's glyph CENTRE against the screen (12.5% / 62.5%), which pins both spans at once — a container's WIDTH stays invisible to XCUITest a11y frames, but where a glyph sits is a real layout fact. Element padding={4} is asserted by the marker-to-child vertical offset (12pt stack gap + 16pt padding) on both. Bisected per platform: span ignored → Android reads 0.5 of the row; padding modifier dropped → iOS reads 12.0pt instead of 28. Building it found TWO real Compose bugs the emit tests could not see, both of which made the grid silently wrong in real apps: (a) a span lowered to fillMaxWidth(size/12f), but a Row measures each child against the REMAINING width, so fractional fills COMPOUND — 3/12 then 9/12 lays out as 25% + 56% and the row never adds up; it now lowers to RowScope weight(size), an exact division and the semantic twin of Swift's containerRelativeFrame(count:span:). (b) a <Col>'s data-testid landed on the INNER stack while the width lived on the wrapper Box, so the sized node was unaddressable and the column measured 7.2dp of a 308dp row — the <Link> identifier-drop class again. The emit test asserted the OLD fillMaxWidth string and passed throughout, which is exactly what a compile-level assertion can do: confirm the code agrees with itself. Typography-from-tokens is now DEVICE-PROVEN by glyph geometry, and reaching it meant fixing TWO compiler gaps, not writing a missing example: GROUP_ALIAS knew only color/spacing/radius — a fontSize/fontWeight group in defineTheme was structurally unresolvable, so font-size: ${(t) => t.fontSize.display} warn-DROPPED while the padding token beside it resolved; and collectTheme hand-enumerated the three old groups, so even after the groups joined the table the app's parsed entries were silently discarded before merge — fontWeight.bold kept "working" only because the DEFAULT scale carries bold:700, the masked-by-default shape. The proof: two lines render the SAME glyph at sizes from different token leaves (body=16, display=34, both deliberately absent from the DEFAULT scale so the rendered sizes can only be the app's declaration), and the glyph-box HEIGHT ratio pins both values — iOS a11y frames hug glyphs, so height is real geometry there even though container widths are not. Token-flip bisected on device: display 34→16 collapses the ratio to exactly 1.0 (19.33/19.33pt) with the diagnostic naming it; restored, ~2.1 passes. Rendered COLOUR is now asserted on ANDROID — the earlier "unreadable by both harnesses" claim was stale the day the Media row shipped its pixel instrument: captureToImage reads the accent chip's drawn background (~#ff3b30 at the centre), so "a token resolving to the wrong colour compiles perfectly" is closed on one platform. iOS colour remains existence-only (XCUITest cannot read pixels; the screenshot-diff instrument stays the tracked follow-up). Still absent: attrs and static-only dimension cascades have no native example. @pyreon/attrs — the row's last named absent with no native example at all — is now DEVICE-PROVEN by geometry on both platforms. attrs(Stack).attrs({ gap: 5 }) accumulates a DEFAULT prop over a base; the use site passes only padding, so the card's measured child spacing (20pt/dp) can ONLY be that default surviving the merge, and the use-site padding proves the merge ORDER (use-site wins). Bisected on device: renaming the default key so the chain contributes nothing collapses the gap to 2.0pt and the test names it ("~12 means the attrs chain was dropped and the parent page's gap applied"); restored, ~20 passes. Android reads the same geometry through getUnclippedBoundsInRoot and rides the required gate. What remains genuinely open in this row: static-only rocketstyle dimension cascades, and iOS rendered COLOUR (XCUITest cannot read pixels — the Android pixel instrument covers that half; a screenshot-diff instrument stays the tracked follow-up).
Offline / sync30.35@pyreon/sync (the CRDT layer) is still web-only — a native Yjs port is a separate arc. But the OFFLINE-FIRST half, which is the part a real app actually depends on, is now DEVICE-PROVEN and was the reason this row read 0.0 while the capability half-existed. Durability: a record written with no network survives and is re-read from the database — iOS across REAL process death (app.terminate() then a cold launch), Android across an activity relaunch. Connectivity: useOnline() is asserted as a live FLIP on one Android device (radios down → Online: false → radios up → Online: true); asserting only the online state would pass on a hook hard-wired to true, which is exactly what Android's was. The iOS Simulator has no supported per-app network toggle, so that half is Android-only by tooling, not by omission. Two real bugs fell out of writing it. (1) useOnline() on Android had NO live monitor at all: PyreonNetworkStatus shipped as a pure container defaulting to true with a start(register) seam the app was expected to wire, and nothing wired it — so the hook reported online forever regardless of the device. Fixed with a self-installing rememberPyreonNetworkStatus() that registers a real ConnectivityManager.NetworkCallback and tears it down on leave (the same shape, and the same lesson, as the geolocation registry: a default that requires a step nobody takes is not a default). Bisected on device — reverting to the inert container times out waiting for the flip. (2) const found = db.get(c, id); if (found) — read a row, branch on whether it exists, the single most common database shape — compiled on NEITHER target until database.get joined SERVICE_METHOD_RETURNS. Remaining: the CRDT/sync layer itself (native Yjs), conflict resolution, and a replay-on-reconnect queue all have no native implementation
Maps / geolocation30.5useGeolocation BEHAVIORAL R4 on iOS — the counter renders <Text>Geo: {geo.latitude}</Text> and its XCUITest taps Locate, then asserts the RENDERED coordinate matches a simctl location-injected fix. That requires the whole chain to have run on-device (tap → real CLLocationManager watch → CoreLocation fix → @Observable update → SwiftUI re-render), which is a stronger claim than the biometric gate's denied-path proof. Bisect-verified AT THE DEVICE LEVEL: injecting London (51.5074) makes it fail after polling 25s; restoring 37.3349 makes it pass — so it genuinely reads the fix rather than passing vacuously. LOCAL Simulator pass, NOT CI-gated — the same disclosed footing as the i18n row. CI cannot make it deterministic: simctl privacy grant location only sticks for an ALREADY-INSTALLED app, and xcodebuild installs during the test run, so on a clean runner the grant is a no-op and iOS prompts. An interruption monitor did not close it either. The assertion is therefore gated on PYREON_GEO_FIX_INJECTED, which examples/native-counter-ios/scripts/geo-device-test.sh sets after installing the app and injecting a location; the CI run proves emit + launch + tap-without-crash and claims nothing more. Stated plainly because for three rounds this test passed locally, failed in CI, and was described as proof. Raising this row needed THREE fixes before a line of the test could be written: the web half did not exist so the import did not resolve at all; geo.start() did not build on Android (Swift's is 0-arg, Kotlin's took a host closure) and native-counter-android compiles the SAME source; and an interpolated Double? rendered Optional(37.3349). Only the last would have been visible from the test. The Android device test now EXISTS AND PASSES (0.3 → 0.5) — and writing it surfaced that the Android half was not merely unasserted but BROKEN: nothing ever installed AndroidLocationSource, so geo.start() errored “assign PyreonGeolocationRegistry.source” on every real device while the emit compiled green. Fixed structurally with rememberPyreonGeolocation() (the emit's new lowering), which SELF-INSTALLS the platform LocationManager source exactly like rememberPyreonStorage (“a default that requires a step nobody takes is not a default”); guarded, so an app-chosen registry source wins. The instrumented test is fully self-contained — it grants ACCESS_FINE_LOCATION + the mock-location appop via UiAutomation, registers a TEST GPS provider, taps Locate through semantics (below the API-33 fold), and injects fixes inside the wait loop — then asserts the RENDERED “Geo: 37.422”, the same full-chain claim as the iOS twin (start → LocationManager → AndroidLocationSource → Compose re-render). Emit-revert bisected (bare remember { PyreonGeolocation() } → the test fails; restored → passes). PyreonGeolocationAndroid.kt also joined the verify-kotlin gate (it had NO Linux-PR typecheck coverage at all — a pre-existing hole its siblings didn't have). Outstanding: useMap — the web half now EXISTS (@pyreon/hooks, state-only: camera + markers + selection, mirroring PyreonMapState exactly, so it needs no mapping library), and map.moveTo(…) / map.removeMarker(…) now COMPILE on Swift (they emitted positionally against labelled Swift signatures — the same defect #2514 fixed for PyreonDatabase, which had been fixed in a database-shaped way that left every other service exposed). The fraction is UNCHANGED at 0.3 because it measures device proof, and there is still no device test for useMap on either platform — so half this row's name remains unproven ON DEVICE, even though it is no longer unbuildable.
Payments20.0R2 runtime; no device test
Background / push30.3The RECEIPT half is now device-proven on both platforms, and reaching it meant fixing the same never-wired class useOnline had: both runtimes shipped pure containers with a start(register) seam the app was expected to wire, nothing wired it, and usePush() rendered its initial state forever — with zero warnings. The runtime file's own header argued the whole capability had to be injected because the APNs token lands in the AppDelegate; that is true of the token and only the token. iOS: the no-arg start() the emit now calls from .onAppear installs a container-owned UNUserNotificationCenter delegate + requests authorization — and xcrun simctl push injects a REAL APNs payload through exactly that pipeline, credential-free, so the assertion covers system delivery → delegate → container → SwiftUI re-render (scripts/push-device-test.sh; the plain CI run proves render + start-without-crash and claims nothing more, the geo-script footing). Android: rememberPyreonPushNotifications() registers a NOT_EXPORTED BroadcastReceiver on PYREON_PUSH_ACTION — the app-internal delivery seam an FCM service forwards into — and the instrumented test (same UID) broadcasts through the real Binder path and asserts the Compose re-render, with the action string imported from the runtime's constant rather than retyped. Not proven, stated plainly: the APNs/FCM TRANSPORT itself (needs store credentials — start(register:) stays the app-wired path and the first start of either kind wins), the device token (nil on the self-owned path), background execution (BGTaskScheduler/WorkManager have no vocabulary at all).
Release & distribution (archive/signing/store)50.3The ANDROID release path is now device-proven two ways and rides the required Android job (this row was added at 0.0 one PR earlier — it had been missing from the denominator entirely). Lane 1, the exact-bytes claim: release-smoke.sh boots the UNTOUCHED release APK (full R8 shrink+obfuscate+optimize, signed with a SELF-GENERATED keystore — credential-free by design, Play App Signing re-signs store uploads; a real upload key drops into the same keystore.properties) and asserts a REACTIVE Compose text landed in the accessibility tree via uiautomator, with no instrumentation relationship at all. Lane 2, the behavioral claim: gradle -PpyreonReleaseTests connectedCheck re-runs the FULL instrumented suite against the signed release build; the tested build applies -dontshrink + scoped -keepnames (the test frameworks resolve the stdlib/coroutines/tracing/compose hooks from the APP's classpath, which R8 strips — found FOUR classes deep, one NoClassDefFoundError at a time, before the shape was recognized; the app's own code and the com.pyreon.* runtime stay fully OBFUSCATED, the half of R8 that exercises Pyreon's claim). assert-release-lane.sh closes three silent-vacuity holes (minify dropped / toggle removed → silent debug retest / runner crashed at startup with 0 tests — all observed shapes), and the debug→release signed-install transition (INSTALL_FAILED_UPDATE_INCOMPATIBLE) got uninstall boundaries after the local bisect hit it. All three arms bisect-verified with restore. iOS: the repo's first DEVICE-SDK compilearchive.sh builds the app + both Pyreon Swift runtimes for real arm64-ios via xcodebuild archive (generic/platform=iOS) and asserts the .xcarchive contains an arm64 executable; UNSIGNED by design (exporting an .ipa needs an Apple Developer account), so the archive is never RUN — the iOS half contributes compile-proof, not R4, and the fraction counts it as 0. Scaffolded apps ship the same Android lane (npm run release:android; the scaffold also gained the .gitignore it never had — signing material was committable). Still absent: iOS signed export + on-device run (Apple Developer account), Play/App Store submission (both store accounts), any versioning surface beyond static versionCode. 0.3 = the Android signing+release-run share of this row, proven; everything else is the open remainder
Crash reporting & observability30.2The credential-free half now EXISTS and lowers to both targets — this row read 0.0 / ABSENT — no vocabulary at all. No useCrashReporter while useCrashReporter() shipped, was exported from @pyreon/hooks, and was wired in BOTH emitters. The matrix gate only checks that the headline equals the column sum, so it is structurally unable to catch a false ROW; this one was caught by reading the table against the code. What exists: capture (web window.onerror/unhandledrejection, iOS NSSetUncaughtExceptionHandler, Android Thread.setDefaultUncaughtExceptionHandler chaining to the previous handler), persistence (localStorage / Application Support / app files dir), and rehydration of the previous session's report on next launch — and the emit auto-starts it on the stable host, the same never-wired-class fix useAppState needed. 0.2, not higher: there is NO device test on either platform — the claim is emit-gated + stub-typechecked only, which is the rung below every behavioral row here. Still absent: symbolication (R8 mapping.txt and dSYMs are produced and nothing retains or uploads either, so a wired reporter would still show unreadable stacks), background execution telemetry, and signal (iOS) / NDK (Android) crashes, disclosed out of v1 scope. The vendor TRANSPORT is app-wired by design via setCrashTransport / PyreonCrashTransportRegistry — the framework never fakes an upload — so it is not counted as missing here.
Charts (plot engine)50.4@pyreon/charts/plot renders natively — this row was MISSING while the whole family set crossed. Every engine family's geometry (bar/line/area/points, pie, funnel, treemap, sunburst, tree, river, polar, sankey, graph, heat, candlestick, calendar, gantt, parallel) is generated into PyreonChartEngine.swift/.kt and drift-tested on both toolchains (R2/R3), and every host lowers to a native canvas (PyreonChartCanvas): the data-prop hosts, the accessor hosts (Funnel/Pie/Gauge/Radar/Candlestick/Heatmap) with their accessor bodies inlined, <PlotChart marks> itself, the legend + title blocks, theme overrides, formatters and bubble marks; selection (onSelect / onSelectIndex) lowers to a tap over the engine's own hit test. Device assertions exist in the tasks showcase on both platforms (a Sankey render + tap, a bar-chart tap → the bound index) and ride the required native-device job; the fraction sits at 0.4 until those runs are green and stays below 1.0 because the gesture surfaces every gesture surface now lowers — dataZoom (pinch + pan, #3268), zoomPresets (the engine-laid-out strip, tap-hit), the legend's tap toggle + paging, the navigator strip (its drag on a dedicated overlay) and brush (a plain drag selects; with dataZoom on, the web needs Shift, so that one combination stays web-only and warns by name) all ride the stateful-host emission that #3268 introduced — and <CalendarChart> / <ParallelChart> lower through compile-time literal adapters (a values record → CalendarValue[], mixed rows → Double[][] with categories resolved through the axes literal and gaps as NaN; anything non-literal warns by name) — only <OptionChart> (the ECharts option facade) stays web-only. Accessibility crosses: a11y.ts is in the generated engine, so every native chart canvas carries a name — an explicit accessibilityLabel, else the plot host's DATA DESCRIPTION (describeChart over the painted series, the sentence the web aria-label reads), else title, else the family word — device-asserted on both platforms. The family chrome (title, legend, tap tooltip) and the entrance tween (animate, PyreonChartEntrance) cross too, from the same crossing chrome.ts / the same progress parameter the web host uses — emit-locked and toolchain-compiled, not yet device-asserted, so the fraction does not move. The scale vocabulary crosses with the engine: yScale="log" (the log view), yTime, stackNormalize (the 100% stack), the axis titles and xLabels (thinning / the 45° slant, painted through the native canvases' own text rotation) lower as literals, <Scale> / <Axis title labels scale time> desugar, and the waterfall mark lowers; error bars lower as a second per-row accessor pair (errorLow / errorHigh → the engine's errLow / errHigh, the bubble radius channel's shape); <Histogram> (the row reshape — binValues itself crosses), locale (Intl) and facet (a DOM panel grid) are named as web-only at compile time. Device assertions exist in the tasks showcase on both platforms (a Sankey render + tap, a bar-chart tap → the bound index) and ride the required native-device job; the fraction sits at 0.4 until those runs are green and stays below 1.0 because the gesture surfaces every gesture surface now lowers — dataZoom (pinch + pan, #3268), zoomPresets (the engine-laid-out strip, tap-hit), the legend's tap toggle + paging, the navigator strip (its drag on a dedicated overlay) and brush (a plain drag selects; with dataZoom on, the web needs Shift, so that one combination stays web-only and warns by name) all ride the stateful-host emission that #3268 introduced — and <CalendarChart> / <ParallelChart> lower through compile-time literal adapters (a values record → CalendarValue[], mixed rows → Double[][] with categories resolved through the axes literal and gaps as NaN; anything non-literal warns by name), and <MapChart> lowers from a PRECOMPUTED GeoShape[] (raw GeoJSON's geometry union puts coordinates at two array depths, which the struct lowering refuses to merge; the registry name, the raw FeatureCollection and geoShapes() itself DECLINE BY NAME, naming the shape that does cross — project once on the web or in a build step) — only <OptionChart> (the ECharts option facade) stays web-only, and it DECLINES BY NAME. <BoxplotChart> crossed (its five-number reduction runs in the generated engine; device-asserted by a tap per band on both platforms), <RadarChart> gained the tap it had on neither target (device-asserted at a vertex on both), and a bare host — no theme, no provider — now follows the phone's colour scheme at runtime as the web host follows prefers-color-scheme (it was hard-wired light, silently). What does NOT cross says so: a rich-hit onSelect on the table-driven hosts, <ParallelChart tooltip>, and the web-only <PlotChart> props (keyboard, updateAnimation, toolbox, link, …) each warn by name. Legend PLACEMENT crosses as of the placeLegend split: legendPosition (top / bottom / left / right) lowers on every host that draws its chrome through the shared seam, and the split also closed a silent divergence — the native emit had been drawing the legend at x = 0 across the full width while the web host inset it by 8 on each side

Weighted totals (2026-07-08 baseline; M2.3 + M3.1 + M3.2 + M3.2b + M3.3 + M2.2 + a11y-label + M2.2b + M2.4-i18n + M2.5-colorScheme + M2.6-machine + M2.7-animations + M2.8-transitiongroup + M3.5-biometrics applied): device-proven (R4+) coverage≈ 69% (87.1 / 126 — gated: scripts/check-multiplatform-matrix.ts recomputes Σ(weight × fraction) from the table above and fails validate-fast + CI when this headline disagrees; before the gate this page carried three disagreeing self-ratings at once (a 66/100 status, a ≈72% headline, and a table that actually summed to 83.0 / 113 ≈ 73%). +0.6 from correcting a FALSE ROW (Crash reporting 0.0 → 0.2): the row read ABSENT — no vocabulary at all. No useCrashReporter while useCrashReporter() shipped, was exported, and was wired in both emitters. The gate compares the headline against the column SUM, so it is structurally unable to catch a row that misdescribes itself — only reading the table against the code does. A row claiming a capability is absent is as wrong as one claiming it is proven. The denominator grew again, and the headline fell (≈73% → ≈69%): two rows a shippable app cannot skip — Release & distribution (weight 5) and Crash reporting & observability (weight 3) — were missing from the table entirely, both at 0.0. Omitting the shipping path made every percentage flattering rather than true; the drop is a correction to the measurement, not a regression in the product. Increment history: +1.5 for the Release & distribution row (0.0 → 0.3): the Android release path device-proven two ways — the untouched fully-R8'd artifact boots and renders reactively, and the full instrumented suite passes against the signed obfuscated release build — plus the repo's first iOS device-SDK compile (unsigned arm64 .xcarchive; compile-proof only, counted 0). Building it surfaced five real defects, including a four-class-deep cross-APK linkage chain and a scaffold with no .gitignore at all, on top of +0.6 for the Styling row (0.65 → 0.75): @pyreon/attrs device-proven by geometry on both platforms — the row's last named absent with no native example, closed with a default-prop merge whose gap is measurable and whose neutered form fails decisively, on top of +0.3 for the Forms row (0.75 → 0.8): the Kotlin-only onSubmit self-reference gap — the "clear the field after submit" idiom that did not compile on Android at all — fixed by mirroring Swift's post-init assignment, compile-bisected against the recorded error and device-asserted in the finance app, on top of +0.5 for the Platform-APIs row (0.8 → 0.85): app lifecycle device-proven on both platforms, and the THIRD member of the never-wired class — PyreonAppState wired real OS lifecycle notifications nothing ever started; the sticky wasBackgrounded flag makes the fix an end-state assertion no frozen container can fake, on top of +0.3 for the Forms row (0.75 → 0.8): the Kotlin-only onSubmit self-reference gap — the "clear the field after submit" idiom that did not compile on Android at all — fixed by mirroring Swift's post-init assignment, compile-bisected against the recorded error and device-asserted in the finance app, on top of +0.6 for the Media row (0.45 → 0.6): the <Video> canonical primitive exists — AV playback had NO vocabulary at all — and playback STATE is device-proven on both platforms through the real AVPlayer/ExoPlayer pipelines; building it surfaced that the create-multiplatform Android template was missing the okhttp artifact the runtime srcDir already required, which the next npm release would have turned into every scaffolded app failing to compile, on top of +0.9 for the Styling row (0.65 from 0.5): typography-from-tokens device-proven by glyph geometry on both platforms and rendered COLOUR asserted at the pixel on Android — and reaching typography meant fixing two compiler gaps (the fontSize/fontWeight groups did not exist in the token resolver, and collectTheme's hand-enumerated group copy silently discarded app-declared entries — masked because fontWeight.bold rode the DEFAULT scale), on top of +0.9 for the Background/push row (0.0 → 0.3): the push-receipt half device-proven — a real simctl push APNs payload through the system pipeline on iOS, the broadcast delivery seam on Android — and building it found the same never-wired class as useOnline: both runtimes shipped pure containers whose start(register) seam nothing wired, so usePush() rendered its initial state forever with zero warnings; the APNs/FCM transport, the device token, and background execution stay honestly at zero (credentials / no vocabulary), on top of +1.05 for the Offline/sync row (0.0 → 0.35): offline-first durability + connectivity device-proven, closing the last true zero not blocked on credentials — and it found that useOnline() on Android had no live monitor at all, plus a database presence check that compiled on neither target, on top of +1.0 for the Platform-APIs row (0.7 → 0.8): inbound deep links shipped and device-proven on both platforms, cold and warm — a capability that had no vocabulary at all, closed with a runtime-only change that needs no compiler or emit change, on top of +0.9 for the Styling row (0.35 → 0.5): coolgrid + Element device-proven by geometry on both platforms, per-platform bisected — and building it found TWO real Compose layout bugs (compounding fractional widths in a Row; a test id on the wrong node) that every emit-level assertion had passed straight over, on top of +0.75 for the Auth row (0.4 → 0.55): session rehydration device-proven on both platforms and in both directions (restore across iOS process death / Android activity relaunch + cold-instance decrypt; sign-out clearing the persisted token), each half bisected on device — and building it found THREE real emit bugs that made the natural authoring shapes uncompilable on both targets, on top of +0.9 for the Animations row (0.45 → 0.6): asymmetric enter/leave shipped across all three targets and device-proven by an opposite-configs-at-one-instant assertion on Android — and the arc found that the numeric timing vocabulary had never existed on WEB at all, so one shared source animated differently in a browser than on a phone, on top of +0.8 for the Media row (0.25 → 0.45): remote images device-proven at the RENDERED-PIXEL level on both platforms (a red fixture PNG over real HTTP; server-down/emit-strip bisected) — and the page surfaced that remote images were dead-on-arrival in real apps (no ATS exception on iOS, no Coil artifact on Android), on top of +0.75 for the Accessibility row (0.3 → 0.55): roles + hidden device-proven — the button ROLE lands in both real accessibility trees (iOS trait→element-type derivation, Android Role.Button semantics), the header role + hidden land in Android's semantics tree, both emitter-stripped bisected on device, on top of +1.2 for the Lists row (0.7 → 0.85): the 10k-row list device-proven on both platforms (creation, laziness, deep reachability) — and the page found a real Array.from-lowering bug (unseeded index param → tuple emit, uncompilable on both targets), on top of +0.6 for the Maps/geolocation row (0.3 → 0.5): the Android geolocation device test exists and passes — and surfaced that the Android half was BROKEN (empty source registry), fixed structurally with the self-installing rememberPyreonGeolocation(), on top of +0.8 for the Gestures row (0.6 → 0.8): the swipe vocabulary shipped end-to-end and device-proven with REAL injected gestures on both platforms — and the three-way status separability caught a real SwiftUI coexistence bug (simultaneous drag lost the write race to the Button's touch-up-inside action) on the first device run, fixed with .highPriorityGesture, on top of +1.0 for the Adaptive row (0.4 → 0.6): responsive prop values device-proven — Android's live wm-resize class FLIP on one device, iOS compact half, branch-swap bisected, on top of +0.75 for the i18n row (0.3 → 0.55): interpolation + plural-rule selection device-proven on both platforms, runtime-bisected, on top of +0.9 for the Animations row (0.3 → 0.45): duration/easing config lowers on both targets, device-timing-proven on Android's deterministic clock, config-strip bisected (iOS timing disclosed as un-assertable through the a11y tree), on top of +0.9 for the Styling row (0.2 → 0.35): defineTheme tokens + styled(Prim) device-proven by geometry on both platforms, token-flip bisected (and the missing web defineTheme shipped), on top of +1.2 for the Networking row (0.5 → 0.65): useWebSocket echo round trip device-proven on both platforms (the send→server→receive→re-render chain through URLSession/OkHttp; server-kill bisected), on top of +1.5 for the Forms row (0.5 → 0.75): useFieldArray built end-to-end and device-proven on both platforms (runtimes + lowering + keyed-For integration; the accessor-unwrap seam makes a paren-keeping emit uncompilable), on top of +1.75 for the Storage row (0.45 → 0.7): useSecureStorage device-proven on both platforms (iOS Keychain terminate+relaunch survival; Android cold-instance decrypt + encrypted-at-rest — closing a sub-capability that was three-quarters missing: warn-drop emit, no real Android backend, no web half), on top of +2.0 for the Core-UI row closure (0.8 → 1.0, now COMPLETE: all 15 canonical primitives behaviourally device-asserted, Layer/Spacer/Heading by geometry on both platforms): the four canonical primitives this table had named as gaps (Modal/Toggle/Scroll/Link) are now device-asserted, and TWO of them were blocked by emit bugs that only a real simulator could surface — <Modal> anchored .sheet to an EmptyView() host so it never presented on iOS, and <Link> dropped data-testid before it became .accessibilityIdentifier, making the element unqueryable and therefore unassertable. Both emitted valid, typechecking Swift the whole time, which is precisely the class R1–R3 cannot see. Prior increments follow: — the Platform-APIs row's +1.0 each for haptics/share/link/notifs, +1.0 for useSizeClass() (M2.2), +0.45 for the accessibilityLabel device assertion, +1.0 for the adaptive Stack↔Inline layout (M2.2b), +0.9 for the createI18n translation device assertion (M2.4), +1.2 for the <Transition show> animation device assertion (M2.7), +0.6 for the <TransitionGroup> animated-list device assertion (M2.8), plus +1.0 for the useBiometrics() async-lowering device assertion (M3.5) (the Platform-APIs row moved 0.4 → 0.5 as the M4.5 await hook.method() lowering — the keystone for the whole async-platform-API tier — was proven to RUN on-device, not just compile, via a biometric gate whose deterministic denied path flips an observable text; the biometric SUCCESS path + Android real BiometricPrompt stay follow-ups) and +1.0 for the useImagePicker() device assertion (M3.4) (the row moved 0.5 → 0.6 as the first CAMERA/photo-library capability landed with a behavioural iOS round trip and a real — not scaffolded — Android launcher emit) and +1.0 for the useFilePicker() device assertion (M3.8): the row moves 0.6 → 0.7 as the files/documents capability lands (the document sibling — UIDocumentPicker / SAF OpenDocument), same behavioural iOS round trip + real Android launcher emit, leaving the pick-a-real-file path, the Android round trip, and file SAVING (M3.8b) as disclosed follow-ups. useColorScheme (M2.5) + createMachine (M2.6) landed device-proofs that deepened already-recorded rows so neither moved the fraction. The denominator grew, and the headline fell: ≈53% → ≈51%. A Styling & design system row (weight 6) was added because it had been missing entirely — the whole styled / elements / coolgrid / attrs / rocketstyle / theme-token surface lowers to both targets and is documented as supported, yet had no row and so sat outside the denominator. Its fraction is 0.2: the rocketstyle-over-Text pattern with a reactive dimension flip IS device-asserted on both platforms at the RE-RENDER level (the counter's StatusBadge flips Badge:okBadge:warn; the rendered COLOUR is not asserted — the harnesses cannot read it, and those tests are explicit about that), while styled() / Element / coolgrid / attrs / defineTheme tokens / typography have no native example at all. (Drafted as 0.0 and corrected on review — the "nothing is proven" reading was true of the branch it was written on and false of main, where the badge had already landed. Overstating a gap is as wrong as hiding one.) Omitting a track a real app leans on heavily made every percentage here flattering rather than true, and this table is supposed to BE the denominator. The drop is a correction to the measurement, not a regression in the product. Before that, Then +1.05 for useDatabase (Storage 0.3 → 0.45) — the first device assertion for structured storage, and the one that required THREE emitter/runtime fixes before the test could be written at all: get/delete/find had no argument labels (never compiled), db.insert({ id, fields }) lowered the record to an anonymous tuple (so the only WRITE never compiled either), and the default backend was in-memory (so nothing survived a relaunch). "No gated app renders FROM the database" turned out to have a cause rather than being an absence of effort. Before that, +0.5 for useAuth (Auth 0.3 → 0.4), from the first REAL-APP device gate — examples/native-finance-ios, which asserts a COMPOSED flow (useForm validation → the useAuth transition → a store-backed route guard → a computed balance → a keyed <For> mutation) rather than one hook at a time. That gate immediately earned its place by catching a locale-formatting bug four demo apps could not: Text("\(balance)") selects SwiftUI's LocalizedStringKey overload, which formats numbers per locale, so a 2700 balance rendered "2 700" and disagreed with the web and Android builds of the same source — invisible below 1000, hence invisible to Count: 0); plus +0.9 for the useGeolocation device assertion (Maps/geolocation 0.0 → 0.3 — the row's first non-zero score; the fraction stays low because useMap has no DEVICE test at all and the Android half is compile-only, so 0.3 buys iOS geolocation and nothing else. useMap's web half now exists and its moveTo/removeMarker now compile on Swift, which moves it from unbuildable to unproven-on-device — that is a real change but NOT a fraction change, because this number measures device proof. The headline does not move off ≈52% at this precision, which is the honest reading: one hook's device proof is a small share of a 113-point denominator). compile-proven (R2+) upper bound ≈ 71% — editorial, NOT machine-derived (the table has no R2 column): the prior ≈75%-of-113 estimate (~84.8 pts) plus the Android minified assembleRelease build proof (~1.5 of Release & distribution's 5) and nothing for Crash reporting, over the new 121-point surface — +2.0 for a NEW Charts (plot engine) row (weight 5 at 0.4) — a track that had no row at all while every engine family and every host crossed; the denominator grows to 126 and the headline moves with it.i.e. roughly three-quarters of the weighted surface already exists and typechecks, and about two-thirds is now proven to behave on a device — the residual between the two columns is the assertion debt this table exists to burn down.The production goal is 70–90% at R4+; the gap between the two numbers is, precisely, the roadmap: assert what exists (storage, auth, services), then build what doesn't (platform APIs, animations, adaptive layout, gestures beyond tap). The self-rating moves only on device evidence — this table is where that evidence is ledgered.

Per-hook device-behavior audit (M1.2)

The service-hook layer at per-hook granularity — same rung vocabulary, same strictness (a hook that runs inside a green device test but has no assertion on its behavior earns nothing). "In device app" names which of the nightly-built apps (todomvc / counter / router-demo / tasks, plusfinance — the first REAL app in the gate rather than a demo) uses the hook at all.

HookIn device appBehavior assertedRung
useFetchtasks✅ success (lc-quote) + error (lc-error) renderR5
useFormtasks✅ validators, field bindings, submit gating (login error path)R5
useParams / router nav + guardsrouter-demo✅ nav, typed params, auth gateR5
useStoragetodomvc✅ persistence: test_todosPersistAcrossRelaunch (iOS, genuine terminate+relaunch) + todosPersistAcrossActivityRecreation (Android, activity recreation — honest scope: not full process death, and until 2026-07 there was nothing BEHIND that scope: the Android backend was an in-memory map, so the test was measuring the one form of persistence that needs no persistence layer. FileStorageBackend now backs it by default; the process-death assertion is still owed)R4→R5
useLoaderData (loader auto-emit)R2
useAuthfinance✅ the container's initial status renders (signedOut), and beginSignIn() drives the sign-in flow that reaches the guarded dashboard; session rehydration device-proven both platforms, both directions (#2620): sign-in persists to the platform secret store and a relaunch restores the session through the whole chain (secret store → auth container → the store flag the route guard reads → dashboard) with no typing — iOS across REAL process death, Android across activity relaunch + a cold PyreonSecureStorage decrypt — and sign-out provably CLEARS the persisted token (bisected: disabling the clear bounces the login screen straight back to the dashboard)R4 (iOS + Android)
useDatabasecounterdb.insert lands on-device (the rendered count advances by one) AND the record survives a genuine terminate() + relaunch — on the relaunched process onMount's db.count() is the only source of that number. Asserted RELATIVE to the count at launch, because the Simulator keeps the app container between runs and an absolute assertion would pass once and fail forever after. Android asserts BOTH the write path (tap → the rendered count advances) and DURABILITY: a freshly-constructed PyreonDatabase over the app's own filesDir reads what the UI just wrote, and a fresh instance carries no in-memory state, so the record demonstrably came off the device's disk. The remaining delta vs iOS is narrow and named: AndroidJUnitRunner executes instrumented tests INSIDE the app process, so am force-stop would kill the runner too — the cold-launch onMount re-read is therefore iOS-onlyR4 (iOS relaunch; Android disk round-trip)
useSecureStoragerouter-demo✅ write→read round trip renders; iOS: the secret survives a genuine terminate+relaunch (Keychain — the fresh process's mounted read is its only source); Android: a COLD PyreonSecureStorage(context) decrypts the UI's write AND the raw prefs value is ciphertext, not plaintext (encryption at rest). Both halves bisect-verified via an in-memory-default swapR4 (iOS relaunch; Android cold-instance + encrypted-at-rest)
useWebSocketrouter-demo✅ echo ROUND TRIP on both platforms (#2614): send → live loopback echo server → receive → re-render, through the real transports (URLSession on iOS, OkHttp on Android); server-kill bisected. The device workflow boots the echo server for both the iOS and Android suitesR4→R5 (both platforms)
useGeolocationcounter✅ behavioral on iOS (#2570) — the device workflow grants location privacy + injects a Simulator fix (simctl location set), and the rendered coordinates assert against the injected value; Android's device test exists and passes (#2569 made the Kotlin half real: the empty source registry was replaced by the self-installing rememberPyreonGeolocation(), killing the manual-.start() asymmetry for this hook)R4
useMapR2
usePush❌ (manual .start())R2
usePayments❌ (manual .start())R2
usePermissions❌ (unit-tested; no device use)R2–R3
useClipboardR2
useOnline❌ (unit-tested emit + swiftc/kotlinc compile-proof of the shared net() accessor; no device use)R2–R3
useAppState❌ (unit-tested emit + swiftc/kotlinc compile-proof of the shared state() accessor + runtime state-machine tests; no device use)R2–R3
useColorSchemecounter✅ (M2.5) — Text("Theme: \(colorScheme)") reads @Environment(\.colorScheme); the counter XCUITest asserts "Theme: light" under the default Simulator appearance and "Theme: dark" under simctl ui appearance dark (proven locally), so the read reflects the LIVE system appearance, not a constant (a constant would render the same string in both). Still emit-only (no runtime port); Android asserts the same node in Compose (isSystemInDarkTheme(), CI-gated)R4 (iOS local + Android CI)

Assertion queue (the follow-up PR order; each moves a row to R5 and re-scores the matrix). Done: useStorage, useAuth (incl. #2620 session rehydration), useDatabase (iOS relaunch + Android disk round-trip),useWebSocket (#2614 echo, both platforms), useGeolocation (#2570 iOS behavioral + Android). Remaining: 1. useClipboard, 2. useMap(no device test on either platform), 3. usePermissions device use.usePush/usePayments stay gated on M3.9 auto-start parity first.

useDatabase deserves a note. Two things blocked it, and the second was worse than "unproven":

  1. Its emitted Swift did not compile — the get/delete/find calls were missing the argument labels the runtime declares (see the type gate).

  2. It did not persist. Both runtimes' PyreonDatabase defaulted to an in-memory backend, and the emit constructed exactly that default — so an app that inserted records and relaunched found them gone, with no warning, no error, and nothing failing. The entire reasonuseDatabase exists over useStorage is structured data that outlives the process, so an ephemeral default was not a conservative starting point; it was silent data loss wearing the word "default".

Both are now fixed. FileDatabaseBackend (one JSON file per collection, atomic writes, Application Support on iOS / filesDir on Android) is the default on both platforms, and the Kotlin emit threadsLocalContext.current into the constructor because Android cannot resolve app-private storage without a Context. The spelling is deliberately asymmetric — Swift's no-arg initialiser is the persistent one, Kotlin has no no-arg form at all — and the on-disk bytes are identical, locked by a cross-language format test that asserts the same string from Swift'sJSONSerialization and Kotlin's hand-written codec.

Deliberately Foundation/JVM-only, no SQLite: a record is an id plus string fields, and a SQLite module map differs between Apple platforms and Linux — the exact toolchain split that has broken this runtime's CI before. Apps that outgrow the file store inject Room / SQLDelight / Core Data through the same constructor.

A third defect surfaced while writing the device test, and it explains the other two: db.insert(collection, { id, fields }) lowered the record to an anonymous tuple, so the only way to WRITE to the store did not compile. With no writes possible, nothing downstream was reachable — "no gated app renders FROM the database" had a cause. Fixed in both backends.

With all three closed, the capability is device-proven on iOS: the counter taps Save Note, asserts the rendered count advanced, then terminates and relaunches and asserts the record survived. Still owed: the same assertion on Android (its emit and persistence are unit-proven, but nothing has run it on an emulator).

M2.3 — gestures (long-press) SHIPPED. <Press onLongPress={fn}> now lowers on native (the type + web 500ms-polyfill already existed; only the native emit was missing). Swift uses a SIMULTANEOUS LongPressGesture, not .onLongPressGesture — a bare long-press modifier on a Buttondoes NOT fire (the button's tap recognizer swallows it; found on a real Simulator, invisible to swiftc). Android uses combinedClickable( onClick, onLongClick). Device-proven: examples/native-counter-ios's XCUITest holds a <Press> reset zone >=0.5s and asserts the counter resets (R4 local pass); the Android longClick() sibling is proven by the device run. First gesture beyond tap. Deferred: onSwipe / drag (M2.3b).

Native routing

createRouter({ routes }) compiles to native dispatch — SwiftUINavigationStack + .navigationDestination(for:) on iOS, a Composewhen (router.currentPath) block on Android. One route table, both targets.

const router = createRouter({
  routes: [
    { path: '/',            component: Home },
    { path: '/users/:id',   component: User },          // path param
    { path: '/files/:rest*', component: Files },         // splat / catch-all
    { path: '/old',         redirect: '/users/1' },      // redirect (alias)
    { path: '/admin',       component: Admin, beforeEnter: () => isAuthed() }, // guard
    { path: '/app',         component: AppLayout, children: [   // nested layout
      { path: 'dashboard',  component: Dashboard },
      { path: 'settings',   component: Settings },
    ] },
    { path: '*',            component: NotFound },        // wildcard 404
  ],
  beforeEach: [requireAuth],                              // global guards run before every nav
  afterEach: [logAnalytics],                              // global hooks fire after every nav
})
return <RouterProvider router={router}><RouterView /></RouterProvider>

Inside a route component, read path params via destructuring:

function User() {
  const { id } = useParams<{ id: string }>()   // → id reads the active route's param
  return <Text>{id}</Text>
}

Path matching (mirrors @pyreon/router's match.ts, verified by the native router runtime's own swift test / kotlinc smoke):

PatternMatchesCaptures
/users/:id/users/42id = "42"
/blog/:rest* (splat)/blog/a/b/c (one-or-more tail)rest = "a/b/c"
/users/:id? (optional)/users and /users/42id absent or set
* / (.*) (wildcard)any unmatched path— (renders the 404 component)

Leading/trailing slashes are tolerated (/about/ matches /about).

Redirects are compile-time aliases: { path: '/old', redirect: '/new' }makes the /old dispatch branch render /new's component directly (no runtime push). Chains (/a → /b → /c) resolve transitively; cyclic / dangling redirects are dropped to the no-match fallback.

Wildcard 404: a * / (.*) route's component becomes the dispatchelse-branch — the canonical not-found page for any unmatched path.

Guards (beforeEnter: () => <boolExpr>) wrap the matched component in an inline conditional checked at navigation time; on failure the branch renders the wildcard catch-all (if present) or a denial placeholder.

Nested routes (children: [...]) compile to a flattened full-path dispatch where each leaf is wrapped in its layout chain via a content slot: a layout component (a route parent) is emitted with a@ViewBuilder content closure (SwiftUI) / content: @Composable () -> Unit(Compose), and its <RouterView /> becomes that slot. So /app/dashboardrenders AppLayout { Dashboard() }; the layout's own /app index rendersAppLayout { EmptyView() }. Three-plus levels nest outermost-first (AppLayout { TeamLayout { Members() } }). Flat route tables keep the original dispatch unchanged.

useParams() destructuringconst { id } = useParams() (and{ id: userId } aliasing) binds each field to the active router's param map: a computed private var id: String { useParams(router:)["id"] ?? "" }on SwiftUI (computed, not stored — it reads @Environment), val id = useParams()["id"] ?: "" on Compose.

Typed params prop — a route component may instead declareprops: { params: { id: string } } (the web router's prop-injection shape). PMTC synthesizes a named type per component — UserPagestruct UserPageParam: Codable (SwiftUI) / data class UserPageParam(Compose) — and the dispatcher constructs it from the matched path segments: UserPage(params: UserPageParam(id: params["id"] ?? "")) /UserPage(params = UserPageParam(id = params["id"] ?: "")). number /boolean fields coerce from the string segments with safe defaults (Int(...) ?? 0, == "true"). If the params shape structurally matches a struct you declared yourself (type RouteParams = { id: string }), your name is reused instead of synthesizing. Components without aparams prop are dispatched with no arguments.

Loader dataPyreonRouter exposes a loaderData store +useLoaderData<T>(); a route's loaded data is keyed by the active path and read back, typed, by the current route. A route'sloader: () => <expr> is now auto-emitted (v1): the compiler wraps the route in a runtime PyreonRouteLoader host that fires the loader once on appear (.task / LaunchedEffect) and calls setLoaderData, souseLoaderData<T>() reads it with no manual wiring. v1 covers zero-param, expression-body loaders (signal/store reads + sync expressions); param-using, block-body, and truly-async loaders WARN and emit unloaded — see the loader-auto-emit note in the roadmap.

Global guards (beforeEach / afterEach) — pass arrays of identifier-referenced guard/hook functions on the createRouter({ ... })config. The parser extracts the identifiers (inline arrow bodies + non- array forms are silently dropped — a documented follow-up); the emit configures the router via a Swift closure-init / Kotlin apply { } block. At runtime, push / replace wrap the navigation in the guard chain — any guard returning false blocks the navigation, then every afterEachhook fires after a successful commit:

const requireAuth = (path: string) => isAuthed() || path === '/login'
const logAnalytics = (path: string) => trackPageView(path)

const router = createRouter({
  routes,
  beforeEach: [requireAuth],   // any → false blocks the nav
  afterEach: [logAnalytics],   // all fire after successful commit
})

Falls back to bare init when no guards are configured (back-compat — existing apps need no changes).

Throw-redirect pattern (router.redirect(path)) — the native equivalent of web's throw redirect("/login") from a loader/guard, without the guard-return-type redesign. Inside a beforeEach,router.redirect(path) queues a replace AND returns false-equivalent short-circuit semantics; an internal _inGuard re-entry flag prevents the redirect's own navigation from infinite-recursing through the same guard chain:

router.beforeEachGuards.append { path in
    if !isAuthed() && path != "/login" {
        router.redirect("/login")  // queues replace, re-entry-safe
        return false               // blocks the original push
    }
    return true
}

Same shape on Kotlin (router.beforeEachGuards.add { path -> … }). The runtime addition is ~30 LOC per target; no compiler changes.

Status: path matching, redirects, wildcard 404, per-route guards,nested routes, useParams destructuring, the loader-data runtime, global beforeEach / afterEach guards (#1108), therouter.redirect() throw-pattern (#1109), and the typedparams prop (synthesized per-component struct/data class + dispatcher construction from the matched segments) are all landed. Loader auto-emit and a typed useParams<T>() hook generic are planned.

Bundled images — the asset pipeline

One assets/ directory next to your shared src/ carries the app's images; the pyreon-native assets build step materializes it per target:

TargetOutputMechanism
iOSAssets.xcassets/<name>.imageset (1x/2x/3x from @2x/@3x suffixes)<Image src="logo.png">Image("logo")
Androidres/drawable-{mdpi,xhdpi,xxhdpi} (names sanitized to resource rules)Image(painterResource(pyreonDrawable("logo"))) — a name-keyed runtime lookup, so the generated code never references the host's R class
Webpublic/assets/the web <Image> primitive prefixes bare names with /assets/

All 15 primitives compile + render on a REAL Android build, not just the kotlinc-validate subset: the emit's androidx symbols that live outside the star-imported packages (Color, RoundedCornerShape,verticalScroll/rememberScrollState for <Scroll>, Dialog for<Modal>, Coil's AsyncImage for remote <Image>) each get a content-keyed conditional import — the kotlinc stubs would otherwise MASK a missing import (green validate, red gradle assembleDebug).

The src dispatch is canonical across targets: http(s)://… is remote (AsyncImage/Coil/<img>), a BARE name (logo.png) is a bundled asset, and a path-style src (/img/x.png) is web-only — the compiler warns and native falls through to the remote emit (visible failure, never silent). fit maps toscaledToFill/scaledToFit (SwiftUI) and ContentScale.Crop/Fit/ FillBounds/None (Compose); the web default cover holds everywhere.

Asset-name collisions after Android sanitization (my-logo.png vsmy_logo.png → both my_logo) abort the build loudly.

Icons — the canonical name map

<Icon name="star"> uses ONE semantic name everywhere: iOS maps it to an SF Symbol (Image(systemName: "star.fill")), Android to a COMPILE-TIME Material reference (Icons.Filled.Star — hosts need only the small material-icons-core artifact, never -extended), and web to the app sprite's symbol id. The curated ~37-glyph map lives incanonical-primitives.ts (ICON_MAP: navigation, actions, status). An UNMAPPED name warns at compile time and stays visible: iOS passes it through raw (direct SF ids keep working), Android renders thewarning placeholder glyph — never a silent blank.

Custom fonts

Drop .ttf/.otf files in the same assets/ dir; the assets step copies them per target (iOS bundle + UIAppFonts; Android res/font; web public/fonts). <Text font="Brand"> / <Heading font="Brand">renders the bundled family. The load-bearing detail iOS gets wrong by default: Font.custom needs the font's POSTSCRIPT NAME (its internalname-table id), NOT the filename — a filename-keyed Font.customsilently falls back to the system font on-device. The CLI reads the PostScript name from the sfnt table (no dependency) and bakes it into the emit, so <Text font="Brand">Font.custom("Trattatello", …)even when the file is Brand.ttf. Android resolves res/font at runtime via pyreonFont(name) (a missing font throws loudly).

Native data & services

Data hooks compile to native via per-service runtime ports behind the shared TS API (the PyreonStorage pattern — each service has a Swift + Kotlin runtime the emitted code drives):

  • Platform prerequisites for networked apps (both device-CI findings): Android needs <uses-permission android:name="android.permission.INTERNET" /> in the manifest — without it socket creation fails with the opaqueSocketException: socket failed: EPERM — plus a network-security-config exception if the endpoint is plain http (scope it to loopback/dev hosts only). iOS needs an ATS exception for non-HTTPS endpoints (NSAllowsLocalNetworking for loopback/dev). The create-multiplatform scaffold ships the INTERNET permission by default.

  • useFetch<T>('/url') → a PyreonFetch<T> reactive container ({ data, error, isPending, refetch }). The compiler emits a mount-time.task { } (SwiftUI) / LaunchedEffect (Compose) that runs the request through the container's begin → resolve | reject state machine and decodes into T. Field reads (x.data, x.isPending) are @Observableproperties on iOS, Compose MutableState on Android.

  • useForm → a PyreonForm container (per-field values / errors / touched + submit state). const form = useForm({ initialValues }) emits@State PyreonForm(initialValues:[...]) (SwiftUI) / remember { PyreonForm(mapOf(...)) } (Compose); MutableState field reads append.value on Compose (except the derived isValid getter).v2 (form-binding arc) — device-proven. useForm({ initialValues, validators, onSubmit }) lowers fully: per-field validators emit as native closures ('' = valid), <Field value={form.values.x}> binds through the runtime (form.binding("x") on SwiftUI — a realBinding<String> whose setter re-validates after an error; a value/onValueChange pair through setValue on Compose), per-field dict access subscripts with typed defaults (form.errors.xform.errors["x"] ?? ""), and submit() gates on validateAllbefore invoking onSubmit. The web-parity names (setFieldValue,handleSubmit) exist on both runtime ports. SwiftUI nuance handled by the emit: an onSubmit capturing instance members (navigate, store writes) attaches via .onAppear { form.onSubmit = … } — a @State property initializer runs before self exists. The tasks showcase's login is the canonical validated form; its device smokes assert the ERROR path before the happy path. Open: block-body + async validators, schema validation (@pyreon/validationreachability), <Form>/<Submit> wrappers.

  • usePermissions → a PyreonPermissions container (RBACcan/cannot/all/any with "x.*" wildcards). const can = usePermissions([...]) seeds the grant set; reads are method calls (no.value rewrite).

  • useOnline → a PyreonNetworkStatus container with a reactiveisOnline flag (real NWPathMonitor on iOS; the Compose side takes the app's connectivity callback). Because the WEB useOnline() returns an ACCESSOR (() => boolean), ONE shared source reads it as net() — the emit lowers that accessor call to the container's isOnline read (net.isOnline on SwiftUI, net.isOnline.value on Compose). The directnet.isOnline member read also compiles on native (a native-only shape with no web equivalent), so both idioms work — prefer net() for cross-platform parity.

  • useAppState → a PyreonAppState container with a reactive phaseString ("active" | "inactive" | "background"), driven byUIApplication lifecycle notifications on iOS and an app-injectedProcessLifecycleOwner source on Android (the same injected-source shapePyreonNetworkStatus/PyreonStorage use to stay Android-SDK-free for the kotlinc stub gate). The WEB useAppState() returns an ACCESSOR (() => 'active' | 'inactive' | 'background'), so ONE shared source reads it as state() — the emit lowers that accessor call to state.phase (SwiftUI@Observable) / state.phase.value (Compose MutableState). The directstate.phase member read also works. Use it to pause a live poll while backgrounded or dim UI while inactive — identical source on all three targets.

  • useClipboard → a PyreonClipboard container with a copy(text)method + a reactive copied: Bool flag that auto-resets to false ~2s after each copy (matches the web @pyreon/hooks contract). WrapsUIPasteboard.general.string on iOS (cross-platform UIKit/AppKit — #1096 split out the macOS NSPasteboard path so the Swift runtime builds on both Apple platforms) and the system ClipboardManager on Android. Reads are plain method calls + a plain Bool/Boolean field — no .value rewrite. Kotlin emit is a two-line shape — val cbCtx = LocalContext.current hoisted out of the remember { … } lambda (the lambda is non-Composable; LocalContext.current can't be read inside it) + val cb = remember { PyreonClipboard(cbCtx) }. The Swift container's deinit now cancels the in-flight reset Task (#1107 — Class I leak fix) so a view that disappears mid-copy doesn't leak a pending 2-second timer. BOTH the single-binding shape const cb = useClipboard() AND the destructure form const { copy, copied } = useClipboard() compile (PR1 — destructure lowers to a synthetic single-binding container + per-field aliases; see "Binding idioms").

  • useColorScheme() → returns "light" | "dark" reactively from the platform's preferred-color-scheme channel. No runtime port needed — both SwiftUI (@Environment(\.colorScheme)) and Compose (isSystemInDarkTheme()) ship the primitive directly, so PMTC emit is a thin per-target wrapper: Swift injects @Environment(\.colorScheme) private var pyreonColorScheme on the View struct + a computed private var <name>: String { pyreonColorScheme == .dark ? "dark" : "light" }; Kotlin emits val <name> = if (isSystemInDarkTheme()) "dark" else "light" inline. Same "light" | "dark" string contract the web hook uses — scheme === 'dark' works identically across all three targets (#1103).

  • Data/services hooks (Phase 5 — #1689): seven more @pyreon/* hooks now compile to their runtime containers, instantiated + read exactly likeuseOnline (Swift @State private var x = PyreonX() with bare@Observable reads; Kotlin val x = remember { PyreonX() } with .valueon the MutableState fields, bare on Bool getters + methods):useAuth<User>()PyreonAuth<User> (status / user / error +isAuthenticated); useDatabase()PyreonDatabase (insert / get / all / find / delete / count); useGeolocation()PyreonGeolocation (lat / lon / accuracy / isAuthorized); useMap()PyreonMapState (camera / markers / selectedMarkerId); useWebSocket('wss://…')PyreonWebSocket(lastMessage / messages / isConnected; URL must be a string literal);usePush()PyreonPushNotifications (token / lastNotification / isAuthorized); usePayments()PyreonPayments (products / ownedProductIds / purchasing). This unblocks writing the finance, analytical, maps, and realtime/uber archetypes from one .tsx — verified at the compile rung: an archetype component using all seven emits typecheck-clean Swift (swiftc) and Kotlin (kotlinc).Two honest limits:(1) WebSocket lifecycle auto-start is EMITTEDuseWebSocket(url)now auto-connects on mount on BOTH targets, matching the web hook: the compiler synthesizes a mount-time ws.connect() (Swift .onAppear on the stable host / Kotlin LaunchedEffect(Unit)), url-threaded to the faithfulconnect(to: URL(string: …)!) (Swift) / connect("wss://…") via the@pyreon/native-runtime-kotlin OkHttp transport extension (fun PyreonWebSocket.connect(url: String), Kotlin). An EXPLICITonMount(() => ws.connect()) is respected — the auto-connect is skipped when the component already calls .connect(), so there's no double-connect. Both auto-connect shapes are proven by real swiftc -typecheck + kotlinc.geolocation.start() / push.start() still LOWER only via the EXPLICITonMount(() => …) escape hatch (their Kotlin containers need an app-injected source — FusedLocationProvider — that the compiler can't synthesize, so their zero-call auto-start stays a host-wiring follow-up). (2) useSecureStorage() now lowers on both targets (the v1 warn-drop is closed): Swift PyreonSecureStorage() (Keychain default), KotlinPyreonSecureStorage(ctx) (AndroidKeyStore AES-GCM viaKeystoreSecureBackend — the stated blocker, an app-injected backend requirement, was resolved the same way FileDatabaseBackend was). Calls emit KEY-FIRST with Swift labels (write(key:value:)), so a crossed positional call is uncompilable rather than a silent wrong-key write. As with every native service, the runtime behavior is compile-verified, not device-proven — the nightly device gate + example apps are the runtime-proof layer.

Status: useForm (v2 — validated forms, device-proven via the tasks showcase's error-path smoke), useFetch (device-proven — the networked Quotes screen), usePermissions,useOnline, useClipboard, and useColorScheme are landed(runtime port + compiler emit — useColorScheme is emit-only because the platform primitive is enough). useFetch's open item is a device-scope NETWORK proof (the UITest gates don't run a backend yet). useValidation reachability planned.

The supported TypeScript surface

PMTC compiles a deliberate SUBSET of TypeScript — the shapes the canonical examples exercise, enumerated here so you know where the boundary is BEFORE the compiler tells you. Outside the subset, the contract is: a warning naming the construct + either a conservative passthrough (the native compiler then errors loudly at the site) or a whole-decl bail — never silent misbehavior. pyreon-native build prints every warning; treat any warning as "this construct is outside v1."

Declarations (component body)

ShapeNotes
const x = signal(init) / signal<T>(init)un-annotated literals infer string/number/boolean; enum-typed signals get native enums
const c = computed(() => expr)expression OR block body (block: let + if/return)
const f = (args) => …functions; expression or block body
const x = <expr> (plain value)non-call / non-arrow inits — string / number / boolean / arithmetic / member / signal-read — emit as a body-local let (Swift, in body) / val (Kotlin); captures-once like a JS const (#1691)
useStorage<T>('key', default)literal string key required
createRouter({ routes }) / useNavigate() / useParams() / useLoaderData<T>()literal route arrays; guards as expression-body arrows
useFetch<T>(url) / usePermissions([...]) / useOnline() / useClipboard() / useColorScheme()see the services section for per-hook status
useAuth<User>() / useDatabase() / useGeolocation() / useMap() / useWebSocket('wss://…') / usePush() / usePayments()Phase 5 (#1689) — container + reactive reads; useWebSocket needs a literal URL; useWebSocket auto-connects on mount (both targets, synthesized — no .connect() call needed); useGeolocation/usePush/usePayments auto-start still deferred (their Kotlin start(register:)/connect(register:) needs a default per-hook transport, the OkHttp-for-WebSocket pattern) + useSecureStorage lowers with a real encrypted default on both targets (device-proven — see the per-hook table)
createI18n({...}) / createMachine({...}) / defineStore(id, setup) / model({...}).create()literal configs; store v2 setup bodies take signals + expression-body computeds + arrow methods
rx.METHOD(source, …)21 collection methods (Strategy-A lowering)

Binding idioms — one requirement, one convenience that now lowers:

  • Hook results → single-binding OR destructure (both work now). Bind the hook's result to one name and read fields off it (const q = useFetch<T>(url); q.data() / q.isPending), OR destructure it directly (const { data, isPending } = useFetch<T>(url)). The destructure formlowers (PR1) to a synthetic single-binding container (const __pyHookN = useFetch<T>(url)) + one field alias per key, so each local rewrites to __pyHookN.<field> at its use sites — producingbyte-identical native output to the single-binding form on both targets, with the call form preserved (accessor data() vs plainisPending). Covers useFetch / useForm / useClipboard /useStorage / usePermissions / useOnline / useColorScheme /useNetworkStatus / the seven Phase-5 data/services hooks. useParamskeeps its own per-key lowering (router section). Still warn-drop: a rest element (const { data, ...rest } = …) or a nested pattern (const { user: { id } } = …) — v1 lowers all-simple destructures only; and useLoaderData's destructure (its read returns an opaque T with no field shape to alias).

  • Destructured function/arrow params → lowered. A helper with a destructured param — const dist = ({ x, y }: Point): number => x + y,const apply = ({ id }: T) => { remove(id) } — synthesizes a positional param __pN (typed from the pattern's annotation — a named type resolves to the declared struct) + prepends let x = __pN.x per key, so the body references x/y as written. Works for void handlers and functions with an explicit return-type annotation. A value-returning destructured function WITHOUT a return annotation should annotate it (({ x }: P): number => x) — an unannotated value return infers Unit on Kotlin (a separate, pre-existing return-inference limit, not specific to destructuring). Rest / nested patterns warn + stay un-destructured.

  • Store reads → inline OR aliased (both work). Read store state inline through the hook (useApp().store.tasks()) OR bind the hook to a local first (const app = useApp(); app.store.tasks()) — the aliaslowers to a useApp() call at every use site, producing byte-identical native output to the inline form. (Aliasing previously failed the native build with Unresolved reference 'app'; it now compiles.)

  • Static attrs → literal OR module-level const. A native-mapped static attribute (<Image src=…>, <WebView src=… /> / html=, font, background, …) accepts an inline string literal OR a module-levelconst string/number/boolean binding referenced by name:

    const CHART_URL = "https://x.example/c.png"
    // both emit AsyncImage(url:) / Coil AsyncImage(model=) identically:
    <Image src="https://x.example/c.png" alt="chart" />
    <Image src={CHART_URL} alt="chart" />

    A let (mutable) binding, a non-literal init (const x = f()), or a component-scope / unknown identifier is NOT resolved — it falls through to the normal "needs static" emit path. (Component-scope const + transitive const B = A resolution are tracked follow-ups.)

Expressions

ShapeNotes
literals, identifiers, calls, member access
string / array methods → native idiomsmap / filter / find / findIndex / some / every / reduce / sort / includes / indexOf / join / concat / flatMap (arrays) and startsWith / endsWith / split / repeat / trim / toUpperCase / toLowerCase (strings) lower to the platform idiom — e.g. joinjoined(separator:) / joinToString, findIndex(firstIndex(where:) ?? -1) / indexOfFirst (JS -1-sentinel preserved), splitcomponents(separatedBy:) / native split. replace lowers to a FIRST-only replace (Kotlin replaceFirst; Swift an IIFE over replacingOccurrences(of:with:options:range:) bounded to the first match) — distinct from replaceAll, which stays replace-ALL on both. slice and Number() lower too — with one honest divergence on Number(): a non-numeric string is NaN in JS but lowers to ?? 0 / ?: 0.0 (no clean native NaN in an Int-inferred context), so guard the input rather than relying on NaN propagation. (An older note here said slice / replace / Number() were unmapped; only replace still was, and leaving it unmapped meant it was emitted VERBATIM — a hard swiftc error and a silently replace-ALL Kotlin build from one source line.)
xs[i] index accessarrays/lists; element-typed inference
+ - * / %, comparisons, && ||, !, ternary===/!== coalesce to native ==/!=; / is always float division (→ Double, like JS)
** (exponent)pow(Double(a), Double(b)) (Swift) / Math.pow((a).toDouble(), (b).toDouble()) (Kotlin); result is Double (matches JS), right-associative
& | ^ << >> (bitwise)Swift keeps the symbols; Kotlin uses the infix functions and/or/xor/shl/shr (compound operands parenthesized to preserve JS grouping). >>> is NOT lowered
a?.b (optional chaining)member access lowers to native ?. (and propagates down the chain — a?.b.ca?.b?.c — required for Kotlin). Optional index (a?.[i]) and optional call (f?.()) are NOT supported (they diverge per target)
Math.<fn>(…)abs/min/max/floor/ceil/round/sqrt/cbrt/pow/hypot/sin/cos/tan/atan2/log/log10/log2/exp/trunc + PI/E/random lower to native (Foundation free fns on Swift; java.lang.Math / kotlin.math with .toDouble() arg coercion on Kotlin). Math.sign lowers on Kotlin only (no clean Foundation equivalent — Swift tracked)
{cond && <View/>} conditional renderlowers to if cond { view } (SwiftUI) / if (cond) { view } (Compose) — the same form <Show> emits; parens are seen through so {cond && (a ? <X/> : <Y/>)} lowers too. (A value-only a && b with no view RHS stays a value expression.)
x++ / x--value-position degrades to x + 1 (side effect dropped — warning); statement-position composes via .update
sig.set(v) / sig.update(fn)lower to native assignment; .update needs a single-param expression-body arrow whose param isn't shadowed
object literalsconstruct declared structs / synthesized types; an anonymous all-scalar-literal object ({ id: 1, name: 'a' }) matching no declared struct synthesizes a module-scope struct (Swift Codable) / data class (Kotlin), deduped by field name:type shape (__Obj0, …; cross-target names align) — replaces the old labelled-tuple emit (illegal single-field Swift tuple; tuple key-paths break ForEach(id:)). A non-literal field whose type INFERS to a scalar ({ id: count(), name: label() } — signal reads) now synthesizes too; only a non-scalar (array / nested-object / typeRef) field keeps the tuple emit. { ...t, field: v } single-spread becomes Swift IIFE-copy / Kotlin .copy(...)
array literals + spreads[...xs, item] → concatenation
zero-param accessor arrows in condition positionsunwrap to their body (when={() => cond()})
zero-arg accessors in TEXT/child positionboth spellings resolve to the VALUE: the inline arrow {() => shout()} unwraps to its body, and a bare reference to a zero-arg function (const shout = () => … used as {shout}) is CALLED. Native has no accessor concept — the surrounding body re-runs — so the call is the equivalent. Scope is text/child position and arity zero: a bare reference in PROP position (onPress={handler}) stays a reference, and a function taking arguments is left alone

Types

ShapeNotes
string / number / boolean, arrays, T | nullnumber infers Int OR Double from literal evidence — including THROUGH an explicit annotation: signal<{ price: number }[]>([{ price: 2.5 }]) types the field Double, because a TS number carries no int/float distinction and so must not override the initializer beside it (the annotated and un-annotated spellings emit identically). Literal evidence: a fractional literal (12.5) → Double, integer literals stay Int. Applies to scalars (signal(12.5) / signal<number>(12.5)), struct fields, array elements (signal([12.5, 8.3])[Double]), and reduce seeds (a Double accumulation flips the seed to 0.0). Whole-number elements in a Double array render 15.0 so [Double] / List<Double> stays homogeneous.
type X = {...} / interfacesbecome Codable structs / @Serializable data classes
string-literal unionsbecome native enums
anonymous object types in propssynthesize named structs (UserPage+paramsUserPageParam); declared structs win on structural match
generics beyond the recognized hooks' <T> slotsNOT supported

Statements (function/computed bodies): const/let (incl.multi-declarator const a = 1, b = 2 → split into one decl each),return, if/else, for…of / while / switch (→ native for in /while / switch·when), and reassignmentt = t + x, += -= *= /= %= (a reassigned local is emitted var, not let/val, automatically), so an imperative loop body can accumulate. C-style for (let i = 0; …; …) is NOT in v1 — use for…of over an array or while. Array destructuring of locals (const [a, b] = xs()) LOWERS — in function/computed bodies (a block-scoped container + indexed lets) AND at component scope (a synthetic container const + __pyDestrN[i] aliases, the same IR as the explicitxs()[0] shape; the component-scope form was a silent drop until 2026-08 — the emit referenced a/b unbound and failed swiftc/kotlinc with zero warnings). Holes, rest, defaults, and nested patterns warn by name — at BOTH scopes — and the declaration is skipped whole, never half-bound. (Object destructuring const {a, b} = obj DOES lower — body-local, component-param, and hook-result forms, e.g. const { data } = useFetch(url); its non-simple component-scope shapes now warn loudly too, where they previously vanished silently.)<For> remains the idiom for RENDERING a list; these loops are for in-body data work.

JSX: the 15 canonical primitives, <For each by>, <Show when>,<Suspense fallback>, <ErrorBoundary fallback>, <KeepAlive when>,<Transition show>, <Modal open>, <RouterProvider>/<RouterView>/<Link>. data-testid flows to accessibilityIdentifier / testTag(containers gain the queryability semantic automatically). The cross-platform a11y vocabulary on @pyreon/primitives lowers the same way on every target — write the neutral prop once:accessibilityLabel="…" → web aria-label / SwiftUI.accessibilityLabel(…) (the VoiceOver name) / Compose.semantics { contentDescription = … } (the TalkBack name); andaccessibilityHidden → web aria-hidden="true" / SwiftUI.accessibilityHidden(true) / Compose .clearAndSetSemantics { }(clears the node + subtree from the a11y tree — clearAndSetSemantics is stable in the targeted Compose 1.7 BOM, vs the experimentalinvisibleToUser()); and accessibilityRole ("button" / "image" /"header" — the values that map 1:1 to every target's role model) → webrole="button"/"img"/"heading" / SwiftUI.accessibilityAddTraits(.isButton / .isImage / .isHeader) / Compose.semantics { role = Role.Button / Role.Image } (and heading() for headers). Component children must be JSX or value expressions (auto-wrapped in Text).

Module scope: let/const primitives (non-reactive on native), type aliases, the recognized factory calls. Module-scope signal() is NOT lowered — declare signals inside components or stores.

Consuming compiler diagnostics

The parser warnings introduced by Round-1 (#1094 — Icon / Image /Link missing required props) and Round-2 (#1099 — Press withoutonPress, native Link prefetch={…}, Stack/Inline/Layer align="<typo>") flow through the same result.warnings channel as every other parse warning. Read them programmatically from the compiler:

import { transform } from '@pyreon/native-compiler'

const { code, warnings } = transform(source, { target: 'swift' })
for (const w of warnings) console.warn(w)

The shipped surface today is the pyreon-native build CLI, which aggregates warnings per file and prints them to stderr as[pyreon-native] N warning(s): after each build, plus pyreon-native check --lsp (a stdio LSP server; warnings are currently position-less — threading spans through the compiler's warn sites is a follow-up). There is no Vite-plugin surfacer yet — that's an explicit Phase 6 DX follow-up. @pyreon/native-compiler publishes to npm with the rest of the native stack, so transform() is importable from a standalone project as well as the workspace.

WebView host — embedding web-only-rich viz (charts / flow)

Some libraries are structurally web-only@pyreon/charts (ECharts),@pyreon/flow (the SVG host — its createFlow state lowers), @pyreon/code (CodeMirror), @pyreon/document(pdfmake) all wrap a browser-runtime engine and cannot compile to SwiftUI / Compose. The multiplatform answer is a hybrid: a substantial native shell (the canonical primitives) with the heavy viz hosted in a<WebView>WKWebView on iOS, Android WebView, an <iframe> on web.

<NativeIOS><WebView html={CHART_HTML} /></NativeIOS>
<NativeAndroid><WebView html={CHART_HTML} /></NativeAndroid>
<Web>{/* render the chart inline — it's already web */}</Web>

<WebView> takes html (inline page — loadHTMLString / srcdoc) ORsrc (a LOCAL bundled asset — preferred, policy-safe — or a remote URL). For App Store / Play Store review, prefer bundled local assets so the viz is app content, not remote code.

The two-way data bridge

A hosted chart isn't a static screenshot — it stays live in BOTH directions, over a unified JS API the runtime wires per platform:

Forward — native → page (data). Pass a signal; the runtime JSON-encodes it and PUSHES it into the already-loaded page aswindow.__pyreonData, firing a pyreondata event, WITHOUT reloading — so the chart updates in place (no flicker, zoom/animation preserved). Adata-only change never reloads; only an html/src change does.

const metrics = signal<Metric[]>([])
<WebView html={CHART_HTML} data={metrics()} />
// In the hosted page:
function render() { const d = window.__pyreonData || []; /* draw d */ }
window.addEventListener('pyreondata', render); render()

Reverse — page → native (onMessage). The page callswindow.pyreonPostMessage("payload"); the string is delivered to the native onMessage callback, so a tap inside the chart drives a native signal. (iOS WKScriptMessageHandler; Android a main-thread-marshalled@JavascriptInterface; web the parent defines window.pyreonPostMessageon the iframe.) The payload is a plain string — JSON-stringify structured data and parse it in the handler.

const selected = signal('')
<WebView html={CHART_HTML} data={metrics()} onMessage={(m) => selected.set(m)} />
<Text>Selected: {selected()}</Text>
// In the hosted page — a tapped bar reports back:
bar.addEventListener('click', () => window.pyreonPostMessage(region))

Together these make a webview-hosted chart a first-class interactive member of the native app: live native data flows in, user events flow back out. examples/native-analytics is the canonical end-to-end proof (native data table + aggregation + an interactive WebView chart from ONE.tsx). Web caveat: both bridges are same-origin / srcdoc only — a cross-origin remote src can't be reached from the parent frame (the native targets reach remote content via evaluateJavaScript / the script handler).

The /webview subpaths — web engines that cross by HOSTING

Four packages wrap a web engine that has no native equivalent and cannot be reimplemented as a native view: @pyreon/charts (ECharts, a canvas engine), @pyreon/code (CodeMirror 6, a DOM editor), @pyreon/rich-text(TipTap/ProseMirror, a DOM editor), and @pyreon/flow (an SVG layout). Rather than leave them web-only, each ships a ./webviewsubpath that builds a self-contained host page and runs the SAME web bundle inside the <WebView> documented above:

Packagehost-page builderhost component
@pyreon/charts/webviewbuildChartHostHtml()<ChartWebView option onSelect>
@pyreon/code/webviewbuildCodeHostHtml()<CodeWebView state onChange>
@pyreon/flow/webviewbuildFlowHostHtml()<FlowWebView graph onSelect>
@pyreon/rich-text/webviewbuildRichTextHostHtml()<RichTextWebView state onChange>

examples/native-viz is the breadth proof — ONE src/VizApp.tsxcompiles to web + iOS + Android hosting 24 surfaces (21 ECharts chart types, a flow diagram, a real CodeMirror editor, a real TipTap WYSIWYG), emitting 24 PyreonWebView calls with 0 warnings on both targets.

This is one codebase on every target, and it ships. It is NOT a natively-rendered chart, and the difference is worth stating plainly before you reach for it:

  • a WebView costs process/startup time a native view tree does not;

  • every prop and event crosses a JSON bridge, so interaction is not at native latency and large payloads are re-serialized per update;

  • the hosted content is opaque to native gestures and to the platform accessibility tree — VoiceOver/TalkBack see a web document, and theAccessibilityProps vocabulary stops at the WebView boundary;

  • the host page must be bundled with the app (see the web/ staging step above), so the engine's bytes ship per app rather than being provided by the platform.

Evidence rung — what is actually proven. The host bridge is proven end-to-end in real Chromium against the real engine for each package (src/webview.browser.test.tsx: real ECharts renders a canvas and a signal update re-renders on the SAME instance without a reload; a real view.dispatch in CodeMirror and a real execCommand edit in TipTap each drive onChange while a native push does not echo back; flow renders real bezier <path>s and a real click reports the node id). The native host is proven at the emit + stub-typecheck rung only — PMTC lowers <WebView html/src/data onMessage> to PyreonWebView on both targets and the emitted code type-checks against the SwiftUI/Compose stubs. No device test hosts a WebView on either platform, so "the WKWebView/Android WebView actually paints and bridges on a real device" rides the same nightly device rung as the rest of the native surface — it is not CI-proven today. Note also that the web-side <WebView> is an<iframe srcdoc>: it speaks the identical protocol, which is what makes the Chromium proof meaningful, but it is not the native host itself.

The check-native-coverage gate files these four under a webview-hostmechanism and verifies the claim rather than asserting it — per package it requires that package.json declares the ./webview export, that the module exists, that it exports both the named host-page builder and the named host component, and that a test covers it. Any one of those going missing FAILS the gate (a webview-host entry claims a shipping crossing path, so it is a regression, not a tracked gap).

partial — a declared subset that really lowers

Three packages are web-only at the TIER level while their own manifests declare a multiplatform.nativeFrontend: a named subset that genuinely lowers. Filing them as plain gaps understated them, so the gate counts them apart, and each one's snippet exercises exactly the documented form and is asserted to emit zero warnings on both targets:

Packagewhat actually crosses
@pyreon/httpsame-file endpoint calls — createHttp({ baseUrl }) + api.endpoint('GET /users/:id') resolve through useFetch/useQuery to native PyreonFetch/PyreonQuery. A RUNTIME :param (a signal read, a prop) lowers through useQuery — the native harness is keyed on the value, so it re-fetches when the value changes, and PyreonURL.encodePathParam encodes it to match the web byte for byte. useFetch lowers to a one-shot task, so a runtime param there still stays web (its warning names useQuery); a computed baseUrl stays web on both
@pyreon/validationthe declaration form — a top-level zodSchema(z.object({…})) emits native field validators (the adapters, inline .parse() and the async path stay web)
@pyreon/url-stateuseUrlState(key, 'default') with a string default, bound to the native router's query

A partial entry must ALSO be declared in the package's own manifest — the gate fails one whose manifest carries no nativeFrontend, so the mechanism cannot become a way to launder a gap into a crossing.

Why this needed a structural fix, not just three edits. transform()never resolves imports, so a snippet naming a symbol that does not exist still "runs" — and because an unknown symbol warns "has NO native lowering", it produces a warning indistinguishable from a genuine gap. Three registry entries shipped on fictional snippets (createHttpClient, which is really createHttp; object/string/number, which are @pyreon/validate's builders, not validation's;useHotkeys, which is really useHotkey), and two of the three were misclassified as a result. Every snippet's @pyreon/* named imports are now checked against the package's real exports, and a phantom symbol fails the gate loudly — for every mechanism, web-first included, which is the one where a bogus warning otherwise looks like proof.

DX surfaces on native (honest scope)

The "one source" promise extends to WRITING the source, not just shipping it. Pyreon ships several developer-experience surfaces; which of them work on the native targets is a structural question — some are pre-emit (source-level) and target-agnostic, others depend on the Pyreon runtime that PMTC erases when emitting Swift/Kotlin.

Works on native source (✅ — same DX as web)

These analyze your .tsx source BEFORE PMTC emits anything, so they are target-agnostic by construction.

  • Reactivity Lens (analyzeReactivity from @pyreon/compiler). Returns the same structural reactivity facts (reactive /reactive-prop / static-text / hoisted-static) and footgun findings (props-destructured, signal-write-as-call, …) on a PMTC source file as it does on a web-only source. Verified end-to- end against a <Stack>/<Button>/<Text> Counter fixture: the Lens correctly flags const { x } = props as footgun and the signal reads inside {count()} as reactive, identical to the output it produces for the same shape in a web component.

  • @pyreon/lint rules + pyreon doctor. Every rule runs on the source AST; none of them load the runtime. pyreon/no-window-in- ssr, pyreon/signal-write-as-call, pyreon/props-destructured,pyreon/no-iterate-children-without-resolve, the islands audit, the SSG audit, the test-environment audit — all surface the same findings on a PMTC source file. The pyreon/no-window-in-ssrrule is actually MORE valuable on native sources (the emit target literally has no window), but the surface is the same.

  • Static type checking + audit-types. tsc --noEmit and the typed-but-unimplemented gate care only about TypeScript types, so they work identically across targets.

  • MCP tools (validate, get_api, get_pattern,get_anti_patterns, get_changelog, audit_test_environment,audit_islands). All operate on source / repo metadata, not the runtime. An AI agent driving a native source through validategets the same anti-pattern catalog as it would for a web file.

  • pyreon-native check editor-ready diagnostics. The fast authoring-loop command exposes an in-memory checkSource(code, fileName, opts) core — no disk read, so it checks an unsavededitor buffer, the case a linter plugin needs — and attaches aposition ({ line, column }) to transform + type-check-error findings, parsed from the file:line:col the toolchains embed. Socheck --json is consumable by an editor linter integration (null-ls, a generic-linter extension) or a CI annotation matcher, with precise squiggles on the errors that carry a location. Honest scope:unsupported-subset WARNINGS are still position-less (rendered as afile-level diagnostic — threading source spans through the ~110 compiler warn sites is a tracked follow-up).

  • pyreon-native check --lsp — a stdio LSP server. Publishes those findings as live editor diagnostics on open / change (atextDocument/didOpen / didChangepublishDiagnostics loop over the in-memory checkSource core), so the authoring loop no longer needs a manual CLI run — squiggles appear as you type in any LSP-speaking editor (VS Code, Neovim, …). The JSON-RPC framing is hand-rolled, mirroring @pyreon/lint --lsp (no vscode-languageserverdependency); the pure core (finding→diagnostic mapper + message handler) is unit-tested without the transport, and the end-to-end server is proven by spawning it and round-tripping a realinitialize + didOpen. Honest v1 scope: diagnostics only (no inlay hints — native has no reactivity-lens surface to project), a synchronous re-check on change (debounce is a follow-up — the in-memory transform is fast), and no swiftc -typecheck on the keystroke path (slow / macOS-only). Warnings stay file-level until the compiler warn sites carry spans.

Web-only by structural design (❌ — not coming to native)

These surfaces depend on the Pyreon RUNTIME (signal registry, effect graph, devtools hook). PMTC erases that runtime when it emits to SwiftUI @State / Compose mutableStateOf — there is no Pyreon-side data structure to introspect on a native target; SwiftUI's _GraphInputs and Compose's SlotTable own the reactive graph end-to-end. This is structural-infeasibility, not engineering effort.

  • LPIH (Live Program Inlay Hints — fire counts / re-run counters at the source line). Requires the dev-mode@pyreon/reactivity registry (activateReactiveDevtools +getFireSummaries) to be alive in the running app. On native builds the entire reactivity package is tree-shaken — thesignal(0) call you wrote is emitted as @State var count = 0, there is no Pyreon-side wrapper to count fires. Use on web during development; the inlay hints don't reach a running iOS / Android build, by design.

  • Devtools panel (the Chrome extension underpackages/tools/devtools). Connects towindow.__PYREON_DEVTOOLS__ (a hook attached by@pyreon/runtime-dom's installDevTools()) to walk the component tree, highlight nodes, watch signals fire. On a native build there is no window, no __PYREON_DEVTOOLS__, and no Pyreon component tree — SwiftUI and Compose own the view hierarchy. For native runtime debugging use Xcode's View Hierarchy Debugger (iOS) and Android Studio's Layout Inspector (Android); they're the native equivalents of the Pyreon devtools panel and they work on the emitted view tree directly.

  • Pyreon HMR + @pyreon/vite-plugin signal-preserving HMR. Web-only by construction (Vite is a web dev server). iOS uses Xcode's incremental compile + Simulator hot-reload; Android uses Gradle's incremental build + Compose's LiveLiterals /recomposeHighlighter. These are platform-native HMR equivalents — there is no shared Pyreon HMR surface across targets.

Partial — works for the source-level part, runtime part is on the platform

  • pyreon-native build warnings (the silent-drop diagnostic surface from PRs #1235 / #1441). Pre-emit warnings about droppeduseLoaderData() reads, dropped <Suspense fallback> props, etc. ARE shown — they're emitted at compile time, surfaced viatransform() result.warnings and the CLI's[pyreon-native] N warning(s) stderr aggregation. The actual runtime-state debugging is per-target (Xcode + Android Studio above).

If you adopt PMTC for a real production app, the practical workflow is: write + debug source-level concerns on web (Lens, devtools, HMR, lint) where the iteration loop is fastest; verify + debug native-runtime concerns on the device with the platform's own tooling. Same .tsx, two debugging surfaces.

Verifiable today (compile contract)

  • Web: @pyreon/runtime-dom renders any Pyreon JSX. Full ecosystem available.

  • iOS: pyreon-native build --target=ios --source=./src --out=./generated produces typecheck-clean Swift (verified via swiftc -parse in the native-validate CI). The opt-in native-device workflow additionally runs xcodegen + xcodebuild to compile the full example app on a real Xcode/Simulator SDK, then xcodebuild test boots the iPhone 15 Simulator + runs PyreonTodoMVCUITests to assert accessibilityIdentifier("todo-app") renders within 30s.

  • Android: pyreon-native build --target=android --source=./src --out=./generated produces typecheck-clean Kotlin (verified via kotlinc + Compose stubs). The same opt-in native-device workflow runs gradle assembleDebug against the real Android toolchain, then boots a Pixel-6 emulator (API 33, google_apis, x86_64, via reactivecircus/android-emulator-runner) + runs gradle connectedCheck which executes TodoAppInstrumentedTest's composeRule.onNodeWithTag("todo-app").assertIsDisplayed().

TodoMVC reference walkthrough (locally verified, June 2026)

The examples/native-todomvc-{web,ios,android} apps form the canonical proof of the single-source contract. The shared TodoApp source (examples/native-todomvc-ios/src/TodoApp.tsx) renders on all three targets without modification.

Web (a real running app in the browser):

cd examples/native-todomvc-web
bun run build      # 88 modules → 35 KB JS bundle, 13 KB gzipped
bun run dev        # http://localhost:5173/

Then in a browser: type, hit Enter, toggle, filter All/Active/Completed, click Clear completed. Zero console errors. Web fully working.

iOS Swift emit:

bash examples/native-todomvc-ios/scripts/build.sh
# → examples/native-todomvc-ios/generated/TodoApp.swift

The emitted file opens with the import preamble (import SwiftUI / PyreonRuntime / PyreonRouter) and emits idiomatic SwiftUI: @PyreonAppStorage("pyreon-todomvc:todos") for persistence, @State for local signals, VStack(spacing: 8) / HStack for layout, TextField(..., text: $draft) with .onSubmit { addTodo() }, ForEach keyed by id, Button(action:). The data-testid="todo-app" JSX attribute becomes .accessibilityIdentifier("todo-app") so the same string works on the iOS UI test.

Verify it compiles against the real SwiftUI SDK:

swiftc -typecheck \
  -target arm64-apple-macos14.0 \
  packages/native/runtime-swift/Sources/PyreonRuntime/*.swift \
  packages/native/router-swift/Sources/PyreonRouter/*.swift \
  examples/native-todomvc-ios/generated/TodoApp.swift
# → exit 0 (zero errors)

Android Kotlin emit:

bash examples/native-todomvc-android/scripts/build.sh
# → examples/native-todomvc-android/app/src/main/kotlin/com/pyreon/generated/TodoApp.kt

The emitted file opens with package com.pyreon.generated, the Compose import preamble (androidx.compose.runtime.* / material.* / kotlinx.serialization.Serializable / com.pyreon.runtime.*), and emits idiomatic Compose: var todos by rememberPyreonStorage<List<Todo>>(...), var filter by remember { mutableStateOf(Filter.all) }, val visible by remember { derivedStateOf { ... } }, Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.testTag("todo-app")), TextField with KeyboardOptions(imeAction = ImeAction.Done) + KeyboardActions(onDone = { addTodo() }), LazyColumn { items(visible, key = { it.id }) { ... } }, Button(onClick = ...).

Verify against the framework's validateKotlin (same Compose stub set the validate-kotlin.test.ts gate uses):

bun -e "
  import('./packages/native/compiler/src/validate.ts').then(async (m) => {
    const { readFileSync } = await import('node:fs')
    const src = readFileSync('examples/native-todomvc-android/app/src/main/kotlin/com/pyreon/generated/TodoApp.kt', 'utf8')
    // Strip the package + wildcard imports (the stub set is in default package).
    const stripped = src.split('\n')
      .filter(l => !l.startsWith('package ') && !l.startsWith('import androidx') && !l.startsWith('import kotlinx') && !l.startsWith('import com.pyreon'))
      .join('\n')
    console.log(JSON.stringify(m.validateKotlin(stripped), null, 2))
  })
"
# → { "ok": true }

One source. Three targets. Verified locally on macOS 14 with Xcode 15 + JDK 21 + Kotlin 2.x.

The runtime packages exist, with one reactive container per data/service hook:

  • @pyreon/native-runtime-swift@PyreonAppStorage + PyreonStorage, PyreonFetch<T>, PyreonForm, PyreonPermissions, PyreonNetworkStatus (@Observable containers)

  • @pyreon/native-runtime-kotlinrememberPyreonStorage + the same PyreonFetch / PyreonForm / PyreonPermissions / PyreonNetworkStatus / PyreonClipboard containers (Compose MutableState); PR #1104 closed the last untested service by adding the Kotlin PyreonClipboard test suite, bringing every container to parity test coverage

  • @pyreon/native-router-{swift,kotlin}PyreonRouter (path stack, matchPath, params, loaderData) + useNavigate / useParams / useLoaderData hooks

Reference

  • Compiler source: packages/native/compiler/src/emit-swift.ts / emit-kotlin.ts per-target emit; canonical-primitives.ts shared name maps + token resolution

  • Native runtime packages: packages/native/runtime-swift/, packages/native/runtime-kotlin/

  • Web runtime: packages/core/primitives/src/web/ — all 15 canonical primitives

  • Example apps: examples/native-todomvc-{ios,android,web}/ + examples/native-router-demo-{ios,web}/native-router-demo-ios ships a full XcodeGen host shell (#1105) so bash scripts/build.sh produces a buildable Xcode project, not a source-only stub. examples/native-todomvc-web/README.md was also corrected (#1106) so it no longer references a fictional src/TodoApp.tsx — the one-source contract (Phase E3) keeps the shared TodoApp source in examples/native-todomvc-ios/src/.

  • Real-device build gate: .github/workflows/native-device.yml (opt-in via the native-device label / dispatch)

  • CLAUDE.md "PMTC Multi-Target Architecture" section — agent-context summary of the layered model + roadmap

Multi-Platform Pyreon