@swiftbrowser/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +600 -0
  2. package/app/index.html +125 -0
  3. package/index.html +56 -0
  4. package/node/dist/node/apps.d.ts +58 -0
  5. package/node/dist/node/apps.d.ts.map +1 -0
  6. package/node/dist/node/apps.js +60 -0
  7. package/node/dist/node/apps.js.map +1 -0
  8. package/node/dist/node/config.d.ts +42 -0
  9. package/node/dist/node/config.d.ts.map +1 -0
  10. package/node/dist/node/config.js +99 -0
  11. package/node/dist/node/config.js.map +1 -0
  12. package/node/dist/node/examples.d.ts +39 -0
  13. package/node/dist/node/examples.d.ts.map +1 -0
  14. package/node/dist/node/examples.js +116 -0
  15. package/node/dist/node/examples.js.map +1 -0
  16. package/node/dist/node/index.d.ts +47 -0
  17. package/node/dist/node/index.d.ts.map +1 -0
  18. package/node/dist/node/index.js +52 -0
  19. package/node/dist/node/index.js.map +1 -0
  20. package/node/dist/plugins/app-manifests.d.ts +61 -0
  21. package/node/dist/plugins/app-manifests.d.ts.map +1 -0
  22. package/node/dist/plugins/app-manifests.js +168 -0
  23. package/node/dist/plugins/app-manifests.js.map +1 -0
  24. package/node/dist/plugins/asset-catalogs.d.ts +119 -0
  25. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
  26. package/node/dist/plugins/asset-catalogs.js +488 -0
  27. package/node/dist/plugins/asset-catalogs.js.map +1 -0
  28. package/node/dist/plugins/bundle-resources.d.ts +49 -0
  29. package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
  30. package/node/dist/plugins/bundle-resources.js +172 -0
  31. package/node/dist/plugins/bundle-resources.js.map +1 -0
  32. package/node/dist/plugins/swift-wasm.d.ts +33 -0
  33. package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
  34. package/node/dist/plugins/swift-wasm.js +241 -0
  35. package/node/dist/plugins/swift-wasm.js.map +1 -0
  36. package/node/dist/src/assets.d.ts +60 -0
  37. package/node/dist/src/assets.d.ts.map +1 -0
  38. package/node/dist/src/assets.js +54 -0
  39. package/node/dist/src/assets.js.map +1 -0
  40. package/node/dist/src/devEvents.d.ts +31 -0
  41. package/node/dist/src/devEvents.d.ts.map +1 -0
  42. package/node/dist/src/devEvents.js +9 -0
  43. package/node/dist/src/devEvents.js.map +1 -0
  44. package/package.json +56 -0
  45. package/plugins/app-manifests.ts +192 -0
  46. package/plugins/asset-catalogs.ts +558 -0
  47. package/plugins/bundle-resources.ts +195 -0
  48. package/plugins/swift-wasm.ts +259 -0
  49. package/public/_headers +23 -0
  50. package/public/apple-touch-icon.png +0 -0
  51. package/public/icon.svg +14 -0
  52. package/public/manifest.webmanifest +14 -0
  53. package/src/animation.ts +521 -0
  54. package/src/assets.ts +86 -0
  55. package/src/chartDom.ts +481 -0
  56. package/src/devEvents.ts +35 -0
  57. package/src/deviceMode.ts +80 -0
  58. package/src/ghost.ts +25 -0
  59. package/src/interaction.ts +1113 -0
  60. package/src/landing.css +261 -0
  61. package/src/landing.ts +141 -0
  62. package/src/layout/chart.ts +477 -0
  63. package/src/layout/controls.ts +116 -0
  64. package/src/layout/engine.ts +3581 -0
  65. package/src/layout/index.ts +91 -0
  66. package/src/layout/measure.ts +86 -0
  67. package/src/layout/text.ts +124 -0
  68. package/src/layout/types.ts +153 -0
  69. package/src/layout/typography.ts +188 -0
  70. package/src/layoutDom.ts +549 -0
  71. package/src/layoutEvents.ts +110 -0
  72. package/src/locale.ts +64 -0
  73. package/src/main.ts +554 -0
  74. package/src/mock.ts +752 -0
  75. package/src/nodeQueries.ts +60 -0
  76. package/src/protocol.ts +1421 -0
  77. package/src/renderer.ts +2882 -0
  78. package/src/styles.css +2834 -0
  79. package/src/styles.ts +373 -0
  80. package/src/swipe.ts +110 -0
  81. package/src/symbols.ts +595 -0
  82. package/src/typography.ts +76 -0
  83. package/src/vite-env.d.ts +26 -0
  84. package/src/wasm.ts +400 -0
package/README.md ADDED
@@ -0,0 +1,600 @@
1
+ # SwiftBrowser web renderer
2
+
3
+ Vite + TypeScript (no UI framework) renderer that applies the ops stream
4
+ described in [`docs/ops-protocol.md`](../docs/ops-protocol.md) to the DOM inside
5
+ an iPhone-shaped frame styled with iOS design tokens, lays the tree out with a
6
+ SwiftUI-style layout engine, animates changes, forwards taps, text input,
7
+ toggles, selections, geometry reports, the color scheme and the Dynamic Type
8
+ size back to the Swift/Wasm app, and drives the app's cooperative executor
9
+ (Phase 4) so `Task`, `.task` and sleeps reach the screen.
10
+
11
+ ```
12
+ index.html Landing page (served at /): one card per registered app with an Open link and a QR code
13
+ app/index.html The app page (served at /app/): toolbar, iPhone frame, #screen
14
+ src/landing.ts Landing page script: app cards (qrcode SVGs), build info from the `define`s
15
+ src/landing.css Landing page styles
16
+ src/deviceMode.ts Frame vs device mode decision, env(safe-area-inset-*) reader, viewport watcher
17
+ src/vite-env.d.ts Types of the build-time defines (__SB_APPS__, __SB_COMMIT__, …)
18
+ src/protocol.ts TypeScript mirror of the ops protocol (Op, element kinds, Style, Color, Animation, events)
19
+ src/renderer.ts Renderer: Map<id, HTMLElement> + LayoutNode tree, ops, navigation, sheets, layout on commit
20
+ src/layout/ The layout engine (propose/report/place, text wrapping, Dynamic Type table); unit-tested with vitest
21
+ src/layoutDom.ts Applies a LayoutResult to the DOM: absolute positioning, text lines, chrome rects
22
+ src/animation.ts Animator: Web Animations for animated commits / animationToken subtrees, transitions, spring easing
23
+ src/typography.ts Page-side Dynamic Type helpers over src/layout/typography.ts (CSS variables per text style)
24
+ src/symbols.ts SF Symbol name → lucide glyph table for Image(systemName:)
25
+ src/wasm.ts loadApp(): WASI shim, _start, sb_ops_*, sb_event, sb_alloc + sb_event_json, sb_state_*, sb_run_jobs (JobScheduler)
26
+ src/mock.ts Counter, Todos, Gallery and Settings fixtures + in-page mock "Swift core" for ?mock=1
27
+ src/main.ts App page wiring: mode, theme toggle, Text size control, app label, boot, hot-reload state stash
28
+ src/devEvents.ts Payload types for the sb:build-* websocket events
29
+ src/styles.css iOS tokens, device frame, device mode, element styles
30
+ node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
31
+ examples.ts (Examples/* as specs, build-wasm.sh runner, descriptions),
32
+ index.ts (createDevServer / buildSite, the package entry); compiled to node/dist/
33
+ plugins/ Vite plugins over the registry: swift-wasm.ts serves /<name>.wasm, rebuilds on Swift changes
34
+ and copies the modules into dist/; app-manifests.ts: one web app manifest (+ icon) per app
35
+ (/manifests/<name>.webmanifest); asset-catalogs.ts: *.xcassets -> /catalog/<name>.json + files
36
+ e2e/ Playwright tests (mock fixtures + the real Counter.wasm / Todos.wasm / Settings.wasm)
37
+ public/ Counter.wasm / Todos.wasm / Gallery.wasm / Settings.wasm land here (git-ignored);
38
+ also manifest.webmanifest, icon.svg, apple-touch-icon.png and the Cloudflare _headers
39
+ vite.config.ts `npm run dev` / `vite build` for the examples: createViteConfig(exampleAppSpecs())
40
+ tsconfig.node-build.json Emits node/dist/ (ESM + .d.ts) for `import ... from '@swiftbrowser/web'`
41
+ ```
42
+
43
+ ## Run
44
+
45
+ ```sh
46
+ npm install
47
+ npm run dev # http://localhost:5173/ landing page (list of examples)
48
+ # http://localhost:5173/app/ loads /Counter.wasm
49
+ # http://localhost:5173/app/?app=Todos loads /Todos.wasm
50
+ # http://localhost:5173/app/?mock=1 hardcoded Counter fixture, no wasm needed
51
+ npm run build # typechecks src/, writes dist/ (dist/index.html + dist/app/index.html) and node/dist/
52
+ npm run build:node # only node/dist/ (the programmatic API other packages import)
53
+ npm run preview # serves dist/
54
+ npm run typecheck # src/, e2e/, node/, plugins/ and the config files
55
+ npm run test:unit # vitest: the layout engine (src/layout/__tests__), the plugins and the registry
56
+ npm run test:e2e # Playwright; starts the dev server itself
57
+ ```
58
+
59
+ ## App registry and the programmatic API
60
+
61
+ `vite.config.ts` no longer hardcodes the examples: `node/config.ts` builds the
62
+ Vite config from a list of `AppSpec`s (`node/apps.ts`), one per app:
63
+
64
+ ```ts
65
+ interface AppSpec {
66
+ name: string; // URL-safe: ?app=<name>, /<name>.wasm, /catalog/<name>.json, /manifests/<name>.webmanifest
67
+ displayName?: string; // landing card title + manifest name; defaults to name
68
+ description?: string; // landing card subtitle + manifest description
69
+ wasm: string; // absolute path of the built module (may not exist yet in dev)
70
+ catalogs: string[]; // absolute *.xcassets directories
71
+ watch?: string[]; // absolute directories whose **/*.swift changes trigger `build`
72
+ build?: () => Promise<void>; // rebuilds `wasm`; rejects with the compiler output
73
+ icon?: string; // absolute path of a PNG; served at /manifests/<name>-icon.png
74
+ }
75
+ ```
76
+
77
+ The three plugins take the same list: `plugins/swift-wasm.ts` serves
78
+ `/<name>.wasm` from `spec.wasm` (building it on the first request when it is
79
+ missing), watches `spec.watch` and copies the module into `dist/` on build;
80
+ `plugins/asset-catalogs.ts` builds `/catalog/<name>.json` from
81
+ `spec.catalogs`; `plugins/app-manifests.ts` writes the manifest (and the icon)
82
+ from `displayName`, `description` and `icon`.
83
+
84
+ `node/examples.ts` turns `../Examples/*` into specs (`wasm` =
85
+ `public/<Name>.wasm`, `build` = `bash scripts/build-wasm.sh <Name>` with
86
+ `SB_WASM_OPT=0`, `watch` = `Sources/` + the example's directory), which is
87
+ what `npm run dev` / `vite build` use.
88
+
89
+ Other packages (the CLI) import the same machinery through the package entry,
90
+ compiled by `npm run build:node` (`tsconfig.node-build.json` → `node/dist/`):
91
+
92
+ ```ts
93
+ import { createDevServer, buildSite, type AppSpec } from '@swiftbrowser/web';
94
+
95
+ const server = await createDevServer({ apps, port: 5173, host: true }); // started; server.printUrls()
96
+ await buildSite({ apps, outDir: 'dist', commit, branch }); // complete static site
97
+ ```
98
+
99
+ Both resolve the Vite root (`Web/`) relative to the module and ignore
100
+ `vite.config.ts`.
101
+
102
+ ## Pages and URLs
103
+
104
+ The site is a Vite multi-page app (`build.rollupOptions.input` in
105
+ `vite.config.ts`, `appType: 'mpa'`):
106
+
107
+ - `/` (`index.html` + `src/landing.ts`): "SwiftBrowser examples". The list of
108
+ apps comes from the app registry (`node/config.ts`, see "App registry"
109
+ below), injected with `define` as `__SB_APPS__` (`{ name, displayName,
110
+ description?, icon? }[]`); `vite.config.ts` registers every directory under
111
+ `../Examples`, with the descriptions in `node/examples.ts`. The card title
112
+ is `displayName` (the name by default), the subtitle the description or
113
+ "the <name> app". Every card has
114
+ an "Open" link to `/app/?app=<Name>` and an SVG QR code of the absolute URL
115
+ (`location.origin + '/app/?app=' + name`, generated in the browser with the
116
+ `qrcode` package) so a phone can scan it from a desktop. The footer shows
117
+ the build info, also from `define`: `__SB_COMMIT__` (`GITHUB_SHA`, else
118
+ `git rev-parse --short HEAD`, else `dev`; linked to the commit on GitHub
119
+ unless `dev`), `__SB_BRANCH__` (`GITHUB_REF_NAME`, else
120
+ `git rev-parse --abbrev-ref HEAD`) and `__SB_BUILT_AT__`.
121
+ - `/app/` (`app/index.html` + `src/main.ts`): the renderer. `import.meta.env.BASE_URL`
122
+ stays `/`, so the modules are still fetched from `/<Name>.wasm` and the
123
+ dev-server plugin's rebuild registry (which watches those requests) works
124
+ unchanged.
125
+
126
+ The app page's query parameters:
127
+
128
+ - `?app=Name` loads `/Name.wasm` instead of `/Counter.wasm` (and passes `Name`
129
+ as `argv[0]`). The dev server remembers every app requested this way and
130
+ rebuilds all of them on Swift changes (see "Hot reload").
131
+ - `?mock=1` renders a hardcoded screen and answers events in-page, so the
132
+ renderer can be worked on without a Swift toolchain. Two fixtures exist:
133
+ - `?mock=1` (or `?mock=1&app=Counter`): the Phase 1 Counter screen; `+`/`−`
134
+ answer with `update` ops.
135
+ - `?mock=1&app=Todos`: a Phase 2 `navstack` → `navscreen` ("Todos", large
136
+ title) → `list` with a "Today" `section` of three rows (a `toggle`, a
137
+ `navlink`, an `hstack` with an `image` and text) and a second section with
138
+ a `roundedBorder` `textfield` plus a `bordered` "Add" button wrapped in a
139
+ `styled { disabled }`. The mock flips `isOn` on toggle, pushes an inline
140
+ "Detail" `navscreen` on the navlink, pops it on a tap of the screen's own
141
+ id (the back button), echoes typed text back and enables "Add" once there
142
+ is text. `window.__sb.app().received` lists every event it got.
143
+ - `?mock=1&app=Gallery`: Phase 3. A `vstack` of one `text` per text style
144
+ (to check Dynamic Type), a `color` and two `shape`s (a filled circle, a
145
+ stroked capsule) in fixed frames, an `.animation(.spring)` subtree
146
+ (`styled { animation, animationToken }`) around a rounded rectangle, and
147
+ two buttons. "Toggle" bumps the `animationToken`, swaps the shape's fill
148
+ (red ↔ green), doubles the frame width (80 ↔ 160) and inserts or removes
149
+ an "Expanded" text with `transition: opacity`, all in a commit carrying
150
+ `animation: easeInOut 0.35`. "Show sheet" appends a root-level `sheet`
151
+ (`detents: ["large"]`) with a title and a "Done" button; "Done", a tap on
152
+ the scrim and a drag of more than 100px all remove it (the latter two by
153
+ sending a `tap` with the sheet's own id). Ids are in `GALLERY_IDS`.
154
+ - `?mock=1&app=Settings`: Phase 4. A `tabview` (`selected: 0`) with two
155
+ tabs: "Home" (`house`) holds a `navstack` → `navscreen` ("Home", large
156
+ title) → inset-grouped `list` with a "Geometry" section (a
157
+ `styled { frame: { height: 22 } }` → `geometry` → `text`, which the mock
158
+ rewrites to the reported `W × H`) and a "Rows" section of 20 rows, long
159
+ enough to scroll under the tab bar; "Settings" (`gear`) holds a `list`
160
+ with `style: "grouped"` containing a segmented `picker` ("Appearance":
161
+ Light/Dark/Auto), a menu `picker` ("Units": Metric/Imperial) and a
162
+ `toggle`. `select` on the tab view or a picker updates its `selected`,
163
+ `toggle` flips `isOn`, `geometry` updates the text. Ids are in
164
+ `SETTINGS_IDS`; every event lands in `window.__sb.app().received`.
165
+
166
+ - `?mode=device` / `?mode=frame` forces device mode or the iPhone frame (see
167
+ "Device mode" below).
168
+
169
+ The light/dark toggle in the toolbar flips `data-theme` on the device frame and
170
+ is remembered in `localStorage` (`sb-theme`). It only affects the phone; the
171
+ page chrome follows the OS `prefers-color-scheme`.
172
+
173
+ The **Text size** select (`#type-size`, labelled "Text size") picks one of the
174
+ 12 Dynamic Type categories (`xSmall` … `accessibility5`, default `large`),
175
+ remembered in `localStorage` (`sb-type-size`). It sets `data-type-size` on the
176
+ device frame, writes `--sb-ts-<style>-size` / `-lh` / `-scale` CSS variables
177
+ for every text style (`src/typography.ts`), re-runs the layout engine with the
178
+ new `dynamicTypeSize`, and sends an `environment` event.
179
+
180
+ ## Device mode (real phones)
181
+
182
+ `src/deviceMode.ts` decides between two modes when the app page loads (an
183
+ inline script in `app/index.html` applies the same rule before the first paint
184
+ so a phone never flashes the bezel):
185
+
186
+ - **frame** (desktop, tests): the fake iPhone 15 Pro bezel with its drawn
187
+ status bar, Dynamic Island and home indicator, plus the toolbar. Exactly as
188
+ before.
189
+ - **device**: `?mode=device`, or automatically when
190
+ `matchMedia('(pointer: coarse)').matches` (a coarse pointer at any width:
191
+ a tablet fills the viewport instead of showing the phone bezel; `?mode=frame`
192
+ wins over the automatic rule).
193
+
194
+ In device mode `<html data-sb-mode="device">` hides the toolbar and the bezel
195
+ chrome, `#screen` fills `100dvw × 100dvh` at (0, 0), the layout environment's
196
+ `screen` is the viewport and its `safeArea` comes from the real
197
+ `env(safe-area-inset-top/right/bottom/left)` values, exposed as
198
+ `--sb-inset-top/right/bottom/left` on `:root` and read with
199
+ `getComputedStyle` (`renderer.setSafeArea`). They are non-zero only in
200
+ standalone (home screen) mode or landscape, which is what a native app would
201
+ get. `resize`, `orientationchange` and `visualViewport` resizes re-run the
202
+ engine (debounced 100ms, `renderer.viewportChanged`). The color scheme follows
203
+ `prefers-color-scheme` (and its changes), the Dynamic Type size is `large`, and
204
+ the `environment` event carries both as usual. `touch-action: manipulation`
205
+ and `-webkit-text-size-adjust: 100%` stop double-tap zoom and text inflation;
206
+ the viewport meta has `viewport-fit=cover`. Hot-reload state restore and the
207
+ job scheduler are unaffected. `window.__sb.mode` reports the mode.
208
+
209
+ `app/index.html` also declares `apple-mobile-web-app-capable`,
210
+ `apple-mobile-web-app-status-bar-style: black-translucent` (the OS status bar
211
+ stays transparent, the app's own background shows under it and the layout
212
+ engine gets the bar as a safe-area inset; `default` draws an opaque light bar
213
+ whatever the app's color scheme), light/dark `theme-color`s, a web app
214
+ manifest and the icons `public/icon.svg` + `public/apple-touch-icon.png`
215
+ (180×180, drawn with ImageMagick to match the SVG). In Safari, Share → Add to
216
+ Home Screen then runs an app full screen.
217
+
218
+ Safari takes a home-screen icon's launch URL and label from the manifest, not
219
+ from the page URL, so there is one manifest per example: `plugins/app-manifests.ts`
220
+ serves `/manifests/<Name>.webmanifest` (`start_url: /app/?app=<Name>`,
221
+ `short_name: <Name>`, `display: standalone`) in the dev server and emits them
222
+ in `vite build`, and an inline script in `app/index.html` points
223
+ `<link rel="manifest">` and `apple-mobile-web-app-title` at the app named by
224
+ `?app=`. `public/manifest.webmanifest` (`start_url: /app/`, the default app)
225
+ remains for the landing page and for `/app/` without a parameter.
226
+
227
+ ## Deploying (Cloudflare Pages)
228
+
229
+ `npm run build` writes a static site to `dist/`: `index.html`, `app/index.html`,
230
+ hashed `assets/`, the `.wasm` modules and everything from `public/`. The
231
+ GitHub workflow builds every example, runs `npm ci && npm run build` in `Web/`
232
+ and deploys `Web/dist` with `wrangler pages deploy`. `public/_headers` tells
233
+ Pages to serve `/*.wasm` with `Content-Type: application/wasm` and
234
+ `Cache-Control: no-cache` (the modules are not content-hashed) and the hashed
235
+ `/assets/*` as immutable. Apps are opened as
236
+ `https://<site>/app/?app=<Name>`; the landing page lists and QR-codes them.
237
+
238
+ ## Getting `Counter.wasm` into `public/`
239
+
240
+ The renderer fetches `/<App>.wasm`, which Vite serves from `Web/public/`.
241
+ Those files are build products and are git-ignored (`public/*.wasm`); only
242
+ `public/.gitkeep` is tracked.
243
+
244
+ From the repo root:
245
+
246
+ ```sh
247
+ scripts/build-wasm.sh # swift build --swift-sdk <wasm sdk> -c release, wasm-opt -Os (when installed), then
248
+ # copies Counter.wasm into Web/public/
249
+ scripts/build-wasm.sh Todos # same for the Todos example
250
+ ```
251
+
252
+ ## Hot reload
253
+
254
+ `npm run dev` runs the `swiftWasm()` plugin from `plugins/swift-wasm.ts`:
255
+
256
+ 1. It watches `../Sources/**/*.swift` and `../Examples/**/*.swift` with the
257
+ dev server's own file watcher and debounces changes for 300ms.
258
+ 2. It runs `bash scripts/build-wasm.sh <App>` from the repo root for every app
259
+ requested since the server started (`Counter` by default; a `/Todos.wasm`
260
+ request adds `Todos`). Build output is logged to the terminal. `swift` is
261
+ resolved from `$SWIFT_TOOLCHAIN_BIN`, then
262
+ `/root/.local/share/swiftly/toolchains/6.4.0/usr/bin` if present, then `PATH`.
263
+ 3. While building, the page shows a "Rebuilding…" indicator in the toolbar
264
+ (websocket events `sb:build-start` / `sb:build-end`). On success the server
265
+ sends a `full-reload`; on failure it sends `sb:build-error` with the
266
+ compiler output, which the page shows in the error banner while the current
267
+ app keeps running.
268
+ 4. Before the reload (`vite:beforeFullReload`, plus `beforeunload`/`pagehide`
269
+ as a fallback for manual refreshes), `main.ts` reads the app's `@State`
270
+ snapshot through `sb_state_ptr`/`sb_state_len` and stores it in
271
+ `sessionStorage['sb-state:<App>']`. On boot the snapshot is removed from
272
+ storage and passed to the new module as the WASI environment variable
273
+ `SB_STATE=<json>`, e.g. `SB_STATE={"ContentView#0":3}`. Modules without the
274
+ snapshot exports (Phase 1) are reloaded without state.
275
+
276
+ Mock mode never stashes state. `window.__sb.snapshotState()` returns the
277
+ current snapshot for inspection.
278
+
279
+ Until that script exists you can copy the file by hand:
280
+
281
+ ```sh
282
+ cp "$(swift build --show-bin-path --swift-sdk swift-6.4.0-RELEASE_wasm -c release)/Counter.wasm" Web/public/
283
+ ```
284
+
285
+ Then `npm run dev` and open http://localhost:5173/app/ (without `?mock=1`). If the
286
+ file is missing, the page shows an error banner under the device explaining
287
+ what it expected.
288
+
289
+ ## What the renderer expects from the Wasm module
290
+
291
+ See `docs/ops-protocol.md`. In short, `src/wasm.ts`:
292
+
293
+ 1. fetches and compiles the module; it must import only `wasi_snapshot_preview1`;
294
+ 2. instantiates it with `@bjorn3/browser_wasi_shim` (`args: ["Counter"]`,
295
+ `env: []`, stdout/stderr forwarded to the browser console);
296
+ 3. calls `_start` (or `_initialize` for a reactor build). `_start` returning
297
+ normally **or** calling `proc_exit(0)` both count as success; any non-zero
298
+ exit code is an error. The instance stays alive afterwards;
299
+ 4. reads `sb_ops_ptr()`/`sb_ops_len()` as a UTF-8 JSON array out of
300
+ `exports.memory`, calls `sb_ops_clear()`, then applies the ops;
301
+ 5. on every tap (`button`, `navlink`, or the back button of a `navscreen`)
302
+ calls `sb_event(id)` and repeats step 4;
303
+ 6. for every other event (`text`, `toggle`, `environment`, `select`,
304
+ `geometry`) encodes the JSON event as UTF-8, calls `sb_alloc(len)`, writes
305
+ the bytes at the returned pointer, calls `sb_event_json(ptr, len)` and
306
+ repeats step 4. Modules that lack `sb_alloc`/`sb_event_json` (Phase 1)
307
+ skip these events; `environment` is sent once right after `_start` and
308
+ again whenever the theme toggle or the Text size control changes. It
309
+ always carries both fields:
310
+ `{"type":"environment","colorScheme":"light","dynamicTypeSize":"large"}`,
311
+ with `dynamicTypeSize` one of `xSmall`, `small`, `medium`, `large`,
312
+ `xLarge`, `xxLarge`, `xxxLarge`, `accessibility1` … `accessibility5`.
313
+ Phase 2 modules ignore the extra field. Events the renderer produces
314
+ before the handle exists (the `geometry` reports of the very first layout
315
+ pass, which runs inside `loadApp`) are queued and delivered right after
316
+ `environment`;
317
+ 7. (Phase 4) if the module exports `sb_run_jobs(now_ms: f64) -> f64`, calls
318
+ it with `performance.now()` right after the first ops were applied, after
319
+ every delivered event (after that event's ops were applied) and whenever
320
+ the delay it returned has elapsed; after every call it repeats step 4.
321
+ `src/wasm.ts`'s `JobScheduler` keeps a single pending `setTimeout` for
322
+ the next run (every call cancels and reschedules it; the delay is clamped
323
+ to at least 4 ms; a negative return schedules nothing until the next
324
+ event). `window.__sb.runJobs()` runs it on demand and returns the delay
325
+ (`null` for modules and mocks without the export, which keep working as
326
+ before).
327
+
328
+ `memory.buffer` is re-read after every call into Wasm because growth detaches
329
+ the previous `ArrayBuffer`.
330
+
331
+ ## Renderer notes
332
+
333
+ ### Ops and the element tree
334
+
335
+ - `insert` with an element that is already attached is a move. `index` is the
336
+ position in the parent's child list *after* the op (the element is detached
337
+ first, then inserted before the current child at `index`).
338
+ - `update` replaces props entirely: inline styles and data attributes produced
339
+ by the previous props are cleared first (the layout box is kept until the
340
+ commit re-lays out).
341
+ - `remove` detaches the element and forgets it and every descendant.
342
+ - `commit` runs the layout engine over the whole tree, positions every element
343
+ and hands the pass to the Animator; it then dispatches a `sb:commit`
344
+ CustomEvent on `#screen` (`detail.animation` is the commit's animation or
345
+ null). Everything else is applied eagerly, not batched.
346
+ - Besides `elements` (id → HTMLElement) the renderer keeps `nodes` (id →
347
+ `LayoutNode { id, kind, props, children }`, the shape the engine reads) and
348
+ `parents`. `window.__sb.renderer.layoutResult` is the last `LayoutResult`.
349
+
350
+ ### Layout (Phase 3)
351
+
352
+ Layout is not CSS. On every commit, and whenever the Dynamic Type size, the
353
+ color scheme or the fonts change (`renderer.setEnvironment`,
354
+ `renderer.fontsChanged`), the renderer calls
355
+ `layoutTree(root.children, env, new CanvasTextMeasurer())` from `src/layout`
356
+ (see the "Layout engine semantics" table in `docs/ops-protocol.md`) and
357
+ `src/layoutDom.ts` applies the result:
358
+
359
+ - Every protocol element is `position: absolute` with `left/top/width/height`
360
+ from its frame, relative to its parent element's box. `#screen` is a plain
361
+ `overflow: hidden` box; the safe areas are part of the engine's proposal.
362
+ - `text` elements get the engine's exact lines joined with `\n` under
363
+ `white-space: pre`, the resolved `font` shorthand (`weight size/lineHeight
364
+ family`) and `text-align` from `multilineTextAlignment`. `lineLimit`
365
+ truncation (the `…`) comes from the engine too.
366
+ - `image` elements are a square of the font's line height with the glyph at
367
+ `1em` (font-size from `result.fonts`).
368
+ - `scrollview` and `list` get an inner content box (`.sb-scroll-content`,
369
+ `.sb-list-content`) sized from `contentSizes`; children are placed inside it
370
+ at scroll offset 0 and the box scrolls (`overflow: auto`, hidden scrollbars).
371
+ When the view reaches the bottom of the screen (and is not inside a sheet),
372
+ the bottom safe area (34px) is added to the content box's height, iOS's
373
+ content inset, so the last row can scroll clear of the home indicator (the
374
+ engine's frames are unchanged).
375
+ - Compound kinds use the engine's `chrome` rects: `navscreen` {bar (status bar
376
+ + 44, padded so the 44px row is at the bottom), largeTitle (52px row, text
377
+ from `chromeText`), back, content}, `sheet` {grabber}, `toggle` {switch},
378
+ `section` {rows (the inset card), header, footer}, `navlink` rows {chevron},
379
+ `tabview` {bar, item0 … itemN-1}, `picker` {segment0 … / label, value}.
380
+ A `list` or `scrollview` under a tab bar gets a `bottomInset` rect (the
381
+ part of its frame the bar covers): its height replaces the 34px safe-area
382
+ content inset.
383
+ Section headers come upper-cased from the engine in the footnote font;
384
+ footers are drawn from the section's own `footer` prop (sentence case).
385
+ - A list row's DOM box is the engine's full-width `row` rect (so the cell
386
+ background, the 16px-inset separator and the hit area match iOS); leaf rows
387
+ (`text`, `image`, `textfield`) pad their content frame back into place.
388
+ Rows whose own chrome replaced the `row` key (`toggle`, bordered `button`)
389
+ rebuild it from the card and the engine's rule `max(44, content + 2 × 11)`.
390
+ - Layout-only style fields (`padding`, `frame`, `layoutPriority`, `fixedSize`,
391
+ `lineLimit`, `multilineTextAlignment`) leave data attributes
392
+ (`data-sb-padding`, `data-sb-frame`, `data-sb-line-limit`, …) and are read by
393
+ the engine from `nodes`; `font`, `foreground`, `background`, `cornerRadius`
394
+ (+ `overflow: hidden`), `opacity` and `disabled` are inline CSS as before.
395
+ - Expected geometry with the real modules: Counter's three buttons are 88×44
396
+ at x = 48 / 152 / 256, y = 521 (16px gaps, centered); Todos' bar spans
397
+ y 0–103, the large title 103–155, the list starts at 155 and its rows are
398
+ 44 high and inset 16; a pushed inline screen's bar row is y 59–103.
399
+
400
+ ### Fonts and Dynamic Type
401
+
402
+ - `Style.font` fields are independent: `{ "weight": "bold" }` changes only the
403
+ weight. A `textStyle` resolves through Apple's Dynamic Type table in
404
+ `src/layout/typography.ts` (the single source of truth; `src/typography.ts`
405
+ re-exports it): Large is 34/28/22/20/17/17/16/15/13/12/11 for largeTitle …
406
+ caption2, line heights 41/34/28/25/22/22/21/20/18/16/13; other sizes use the
407
+ HIG point sizes with `round(size × 1.2)` line heights. `headline` implies
408
+ `semibold`. A fixed `size` does not scale unless `relativeTo` names a text
409
+ style, in which case it scales by that style's ratio to Large.
410
+ - Styled boxes with a `textStyle` also carry `font-size: var(--sb-ts-<style>-size)`
411
+ (fallback: the Large value) so chrome and un-laid-out text follow the Text
412
+ size control; `--sb-font-size-body` on the device frame follows `body`.
413
+
414
+ ### Kinds
415
+
416
+ - Semantic colors resolve to CSS variables (`--sb-color-primary`, `-secondary`,
417
+ `-accent`, `-system-background`, `-secondary-system-background`); `clear` is
418
+ `transparent`. Values for both schemes live in `styles.css` under
419
+ `.device[data-theme]`. An `a` on a semantic color is an opacity multiplier,
420
+ rendered as `color-mix(in srgb, var(--token) <a*100>%, transparent)`.
421
+ - `Style.disabled` multiplies the box's opacity by 0.4 and sets
422
+ `pointer-events: none` (plus `data-sb-disabled` / `aria-disabled`).
423
+ - `button.style` and `button.role` become `data-sb-style` / `data-sb-role`;
424
+ `bordered` is a gray capsule, `borderedProminent` a filled accent capsule
425
+ (label + 14/7 padding, 34px minimum, from the engine), `destructive` red
426
+ text. Phase 1 modules send `{}`, which reads as `automatic`.
427
+ - `image`: `systemName` is looked up in `src/symbols.ts` (SF Symbol name →
428
+ lucide glyph, including `.fill` / `.circle` variants) and rendered as an
429
+ inline `<svg>` in `currentColor`. Unknown names draw a dashed square with
430
+ `title` set to the name, so gaps are visible.
431
+ - `color` is a box filled with its color; `shape` draws `rectangle`,
432
+ `roundedRectangle` (`border-radius: cornerRadius`), `circle` / `ellipse`
433
+ (`50%`) and `capsule` (`9999px`). `fill` is the background; `fill: null`
434
+ without a stroke paints `currentColor` (a bare `Circle()`), with a stroke it
435
+ paints nothing (`Shape.stroke(_:)`); `stroke` is a solid border of
436
+ `lineWidth` (inside the frame, `box-sizing: border-box`). Both take exactly
437
+ the size the engine proposes (10pt on an unconstrained axis).
438
+ - `list` / `section` render iOS inset-grouped: grouped background on the
439
+ list, white (dark: `#1C1C1E`) 10px-rounded cards inset 16px, 44px rows
440
+ with 16px content insets and separators inset 16px. Every direct child of a
441
+ section (and every non-`section` child of a list) is a row; a `navlink`
442
+ row shows a trailing chevron, a `toggle` row puts the switch at the
443
+ trailing inset.
444
+ - Compound kinds (`navstack`, `navscreen`, `section`, `toggle`, `navlink`,
445
+ `list`, `scrollview`, `sheet`) own some chrome. Their protocol children go
446
+ into a slot element (`.sb-slot`), so `insert` indices are exact;
447
+ `Renderer.elements` still maps ids to the element itself.
448
+ - `navstack` / `navscreen`: only the last screen is interactive
449
+ (`data-sb-nav-position`, `inert`); a new screen slides in from the right
450
+ over 0.35s and the one below parks at `translateX(-30%)`. A removed top
451
+ screen animates out through a visual clone stripped of element ids. Screens
452
+ at `depth > 0` show a back button (`chevron.left` + the previous screen's
453
+ current title) that sends a `tap` with the `navscreen`'s own id. A title
454
+ `update` on any screen also refreshes the back labels above it.
455
+ - `textfield` is an `<input>` (34px `roundedBorder`, 22px `plain`); `input`
456
+ events send `{"type":"text"}`. An `update` whose `text` equals the current
457
+ value leaves `.value` (and the caret) alone. `toggle` renders a 51×31 switch
458
+ (`role="switch"`); clicking it flips the switch optimistically and sends
459
+ `{"type":"toggle"}`; Swift's `update` confirms the state.
460
+
461
+ ### Tab views, pickers, list styles and GeometryReader (Phase 4)
462
+
463
+ - `tabview` fills the screen like a `navstack` (safe areas ignored). Its
464
+ `tab` children go into `.sb-tabview-content` and all share the tab view's
465
+ frame; the bar (`.sb-tabbar`, the engine's `bar` rect: 49 + the bottom
466
+ safe area when the tab view reaches the screen bottom) is drawn on top with
467
+ a translucent system background (`rgba(249,249,249,0.94)`, dark
468
+ `rgba(29,29,29,0.94)`), a hairline on top and one `.sb-tabbar-item` per
469
+ tab at the engine's `item<i>` rect (equal widths, 49 high): the tab's
470
+ `systemImage` through the symbol table at 24px over a 10px title, accent
471
+ when selected (`aria-selected`), secondary otherwise. Tapping an item sends
472
+ `{"type":"select","id":<tabview id>,"value":<index>}` and nothing else:
473
+ the `selected` prop is authoritative, the `update` switches tabs. Only the
474
+ selected tab's content is laid out; the other tabs keep their DOM (and
475
+ scroll positions) under `data-sb-tab-hidden` (`visibility: hidden`,
476
+ `inert`, `aria-hidden`), driven by `LayoutResult.hidden`.
477
+ - Tab content is placed like a navscreen's: a `navstack` fills the whole tab
478
+ (its screens reserve the status bar and their lists run under the
479
+ translucent bar); a `list`/`scrollview` starts below the status bar and
480
+ also runs to the bottom; anything else is centered between the status bar
481
+ and the bar. Scrolling views under the bar get the bar height (83) as
482
+ scrollable content inset instead of the 34px safe area (engine chrome
483
+ `bottomInset`), so the last row scrolls clear of the bar like iOS.
484
+ - `picker` `segmented`: an iOS segmented control, 32 high, width = proposal
485
+ (or every label + 20): `.sb-segment-track` (`rgba(118,118,128,0.12)`, 8px
486
+ radius; dark `rgba(118,118,128,0.24)`) at the control's frame and one
487
+ `.sb-segment` radio button per option at the engine's `segment<i>` rect
488
+ (13px semibold); the checked one carries a white pill (dark `#636366`)
489
+ inset 2px with a soft shadow. Tapping a segment moves the pill
490
+ optimistically and sends `select` with the index; Swift's `update`
491
+ confirms. In a list row the row is 44 high with the control centered.
492
+ - `picker` `menu`: 44 high in a list row, 34 elsewhere; `role="button"` with
493
+ `.sb-picker-label` at the engine's `label` rect (leading) and
494
+ `.sb-picker-value` (the selected option plus `chevron.up.chevron.down`,
495
+ secondary color) at `value` (trailing). Tapping it (or Enter/Space) opens
496
+ `.sb-picker-menu` appended to `#screen`: 250 wide, 13px radius, system
497
+ background, one 44px `menuitemradio` row per option with a checkmark on
498
+ the current one, anchored under the control (above it when there is no
499
+ room, trailing edges aligned, 8px from the screen edges). Choosing a row
500
+ sends `select` and closes the menu; Escape or a tap anywhere outside
501
+ closes it (a tap inside the screen is swallowed so the control under it
502
+ does not fire); an `update` or removal of the picker closes it too.
503
+ `renderer.openMenuElement` exposes the open menu.
504
+ - `list.style`: `insetGrouped` (default; Phase 2 modules send `{}`) is the
505
+ existing card layout; `grouped` (`Form`) makes sections full width with no
506
+ corner radius, a hairline above and below each card and the usual 16px
507
+ content inset and 35px between sections; `plain` drops the cards, the top
508
+ gap and the gaps between sections and uses the system background. The
509
+ list carries `data-sb-list-style`.
510
+ - `geometry` (`GeometryReader`) is a plain box the engine sizes to its
511
+ proposal (10 on an unconstrained axis, so one in a list row needs a frame
512
+ height) and whose child is proposed that size and placed top leading.
513
+ After every layout pass the renderer compares each `geometry` element's
514
+ frame (rounded to whole px) with the size it last reported and queues
515
+ `{"type":"geometry","id":N,"width":w,"height":h}` for the changed ones; the
516
+ batch is sent once the ops being applied are done (so Swift's answering
517
+ commit re-enters the renderer cleanly) and never when the size is
518
+ unchanged, which is what lets the re-render loop converge. Elements in a
519
+ hidden tab are not laid out and therefore not reported.
520
+
521
+ ### Sheets (Phase 3)
522
+
523
+ A root-level `sheet` (child of id 0) is a full-screen layer (`role="dialog"`):
524
+ a scrim (`rgba(0,0,0,0.4)`) over the presenting tree and a card at the
525
+ engine's frame (large: screen height − status bar − 10; medium: half the
526
+ screen) with 10px top corners, a 36×5 grabber 8px from the top, and the
527
+ sheet background (`#FFFFFF`, dark: `#1C1C1E`, the elevated background). The
528
+ card slides up over 0.4s on insert (`sb-sheet-in`); on remove a clone
529
+ (`.sb-sheet--out`, no element ids) slides down and fades its scrim, then goes.
530
+ While the top sheet is `large`, `#screen` carries `data-sb-sheet="large"` and
531
+ the other root children scale to 0.92 behind the scrim; a `medium` sheet only
532
+ dims. Sheets stack: only the last is interactive (`data-sb-sheet-position`,
533
+ `inert`), sheets below park slightly scaled. A tap on the scrim, or a
534
+ downward drag of the card past 100px (pointer events; shorter drags spring
535
+ back), sends `{"type":"tap","id":<sheet id>}`: the dismiss request Swift
536
+ answers by removing the element. `detents` only matters by its first entry.
537
+
538
+ ### Animation (Phase 3)
539
+
540
+ `src/animation.ts` animates a pass with the Web Animations API when the
541
+ `commit` carries an `animation` (`withAnimation`) or a `styled` element's
542
+ `animationToken` changed in an `update` (`.animation(_:value:)`). A token
543
+ subtree uses its own `animation` (it overrides the commit's, like SwiftUI's
544
+ transaction override); everything else in an animated commit uses the commit's.
545
+
546
+ - Style changes of updated elements tween from their computed value before the
547
+ pass: `opacity`, `background-color`, `color`, `border-radius`, `font-size`.
548
+ - Frame changes tween `left/top/width/height` for every element whose layout
549
+ box moved or resized in the pass (the engine's frames before and after).
550
+ - Inserted subtree roots play their `transition` forwards, removed elements
551
+ leave a visual clone (`.sb-exit-clone`, stripped of ids, absolutely
552
+ positioned at the old frame on `#screen`) that plays it backwards and is
553
+ dropped when done. `opacity` fades; `scale` scales from `scale` (default
554
+ 0.5) with a fade; `slide` enters from the leading edge and exits through the
555
+ trailing edge; `move(edge)` translates by the element's own size from that
556
+ edge; `identity` does nothing; arrays combine. Without a `transition`,
557
+ inserted/removed views fade (SwiftUI's default). The transition is looked up
558
+ on the removed/inserted root or down a single-child chain of `styled` boxes
559
+ under it (`Text.transition(.opacity).padding()`). `navscreen` and `sheet`
560
+ keep their own CSS enter/exit instead.
561
+ - `Animation` → CSS timing: `default` and `easeInOut` →
562
+ `cubic-bezier(0.42, 0, 0.58, 1)`, `easeIn` → `cubic-bezier(0.42, 0, 1, 1)`,
563
+ `easeOut` → `cubic-bezier(0, 0, 0.58, 1)`, `linear`; `duration` defaults to
564
+ 0.35s, `delay` to 0. `spring` becomes a `linear()` easing sampled at 60
565
+ points from a damped spring with ω = 2π / duration and damping ratio
566
+ 1 − bounce (bounce 0: critically damped; 0.3: overshoots ~4.6%), run over
567
+ the perceptual `duration` (default 0.5s); browsers without `linear()` fall
568
+ back to easeInOut. `window.__sb.animation.{easingFor, timingFor,
569
+ springEasing}` expose the mapping.
570
+
571
+ - `window.__sb.renderer` exposes the live `Renderer` (used by the e2e tests
572
+ and handy in the console: `__sb.renderer.applyOps([...])`);
573
+ `window.__sb.sentEvents` lists the JSON events delivered to the app,
574
+ `window.__sb.colorScheme()` the current appearance,
575
+ `window.__sb.typeSize()` the current Dynamic Type size,
576
+ `window.__sb.runJobs()` runs the app's executor (Phase 4) and
577
+ `window.__sb.JobScheduler` is the executor driver class for tests.
578
+
579
+ ## Testing
580
+
581
+ `npm run test:unit` runs the layout engine's vitest suite (Node, no browser).
582
+ `npm run test:e2e` runs Playwright with Chromium. The config boots
583
+ `npm run dev -- --port 5173 --strictPort` itself. `e2e/renderer.spec.ts`
584
+ covers the Counter mock and op semantics, `e2e/phase2.spec.ts` the Phase 2
585
+ kinds against the Todos mock, `e2e/phase3.spec.ts` Dynamic Type, color/shape,
586
+ sheets and animation against the Gallery mock, `e2e/phase4.spec.ts` tab
587
+ views, pickers, list styles, geometry reports and the executor loop
588
+ (`JobScheduler` against a stub) against the Settings mock, and
589
+ `e2e/counter.spec.ts` / `e2e/todos.spec.ts` / `e2e/settings.spec.ts` drive
590
+ the real `public/Counter.wasm` / `Todos.wasm` / `Settings.wasm`, including
591
+ the geometry the engine guarantees (bounding boxes, not CSS flex properties)
592
+ and, for Settings, the clock task that only advances while `sb_run_jobs` is
593
+ driven. `e2e/landing.spec.ts` checks the landing page (cards, Open links, QR
594
+ SVGs, build info) and `e2e/device-mode.spec.ts` runs a 393×852 touch viewport
595
+ through `?mode=device` (no frame, full-screen `#screen`, real taps, resize
596
+ and color-scheme changes), the automatic rule and `?mode=frame`. The specs
597
+ open `/app/…`; the dev server is started at `/app/?mock=1`. Chromium is expected under
598
+ `$PLAYWRIGHT_BROWSERS_PATH/chromium`; set `SB_CHROMIUM_PATH` to point at a
599
+ different binary, or unset `PLAYWRIGHT_BROWSERS_PATH` to let Playwright use its
600
+ own download.