@swiftbrowser/web
v0.6.1
Published
SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.
Readme
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 / of a multi-app site): one card per registered app with an Open link and a QR code
app/index.html The app page (served at /app/, and at / of a single-app site): 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
plugins/site-layout.ts The site's layout (one app at /, or a landing page over several) and window.__SB_SITE__ for the app page
src/deviceMode.ts Frame vs device mode decision, env(safe-area-inset-*) reader, viewport watcher
src/chromeColor.ts Device mode: the browser's status bar strip and safe areas take the app's top-edge color
src/vite-env.d.ts Types of the build-time defines (__SB_APPS__, __SB_DEFAULT_APP__, __SB_PAGE_MODE__, __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: draws recipes on each image's <canvas>, through the store in a worker or on the page
src/bitmapStore.ts The bitmap store: keeps `imagedata` sources, decodes them, draws recipes (bitmapPlan.ts, imageOps.ts); no DOM
src/bitmapWorker.ts The store in a worker, answering drawings with ImageBitmaps
src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixellate, unsharp mask, vignette, crop) on rasters; no DOM
src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
src/press.ts iOS 26 press feedback: the Liquid Glass under a pressed bar button or glass button swells and springs back
src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
src/page.css The app page around the frame: its toolbar, stage, error banner, device-mode chrome
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/
# (the examples are a multi-app site; a site with one app is built as the app at /, see "Site layouts")
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 itselfApp 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.
A built site lists its apps in apps.json, and other built sites can join a
build (SiteOptions.sites; SB_SITES for vite build, directories separated
like PATH): plugins/included-sites.ts adds their apps to the landing page
and copies their files (modules, catalogs, bundles, manifests) next to this
site's, keeping this site's pages and any file it already has. CI's deploy
adds Apple's Backyard Birds this way, built from its repository with
swiftbrowser build.
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 siteBoth 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
node/config.ts, appType: 'mpa'). The examples are a multi-app site, the
landing layout; a site with one app (what swiftbrowser dev and build
make) is the app layout, see "Site layouts" below.
/(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 "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 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. Without?app=it runs the site's default app (__SB_DEFAULT_APP__;Counterfor the examples,vite.config.ts).
Site layouts
node/config.ts picks a layout from the registry (siteLayout,
SiteOptions.layout overrides it), and plugins/site-layout.ts applies it:
app: one app and no included sites. The site is that app: the dev server serves the app page at/, a build writes it asindex.htmland nothing at/app/(the directory holds only the OAuth callback page), there is no landing page, every manifest starts the app at/and the site's/manifest.webmanifestis the app's own.buildSitebuilds such a site withpageMode: 'device': the app fills the window on every screen, so a desktop browser shows the app's regular-width (iPad) layout, not a phone in a bezel;?mode=framestill shows the frame. The dev server keepspageMode: 'auto': the frame and its dark mode and text size controls on a desktop, device mode on a phone.landing: several apps, or included sites. The landing page at/lists them and each runs at/app/?app=<Name>, as the examples do.
Both layouts give the app page window.__SB_SITE__ ({ layout, defaultApp,
displayName, pageMode }), which its inline scripts read before the first
paint, and src/main.ts the same values as defines.
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).?arg=<value>(repeatable) adds launch arguments after the app's name, assimctl launchpasses them:?app=Fidelity&arg=-screen&arg=formopens that screen of Examples/Fidelity (ProcessInfo.processInfo.arguments).
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 (the defaults
until the app draws), 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.
In the browser itself (not standalone), Safari tints its status bar strip and
the safe areas from theme-color, the page background and (in recent Safari)
the fixed element at the top edge. src/chromeColor.ts keeps those in step
with the app: after each layout pass, a theme change and the end of each push,
pop or sheet animation (at most four times a second), it composites the
backgrounds the app paints at the middle of its top edge, walking only the
elements whose box holds that point (about 270 on Ice Cubes' timeline, under a
millisecond). The
result becomes the one theme-color, the html, body and #device
backgrounds, and html's color-scheme is the app's scheme, so an app's own
bar color, its .preferredColorScheme or a sheet's dimming reach the strip
instead of the OS appearance's defaults. Safari seems to take the strip's
color from the first paint and not follow later changes, so in device mode the
first paint already has the OS appearance: an inline script after #device
sets its data-theme from prefers-color-scheme, and the page background is
the system background for it (white / black) before main.ts runs.
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. In the
app layout the manifests start the app at /, the site's
/manifest.webmanifest is the app's, and the page is titled by the app's
display name alone.
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. A
site built by the CLI from one app is that app at https://<site>.
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 onlyHot 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. This is the dev server's only (import.meta.hot): a production build never stashes or restores state, so a reload of a built site starts fresh.
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 (62px row, text
from
chromeText), back, content},sheet{grabber, floating},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. Alistorscrollviewwith alargeTitlerect carries its screen's large title (src/largeTitle.ts): the title follows its scroll offset and fades out over 16px at the bar's edge, as the scrolled content does (data-sb-scrolled, a mask standing in for iOS 26's scroll edge effect); past 44 the screen getsdata-sb-collapsed, which fades the inline title in (its text is always set; CSS hides it on a large screen until then). On an overscroll the title moves down with the content (iOS 26 no longer grows it). Section headers keep their case (iOS 26) and come from the engine in the headline font (17 semibold), secondary color; footers are drawn from the section's ownfooterprop in the engine's footnote font and rect.
- 44, padded so the 44px row is at the bottom), largeTitle (62px row, text
from
- A list row's DOM box is the engine's full-width
rowrect (so the cell background, the separator and the hit area match iOS); leaf rows (text,image,textfield) pad their content frame back into place. The separator (--sb-color-row-separator, lightrgba(0,0,0,0.09)) starts at the row's first text (the engine's row chromeseparator, set as--sb-separator-leading, else 16px) and ends 16px before the trailing edge. Rows whose own chrome replaced therowkey (toggle, borderedbutton) rebuild it from the card and the engine's rulemax(52, content + 2 × 13). - 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–165 (scrolling with the list, which starts under the bar at 103 with its content 62 down, so its first card is at 183), and its rows are 52 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]: iOS 26's palette (accent and blue#0088ff, dark#0091ff; red#ff383c, orange#ff8d28, …). 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 capsule in the secondary fill, or in the view'stintat 18% (25% in dark mode,--sb-tint-fill, which the renderer sets beside--sb-tint),borderedProminenta filled accent capsule (label + 12/6 padding, 34px minimum, from the engine; with acontrolSizeprop:small28,mini24,large50,extraLarge58),destructivered text; a disabled bordered button keeps the gray fill and dims its label rather than fading. 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), dashed when it carries adashpattern (the dash lengths are the browser's). Both take exactly the size the engine proposes (10pt on an unconstrained axis).list/sectionrender iOS 26 inset-grouped: grouped background on the list, white (dark:#1C1C1E) 26px-rounded cards inset 16px, rows at least 52px with 16px content insets and separators from the first text to 16px before the trailing edge. 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 that sends atapwith thenavscreen's own id: iOS 26's 44×44 glass circle at x 16 withchevron.leftalone, in the label color (the engine's back label is empty; the previous screen's title shows only until the engine has run).textfieldis an<input>(34pxroundedBorder, 22pxplain);inputevents send{"type":"text"}. Anupdatewhosetextequals the current value leaves.value(and the caret) alone.togglerenders a 63×28 switch with a 37×24 pill knob (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) is iOS 26's floating glass capsule drawn on top: 62 high, x 21…372 on a 393 screen, 22 above the screen bottom (8 above the bottom of a tab view that ends higher),--sb-glass-backgroundwith--sb-glass-shadowand a backdrop blur, 31px radius. One.sb-tabbar-itemper tab sits at the engine'sitem<i>rect (equal widths inside a 4 inset, 54 high): the tab'ssystemImagethrough the symbol table at 24px over a 10px semibold title, in the label color, the selected one (aria-selected) in the accent color on a gray glass pill (--sb-glass-selected). 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 floating 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 what the bar covers (the capsule and the gap under it, 84) 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), a capsule; 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 capsule (dark#5a5a5f) inset 2px with a soft shadow. Tapping a segment moves the pill optimistically and sendsselectwith the index; Swift'supdateconfirms. In a list row it gets the 13px row padding (a 58 row) 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, and whatFormsends; Phase 2 modules send{}) is the existing card layout;groupedmakes 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, 10px top corners;
medium: 433 high) and the sheet background (#FFFFFF, dark: #1C1C1E, the
elevated background). A 36×5 grabber sits 8px from the top only when the
sheet has more than one detent (iOS 26). A medium, height or fraction sheet
floats (engine chrome floating, data-sb-floating): a card inset 8px from
the screen sides and 15px above its bottom, 38px corners all round, the
sheet background at 55% over a backdrop blur, a lighter scrim
(rgba(0,0,0,0.22)); navigation screens in it are transparent, so the
glass shows through them. A navigation stack in a sheet fills the card from
its top, its bar taking 16px (20 with a grabber) above the 44px row. 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 matters by its first entry (and
its count, for the grabber).
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.
