pyreon

Multiplatform app (one source → web + iOS + Android)

The golden rule (read this first)

PMTC compiles your COMPONENT SOURCE — signals, the 15 canonical primitives, a fixed hook set, and a narrow declarative TS subset — to SwiftUI (iOS) and Jetpack Compose (Android). It does NOT transpile npm packages to native. So a multiplatform app is built from: the canonical primitives + reactivity + the ported hooks/services, written in the supported TS subset. Anything outside that compiles for web but silently breaks or drops on native. Build inside the lane and it works first-try; step outside and it won't.

Status: native PMTC is demo-quality (self-rated 66/100). Per-PR validation is swiftc -parse + kotlinc-against-stubs (syntax-level); full device builds are advisory. Treat the rules below as hard constraints, not suggestions.

Imports — the canonical layer

import { Stack, Inline, Text, Heading, Button, Press, Field, Toggle,
         Image, Icon, Link, Scroll, Layer, Spacer, Modal } from '@pyreon/primitives'
import { signal, computed, effect } from '@pyreon/reactivity'

Use @pyreon/primitives (the multiplatform layer), NOT @pyreon/elements / @pyreon/ui-components (those are web-only, CSS-in-JS-coupled). The 15 primitives are the entire native UI vocabulary:

PrimitiveWebiOSAndroidNotes
<Stack>flex columnVStackColumndirection?="column"|"row", gap, align
<Inline>flex rowHStackRowsugar for <Stack direction="row">. ⚠ does NOT wrap — see gotchas
<Layer>abs/overlayZStackBoxstacked children
<Scroll>scroll containerScrollViewverticalScroll Column
<Spacer>flex spacerSpacerSpacer(Modifier.weight)
<Text> / <Heading><span>/<h*>TextText
<Button onPress><button>ButtonButtonstyled CTA
<Press onPress><div role=button>Button {}Box(clickable)unstyled tap target
<Field value onChangeText><input>TextFieldTextField
<Toggle>checkboxToggleSwitch
<Image> / <Icon><img>/svgImage/SF SymbolAsyncImage/Icon
<Link><a>navnavrouter-aware
<Modal open onClose>overlay.sheetDialog

One canonical event name everywhere: onPress (not onClick), onChangeText, onSubmit. Tokens-first styling: padding={4}, gap="md". No responsive props on native (v1).

Reactivity — the same on every target

function Counter() {
  const count = signal(0)
  const doubled = computed(() => count() * 2)
  return (
    <Stack gap="md">
      <Text>{count()}</Text>
      <Text>{doubled()}</Text>
      <Button onPress={() => count.set(count() + 1)}>+1</Button>
    </Stack>
  )
}

signal@State/mutableStateOf, computed → computed property/derivedStateOf. Write count.set(v) to update (never count(v)). Multi-statement handlers work: onPress={() => { a.set(1); b.set(2) }}.

The supported TypeScript subset (stay inside this)

PMTC compiles a deliberately narrow, declarative subset. Inside it, native emit is correct:

  • signal / computed / effect, typed props, the canonical-primitive JSX

  • <For each by> / <Show when> / ternary / if

  • array + string method calls (.map/.filter/.find/.join/.toUpperCase/…)

  • object-literal type aliases → structs: type Todo = { id: number; title: string }

  • string-literal union aliases → enums: type Filter = 'all' | 'active' | 'done'

  • arithmetic (and / now yields a float: 7 / 23.5 on all targets)

Outside it, native silently drops or mis-emits (the cliff):

Don't (silently breaks native)Do instead
interface Todo { … }type Todo = { … } (synthesizes a struct)
TS enum Color { … }type Color = 'red' | 'green' (string-literal union)
class Foo { … }functions + signals (or defineStore / model())
const o = { a: 1 } (bare local object)a typed signal/store, or a type-aliased shape
new Map() / new Set() / new Date()a plain object/array shape; pass time as a prop
`Hi ${name}` (template literal)string concat 'Hi ' + name (template-literal native support is partial)
a?.b?.c (optional chaining)a && a.b guards
for / while / switch / try in a body.map/.filter, <For>, ternary, <Show>
destructured props function C({ x }) {}function C(props) { … props.x … } (destructure loses reactivity)

The framework now warns when you declare a top-level interface/enum/class for native — but most of the other drops are silent. The swiftc -parse gate can't catch type-level corruption, so a wrong build can still pass CI: stay in the subset.

Per-target hooks + services (these DO work on native)

import { useStorage } from '@pyreon/storage'      // → @PyreonAppStorage / rememberPyreonStorage
import { useFetch } from '@pyreon/hooks'           // → PyreonFetch (URLSession / ktor)
import { useForm } from '@pyreon/form'             // → PyreonForm (device-proven)
import { usePermissions } from '@pyreon/permissions'
import { defineStore } from '@pyreon/store'        // → PyreonStore
import { useNavigate, useParams, useLoaderData } from '@pyreon/router'

Native-ported: reactivity, the 15 primitives, store, machine, state-tree, i18n, form, permissions, storage, the router (nested routes, beforeEnter, per-route loader), and the hooks useFetch / useOnline / useClipboard / useColorScheme. For data, use useFetch — NOT @pyreon/query (TanStack is web-only).

Web-only packages — only via a <WebView> bridge

These can NOT be native-rendered (they're bound to canvas/DOM/vendors): @pyreon/charts (echarts), @pyreon/flow (elkjs+SVG), @pyreon/code (CodeMirror), @pyreon/dnd, @pyreon/document, @pyreon/query, @pyreon/table/virtual, and the whole CSS-in-JS UI stack (elements/styler/rocketstyle/coolgrid/kinetic).

You CAN still use them — host the web component in a <WebView> (a real browser engine) with the bidirectional bridge:

import { WebView } from '@pyreon/primitives'
// data → window.__pyreonData (live, no reload); page calls window.pyreonPostMessage(x) → onMessage
<WebView html={CHART_HTML} data={metrics()} onMessage={(m) => selected.set(m)} />

Right for charts/diagrams/editors (self-contained panes you wouldn't reimplement in SwiftUI). Not for core nav/forms/lists — use the native primitives there.

Gotchas (each has bitten a real build)

  • <Inline> does NOT wrap on Android (it's a Row). 5+ buttons overflow + the last becomes untappable. Keep horizontal groups short, or use <Stack> (vertical) for action lists.

  • No Double confusion: a fractional literal (signal(9.99)) infers Double; / always yields a float now. Integer signals stay Int.

  • useLoaderData<T>() reads a route loader: () => … (zero-param, expression body) auto-fired on navigation.

  • Escape hatches for genuinely-per-platform UI: <Web> / <NativeIOS> / <NativeAndroid>.

Minimal correct multiplatform app (copy this shape)

import { Stack, Inline, Text, Heading, Button, Field, Toggle } from '@pyreon/primitives'
import { signal, computed } from '@pyreon/reactivity'

type Todo = { id: number; title: string; done: boolean }   // type alias, NOT interface

export function App() {
  const todos = signal<Todo[]>([])
  const draft = signal('')
  const remaining = computed(() => todos().filter((t) => !t.done).length)

  const add = () => {
    if (draft() === '') return
    todos.set([...todos(), { id: todos().length, title: draft(), done: false }])
    draft.set('')
  }

  return (
    <Stack gap="md" padding={4}>
      <Heading>Todos ({remaining()} left)</Heading>
      <Inline gap="sm">
        <Field value={draft()} onChangeText={(t) => draft.set(t)} />
        <Button onPress={add}>Add</Button>
      </Inline>
      <For each={todos()} by={(t) => t.id}>
        {(t) => (
          <Inline gap="sm">
            <Toggle value={t.done} onChange={(v) => todos.set(todos().map((x) => x.id === t.id ? { ...x, done: v } : x))} />
            <Text>{t.title}</Text>
          </Inline>
        )}
      </For>
    </Stack>
  )
}

This compiles to web + iOS + Android from one source: canonical primitives, a type-alias struct, signals, <For> keyed list, .filter/.map, a multi-statement handler — every piece inside the supported subset.

Anti-pattern

Reaching outside the multiplatform lane — the native build silently breaks, often with no warning:

// ❌ DON'T — every line here breaks the native (iOS/Android) build:
import { Card, Button } from '@pyreon/ui-components'  // web-only CSS-in-JS — won't render native
import { Chart } from '@pyreon/charts'                // echarts canvas — can't be native-rendered
interface Todo { id: number }                         // PMTC silently DROPS interface → undefined type on device
class TodoStore { items = [] }                        // class silently dropped too

function Dashboard({ title }: { title: string }) {    // destructured prop loses reactivity
  const data = { count: 0 }                           // bare local object literal — dropped on native
  const pct = data.count / total                      // (web `/` is float; native truncates unless coerced — fixed in-compiler)
  return <Card>{`Hits: ${title}`}</Card>              // template literal partially-supported on native; <Card> is web-only
}
// ✅ DO — stay in the lane (or bridge):
import { Stack, Text, Button } from '@pyreon/primitives'  // multiplatform UI
import { signal } from '@pyreon/reactivity'
type Todo = { id: number }                                // type alias → struct on native
function Dashboard(props: { title: string }) {            // props.x stays reactive
  const count = signal(0)                                 // reactive state, not a bare object
  return <Stack><Text>{'Hits: ' + props.title}</Text></Stack>
}
// Need a chart on native? Host it: <WebView html={CHART_HTML} data={metrics()} onMessage={...} />

Catch these BEFORE the device build with pyreon doctor --check-native (project scan) or the MCP validate tool (per-snippet) — both flag web-only imports + native-dropped interface/enum/class in files importing @pyreon/primitives.

  • get_pattern({ name: "routing-setup" }) — the router works on native (nested routes, beforeEnter, loader); useNavigate/useParams/useLoaderData emit per target.

  • get_pattern({ name: "state-management" })defineStore / @pyreon/store is native-ported.

  • get_pattern({ name: "data-fetching" }) — on native, use useFetch (NOT @pyreon/query, which is web-only).

  • get_api({ name: "@pyreon/primitives/Stack" }) (and the other 14 + WebView) — per-primitive props, per-target mapping, and gotchas.

Multiplatform app (one source → web + iOS + Android)