SwiftBrowser docs
View as MarkdownEdit on GitHub

SwiftBrowser web renderer#

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

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

Run#

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

App registry and the programmatic API#

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

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

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

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

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

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

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

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

Pages and URLs#

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

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

The app page's query parameters:

  • ?app=Name loads /Name.wasm instead of /Counter.wasm (and passes Name as argv[0]). The dev server remembers every app requested this way and rebuilds all of them on Swift changes (see "Hot reload").

  • ?mock=1 renders a hardcoded screen and answers events in-page, so the renderer can be worked on without a Swift toolchain. Two fixtures exist:

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

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

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

Device mode (real phones)#

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

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

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

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

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

Deploying (Cloudflare Pages)#

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

Getting Counter.wasm into public/#

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

From the repo root:

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

Hot reload#

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

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

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

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

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

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

What the renderer expects from the Wasm module#

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

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

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

Renderer notes#

Ops and the element tree#

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

Layout (Phase 3)#

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

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

Fonts and Dynamic Type#

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

Kinds#

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

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

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

Sheets (Phase 3)#

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

Animation (Phase 3)#

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

  • Style changes of updated elements tween from their computed value before the pass: opacity, background-color, color, border-radius, font-size.

  • Frame changes tween left/top/width/height for every element whose layout box moved or resized in the pass (the engine's frames before and after).

  • Inserted subtree roots play their transition forwards, removed elements leave a visual clone (.sb-exit-clone, stripped of ids, absolutely positioned at the old frame on #screen) that plays it backwards and is dropped when done. opacity fades; scale scales from scale (default 0.5) with a fade; slide enters from the leading edge and exits through the trailing edge; move(edge) translates by the element's own size from that edge; identity does nothing; arrays combine. Without a transition, inserted/removed views fade (SwiftUI's default). The transition is looked up on the removed/inserted root or down a single-child chain of styled boxes under it (Text.transition(.opacity).padding()). navscreen and sheet keep their own CSS enter/exit instead.

  • Animation → CSS timing: default and easeInOut → cubic-bezier(0.42, 0, 0.58, 1), easeIn → cubic-bezier(0.42, 0, 1, 1), easeOut → cubic-bezier(0, 0, 0.58, 1), linear; duration defaults to 0.35s, delay to 0. spring becomes a linear() easing sampled at 60 points from a damped spring with ω = 2π / duration and damping ratio 1 − bounce (bounce 0: critically damped; 0.3: overshoots ~4.6%), run over the perceptual duration (default 0.5s); browsers without linear() fall back to easeInOut. window.__sb.animation.{easingFor, timingFor, springEasing} expose the mapping.

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

Testing#

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