SwiftBrowser docs
View as MarkdownEdit on GitHub

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).

{
  "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.

{ "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 sections
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 navscreens, 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:

{ "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:

{ "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
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:

{ "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)#

{ "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) tabs
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)#

{ "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.

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:

{ "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)#

{ "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 Layouts 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 gridrows, 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.

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

    { "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)#

{ "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

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:

{ "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#

  • 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:

{ "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:

{ "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#

"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.

{
  "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:

{ "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:

[{ "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)#

{ "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 chartitems (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#

{ "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#

{ "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. LineMarks / AreaMarks 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 { 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 navigationDestinations 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)#

{ "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 buttons, 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 buttons, 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 sections; 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.

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
    1. 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 SortDescriptors, 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:

{ "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:

{ "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:

{ "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 PhotosPickerItems 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 UTTypes (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.

[{ "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#

{ "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#

{ "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.