pyreon

@pyreon/primitives — API Reference

Generated from primitives's src/manifest.ts — the same source that powers llms.txt and MCP get_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see primitives.

The multiplatform UI vocabulary for Pyreon. ONE canonical name per concept (<Stack> not <View>/<VStack>/<div>; onPress everywhere, not onClick vs action:). Web renders real DOM via @pyreon/runtime-dom; on iOS/Android the PMTC compiler intercepts the JSX at build time and emits idiomatic SwiftUI / Compose (the import is a type-anchor on native). Tokens-first styling (padding={4}, gap="md") resolves through the theme per target. No responsive props / animations in v1 — apps needing responsive web use @pyreon/elements directly. CRITICAL boundary for native: PMTC compiles your component SOURCE in a narrow declarative TS subset, NOT npm libraries — see get_pattern({ name: "multiplatform" }) for the supported subset + the silent-failure cliff.

Features

  • 15 canonical primitives compile to web DOM + iOS SwiftUI + Android Compose from one .tsx

  • One canonical name + event per concept — <Stack> (not View/VStack/div), onPress everywhere

  • Tokens-first styling (padding={4}, gap="md") resolves through the theme per target

  • PMTC compiles your component SOURCE in a narrow declarative TS subset — NOT npm libraries

  • <WebView> hosts a web-only component (charts/flow/editor) natively with a bidirectional data bridge

  • <Web> / <NativeIOS> / <NativeAndroid> escape hatches for genuinely per-platform UI

  • useNativeModule FFI — add a platform capability the framework does not ship (Bluetooth, ARKit, a vendor SDK) as an app-level Swift/Kotlin class, no framework PR

  • No responsive props or animations in v1 — responsive web uses @pyreon/elements directly

Complete example

A full, end-to-end usage of the package:

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 (PMTC drops interface on native)

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

  return (
    <Stack gap="md" padding={4}>
      <Heading level={1}>Todos ({remaining()} left)</Heading>
      <Inline gap="sm">
        <Field value={draft()} onChangeText={(t) => draft.set(t)} />
        <Button onPress={() => { todos.set([...todos(), { id: todos().length, title: draft(), done: false }]); draft.set('') }}>
          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>
  )
}

Exports

SymbolKindSummary
StackcomponentPrimary layout container.
InlinecomponentHorizontal row — sugar for <Stack direction="row">.
LayercomponentStacked / overlay container.
ScrollcomponentScrollable region.
SpacercomponentFlexible gap that pushes siblings apart.
TextcomponentInline text.
HeadingcomponentHeading text.
ImagecomponentImage.
IconcomponentIcon by canonical name.
ButtoncomponentStyled CTA.
PresscomponentUnstyled tap target (no chrome).
LinkcomponentNavigation link.
FieldcomponentText input.
TogglecomponentBoolean switch/checkbox.
ModalcomponentModal/sheet.
WebViewcomponentHost a web page/component natively (WKWebView on iOS, Android WebView; <iframe srcdoc> on web).
Web / NativeIOS / NativeAndroidcomponentThe Layer-4 per-platform escape hatch — one source carries a platform-specific subtree and exactly ONE branch renders pe
defineNativeModule / useNativeModulefunctionThe Layer-4 FFI escape hatch — how an APP adds a platform capability the framework does not ship (Bluetooth, ARKit, a pa
init / resetPrimitivesConfigfunctionOne-time app-boot configuration for @pyreon/primitives.

API

Stack component

(props: { direction?: 'column' | 'row'; align?: Align; justify?: Justify; gap?: Space; wrap?: boolean; padding?: Space; children }) => VNode

Primary layout container. Web → <div style="display:flex;flex-direction:column|row">; iOS → VStack/HStack; Android → Column/Row. Default direction="column". gap/padding are theme-space tokens (number index OR "sm"|"md"|"lg").

Example

<Stack gap="md" align="center"><Text>a</Text><Text>b</Text></Stack>

Common mistakes

  • Using <View> / <VStack> / <div> — the canonical name is <Stack> (one name, all platforms)

  • Expecting responsive props (breakpoint arrays) — not supported in v1; use @pyreon/elements for responsive web

See also: Inline · Layer · Scroll


Inline component

(props: { align?: Align; justify?: Justify; gap?: Space; wrap?: boolean; padding?: Space; children }) => VNode

Horizontal row — sugar for <Stack direction="row">. Web flex-row; iOS HStack; Android Row. ⚠ On Android <Inline> is a NON-WRAPPING Row (SwiftUI HStack shrinks to fit, but Compose Row overflows + clips the last children). Keep horizontal groups short, or use a vertical <Stack> for action lists.

Example

<Inline gap="sm"><Field value={q()} onChangeText={(t) => q.set(t)} /><Button onPress={search}>Go</Button></Inline>

Common mistakes

  • Putting 5+ buttons in an <Inline> — they overflow + clip (become untappable) on Android; stack vertically or split

  • Relying on wrap for native multi-line — wrapping behavior differs per target

See also: Stack


Layer component

(props: { align?: Align; padding?: Space; children }) => VNode

Stacked / overlay container. Web → position:relative + abs children; iOS → ZStack; Android → Box. Use for badges, overlays, layered composition.

Example

<Layer><Image src={hero} alt="" /><Text>overlaid caption</Text></Layer>

Common mistakes

  • Using it for flow layout — Layer stacks children on the z-axis, not in a row/column

See also: Stack


Scroll component

(props: { direction?: 'vertical' | 'horizontal'; padding?: Space; children }) => VNode

Scrollable region. Web → overflow:auto; iOS → ScrollView; Android → Column(verticalScroll) / Row(horizontalScroll). ⚠ Do not put a weighted <Spacer> inside a Scroll on Android (weight inside a scroll is invalid Compose).

Example

<Scroll><Stack gap="md">{/* long content */}</Stack></Scroll>

Common mistakes

  • Nesting a <Spacer> (weight) inside <Scroll> — invalid on Android Compose

See also: Stack


Spacer component

() => VNode

Flexible gap that pushes siblings apart. Web → flex spacer; iOS → Spacer; Android → Spacer(Modifier.weight(1f)). Use in an <Inline>/<Stack> to right-align or space-between.

Example

<Inline><Text>left</Text><Spacer /><Text>right</Text></Inline>

Common mistakes

  • Using it inside a <Scroll> on Android (weight + scroll conflict)

See also: Inline · Stack


Text component

(props: { color?: ColorToken; size?: 'xs'|'sm'|'md'|'lg'|'xl'; weight?: 'regular'|'medium'|'bold'; truncate?: boolean; children }) => VNode

Inline text. Web <span>; iOS/Android Text. Read signals directly in children: <Text>{count()}</Text> (the compiler wraps it reactively). Avoid template literals on native — use string concat.

Example

<Text size="lg" weight="bold" color="primary">{label()}</Text>

Common mistakes

  • Using a template literal {Count: ${n()}} — partial native support; prefer {"Count: " + n()}

  • Wrapping in String(...) — unnecessary, numbers coerce in JSX text

See also: Heading


Heading component

(props: { level?: 1|2|3|4|5|6; color?: ColorToken; children }) => VNode

Heading text. Web <h1><h6> by level; iOS/Android a sized/weighted Text.

Example

<Heading level={2}>Section</Heading>

Common mistakes

  • Omitting level when document outline matters (web a11y)

See also: Text


Image component

(props: { src: string; alt: string; fit?: 'cover'|'contain'|'fill'|'none'; width?: number|string; height?: number|string }) => VNode

Image. Web <img>; iOS Image; Android AsyncImage (Coil). src + alt REQUIRED. Bundled assets (via the asset pipeline) vs remote URLs dispatch per target.

Example

<Image src={logo} alt="Logo" width={120} height={40} fit="contain" />

Common mistakes

  • Omitting alt (required — a11y + it is the native contentDescription)

See also: Icon


Icon component

(props: { name: string; size?: 'sm'|'md'|'lg'; color?: ColorToken }) => VNode

Icon by canonical name. Web → svg; iOS → SF Symbol (Image(systemName:)); Android → Material Icons.Filled.*. The name maps through ICON_MAP; unmapped names warn + fall back.

Example

<Icon name="star" size="md" color="primary" />

Common mistakes

  • Using a platform-specific icon id — use the canonical name; the compiler maps it per target

See also: Image


Button component

(props: { onPress: () => void; disabled?: boolean; variant?: 'primary'|'secondary'|'ghost'|'danger'; children }) => VNode

Styled CTA. Web <button>; iOS/Android Button. Handler is onPress (NOT onClick). Multi-statement handlers work: onPress={() => { a.set(1); b.set(2) }}.

Example

<Button variant="primary" onPress={() => count.set(count() + 1)}>Increment</Button>

Common mistakes

  • Using onClick — the canonical event is onPress (mapped to onClick/action:/onClick per target)

  • Passing onPress={maybeUndefined} — guard it; a non-function handler is a footgun

See also: Press · Link


Press component

(props: { onPress: () => void; onLongPress?: () => void; disabled?: boolean; children }) => VNode

Unstyled tap target (no chrome). Web <div role="button">; iOS Button {} (plain); Android Box(clickable). Use to make arbitrary content tappable; supports onLongPress.

Example

<Press onPress={() => select(item)}><Card item={item} /></Press>

Common mistakes

  • Using <Press> for a primary action — use <Button> for styled CTAs

See also: Button


(props: { to: string; external?: boolean; children }) => VNode

Navigation link. Web <a>; iOS/Android router-aware navigation. Integrates with @pyreon/router (to is a route path). external opens outside the app.

Example

<Link to="/profile">Profile</Link>

Common mistakes

  • Hardcoding an href for internal routes — use to so it routes natively too

See also: Button


Field component

(props: { value: string | (() => string); onChangeText: (next: string) => void; kind?: 'text'|'number'|'password'|'email'|'search'|'tel'|'url'; placeholder?: string; disabled?: boolean; onSubmit?: () => void }) => VNode

Text input. Web <input>; iOS/Android TextField. Handler is onChangeText(next) (NOT onInput/onChange). value accepts a signal accessor for two-way binding.

Example

<Field value={draft()} onChangeText={(t) => draft.set(t)} placeholder="Search…" onSubmit={search} />

Common mistakes

  • Using onChange/onInput — the canonical handler is onChangeText(next: string)

  • Forgetting value is the source of truth — write back via onChangeText → signal.set

See also: Toggle


Toggle component

(props: { value: boolean | (() => boolean); onChange: (next: boolean) => void; disabled?: boolean }) => VNode

Boolean switch/checkbox. Web checkbox; iOS Toggle; Android Switch. onChange(next: boolean).

Example

<Toggle value={enabled()} onChange={(v) => enabled.set(v)} />

Common mistakes

  • Using onPress/onClick — Toggle uses onChange(next: boolean)

See also: Field


(props: { open: boolean | (() => boolean); onClose: () => void; children }) => VNode

Modal/sheet. Web overlay; iOS .sheet(isPresented:); Android Dialog(onDismissRequest). Drive open with a signal; onClose fires on dismiss.

Example

<Modal open={showSheet()} onClose={() => showSheet.set(false)}><Stack>{/* sheet body */}</Stack></Modal>

Common mistakes

  • Forgetting onClose — needed so the platform dismiss gesture updates your signal

See also: Layer


WebView component

(props: { html?: string; src?: string; data?: unknown; onMessage?: (message: string) => void }) => VNode

Host a web page/component natively (WKWebView on iOS, Android WebView; <iframe srcdoc> on web). THE escape hatch for web-only packages (charts/flow/code/document) on native — they run inside the WebView. Bidirectional bridge: data is pushed in as window.__pyreonData (+ a pyreondata event, live, no reload); the page calls window.pyreonPostMessage(payload) → your onMessage closure.

Example

<WebView html={CHART_HTML} data={metrics()} onMessage={(m) => selected.set(m)} />

Common mistakes

  • Using it for core UI (nav/forms/lists) — pays WebView boot + bundle cost; use native primitives there. Reserve <WebView> for self-contained web-island panes (charts/editors/diagrams)

  • Expecting native look-and-feel — content renders as a web view, not native widgets

See also: Web


Web / NativeIOS / NativeAndroid component

Web(props: { children }) => VNodeChild · NativeIOS(props: { children }) => VNodeChild · NativeAndroid(props: { children }) => VNodeChild

The Layer-4 per-platform escape hatch — one source carries a platform-specific subtree and exactly ONE branch renders per target. <Web> renders its children on WEB only (a layout-transparent Fragment, no wrapper element); <NativeIOS> / <NativeAndroid> render NOTHING on web (they return null — their children are emitted only on the iOS / Android target by PMTC). Reach for these for the rare genuinely-per-platform UI branch the 15 canonical primitives can't express (a web-only-rich chart/flow/table view vs a native equivalent or a <WebView> embed).

Example

<Web>{/* web-only-rich: <Chart>, <Flow>, <Table> */}</Web>
<NativeIOS>{/* Swift Charts, or a <WebView> embed */}</NativeIOS>
<NativeAndroid>{/* Compose chart, or a <WebView> embed */}</NativeAndroid>

Common mistakes

  • Overusing them — defeats the one-source model; reach for them only when a target genuinely needs different UI.

  • Putting web-visible content in <NativeIOS> / <NativeAndroid> — both render NOTHING on web (they are no-ops there); only <Web> content reaches the browser.

See also: WebView · init / resetPrimitivesConfig · defineNativeModule / useNativeModule


defineNativeModule / useNativeModule function

defineNativeModule<T>(name: string, webImpl: T) => T · useNativeModule<T>(name: string) => T · hasNativeModule(name: string) => boolean

The Layer-4 FFI escape hatch — how an APP adds a platform capability the framework does not ship (Bluetooth, ARKit, a payments/analytics SDK). PMTC lowers useNativeModule('X') to an instance of a class YOU provide (X() on iOS, X(context) on Android) and passes member calls through verbatim, so the platform compiler type-checks the surface; on web the same call resolves the defineNativeModule registration, keeping one source running on all three targets. await mod.method() composes with the async lowering with no extra machinery. This is distinct from <NativeIOS>/<NativeAndroid>, which only BRANCH between canonical-primitive subtrees — they cannot host raw platform code.

Example

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

// web implementation (native targets never run this)
defineNativeModule<Bluetooth>('Bluetooth', { connect: async () => false })

function Pairing() {
  const bt = useNativeModule<Bluetooth>('Bluetooth')
  return <Button onPress={() => { void bt.connect('cuff') }}>Connect</Button>
}

Common mistakes

  • Passing a non-literal module name (useNativeModule(NAME)) — it is emitted verbatim as the native class name and PMTC resolves one file at a time, so only a STRING LITERAL at the call site works; anything else warns and the declaration is skipped on native.

  • Forgetting defineNativeModule — native targets compile the call away, so a missing registration only surfaces on WEB, where useNativeModule throws.

  • Giving the Android class a no-arg constructor — the Compose emit injects LocalContext.current, so it must take a SINGLE Context parameter (ignore it if unused); iOS is the opposite (no-argument initialiser).

  • Declaring the Kotlin class in a different package from the generated sources — the emit references it UNQUALIFIED, so it must live in the --kotlin-package package.

  • Expecting reactive re-render for free — mark the Swift class @Observable / back Kotlin state with mutableStateOf if its state should drive the view.

  • Writing a method-bearing type/interface and expecting a struct — a method-only contract type is intentionally not lowered to a native struct (its methods live on the platform class); a MIXED data+method type emits the data fields and warns about the dropped methods.

See also: Web / NativeIOS / NativeAndroid · WebView


init / resetPrimitivesConfig function

init(options: { navigate?: (to: string) => void }) => void · resetPrimitivesConfig() => void

One-time app-boot configuration for @pyreon/primitives. The package is deliberately router-AGNOSTIC (a consumer using only <Stack>/<Text> never pulls a router into their graph), so <Link> needs a navigation handler supplied ONCE via init({ navigate }). With it, <Link> intercepts plain left-clicks and routes via navigate (SPA — no full reload); WITHOUT it, <Link> is a plain <a href> that does a normal full-page navigation — so links always WORK, init only UPGRADES them to SPA. init merges with any previous config (later calls override the keys they set). resetPrimitivesConfig() clears it back to defaults (primarily for tests / teardown). The config is a module-level singleton and SSR-safe (the server renders a static <a href>; navigate is read only inside a client click handler).

Example

import { init } from '@pyreon/primitives'

// at app boot, wire your router's navigate so <Link> does SPA navigation:
init({ navigate: (to) => myRouter.push(to) })

Common mistakes

  • Wondering why <Link> does a FULL PAGE RELOAD — you did not call init({ navigate }). Without a navigate handler, <Link> falls back to a plain <a href> full-load; call init once at app boot with your router push.

  • Expecting it to import a router — it is router-AGNOSTIC by design (works with any router, or none); YOU supply the navigate closure so the package never depends on @pyreon/router.

See also: Link · Web / NativeIOS / NativeAndroid


Canonical Multiplatform Primitives — API Reference