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 withdefineas__SB_APPS__({ name, displayName, description?, icon? }[]);vite.config.tsregisters every directory under../Examples, with the descriptions innode/examples.ts. The card title isdisplayName(the name by default), the subtitle the description or "theapp". 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 theqrcodepackage) so a phone can scan it from a desktop. The footer shows the build info, also fromdefine:__SB_COMMIT__(GITHUB_SHA, elsegit rev-parse --short HEAD, elsedev; linked to the commit on GitHub unlessdev),__SB_BRANCH__(GITHUB_REF_NAME, elsegit rev-parse --abbrev-ref HEAD) and__SB_BUILT_AT__./app/(app/index.html+src/main.ts): the renderer.import.meta.env.BASE_URLstays/, so the modules are still fetched from/<Name>.wasmand the dev-server plugin's rebuild registry (which watches those requests) works unchanged.
The app page's query parameters:
?app=Nameloads/Name.wasminstead of/Counter.wasm(and passesNameasargv[0]). The dev server remembers every app requested this way and rebuilds all of them on Swift changes (see "Hot reload").?mock=1renders 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 withupdateops.?mock=1&app=Todos: a Phase 2navstack→navscreen("Todos", large title) →listwith a "Today"sectionof three rows (atoggle, anavlink, anhstackwith animageand text) and a second section with aroundedBordertextfieldplus abordered"Add" button wrapped in astyled { disabled }. The mock flipsisOnon toggle, pushes an inline "Detail"navscreenon 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().receivedlists every event it got.?mock=1&app=Gallery: Phase 3. Avstackof onetextper text style (to check Dynamic Type), acolorand twoshapes (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 theanimationToken, swaps the shape's fill (red ↔ green), doubles the frame width (80 ↔ 160) and inserts or removes an "Expanded" text withtransition: opacity, all in a commit carryinganimation: easeInOut 0.35. "Show sheet" appends a root-levelsheet(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 atapwith the sheet's own id). Ids are inGALLERY_IDS.?mock=1&app=Settings: Phase 4. Atabview(selected: 0) with two tabs: "Home" (house) holds anavstack→navscreen("Home", large title) → inset-groupedlistwith a "Geometry" section (astyled { frame: { height: 22 } }→geometry→text, which the mock rewrites to the reportedW × H) and a "Rows" section of 20 rows, long enough to scroll under the tab bar; "Settings" (gear) holds alistwithstyle: "grouped"containing a segmentedpicker("Appearance": Light/Dark/Auto), a menupicker("Units": Metric/Imperial) and atoggle.selecton the tab view or a picker updates itsselected,toggleflipsisOn,geometryupdates the text. Ids are inSETTINGS_IDS; every event lands inwindow.__sb.app().received.
?mode=device/?mode=frameforces 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 whenmatchMedia('(pointer: coarse)').matches(a coarse pointer at any width: a tablet fills the viewport instead of showing the phone bezel;?mode=framewins 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:
- It watches
../Sources/**/*.swiftand../Examples/**/*.swiftwith the dev server's own file watcher and debounces changes for 300ms. - It runs
bash scripts/build-wasm.sh <App>from the repo root for every app requested since the server started (Counterby default; a/Todos.wasmrequest addsTodos). Build output is logged to the terminal.swiftis resolved from$SWIFT_TOOLCHAIN_BIN, then/root/.local/share/swiftly/toolchains/6.4.0/usr/binif present, thenPATH. - While building, the page shows a "Rebuilding…" indicator in the toolbar
(websocket events
sb:build-start/sb:build-end). On success the server sends afull-reload; on failure it sendssb:build-errorwith the compiler output, which the page shows in the error banner while the current app keeps running. - Before the reload (
vite:beforeFullReload, plusbeforeunload/pagehideas a fallback for manual refreshes),main.tsreads the app's@Statesnapshot throughsb_state_ptr/sb_state_lenand stores it insessionStorage['sb-state:<App>']. On boot the snapshot is removed from storage and passed to the new module as the WASI environment variableSB_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:
- fetches and compiles the module; it must import only
wasi_snapshot_preview1; - instantiates it with
@bjorn3/browser_wasi_shim(args: ["Counter"],env: [], stdout/stderr forwarded to the browser console); - calls
_start(or_initializefor a reactor build)._startreturning normally or callingproc_exit(0)both count as success; any non-zero exit code is an error. The instance stays alive afterwards; - reads
sb_ops_ptr()/sb_ops_len()as a UTF-8 JSON array out ofexports.memory, callssb_ops_clear(), then applies the ops; - on every tap (
button,navlink, or the back button of anavscreen) callssb_event(id)and repeats step 4; - for every other event (
text,toggle,environment,select,geometry) encodes the JSON event as UTF-8, callssb_alloc(len), writes the bytes at the returned pointer, callssb_event_json(ptr, len)and repeats step 4. Modules that lacksb_alloc/sb_event_json(Phase 1) skip these events;environmentis sent once right after_startand again whenever the theme toggle or the Text size control changes. It always carries both fields:{"type":"environment","colorScheme":"light","dynamicTypeSize":"large"}, withdynamicTypeSizeone ofxSmall,small,medium,large,xLarge,xxLarge,xxxLarge,accessibility1…accessibility5. Phase 2 modules ignore the extra field. Events the renderer produces before the handle exists (thegeometryreports of the very first layout pass, which runs insideloadApp) are queued and delivered right afterenvironment; - (Phase 4) if the module exports
sb_run_jobs(now_ms: f64) -> f64, calls it withperformance.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'sJobSchedulerkeeps a single pendingsetTimeoutfor 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 (nullfor 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#
insertwith an element that is already attached is a move.indexis the position in the parent's child list after the op (the element is detached first, then inserted before the current child atindex).updatereplaces 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).removedetaches the element and forgets it and every descendant.commitruns the layout engine over the whole tree, positions every element and hands the pass to the Animator; it then dispatches asb:commitCustomEvent on#screen(detail.animationis the commit's animation or null). Everything else is applied eagerly, not batched.- Besides
elements(id → HTMLElement) the renderer keepsnodes(id →LayoutNode { id, kind, props, children }, the shape the engine reads) andparents.window.__sb.renderer.layoutResultis the lastLayoutResult.
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: absolutewithleft/top/width/heightfrom its frame, relative to its parent element's box.#screenis a plainoverflow: hiddenbox; the safe areas are part of the engine's proposal. textelements get the engine's exact lines joined with\nunderwhite-space: pre, the resolvedfontshorthand (weight size/lineHeight family) andtext-alignfrommultilineTextAlignment.lineLimittruncation (the…) comes from the engine too.imageelements are a square of the font's line height with the glyph at1em(font-size fromresult.fonts).scrollviewandlistget an inner content box (.sb-scroll-content,.sb-list-content) sized fromcontentSizes; 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
chromerects: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},navlinkrows {chevron},tabview{bar, item0 … itemN-1},picker{segment0 … / label, value}. Alistorscrollviewunder a tab bar gets abottomInsetrect (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 ownfooterprop (sentence case).
- 44, padded so the 44px row is at the bottom), largeTitle (52px row, text
from
- A list row's DOM box is the engine's full-width
rowrect (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 therowkey (toggle, borderedbutton) rebuild it from the card and the engine's rulemax(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 fromnodes;font,foreground,background,cornerRadius(+overflow: hidden),opacityanddisabledare 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.fontfields are independent:{ "weight": "bold" }changes only the weight. AtextStyleresolves through Apple's Dynamic Type table insrc/layout/typography.ts(the single source of truth;src/typography.tsre-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 withround(size × 1.2)line heights.headlineimpliessemibold. A fixedsizedoes not scale unlessrelativeTonames a text style, in which case it scales by that style's ratio to Large.- Styled boxes with a
textStylealso carryfont-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-bodyon the device frame followsbody.
Kinds#
- Semantic colors resolve to CSS variables (
--sb-color-primary,-secondary,-accent,-system-background,-secondary-system-background);clearistransparent. Values for both schemes live instyles.cssunder.device[data-theme]. Anaon a semantic color is an opacity multiplier, rendered ascolor-mix(in srgb, var(--token) <a*100>%, transparent). Style.disabledmultiplies the box's opacity by 0.4 and setspointer-events: none(plusdata-sb-disabled/aria-disabled).button.styleandbutton.rolebecomedata-sb-style/data-sb-role;borderedis a gray capsule,borderedProminenta filled accent capsule (label + 14/7 padding, 34px minimum, from the engine),destructivered text. Phase 1 modules send{}, which reads asautomatic.image:systemNameis looked up insrc/symbols.ts(SF Symbol name → lucide glyph, including.fill/.circlevariants) and rendered as an inline<svg>incurrentColor. Unknown names draw a dashed square withtitleset to the name, so gaps are visible.coloris a box filled with its color;shapedrawsrectangle,roundedRectangle(border-radius: cornerRadius),circle/ellipse(50%) andcapsule(9999px).fillis the background;fill: nullwithout a stroke paintscurrentColor(a bareCircle()), with a stroke it paints nothing (Shape.stroke(_:));strokeis a solid border oflineWidth(inside the frame,box-sizing: border-box). Both take exactly the size the engine proposes (10pt on an unconstrained axis).list/sectionrender 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-sectionchild of a list) is a row; anavlinkrow shows a trailing chevron, atogglerow 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), soinsertindices are exact;Renderer.elementsstill 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 attranslateX(-30%). A removed top screen animates out through a visual clone stripped of element ids. Screens atdepth > 0show a back button (chevron.left+ the previous screen's current title) that sends atapwith thenavscreen's own id. A titleupdateon any screen also refreshes the back labels above it.textfieldis an<input>(34pxroundedBorder, 22pxplain);inputevents send{"type":"text"}. Anupdatewhosetextequals the current value leaves.value(and the caret) alone.togglerenders a 51×31 switch (role="switch"); clicking it flips the switch optimistically and sends{"type":"toggle"}; Swift'supdateconfirms the state.
Tab views, pickers, list styles and GeometryReader (Phase 4)#
tabviewfills the screen like anavstack(safe areas ignored). Itstabchildren go into.sb-tabview-contentand all share the tab view's frame; the bar (.sb-tabbar, the engine'sbarrect: 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), darkrgba(29,29,29,0.94)), a hairline on top and one.sb-tabbar-itemper tab at the engine'sitem<i>rect (equal widths, 49 high): the tab'ssystemImagethrough 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: theselectedprop is authoritative, theupdateswitches tabs. Only the selected tab's content is laid out; the other tabs keep their DOM (and scroll positions) underdata-sb-tab-hidden(visibility: hidden,inert,aria-hidden), driven byLayoutResult.hidden.- Tab content is placed like a navscreen's: a
navstackfills the whole tab (its screens reserve the status bar and their lists run under the translucent bar); alist/scrollviewstarts 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 chromebottomInset), so the last row scrolls clear of the bar like iOS. pickersegmented: an iOS segmented control, 32 high, width = proposal (or every label + 20):.sb-segment-track(rgba(118,118,128,0.12), 8px radius; darkrgba(118,118,128,0.24)) at the control's frame and one.sb-segmentradio button per option at the engine'ssegment<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 sendsselectwith the index; Swift'supdateconfirms. In a list row the row is 44 high with the control centered.pickermenu: 44 high in a list row, 34 elsewhere;role="button"with.sb-picker-labelat the engine'slabelrect (leading) and.sb-picker-value(the selected option pluschevron.up.chevron.down, secondary color) atvalue(trailing). Tapping it (or Enter/Space) opens.sb-picker-menuappended to#screen: 250 wide, 13px radius, system background, one 44pxmenuitemradiorow 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 sendsselectand 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); anupdateor removal of the picker closes it too.renderer.openMenuElementexposes 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;plaindrops the cards, the top gap and the gaps between sections and uses the system background. The list carriesdata-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 eachgeometryelement'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/heightfor every element whose layout box moved or resized in the pass (the engine's frames before and after).Inserted subtree roots play their
transitionforwards, 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.opacityfades;scalescales fromscale(default 0.5) with a fade;slideenters from the leading edge and exits through the trailing edge;move(edge)translates by the element's own size from that edge;identitydoes nothing; arrays combine. Without atransition, inserted/removed views fade (SwiftUI's default). The transition is looked up on the removed/inserted root or down a single-child chain ofstyledboxes under it (Text.transition(.opacity).padding()).navscreenandsheetkeep their own CSS enter/exit instead.Animation→ CSS timing:defaultandeaseInOut→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;durationdefaults to 0.35s,delayto 0.springbecomes alinear()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 perceptualduration(default 0.5s); browsers withoutlinear()fall back to easeInOut.window.__sb.animation.{easingFor, timingFor, springEasing}expose the mapping.window.__sb.rendererexposes the liveRenderer(used by the e2e tests and handy in the console:__sb.renderer.applyOps([...]));window.__sb.sentEventslists 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) andwindow.__sb.JobScheduleris 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.