# SwiftBrowser web renderer

Vite + TypeScript (no UI framework) renderer that applies the ops stream
described in [`docs/ops-protocol.md`](https://swiftbrowser-docs.pages.dev/ops-protocol) to the DOM inside
an iPhone-shaped frame styled with iOS design tokens, lays the tree out with a
SwiftUI-style layout engine, animates changes, forwards taps, text input,
toggles, selections, geometry reports, the color scheme and the Dynamic Type
size back to the Swift/Wasm app, and drives the app's cooperative executor
(Phase 4) so `Task`, `.task` and sleeps reach the screen.

```
index.html          Landing page (served at /): one card per registered app with an Open link and a QR code
app/index.html      The app page (served at /app/): toolbar, iPhone frame, #screen
src/landing.ts      Landing page script: app cards (qrcode SVGs), build info from the `define`s
src/landing.css     Landing page styles
src/deviceMode.ts   Frame vs device mode decision, env(safe-area-inset-*) reader, viewport watcher
src/vite-env.d.ts   Types of the build-time defines (__SB_APPS__, __SB_COMMIT__, …)
src/protocol.ts     TypeScript mirror of the ops protocol (Op, element kinds, Style, Color, Animation, events)
src/renderer.ts     Renderer: Map<id, HTMLElement> + LayoutNode tree, ops, navigation, sheets, layout on commit
src/layout/         The layout engine (propose/report/place, text wrapping, Dynamic Type table); unit-tested with vitest
src/layoutDom.ts    Applies a LayoutResult to the DOM: absolute positioning, text lines, chrome rects
src/animation.ts    Animator: Web Animations for animated commits / animationToken subtrees, transitions, spring easing
src/typography.ts   Page-side Dynamic Type helpers over src/layout/typography.ts (CSS variables per text style)
src/symbols.ts      SF Symbol name → lucide glyph table for Image(systemName:)
src/wasm.ts         loadApp(): WASI shim, _start, sb_ops_*, sb_event, sb_alloc + sb_event_json, sb_state_*, sb_run_jobs (JobScheduler)
src/mock.ts         Counter, Todos, Gallery and Settings fixtures + in-page mock "Swift core" for ?mock=1
src/main.ts         App page wiring: mode, theme toggle, Text size control, app label, boot, hot-reload state stash
src/devEvents.ts    Payload types for the sb:build-* websocket events
src/hostRequests.ts Phase 13: performs the app's `request` ops (URLSession → fetch, AsyncImage → an Image() probe) and answers with `response` events
src/bitmaps.ts      UIImage bitmaps: keeps `imagedata` sources, decodes them, draws recipes on each image's <canvas>
src/imageOps.ts     Core Image's steps (sepia, crystallize, edges, blur, pixellate, unsharp mask, vignette, crop) on rasters; no DOM
src/styles.css      iOS tokens, device frame, device mode, element styles
node/               Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
                    examples.ts (Examples/* as specs, build-wasm.sh runner, descriptions),
                    index.ts (createDevServer / buildSite, the package entry); compiled to node/dist/
plugins/            Vite plugins over the registry: swift-wasm.ts serves /<name>.wasm, rebuilds on Swift changes
                    and copies the modules into dist/; app-manifests.ts: one web app manifest (+ icon) per app
                    (/manifests/<name>.webmanifest); asset-catalogs.ts: *.xcassets -> /catalog/<name>.json + files
e2e/                Playwright tests (mock fixtures + the real Counter.wasm / Todos.wasm / Settings.wasm)
public/             Counter.wasm / Todos.wasm / Gallery.wasm / Settings.wasm land here (git-ignored);
                    also manifest.webmanifest, icon.svg, apple-touch-icon.png and the Cloudflare _headers
vite.config.ts      `npm run dev` / `vite build` for the examples: createViteConfig(exampleAppSpecs())
tsconfig.node-build.json  Emits node/dist/ (ESM + .d.ts) for `import ... from '@swiftbrowser/web'`
```

## Run

```sh
npm install
npm run dev          # http://localhost:5173/                 landing page (list of examples)
                     # http://localhost:5173/app/             loads /Counter.wasm
                     # http://localhost:5173/app/?app=Todos   loads /Todos.wasm
                     # http://localhost:5173/app/?mock=1      hardcoded Counter fixture, no wasm needed
npm run build        # typechecks src/, writes dist/ (dist/index.html + dist/app/index.html) and node/dist/
npm run build:node   # only node/dist/ (the programmatic API other packages import)
npm run preview      # serves dist/
npm run typecheck    # src/, e2e/, node/, plugins/ and the config files
npm run test:unit    # vitest: the layout engine (src/layout/__tests__), the plugins and the registry
npm run test:e2e     # Playwright; starts the dev server itself
```

## App registry and the programmatic API

`vite.config.ts` no longer hardcodes the examples: `node/config.ts` builds the
Vite config from a list of `AppSpec`s (`node/apps.ts`), one per app:

```ts
interface AppSpec {
  name: string;          // URL-safe: ?app=<name>, /<name>.wasm, /catalog/<name>.json, /manifests/<name>.webmanifest
  displayName?: string;  // landing card title + manifest name; defaults to name
  description?: string;  // landing card subtitle + manifest description
  wasm: string;          // absolute path of the built module (may not exist yet in dev)
  catalogs: string[];    // absolute *.xcassets directories
  watch?: string[];      // absolute directories whose **/*.swift changes trigger `build`
  build?: () => Promise<void>; // rebuilds `wasm`; rejects with the compiler output
  icon?: string;         // absolute path of a PNG; served at /manifests/<name>-icon.png
}
```

The three plugins take the same list: `plugins/swift-wasm.ts` serves
`/<name>.wasm` from `spec.wasm` (building it on the first request when it is
missing), watches `spec.watch` and copies the module into `dist/` on build;
`plugins/asset-catalogs.ts` builds `/catalog/<name>.json` from
`spec.catalogs`; `plugins/app-manifests.ts` writes the manifest (and the icon)
from `displayName`, `description` and `icon`.

`node/examples.ts` turns `../Examples/*` into specs (`wasm` =
`public/<Name>.wasm`, `build` = `bash scripts/build-wasm.sh <Name>` with
`SB_WASM_OPT=0`, `watch` = `Sources/` + the example's directory), which is
what `npm run dev` / `vite build` use.

Other packages (the CLI) import the same machinery through the package entry,
compiled by `npm run build:node` (`tsconfig.node-build.json` → `node/dist/`):

```ts
import { createDevServer, buildSite, type AppSpec } from '@swiftbrowser/web';

const server = await createDevServer({ apps, port: 5173, host: true });  // started; server.printUrls()
await buildSite({ apps, outDir: 'dist', commit, branch });              // complete static site
```

Both resolve the Vite root (`Web/`) relative to the module and ignore
`vite.config.ts`.

## Pages and URLs

The site is a Vite multi-page app (`build.rollupOptions.input` in
`vite.config.ts`, `appType: 'mpa'`):

- `/` (`index.html` + `src/landing.ts`): "SwiftBrowser examples". The list of
  apps comes from the app registry (`node/config.ts`, see "App registry"
  below), injected with `define` as `__SB_APPS__` (`{ name, displayName,
  description?, icon? }[]`); `vite.config.ts` registers every directory under
  `../Examples`, with the descriptions in `node/examples.ts`. The card title
  is `displayName` (the name by default), the subtitle the description or
  "the <name> app". Every card has
  an "Open" link to `/app/?app=<Name>` and an SVG QR code of the absolute URL
  (`location.origin + '/app/?app=' + name`, generated in the browser with the
  `qrcode` package) so a phone can scan it from a desktop. The footer shows
  the build info, also from `define`: `__SB_COMMIT__` (`GITHUB_SHA`, else
  `git rev-parse --short HEAD`, else `dev`; linked to the commit on GitHub
  unless `dev`), `__SB_BRANCH__` (`GITHUB_REF_NAME`, else
  `git rev-parse --abbrev-ref HEAD`) and `__SB_BUILT_AT__`.
- `/app/` (`app/index.html` + `src/main.ts`): the renderer. `import.meta.env.BASE_URL`
  stays `/`, so the modules are still fetched from `/<Name>.wasm` and the
  dev-server plugin's rebuild registry (which watches those requests) works
  unchanged.

The app page's query parameters:

- `?app=Name` loads `/Name.wasm` instead of `/Counter.wasm` (and passes `Name`
  as `argv[0]`). The dev server remembers every app requested this way and
  rebuilds all of them on Swift changes (see "Hot reload").
- `?mock=1` renders a hardcoded screen and answers events in-page, so the
  renderer can be worked on without a Swift toolchain. Two fixtures exist:
  - `?mock=1` (or `?mock=1&app=Counter`): the Phase 1 Counter screen; `+`/`−`
    answer with `update` ops.
  - `?mock=1&app=Todos`: a Phase 2 `navstack` → `navscreen` ("Todos", large
    title) → `list` with a "Today" `section` of three rows (a `toggle`, a
    `navlink`, an `hstack` with an `image` and text) and a second section with
    a `roundedBorder` `textfield` plus a `bordered` "Add" button wrapped in a
    `styled { disabled }`. The mock flips `isOn` on toggle, pushes an inline
    "Detail" `navscreen` on the navlink, pops it on a tap of the screen's own
    id (the back button), echoes typed text back and enables "Add" once there
    is text. `window.__sb.app().received` lists every event it got.
  - `?mock=1&app=Gallery`: Phase 3. A `vstack` of one `text` per text style
    (to check Dynamic Type), a `color` and two `shape`s (a filled circle, a
    stroked capsule) in fixed frames, an `.animation(.spring)` subtree
    (`styled { animation, animationToken }`) around a rounded rectangle, and
    two buttons. "Toggle" bumps the `animationToken`, swaps the shape's fill
    (red ↔ green), doubles the frame width (80 ↔ 160) and inserts or removes
    an "Expanded" text with `transition: opacity`, all in a commit carrying
    `animation: easeInOut 0.35`. "Show sheet" appends a root-level `sheet`
    (`detents: ["large"]`) with a title and a "Done" button; "Done", a tap on
    the scrim and a drag of more than 100px all remove it (the latter two by
    sending a `tap` with the sheet's own id). Ids are in `GALLERY_IDS`.
  - `?mock=1&app=Settings`: Phase 4. A `tabview` (`selected: 0`) with two
    tabs: "Home" (`house`) holds a `navstack` → `navscreen` ("Home", large
    title) → inset-grouped `list` with a "Geometry" section (a
    `styled { frame: { height: 22 } }` → `geometry` → `text`, which the mock
    rewrites to the reported `W × H`) and a "Rows" section of 20 rows, long
    enough to scroll under the tab bar; "Settings" (`gear`) holds a `list`
    with `style: "grouped"` containing a segmented `picker` ("Appearance":
    Light/Dark/Auto), a menu `picker` ("Units": Metric/Imperial) and a
    `toggle`. `select` on the tab view or a picker updates its `selected`,
    `toggle` flips `isOn`, `geometry` updates the text. Ids are in
    `SETTINGS_IDS`; every event lands in `window.__sb.app().received`.

- `?mode=device` / `?mode=frame` forces device mode or the iPhone frame (see
  "Device mode" below).

The light/dark toggle in the toolbar flips `data-theme` on the device frame and
is remembered in `localStorage` (`sb-theme`). It only affects the phone; the
page chrome follows the OS `prefers-color-scheme`.

The **Text size** select (`#type-size`, labelled "Text size") picks one of the
12 Dynamic Type categories (`xSmall` … `accessibility5`, default `large`),
remembered in `localStorage` (`sb-type-size`). It sets `data-type-size` on the
device frame, writes `--sb-ts-<style>-size` / `-lh` / `-scale` CSS variables
for every text style (`src/typography.ts`), re-runs the layout engine with the
new `dynamicTypeSize`, and sends an `environment` event.

## Device mode (real phones)

`src/deviceMode.ts` decides between two modes when the app page loads (an
inline script in `app/index.html` applies the same rule before the first paint
so a phone never flashes the bezel):

- **frame** (desktop, tests): the fake iPhone 15 Pro bezel with its drawn
  status bar, Dynamic Island and home indicator, plus the toolbar. Exactly as
  before.
- **device**: `?mode=device`, or automatically when
  `matchMedia('(pointer: coarse)').matches` (a coarse pointer at any width:
  a tablet fills the viewport instead of showing the phone bezel; `?mode=frame`
  wins over the automatic rule).

In device mode `<html data-sb-mode="device">` hides the toolbar and the bezel
chrome, `#screen` fills `100dvw × 100dvh` at (0, 0), the layout environment's
`screen` is the viewport and its `safeArea` comes from the real
`env(safe-area-inset-top/right/bottom/left)` values, exposed as
`--sb-inset-top/right/bottom/left` on `:root` and read with
`getComputedStyle` (`renderer.setSafeArea`). They are non-zero only in
standalone (home screen) mode or landscape, which is what a native app would
get. `resize`, `orientationchange` and `visualViewport` resizes re-run the
engine (debounced 100ms, `renderer.viewportChanged`). The color scheme follows
`prefers-color-scheme` (and its changes), the Dynamic Type size is `large`, and
the `environment` event carries both as usual. `touch-action: manipulation`
and `-webkit-text-size-adjust: 100%` stop double-tap zoom and text inflation;
the viewport meta has `viewport-fit=cover`. Hot-reload state restore and the
job scheduler are unaffected. `window.__sb.mode` reports the mode.

`app/index.html` also declares `apple-mobile-web-app-capable`,
`apple-mobile-web-app-status-bar-style: black-translucent` (the OS status bar
stays transparent, the app's own background shows under it and the layout
engine gets the bar as a safe-area inset; `default` draws an opaque light bar
whatever the app's color scheme), light/dark `theme-color`s, a web app
manifest and the icons `public/icon.svg` + `public/apple-touch-icon.png`
(180×180, drawn with ImageMagick to match the SVG). In Safari, Share → Add to
Home Screen then runs an app full screen.

Safari takes a home-screen icon's launch URL and label from the manifest, not
from the page URL, so there is one manifest per example: `plugins/app-manifests.ts`
serves `/manifests/<Name>.webmanifest` (`start_url: /app/?app=<Name>`,
`short_name: <Name>`, `display: standalone`) in the dev server and emits them
in `vite build`, and an inline script in `app/index.html` points
`<link rel="manifest">` and `apple-mobile-web-app-title` at the app named by
`?app=`. `public/manifest.webmanifest` (`start_url: /app/`, the default app)
remains for the landing page and for `/app/` without a parameter.

## Deploying (Cloudflare Pages)

`npm run build` writes a static site to `dist/`: `index.html`, `app/index.html`,
hashed `assets/`, the `.wasm` modules and everything from `public/`. The
GitHub workflow builds every example, runs `npm ci && npm run build` in `Web/`
and deploys `Web/dist` with `wrangler pages deploy`. `public/_headers` tells
Pages to serve `/*.wasm` with `Content-Type: application/wasm` and
`Cache-Control: no-cache` (the modules are not content-hashed) and the hashed
`/assets/*` as immutable. Apps are opened as
`https://<site>/app/?app=<Name>`; the landing page lists and QR-codes them.

## Getting `Counter.wasm` into `public/`

The renderer fetches `/<App>.wasm`, which Vite serves from `Web/public/`.
Those files are build products and are git-ignored (`public/*.wasm`); only
`public/.gitkeep` is tracked.

From the repo root:

```sh
scripts/build-wasm.sh          # every example through the CLI (swiftbrowser build: release, wasm-opt -Oz
                               # when installed), each <App>.wasm copied into Web/public/
scripts/build-wasm.sh Todos    # the Todos example only
```

## Hot reload

`npm run dev` runs the `swiftWasm()` plugin from `plugins/swift-wasm.ts`:

1. It watches `../Sources/**/*.swift` and `../Examples/**/*.swift` with the
   dev server's own file watcher and debounces changes for 300ms.
2. It runs `bash scripts/build-wasm.sh <App>` from the repo root for every app
   requested since the server started (`Counter` by default; a `/Todos.wasm`
   request adds `Todos`). Build output is logged to the terminal. `swift` is
   resolved from `$SWIFT_TOOLCHAIN_BIN`, then
   `/root/.local/share/swiftly/toolchains/6.4.0/usr/bin` if present, then `PATH`.
3. While building, the page shows a "Rebuilding…" indicator in the toolbar
   (websocket events `sb:build-start` / `sb:build-end`). On success the server
   sends a `full-reload`; on failure it sends `sb:build-error` with the
   compiler output, which the page shows in the error banner while the current
   app keeps running.
4. Before the reload (`vite:beforeFullReload`, plus `beforeunload`/`pagehide`
   as a fallback for manual refreshes), `main.ts` reads the app's `@State`
   snapshot through `sb_state_ptr`/`sb_state_len` and stores it in
   `sessionStorage['sb-state:<App>']`. On boot the snapshot is removed from
   storage and passed to the new module as the WASI environment variable
   `SB_STATE=<json>`, e.g. `SB_STATE={"ContentView#0":3}`. Modules without the
   snapshot exports (Phase 1) are reloaded without state.

Mock mode never stashes state. `window.__sb.snapshotState()` returns the
current snapshot for inspection.

Until that script exists you can copy the file by hand:

```sh
cp "$(swift build --show-bin-path --swift-sdk swift-6.4.0-RELEASE_wasm -c release)/Counter.wasm" Web/public/
```

Then `npm run dev` and open http://localhost:5173/app/ (without `?mock=1`). If the
file is missing, the page shows an error banner under the device explaining
what it expected.

## What the renderer expects from the Wasm module

See `docs/ops-protocol.md`. In short, `src/wasm.ts`:

1. fetches and compiles the module; it must import only `wasi_snapshot_preview1`;
2. instantiates it with `@bjorn3/browser_wasi_shim` (`args: ["Counter"]`,
   `env: []`, stdout/stderr forwarded to the browser console);
3. calls `_start` (or `_initialize` for a reactor build). `_start` returning
   normally **or** calling `proc_exit(0)` both count as success; any non-zero
   exit code is an error. The instance stays alive afterwards;
4. reads `sb_ops_ptr()`/`sb_ops_len()` as a UTF-8 JSON array out of
   `exports.memory`, calls `sb_ops_clear()`, then applies the ops;
5. on every tap (`button`, `navlink`, or the back button of a `navscreen`)
   calls `sb_event(id)` and repeats step 4;
6. for every other event (`text`, `toggle`, `environment`, `select`,
   `geometry`) encodes the JSON event as UTF-8, calls `sb_alloc(len)`, writes
   the bytes at the returned pointer, calls `sb_event_json(ptr, len)` and
   repeats step 4. Modules that lack `sb_alloc`/`sb_event_json` (Phase 1)
   skip these events; `environment` is sent once right after `_start` and
   again whenever the theme toggle or the Text size control changes. It
   always carries both fields:
   `{"type":"environment","colorScheme":"light","dynamicTypeSize":"large"}`,
   with `dynamicTypeSize` one of `xSmall`, `small`, `medium`, `large`,
   `xLarge`, `xxLarge`, `xxxLarge`, `accessibility1` … `accessibility5`.
   Phase 2 modules ignore the extra field. Events the renderer produces
   before the handle exists (the `geometry` reports of the very first layout
   pass, which runs inside `loadApp`) are queued and delivered right after
   `environment`;
7. (Phase 4) if the module exports `sb_run_jobs(now_ms: f64) -> f64`, calls
   it with `performance.now()` right after the first ops were applied, after
   every delivered event (after that event's ops were applied) and whenever
   the delay it returned has elapsed; after every call it repeats step 4.
   `src/wasm.ts`'s `JobScheduler` keeps a single pending `setTimeout` for
   the next run (every call cancels and reschedules it; the delay is clamped
   to at least 4 ms; a negative return schedules nothing until the next
   event). `window.__sb.runJobs()` runs it on demand and returns the delay
   (`null` for modules and mocks without the export, which keep working as
   before).

`memory.buffer` is re-read after every call into Wasm because growth detaches
the previous `ArrayBuffer`.

## Renderer notes

### Ops and the element tree

- `insert` with an element that is already attached is a move. `index` is the
  position in the parent's child list *after* the op (the element is detached
  first, then inserted before the current child at `index`).
- `update` replaces props entirely: inline styles and data attributes produced
  by the previous props are cleared first (the layout box is kept until the
  commit re-lays out).
- `remove` detaches the element and forgets it and every descendant.
- `commit` runs the layout engine over the whole tree, positions every element
  and hands the pass to the Animator; it then dispatches a `sb:commit`
  CustomEvent on `#screen` (`detail.animation` is the commit's animation or
  null). Everything else is applied eagerly, not batched.
- Besides `elements` (id → HTMLElement) the renderer keeps `nodes` (id →
  `LayoutNode { id, kind, props, children }`, the shape the engine reads) and
  `parents`. `window.__sb.renderer.layoutResult` is the last `LayoutResult`.

### Layout (Phase 3)

Layout is not CSS. On every commit, and whenever the Dynamic Type size, the
color scheme or the fonts change (`renderer.setEnvironment`,
`renderer.fontsChanged`), the renderer calls
`layoutTree(root.children, env, new CanvasTextMeasurer())` from `src/layout`
(see the "Layout engine semantics" table in `docs/ops-protocol.md`) and
`src/layoutDom.ts` applies the result:

- Every protocol element is `position: absolute` with `left/top/width/height`
  from its frame, relative to its parent element's box. `#screen` is a plain
  `overflow: hidden` box; the safe areas are part of the engine's proposal.
- `text` elements get the engine's exact lines joined with `\n` under
  `white-space: pre`, the resolved `font` shorthand (`weight size/lineHeight
  family`) and `text-align` from `multilineTextAlignment`. `lineLimit`
  truncation (the `…`) comes from the engine too.
- `image` elements are a square of the font's line height with the glyph at
  `1em` (font-size from `result.fonts`).
- `scrollview` and `list` get an inner content box (`.sb-scroll-content`,
  `.sb-list-content`) sized from `contentSizes`; children are placed inside it
  at scroll offset 0 and the box scrolls (`overflow: auto`, hidden scrollbars).
  When the view reaches the bottom of the screen (and is not inside a sheet),
  the bottom safe area (34px) is added to the content box's height, iOS's
  content inset, so the last row can scroll clear of the home indicator (the
  engine's frames are unchanged).
- Compound kinds use the engine's `chrome` rects: `navscreen` {bar (status bar
  + 44, padded so the 44px row is at the bottom), largeTitle (52px row, text
  from `chromeText`), back, content}, `sheet` {grabber}, `toggle` {switch},
  `section` {rows (the inset card), header, footer}, `navlink` rows {chevron},
  `tabview` {bar, item0 … itemN-1}, `picker` {segment0 … / label, value}.
  A `list` or `scrollview` under a tab bar gets a `bottomInset` rect (the
  part of its frame the bar covers): its height replaces the 34px safe-area
  content inset.
  Section headers come upper-cased from the engine in the footnote font;
  footers are drawn from the section's own `footer` prop (sentence case).
- A list row's DOM box is the engine's full-width `row` rect (so the cell
  background, the 16px-inset separator and the hit area match iOS); leaf rows
  (`text`, `image`, `textfield`) pad their content frame back into place.
  Rows whose own chrome replaced the `row` key (`toggle`, bordered `button`)
  rebuild it from the card and the engine's rule `max(44, content + 2 × 11)`.
- Layout-only style fields (`padding`, `frame`, `layoutPriority`, `fixedSize`,
  `lineLimit`, `multilineTextAlignment`) leave data attributes
  (`data-sb-padding`, `data-sb-frame`, `data-sb-line-limit`, …) and are read by
  the engine from `nodes`; `font`, `foreground`, `background`, `cornerRadius`
  (+ `overflow: hidden`), `opacity` and `disabled` are inline CSS as before.
- Expected geometry with the real modules: Counter's three buttons are 88×44
  at x = 48 / 152 / 256, y = 521 (16px gaps, centered); Todos' bar spans
  y 0–103, the large title 103–155, the list starts at 155 and its rows are
  44 high and inset 16; a pushed inline screen's bar row is y 59–103.

### Fonts and Dynamic Type

- `Style.font` fields are independent: `{ "weight": "bold" }` changes only the
  weight. A `textStyle` resolves through Apple's Dynamic Type table in
  `src/layout/typography.ts` (the single source of truth; `src/typography.ts`
  re-exports it): Large is 34/28/22/20/17/17/16/15/13/12/11 for largeTitle …
  caption2, line heights 41/34/28/25/22/22/21/20/18/16/13; other sizes use the
  HIG point sizes with `round(size × 1.2)` line heights. `headline` implies
  `semibold`. A fixed `size` does not scale unless `relativeTo` names a text
  style, in which case it scales by that style's ratio to Large.
- Styled boxes with a `textStyle` also carry `font-size: var(--sb-ts-<style>-size)`
  (fallback: the Large value) so chrome and un-laid-out text follow the Text
  size control; `--sb-font-size-body` on the device frame follows `body`.

### Kinds

- Semantic colors resolve to CSS variables (`--sb-color-primary`, `-secondary`,
  `-accent`, `-system-background`, `-secondary-system-background`); `clear` is
  `transparent`. Values for both schemes live in `styles.css` under
  `.device[data-theme]`. An `a` on a semantic color is an opacity multiplier,
  rendered as `color-mix(in srgb, var(--token) <a*100>%, transparent)`.
- `Style.disabled` multiplies the box's opacity by 0.4 and sets
  `pointer-events: none` (plus `data-sb-disabled` / `aria-disabled`).
- `button.style` and `button.role` become `data-sb-style` / `data-sb-role`;
  `bordered` is a gray capsule, `borderedProminent` a filled accent capsule
  (label + 14/7 padding, 34px minimum, from the engine), `destructive` red
  text. Phase 1 modules send `{}`, which reads as `automatic`.
- `image`: `systemName` is looked up in `src/symbols.ts` (SF Symbol name →
  lucide glyph, including `.fill` / `.circle` variants) and rendered as an
  inline `<svg>` in `currentColor`. Unknown names draw a dashed square with
  `title` set to the name, so gaps are visible.
- `color` is a box filled with its color; `shape` draws `rectangle`,
  `roundedRectangle` (`border-radius: cornerRadius`), `circle` / `ellipse`
  (`50%`) and `capsule` (`9999px`). `fill` is the background; `fill: null`
  without a stroke paints `currentColor` (a bare `Circle()`), with a stroke it
  paints nothing (`Shape.stroke(_:)`); `stroke` is a solid border of
  `lineWidth` (inside the frame, `box-sizing: border-box`). Both take exactly
  the size the engine proposes (10pt on an unconstrained axis).
- `list` / `section` render iOS inset-grouped: grouped background on the
  list, white (dark: `#1C1C1E`) 10px-rounded cards inset 16px, 44px rows
  with 16px content insets and separators inset 16px. Every direct child of a
  section (and every non-`section` child of a list) is a row; a `navlink`
  row shows a trailing chevron, a `toggle` row puts the switch at the
  trailing inset.
- Compound kinds (`navstack`, `navscreen`, `section`, `toggle`, `navlink`,
  `list`, `scrollview`, `sheet`) own some chrome. Their protocol children go
  into a slot element (`.sb-slot`), so `insert` indices are exact;
  `Renderer.elements` still maps ids to the element itself.
- `navstack` / `navscreen`: only the last screen is interactive
  (`data-sb-nav-position`, `inert`); a new screen slides in from the right
  over 0.35s and the one below parks at `translateX(-30%)`. A removed top
  screen animates out through a visual clone stripped of element ids. Screens
  at `depth > 0` show a back button (`chevron.left` + the previous screen's
  current title) that sends a `tap` with the `navscreen`'s own id. A title
  `update` on any screen also refreshes the back labels above it.
- `textfield` is an `<input>` (34px `roundedBorder`, 22px `plain`); `input`
  events send `{"type":"text"}`. An `update` whose `text` equals the current
  value leaves `.value` (and the caret) alone. `toggle` renders a 51×31 switch
  (`role="switch"`); clicking it flips the switch optimistically and sends
  `{"type":"toggle"}`; Swift's `update` confirms the state.

### Tab views, pickers, list styles and GeometryReader (Phase 4)

- `tabview` fills the screen like a `navstack` (safe areas ignored). Its
  `tab` children go into `.sb-tabview-content` and all share the tab view's
  frame; the bar (`.sb-tabbar`, the engine's `bar` rect: 49 + the bottom
  safe area when the tab view reaches the screen bottom) is drawn on top with
  a translucent system background (`rgba(249,249,249,0.94)`, dark
  `rgba(29,29,29,0.94)`), a hairline on top and one `.sb-tabbar-item` per
  tab at the engine's `item<i>` rect (equal widths, 49 high): the tab's
  `systemImage` through the symbol table at 24px over a 10px title, accent
  when selected (`aria-selected`), secondary otherwise. Tapping an item sends
  `{"type":"select","id":<tabview id>,"value":<index>}` and nothing else:
  the `selected` prop is authoritative, the `update` switches tabs. Only the
  selected tab's content is laid out; the other tabs keep their DOM (and
  scroll positions) under `data-sb-tab-hidden` (`visibility: hidden`,
  `inert`, `aria-hidden`), driven by `LayoutResult.hidden`.
- Tab content is placed like a navscreen's: a `navstack` fills the whole tab
  (its screens reserve the status bar and their lists run under the
  translucent bar); a `list`/`scrollview` starts below the status bar and
  also runs to the bottom; anything else is centered between the status bar
  and the bar. Scrolling views under the bar get the bar height (83) as
  scrollable content inset instead of the 34px safe area (engine chrome
  `bottomInset`), so the last row scrolls clear of the bar like iOS.
- `picker` `segmented`: an iOS segmented control, 32 high, width = proposal
  (or every label + 20): `.sb-segment-track` (`rgba(118,118,128,0.12)`, 8px
  radius; dark `rgba(118,118,128,0.24)`) at the control's frame and one
  `.sb-segment` radio button per option at the engine's `segment<i>` rect
  (13px semibold); the checked one carries a white pill (dark `#636366`)
  inset 2px with a soft shadow. Tapping a segment moves the pill
  optimistically and sends `select` with the index; Swift's `update`
  confirms. In a list row the row is 44 high with the control centered.
- `picker` `menu`: 44 high in a list row, 34 elsewhere; `role="button"` with
  `.sb-picker-label` at the engine's `label` rect (leading) and
  `.sb-picker-value` (the selected option plus `chevron.up.chevron.down`,
  secondary color) at `value` (trailing). Tapping it (or Enter/Space) opens
  `.sb-picker-menu` appended to `#screen`: 250 wide, 13px radius, system
  background, one 44px `menuitemradio` row per option with a checkmark on
  the current one, anchored under the control (above it when there is no
  room, trailing edges aligned, 8px from the screen edges). Choosing a row
  sends `select` and closes the menu; Escape or a tap anywhere outside
  closes it (a tap inside the screen is swallowed so the control under it
  does not fire); an `update` or removal of the picker closes it too.
  `renderer.openMenuElement` exposes the open menu.
- `list.style`: `insetGrouped` (default; Phase 2 modules send `{}`) is the
  existing card layout; `grouped` (`Form`) makes sections full width with no
  corner radius, a hairline above and below each card and the usual 16px
  content inset and 35px between sections; `plain` drops the cards, the top
  gap and the gaps between sections and uses the system background. The
  list carries `data-sb-list-style`.
- `geometry` (`GeometryReader`) is a plain box the engine sizes to its
  proposal (10 on an unconstrained axis, so one in a list row needs a frame
  height) and whose child is proposed that size and placed top leading.
  After every layout pass the renderer compares each `geometry` element's
  frame (rounded to whole px) with the size it last reported and queues
  `{"type":"geometry","id":N,"width":w,"height":h}` for the changed ones; the
  batch is sent once the ops being applied are done (so Swift's answering
  commit re-enters the renderer cleanly) and never when the size is
  unchanged, which is what lets the re-render loop converge. Elements in a
  hidden tab are not laid out and therefore not reported.

### Sheets (Phase 3)

A root-level `sheet` (child of id 0) is a full-screen layer (`role="dialog"`):
a scrim (`rgba(0,0,0,0.4)`) over the presenting tree and a card at the
engine's frame (large: screen height − status bar − 10; medium: half the
screen) with 10px top corners, a 36×5 grabber 8px from the top, and the
sheet background (`#FFFFFF`, dark: `#1C1C1E`, the elevated background). The
card slides up over 0.4s on insert (`sb-sheet-in`); on remove a clone
(`.sb-sheet--out`, no element ids) slides down and fades its scrim, then goes.
While the top sheet is `large`, `#screen` carries `data-sb-sheet="large"` and
the other root children scale to 0.92 behind the scrim; a `medium` sheet only
dims. Sheets stack: only the last is interactive (`data-sb-sheet-position`,
`inert`), sheets below park slightly scaled. A tap on the scrim, or a
downward drag of the card past 100px (pointer events; shorter drags spring
back), sends `{"type":"tap","id":<sheet id>}`: the dismiss request Swift
answers by removing the element. `detents` only matters by its first entry.

### Animation (Phase 3)

`src/animation.ts` animates a pass with the Web Animations API when the
`commit` carries an `animation` (`withAnimation`) or a `styled` element's
`animationToken` changed in an `update` (`.animation(_:value:)`). A token
subtree uses its own `animation` (it overrides the commit's, like SwiftUI's
transaction override); everything else in an animated commit uses the commit's.

- Style changes of updated elements tween from their computed value before the
  pass: `opacity`, `background-color`, `color`, `border-radius`, `font-size`.
- Frame changes tween `left/top/width/height` for every element whose layout
  box moved or resized in the pass (the engine's frames before and after).
- Inserted subtree roots play their `transition` forwards, removed elements
  leave a visual clone (`.sb-exit-clone`, stripped of ids, absolutely
  positioned at the old frame on `#screen`) that plays it backwards and is
  dropped when done. `opacity` fades; `scale` scales from `scale` (default
  0.5) with a fade; `slide` enters from the leading edge and exits through the
  trailing edge; `move(edge)` translates by the element's own size from that
  edge; `identity` does nothing; arrays combine. Without a `transition`,
  inserted/removed views fade (SwiftUI's default). The transition is looked up
  on the removed/inserted root or down a single-child chain of `styled` boxes
  under it (`Text.transition(.opacity).padding()`). `navscreen` and `sheet`
  keep their own CSS enter/exit instead.
- `Animation` → CSS timing: `default` and `easeInOut` →
  `cubic-bezier(0.42, 0, 0.58, 1)`, `easeIn` → `cubic-bezier(0.42, 0, 1, 1)`,
  `easeOut` → `cubic-bezier(0, 0, 0.58, 1)`, `linear`; `duration` defaults to
  0.35s, `delay` to 0. `spring` becomes a `linear()` easing sampled at 60
  points from a damped spring with ω = 2π / duration and damping ratio
  1 − bounce (bounce 0: critically damped; 0.3: overshoots ~4.6%), run over
  the perceptual `duration` (default 0.5s); browsers without `linear()` fall
  back to easeInOut. `window.__sb.animation.{easingFor, timingFor,
  springEasing}` expose the mapping.

- `window.__sb.renderer` exposes the live `Renderer` (used by the e2e tests
  and handy in the console: `__sb.renderer.applyOps([...])`);
  `window.__sb.sentEvents` lists the JSON events delivered to the app,
  `window.__sb.colorScheme()` the current appearance,
  `window.__sb.typeSize()` the current Dynamic Type size,
  `window.__sb.runJobs()` runs the app's executor (Phase 4) and
  `window.__sb.JobScheduler` is the executor driver class for tests.

## Testing

`npm run test:unit` runs the layout engine's vitest suite (Node, no browser).
`npm run test:e2e` runs Playwright with Chromium, in three projects: `mock`
(the specs listed in `MOCK_SPECS` in `playwright.config.ts`, which load no
compiled module), `wasm-heavy` (the slowest specs against real modules,
`HEAVY_SPECS`) and `wasm` (every other spec; these need `public/<App>.wasm`).
`--project=mock` runs without any module built; CI runs it while the examples
build, and runs `wasm-heavy` and `wasm` in two jobs of about the same length.
The config boots
`npm run dev -- --port 5173 --strictPort` itself. `e2e/renderer.spec.ts`
covers the Counter mock and op semantics, `e2e/phase2.spec.ts` the Phase 2
kinds against the Todos mock, `e2e/phase3.spec.ts` Dynamic Type, color/shape,
sheets and animation against the Gallery mock, `e2e/phase4.spec.ts` tab
views, pickers, list styles, geometry reports and the executor loop
(`JobScheduler` against a stub) against the Settings mock, and
`e2e/counter.spec.ts` / `e2e/todos.spec.ts` / `e2e/settings.spec.ts` drive
the real `public/Counter.wasm` / `Todos.wasm` / `Settings.wasm`, including
the geometry the engine guarantees (bounding boxes, not CSS flex properties)
and, for Settings, the clock task that only advances while `sb_run_jobs` is
driven. `e2e/landing.spec.ts` checks the landing page (cards, Open links, QR
SVGs, build info) and `e2e/device-mode.spec.ts` runs a 393×852 touch viewport
through `?mode=device` (no frame, full-screen `#screen`, real taps, resize
and color-scheme changes), the automatic rule and `?mode=frame`. The specs
open `/app/…`; the dev server is started at `/app/?mock=1`. Chromium is expected under
`$PLAYWRIGHT_BROWSERS_PATH/chromium`; set `SB_CHROMIUM_PATH` to point at a
different binary, or unset `PLAYWRIGHT_BROWSERS_PATH` to let Playwright use its
own download.
