npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 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 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 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. Without ?app= it runs the site's default app (__SB_DEFAULT_APP__; Counter for 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 as index.html and 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.webmanifest is the app's own. buildSite builds such a site with pageMode: '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=frame still shows the frame. The dev server keeps pageMode: '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=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).

  • ?arg=<value> (repeatable) adds launch arguments after the app's name, as simctl launch passes them: ?app=Fidelity&arg=-screen&arg=form opens 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 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 (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 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. 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:

  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 (62px row, text from chromeText), back, content}, sheet {grabber, floating}, 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. A list or scrollview with a largeTitle rect 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 gets data-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 own footer prop in the engine's footnote font and rect.
  • A list row's DOM box is the engine's full-width row rect (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, light rgba(0,0,0,0.09)) starts at the row's first text (the engine's row chrome separator, set as --sb-separator-leading, else 16px) and ends 16px before the trailing edge. Rows whose own chrome replaced the row key (toggle, bordered button) rebuild it from the card and the engine's rule max(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 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–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.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]: iOS 26's palette (accent and blue #0088ff, dark #0091ff; red #ff383c, orange #ff8d28, …). 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 capsule in the secondary fill, or in the view's tint at 18% (25% in dark mode, --sb-tint-fill, which the renderer sets beside --sb-tint), borderedProminent a filled accent capsule (label + 12/6 padding, 34px minimum, from the engine; with a controlSize prop: small 28, mini 24, large 50, extraLarge 58), destructive red text; a disabled bordered button keeps the gray fill and dims its label rather than fading. 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), dashed when it carries a dash pattern (the dash lengths are the browser's). Both take exactly the size the engine proposes (10pt on an unconstrained axis).
  • list / section render 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-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 that sends a tap with the navscreen's own id: iOS 26's 44×44 glass circle at x 16 with chevron.left alone, in the label color (the engine's back label is empty; the previous screen's title shows only until the engine has run).
  • 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 63×28 switch with a 37×24 pill knob (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) 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-background with --sb-glass-shadow and a backdrop blur, 31px radius. One .sb-tabbar-item per tab sits at the engine's item<i> rect (equal widths inside a 4 inset, 54 high): the tab's systemImage through 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: 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 floating 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 what the bar covers (the capsule and the gap under it, 84) 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), a capsule; 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 capsule (dark #5a5a5f) 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 it gets the 13px row padding (a 58 row) 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, and what Form sends; Phase 2 modules send {}) is the existing card layout; grouped 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, 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/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.