# Render ops protocol

The Swift core (the `SwiftUI` shim module) never touches the DOM. It turns a
SwiftUI view tree into an **element tree** and emits a stream of **ops** that
a renderer applies. Two renderers exist:

- the browser renderer in `Web/` (TypeScript, applies ops to the DOM inside an
  iPhone frame), and
- headless mode, used by CI under wasmtime, which prints one op per line as
  JSON Lines so the output can be snapshot-tested.

Element ids are integers assigned by the Swift core and are stable across
re-renders for an element that keeps its position and kind. Id `0` is the
root container (the device screen) and is never created or removed.

## Ops

Each op is a JSON object with an `op` field.

| op       | fields                              | meaning |
|----------|-------------------------------------|---------|
| `create` | `id`, `kind`, `props`               | Create a detached element. |
| `update` | `id`, `props`                       | Replace the element's props entirely. |
| `insert` | `parent`, `id`, `index`             | Insert element `id` as child `index` of `parent` (moving it if already attached). |
| `remove` | `id`                                | Detach and destroy the element and its subtree. |
| `commit` |                                     | End of a render pass. Renderers may batch DOM work until this. |

Ops within a pass are ordered so that applying them sequentially is always
valid: an element is created before it is inserted, and parents exist before
children are inserted into them.

## Element kinds and props

| kind     | props | children |
|----------|-------|----------|
| `text`   | `text: string` | none |
| `vstack` | `spacing: number \| null`, `alignment: "leading" \| "center" \| "trailing"` | any |
| `hstack` | `spacing: number \| null`, `alignment: "top" \| "center" \| "bottom"` | any |
| `spacer` | `minLength: number \| null` | none |
| `button` | none | the label |
| `styled` | `style: Style` | exactly one |

`spacing: null` means "system default" (the renderer uses 8px).

### Style

Every field is optional. A `styled` element applies the style to the box
around its single child. Nested `styled` elements preserve SwiftUI modifier
order (`.padding().background(...)` is a `styled{background}` wrapping a
`styled{padding}` wrapping the content).

```jsonc
{
  "padding":      { "top": 16, "leading": 16, "bottom": 16, "trailing": 16 },
  "font":         { "size": 17, "weight": "ultraLight" | "thin" | "light" | "regular" | "medium" | "semibold" | "bold" | "heavy" | "black", "design": "default" | "serif" | "rounded" | "monospaced" },
                  // every font field is optional: {"weight":"bold"} changes only the weight
  "foreground":   Color,
  "background":   Color,
  "frame":        { "width": 200, "height": 44, "minWidth": 0, "maxWidth": "infinity", "minHeight": 0, "maxHeight": "infinity", "alignment": "center" },
                  // every frame field is optional; a max/min value is a number or the string "infinity";
                  // alignment is center | leading | trailing | top | bottom | topLeading | topTrailing | bottomLeading | bottomTrailing
  "cornerRadius": 12,
  "opacity":      0.5
}
```

### Color

Either an explicit sRGB color or a semantic name the renderer resolves for
the current color scheme.

```jsonc
{ "r": 0.0, "g": 0.478, "b": 1.0, "a": 1.0 }
{ "name": "primary" | "secondary" | "accent" | "systemBackground" | "secondarySystemBackground" | "clear", "a": 0.5 }
```

`a` on a semantic color is an optional opacity multiplier (`Color.secondary.opacity(0.5)`).

Named iOS palette colors (`red`, `orange`, `yellow`, `green`, `mint`, `teal`,
`cyan`, `blue`, `indigo`, `purple`, `pink`, `brown`, `gray`, `black`, `white`)
are emitted as explicit RGB by the Swift core using the iOS light-mode values.

## Events (renderer → Swift)

The Wasm module exports these C-ABI functions:

| export | meaning |
|--------|---------|
| `sb_event(id: i32)` | A `button` element with this id was tapped. Runs the action and a render pass; the resulting ops are appended to the pending buffer. |
| `sb_ops_ptr() -> i32`, `sb_ops_len() -> i32` | Address and byte length of the pending ops buffer in linear memory: a UTF-8 JSON **array** of ops. |
| `sb_ops_clear()` | Empty the pending buffer after the renderer has applied it. |

The module is a WASI command: the renderer instantiates it with a WASI shim,
calls `_start` (which runs the app's `main`, mounts the root view and performs
the first render pass into the pending buffer), then reads and applies the
buffer. After every `sb_event` call the renderer reads and applies the buffer
again.

## Headless mode

When the environment variable `SB_HEADLESS=1` is set, the app prints each op
as one JSON line to stdout instead of buffering. `SB_EVENTS` may contain a
script of events to replay after the first render, separated by commas or
line breaks, e.g. `tap:"Add", tap:"Add", tap:5`. Each event is echoed as
`{"op":"event","type":"tap","id":5}`, with the id its target resolved to,
before its ops. Keys in every JSON object are emitted in sorted order so the
output is byte-stable.

### Event scripts

An event's target is an element id or a **selector**, resolved against what
is on screen when the event is replayed. Ids shift whenever an element is
added above the target, so scripts that live in the repository
(`Examples/*/__snapshots__/events.txt`, the CI relaunch steps) use selectors:

| selector | names |
|----------|-------|
| `"Add"` | the element showing that text: a `text`, an image's `systemName` or `name`, a screen `title`, a control's `label` or `placeholder`, or an `accessibilityLabel` (a `Label`'s title is one, so an icon-only toolbar button matches its title, as in XCUITest) |
| `@save` | the element with `.accessibilityIdentifier("save")` |
| `[tabview]` | an element of that kind; with text (`[navscreen]"Detail"`) both must hold |
| `…#2` | the second of several matches, in document order |

A match resolves to the nearest element at or above it that has a handler
and suits the event: a `button`, `navlink`, `listrow` or `tapgesture` for
`tap` (so `tap:"Save"` reaches the button around its label), a `textfield`,
`searchfield` or `texteditor` for `text`, a `toggle`, `picker` or `tabview`,
`stepper`, `slider`, `datepicker`, `gesture` (`drag`, `longpress`), `listrow`
(`delete`) or `geometry` for the others. `[kind]` replaces that list.

Only what is on screen matches: the top `sheet` or `alert` when one is
presented, else everything except the screens a stack has pushed over and
the unselected tabs. A selector that matches nothing, or several elements
without `#n`, prints `{"op":"error","message":"tap:\"Edit\" matches 3 elements
(button 12, button 40, button 77); pick one with #1…#3"}` and the run exits
with status 1, so a script never taps the wrong element.

Two events need no target: `back` taps the visible screen's back button and
`dismiss` the top sheet or alert. `select:"Flavor":"Chocolate"` and
`select:[tabview]:"Settings"` name the option or tab instead of its index.
A value may be quoted (`text:"Name":"Smith, Jo"`), and a line whose first
character is `#` is a comment:

```
# Add a todo and open it
text:"New todo":Walk the dog
tap:"Add"
tap:"Walk the dog"
toggle:"Done":true
back
```

---

# Phase 2 additions

Everything above still holds. Phase 2 adds element kinds, a JSON event
channel from the renderer to Swift, an environment event, and a state
snapshot used by hot reload.

## New element kinds

| kind          | props | children |
|---------------|-------|----------|
| `zstack`      | `alignment: Alignment` (same names as `frame.alignment`) | any; later children draw on top |
| `image`       | `systemName: string` | none |
| `divider`     | none | none |
| `scrollview`  | `axes: "vertical" \| "horizontal" \| "both"` | any |
| `list`        | none | rows, or `section`s |
| `section`     | `header: string \| null`, `footer: string \| null` | rows |
| `textfield`   | `text: string`, `placeholder: string`, `style: "plain" \| "roundedBorder"`, `secure: bool` | none |
| `toggle`      | `isOn: bool` | the label |
| `navstack`    | none | `navscreen`s, bottom to top; only the last is visible |
| `navscreen`   | `title: string`, `displayMode: "automatic" \| "large" \| "inline"`, `depth: number` | the screen content |
| `navlink`     | none | the label |

Notes for renderers:

- `image`: `systemName` is an SF Symbol name. Render the closest glyph from
  an open icon set at the current font size and color (`1em`), and a visible
  placeholder for unknown names. The Swift side never sends pixels. (The web
  renderer maps about 570 names to lucide glyphs in `Web/src/symbols.ts`; an
  unlisted name is drawn as its closest listed base, dropping a `.badge…`
  suffix and then trailing parts, with filled forms first, so
  `person.crop.circle.badge.questionmark` draws as `person.crop.circle`. A
  `.slash` variant never falls back to its base, which would mean the
  opposite. SF Symbols themselves are licensed for Apple platforms only, so
  they are not used.)
- `list`: iOS inset-grouped style. Every direct child that is not a `section`
  is one row. Rows get separators and the standard 44pt minimum height.
  `navlink` and `toggle` inside a list render as full-width rows (a `navlink`
  row shows a trailing chevron).
- `navstack`: push and pop are expressed purely as children being inserted
  or removed at the end. The renderer animates the slide and draws the
  navigation bar: the title (large for `automatic` at depth 0 and for
  `large`, inline otherwise) and, for `depth > 0`, a back button labelled
  with the previous screen's title. Tapping back sends a `tap` event with
  the **`navscreen`'s own id**; Swift pops to it.
- `navlink`: a tap on it (its own id) pushes. Inside a list it is a row.
- `textfield`: when an `update` carries the same `text` the field already
  shows, leave the caret alone.

### `button` props (changed)

`button` now carries `style: "automatic" | "plain" | "bordered" | "borderedProminent"`
and `role: "destructive" | null`. `bordered` is a gray capsule, `borderedProminent`
a filled accent capsule, `automatic` and `plain` are text-only.

### `styled` style (added fields)

| field | meaning |
|-------|---------|
| `disabled: bool` | dims the subtree and blocks input (`opacity: 0.4`, `pointer-events: none`) |

## JSON events (renderer → Swift)

Taps keep using `sb_event(id)`. Everything else goes through a JSON event:

| export | meaning |
|--------|---------|
| `sb_alloc(len: i32) -> i32` | Allocate `len` bytes in linear memory for the renderer to write into. Ownership passes to Swift on the next `sb_event_json` call. |
| `sb_event_json(ptr: i32, len: i32)` | Deliver one UTF-8 JSON event written at `ptr`. Swift frees the buffer, handles the event, renders, and appends ops to the pending buffer (read it as after `sb_event`). |

Event shapes:

```jsonc
{ "type": "tap",         "id": 12 }                       // same as sb_event(12)
{ "type": "text",        "id": 7, "value": "Milk" }       // textfield input
{ "type": "toggle",      "id": 9, "value": true }         // toggle changed
{ "type": "environment", "colorScheme": "dark" }          // device appearance changed
```

The renderer sends `environment` once after `_start` (before applying the
first ops is fine) and again whenever the theme toggle changes. Swift
re-renders views that read `@Environment(\.colorScheme)`.

In headless mode `SB_EVENTS` accepts the same events in a compact form:
`tap:12`, `text:7:Milk`, `toggle:9:true`, `env:dark`, where the id may be a
selector (`tap:"Add"`, `text:"New todo":Milk`; see "Event scripts" above).
Each is echoed as `{"op":"event",...}` with the same fields as the JSON event.

## State snapshot (hot reload)

| export | meaning |
|--------|---------|
| `sb_state_ptr() -> i32`, `sb_state_len() -> i32` | A JSON object describing the current `@State` values that can be restored: `{ "<path>": value, ... }`, where `path` identifies a mounted view by its position and type, and `value` is a number, string or bool. Only such values are included. |

To restore, the renderer passes the same JSON in the WASI environment
variable `SB_STATE` when instantiating the next build of the module. The
runtime applies the values when it first mounts a view whose path matches
and whose state type matches; everything else starts fresh. Mismatches are
ignored silently, so a reload across a source change that reshapes the
tree simply loses the state that no longer fits.

The dev server (`npm run dev`) watches `Sources/` and `Examples/`, rebuilds
the Wasm module with `scripts/build-wasm.sh`, and reloads the page. Before
reloading, the page stores the snapshot in `sessionStorage` and feeds it back
through `SB_STATE` on boot.

---

# Phase 3 additions

Phase 3 moves layout out of CSS flexbox and into a SwiftUI-style layout
engine inside the renderer, and adds animation, sheets and Dynamic Type.
The Swift side keeps sending the same element tree; the renderer now
computes a frame for every element.

## Font (changed)

Text styles are no longer resolved to point sizes by Swift, so the renderer
can scale them for Dynamic Type:

```jsonc
{ "textStyle": "largeTitle" | "title" | "title2" | "title3" | "headline" | "subheadline" | "body" | "callout" | "footnote" | "caption" | "caption2",
  "size": 64,                 // fixed size instead of a text style (does not scale)
  "weight": "...", "design": "...",
  "relativeTo": "body" }      // a fixed size that scales like this text style
```

Every field is optional; `size` and `textStyle` are mutually exclusive.
`headline` implies `semibold` unless `weight` is given. The renderer resolves
a text style for the current `dynamicTypeSize` with Apple's Dynamic Type
table (Large is the default: largeTitle 34, title 28, title2 22, title3 20,
headline 17, body 17, callout 16, subheadline 15, footnote 13, caption 12,
caption2 11) and uses the matching iOS line heights.

## New element kinds

| kind    | props | children |
|---------|-------|----------|
| `color` | `color: Color` | none; fills its proposal |
| `shape` | `shape: "rectangle" \| "roundedRectangle" \| "circle" \| "capsule" \| "ellipse"`, `cornerRadius: number`, `fill: Color \| null`, `stroke: { "color": Color, "lineWidth": n } \| null` | none; fills its proposal, `fill: null` means the current foreground color |
| `sheet` | `detents: ["medium", "large"]` | the sheet content |

A `sheet` is always a **root-level** element (a child of id 0) placed after
the app's tree; the Swift side appends it while it is presented and removes
it when dismissed. A `tap` on the sheet's own id is a dismiss request (scrim
tap or drag down); Swift then flips the binding and removes the element.

## `styled` style (added fields)

| field | meaning |
|-------|---------|
| `layoutPriority: number` | stack space distribution priority (default 0) |
| `lineLimit: number \| null` | maximum lines of text in the subtree; extra text is truncated with an ellipsis |
| `multilineTextAlignment: "leading" \| "center" \| "trailing"` | line alignment for wrapped text |
| `fixedSize: { "horizontal": bool, "vertical": bool }` | the child is proposed `nil` on the fixed axes and takes its ideal size |
| `animation: Animation`, `animationToken: number` | `.animation(_:value:)`: whenever `animationToken` changes in an `update`, the layout and style changes of this subtree in that commit animate with `animation` |
| `transition: Transition` | how this subtree appears and disappears inside an animated commit |
| `labelIcon: true` | the box is a `Label`'s icon: inside a `list` it takes the tint, while the row's label (a `navlink` row's too, however it is wrapped) keeps the primary color; an explicit foreground on or around it wins |

```jsonc
Animation:  { "type": "default" | "linear" | "easeIn" | "easeOut" | "easeInOut" | "spring",
              "duration": 0.35, "bounce": 0.0, "delay": 0.0 }
Transition: { "kind": "opacity" | "scale" | "slide" | "move" | "identity", "edge": "top" | "leading" | "bottom" | "trailing", "scale": 0.5 }
            // or an array of these, combined
```

## `commit` (changed)

A commit may carry a transaction animation from `withAnimation`:

```jsonc
{ "op": "commit", "animation": { "type": "easeInOut", "duration": 0.35 } }
```

In an animated commit the renderer animates every frame and style change of
the pass, inserted elements transition in and removed elements transition
out (keeping a visual copy until the transition ends). Without `animation`
on the commit, only subtrees whose `animationToken` changed animate.

## Environment event (extended)

```jsonc
{ "type": "environment", "colorScheme": "dark", "dynamicTypeSize": "xLarge" }
```

`dynamicTypeSize` is one of `xSmall`, `small`, `medium`, `large`, `xLarge`,
`xxLarge`, `xxxLarge`, `accessibility1` … `accessibility5`. Either field may
be omitted. Headless replay: `env:dark`, `env:light`, `type:xLarge`.

## Layout engine semantics

The renderer lays the tree out the way SwiftUI does: a parent **proposes** a
size to each child, the child **reports** the size it wants, and the parent
**places** it. A proposal has an optional width and height; `null` means
"whatever you want", `0` asks for the minimum and `Infinity` for the
maximum. The root is proposed the screen's safe area and centered in it.
Frames are in screen coordinates; the renderer positions every element
absolutely from the computed frame.

| kind | size for a proposal | placement |
|------|---------------------|-----------|
| `text` | measured with the resolved font. Width proposed: wrap greedily at word boundaries to that width (never narrower than the longest word unless `lineLimit` truncates); unspecified width: one line. Height = lines × line height. | lines aligned by `multilineTextAlignment` |
| `image` | a square of the font's line height (symbol glyphs scale with the font) | |
| `spacer` | along its stack's axis: minimum `minLength` (default 8), maximum the proposal; 0 on the cross axis. Outside a stack it fills both axes. | |
| `divider` | height 1 (0.5 on 2× displays), width = proposal (in an `hstack`: width 1, height = proposal) | |
| `vstack` / `hstack` | SwiftUI's stack algorithm: measure each child's minimum and maximum along the axis (proposals 0 and Infinity) to get its flexibility; hand out the remaining space to children in order of **highest layoutPriority first, then least flexible first**, proposing `remaining / childrenLeft` to each; sum the results plus `spacing` (default 8) between children; cross size = largest child. | children in order along the axis, cross-aligned by `alignment` |
| `zstack` | proposes its size to every child; size = the union of children | all children aligned by `alignment` |
| `styled` padding | proposes (proposal − insets) to the child; size = child + insets | child inset |
| `styled` frame | fixed `width`/`height` are proposed to the child and reported as is. Flexible `min`/`max`: the proposal is clamped and proposed; the reported size is the clamped proposal when one was given, else the child's size clamped. `maxWidth: "infinity"` therefore fills the proposal. | child aligned by `frame.alignment` inside the frame |
| `styled` other | pass-through; `font`, `foreground` and text settings change the context the subtree is measured with | |
| `button` | `automatic`/`plain`: the label's size. `bordered`/`borderedProminent`: label + padding 7 vertical, 14 horizontal | |
| `toggle` | width = proposal width (the switch is 51×31 at the trailing edge, the label leads); height = max(label, 31) | |
| `textfield` | width = proposal width; height 34 (`roundedBorder`) or 22 (`plain`) | |
| `color` / `shape` | the proposal (nil axes → 10, like SwiftUI's default for unconstrained shapes) | |
| `scrollview` | fills its proposal; proposes `null` to its content along the scroll axes and its own size on the others | content at the scroll offset |
| `list` | fills its proposal and scrolls. Inset grouped: sections inset 16 with 10px corners; rows are proposed the row width minus 16 padding on each side, height = max(content, 44); section headers 13pt uppercase with 16px top and 6px bottom padding; 35px between sections | |
| `navstack` | fills the whole screen (ignores safe areas) | the top screen |
| `navscreen` | the navigation bar occupies the status bar height plus 44 (plus 52 for a large title); the content is proposed the rest | |
| `sheet` | `large`: screen height − 10 − status bar; `medium`: half the screen; the content is proposed the sheet size minus the grabber row | slides up from the bottom; the presenting tree scales to 0.92 and dims behind a `large` sheet |

Device pixel snapping: frames are rounded to whole CSS pixels after
placement, consistently (origins floor, sizes round), so adjacent elements
never overlap by a fraction.

---

# Phase 4 additions

Phase 4 adds tabs, pickers, forms, value-based navigation, a host-driven
executor so `Task`, `.task` and sleeps reach the screen, and
`GeometryReader`.

## New element kinds

| kind       | props | children |
|------------|-------|----------|
| `tabview`  | `selected: number` (index) | `tab`s |
| `tab`      | `title: string`, `systemImage: string \| null` | the tab's content |
| `picker`   | `style: "segmented" \| "menu"`, `label: string`, `options: string[]`, `selected: number` | none |
| `geometry` | none | exactly one |

- `tabview` fills the screen. The tab bar is 49pt tall plus the bottom safe
  area, drawn at the bottom with each `tab`'s icon and title (10pt text,
  accent color when selected, secondary otherwise). Only the selected `tab`'s
  content is laid out and visible; the others stay in the tree so their
  state survives. Content is proposed the screen minus the tab bar. Tapping
  a bar item sends a `select` event with the `tabview`'s id and the index.
- `picker` with `segmented` style is an iOS segmented control (height 32,
  width = proposal, equal segments, the selected one raised on a white /
  dark-gray pill); with `menu` style it is a row with the `label` at the
  leading edge and the selected option plus a `chevron.up.chevron.down`
  glyph at the trailing edge (height 44 in a list row, 34 elsewhere);
  tapping it opens a simple menu of the options anchored to the control, and
  choosing one sends a `select` event with the picker's id and the index.
- `list` props gain `style: "plain" | "insetGrouped" | "grouped"`. `Form`
  is a `list` with `grouped` style: full-width sections with hairlines
  instead of inset cards.
- `geometry` is transparent for layout (it fills its proposal, like SwiftUI's
  `GeometryReader`, and proposes that size to its child, aligned top
  leading). After every layout pass the renderer sends
  `{ "type": "geometry", "id": N, "width": w, "height": h }` for each
  `geometry` element whose size changed since it last reported; Swift
  re-renders the content with the new size.

## Events (added)

```jsonc
{ "type": "select",   "id": 12, "value": 1 }                 // tab or picker selection
{ "type": "geometry", "id": 7,  "width": 393, "height": 120 }
```

Headless: `select:12:1`, `geometry:7:393x120`.

## Executor (added exports)

Swift concurrency on Wasm needs the host to drive it. The module installs a
cooperative executor; jobs created by `Task`, `.task`, `Task.sleep` and
`ContinuousClock` wait until the host calls in:

| export | meaning |
|--------|---------|
| `sb_run_jobs(now_ms: f64) -> f64` | Sets the executor's clock to `now_ms` (milliseconds, any monotonic origin), runs every queued job and every timer that is due, renders if state changed (ops land in the pending buffer, read it afterwards), and returns the milliseconds until the next timer, or `-1` when none is pending. |

The renderer calls `sb_run_jobs` after `_start`, after every event it
delivers, and then whenever the returned delay elapses (via `setTimeout`,
clamped to at least 4 ms). It reads and applies the pending ops after each
call.

Headless mode drives the same executor with a virtual clock: after replaying
`SB_EVENTS`, if `SB_RUN_MS` is set the runtime repeatedly runs jobs and jumps
the clock to the next timer until no timer remains or the clock passes
`SB_RUN_MS`, printing ops as usual. This makes timer-driven apps
deterministic in snapshots.

---

# Phase 5 additions

Phase 5 adds Xcode asset catalogs: images and colors a SwiftUI app refers to
by name (`Image("dough/plain-full")`, `Color("dough/plain-bg")`), plus
`.aspectRatio` / `.scaledToFit()` / `.scaledToFill()`. The Swift side keeps
sending names only; the web side turns every `*.xcassets` under
`Examples/<App>/` into a manifest the page loads before the app.

## Asset catalog manifest

`Web/plugins/asset-catalogs.ts` serves `/catalog/<App>.json` (dev server and
build) and the image files at `/catalog/<App>/<name>.<ext>`, where `name` has
every `/` of a namespace replaced by `__` (`dough__plain-full.png`). The page
fetches the manifest before loading the Wasm module; a 404 or a broken
manifest means "no catalog" and never an error.

```ts
interface AssetCatalog {
  images: Record<string, { src: string; width: number; height: number; scale: number; kind: 'image' | 'symbol' }>;
  colors: Record<string, { light: string; dark?: string }>;
}
```

- Keys are catalog names. A folder whose `Contents.json` has
  `"properties": { "provides-namespace": true }` prefixes its contents with
  `<folder>/` (nested folders chain); other folders add nothing.
- `*.imageset`: the `2x` entry is published when present, else `1x`, else
  `3x`, else the first entry with a file (no scale → 1). `width`/`height`
  are **points** (pixels ÷ `scale`); PNG, JPEG and SVG are measured, other
  formats are skipped with a warning. `kind: "image"`.
- `*.symbolset`: the SVG is published as `kind: "symbol"` (a template image;
  size irrelevant).
- `*.colorset`: `light` is the entry without appearances, `dark` the one
  with `luminosity: dark`; idiom and contrast variants are ignored.
  Components may be hex (`"0xC2"`), floats (`"0.439"`) or 0…255 integers
  (`"255"`); the values are CSS `rgba(r, g, b, a)` strings.

## `image` props (changed)

`image` props are now one of two forms:

```jsonc
{ "systemName": "checkmark.circle.fill" }        // an SF Symbol, as before
{ "name": "dough/plain-full", "resizable": false } // an asset catalog image
```

| catalog entry | size for a proposal | rendering |
|---------------|---------------------|-----------|
| `kind: "image"`, `resizable: false` | always the intrinsic point size | `<img src alt="" draggable="false">` filling the frame (`object-fit: fill`; fitting is `aspectRatio`'s job) |
| `kind: "image"`, `resizable: true` | `width: proposal.width ?? intrinsic`, `height: proposal.height ?? intrinsic` (fills what is proposed, like SwiftUI; `Infinity` passes through so it is maximally flexible in stacks) | same |
| `kind: "symbol"` | a square of the font's line height, like an SF Symbol | a span with `mask-image: url(src)` filled with `currentColor` (tints like a symbol) |
| not in the manifest | 44×44 | the dashed placeholder unknown symbols get, with the name in `title` |

## `styled` style (added field)

| field | meaning |
|-------|---------|
| `aspectRatio: { "ratio": number \| null, "contentMode": "fit" \| "fill" }` | `.aspectRatio(_:contentMode:)`; `.scaledToFit()` is `{ ratio: null, contentMode: "fit" }`, `.scaledToFill()` the `fill` form |

Layout: let the child's proposal (after the frame, padding and fixedSize
steps of the same `styled`, which nest as frame > padding > fixedSize >
aspectRatio > child) be `(pw, ph)`, where either may be nil or infinite.
When `ratio` is null the child is measured with a nil proposal and its ideal
width ÷ height is the ratio (1 when degenerate). Then:

- both finite: `fit` → the largest size with that ratio inside `(pw, ph)`;
  `fill` → the smallest size with that ratio covering it;
- one finite: the other axis is derived from the ratio;
- both infinite: Infinity × Infinity (a flexible child fills an unbounded box,
  so a custom layout probing the maximum sees it as flexible);
- neither otherwise: the child's ideal size.

That size is proposed to the child and the child's **reported** size is what
the `styled` box reports, for `fill` too (clipping is a visual concern:
SwiftUI needs `.clipped()` as well). The child is centered (or aligned by
`frame.alignment` when the same box has a frame).

## Color (added form)

```jsonc
{ "named": "dough/plain-bg", "opacity": 1.0 }
```

A `.colorset` of the catalog, resolved for the current color scheme.
`opacity` (0…1) multiplies the catalog alpha. The renderer declares every
catalog color as a `--sb-asset-color-<slug>` custom property under
`.device[data-theme="light"]` and `.device[data-theme="dark"]` (the slug is
the name with `/` → `__` and anything but `[A-Za-z0-9_-]` → `_`; a colorset
without a dark variant keeps its light value) and renders a named color as
`var(--sb-asset-color-<slug>, magenta)`, wrapped in `color-mix(... ,
transparent)` when `opacity` < 1. A name the catalog lacks therefore shows
up magenta.

## Transition addition

`transition` parts gain `{ "kind": "offset", "x": number, "y": number }`
(`AnyTransition.offset(x:y:)`): the view enters from, and exits to, that
translation in points. Combines with the other parts like `move`.

---

# Phase 6 additions

Phase 6 runs the app-level screens of Apple's Food Truck sample: custom
`Layout`s evaluated on the Swift side with host-measured subviews, `Grid`,
`LazyVGrid`, `Gauge`, overlays and backgrounds with views, menus, toolbars,
search fields, badges and row insets, shape styles (hierarchical levels,
gradients, materials) and visual effects. The three sub-sections were written
with their implementations.

## Phase 6: layout containers

Phase 6 adds SwiftUI's `Grid`, `LazyVGrid`, `Gauge`, `.overlay { }` /
`.background { }` with a view, `.position(x:y:)`, `.tint` and the `Layout`
protocol: a custom layout is evaluated on the Swift side with subviews the
renderer measures. Everything in `docs/ops-protocol.md` still holds; the
renderer lays these kinds out like the others (propose / report / place) and
positions every child absolutely from its frame.

### New element kinds

| kind        | props | children |
|-------------|-------|----------|
| `layout`    | `proposal: Proposal \| null`, `size: {width, height} \| null`, `frames: [{x, y, width, height}] \| null`, `sizes: {min, ideal, max} \| null` (each `{width, height}`, a dimension may be `"infinity"`) | the subviews, in order |
| `grid`      | `horizontalSpacing: number \| null`, `verticalSpacing: number \| null`, `alignment: Alignment` | `gridrow`s, or any other element (one full-span cell) |
| `gridrow`   | `alignment: "top" \| "center" \| "bottom" \| "firstTextBaseline" \| "lastTextBaseline" \| null` | the cells |
| `lazyvgrid` | `columns: GridItem[]`, `spacing: number \| null`, `alignment: Alignment` | the cells (already flattened) |
| `gauge`     | `value: number` (0…1), `style: "automatic" \| "linearCapacity" \| "accessoryLinear" \| "accessoryCircular" \| "accessoryCircularCapacity"`, `hasLabel: bool`, `hasCurrentValueLabel: bool` | the label (when `hasLabel`), then the current value label (when `hasCurrentValueLabel`) |
| `decorated` | `role: "overlay" \| "background"`, `alignment: Alignment` | exactly `[content, decoration]` |

`Alignment` is the `frame.alignment` set (`center`, `leading`, `trailing`,
`top`, `bottom`, `topLeading`, `topTrailing`, `bottomLeading`,
`bottomTrailing`). `spacing: null` means the system default, 8.

```jsonc
Proposal: { "width": 393 | "infinity" | null, "height": 759 | "infinity" | null }
          // null is a nil axis ("whatever you want"), "infinity" the maximum

GridItem: { "size": { "kind": "fixed", "value": 80 }
                  | { "kind": "flexible", "minimum": 10, "maximum": "infinity" }
                  | { "kind": "adaptive", "minimum": 100, "maximum": "infinity" },
            "spacing": 8 | null,          // to the next column (default 8)
            "alignment": Alignment | null } // for the cells of this column (default: the grid's)
```

The renderer gives each a `div` with the kind class (`sb-layout`, `sb-grid`,
`sb-gridrow`, `sb-lazyvgrid`, `sb-gauge`, `sb-decorated`). The gauge owns two
chrome nodes, `.sb-gauge-track` and `.sb-gauge-fill`, positioned from its own
box; they carry no `data-sb-id`.

### `styled` style (added fields)

| field | meaning |
|-------|---------|
| `gridCellColumns: number` | `.gridCellColumns(_:)`: the cell spans this many `grid` columns (default 1). The `styled` is the direct child of the `gridrow`. |
| `gridCellAnchor: { "x": 0…1, "y": 0…1 }` | `.gridCellAnchor(_:)`: unit point the cell is aligned by inside its cell box (default: the row's vertical and the grid's horizontal alignment) |
| `tint: Color` | `.tint(_:)` (same JSON forms as `foreground`): sets the CSS variable `--sb-tint` on the box; gauge fills, switched-on toggles and `borderedProminent` / plain button colors below read `var(--sb-tint, …)` |
| `position: { "x": number, "y": number }` | `.position(x:y:)`: the box takes the whole proposal (the child's ideal size on nil axes) and places the child's center at (x, y) inside it; the child is measured with a nil proposal |

### Layout rules

#### `layout` (custom `Layout`)

- **Measure**: `size` for the proposal it answers (`proposal`, compared within
  the 2-decimal rounding). For any other proposal the element answers from
  `sizes`, the layout's own `sizeThatFits` for a zero, unspecified and infinite
  proposal that Swift sends with every answer: per axis, a nil proposal gives
  `ideal`, anything else is clamped to `min` … `max` (what
  `LayoutSubview.sizeThatFits` does on the Swift side), until Swift answers
  that proposal in a later pass. Before Swift's first answer the element is
  flexible: it fills a finite proposal (Infinity stays Infinity) and takes the
  union of the children's ideal sizes on nil axes. Guessing flexibility from
  the answered size instead made a layout nested in another custom layout
  look rigid at a stale size, and the two then traded answers forever.
- **Place**: when `frames` has one entry per child, child *i* is proposed
  `frames[i]`'s width and height and gets exactly that frame, relative to the
  element's origin (nothing is re-measured beyond that). Otherwise the children
  are proposed the element's size and centered in it, like a `zstack`.
- After every layout pass the renderer builds, for each `layout` element that
  has a frame (hidden tabs are skipped, like `geometry`), the record

  ```jsonc
  { "type": "layout", "id": 12,
    "proposal": { "width": 393, "height": 759 },            // the proposal the element was *placed* with
    "children": [                                            // one per child, in order
      { "min":   { "width": 10.2, "height": 66 },            // measured with { width: 0, height: 0 }
        "ideal": { "width": 30.6, "height": 22 },            // measured with { width: null, height: null }
        "max":   { "width": "infinity", "height": "infinity" } } ] }  // measured with Infinity × Infinity
  ```

  Numbers are rounded to 2 decimals; a nil proposal axis is `null` and an
  unbounded one (a `Color`, a `Spacer`, `maxWidth: "infinity"`) is the string
  `"infinity"`. `min`, `ideal` and `max` are the engine's own answers to those
  three proposals (a `text` proposed width 0 breaks per character, as in a
  stack). The event goes through the same channel as `geometry` events, **only
  when the JSON of the record differs from the last one sent for that id**; the
  per-id cache is cleared when the element is removed. Swift answers in a later
  pass with an `update` carrying `proposal`, `size`, `frames` and `sizes`,
  which lays the tree out again; an unchanged record sends nothing, so the
  loop converges. A cycle guard covers the remaining case of nested layouts
  trading answers: a record already sent for that id within the last second is
  not sent again (a `[sb] layout N: measurements cycle` warning is logged once
  per id) and Swift's latest answer stands; after the window the same record
  counts as data that changed back and is sent. The renderer exposes the last
  sent records as `renderer.reportedLayouts` (a `Map<id, event>`).

#### `grid` / `gridrow`

- Each `gridrow` child is a row whose children are the cells; any other child
  of the grid is a row with one cell spanning every column. A cell's span is
  its `gridCellColumns` (read through its `styled` wrappers), default 1.
- Column count = the maximum over rows of Σ spans (at least 1).
- A column is **flexible** when it has no non-spanning cells or all of its
  non-spanning cells are flexible, i.e. report a width ≥ the proposal width
  when proposed `{ width: proposal width (Infinity when nil), height: null }`.
  Other columns are fixed at the largest ideal width (nil proposal) of their
  non-spanning cells.
- With a finite proposal width the flexible columns share, equally and at
  least 0, what is left after the fixed columns and `horizontalSpacing` ×
  (columns − 1). Without one they take their cells' largest ideal width.
- A cell's box width = the widths of its spanned columns + `horizontalSpacing`
  between them; a full-span cell gets the whole grid width. Row height =
  max over the row's cells of `measure(cell, { width: cell width, height:
  null }).height`.
- Grid size = (Σ column widths + spacing, Σ row heights + spacing). The grid
  does not fill a proposal that has no flexible column.
- Placement: rows top to bottom, cells left to right; each cell is aligned in
  its box by `gridCellAnchor`, else horizontally by the grid `alignment` and
  vertically by the row `alignment` (`top` / `firstTextBaseline` → 0, `center`
  → 0.5, `bottom` / `lastTextBaseline` → 1; `null` → the grid alignment's
  vertical anchor). Every `gridrow` gets a frame (the grid width × its row
  height).

#### `lazyvgrid`

Columns are resolved for the proposal width `W`:

- `fixed` → `value`.
- With `W` finite: `R = W − Σ fixed − Σ gaps`, where the gap after item *i*
  is that item's `spacing` (default 8) and there is one gap between
  consecutive items. Flexible and adaptive items share `R` equally;
  `flexible` → that share clamped to `[minimum, maximum]`; the width left
  after the flexible items, `W'`, is split equally between the adaptive
  items, each of which becomes `n = max(1, floor((W' + s) / (minimum + s)))`
  columns of `(W' − (n − 1)·s) / n` clamped to `[minimum, maximum]`, `s`
  being the item's spacing (also used between its columns).
- With `W` nil: `flexible` and `adaptive` → one column of the cells' largest
  ideal width clamped to `[minimum, maximum]`.
- An empty `columns` list is one flexible column (minimum 10).

Cells fill rows left to right, one per resolved column; row height = max cell
height measured with `(column width, null)`. Size = (`W`, or the columns plus
gaps when `W` is nil; Σ row heights + `spacing` (default 8) between rows).
When the columns are narrower than `W` they are offset inside it by the grid
`alignment`'s horizontal anchor. Each cell is aligned in its box (column
width × row height) by the column item's `alignment`, else the grid's. All
cells render; nothing is lazy.

#### `gauge`

- Width = the proposal width (100 when nil); height = 4 + (label row + 4 when
  there is a label or a current value label). The label row is the taller of
  the two labels measured in the `caption` text style with the gauge width.
- The label is placed leading and the current value label trailing on the
  same row (centered vertically in it); the bar (chrome `track`, 4px, rounded,
  `var(--sb-color-fill)`) is below them, and chrome `fill` is the leading
  `value` share of the track, colored `var(--sb-tint, var(--sb-color-accent))`.
  `value` is clamped to 0…1.
- Circular styles (`accessoryCircular`, `accessoryCircularCapacity`) render as
  linear for now.
- The element has `role="meter"` with `aria-valuenow`, and `data-sb-style`.

#### `decorated`

Size = the content measured with the proposal. The decoration is proposed the
content's size, aligned inside it by `alignment` and placed with that
proposal; it never affects the size. For `overlay` the decoration (the later
child) draws above the content; for `background` the content is raised
(`z-index: 1`, the box is an isolated stacking context) so the decoration
draws below it.

#### `position`

In the `styled` nesting (frame > padding > fixedSize > aspectRatio > child) the
positioned box replaces the child step: it reports the proposal it is handed
(the child's ideal size on nil or infinite axes) and places the child, measured
with a nil proposal, with its center at (x, y) inside that box.

### Events (added)

```jsonc
{ "type": "layout", "id": 12, "proposal": { "width": 393, "height": null },
  "children": [ { "min": {...}, "ideal": {...}, "max": {...} } ] }
{ "type": "batch", "events": [ { "type": "geometry", ... }, { "type": "layout", ... } ] }
```

Sent through `sb_event_json` like `geometry`; see "`layout`" above for when.
When a layout pass produces more than one `geometry` / `layout` event the
renderer sends them as one `batch`: Swift applies every event in it and
renders once, instead of once per element (a screen with eight custom layouts
otherwise re-rendered eight times per pass). A batch is never nested.
`sb_run_jobs` runs once after the batch as after any event.

## Phase 6: menus, toolbars, search, tap gestures and list row hints

Everything in `docs/ops-protocol.md` still holds. This document is the
renderer-side contract for the Phase 6 chrome features: the exact encodings
the Swift side emits and the layout rules the web renderer (`Web/`) applies.

### New element kinds

| kind          | props | children |
|---------------|-------|----------|
| `menu`        | `{}` | `[label, ...items]` |
| `toolbar`     | `placement: "trailing" \| "leading" \| "principal" \| "bottom"` | the items (buttons, menus, navlinks, texts, images) |
| `searchfield` | `text: string`, `placeholder: string` (default `"Search"`) | none |
| `tapgesture`  | `{}` | exactly one |

### `picker` style (added)

`style` gains `"inline"`: `Picker` with `.pickerStyle(.inline)`, or the
default style of a `Picker` inside a `Menu`.

### `styled` style (added fields)

| field | meaning |
|-------|---------|
| `badge: string \| null` | `.badge(_:)`; only meaningful on a list row (a direct child of a `list`, or of a `section` in a list) |
| `listRowInsets: { "top", "leading", "bottom", "trailing" }` | `.listRowInsets(_:)`; replaces the row's default insets for that row |
| `imageScale: "small" \| "medium" \| "large"` | `.imageScale(_:)`; symbol images in the subtree scale by 0.8 / 1 / 1.25 |

### `navscreen` children (changed)

A `navscreen` had exactly one child, its content. It now has
`[content, ...extras]`, where every extra is a `toolbar` or a `searchfield`.
The renderer partitions the children by kind: the first child that is not an
extra is the content (several such children form an implicit `VStack`, as
before).

### Events

Nothing new. `tapgesture` and the rows of a `menu` popover send the existing
events:

```jsonc
{ "type": "tap",    "id": 12 }              // a tapgesture; a button or navlink item of a menu
{ "type": "select", "id": 9, "value": 1 }   // an inline picker row, or a picker item in a menu popover
{ "type": "text",   "id": 7, "value": "Mi" } // searchfield input, exactly like textfield
```

---

### Layout and rendering rules

#### `menu`

- **Size**: the label's (children[0]) size for the proposal. The items are
  never laid out in place: they have no frames, are listed in
  `LayoutResult.hidden`, and the DOM gives them the `hidden` attribute (plus
  a CSS rule hiding every child of `.sb-menu` but the first), so they never
  show inline.
- **Control**: the element is `role="button"`, `aria-haspopup="menu"`,
  focusable, drawn in the accent color like a plain button. Enter / Space
  activate it.
- **Popover**: a tap opens the same popover a `menu` picker opens (250 wide,
  `--sb-color-menu-background`, 13px radius, scrim-less; a click anywhere
  outside closes it and is swallowed, Escape closes it), anchored with its
  trailing edge on the label's trailing edge, 4 below the label when it fits,
  else 4 above, clamped 8 from the screen edges. The rows are built from the
  **current** item children each time it opens, in order:
  - `button` / `navlink` → a 44pt row (`role="menuitem"`) showing the
    subtree's text (all `text` descendants joined with spaces) and, trailing,
    its first `image` with a `systemName`; a `role: "destructive"` button is
    red; a `styled { disabled: true }` wrapper disables the row. Clicking sends
    `{ "type": "tap", "id": <the button/navlink id> }` and closes the popover.
  - `picker` (any style) → one 44pt `menuitemradio` row per `options[i]`
    with a checkmark on `selected`, as a group separated from its neighbours
    by an 8pt band. Clicking sends `{ "type": "select", "id": <picker id>,
    "value": i }` and closes the popover.
  - `divider` → an 8pt separator band (never doubled).
  - `section` → its `header` as a 32pt dim caption, then its children as rows.
  - `text` → a 44pt static (disabled) caption row.
  - a nested `menu` → its label text as a caption, then its items.
  - any other container → its children, in order.
- An `update` or `remove` of the menu or of anything inside it closes an
  open popover (the popover reflects what the items were when it opened).

#### `picker` style `inline`

- **Size**: height = (number of options + 1 if `label` is non-empty) × 44;
  width = the proposal, or the widest row (label width, or option width +
  28 for the checkmark and its gap) when none is given.
- **Rows**: the label, when non-empty, is a leading dim (secondary color)
  caption row; each option is a row with the title leading and an accent
  checkmark trailing on the selected one (`role="radio"`,
  `aria-checked`). Hairlines separate the rows.
- **In a list / form**: the picker is one row of its section whose height is
  options × 44 (no extra vertical padding, like a `menu` picker row), and its
  rows span the full list row width with the usual 16pt content inset, so
  they read as rows of that section. Elsewhere the rows span the control's
  frame.
- Tapping a row sends `select` with its index (the checkmark moves at once;
  Swift's `update` confirms or reverts it).

#### `toolbar` in a `navscreen`

- Items are measured at their **ideal size** (a nil proposal) and laid out
  as an hstack with 16pt spacing, vertically centered in the 44pt bar row
  (the row under the status bar, also for a large title). The `toolbar`
  element's own frame is the group's rect; the items are ordinary elements
  with ordinary frames, so buttons, menus and navlinks inside work as
  anywhere else (the bar never swallows their clicks).
- `trailing`: the group ends 16pt from the trailing edge; several trailing
  toolbars stack leftwards, 16pt apart. `leading`: the group starts 8pt after
  the back button (16pt from the leading edge when there is none).
  `principal`: the group is centered in the row and the inline title is not
  drawn. `bottom`: the toolbar's frame is a bar of 49pt plus the bottom safe
  area (when the screen reaches the screen bottom) at the bottom, with the
  tab bar's translucent background and hairline; its items are spread
  evenly across it (16pt from the edges, equal gaps, a single item
  centered) and vertically centered in the 49pt row. The screen content is
  proposed the area **above** a bottom bar.
- The inline title is centered and truncated to the room the back button and
  the leading / trailing items leave: `chrome.title` is a rect in the 44pt
  row, no wider than 56% of the bar and no wider than
  `bar width − 2 × (max(left occupied, right occupied) + 8)`. The back button
  label picks "previous title" / "Back" / chevron-only as before, with the
  leading items' width subtracted from the room it has.
- Outside a `navscreen` a `toolbar` lays its children out as a plain vstack.

#### `searchfield` in a `navscreen`

- Adds **52pt** to the bar height under the title area (status bar + 44,
  + 52 for a large title): 8pt, the 36pt field, 8pt. The field is 16pt from
  each side (`chrome.searchField`), the element's frame is the field.
- Rendering: `--sb-color-fill` background, 10pt radius, a
  `magnifyingglass` glyph leading in the secondary color, then an
  `<input>` (17pt, primary color) whose placeholder is `placeholder`
  (`"Search"` when empty) in the secondary color.
- Typing sends `{ "type": "text", "id", "value" }` on every input event,
  exactly like `textfield`; an `update` carrying the `text` the input
  already shows leaves the caret alone, a different `text` replaces the
  value.
- Outside a `navscreen` it is a 36pt field taking the proposed width (200
  when none).

#### `tapgesture`

- Size = the child's; the child is placed at the same origin.
- `cursor: pointer`. A click anywhere on it sends `{ "type": "tap", "id" }`
  **unless** the click landed on an inner interactive element (a button,
  navlink, picker, menu, switch…), which handles it alone: the renderer
  resolves the innermost control under the pointer, so an inner button's tap
  never also fires the gesture.

#### List row hints

Read through the row element's `styled` chain (outermost wins).

- **Default row geometry**: content proposed the card width minus 16pt on
  each side; row height = max(44, content + 2 × 11); content centered in the
  row. (Picker rows use 0 vertical padding, as before.)
- `listRowInsets: { top, leading, bottom, trailing }` replaces all four
  values for that row: content x = card x + `leading`, content width =
  card width − `leading` − `trailing` (− the badge reserve), row height =
  max(44, content + `top` + `bottom`), content centered between `top` and
  `bottom`. The DOM row box is still the full card width; only the content
  moves.
- `badge: "text"` (non-empty) reserves `width(text) + 8` at the trailing
  edge: the content is proposed that much less width, and the text is drawn
  in the row's font (17pt body by default), secondary color, right-aligned
  flush with the trailing inset, full row height (`chrome.badge`,
  `chromeText.badge`). The DOM adds a `.sb-badge` span inside the row's
  `styled` element. `null` or `""` reserves nothing.

#### `imageScale`

`image` elements with a `systemName`, and catalog images of `kind:
"symbol"`, measure a square of `line height × scale` (scale 0.8 / 1 / 1.25
for `small` / `medium` / `large`) instead of the plain line height, and are
drawn at `font size × scale` so the glyph (1em) scales with the frame. Text
and bitmap images are unaffected. The scale flows down the subtree like a
font; an inner `imageScale` replaces an outer one.

### `LayoutResult` additions

| kind | chrome keys |
|------|-------------|
| `navscreen` | `title` (inline title rect; absent for a large title or with a `principal` toolbar), `searchField`, `bottomBar` |
| `picker` (inline) | `label` (when non-empty), `option0` … `optionN-1` |
| list rows | `badge` (with `chromeText.badge`) |

`hidden` also lists the items of every `menu`.

## Phase 6: ShapeStyles, stroke borders, visual effects, asymmetric transitions

Everything in `docs/ops-protocol.md` still holds. Phase 6 widens what a view
can be *painted* with, adds a few CSS-only visual effects to `styled`, and
one transition form. The Swift side emits exactly the JSON below; the web
renderer resolves it in `Web/src/styles.ts` (pure, unit-tested) and applies
it in `Web/src/renderer.ts`.

### ShapeStyle

Wherever a `Color` used to be accepted for painting — `style.foreground`,
`style.background`, a `shape`'s `fill` and `stroke.color` — the value is now a
**ShapeStyle**. The three `Color` forms are ShapeStyles and render exactly as
before:

```jsonc
{ "r": 0.0, "g": 0.478, "b": 1.0, "a": 1.0 }                 // explicit sRGB
{ "name": "primary" | "secondary" | "accent" | "systemBackground" | "secondarySystemBackground" | "clear", "a": 0.5 }
{ "named": "dough/plain-bg", "opacity": 1.0 }                // asset catalog color
```

`{ "name": "systemBackground" }` is what `.background()` with no arguments
and `BackgroundStyle` emit; it works as a background, fill and stroke
(`var(--sb-color-system-background)`).

The new forms:

```jsonc
{ "hierarchical": 1 | 2 | 3 | 4 }                            // .primary / .secondary / .tertiary / .quaternary
{ "linearGradient": { "stops": [{ "color": Color, "location": 0.0 }, ...], "start": { "x": 0.5, "y": 0 }, "end": { "x": 0.5, "y": 1 } } }
{ "radialGradient": { "stops": [...], "center": { "x": 0.5, "y": 0.5 }, "startRadius": 0, "endRadius": 100 } }
{ "colorGradient": Color }                                   // Color.gradient
{ "material": "ultraThin" | "thin" | "regular" | "thick" | "ultraThick" }
```

Points (`start`, `end`, `center`) are **unit points** in the view's bounds;
radii are points; stop `location` is 0…1.

Every non-color form may carry an optional top-level `"opacity": number`
(0…1) multiplier — `{ "hierarchical": 4, "opacity": 0.5 }` for
`.quaternary.opacity(0.5)`, `{ "material": "thin", "opacity": 0.8 }`,
`{ "linearGradient": {...}, "opacity": 0.5 }`, `{ "colorGradient": {...},
"opacity": 0.5 }`. It is applied like a semantic color's `a`: hierarchical
colors and every gradient stop are wrapped in `color-mix(in srgb, C p%,
transparent)`; a material's alpha is multiplied.

`ShapeStyle.shadow(_:)` (`.drop(...)` / `.inner(...)`) is an approximation:
the shadowed style serializes as its base style and the shadow is dropped.

#### Resolution by role

A ShapeStyle resolves differently depending on whether it colors glyphs
(**text** role: `style.foreground`) or fills an area (**paint** role:
`style.background`, shape `fill`, `stroke.color`). `styleToCSS(style, role)`
returns `{ kind, value, solid, backdropFilter? }`: `value` is what to paint
with (a `<color>`, or a CSS `<image>` for gradients), `solid` a plain
`<color>` stand-in used where an image cannot go (border colors,
`currentColor` consumers such as symbols): the value itself for flat styles,
the first stop for gradients, the base color for materials.

| style | text role | paint role |
|-------|-----------|------------|
| `hierarchical: 1` | `var(--sb-color-primary)` | `var(--sb-color-fill)` |
| `hierarchical: 2` | `var(--sb-color-secondary)` | `var(--sb-color-secondary-fill)` |
| `hierarchical: 3` | `var(--sb-color-tertiary-label)` | `var(--sb-color-tertiary-fill)` |
| `hierarchical: 4` | `var(--sb-color-quaternary-label)` | `var(--sb-color-quaternary-fill)` |
| `linearGradient` | `linear-gradient(<angle>deg, color pct%, …)` | same |
| `radialGradient` | `radial-gradient(circle <endRadius>px at <cx>% <cy>%, color pct%, …)` | same |
| `colorGradient: C` | `linear-gradient(to bottom, color-mix(in srgb, C, white 12%), color-mix(in srgb, C, black 12%))` | same |
| `material` | the material's base color (see below) | `rgba(var(--sb-color-material-base), α)` + `backdrop-filter: blur(20px)` |

Levels outside 1…4 degrade to 1; an unrecognised object renders magenta.

New theme variables in `Web/src/styles.css`:

| variable | light | dark |
|----------|-------|------|
| `--sb-color-quaternary-label` | `rgba(60,60,67,0.18)` | `rgba(235,235,245,0.16)` |
| `--sb-color-secondary-fill` | `rgba(120,120,128,0.16)` | `rgba(120,120,128,0.32)` |
| `--sb-color-tertiary-fill` | `rgba(118,118,128,0.12)` | `rgba(118,118,128,0.24)` |
| `--sb-color-quaternary-fill` | `rgba(116,116,128,0.08)` | `rgba(118,118,128,0.18)` |
| `--sb-color-material-base` | `255, 255, 255` | `30, 30, 30` |

(`--sb-color-fill` already existed: light `rgba(120,120,128,0.2)`, dark
`rgba(120,120,128,0.36)`.)

**Linear gradient angle.** CSS measures gradient angles clockwise from "to
top", so the angle is `atan2(dx, −dy)` with `d = end − start`: top→bottom is
180deg, leading→trailing 90deg, topLeading→bottomTrailing 135deg. A CSS
gradient line always spans the whole box, so stop locations are mapped onto
the projections of `start` and `end` on that line — exact for axis-aligned
gradients (a gradient from `y: 0.25` to `y: 0.75` puts its stops at 25% and
75%), approximated on a unit square otherwise. `start == end` falls back to
180deg.

**Radial gradient.** Stops are placed at `(startRadius + location ·
(endRadius − startRadius)) / endRadius`. A missing or non-positive
`endRadius` uses `circle farthest-corner` with the raw locations.

**Materials.** α is 0.55 (`ultraThin`), 0.7 (`thin`), 0.82 (`regular`), 0.9
(`thick`), 0.96 (`ultraThick`), times the optional `opacity`. The renderer
sets both `backdrop-filter` and `-webkit-backdrop-filter`.

#### Where each role lands

- `style.background` → `background-color` (colors, materials; materials also
  get the backdrop filter) or `background-image` with a transparent
  `background-color` (gradients) on the styled box.
- `style.foreground` → `color` and `--sb-button-color` on the styled box
  (the `solid` value). For a gradient the box additionally sets the inherited
  custom properties `--sb-text-gradient` (the image), `--sb-text-clip: text`,
  `--sb-text-color: transparent` and `--sb-foreground-paint` (the image), and
  `data-sb-gradient-text`. The `.sb-text` rule reads them:
  `background-image: var(--sb-text-gradient, none); background-clip:
  var(--sb-text-clip, border-box); color: var(--sb-text-color)`, so every text
  in the subtree — including text under nested `styled` boxes — paints the
  gradient through its glyphs. A styled box with a *flat* foreground resets
  the four properties to `initial`, which ends the gradient for its subtree
  (with the properties unset the `.sb-text` declarations are invalid at
  computed-value time and fall back; `color` becomes `unset`, i.e. inherits as
  before). Symbols (`currentColor`) and other `currentColor` consumers get
  the first stop; a `shape` with `fill: null` paints
  `var(--sb-foreground-paint, currentColor)`, so it shows the gradient too.
- shape `fill` → the shape's `background-color` / `background-image`
  (materials with backdrop filter).
- shape `stroke.color` → the border color. CSS borders are flat, so a
  gradient stroke uses its first stop (`solid`); a material stroke its base
  color.

### Shape stroke border and `containerRelative`

```jsonc
"stroke": { "color": ShapeStyle, "lineWidth": 2, "inset": true }
```

`inset: true` is `.strokeBorder(_:lineWidth:)`: the stroke lies fully inside
the shape's frame. The renderer draws every stroke as a CSS `border` on a
`box-sizing: border-box` box, which is already inside the frame, so the
look of a non-inset stroke is unchanged from before. Which one it was is
recorded as `data-sb-stroke="inset" | "center"` on the shape element.

`shape` gains the name `"containerRelative"` (`ContainerRelativeShape`),
rendered like `roundedRectangle` with the given `cornerRadius`.

### Visual effect style hints

New `styled` style keys. They are applied as CSS on the styled box and have
**no layout effect**: the layout engine sizes and places the subtree as if
they were not there.

```jsonc
{
  "offset":         { "x": 10, "y": -4 },          // .offset: points
  "rotation":       45,                            // .rotationEffect: degrees, clockwise positive
  "rotationAnchor": { "x": 0.5, "y": 0.5 },        // unit point, default center
  "scale":          { "x": 1.5, "y": 1.5 },        // .scaleEffect
  "scaleAnchor":    { "x": 0.5, "y": 0.5 },        // unit point, default center
  "shadow":         { "color": Color, "radius": 8, "x": 0, "y": 4 },
  "clipped":        true
}
```

CSS:

- one `transform`, composed in this order: `translate(xpx, ypx)`
  `rotate(deg)` `scale(x, y)` (identity parts are left out; no transform is
  set when nothing applies);
- `transform-origin` from the anchor as percentages (`x·100% y·100%`). CSS has
  a single origin for the whole transform, so when both `rotationAnchor` and
  `scaleAnchor` are present and differ the **rotation anchor wins**; without
  any anchor the origin stays at the default center;
- `shadow` → `filter: drop-shadow(xpx ypx radiuspx color)` (follows the
  alpha of images and text like SwiftUI's `.shadow`);
- `clipped: true` → `overflow: hidden` and `data-sb-clipped` on the box.

Animation: `transform` and `filter` are part of the Animator's animated style
properties, so in an animated commit (`withAnimation`) or a subtree whose
`animationToken` changed, a changed rotation/offset/scale/shadow tweens from
the previous computed value. Frame animations tween `left`/`top`/`width`/
`height` and do not touch the transform. Enter and exit transitions compose
their own transform on top of the box's (`<own> <transition>` → `<own>`), so
a rotated view slides in while staying rotated.

### Asymmetric transitions

`transition` parts gain:

```jsonc
{ "kind": "asymmetric", "insertion": Transition, "removal": Transition }
```

where each side is a part or an array of parts (and may itself contain
`asymmetric` parts; nesting is capped at 8 levels). When the subtree is
inserted the renderer plays `insertion`; when it is removed, the exit clone
plays `removal`. It combines with sibling parts of the enclosing array like
any other part:

```jsonc
[{ "kind": "opacity" },
 { "kind": "asymmetric", "insertion": { "kind": "move", "edge": "leading" }, "removal": { "kind": "scale", "scale": 0.2 } }]
```

### TypeScript

`Web/src/protocol.ts`: `ShapeStyle = Color | HierarchicalStyle |
LinearGradientStyle | RadialGradientStyle | ColorGradientStyle |
MaterialStyle` (`Color` is unchanged and remains the type of `color.color`,
gradient stops and `shadow.color`), `UnitPoint`, `GradientStop`, `Shadow`,
`Stroke.inset`, `ShapeKind` + `containerRelative`, `TransitionPart.insertion/
removal`, and the `Style` effect keys. `Web/src/styles.ts`: `colorToCSS`,
`styleToCSS(style, role)`, `linearGradientCSS`, `radialGradientCSS`,
`effectsCSS(style)`. Tests: `Web/src/__tests__/styles.test.ts` (resolution),
`Web/e2e/phase6-styles.spec.ts` (computed styles in Chromium).

## Phase 7: charts, split navigation, `ViewThatFits`, masks, size classes

Everything above still holds. Phase 7 brings the Food Truck sample's Swift
Charts screens, its `NavigationSplitView` root and the City panel to the
browser. The Swift side (`Sources/Charts`, the shim) turns data into **unit
coordinates**; the renderer owns pixels: it measures axis labels and
annotation views, derives the plot rectangle and draws the marks as SVG.

### Environment event (extended)

```jsonc
{ "type": "environment", "colorScheme": "dark", "dynamicTypeSize": "large",
  "horizontalSizeClass": "compact" }       // or "regular"
```

The renderer sends `regular` when the screen (`#screen`, the frame or the real
viewport) is at least **700 CSS px** wide, else `compact`, and re-sends the
event when that changes (a resize or rotation). Swift exposes it as
`EnvironmentValues.horizontalSizeClass` (`UserInterfaceSizeClass?`;
`.compact` until the first event). Headless runs are compact.

### Device mode (changed)

A coarse pointer now means device mode at any width (a tablet fills the
viewport instead of showing the phone bezel); the 500 px rule is gone.
`?mode=frame` / `?mode=device` still force a mode.

### New element kinds

| kind          | props | children |
|---------------|-------|----------|
| `chart`       | `marks: Mark[]`, `xAxis: Axis \| null`, `yAxis: Axis \| null`, `legend: Legend \| null` | `chartitem`s (annotations and custom axis labels), in any order |
| `chartitem`   | `role: "annotation" \| "xLabel" \| "yLabel"`, `x0, x1, y0, y1: number` (unit rect of the mark; for labels `x0 == x1` / `y0 == y1` is the tick), `position: "top" \| "bottom" \| "leading" \| "trailing" \| "overlay"`, `alignment: Alignment`, `spacing: number` | one view |
| `navsplit`    | none | `[sidebar, detail]`, each a `navstack` |
| `viewthatfits`| `axes: "horizontal" \| "vertical" \| "both"` | the candidates, in order |

#### Unit coordinates

Every chart coordinate is a number in 0…1 relative to the **plot rectangle**
(the chart's box minus axes and legend, computed by the renderer): `x` runs
left → right, `y` runs **top → bottom** (so the largest value is `y = 0`,
like every other box in this protocol). Swift applies the scales: a
continuous axis maps `(value − min) / (max − min)` with the "nice" domain it
chose; a categorical (band) axis gives category *i* of *n* the band
`[i/n, (i+1)/n]`. Values outside 0…1 are allowed (a mark may leave the plot)
and are clipped by the renderer to the plot rectangle, except annotations.

#### `Mark`

All marks carry `style: ShapeStyle` (Phase 6 JSON: a `Color`, `linearGradient`,
`radialGradient`, `hierarchical`, …), `opacity: number` (0…1, default 1) and
an optional `mask: Mark` (an `area` or `rect` mark whose geometry clips this
mark; the renderer renders it as an SVG `clipPath`). `kind` selects the rest:

| `kind` | fields | drawn as |
|--------|--------|----------|
| `rect` | `x0, x1, y0, y1` (unit rect, `x0 ≤ x1`, `y0 ≤ y1`), `cornerRadius: number` (px, default 0), `fixedWidth: number \| null` (px: the rect is `fixedWidth` wide, centered on `(x0 + x1) / 2`) | a filled `<rect>` (`BarMark`, `RectangleMark`) |
| `line` | `points: [{ x, y }]`, `lineWidth: number` (px, default 2), `interpolation: Interpolation`, `symbol: Symbol \| null`, `symbolSize: number` (px, default 8) | a stroked `<path>` plus one symbol glyph per point (`LineMark`) |
| `area` | `points: [{ x, y0, y1 }]` (`y0 ≤ y1`), `interpolation: Interpolation` | a filled `<path>` between the two curves (`AreaMark`) |
| `point` | `x, y`, `symbol: Symbol`, `symbolSize: number` (px, default 8) | one symbol glyph (`PointMark`) |
| `rule` | `x0, x1, y0, y1`, `lineWidth: number` (px, default 1) | a stroked line between the two points (`RuleMark`) |

`Interpolation` is `"linear" | "cardinal" | "catmullRom" | "monotone" |
"stepStart" | "stepCenter" | "stepEnd"`; the renderer draws `cardinal`,
`catmullRom` and `monotone` as a Catmull-Rom spline through the points (cubic
Béziers) and the steps as right-angle paths. `Symbol` is `"circle" | "square"
| "triangle" | "diamond" | "cross" | "plus" | "asterisk"`; it is drawn in the
mark's style, centered on the point. Marks draw in array order.

#### `Axis`

```jsonc
{ "position": "bottom",                      // x: "bottom" | "top"; y: "trailing" | "leading"
  "gridLines": [0.0, 0.25, 0.5, 0.75, 1.0],   // unit positions along the axis
  "ticks":     [0.0, 0.25, 0.5, 0.75, 1.0],
  "labels":    [{ "at": 0.125, "text": "Mon" }, …] }   // default text labels
```

Positions are unit values along that axis (for the y axis `0` is the top).
A label whose content is a view instead of text is not in `labels`: it is a
`chartitem` child with role `xLabel` / `yLabel` and the tick position in
`x0 == x1` (x) or `y0 == y1` (y). `null` for the whole axis means hidden.

#### `Legend`

```jsonc
{ "position": "top",                          // "top" | "bottom" | "leading" | "trailing"
  "items": [{ "label": "Cupertino", "style": ShapeStyle, "symbol": Symbol | null }] }
```

`null`: no legend. Swift builds one entry per distinct `foregroundStyle(by:)`
/ `symbol(by:)` value, in first-seen order, with SwiftUI's chart palette
(blue, green, orange, purple, red, teal, yellow, pink, indigo, mint, cyan,
brown, in that order, cycling).

#### Layout rules: `chart`

- **Size**: the proposal on both axes; a nil axis is 300 (width) / 200
  (height) and an infinite axis stays infinite (the chart is as flexible as a
  shape). The chart's box is split into the legend row (top or bottom; a
  leading / trailing legend is a column), the axis bands and the plot. The
  legend is separated from the rest by 8 px.
- **Axis bands**: the y-axis band is as wide as its widest label (text labels
  measured in the axis font, `yLabel` children at their ideal width) plus
  6 px, 0 when there are no labels; the x-axis band is as tall as its tallest
  label (text: one line of the axis font; `xLabel` children at their ideal
  height) plus 6 px. The 6 px lie between the plot edge and the label (the
  ticks sit inside them). A `top` x axis puts its band above the plot, a
  `leading` y axis its band to the left. The axis font is `caption` (12 px)
  in the secondary color. Grid lines are 1 px in `--sb-separator`; ticks are
  1 px, 4 px long, outside the plot, same color.
- **Plot**: what remains. Marks are drawn into one `<svg>` the size of the
  plot (chrome; `data-sb-id`-less) with `overflow: hidden`. The engine
  reports the plot, the legend, the axis bands, every text label and every
  legend item as chrome rects (`plot`, `legend`, `xband`, `yband`,
  `xlabel<i>`, `ylabel<i>`, `legend<i>`).
- **Labels**: an x text label is centered on `at`, clamped to the chart's
  width; a y text label is right-aligned (trailing axis: left-aligned) to the
  band, vertically centered on `at` and clamped to the chart's height.
  `xLabel` children: ideal size, centered horizontally on `x0`, against the
  plot edge of the x band (6 px away from the plot). `yLabel` children: ideal
  size, centered vertically on `y0`, against the plot edge of the y band.
- **Annotations** (`role: "annotation"`): the child is measured at its ideal
  size and placed against the mark's pixel rect `R` (`x0…x1`, `y0…y1` mapped
  into the plot): `top` → centered above `R`, bottom edge at `R.top − spacing`;
  `bottom` → below, top edge at `R.bottom + spacing`; `leading` / `trailing`
  → beside, centered vertically; `overlay` → centered on `R`. `alignment`
  shifts it along the free axis the way `ZStack` alignment does (`top` +
  `.leading` puts its leading edge on `R.left`). Annotations are not clipped
  to the plot and draw above the marks.
- **Legend items**: a 10 px symbol glyph (or an 18 × 3 px line when `symbol`
  is null) in the item's style, then the label in `caption` (12 px) secondary
  text, 4 px apart; items flow horizontally with 12 px between them and wrap
  at the chart's width into rows 4 px apart (a leading / trailing legend puts
  one item per row and is as wide as its widest item). Each item is one line
  of the caption font tall (16 px at the default size) and its width is
  rounded up to whole pixels.
- **Styles in SVG**: a `Color` paints with the same CSS it does elsewhere
  (semantic and named colors through the `--sb-color-*` tokens, the chart
  palette names included); `hierarchical` levels use the fill tokens; a
  `linearGradient` becomes an SVG `<linearGradient>` in object bounding box
  units (`start` → `x1 y1`, `end` → `x2 y2`), a `radialGradient` a
  `<radialGradient>` in user space centered on the mark's bounds with
  `startRadius` / `endRadius` in px; `colorGradient` a vertical light → dark
  gradient of its color; a `material` a translucent gray (no backdrop blur
  inside the SVG). The style's `opacity` multiplies the stops, the mark's
  `opacity` the whole mark.

#### Swift side

How the shim (`Sources/SwiftUI/Charts`, re-exported by `Sources/Charts`)
fills the JSON above; these refine the spec rather than change it:

- **Domains.** A continuous y scale includes 0 by default for every mark
  kind (as in Swift Charts; `chartYScale(domain: .automatic(includesZero:
  false))` fits the data); a continuous x scale never adds 0. Numeric ticks
  use steps of 1, 2, 2.5, 5 × 10ᵏ (the one nearest to `span / (desiredCount
  − 1)`, about 5 ticks by default, at least `minimumStride`); with
  `roundLowerBound` / `roundUpperBound` (default true) the domain extends to
  the first and last tick, otherwise ticks outside the data are dropped.
  Explicit `AxisMarks(values: [...])` are used verbatim and never round the
  domain. A hidden axis still rounds the domain like a default one. Masks'
  marks do not contribute to the domain.
- **Dates** are UTC epoch seconds; the tick unit follows the span: up to 3
  days, every 1 / 3 / 6 / 12 hours ("9 AM"); up to 45 days, every 1 / 2 / 7 /
  14 days ("Sep 30"); beyond, every 1 / 2 / 3 / 6 months ("Jan", or "Jan 2026"
  when the ticks cross a year) or 1 / 2 / 5 / 10 years ("2026"). The stride is
  the smallest giving at most `desiredCount + 1` (default 8) intervals.
  Explicit date values are labelled by their own granularity.
- **Series.** `LineMark`s / `AreaMark`s with the same key (`series:`, else
  the `foregroundStyle(by:)` value, else the `symbol(by:)` value, else none)
  form one `line` / `area` mark wherever they appear in the content, with
  their points in x order. The mark's style, line width, symbol and
  interpolation come from the group's first mark. A mark with no style and no
  `by:` value is `Color.blue`.
- **Bars** on a band axis are 0.8 of the band; on a continuous x axis the
  band is the smallest gap between bar centers. `MarkDimension.ratio` scales
  the band, `.inset` is treated as `.automatic`, `.fixed` sets `fixedWidth`
  with `x0 == x1` at the center. Bars are not stacked.
- **Symbols** for `symbol(by:)` cycle circle, square, triangle, diamond,
  cross, plus, asterisk (`.pentagon` draws as `diamond`). `symbolSize(_:)`
  takes an area and emits its square root as the diameter.
- **Annotations** anchor to the mark's rect (bars, rectangles), the rule's
  segment, or the point at `(x, y)` / `(x, yEnd)` for lines, areas and
  points; `.topLeading` and friends become `top` / `bottom` with the implied
  `alignment`. The default `spacing` is 4.

### `NavigationSplitView`

`NavigationSplitView { sidebar } detail: { detail }` renders by size class:

- **Regular**: a `navsplit` with two children. The sidebar child is a
  `navstack` whose root `navscreen` holds the sidebar content (its title from
  `.navigationTitle`, `displayMode` large). The detail child is the detail
  view's own `NavigationStack` (a `navstack`), or one the shim wraps around it.
  Layout: the sidebar column is **320 pt** wide (at most 40 % of the width
  left by the horizontal safe-area insets), the detail column takes the
  rest; a 1 px `--sb-color-separator` hairline sits between them. Each
  column's `navstack` fills its column (the renderer's chrome `sidebar`,
  `separator` and `detail` rects); the safe area's top and bottom insets
  apply to both columns (each column's screens reserve the status bar and
  the bottom inset exactly as a root `navstack` does), its leading inset to
  the sidebar and its trailing inset to the detail: the sidebar's `navstack`
  starts at the leading inset and the detail's ends at the trailing inset,
  so the column rects include the insets and the navstacks exclude them. A
  `navsplit` is only laid out as a root element (like a root `navstack` it
  ignores the safe areas itself).
- **Compact**: a plain `navstack` rooted at the sidebar content. While the
  sidebar's selection (below) is non-nil (also at launch), Swift pushes one
  `navscreen` with the detail view's content; links inside the detail push
  after it; the back button pops it and clears the selection. A
  `NavigationStack` **nested inside a `navscreen`** of another
  stack is flattened: it renders its root content in place, its
  `navigationDestination`s register with the enclosing stack and its links
  push onto that stack (its `path` binding is ignored).

**List selection.** `List(selection: Binding<Value?>)` makes its rows
selectable: a `NavigationLink(value:)` row inside it sets the selection to
its value instead of pushing when the list is a `NavigationSplitView`
sidebar (in compact mode the split view then pushes the detail, see above;
outside a split view the link still pushes). The selected row's `navlink`
carries `selected: true` and the renderer draws it with a
`--sb-color-tertiary-fill` rounded (10 px) background (sidebar style,
`data-sb-selected` on the row) and no chevron when inside a `navsplit`
sidebar; elsewhere (the detail column, a plain stack) the chevron stays.
The label keeps reserving the chevron's width either way, so selecting a
row never reflows it.

### `ViewThatFits`

Props `axes` (default `both`). The engine measures every child's **ideal**
size and shows the first child whose ideal size fits the proposal on the
restricted axes (`ideal.width ≤ proposal.width` when `horizontal` is
restricted, same for height; a nil or infinite proposal axis always fits);
when none fits, the last child. Only the chosen child is measured with the
proposal and placed, and the element reports its size; the others get no
frame and the renderer hides them (`hidden` attribute). Within one pass the
choice is stable (it depends only on the proposal and the children).

### `styled` style (added fields)

| field | meaning |
|-------|---------|
| `hidden: true` | `.hidden()`: the box keeps its layout but draws nothing (`visibility: hidden`, no input) |
| `mask: ShapeStyle` | `.mask { Gradient / Color }`: CSS `mask-image` (and `-webkit-mask-image`, `mask-size: 100% 100%`) from the style (a `linearGradient` / `radialGradient` becomes the matching CSS gradient, a `Color` a solid mask whose alpha is the color's: `linear-gradient(rgba(0,0,0,a), rgba(0,0,0,a))`; a hierarchical style or material a solid mask at its `opacity`). Only style masks are supported; a view mask falls back to the unmasked view on the Swift side |

### Headless

`chart` and `chartitem` elements appear in snapshots like any other; their
props are stable for a pinned `SB_NOW`.

### TypeScript

`src/protocol.ts`: `ChartProps`, `ChartMark` (union by `kind`), `ChartAxis`,
`ChartLegend`, `ChartItemProps`, `ChartInterpolation`, `ChartSymbol`,
`NavSplitProps`, `ViewThatFitsProps`, `Style.hidden`, `Style.mask`,
`EnvironmentEvent.horizontalSizeClass`, `NavLinkProps.selected`.

## Phase 8: interaction

Everything above still holds. Phase 8 adds what real apps do with their
fingers: gestures, swipe actions and context menus on rows, edit mode with
deletion and multi-selection, alerts and confirmation dialogs, full-screen
covers, pull to refresh, the remaining controls (`Stepper`, `Slider`,
`DatePicker`, `TextEditor`), plus 3D rotation, blur, safe-area bleed,
repeating animations and the scene phase. The driver is `Examples/HackingWithSwift`
(five of Paul Hudson's public-domain SwiftUI projects).

### Events (added)

```jsonc
{ "type": "drag", "id": 12, "phase": "began" | "changed" | "ended" | "cancelled",
  "translation": { "width": 40.5, "height": -3 },     // since the pointer went down, points
  "location": { "x": 120, "y": 30 },                   // in the gesture element's box
  "startLocation": { "x": 80, "y": 33 },
  "velocity": { "width": 310, "height": 0 } }          // points per second (ended only; else 0)
{ "type": "longpress", "id": 12 }                      // 0.5 s hold without moving more than 10 px
{ "type": "step",  "id": 7, "value": 1 }               // Stepper: +1 / -1
{ "type": "slide", "id": 7, "value": 0.35 }            // Slider: the value in its range
{ "type": "date",  "id": 7, "value": 1700000000 }      // DatePicker: seconds since 1970, UTC
{ "type": "delete", "id": 31 }                         // a `listrow`: swipe-to-delete or the edit-mode minus
{ "type": "refresh", "id": 40 }                        // a `list` / `scrollview` with `refreshable`
```

`text` is reused by `texteditor`; `tap` by `alert` buttons, swipe actions,
context-menu items and `listrow` selection. `drag` events are coalesced to
one per animation frame while `changed`; `began` is sent once the pointer has
moved `minimumDistance` (default 10 px, 0 for an immediate gesture), `ended`
when the pointer lifts and `cancelled` when the browser takes the pointer
(scroll, pointercancel). Headless: `drag:12:changed:40x-3`, `longpress:12`,
`step:7:1`, `slide:7:0.35`, `date:7:1700000000`, `delete:31`, `refresh:40`.

### Environment event (extended)

`"scenePhase": "active" | "inactive" | "background"` from the document's
visibility and focus (`active` when visible and focused, `inactive` when
visible but unfocused, `background` when hidden). Swift exposes it as
`EnvironmentValues.scenePhase` (`ScenePhase`; `.active` by default). Every
`environment` event carries it; the renderer sends a new one on
`visibilitychange` and window `focus` / `blur` only when the phase actually
changed (`window.__sb.scenePhase()` reads the current value).

### New element kinds

| kind | props | children |
|------|-------|----------|
| `gesture` | `drag: bool`, `longPress: bool`, `minimumDistance: number`, `minimumDuration: number` | exactly one |
| `swipeactions` | `trailing: number`, `leading: number`, `fullSwipeTrailing: bool`, `fullSwipeLeading: bool` | `[content, ...trailing actions, ...leading actions]`; actions are `button`s, each possibly inside `styled` wrappers (`.tint` is the `tint` style on such a wrapper and colors the action; `.disabled` likewise); role `destructive` is red |
| `contextmenu` | none | `[content, ...items]`; items as in a `menu` |
| `listrow` | `selectable: bool`, `selected: bool`, `deletable: bool` | one view (the row content) |
| `alert` | `title: string`, `message: string \| null`, `style: "alert" \| "dialog"` | the `button`s, in order (a `role: "cancel"` button is drawn apart; none → the renderer adds "OK") |
| `stepper` | `canIncrement: bool`, `canDecrement: bool` | the label (may be empty) |
| `slider` | `value: number`, `min: number`, `max: number`, `step: number \| null` | `[label?, minimumValueLabel?, maximumValueLabel?]` as marked by `labels: ["label", "min", "max"]` in order present |
| `datepicker` | `seconds: number`, `components: "date" \| "hourAndMinute" \| "dateAndTime"`, `min: number \| null`, `max: number \| null` | the label (may be empty; `labelsHidden` hides it) |
| `texteditor` | `text: string` | none |

`sheet` gains `detents: ["full"]` for `.fullScreenCover`: the card fills the
screen (no corner radius, no grabber, no drag to dismiss) and slides up like a
sheet. `list` and `scrollview` gain `refreshable: bool` and `refreshing: bool`;
`list` gains `editing: bool`. Swift omits these when false (`refreshable` and
`refreshing` are both present on a refreshable element, `editing` only while
editing): absent means false.

### Gestures: `gesture`

The element is transparent for layout (its child's size). The renderer tracks
pointers on it: with `drag`, a pointer that moves `minimumDistance` starts a
drag and the element receives `drag` events until the pointer lifts; the
coordinates are relative to the element's own box. With `longPress`, a
pointer held `minimumDuration` (default 0.5 s) without moving 10 px sends
`longpress`. Taps still reach controls inside (a `drag` begins only after the
distance is met; a long press suppresses the following click). A `gesture`
inside a scrolling container claims the pointer once the drag begins on the
axis the gesture wants; `drag` here is two-dimensional, so it claims
immediately after `minimumDistance`. Swift applies `.gesture`,
`.highPriorityGesture` and `.simultaneousGesture` identically (one wrapper
each). `.onLongPressGesture` is a `gesture` with `longPress`. A `TapGesture`
attached with `.gesture` is the Phase 6 `tapgesture` element, not a
`gesture` (a gesture that both taps and drags nests the `tapgesture` inside
the `gesture`); a gesture with neither drag, long press nor tap emits
nothing. Combined gestures (`sequenced`, `simultaneously`, `exclusively`)
merge into one `gesture` that tracks both, with the smaller distance /
duration; the shim does not order or exclude them. On the Swift side a
`cancelled` drag ends the gesture: `onEnded` runs with the event's value (so
state an app resets in `onEnded` is reset), and `began` is handled like
`changed`. A `longpress` runs `LongPressGesture`'s `onChanged(true)` and
`onEnded(true)` together, and `.onLongPressGesture`'s `onPressingChanged`
is told `true` then `false` around `perform`.

Renderer details (src/interaction.ts): `began` carries the translation at the
moment the distance is met (with `minimumDistance: 0` the pointer is owned
from the pointer down and `began` goes out with the first move); `velocity`
is the displacement over the samples of the last 100 ms; `cancelled` is sent
when the browser cancels the pointer (a native scroll won). A pointer down in
a text field (`input`, `textarea`) never starts a gesture, so text selection
still works there; everywhere else a pointer session suppresses text
selection. A right click on a `gesture` with `longPress` shows no browser
menu. When several behaviours could own one pointer they are asked in a fixed
order on each move: swipe back (a down within 24 pt of a pushed screen's
leading edge) first, then the wrappers between the target and the screen
innermost first (`gesture`, `contextmenu`, swipe row), then the sheet card,
then pull to refresh; the first whose direction matches claims the pointer
and the others are dropped.

`allowsHitTesting(false)` is the style hint `hitTesting: false`: the subtree
gets `pointer-events: none`.

**Hit testing follows SwiftUI's**: a stack, spacer, `geometry`, `grid`,
`layout`, `viewthatfits`, `decorated`, `gesture` / `tapgesture` wrapper or a
plain `styled` box is not hit in its empty space (the pointer reaches whatever
is under it, as a full-screen `VStack { Spacer() }` overlay never blocks the
cards beneath it in Flashzilla); content is hit: text, images, shapes, colors,
controls, rows, and a `styled` whose style paints a `background`. The
renderer implements this with `pointer-events: none` on the containers and an
opt-in on their children (styles.css), so an inert subtree (`hidden`,
`disabled`, `hitTesting: false`, a popping screen, exit clones, a dismissing
sheet or alert, a hidden tab) is blocked by an explicit descendant rule.

### Swipe actions: `swipeactions`

Wraps a list row's content. A horizontal pan on the row (by pointer, or a
horizontal wheel/trackpad scroll) slides the content aside and reveals the
actions of that edge as full-height colored buttons, 74 pt wide each, text
(and symbol, stacked) in white, in a row behind the content: trailing actions
on the right with the **first declared action nearest the content**, leading
on the left. Tapping an action sends `tap` with the button's id and closes the
row. Pulling past the actions' total width plus 60 pt with full swipe allowed
performs the first declared action of that edge (its `tap`) and closes the
row. Tapping anywhere else, scrolling, or another row opening closes the open
row with the slide animation. Outside a `list` the element is transparent.
A row with `onDelete` (below) and no `swipeActions` behaves like one with a
single trailing destructive "Delete" action that sends `delete` instead of
`tap`. Several `.swipeActions` on one row accumulate into one element
(trailing actions in declaration order, then leading ones); the per-edge
`allowsFullSwipe` is the last one declared for that edge. The actions are
flattened like a `menu`'s items, so `trailing` + `leading` equals the number
of children after the content; the renderer finds each action's `button` by
looking through its `styled` wrappers (that is where a `.tint` lives). When a
row has both `swipeActions` and `onDelete` the `listrow` wraps the
`swipeactions` element.

Renderer details: the action `button` children are never laid out (they are
in `hidden`, like a `menu`'s items); when a row starts to open the renderer
builds the buttons from the current children (title from their `text`,
symbol from their first `image`, `destructive` role → red, a `tint` on the
button or its `styled` wrapper → that color, otherwise gray). The content
follows the pointer 1:1 up to the actions' total width, then with resistance
(no further than the row width), and the button nearest the content stretches
past the total width. On release the row opens when the content is past half
the actions' width or flung open faster than 300 pt/s, closes otherwise;
`fullSwipeTrailing` / `fullSwipeLeading` default to true. A tap outside an
open row closes it *and* is swallowed (nothing under it fires). A horizontal
wheel sequence settles 150 ms after its last event with the same rules.

### Context menus: `contextmenu`

The content is laid out alone; the items are hidden inline, like a `menu`'s.
A long press (0.5 s) or a right click on the content opens the same popover a
`menu` opens, anchored to the content, with the items as rows; choosing one
sends `tap` with the item's id. The browser's own context menu is suppressed
on the subtree, the long press swallows the click that follows it, and the
long press is cancelled by moving 10 px (so the row still scrolls or swipes).

### Edit mode and list selection: `listrow`

Swift wraps a `List` row in a `listrow` when it can be selected (the list has a
`selection:` binding and the row carries a `.tag`, or is a `NavigationLink(value:)`
in a `List(selection:)`) or deleted (its `ForEach` has `.onDelete`). The row's
`list` carries `editing` (from `EditButton` / the `editMode` environment).
Rows are the list's direct children and the children of its `section`s; the
wrapper takes over the row's identity (its `ForEach` key), and a row that is
neither selectable nor deletable is emitted bare, as before. A `listrow` whose
content is a `swipeactions` is the swipe host itself: the whole row slides and
the actions sit at the row's edge (the `swipeactions` wrapper also spans the
row's width in the layout, so a pan anywhere on the row starts the swipe). A `ForEach` id
is a row's implicit tag, as in SwiftUI, so `List(items, selection:)` needs no
`.tag`; a tag (or link value) counts only when it is of the selection's value
type (`Set<V>` or `V?`). A tag below `styled`, `swipeactions`, `contextmenu`,
`gesture` or `tapgesture` wrappers still marks the row. `.deleteDisabled(true)`
makes a row not deletable; `onMove` / `.moveDisabled` are accepted but the
renderer offers no reordering yet.

The `editMode` binding comes from the nearest `navscreen` (each screen owns
its own mode, as on iOS), or from the `List` itself when no screen encloses it
(a list in a sheet or a plain root); an app-supplied
`.environment(\.editMode, $mode)` below either wins. `EditButton` sets the
binding to `.active` / `.inactive` and shows "Done" / "Edit".

A `tap` on a `listrow`: for a `NavigationLink(value:)` row that is not
editing, Swift does what a tap on the `navlink` does (push, or select in a
split-view sidebar); otherwise a `Set` selection toggles the value, a `V?`
selection sets it, or toggles it (deselects on a second tap) while editing.
`delete` on a deletable row calls its `ForEach`'s `onDelete` with the row's
offset in the `ForEach`'s data (one offset, computed from the row's position
among the `ForEach`'s children, so it is right after earlier removals).

- **Not editing**: a selectable row with a single-value selection sends `tap`
  on its id when tapped (Swift sets the selection; the row shows `selected`
  with the Phase 7 selected-row fill). A multi-selection list ignores taps.
  A deletable row swipes to delete (see `swipeactions`).
- **Editing** (`list.editing`): every `listrow` gains a 44 pt leading control
  and its content shifts right by 44 with the list's animation: a selection
  circle for `selectable` rows (filled accent with a white checkmark when
  `selected`; a tap sends `tap` on the row id and Swift toggles the
  selection), a red minus circle for `deletable` rows that are not selectable
  (a tap slides the content to reveal a trailing "Delete" button; tapping it
  sends `delete`). `EditButton` itself is a `button` whose title Swift flips
  between "Edit" and "Done".
- `delete` is sent with the `listrow` id; Swift maps it to the row's offset in
  its `ForEach` and calls `onDelete` with an index set of one.
- Renderer details: the `listrow` is transparent for layout and keeps the
  list-row context (a `navlink` inside it still gets its chevron; `badge` /
  `listRowInsets` on a `styled` inside it still apply). In edit mode the
  engine proposes the row content 44 less width and reports the control as
  the row's `editControl` chrome (44 × row height at the card's leading
  edge); the shift animates with the commit like any frame change. Swift
  emits the `listrow` as the row's outermost element (the row modifiers
  inside it): the row clips to its box, so a `styled` wrapper *around* a
  `listrow` would clip the control. A tap on a selectable row sends `tap`
  with the row id whether or not the list is editing (inner controls still
  win the tap); a non-selectable row sends nothing on a plain tap.

### Alerts and dialogs: `alert`

A root-level element (a child of id 0) like `sheet`, placed after the tree.
`style: "alert"` draws an iOS alert: a dimming scrim, a 270 pt wide centered
card (`--sb-color-secondary-system-background`, 14 pt radius, 60% translucent
material look) with the bold title, the message below it, and the buttons:
two buttons side by side, otherwise stacked, separated by hairlines; a
`cancel` role is bold, `destructive` is red, others accent. `style: "dialog"`
draws an action sheet: the buttons in a bottom card above a separate
`cancel` button, the title (and message) as a dim caption on top (Swift sends
an empty `title` unless `titleVisibility` is `.visible`, as iOS hides it by
default; an empty title and null message mean no caption). A button
tap sends `tap` with the button id (Swift runs the action and dismisses); a
tap on the scrim of a dialog, or on an alert with no buttons, sends `tap` with
the alert's own id (dismiss). Both appear with a 0.25 s fade (alert: scale
from 1.1) and leave with a fade. An `alert` above a `sheet` covers the sheet.

Geometry (engine `chrome`: `card`, `title`, `message`, `button<i>`, `ok`,
`cancelCard`; `chromeText`: the centered `title` / `message` lines). The
scrim is 30% black; the layer never scales with the presenting tree behind a
`large` sheet. `alert`: the card is 270 wide (narrower only on a screen under
286), vertically centered; the title is `headline` (17 semibold) and the
message `footnote` (13), both centered, inset 20 top and bottom, 16 at the
sides and 4 apart; every button row is 44 high, two buttons share one row
(135 each), one or three and more stack in order, none gives a renderer-drawn
"OK" row. The scrim of an alert that has buttons swallows the tap (modal).
`dialog`: the button card and the cancel card are inset 8 from the screen
sides; the cancel card is 57 high and ends 8 above the bottom safe area, the
button card 8 above it (no `cancel` child: the button card sits there itself);
rows are 57 high with 20 pt labels, in declared order without the cancel;
the title (`footnote` semibold) and message (`footnote`) are a secondary-color
caption inset 14 top and bottom and 16 at the sides (absent when both are
empty, so the first row is flush with the card top). A button child gets its
row as its frame with the label centered (a `cancel` label is measured
semibold); a tap on it is an ordinary `tap`.

### Controls

- `stepper`: the label leads; a 94 × 32 pt segmented minus/plus control
  trails (`--sb-color-tertiary-fill` track, 8 pt radius, hairline divider); a
  disabled half (`canDecrement` / `canIncrement` false) is dimmed (35%) and
  swallows taps. A tap sends `step` with −1 / +1; Swift applies `step` within
  `in` and emits the new label. Like a `toggle`: the label is proposed the
  width minus 94 + 8, the height is max(label, 32), the width is the proposal
  or label + 8 + 94 (engine `chrome`: `label`, `control`, `decrement`,
  `increment`).
- `slider`: 4 pt track (`--sb-color-tertiary-fill`) with the accent fill from
  the minimum to the thumb, a 27 pt white shadowed thumb; width = proposal
  (200 when nil), height 44 (the label row above it when a label child
  exists, as iOS does in a `Form`: label leading, min/max value labels at the
  track's ends). The label row is the label's own height; the min / max
  labels sit 8 from the track's ends, vertically centered in the 44 row; the
  thumb's center travels from 13.5 after the track start to 13.5 before its
  end, so the thumb stays inside the track (engine `chrome`: `track`, `fill`,
  `thumb`). Dragging or tapping anywhere on the slider sends `slide`
  continuously (coalesced per frame, the thumb follows at once) with the
  value in `min…max`, snapped to `step` (to the nearest multiple from `min`,
  with the step's decimals; six decimals without a step); Swift confirms with
  `value`. Without `labels` all children are ignored.
- `datepicker`: compact style. The label leads; one or two capsule buttons
  trail (`--sb-color-tertiary-fill`, 8 pt radius) showing the date
  ("Oct 3, 2026") and/or the time ("7:00 AM") formatted by the renderer in
  US English from `seconds` in UTC. Each capsule is its text (body font) plus
  11 at each side, 34 high; two capsules are 8 apart and the label is proposed
  the width minus the capsules and 8; the width is the proposal or label + 8
  + capsules (engine `chrome`: `label`, `date`, `time`). `labelsHidden: true`
  drops the label child from the layout. Tapping one opens the browser's
  native `<input type="date">` / `<input type="time">` picker (an invisible
  input over the capsule); a change sends `date` with the combined seconds:
  the changed component replaces its part of `seconds` (day, or hour and
  minute) and the other part is kept; seconds within the minute are dropped.
  `min` / `max` bound the native date input; the time input only when they
  fall on the shown day.
- `texteditor`: a `<textarea>` filling the proposal (nil height → 100, nil
  width → 200, flexible on an infinite axis like a shape), body font, no
  border (its parent provides the frame); input sends `text` like
  `textfield`, and an `update` with the same `text` leaves the caret alone.

### Pull to refresh

A `list` or `scrollview` with `refreshable` lets a pull past 60 pt at the
top (by touch or wheel) send `refresh`; the renderer shows a spinner row of
40 pt above the content while `refreshing` is true (Swift sets it before
running the action and clears it after). `.refreshable` is an environment
hint the nearest `List` / `ScrollView` below it reads; a `refresh` that
arrives while `refreshing` is ignored. The action runs as a `Task` on the
host executor, so the clearing `update` arrives on a later `sb_run_jobs`.

Renderer details: the pull is read from the pointer (a mouse drag at
`scrollTop` 0, the content following with resistance and the spinner fading
in), from touch moves (the browser cancels the pointer once the scroller
scrolls, so touches are read directly), and from an upward wheel at
`scrollTop` 0 (a sequence ends 300 ms after its last event). Each pull or
wheel sequence sends `refresh` at most once, on reaching 60 pt (the pointer
pull sends on release). While `refreshing` the content is translated down by
40 (an iOS content inset: the last 40 pt scroll under the viewport edge) and
the spinner row sits at the top of the scroller.

### `styled` style (added fields)

| field | meaning |
|-------|---------|
| `rotation3D: { "angle": degrees, "axis": { "x", "y", "z" }, "anchor": UnitPoint, "perspective": number }` | `.rotation3DEffect`: CSS `perspective(500px / perspective) rotate3d(x, y, z, angle)` about `anchor` (default center); `perspective` default 1 → `perspective(500px)` (a fixed distance, not scaled by the box). A zero angle or axis is the identity. The transform list is `translate(offset) rotate(rotation) perspective() rotate3d() scale(scale)` (CSS applies it right to left: scale, 3D turn, 2D turn, offset); one `transform-origin`: `rotationAnchor`, else `rotation3D.anchor`, else `scaleAnchor` |
| `blur: number` | `.blur(radius:)`: `filter: blur(<radius>px)`, ahead of `shadow`'s `drop-shadow` in one `filter` (the shadow is cast by the blurred shape); 0 is the identity |
| `hitTesting: false` | `.allowsHitTesting(false)`: `pointer-events: none` on the subtree |
| `ignoresSafeArea: "all" \| ["top", "bottom", "leading", "trailing"]` | the engine extends the `styled` box to the screen edge on each listed side when the box's edge is at (or inside) the inset band it must clear: the safe area at the root, a `navscreen`'s bar (plus large title / search field) inside one, the tab bar under a `tabview`. The parent's layout is unaffected (the box reports its unextended size). A child whose size changes under the grown proposal (a color, gradient, shape, resizable image, a stack of them) is proposed the grown box and fills it, so `Image.resizable().ignoresSafeArea()` and `RadialGradient.ignoresSafeArea()` bleed under the (transparent) navigation bar, whose items stay on top; a child whose size does not change (a fixed `frame`) stays where the parent put it and the extension shows the box's own `background` only |

### Animation (extended)

`Animation` JSON gains `"repeat": { "count": number | "forever", "autoreverses": bool }`
(`.repeatCount(_:autoreverses:)`, `.repeatForever(autoreverses:)`): the WAAPI
`iterations` (Infinity for forever) and `direction: alternate` when
autoreversing, applied to the style and frame tweens of that commit. Enter
and exit transitions of the same commit play once (a forever repeat would
never drop the exit clone); a `count` of 0 or less is ignored.

### Navigation (extended): swipe back

A pointer that goes down within 24 pt of the leading edge of a `navscreen`
with `depth > 0` and moves right drags the screen interactively (the screen
below slides from −30% toward 0 and un-dims). Releasing past a third of the
width, or with a rightward velocity above 500 pt/s, sends `tap` with the
screen's id (the same pop the back button sends) and the renderer completes
the slide; otherwise the screen snaps back. Nothing is sent until release.

Renderer details: the drag starts once the pointer has moved 8 px right by
more than it moved vertically (a vertical move leaves the pointer to the
list); a pointer down in a text field never starts it. On touch devices a
24 pt strip along the edge owns the touch so the list does not scroll (a
plain tap on the strip is forwarded to what is under it); with a mouse the
pointer position alone decides. The screen below carries a 12% black dim at
rest (`--sb-nav-dim`, also during push and pop) that fades out with the
drag. After the `tap` the screens stay where the drag left them until the
`remove` arrives: the pop clone then slides from that position
(`--sb-nav-from`) instead of from 0, and the screen below finishes its slide
to 0 as the new top screen. Should no `remove` arrive within 0.7 s the screen
snaps back.

### Swift notes

- `DatePicker`'s selection is generic over `_DatePickerValue` (seconds since
  1970) and `ForEach.onDelete` / `remove(atOffsets:)` over `_OffsetSet`;
  `FoundationLite` conforms `Date` and its own `IndexSet` (FoundationEssentials
  has neither `IndexSet` nor the format styles). `_OffsetSet` is
  `public protocol _OffsetSet { init(_offsets: [Int]); var _offsets: [Int] { get } }`
  (ascending, unique). The shim also ships `_IndexSetLite: _OffsetSet`, which
  `onDelete` / `onMove` infer when nothing pins the type (an app without
  FoundationLite can write `.onDelete { items.remove(atOffsets: $0) }`);
  `remove(atOffsets:)` is on `RangeReplaceableCollection` and
  `move(fromOffsets:toOffset:)` on `MutableCollection & RangeReplaceableCollection`.
  `onDelete` / `onMove` are declared on `DynamicViewContent` (`ForEach` and
  their own result conform), so they chain as in SwiftUI. `Text(_:format:)` and
  `TextField(_:value:format:)` take a `_TextFormat` (format and parse);
  `FoundationLite` supplies `.currency(code:)` and `.number` for `Double` / `Int`.
- `AnyTransition.modifier(active:identity:)` is accepted and rendered as
  `opacity` (the shim cannot diff arbitrary modifiers yet).
- A `TabView` renders every tab, so only the selected tab's `navigationTitle`
  and `.toolbar` reach the enclosing (flattened) screen; a `NavigationStack`
  of its own inside a tab keeps its own title. Switching tabs re-emits the
  screen's title and bar items.
- Headless replay seeds WASI's `random_get` (`SB_SEED`, `scripts/run-wasi.mjs`),
  so apps that shuffle replay identically; the wasm links with an 8 MB stack
  (the reconciler recurses once per view).

### TypeScript

`src/protocol.ts`: `DragEvent`, `LongPressEvent`, `StepEvent`, `SlideEvent`,
`DateEvent`, `DeleteEvent`, `RefreshEvent`, `GestureProps`, `SwipeActionsProps`,
`ContextMenuProps`, `ListRowProps`, `AlertProps`, `StepperProps`, `SliderProps`,
`DatePickerProps`, `TextEditorProps`, `Style.rotation3D`, `Style.blur`,
`Style.hitTesting`, `Style.ignoresSafeArea`, `Animation.repeat`,
`EnvironmentEvent.scenePhase`, `ListProps.editing/refreshable/refreshing`,
`ScrollViewProps.refreshable/refreshing`, `SheetProps.detents` with `"full"`.

## Phase 9: incremental rendering

Nothing in the wire protocol changes: the ops a commit carries are the same
ops a full re-render would have produced. What changes is how little work
produces them, on both sides of the boundary.

### Swift: dirty-tracked reconcile

Every `_MountedNode` knows its `parent` and carries two flags: `dirty` (its own
state changed) and `subtreeDirty` (some descendant is dirty). The runtime marks
a node dirty, and the chain of ancestors above it `subtreeDirty`, when:

- one of the node's own `@State` / `@StateObject` boxes is written (each box's
  `_onChange` names its owning node);
- an `@Observable` property the node's body (or a `ForEach`'s row closures)
  read is mutated: each body runs under its own `withObservationTracking`;
- an `ObservableObject` the node observes (`@ObservedObject`,
  `@EnvironmentObject`, `@StateObject`) sends `objectWillChange`. Publishers
  keep one subscriber per observing node, so every observer of a shared
  object re-runs (a weak owner table, pruned on `send()`);
- the node's `editMode` or `GeometryReader` size changes.

`_Runtime.invalidate()` (no node) still means a full render: the renderer's
`environment` events use it, as does an observable read while producing
elements (rare; the element phase runs under a fallback tracker).

A render reconciles from the root with `force = fullRender`. For a node whose
`force` is false:

- not dirty and no dirty descendant: it is returned as is, children untouched;
- not dirty but `subtreeDirty`: its view value is unchanged, so its body does
  not run and no reflection happens; its children are the mounted ones (their
  stored views), reconciled with `force = false` in turn; the node's own
  setup (context for its children: navigation stack, screen, environment
  modifiers) still runs because a dirty descendant may need it;
- dirty: the body runs and every child is reconciled with `force = true` (a new
  view value cannot be compared with the old one, so the whole subtree re-runs).

Per-pass state that descendants fill in while they are reconciled
(`navTitle` / `navDisplayMode` on a screen, `splitSelection` on a split,
`sheetDetents` on a sheet) is reset only on a pass that re-ran the node's body;
a clean pass keeps what the skipped descendants set last time. A
`NavigationStack` / compact split keeps its `_NavContext` on the node across
passes (`detailShown` is recomputed only when the split re-runs, and the
screens' pop handlers capture it).

Known limit: a `.navigationTitle` that disappears (an `if` around it) while
its screen is clean leaves the old title; a title that changes value updates.

### Swift: cached elements and reconciler short-circuit

`elements(of:)` caches each node's output (`cachedElements`, plus the sheets
and alerts its subtree presented, which the element phase collects as side
effects). The cache is dropped for every node that re-ran and for every
ancestor of a dirty node; clean subtrees return the cached array.

Every `_Element` carries a `token`, fresh per produced element and kept by
copies. `_Reconciler.reuse` returns the previous `Rendered` unchanged when the
tokens match: no props comparison, no descent, no ops. Code that derives a
modified copy of a child's element must give the copy a fresh token
(`_hoistBarItems` does, and only when it actually removed something).

### Web: incremental layout

`LayoutPass` persists across commits. The renderer tells it what its ops did:

| Op | Invalidation |
|----|--------------|
| `update id` | the node, its ancestors (sizes may change) and its descendants (context may change); for a `navscreen`, the whole stack (the next screen's back label) |
| `insert parent id` | the old and new parents' ancestor chains, the moved subtree |
| `remove id` | the parent's ancestor chain; the subtree's frames, text, chrome, hidden entries and memo are dropped |
| environment, fonts, catalog | a new pass |

Invalidating a node drops its measure memo and its last placement (and those
of its implicit stack). `place(node, proposal, x, y)` returns immediately for a
node placed before with the same proposal at the same origin whose last
placement survived: the whole subtree's frames, chrome and text are still
right. Everything a run places, hides or drops is collected in
`LayoutResult.changed`; `applyLayout` visits only those ids (plus the
renderer's `dirty` set of elements whose inline style was reset) and keeps its
box map across runs. `hidden` entries are owned by the node that added them
and cleared when that node is placed again; a node's chrome from an earlier
run is dropped when it is placed again (a parent's `row` / `badge` set in the
same run just before is kept). `LayoutPass.stats` counts placed and skipped
nodes per run; `Web/e2e/perf.spec.ts` (`SB_PERF=1`) is the probe.

Measured on Food Truck's Truck screen (header at 20 fps, 733 elements) in
headless Chromium: Swift per tick ~24 ms → ~2 ms; renderer per commit ~5 ms →
~1.5 ms; main thread busy ~36% → ~6%; the timer reached its 20 fps.

## Phase 10: bring your own app

The protocol is unchanged. What this phase adds sits around it:

- `@swiftbrowser/web` exposes the renderer and dev server to other packages
  through an **app registry** (`AppSpec`: name, display name, the wasm path,
  the asset catalogs, the directories to watch and a `build` hook) and two
  calls, `createDevServer(options)` and `buildSite(options)`. The examples run
  through the same registry (`Web/node/examples.ts`); `Web/vite.config.ts` is a
  thin use of it. The landing page reads `__SB_APPS__` (name, display name,
  description, optional icon) instead of the old example list.
- `@swiftbrowser/xcodeproj` reads `project.pbxproj` (old-style plist) into
  targets with their Swift sources (navigator order), resource files, asset
  catalogs, build settings and package products, including Xcode 16
  synchronized folders and their exception sets. No Xcode needed.
- `@swiftbrowser/cli` stages a project into `<project>/.swiftbrowser/<Name>/`:
  a SwiftPM package depending on `@swiftbrowser/swift` by path, whose one
  executable target is a copy of the app's sources with three line-preserving
  adjustments (`import SwiftUI` also imports FoundationEssentials +
  FoundationLite, as Apple's SwiftUI re-exports Foundation and Combine;
  `import Foundation` becomes those two in lean mode; `#Preview` blocks are
  blanked) and `SWIFTBROWSER` defined. Diagnostics are mapped back to the
  user's files. `check` compiles natively (same errors, seconds), `dev` and
  `build` for WebAssembly.
- Shim additions driven by running the Hacking with SwiftUI projects in place:
  `_Publisher` + `View.onReceive(_:perform:)` (a task that subscribes while the
  view is shown), and in FoundationLite `Timer.publish(every:on:in:).autoconnect()`,
  `Timer.scheduledTimer`, `RunLoop`, and an in-memory `UserDefaults`.

Foundation, measured (wasm-opt `-Os`, stripped): `import Foundation` costs
nothing while unused, about 42 MB (36 MB of ICU data, 17 MB gzipped) as soon
as any symbol of it is referenced, because CoreFoundation's initialization
pulls FoundationInternationalization in; `FoundationEssentials` costs about
4 MB. Hence lean mode by default and `--foundation full` as the escape hatch.

**Trimmed ICU data for full Foundation** (`packages/cli/src/icu.ts`). The
SDK's `lib_FoundationICU.a` has one data object, `icu_packaged_data.cpp.obj`,
whose single wasm data segment is ICU 76's complete common-data package
(`icudt76_dat`, 34.0 MB, format "CmnD": a UDataInfo header, a table of
(nameOffset, dataOffset) pairs, NUL-terminated names, 16-byte-aligned items;
4,855 items, of which locale resource bundles in ten directories, collation
tables, converters, break-iteration rules and dictionaries). `icupkg` from a
host ICU refuses the file (ICU 76's name table is longer than ICU 74's
limit), so the CLI parses the `ar` archive, the wasm object and the CmnD
table itself. It keeps `root.res`, every item that is not a locale bundle
(string pools, `supplementalData`, `likelySubtags`, time zones, `ucadata.icu`
and root collation, `unames.icu`, the `.brk` rules, normalization data, small
converters) and the bundles of the configured languages (`"locales"`,
default `["en"]`), and drops the other languages' bundles in every
directory, `translit/`, the `.dict` word-break dictionaries (`cjdict.dict`
alone is 2 MB) and converters of 16 kB and more. English: 1,016 items,
5.6 MB; each further language 100 to 300 kB, Chinese 1.7 MB. A dropped locale
falls back to root formatting at run time.

Replacing the data at link time was the hard part. wasm-ld's `--wrap` only
handles functions (`symbol type mismatch` for a data symbol);
`--allow-multiple-definition` keeps the first definition, which is the
archive's because the swift driver hoists the SDK's `-L` ahead of any
user search path and puts `-Xlinker` inputs after the autolinked `-l`
flags; a `-L` with a modified archive copy loses for the same reason. What
does precede the archives on the link line is every target's object files,
so the staged package gets a C target `_ICUData` with one assembly file
(`#ifdef __wasm__` around a `.globl icudt76_dat`, `.p2align 4`, `.incbin` of
the trimmed package from `~/.cache/swiftbrowser/icu/<key>/`). Its object
defines the symbol before the linker reaches `lib_FoundationICU.a`, so the
archive's data member is never pulled; the SDK is untouched and a host
`check` build assembles an empty file. Result on the probe app
(DateFormatter, NumberFormatter, JSON, URLComponents, format styles):
46.7 MB → 19.2 MB optimized (code 10.7 MB, data 8.2 MB), 18.5 MB → 6.6 MB
gzipped, identical output in the headless harness.

## Phase 11: the real Foundation by default

The protocol is unchanged. The CLI's default Foundation mode flips from lean
(FoundationEssentials + FoundationLite) to full (the wasm SDK's
swift-corelibs Foundation with trimmed ICU data), because every Foundation
API then compiles as on iOS and the cost, measured on Counter with
`wasm-opt -Os`, is 18.3 MB raw / 6.7 MB gzip / 4.6 MB brotli against
4.5 / 1.7 / 1.2 MB lean. Compile time in Chromium is not the issue (90 ms for
the full module, 164 ms with the CPU throttled 4x); transfer is. Lean stays as
`--foundation lean`.

**Where the 10 MB over lean-with-Essentials goes** (linker map, before
wasm-opt): ICU code 2.0 MB, corelibs Foundation 1.7 MB, FoundationInternationalization
1.0 MB, CoreFoundation 0.6 MB; ICU data 4.7 MB plus 1.5 MB of their data
segments. The ICU data compresses 5:1 (4.7 MB → 1 MB brotli), so dropping more
of it barely moves the download; the two drops made here (the CJK / phrase /
loose / normal line-break rule variants, reached only through the Japanese and
Chinese bundles, and `unames.icu` behind the `\N{…}` regex escape) take the
English package from 5.6 to 4.7 MB raw. Converters (0.55 MB), language and
region display names (0.3 MB) and collation (0.94 MB) would be the next
candidates, each breaking a Foundation API when missing, so they stay.

**Coexisting with the real Foundation.** The shim never imports Foundation:
on wasm its modules are whole-module objects, so a single reference to
`CGSize` would link Foundation, CoreFoundation and ICU into every lean app.
Both therefore declare `CGFloat`, `CGPoint`, `CGSize` and `CGRect`, and an app
importing both gets "ambiguous use" (verified on the host: Swift has no
shadowing between two imported modules, even when one imports the other; it
only lets the *current* module shadow imports). So the CLI generates
`_SwiftBrowserFoundation.swift` into the staged app with public typealiases
pointing the four names at the shim's types (what `frame(width:)` and the
layout protocol take; `public` so the app's own public API may use them) and
`Timer` / `RunLoop` at `FoundationBridge`'s.

**What corelibs Foundation lacks on WASI**, found by probing the SDK:
`RunLoop` is marked unavailable, `Timer` compiles but does not link
(`CFRunLoopTimerCreate` and friends are undefined), `Timer.publish` is
Combine's, `Bundle.main` traps at first use, `NotificationCenter`'s closure
observers and `OperationQueue` are absent, `AttributedString(markdown:)` is
absent. Working: `UserDefaults` (in memory for the run), `Locale.current`
(`en_001`), `TimeZone.current` (GMT), `DateFormatter`, `NumberFormatter`,
`NSRegularExpression`, `Measurement`, `AttributedString`, `String(data:encoding:)`,
`IndexSet`, `localizedCaseInsensitiveContains`.

**FoundationBridge** (`Sources/FoundationBridge`, a product of the package and
of `@swiftbrowser/swift`) is the full-mode counterpart of FoundationLite's
glue: `IndexSet: _OffsetSet`, `Date: _DatePickerValue`, `Text(_:format:)` for
any `FormatStyle` with string output and `TextField(_:value:format:)` for any
`ParseableFormatStyle` (through a `_TextFormat` adapter and a new public
`TextField(_placeholder:value:format:)` on the shim), and `Timer` + `RunLoop`
with `publish(every:on:in:)` and `scheduledTimer(withTimeInterval:repeats:block:)`.
The timer logic moved into the shim as `_IntervalPublisher<Output>` and
`_ExecutorTimer`, which FoundationLite and FoundationBridge both wrap with
`Date`; a scheduled timer now stays alive until it fires (FoundationLite's used
to hold itself weakly, so an unretained `Timer.scheduledTimer` never fired).

**Staging in full mode**: `import SwiftUI` → `import SwiftUI; import Foundation;
import FoundationBridge`; `import FoundationLite` → `import FoundationBridge`
(the examples' `#if canImport(FoundationEssentials)` branch); `import
FoundationEssentials` stays (Foundation re-exports it); the generated typealias
file; the `_ICUData` target. `check` (a host build) works the same way, with
the host's Foundation. In a repository checkout the root package also declares
the example executables, whose names a staged example app shares, so
`scripts/full-foundation-examples.sh` uses the published layout of
`@swiftbrowser/swift` (`node packages/swift/sync-sources.mjs`) and builds every
example in full mode, running each headless with its snapshot events as a
smoke test (formatting differs from FoundationLite's, so no op-stream diff).
All six examples and the Hacking with SwiftUI projects that compile in lean
mode compile and run in full mode; Flashzilla's countdown ticks through the
bridge's timer.

### Phase 11 additions: the viewer's locale, saved defaults, the resource bundle

**Locale and time zone.** The page passes `TZ` (from
`Intl.DateTimeFormat().resolvedOptions().timeZone`) and `SB_LOCALE` into the
WASI environment at launch. corelibs Foundation honors `TZ` on WASI as is.
The locale is chosen by `Web/src/locale.ts`: the first of
`navigator.languages` whose language the module ships (`AppSpec.locales`,
`"locales"` in swiftbrowser.json, default `["en"]`, `"*"` for all), kept with
its region (`en-GB`); else the first shipped language; `?locale=` on the app
URL overrides. Clamping matters because a locale without data falls back to
ICU's root formatting (`2023 M11 14 23:13`). On the Swift side swift-foundation
has no hook: `LocaleCache.preferences()` returns a hard-coded `en_001` under
`NO_CFPREFERENCES` and reads no environment variable. The one call to it
(from `_currentAndCache`) survives as a relocation in the SDK's
`Locale_Cache.swift.obj`, so the staged full-mode package links with
`--wrap=$s20FoundationEssentials11LocaleCacheV11preferencesAA0C11PreferencesV_SbtyF`
(and the same for the static `Locale.preferredLanguages` getter), and
`FoundationBridge/Locale.swift` defines the `__wrap_` symbols: a class method
(Swift passes `self` in its own trailing parameter on wasm, and the cache
pointer must land there; a struct method or a free function has a different
arity, which the wasm validator rejects at instantiation) returning a struct
that mirrors `LocalePreferences` field for field (60 bytes on wasm32; the
caller reads `doCache` at that offset) with `locale` / `languages` from
`SB_LOCALE`. This is tied to the SDK's swift-foundation, which the CLI pins
and CI exercises (French formatting in a full-mode build). Verified: `fr-FR`
gives `Locale.current.identifier == "fr_FR"` and `€ 3,50`-style output once
the `fr` data is shipped; `en-GB` gives `14 November 2023 at 22:13`. Lean mode
keeps FoundationLite's US English.

**`defaults` op** (Swift → host): `{"op": "defaults", "values": {…}}`, the
whole `UserDefaults` dictionary after every change, emitted through the
render host like any op (headless prints it). The loader (`Web/src/wasm.ts`)
takes the last one of a batch out before the renderer sees the ops and hands
it to the page, which stores it in `localStorage["sb-defaults:<App>"]`; at
the next launch the page passes it back as `SB_DEFAULTS`. Values: strings,
booleans, numbers (whole numbers come back as `Int`), arrays, dictionaries,
`Data` as `{"$data": base64}`, `Date` as `{"$date": seconds}`, `URL` as
`{"$url": string}`. `register(defaults:)` values are fallbacks and never
saved. One `UserDefaults` for all suites.

**Resource bundle.** `AppSpec.resources` (the CLI fills it from the target's
Copy Bundle Resources, minus asset catalogs and what Xcode compiles:
storyboards, Core Data models, `.strings`, `Info.plist`; or `"resources"` in
swiftbrowser.json) is published by `Web/plugins/bundle-resources.ts` as
`/bundle/<App>.json` (`{files: [{path, size}]}`) plus `/bundle/<App>/<path>`,
a file under its base name, a folder with its structure. Before loading the
module the page fetches them all and mounts them read-only at `/bundle`
(a `PreopenDirectory` of `@bjorn3/browser_wasi_shim`, fd 3), where WASI libc
resolves paths, so `Data(contentsOf:)`, `FileManager` and `String(contentsOf:)`
work unchanged. `_FoundationCommon.Bundle` answers `main`, `url(forResource:
withExtension:)`, `path(forResource:ofType:inDirectory:)`,
`urls(forResourcesWithExtension:subdirectory:)`, `bundlePath` / `bundleURL` /
`resourceURL` (`SB_BUNDLE_PATH`, default `/bundle`), `bundleIdentifier`
(`SB_BUNDLE_ID`) and an `infoDictionary` with the app's name (`SB_APP_NAME`);
`localizedString` returns its key. The headless runner mounts `SB_BUNDLE_DIR`
at `/bundle` through Node's WASI preopens.

**`_FoundationCommon`** is the new shared target: `UserDefaults` and `Bundle`
written against FoundationEssentials (whose types the full Foundation
re-exports), re-exported by FoundationLite and aliased by FoundationBridge
(and again by the generated `_SwiftBrowserFoundation.swift`, so they shadow
corelibs Foundation's in the app).

## Phase 12: the rest of the Hacking with SwiftUI list

Phase 12 re-ran `check` over the nineteen Hacking with SwiftUI projects and
closed the shim gaps that stopped the ones no framework stands in front of.
Twelve now compile and run unchanged (1, 2, 3, 5, 6, 7, 8, 9, 15, 17, 18, 19);
the other seven need Core ML, `URLSession`/`AsyncImage`, SwiftData, Core Image,
PhotosUI, StoreKit, MapKit, Core Location or Local Authentication.

**Focus and submit.** `@FocusState` wraps a `State`, and `.focused($field)` /
`.focused($field, equals:)` put an `_FocusRequest` in the environment that
`TextField` and `SecureField` read: the `textfield` element gains
`"focused": true|false` on every pass, and the renderer calls `focus()` /
`blur()` after commit so the DOM follows the state (`syncFocus`). The reverse
direction is a new whole-event handler: `focusin` / `focusout` on a text field
send `{"type":"focus","id":N,"value":true|false}`, which the runtime routes to
the field's handler and which sets or clears the focus state. `.onSubmit`
marks the field with `"submit": true`; Enter in such a field sends
`{"type":"submit","id":N}` and the handler runs the closure (`SubmitTriggers`
is accepted and ignored). `.submitLabel(.go)` etc. emits `"submitLabel"`,
which becomes the input's `enterKeyHint`. Handlers that need the environment
implement `_EnvironmentHandling` and are assigned after the environment
modifiers have been applied.

**Preferred color scheme.** `.preferredColorScheme(.dark)` is an environment
modifier (`_PreferredColorSchemeModifying`) that the runtime notes while
mounting the active tree (inactive tabs don't count). When the preference
differs from the one last sent, `render()` prepends
`{"op":"update","id":0,"props":{"colorScheme":"dark"|"light"|null}}` for the
root; the renderer turns that into an `sb:color-scheme` DOM event and the page
applies the theme without persisting it (`null` returns to the stored or
system theme).

**Scroll targets.** `.scrollTargetBehavior(.viewAligned | .paging)` on a
`ScrollView` emits `"snap": "viewAligned" | "paging"` and `.scrollTargetLayout()`
on the stack inside it emits `"scrollTarget": true` on that `hstack` /
`vstack` only (the modifier is consumed by the first `_ScrollTargetContainer`
below it). CSS does the rest: `scroll-snap-type` on the scroll view and
`scroll-snap-align: start` (or `center` for paging) on the children of the
target layout. `.scrollClipDisabled()` and `.scrollBounceBehavior` are accepted
no-ops.

**containerRelativeFrame.** `.containerRelativeFrame(.horizontal)`, the
`count:span:spacing:` overload and the closure overload all become a style
hint `"containerRelativeFrame": {"horizontal": true, "a": slope,
"b": intercept, "alignment": …}` (vertical likewise), where
`length = a * container + b`; the closure is sampled at 0 and 1000 to recover
a linear function. The layout engine keeps the current container size in its
`Context` (the screen at the root, the viewport inside a `scrollPlan`) and
resolves the hint into a fixed frame before measuring the child.

**visualEffect.** `.visualEffect { content, proxy in … }` runs the closure
once with a zero-sized proxy and applies the resulting chain (offset, scale,
rotation, 3-D rotation, opacity, blur; the color effects are accepted and
dropped) through the ordinary `styled` wrapper, so the effects appear in that
element's style and are not sent separately.

**Typed environment objects.** `@Environment(Order.self) var order` and the
optional `@Environment(Order.self) var order: Order?` read an `Observable`
object stored by `.environment(order)`; a missing object traps with the type
name, like SwiftUI. `.environment(_:)` also accepts an optional object.

**Smaller additions.** `navigationTitle(Text)` and `navigationTitle(Binding<String>)`;
`dynamicTypeSize(range)` clamps to the nearest size in the range;
`accessibilityInputLabels`; `Angle * Double`, `/`, `+=`, `-=`;
`UITextChecker` (always "no misspelling") moved to FoundationBridge because
it needs `NSRange`.

**CLI.** Companion files and the lean `import Foundation` rewrite now import
Observation too, so a Foundation-only file can declare `@Observable`
(CupcakeCorner's `Order`). File references in `.pbxproj` whose case doesn't
match the checkout are repaired component by component (`repairCase`) because
Xcode on a case-insensitive disk never noticed. CI builds the twelve projects
in full mode and the ones without Foundation-only code in lean mode, and runs
Flashzilla, Moonshot and WordScramble headless; Moonshot must render
"Apollo 11" from its staged JSON and request the dark color scheme.

**Headless runner.** `SB_BUNDLE_DIR` is mounted only when the directory
exists; an app without resources stages none, and a missing preopen used to
abort `uvwasi_init` before `main` ran.

## Phase 13: networking

The app has no network of its own; the page is the network. Two additions to
the protocol carry everything: a `request` op from the app and a `response`
event from the page.

**`request` op.** `{"op":"request","id":N,"kind":"fetch"|"image","params":{…}}`.
The id is the app's, increasing per request; it never collides with element
ids (it is a different namespace). The loader (Web/src/wasm.ts) takes these
ops out of the batch before the renderer sees it and performs each with
Web/src/hostRequests.ts:

- `fetch`: `params` is `{"url","method","headers":{…},"body":base64|null,"timeout":ms}`.
  The page runs the browser's `fetch` with those (an `AbortController` for
  the timeout) and answers with `{"status","statusText","headers":{…},"body":base64,"url"}`;
  header names come back lower-cased, `url` is the final URL after redirects.
  HTTP errors are answers, not failures, as with Foundation's URLSession.
- `image`: `params` is `{"url"}`. The page decodes the image off screen (an
  `Image()`) and answers with `{"width","height"}` in pixels. The bitmap never
  crosses into Wasm: the `<img>` the renderer creates later finds it in the
  browser's cache.

**`response` event.** `{"type":"response","id":N,"value":…}` on success,
`{"type":"response","id":N,"error":"message"}` on failure. The message starts
with a tag the app maps to a `URLError`: `timeout`, `abort`, `offline`,
`network` (a failed fetch, which is also how the browser reports a CORS
refusal), `headless` (no fixture). The event goes through `sb_event_json` like
any other, which also runs the executor, so the awaiting task resumes at once.

**Runtime.** `_Runtime._hostRequest(kind:params:) async throws -> _JSON`
allocates the id, emits the op and parks a continuation until the matching
`response` arrives (`_HostRequestError` for `error`). `URLSession` and
`AsyncImage` (Sources/_FoundationCommon, shared by lean and full builds like
`UserDefaults`) are built on it.

**`URLSession`.** `data(from:)`, `data(for:)`, `upload(for:from:)`, the
completion-handler `dataTask` / `uploadTask` (started by `resume()`),
`URLSessionConfiguration` (its `httpAdditionalHeaders` and
`timeoutIntervalForRequest` are honored), `URLRequest` (method, headers,
body, timeout), `URLResponse` / `HTTPURLResponse` (status, headers, MIME type
and charset from `Content-Type`), `URLError` with Foundation's codes. In full
mode corelibs Foundation declares these names as unavailable (they moved to
FoundationNetworking, which the SDK does not ship), so FoundationBridge
aliases them and the CLI's generated `_SwiftBrowserFoundation.swift` settles
them in the bridge's favor, as for `Timer`.

**`AsyncImage`.** All three initializers (`url:scale:`,
`url:scale:content:placeholder:`, `url:scale:transaction:content:`) and
`AsyncImagePhase`. The view renders the placeholder (or `.empty`), sends an
`image` request from its `.task(id: url)`, and on the answer switches to
`.success(Image)`, an image whose props are the new URL form:
`{"url","resizable","width","height"}`, the size in points (`pixels / scale`).
The layout engine uses that as the intrinsic size (or the proposal when
resizable); the renderer sets the `<img>`'s `src` to the URL and marks the
element `data-sb-url`. A nil URL stays `.empty` and asks nothing; a failed
load is `.failure(URLError)`, which the two-closure form shows as the
placeholder, like SwiftUI. `Transaction` is a plain struct the shim accepts.

**Headless mode.** No page answers, so `SB_RESPONSES` (a JSON array) does:
each item names a `url` (exact) or a `prefix` and carries `status`, `headers`
and `body` (text) or `bodyBase64` for a fetch, `width` and `height` for an
image; a request with no entry fails with `headless: …`. The runtime answers
inside `_hostRequest`, so the awaiting task resumes on the next executor run
(`SB_RUN_MS`). `scripts/snapshot-test.sh` and
`scripts/full-foundation-examples.sh` read `Examples/<App>/__snapshots__/responses.json`
into it; `scripts/run-wasi.mjs` forwards it.

**Lean builds.** `error.localizedDescription` now exists in FoundationLite
(Foundation's comes from its NSError bridge, which FoundationEssentials lacks),
and `"\(value, format: style)"` interpolates with a FoundationLite style (the
bridge adds the overload for Foundation's `FormatStyle`).

**Example.** `Examples/CupcakeCorner` (Hacking with SwiftUI project 10):
`AsyncImage` from hws.dev, an order posted with `URLSession.upload` and the
echo decoded into the confirmation. Its snapshot replays the whole order with
`responses.json`; the Playwright test routes both URLs and asserts the POST's
JSON body and the alert.

## Phase 14: one Foundation

Lean mode is removed; every app links the real Foundation (Phase 11's build),
and the layers that existed for lean go with it:

- The SwiftUI shim imports Foundation. `onDelete` / `onMove` and
  `remove(atOffsets:)` / `move(fromOffsets:toOffset:)` take an `IndexSet`,
  `DatePicker` a `Date`, `Text(_:format:)` and the `"\(value, format:)"`
  interpolation a `FormatStyle`, `TextField(_:value:format:)` a
  `ParseableFormatStyle`. The protocols that stood in for them
  (`_OffsetSet` with `_IndexSetLite`, `_DatePickerValue`, `_TextFormat`) and
  FoundationBridge's conformances to them are gone.
- `Sources/FoundationLite` and `Sources/_FoundationCommon` are deleted.
  `FoundationBridge` holds what corelibs Foundation lacks on WASI: `Timer`,
  `RunLoop`, `UserDefaults`, `Bundle`, `URLSession` and friends, `AsyncImage`,
  the `Locale` hook, `UITextChecker`. The CLI's generated alias file settles
  the names Foundation also declares; `AsyncImage` needs no alias.
- The root `Package.swift` declares the three libraries and `HelloWasm` only.
  The examples are built through the CLI (`scripts/build-wasm.sh` runs
  `swiftbrowser build Examples/<App>`), so they get the same trimmed ICU data,
  generated alias file and wasm-opt as any app, and their sources say
  `import Foundation` like an Xcode project. `scripts/full-foundation-examples.sh`
  is gone (it was this).
- Headless snapshots run in `en_US` and GMT (`SB_LOCALE`, `TZ` in
  `scripts/snapshot-test.sh`), so currency is `$`, and are recorded with ICU's
  formatting.
- CLI: `--foundation` and `"foundation"` in `swiftbrowser.json` are accepted
  and ignored with a warning; `import FoundationLite` in an old file becomes
  `import FoundationBridge`; `check` no longer prints a Foundation mode.

## Phase 15: smaller modules

Where an 18 MB module went (Counter, optimized): 10.8 MB of code, of which
the app itself is 6 kB and the shim 0.6 MB; the rest is the Swift standard
library (3.5 MB, with every generic specialization), FoundationEssentials
(2.3 MB), ICU's library (1.35 MB), the NSObject layer of corelibs Foundation
(0.9 MB), the runtime, CoreFoundation, libc++ and the regex engine; and
7.1 MB of data, 4.7 MB of it ICU's locale package trimmed to English. The
linker's dead-code elimination works, at function granularity: what survives
is reachable through protocol conformances (any dynamic cast or
`String(describing:)` may need any conformance, so every witness table of
every linked type stays, with its methods: 366 kB of `description` getters
nobody calls, 343 kB of Codable witnesses), through per-object-file metadata
segments, and through the dense NSObject graph that FoundationEssentials
itself pulls in. The SDK's archives are machine code, so the compiler's
whole-program eliminators cannot run over them. What this phase changes:

- **ICU trimming drops more.** The legacy character-set converters (`*.cnv`,
  549 kB; UTF-8/16/32, ASCII and Latin-1 are algorithmic, so only
  `String(data:encoding:)` with Shift JIS, Windows-1252 and the like loses,
  and returns nil), the StringPrep profiles (`*.spp`, IDNA 2003 and LDAP) and
  the spoof-checker data (`confusables.cfu`). `"collation": false` in
  `swiftbrowser.json` also drops the collation tables (`coll/`, 0.9 MB) for
  apps that never compare strings in a locale: `localizedCompare`,
  `localizedStandardCompare` and `.localizedStandard` sorting then order by
  Unicode scalar instead of failing.
- **wasm-opt runs `-Oz`** (measured as fast as `-Os` on an 18 MB module, and
  1.3% smaller), and the CLI removes the `.swift1_autolink_entries` custom
  section afterwards (108 kB of library names the linker already used;
  `--strip-debug` leaves it). `packages/cli/src/wasm.ts` splits a module into
  sections and rewrites the custom ones; nothing else is touched.
- Counter: 17.4 MB (4.5 MB brotli), from 18.4 MB (4.6 MB).
- Not done, measured: `-Osize` for the shim and app saves 26 kB (0.1%); the
  shim's own references into the NSObject layer (`IndexSet`, `CharacterSet`)
  cost nothing to remove because FoundationEssentials pulls the layer anyway.
- Built, measured and parked on the `icu-sidecar` branch: the ICU data as a
  content-named file beside the module (`icudt76l-<hash>.dat`), which the
  module reserves zero-filled memory for and exports the address of, and the
  loaders copy into memory before `_start` from a `swiftbrowser.icu` custom
  section's directions. Counter becomes a 13.6 MB module (3.6 MB brotli) plus
  a 3.9 MB file (0.8 MB) that every app on a site shares and an update never
  re-downloads; wasm-opt gets 2 s faster. For a single-app site that is a
  fifth off each update's download, against a second file the module cannot
  start without, so it is not in.

## Phase 16: skipping unchanged views

Before this phase, a body that re-ran forced every view below it to re-run:
the runtime could not tell whether a child's new view value differed from
the one it held. In Food Truck a new order re-ran the whole Truck screen
under `TruckView` (which observes the model), though only the orders card
showed anything new: 37 ms of a 81 ms task, during which the truck header,
redrawn every 50 ms, stalled.

When a parent re-runs, a child is now treated as clean (its body and its
subtree are skipped, its elements reused) when all of these hold
(`_Runtime.canSkip`):

- the child is not dirty itself (its own `@State`, observed object or
  `@Observable` read did not change);
- it has a body of its own: the shim's containers and primitives (stacks,
  modifiers, `Text`, `ForEach`) re-run as before, because they are cheap and
  their values hold their whole content, which would be walked again at
  every level; the views with bodies inside them are compared one by one;
- its new view value is the same as the one it holds (`_Sameness`, by
  reflection, with each type's strategy worked out once): `Equatable` values compare with `==`; class instances by
  identity; `@State`, `@StateObject`, `@FocusState`, `@Environment`,
  `@EnvironmentObject` and `@ScaledMetric` are ignored (they belong to the
  node or come from the environment); structs, tuples, enums, optionals and
  collections member by member. A closure, a binding or a value larger than
  256 members is different. A view type that holds a closure or a binding is
  remembered and not compared again;
- the context it receives is the same: the environment (every entry compared
  the same way), the path, the enclosing navigation stack, screen, sheet,
  split view and tab state. The values the runtime itself re-creates on
  every pass carry an identity for this: a screen's or sheet's
  `DismissAction`, the edit-mode binding a screen or list owns and a
  `List(selection:)`'s selection (their identities include the current mode
  or selection, so a change re-runs the views below);
- nothing in its subtree writes to its ancestors while reconciled: a
  navigation title, a destination, presentation detents, a list selection,
  a stack, split view or tab structure. Their ancestors reset what they set
  when they re-run and expect them to set it again.

The rules are conservative: anything not proven equal re-runs, as before.

Measured on Food Truck's new order (Chromium, optimized module): the render
pass reconciles 142 nodes instead of the whole tree's 1,572; the order task,
69 ms (median of nine orders) before, is now under 50 ms for five orders of
nine and 52 to 62 ms for the others; the longest gap between truck header
frames around an order is 110 ms (median) instead of 136, against about
100 ms between frames when nothing happens.

## Phase 17: cheaper passes

Phase 16 cut how many views a pass re-runs. This phase cuts what each pass
costs, measured on Food Truck's Truck screen. There the header animates,
which means a render pass several times a second.

The runtime side:

- **View traits per type** (`_ViewTraits`, Runtime/ViewTraits.swift).
  Reconciling a node asked its view about two dozen runtime protocols
  (`_GroupView`, `_HostView`, `_SheetPresenting`, `_OnAppearing`, and so on).
  Producing its elements asked about more. Each question was a dynamic cast
  to an existential, which is a conformance lookup every time. The answers
  are now worked out once per view type, from the metatype, and cached as a
  bit set. A cast still runs where the runtime needs the view's values, and
  only for protocols the type conforms to. The skip gates of Phase 16 (has a
  body, writes to its ancestors) became bit tests.

  The answers come from the type, not from the value. A dynamic cast of an
  `Optional` value unwraps it, so before this phase `if show { Text("x")
  .onAppear { ... } }` was both a group (the optional) and an `onAppear`
  view (the text inside). The `onAppear` action ran once for each of the
  two nodes, and a `.task` started twice. An optional is now a group and
  nothing else.
- **Modifier chains in linear time.** Every modifier is a `_StyledGroup`
  around its content. It applies to each child of a content that is a group
  of other than one child (or a keyed one, such as `ForEach`), so both its
  children and its keys asked the content for its children and its keys.
  Down a chain of n modifiers that was about 2^n calls, each allocating an
  array. A view with eight modifiers cost 256 of them per pass. Groups now
  answer `_spreads` directly, and a chain asks each level once.
- **Toolbar hoisting by token.** A screen moves `toolbar` and `searchfield`
  elements from anywhere in its content to its own children. That walked the
  whole content on every pass that produced the screen's elements. The
  subtrees found to hold none are remembered by element token, and a clean
  node's cached elements keep their token. A pass now walks only the
  elements produced anew.

The renderer side:

- **Style snapshots only when a pass can animate.** Before applying an
  `update`, the renderer recorded the element's computed animatable styles,
  and before a `remove`, its rectangle, in case the pass animated. Each read
  forces a style recalculation in the middle of the batch. The renderer now
  looks ahead to the pass's `commit` when the pass starts. The pass can
  animate when that commit has an animation, or when one of its updates
  changes an `animationToken`. Only then does it take the snapshots, and it
  reads the animation root's rectangle once per pass. A pass whose `commit`
  is in a later batch is assumed to animate. A pass that cannot animate
  still retargets running frame tweens, which needs none of this.
- **The screen size after a resize only.** Every layout read the root's
  `clientWidth` and `clientHeight` after the pass had changed the DOM: a
  forced layout. The size is now read once and again only after a
  ResizeObserver reports a change, or when `viewportChanged` is called.
  Without a ResizeObserver it is read on every pass, as before.
- **Spring curves cached.** A spring's `linear()` easing (60 samples) was
  computed for every tweened element. It is now cached per duration and
  bounce.

Measured with the optimized module in Chromium, three runs each. On the
Truck screen at rest, script time per second goes from 127–150 ms to
90–102 ms: wasm from 76–87 to 58–68, JavaScript from 45–58 to 27–30. In
a profile of the unoptimized module, which keeps the names, dynamic casts
went from 84 ms to 17 ms over the same six seconds. The renderer's `update`
went from 31 to 13 ms per second. The new-order task's long tasks are
50–55 ms instead of 55–74, and there are fewer of them. The header's frame
gaps around an order are within noise of before. They are dominated by the
header's own cadence, about 100 ms between redraws in this measurement.

## Phase 18: SwiftData on IndexedDB

An app that imports SwiftData links the `SwiftData` module of the Swift
package (Sources/SwiftData, with its macros in Sources/SwiftDataMacros). The
models live in Swift memory for the run; the page keeps them across launches
in IndexedDB. The protocol gains one op and one launch variable.

**`swiftdata` op** (Swift → host): one save of one store,
`{"op":"swiftdata","store":"default.store","put":[{"id":1,"entity":"Book","values":{…}}],"delete":[2,3]}`,
plus `"clear":true` when `ModelContainer.deleteAllData()` emptied the store
first. `put` holds every model the save inserted or changed, whole; `delete`
the keys of the models it deleted. The loader (Web/src/wasm.ts) takes these
ops out of the batch, in order, and hands each to the page, which writes it
in one IndexedDB transaction (Web/src/swiftData.ts). Headless runs print it
like any op.

**`SB_SWIFTDATA`** (host → Swift, at launch): every store the app saved,
`{"<store>":[{"id":1,"entity":"Book","values":{…}},…]}`. The page reads the
app's database before it instantiates the module and passes it only when it
holds records. `?swiftdata=reset` on the app URL deletes the database first.

**The database.** One per app, `sb-swiftdata:<App>`, version 1, with one
object store, `records`, keyed by `[store, id]`; a row is
`{store, id, entity, values}`. A store is named after its configuration's
URL: `default.store` for `ModelConfiguration()`, `<name>.store` for a named
one. In-memory configurations (`isStoredInMemoryOnly`) send nothing.

**Values.** A record's `values` holds every persisted property: strings,
numbers and booleans as themselves, `Date` as seconds since 1970, nil as
`null`, other `Codable` values as `JSONEncoder` writes them (sorted keys,
dates as seconds since 1970; `UUID` and `URL` as strings, `Data` as base64,
enums with raw values as the raw value). A to-one relationship is the
related record's id or `null`, a to-many one an array of ids. Ids are unique
across the app's stores and increase; a launch continues after the largest
one it was handed.

**Headless.** `scripts/run-wasi.mjs` forwards `SB_SWIFTDATA`. With
`SB_SWIFTDATA_FILE` set, that file plays the database: its stores are handed
in at launch and the run's `swiftdata` ops are applied to it afterwards, so a
second run starts from what the first saved (CI runs Bookshelf and
SwiftDataProject twice this way).

### `@Model`

Apple's SwiftData expands `@Model` with macros, and so does this one; the
expansion follows Apple's shape so that what compiles there compiles here.

- Each stored `var` of the class that is not `@Transient` (nor static, lazy
  or computed) gets `@_PersistedProperty`, which turns it into a computed
  property over the model's backing data: an `init` accessor
  (`@storageRestrictions(accesses: _$backingData, initializes: _title)`) so
  that `self.title = title` in the class's initializer and an initial value
  (`var city = "Unknown"`) still work, and a getter and setter wrapped in
  Observation's `access` / `withMutation`, so models are `Observable`. A
  `_title: _SwiftDataNoType` peer keeps definite initialization honest: the
  class's initializers must still give every persisted property a value.
- The class gains `_$backingData`, `persistentBackingData`, an observation
  registrar, `required init(backingData:)` (how a context materializes a
  stored record) and `static var schemaMetadata`, one
  `Schema.PropertyMetadata(name:keyPath:defaultValue:metadata:)` per
  persisted property with its initial value and its `@Attribute` or
  `@Relationship` arguments.
- An extension adds `PersistentModel`, which is `Observable`, `Hashable`
  (by `persistentModelID`) and `Identifiable` (`id` is the
  `persistentModelID`, unless the class declares its own `id`).

The metadata is built in a generic context where each property's type is
known, so the store learns there how to encode and decode the property,
whether it relates to other models (a model, an optional model, an array or
set of models, optional or not) and how to notify its observers.

**Backing data.** A model's values are live Swift values once set or read;
a model fetched from the store holds its record's JSON until a property is
first read, so a fetch decodes only what a screen shows and a relationship
looks up its models only when it is followed. A property missing from a
record (added to the class since the data was saved) takes its initial
value, or nil when it is optional.

**Contexts.** A context keeps one object per record (two fetches return the
same model), the inserted, changed and deleted models, and the store it
saves to. `fetch` evaluates the descriptor's predicate (Foundation's
`Predicate.evaluate`) over the store's records of the entity and the
context's unsaved inserts, sorts with the `SortDescriptor`s, then applies
`fetchOffset` and `fetchLimit`. Setting a relationship inserts the models it
now holds into the context and keeps the inverse in step on both sides; the
inverse is the one `@Relationship(inverse:)` names on either side, else the
only relationship of the related type that points back. Deleting applies
the delete rules: `.cascade` deletes the related models, `.nullify` (the
default) and `.deny` take the model out of the inverse, `.noAction` leaves
it. `@Attribute(.unique)`: a new model whose value a saved record already
has replaces that record when it is saved. A save in one context reaches the
store's other contexts (their unchanged models read the saved values; deleted
records leave). `rollback()` returns inserted, changed and deleted models to
their saved state.

**Autosave and `@Query`.** A container's `mainContext` has `autosaveEnabled`;
a change schedules a save through `_Runtime._afterUpdate`, which runs once
the event (or executor run, or launch) that made it has rendered, so one tap
is one `swiftdata` op. Every change to a context bumps an observable
generation that `@Query` reads while the view's body runs, so the view
re-runs on inserts, deletes and property sets; the query fetches again only
when the generation moved. `@Query` reads `modelContext` from the environment
through the shim's new `_EnvironmentReading` protocol, which, unlike
`@Environment`, stays part of the view's value for Phase 16's sameness check:
a parent that rebuilds a child's query with other inputs re-runs the child.
`.modelContainer(for:)` on a view or scene makes its container once (by
model types) and puts its main context in the environment; a scene modifier
wraps the root view through the shim's `_RootModifiedScene`.

**`#Predicate`.** The open-source Foundation macro accepts
`localizedStandardContains`, `localizedCompare` and `caseInsensitiveCompare`
only when it is built into Apple's Foundation, and corelibs Foundation has no
expressions for them, so a common SwiftData filter
(`$0.name.localizedStandardContains(search)`) did not compile. SwiftData
carries a copy of the macro (Sources/SwiftDataMacros/PredicateMacro.swift)
that accepts them, with their expressions (Sources/SwiftData/
StringPredicates.swift). For an app that imports SwiftData, the CLI's
generated `_SwiftBrowserFoundation.swift` declares `macro Predicate` pointing
at that copy; a macro declared in the app module shadows Foundation's, and
the type `Predicate` stays Foundation's.

**Building.** The macros need swift-syntax. The package depends on 604.x,
which swift.org publishes prebuilt for Swift 6.4 (`Downloading package
prebuilt …MacroSupport`), so the macro target builds in seconds; elsewhere
SwiftPM compiles swift-syntax once. SwiftPM fetches it only for an app that
links SwiftData: the CLI adds the `SwiftData` product to the staged manifest
when a source imports it.

**Not yet.** `@ModelActor`, history tracking, CloudKit sync, undo, subclassed
models, `.deny` blocking a delete, and `includePendingChanges: false` (pending
inserts are always fetched). Nothing stops two tabs of one app from writing
the same database; the last save of a model wins.

## Sharing

`ShareLink` is a button whose action sends a host request (Phase 13's
channel) of kind `share`:

```jsonc
{ "op": "request", "id": 7, "kind": "share",
  "params": { "title": "Swift", "text": "The Swift programming language", "url": "https://www.swift.org" } }
```

| param | from |
|-------|------|
| `title` | the `subject`, else the `SharePreview` title |
| `text` | the `message`, then each `String` item (and each `URL` after the first), one per line |
| `url` | the first `URL` item (or the URL of an `AsyncImage`-style `Image`) |
| `images` | asset-catalog names of the `Image` items; a symbol image has no file and is left out |

The page shares with `navigator.share`. Images are fetched from the asset
catalog and passed as `files`, when `navigator.canShare({files})` allows it.
Without the Web Share API, or when the browser refuses (no user activation,
unsupported data), it downloads instead: each image as its file, or else the
title, text and link, one per line, as `<title>.txt` (`Shared.txt` without a
title). A share the viewer dismisses (`AbortError`) downloads nothing. The
`response` value is `{ "outcome": "shared" | "downloaded" | "cancelled" }`,
which the app does not see, as SwiftUI reports none. Headless runs answer
`{ "outcome": "headless" }` at once.

Three framework stand-ins ship with the shim as their own small modules,
linked only into apps that import them (`STAND_IN_FRAMEWORKS` in
packages/cli/src/compat.ts):

- `StoreKit`: `RequestReviewAction` (`@Environment(\.requestReview)`) and
  `SKStoreReviewController.requestReview()` are no-ops.
- `CoreLocation`: `CLLocationCoordinate2D` (and `CLLocationDegrees`,
  `CLLocationDistance`, `CLLocationCoordinate2DIsValid`,
  `kCLLocationCoordinate2DInvalid`) as plain values. It has no protocol
  conformances, as on Apple platforms, so an app's own
  `extension CLLocationCoordinate2D: Equatable` compiles.
- `LocalAuthentication`: `LAContext`. `canEvaluatePolicy` returns `true`,
  `evaluatePolicy` replies `(true, nil)` at once (the async form returns
  `true`), and `biometryType` is `.faceID`. `LAPolicy`, `LABiometryType` and
  `LAError` with its codes are there too. It is `@unchecked Sendable`, so an
  app awaiting it from the main actor compiles in Swift 6.

## Phase 19: the app's files

The app gets a writable home directory that the page keeps across launches,
with the layout of an iOS app container. Nothing new crosses the op or event
channels: the files live in the WASI filesystem the page provides.

**Mounts and environment.** Next to `/bundle` (Phase 11, read only) the loader
(Web/src/wasm.ts) preopens:

- `/data`, the home: `Documents`, `Library`, `Library/Application Support` and
  `Library/Caches` always exist, plus whatever the app saved;
- `/tmp`, empty at every launch.

and sets `HOME=/data`, `XDG_DATA_HOME=/data/Library/Application Support`,
`XDG_CACHE_HOME=/data/Library/Caches` and `TMPDIR=/tmp` (unless the page passes
its own). With those, corelibs Foundation's `FileManager.urls(for:in:)`
answers with the iOS directories (`.documentDirectory` → `/data/Documents`,
`.applicationSupportDirectory` and `.cachesDirectory` → their `Library`
folders), and `FileManager.temporaryDirectory` is `/tmp`. Without `HOME`,
Foundation's search-path lookup recurses until the stack overflows; without a
`Documents` directory, writing into it fails, as it never does on a phone.

**FoundationBridge** (Sources/FoundationBridge/FileSystem.swift) supplies what
swift-foundation declares only in Apple's Foundation: `URL.documentsDirectory`,
`URL.libraryDirectory`, `URL.applicationSupportDirectory` and
`URL.cachesDirectory`, derived from the same environment. On WASI it also
supplies `Data.WritingOptions.atomic`: corelibs marks its own unavailable there
("temporary files are not supported") and writes every file in place whatever
the options say, and an unavailable declaration loses overload resolution to
an available one, so `try data.write(to: url, options: [.atomic,
.completeFileProtection])` compiles and writes. (Natively both are available,
so the bridge's is WASI-only.)

**Persistence.** One IndexedDB database per app, `sb-files:<App>`, version 1,
with one object store, `entries`, keyed by `path` (relative to the home):
`{path, data}` for a file, `{path, directory: true}` for a directory
(Web/src/appFiles.ts). Before the module starts the page reads every row and
builds the `/data` tree (`?files=reset` deletes the rows first). The loader
wraps the WASI imports that can change a file (`fd_write` to a descriptor above
2, `fd_pwrite`, `fd_allocate`, `fd_filestat_set_size`, `path_open` with
`CREAT` or `TRUNC`, `path_create_directory`, `path_remove_directory`,
`path_unlink_file`, `path_rename`, `path_link`); after a drain in which one
ran it calls `onFilesWritten`, and the page diffs the tree against what it last
saved (files by their bytes, since a write in place keeps the same buffer) and
writes the difference in one transaction. Writes to `/tmp` or `/bundle` set
the flag too but find nothing to save in the home.

**Headless.** `scripts/run-wasi.mjs` mounts `/data` from `SB_FILES_DIR` (created
with the standard directories if needed), so a second run sees what the first
wrote, or from a fresh temporary directory; `/tmp` is a fresh temporary
directory. CI runs `Examples/Journal` twice this way.

**Not yet.** File coordination, iCloud Drive, `fileImporter` /
`fileExporter`, and file attributes beyond size (dates are the shim's).
Nothing stops two tabs of one app from writing the same home; the last write of
a file wins.

## Core ML models

No protocol change: a Core ML model runs inside the module. Xcode compiles
each `.mlmodel` of a target's Sources into a Swift class (`SleepCalculator`,
`SleepCalculatorInput`, `SleepCalculatorOutput`) and an `.mlmodelc` the class
loads; @swiftbrowser/cli does the equivalent when it stages the app, for the
model types Create ML makes from tables, and the `CoreML` module of the Swift
package (Sources/CoreML) evaluates them.

**Finding the models.** The target's Sources build phase and its
synchronized folders (`models` in @swiftbrowser/xcodeproj), every `.mlmodel`
and `.mlpackage` under a plain folder, or the ones a `swiftbrowser.json`
`sources` list names (a model file, or a folder holding some). An
`.mlpackage` is read through its `Manifest.json` (the root model's
specification under `Data/`).

**Reading them.** packages/cli/src/protobuf.ts reads the protocol buffers
wire format (varints, fixed-width numbers, length-delimited fields, packed
and unpacked repeated fields); packages/cli/src/coreml.ts reads `Model` from
apple/coremltools' Model.proto with it, field by field, and checks what it
finds. Supported, at any specification version:

| Model.proto type | What it is in Create ML |
|---|---|
| `glmRegressor` | linear regression (`MLLinearRegressor`) |
| `glmClassifier` | logistic regression (`MLLogisticRegressionClassifier`) |
| `treeEnsembleRegressor`, `treeEnsembleClassifier` | boosted trees, random forests, decision trees |
| `pipelineRegressor`, `pipelineClassifier`, `pipeline` | the wrapper every tabular model is |
| `featureVectorizer`, `oneHotEncoder`, `imputer`, `normalizer`, `scaler`, `dictVectorizer`, `arrayFeatureExtractor`, `categoricalMapping`, `identity` | the feature engineering inside the pipeline |

Anything else stops the build with the file, the step and the type named:
`Classifier.mlmodel: the model is a neural network (neuralNetwork), which
the browser cannot run. Supported: …`, or `pipeline step 2 is a support
vector machine regressor (supportVectorRegressor)`. So do image, sequence and
state features, multifunction models, unreadable files, a tree whose
branches name a missing node, and a GLM classifier whose weight rows do not
fit its classes.

**The generated class.** `<Class>.mlmodel.swift` in the staged sources (the
class is the file's base name, other characters as `_`), with Xcode's API:
`model: MLModel`, `urlOfModelInThisBundle`, `init(model:)`, `init()`
(deprecated, as Xcode marks it), `init(configuration:) throws`,
`init(contentsOf:)`, `init(contentsOf:configuration:)`, the four
`load(…)` functions (completion handler and async), `prediction(input:)`,
`prediction(input:options:)`, `prediction(<one argument per input>)` and
`predictions(inputs:options:)`. The input class stores one property per
feature and the output class reads one per feature, typed as Xcode types
them: `Int64`, `Double`, `String`, `MLMultiArray`, `[String : Double]` or
`[Int64 : Double]`. SleepCalculator's inputs are all `Double`
(`prediction(wake:estimatedSleep:coffee:)`). Feature names become
identifiers the same way, keywords in backticks.

**The model inside it.** The class carries the model's specification as
JSON in a raw string literal, which `urlOfModelInThisBundle` registers
under the class name; `MLModel(contentsOf:)` finds it by the URL's base name
(`/bundle/SleepCalculator.mlmodelc`) and decodes it once. JSON rather than
Swift literals: the compiler type-checks a string in no time whatever the
model's size (a boosted tree classifier has thousands of nodes; a Swift
array literal of them is slow to type-check), the generator is one
serializer, and one evaluator in the module serves every model, tested once.
Rather than a resource file: nothing else to stage, serve or fetch, and
`check` compiles exactly what runs. The JSON mirrors Model.proto: each model
is `{"specificationVersion", "description": {"inputs", "outputs",
"predictedFeatureName", "predictedProbabilitiesName", "metadata"}, "model":
{"kind": …}}`, a feature `{"name", "type", "optional", "shape", "dataType",
"keyType"}`. Trees are resolved by the CLI: per tree, columns of node
behaviors, feature indices, thresholds, true and false children as positions
(the root first, -1 for a leaf), missing-value directions and the leaves'
(index, value) pairs. Keys of int64 maps are written in decimal; NaN and the
infinities as `"nan"`, `"inf"` and `"-inf"`.

**Evaluation** (Sources/CoreML/Evaluation.swift), as Model.proto defines it:

- A pipeline runs its models in order over one set of named values; the
  outputs are the values its description names.
- GLM regressor: `w · x + b` per weight row, then the transform (none,
  logistic, probit). GLM classifier: one weight row is binary (the second
  label's probability is σ or Φ of it); one row per class is one-vs-rest
  (each σ, normalized to sum to 1); one row per class but the first is a
  reference-class multinomial (softmax over 0 and the rows).
- Tree ensembles: from the base values, each tree adds its leaf's values;
  `≤ < ≥ > == ≠` branches, a NaN follows the node's missing-value direction.
  Regressors may apply a logistic; classifiers apply softmax, softmax with
  the first class at 0, a logistic (one dimension is binary), or nothing (one
  dimension is the second label's probability, as random forests have it).
  The predicted label is the most probable (the first on a tie).
- `x` for a GLM or a tree ensemble is its inputs' numbers concatenated in
  declared order. A feature vectorizer does the same and also expands a
  sparse one-hot dictionary to its dimensions.
- One-hot encoder: a 1 at the category's position, as an array or (sparse) a
  `{index: 1}` dictionary; an unknown category is all zeros or an error, as
  the encoder says. Imputer: a missing value, or NaN (or the value the model
  names), becomes the imputed one; arrays element by element, dictionaries
  key by key. Normalizer: divided by the largest magnitude, the L1 or the L2
  norm. Scaler: `scale × (x + shift)`.

`MLModel.prediction(from:)` checks the inputs against the description first:
a required input missing is `MLModelError.featureType` ("Feature
estimatedSleep is required but not specified."), an optional one may be
absent, integers and doubles convert into each other, a string for a number
is an error. Outputs come back as an `MLDictionaryFeatureProvider` of the
declared outputs only. Predictions are synchronous; `computeUnits` and the
other configuration is accepted and ignored.

**Not provided.** Image and sequence features (`CVPixelBuffer`,
`MLSequence`), `MLModel.compileModel(at:)` (there is no compiler at run
time), `MLShapedArray`, model updates (`MLUpdateTask`), and every model type
outside the table, with Vision and Natural Language.

**BetterRest.** SleepCalculator.mlmodel (534 bytes) is a Create ML linear
regressor: a feature vectorizer of `wake`, `estimatedSleep` and `coffee`
into a GLM, `actualSleep = 0.00008977302487916403 wake + 3601.4055324351725
estimatedSleep + 832.4267097286028 coffee + 461.66411230346455`. At the
app's defaults (wake at 7:00, i.e. 25 200 s; 8 hours; 1 cup) that is
30 107.6 s, 8 h 21 min 48 s, and the alert says "10:38 PM". CI builds the
project and taps Calculate headless to check it.


## Photos picker

`import PhotosUI` gives `PhotosPicker` and `.photosPicker(isPresented:)`
(Sources/PhotosUI). The browser has no photo library; the page's file
chooser stands in for it. A pick is a host request (Phase 13's channel) of
kind `pickFiles`:

```jsonc
{ "op": "request", "id": 3, "kind": "pickFiles",
  "params": { "accept": "image/*", "multiple": false } }
```

| param | from |
|-------|------|
| `accept` | the `PHPickerFilter`: `image/*` for the photo kinds (`.images`, `.livePhotos`, `.screenshots`, …), `video/*` for the video kinds, both for `.spatialMedia`; `.any(of:)`, `.all(of:)` and `.not(_:)` combine them. No filter is `image/*,video/*`, what a library holds |
| `multiple` | a `[PhotosPickerItem]` selection whose `maxSelectionCount` is not 1 |

The page answers with every chosen file:

```jsonc
{ "items": [
  { "name": "IMG_0001.png", "type": "image/png", "data": "iVBORw0K…",
    "url": "blob:https://…", "width": 1170, "height": 2532 }
] }
```

`data` is the bytes, base64; `type` is the browser's MIME type (empty when it
has none). A picture the browser can draw also gets an object URL (`url`,
never revoked: the app may show it for as long as it runs) and its pixel
size. A dismissed chooser answers `{ "items": [] }`.

**User activation.** Browsers open a file chooser only during the user's tap
(transient activation). The request is made inside it: the renderer delivers
the tap from its `click` handler (`sendEvent`), the button's action starts
the pick's task, the loader's `runJobs()` right after the event runs it, the
`request` op is drained in the same call, and `performHostRequest` calls
`pickFiles` before its first `await`, which creates a hidden
`<input type="file" data-sb-picker>`, appends it to the document and calls
`click()`. All of it happens before the `click` handler returns. The input
answers on `change` (the files) or `cancel` (none) and removes itself. A
browser without the `cancel` event never answers a dismissal, and the
selection stays as it was. `.photosPicker(isPresented:)` opens from the
render pass in which `isPresented` becomes true. That works within a button's
action. Setting it from a timer or a task finds no activation, and the browser
opens nothing.

**Swift side.** The answer's files are matched against the filter again (a
chooser's "All files" lets anything through; a file with no MIME type is
typed by its extension), cut to `maxSelectionCount`, and become
`PhotosPickerItem`s that hold the bytes. A non-empty pick replaces the
selection (the first item for a single selection); an empty one leaves it
alone, as a cancelled picker does on iOS. Items are `Hashable` by pick: the
same file picked twice gives two different items, so `onChange(of:)` sees
both. `loadTransferable(type: Data.self)` returns the bytes at once;
`loadTransferable(type: Image.self)` returns an image of `url` at its pixel
size (scale 1), or nil for a file the browser could not draw (HEIC outside
Safari). Other types load nil. `itemIdentifier` is nil unless the picker was
given a `photoLibrary`, as on iOS. `PHPhotoLibrary.shared()` exists for that
and reports every authorization as granted. `preferredItemEncoding` and
`selectionBehavior` are accepted and ignored. `Data` is `Transferable` (in
the shim's Sharing.swift). The completion-handler `loadTransferable` returns
a finished `Progress`. Corelibs Foundation has no `Progress` on WASI, so
PhotosUI declares a small one there.

**UniformTypeIdentifiers.** `supportedContentTypes` are `UTType`s
(Sources/UniformTypeIdentifiers, its own stand-in module). The common image,
movie, audio, text and PDF types are declared with their MIME types and
extensions, and conformance follows Apple's hierarchy (`.heic` conforms to
`.image`, `.data`, `.content`, `.item`). An undeclared MIME type or extension
makes a dynamic type (`dyn.…`) conforming to the supertype asked for.

**Executor (changed).** A render can start tasks: an `onChange(of:)` action
that loads the selected item in a `Task`, as Instafilter's does. When the
render ran at the end of an executor run (`sb_run_jobs`, or a step of the
headless clock), that task used to wait for the next event or timer, so a
picked photo appeared only on the viewer's next tap. The runtime now runs
the executor again, up to 16 rounds per call, while a render left jobs
queued.

**Headless mode.** `SB_RESPONSES` entries with a `pick` answer the
`pickFiles` requests in order, one entry per request. Each file has `name`,
`type`, `body` (text) or `bodyBase64`, and for a picture `width` and
`height` (`url` defaults to a `data:` URL of the bytes). Once the entries run
out, a request answers with no items, as a dismissed chooser does.

```json
[{ "pick": [{ "name": "red.png", "type": "image/png", "bodyBase64": "iVBORw0K…", "width": 4, "height": 3 }] }]
```

**Example.** `Examples/PhotoPicker`: a single-photo picker whose photo is
shown (`Image.self`) and measured (`Data.self`), a three-item picker for
photos and videos, and `.photosPicker(isPresented:)` opened from a button
with `.not(.videos)`. The snapshot replays the three picks from
`responses.json` and then a fourth tap whose chooser is dismissed. The
Playwright test (Web/e2e/photopicker.spec.ts) picks files through the real
input, and fires its `cancel` event.

## Bitmaps and Core Image

`UIImage`, `CGImage` and Core Image (`import CoreImage`, `import
CoreImage.CIFilterBuiltins`) as Hacking with SwiftUI's Instafilter and Hot
Prospects use them. The design has one rule: **Swift never holds decoded
pixels.** A `UIImage`, `CGImage` or `CIImage` is a recipe, the shim's
`_ImageRecipe`: a source (an image file's bytes, or a small pixel buffer a
generator made) and the Core Image steps to apply to it, in order. The page
decodes the source with the browser and runs the steps on a canvas when it
draws the image.

Why not decode in Swift: `UIImage(data:)` is synchronous and the browser's
decoders are asynchronous, so decoding in Swift would mean compiling JPEG,
PNG, WebP and HEIC codecs into every module that shows a picture (megabytes
of Wasm), then copying every frame of every slider drag back out of linear
memory. Swift needs only sizes, which it reads from the file's header, so
`UIImage(data:)` returns at once with the right `size`, and
`outputImage.extent` is right before anything is drawn. Filters then run
where the pixels are, at the resolution the image is shown at, not the
photo's.

**Where the types live.** On iOS, `import SwiftUI` re-exports UIKit's
`UIImage` and Core Graphics' `CGImage` (Hot Prospects' `MeView` imports
SwiftUI and `CoreImage.CIFilterBuiltins` only), so both are in the shim
(Sources/SwiftUI/API/Bitmaps.swift, with the recipe and header parsing in
Runtime/ImageSource.swift); `import UIKit` stays unsupported. Core Image is a
module of its own (Sources/CoreImage), linked only into apps that import it,
like the StoreKit, Core Location and LocalAuthentication stand-ins
(`STAND_IN_FRAMEWORKS`). `CoreImage.CIFilterBuiltins` is a Clang submodule
on Apple platforms; a Swift module has no submodules, so the CLI stages
`import CoreImage.CIFilterBuiltins` as `import CoreImage` (one line for one,
so diagnostics keep their line numbers). The module imports `Data` and
`NSNumber` from Foundation by name only, so Foundation's own `CGRect` and
`CGPoint` never meet the shim's.

### `imagedata` op

```jsonc
{ "op": "imagedata", "key": "f4725a1d3ebc93652", "width": 160, "height": 120, "mime": "image/jpeg", "data": "<base64>" }
{ "op": "imagedata", "key": "p0d565cf280fa70f5", "width": 27, "height": 27, "format": "gray", "pixels": "<base64>" }
```

A bitmap's source, sent once per run before the first element that draws it
(and before a `ShareLink` shares it). `key` is a content hash (FNV-1a, 64
bits; `f` for a file, `p` for pixels), so the same bytes are sent once
however many images and filter chains use them. A file carries its media
type and bytes; a pixel buffer its `format` (`gray`: one byte per pixel;
`rgba`: four, straight alpha) and bytes, rows from the top. `width` ×
`height` is the size in pixels as drawn, a JPEG's EXIF rotation applied. The
renderer keeps every source for the rest of the run (Web/src/bitmaps.ts,
`bitmapStore`); headless runs print the op like any other.

### `image` props: the bitmap form

```jsonc
{ "bitmap": { "source": "f4725a1d3ebc93652", "steps": [ { "op": "sepiaTone", "intensity": 0.5 },
                                                         { "op": "render", "x": 0, "y": 0, "width": 160, "height": 120 } ],
              "width": 160, "height": 120 },
  "resizable": true, "width": 160, "height": 120, "interpolation": "none" }
```

`Image(uiImage:)` and `Image(_:scale:orientation:label:)` with a `CGImage`.
`bitmap.source` is an `imagedata` key, or null for an empty image
(`UIImage()`); `bitmap.width` × `bitmap.height` is the result in pixels.
The outer `width` × `height` is the image's size in points (pixels ÷ the
`UIImage`'s scale), the intrinsic size the layout engine uses as for the
remote form (the proposal when `resizable`). A `UIImage(systemName:)` or
`UIImage(named:)` becomes the existing `systemName` or `name` form.

`interpolation: "none"` (`.interpolation(.none)`, on any form but symbols)
sets `data-sb-interpolation="none"` on the element, and the `<img>` or
`<canvas>` inside draws with `image-rendering: pixelated`, so a QR code's
modules stay square when scaled up. The other levels are omitted and drawn as
the browser smooths.

**Drawing.** The element holds a `<canvas>` filling its frame. Once the
layout engine has placed it, the page picks a working scale (raster pixels per
source pixel): enough for the frame in device pixels, never above 1 (the
source's own resolution), rounded up to a power of √2 so that a frame that
animates does not redraw the picture on every step, and capped at 16 million
pixels. It decodes the source (`createImageBitmap`, which applies EXIF
rotation), draws it at that scale, runs the steps (Web/src/imageOps.ts) and
puts the result on the canvas. One drawing per canvas runs at a time; a
change that arrives meanwhile waits and takes the newest recipe, and the
canvas keeps showing the previous picture until the next is ready, so
dragging a slider never blanks it. The canvas gets `data-sb-bitmap="ready"`
(or `"error"` when the browser cannot decode the file, such as HEIC in
Chromium and Firefox) and dispatches a bubbling `sb:bitmap` event after each
drawing.

### Steps

Coordinates are Core Image's: units of source pixels, origin at the bottom
left, y up. A raster is premultiplied RGBA, as Core Image works, so a blur
fades to clear and not to black. Every length is scaled with the working
scale. `center` is `[x, y]`.

| step | fields | output extent (Swift) | what the page does |
|------|--------|-----------------------|--------------------|
| `crop` | `x`, `y`, `width`, `height` | the overlap with the input (`cropped(to:)`) | keeps that rectangle |
| `render` | `x`, `y`, `width`, `height` (whole pixels) | `(0, 0, width, height)` | the rectangle as an image of its own, clear where the input does not reach (`createCGImage(_:from:)`, `CGImage.cropping(to:)`, `UIImage(ciImage:)`) |
| `clamp` | | infinite | outside its rectangle the image repeats its edge pixels (`clampedToExtent()`) |
| `sepiaTone` | `intensity` | same | the sepia matrix (r′ = .393r + .769g + .189b, g′ = .349r + .686g + .168b, b′ = .272r + .534g + .131b, as CSS `sepia()`), mixed with the input by `intensity` |
| `edges` | `intensity` | same | per color channel, the Sobel gradient's magnitude × `intensity`, capped at 1, on black; alpha kept |
| `crystallize` | `radius` (at least 1), `center` | same | a Voronoi diagram: one seed per cell of a grid `radius` wide through `center`, at a fixed pseudo-random point of its cell; each pixel takes the input's color at its nearest seed |
| `pixellate` | `scale` (above 1; at most 1 adds no step), `center` | same | square cells `scale` wide on a grid through `center`, each the input's color at its middle |
| `gaussianBlur` | `radius` (above 0) | grown by ⌈3 · radius⌉ on every side (infinite stays infinite) | a Gaussian of σ = radius as three box blurs per axis, fading to clear past the edges; a clamped image blurs against its repeated edges |
| `unsharpMask` | `radius` (above 0), `intensity` | same | input + intensity × (input − its Gaussian blur of σ = radius against repeated edges); alpha kept |
| `vignette` | `intensity`, `radius`, `center` and `size` (the input's middle and half its diagonal, from Swift) | same | with d = distance from `center` ÷ `size`, color × (1 + intensity · (cos⁴(atan(d · radius)) − 1)): radius 0 changes nothing (Core Image's identity value), radius 1 leaves a quarter of the light in the corners, a negative intensity brightens |

The page skips a step it does not know. The formulas approximate Core
Image's; they are not its kernels. The QR code generator adds no step: its
output is a pixel source.

**Filters (Swift).** `CIFilter` holds key-value inputs with Core Image's
names (`kCIInputImageKey`, `kCIInputIntensityKey`, `kCIInputRadiusKey`,
`kCIInputScaleKey`, `kCIInputCenterKey`, …), defaults and `inputKeys` order
(`CISepiaTone`: image, intensity 1; `CICrystallize`: image, radius 20, center
(150, 150); `CIEdges`: image, intensity 1; `CIGaussianBlur`: image, radius
10; `CIPixellate`: image, center (150, 150), scale 8; `CIUnsharpMask`: image,
radius 2.5, intensity 0.5; `CIVignette`: image, intensity 0, radius 1;
`CIQRCodeGenerator`: message, correction level "M"). Numbers may be any
numeric type, a center a `CIVector` or a `CGPoint`. `outputImage` appends the
step and works out the extent; without an input image it is nil. The typed
`CIFilterBuiltins` protocols are class-bound, so `let filter =
CIFilter.qrCodeGenerator()` is configured from a non-mutating method, and
read and write the same inputs. `CIFilter(name:)` knows the eight names; any
other is nil, and `applyingFilter` with it returns the empty image.

### QR codes

`CIQRCodeGenerator` encodes in Swift (Sources/CoreImage/QRCode.swift, after
Project Nayuki's reference encoder): byte mode, the smallest version 1 to 40
that holds the message at the level, the mask with the lowest penalty score.
The output is one pixel per module with a one-module light margin, black on
white, as a `gray` pixel source: "Anonymous\nyou@yoursite.com" at level M is
version 2, 27 × 27 pixels. A message too long for version 40 gives a nil
`outputImage`. The tests compare the matrices with a reference encoder's
(node-qrcode, same masks) for versions 1, 2, 3, 7 and 15 across the four
levels; codes of every level up to version 27 were also checked to decode
with jsQR.

### Sources and sizes

`UIImage(data:)` and `CIImage(data:)` read the header of PNG, JPEG, GIF,
WebP (lossy, lossless, extended), BMP, HEIC and AVIF files; anything else is
nil, as on iOS for data that is not an image. A JPEG's EXIF orientation 5 to 8
swaps width and height and a HEIF's `irot` does the same, as browsers draw
them; the `UIImage` reports `.up`, and `CIImage(image:)` sees the picture
upright (iOS's `CIImage(image:)` drops the orientation). `UIImage(data:scale:)`
divides the size by the scale. `UIImage(systemName:)` and `UIImage(named:)`
are never nil (the shim cannot tell which names exist); their `cgImage` is
nil and `CIImage(image:)` of them is nil. `UIImage()` is 0 × 0.

### Sharing

`ShareLink` with an `Image(uiImage:)` item (or several) sends the recipes as
`bitmaps` in the `share` request, after their sources' `imagedata` ops. The
page draws each at full resolution (within the 16-million-pixel cap) into a
PNG file named after the title (`My QR Code.png`) and shares or downloads it
like a catalog image.

### Not yet

`UIImage.pngData()` and `jpegData(compressionQuality:)` (they need the pixels
in Swift), other Core Image filters, custom kernels, `CIImage.transformed(by:)`
and `oriented(_:)`, `CIColor` and `CIImage(color:)`; the `orientation` of
`UIImage(cgImage:scale:orientation:)` and `Image(_:scale:orientation:label:)`
is recorded, not applied. Sources stay in the page's memory for the run.

## Swift package dependencies

An Xcode project's Package Dependencies are `XCRemoteSwiftPackageReference`
(a repository URL and a dependency rule) and `XCLocalSwiftPackageReference`
(a folder) objects in `project.pbxproj`, listed in the project's
`packageReferences`; a target links products of them through
`XCSwiftPackageProductDependency` objects (`packageProductDependencies`).
`@swiftbrowser/xcodeproj` reads both: `XcodeProject.packages` and, per
target, `packageDependencies` (each product with its package). The CLI used
to read only the product names and drop them, so an app that imported a
package failed with "no such module" (or, for CodeScanner, was reported as an
unsupported framework).

The CLI (packages/cli/src/packages.ts) plans each package the app target
links:

| Plan | When | In the staged Package.swift |
|------|------|------------------------------|
| dependency | a remote package with a rule, or a local package | `.package(url:…)` with the rule (`from:` for up to next major, `.upToNextMinor(from:)`, `exact:`, `"a"..<"b"`, `branch:`, `revision:`) or `.package(path:)`, and a `.product(name:package:)` per product; `package` is SwiftPM's identity (the URL's last component without `.git`, lower-cased) |
| stand-in | the package's identity is in `PACKAGE_STAND_INS` | nothing: the shim's module of the same name is linked when a source imports it, like the framework stand-ins |
| unresolved | a rule this reader does not know, or a product whose package the project does not name | nothing; `check` warns and the build reports the import |

SwiftPM fetches a dependency and compiles it for the host (`check`) and for
WASI (`dev`, `build`) like the app's own code, so a pure-Swift package works
if it builds for WASI (swift-algorithms does). One that needs a platform
framework fails to compile, and the error names it. Packages known not to
work in the browser are stood in for instead; today that is CodeScanner,
whose sources are all behind `#if os(iOS)`: on WASI it builds, to an empty
module. `check` reports each package and the path it took:

```
✓ package CodeScanner (github.com/twostraws/CodeScanner, up to next major from 2.4.1): SwiftBrowser's stand-in, not fetched (the package wraps AVFoundation and UIKit)
✓ package swift-algorithms (github.com/apple/swift-algorithms, up to next major from 1.2.0): a SwiftPM dependency of the build (Algorithms)
```

and an import of a dependency's product counts as supported ("from package
swift-algorithms") instead of unknown. The project's own `Package.resolved`
is not copied: SwiftPM resolves the rule afresh into the staging package's.

## Notifications

`import UserNotifications` links a stand-in module (Sources/UserNotifications)
over the browser's Notification API. `UNUserNotificationCenter.current()`
keeps the pending requests and schedules each with an executor timer of its
own (`Task.sleep`, at most a day at a time, since `setTimeout` cannot wait
longer than 24.8 days); when one runs out, it asks the page to show the
notification. The page schedules nothing, so notifications are delivered
only while the page is open: one due tomorrow appears tomorrow if the tab is
still open (a background tab's timers may run late, by up to a minute in
Chrome), and never if it was closed. Repeating triggers are rescheduled after
each delivery.

Every exchange is a host request (Phase 13's channel) of kind
`notification`:

| `action` | params | page | value |
|----------|--------|------|-------|
| `status` | | reads `Notification.permission` | `{ "permission": "default" \| "granted" \| "denied" \| "unsupported" }` |
| `request` | | `Notification.requestPermission()` when the permission is `default`, else the standing answer | same |
| `show` | `id`, `title`, `subtitle`, `body`, `silent`, `badge` | `new Notification(title, { body, tag: id, silent })` with the subtitle and body as the text, one line each (through the service worker registration where the constructor is refused, as on Android); `navigator.setAppBadge(badge)` | `{ "shown": bool }` |
| `close` | `ids` | closes the notifications shown with those tags | `{}` |
| `badge` | `badge` | `navigator.setAppBadge` (0: `clearAppBadge`) | `{}` |

`unsupported` means the page has no Notification API (Safari on iPhone shows
notifications only for a site added to the Home Screen). It maps to
`UNAuthorizationStatus.denied`; `default` is `.notDetermined`, `granted`
`.authorized`. `requestAuthorization` answers `true` for `granted`, `false`
for a refusal, and fails with `UNError(.notificationsNotAllowed)` when
unsupported. A click on a notification focuses the page and closes it.

The module has `UNUserNotificationCenter` (`getNotificationSettings` /
`notificationSettings()`, `requestAuthorization` and `add` in both forms,
`getPendingNotificationRequests` / `pendingNotificationRequests()`,
`removePendingNotificationRequests(withIdentifiers:)`,
`removeAllPendingNotificationRequests()`, the delivered notifications and
their removal, `setBadgeCount`, `delegate`), `UNMutableNotificationContent`
(title, subtitle, body, badge, sound, userInfo and the identifiers; the
request keeps a copy), `UNNotificationSound` (`.default`, named and critical
sounds: the browser plays its own or, without a sound, none),
`UNTimeIntervalNotificationTrigger` (positive, and at least 60 s when
repeating, as on iOS), `UNCalendarNotificationTrigger` (the next date
matching the components in their calendar and time zone, else the current
ones; `nextTriggerDate()`), `UNNotificationRequest`, `UNNotification`,
`UNNotificationSettings`, `UNAuthorizationOptions`, `UNAuthorizationStatus`,
`UNNotificationPresentationOptions` and `UNError`. A
`UNUserNotificationCenterDelegate`'s `willPresent` decides how a delivery is
presented (without `.banner`, `.alert` or `.list` nothing is shown; `.badge`
sets the badge). A center without a delegate shows every delivery, since the
app is necessarily open when one fires (iOS would show none in the
foreground). `didReceive` is never called: the page gets no answer from a
notification.

Headless runs answer from the app itself: the permission is `default` until
the app requests it, then `granted`; `show` answers `{ "shown": true }` once
granted; deliveries happen on the virtual clock (`SB_RUN_MS`), so a 5-second
trigger's `show` op follows the `"ms": 5000` clock event in the log.
`_Runtime._hostRequest(kind:params:headless:)` takes that answer as its
`headless` argument.

## Code scanning

`import CodeScanner` (Paul Hudson's package, a SwiftUI wrapper of an
AVFoundation capture session) links a stand-in module (Sources/CodeScanner)
in its place, with the package's public API: `CodeScannerView` with every
initializer argument and default of version 2.4 (`codeTypes`, `scanMode`,
`manualSelect`, `scanInterval`, `showViewfinder`, `requiresPhotoOutput`,
`simulatedData`, `shouldVibrateOnSuccess`, `isTorchOn`, `isPaused`,
`isGalleryPresented`, `videoCaptureDevice`, `completion`), `ScanResult`
(`string`, `type`, `image`, `corners`), `ScanError`, `ScanMode`, and the
AVFoundation names the initializer mentions: `AVMetadataObject.ObjectType`
(`.qr`, `.ean13`, `.code128` and the rest, with Apple's raw values) and
`AVCaptureDevice` (`bestForVideo` is nil: the browser picks the camera).
`ScanResult.image` (a `UIImage?`) is always nil: the browser hands the app
no camera frames.

The view is what the package shows in the iOS simulator: a screen that
sends back `simulatedData` (as the first of the `codeTypes`, with no
corners) each time "Send Simulated Data" is tapped, whatever the scan mode.
Where the page can scan for real, it also offers "Scan with Camera". Both go
through host requests of kind `scanner`:

- `probe`, from the view's `.task`: `{ "action": "probe", "formats": [...] }`
  with the `BarcodeDetector` formats of the code types (`qr_code`, `ean_13`,
  …; types with none are left out). The page answers `{ "camera": true }`
  when it has a `BarcodeDetector` supporting one of them and a video input
  (Chrome on Android and macOS today; not Safari, Firefox, or Chrome on
  Linux and Windows). Headless runs answer `{ "camera": false }`.
- `scan`, from the button: `{ "action": "scan", "formats", "torch", "vibrate" }`.
  The page opens the back camera (`getUserMedia` with `facingMode:
  "environment"`) full screen over the page with a viewfinder and a Cancel
  button, runs the detector on the video every 120 ms, and answers with the
  first code, `{ "string", "format", "corners": [{ "x", "y" }] }`, or
  `{ "cancelled": true }`. `torch` turns on the track's torch where it has
  one; `vibrate` buzzes on success. A refused camera fails with
  `permission: …` (`ScanError.permissionDenied`), any other failure with
  `camera: …` (`.badInput`).

A camera scan honours `scanMode` as the package does (`.once` and `.manual`
deliver one code, `.oncePerCode` each code once, `.continuous` one per
`scanInterval`, `.continuousExcept` minus its list), one code per tap of the
button. `isGalleryPresented` (scanning a picked photo) is accepted and
ignored.

`Examples/HotProspects` (Hacking with SwiftUI project 16 without its Me tab,
whose QR code `Examples/Filters` draws) drives both: its snapshot scans the simulated data
and schedules a reminder that is shown at 5000 ms, and its Playwright test
checks the notification on a real timer and a camera scan with a stubbed
camera and detector.

## `@AppStorage`

`AppStorage` is declared in FoundationBridge (Sources/FoundationBridge/
AppStorage.swift), next to the `UserDefaults` it reads, rather than in the
SwiftUI shim, which cannot see that class; apps reach it through the
FoundationBridge import the CLI adds. On iOS it is SwiftUI's, so the example
apps type-check against Apple's SDK unchanged. Nothing new crosses the
boundary: a write is `UserDefaults.set`, which sends the `defaults` op
(Phase 11), and a launch reads `SB_DEFAULTS` as before.

The wrapper holds the key, the default, the store (`.standard` unless
`store:` names one) and a read and a write closure chosen by the
initializer: `Bool`, `Int`, `Double`, `String`, `URL`, `Data` and `Date` go
through `UserDefaults`' typed getters (so a `Double` saved as a whole number,
which comes back from JSON as an `Int`, still reads as a `Double`);
`RawRepresentable` values store their `rawValue`; the optional forms
(`init(_:store:)`) read nil for a missing key and remove the key when set to
nil. A missing key reads the default, which is never saved.

**Observation.** Each key has a `_DefaultsKey`, an `Observable` object with a
registrar. `wrappedValue`'s getter calls its `access`, which registers with
the `withObservationTracking` the runtime runs each body under (Phase 9);
`UserDefaults.set` (and so `removeObject`) calls `withMutation` on the
key's object, `register(defaults:)` on each key it registers, and
`removePersistentDomain` on every key read so far. A write therefore
invalidates exactly the nodes whose bodies read that key, however it was
made. Direct `UserDefaults` reads register nothing, as on iOS.

**Stale caches below a clean ancestor.** An action that sets state while a
body is evaluated (`onChange(of:)`'s) marks its node dirty in the middle of a
pass: `markDirty` clears the cached elements of the node and its ancestors,
but the same pass then produces and caches their elements again. In the next
pass the ancestors are clean with a dirty descendant, and the reconciler
used to clear the cache only of nodes whose body re-ran, so the page got
the descendant's new elements only when every ancestor's body re-ran too.
Hot Prospects' `MeView`,
in a `TabView` tab, regenerated its QR code and the page kept the old one.
The reconciler now clears the cache of every node it enters: it enters a
clean node only for a dirty descendant, whose elements are part of its own.

