@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.
- package/README.md +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- 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.
|