@swifttui/web 0.0.6
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/AGENTS.md +52 -0
- package/README.md +116 -0
- package/cli.ts +168 -0
- package/index.html +50 -0
- package/index.ts +8 -0
- package/manifest.ts +1 -0
- package/package.json +33 -0
- package/src/AccessibilityTree.ts +262 -0
- package/src/BoxDrawingRenderer.ts +585 -0
- package/src/PublicEntrypointBoundary.test.ts +20 -0
- package/src/WebHostApp.test.ts +222 -0
- package/src/WebHostApp.ts +269 -0
- package/src/WebHostSceneManifest.test.ts +38 -0
- package/src/WebHostSceneManifest.ts +156 -0
- package/src/WebHostSceneRuntime.test.ts +1752 -0
- package/src/WebHostSceneRuntime.ts +955 -0
- package/src/WebHostSurfaceTransport.test.ts +362 -0
- package/src/WebHostSurfaceTransport.ts +648 -0
- package/src/WebHostTerminalStyle.test.ts +123 -0
- package/src/WebHostTerminalStyle.ts +471 -0
- package/src/WebHostTestFixtures.ts +10 -0
- package/src/WebSocketSceneBridge.test.ts +198 -0
- package/src/WebSocketSceneBridge.ts +233 -0
- package/src/browser.ts +59 -0
- package/src/wasi/BrowserWASIBridge.test.ts +168 -0
- package/src/wasi/BrowserWASIBridge.ts +167 -0
- package/src/wasi/SharedInputQueue.test.ts +146 -0
- package/src/wasi/SharedInputQueue.ts +199 -0
- package/src/wasi/StdIOPipe.ts +72 -0
- package/src/wasi/WasiPollScheduler.test.ts +176 -0
- package/src/wasi/WasiPollScheduler.ts +305 -0
- package/src/wasi/WasmSceneRuntime.ts +205 -0
- package/src/wasi/WasmSceneWorker.ts +182 -0
- package/style.css +15 -0
- package/testing.ts +1 -0
- package/tsconfig.json +29 -0
- package/wasi-worker.ts +1 -0
- package/wasi.ts +4 -0
- package/websocket.ts +1 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guidance for agentic assistants working in **`@swifttui/web`**. Keep this
|
|
4
|
+
concise; [`README.md`](README.md) is the full reference.
|
|
5
|
+
|
|
6
|
+
## What this package is
|
|
7
|
+
|
|
8
|
+
The **browser runtime** for SwiftTUI apps. It owns the browser-safe runtime
|
|
9
|
+
APIs: scene-manifest loading, canvas rendering, ARIA mounting, WebSocket scene
|
|
10
|
+
bridges, and WASI scene bridges. Build/packaging tooling lives in the sibling
|
|
11
|
+
[`@swifttui/build`](../build) workspace package — keep that split.
|
|
12
|
+
|
|
13
|
+
It lives in the repo's Bun workspace (`packages/web` in `swift-tui-web`). Run
|
|
14
|
+
`bun install` from the repo root or any package dir; one root `bun.lock` is
|
|
15
|
+
maintained.
|
|
16
|
+
|
|
17
|
+
## Toolchains
|
|
18
|
+
|
|
19
|
+
- **Bun** for dev, bundling, and the test runner.
|
|
20
|
+
- **`swiftly`** Swift 6.3.1 for any Swift the build path triggers
|
|
21
|
+
(`swiftly run swift --version`). Do not use bare `swift`/`xcrun swift`.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bun test # this package's tests (or `bun run test`)
|
|
27
|
+
bun run build:web # bundle index.html + browser entrypoint
|
|
28
|
+
bun run build -- --app <Exe> # full build (manifest + wasm + web)
|
|
29
|
+
bun run dev # watch/dev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`build:manifest` and `build:wasm` delegate to `@swifttui/build` and default to
|
|
33
|
+
`--configuration release`. The org-level gate for this repo is `bun run ci`
|
|
34
|
+
(install --frozen-lockfile + test + build:web), run from the `swift-tui-web` root.
|
|
35
|
+
|
|
36
|
+
## Architecture notes
|
|
37
|
+
|
|
38
|
+
- Transport is SwiftTUI's **`web-surface` WASI transport**: the Swift runner
|
|
39
|
+
emits raster-surface records on stdout and the host draws rects/text to a
|
|
40
|
+
canvas. **No terminal emulator** — does not use `ghostty-web`/`ghostty-vt.wasm`.
|
|
41
|
+
`BrowserWASIBridge` sets `TUIGUI_TRANSPORT=surface`.
|
|
42
|
+
- Entry points: `createWebHostApp` (`.`), `createWasmSceneRuntimeFactory`
|
|
43
|
+
(`./wasi`), `startWasmSceneWorker` (`./wasi-worker`). Subpath exports are
|
|
44
|
+
declared in `package.json` — keep `exports` in sync when adding modules.
|
|
45
|
+
- Scene switching is controller-managed and retains existing scene runtimes.
|
|
46
|
+
- Terminal styling is host-owned via `WebHostTerminalStyle` (one active
|
|
47
|
+
palette/theme pair); the library ships no built-in mode switcher.
|
|
48
|
+
|
|
49
|
+
## Conventions
|
|
50
|
+
|
|
51
|
+
`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it. Edit `AGENTS.md`.
|
|
52
|
+
Tests are colocated as `*.test.ts` (browser-only specs use `*.browser.ts`).
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# `@swifttui/web`
|
|
2
|
+
|
|
3
|
+
Browser runtime package for SwiftTUI apps.
|
|
4
|
+
|
|
5
|
+
This package owns browser-safe runtime APIs: scene manifest loading, canvas
|
|
6
|
+
rendering, ARIA mounting, WebSocket scene bridges, and WASI scene bridges. Build
|
|
7
|
+
tooling lives in the sibling [`@swifttui/build`](../build) workspace package.
|
|
8
|
+
|
|
9
|
+
Publication status: the package name is reserved for the first public web
|
|
10
|
+
release. Until it is published to npm or attached as a public release tarball,
|
|
11
|
+
use the source checkout and the `swift-tui-examples/WebExample` template.
|
|
12
|
+
|
|
13
|
+
## Toolchains
|
|
14
|
+
|
|
15
|
+
Use Bun for repo-local development of this package, and use the repo-default
|
|
16
|
+
`swiftly` Swift 6.3.1 toolchain for every Swift command that the build pipeline
|
|
17
|
+
triggers.
|
|
18
|
+
|
|
19
|
+
Quick check:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
swiftly run swift --version
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Native-only development should also work in Xcode, but the documented package
|
|
26
|
+
and wasm build path uses `swiftly` plus Bun.
|
|
27
|
+
|
|
28
|
+
For source development, run `bun install` from the repo root or from any
|
|
29
|
+
workspace package directory, and Bun will maintain one root `bun.lock` plus
|
|
30
|
+
stable relative workspace links.
|
|
31
|
+
|
|
32
|
+
## Surface Transport
|
|
33
|
+
|
|
34
|
+
This package uses SwiftTUI's `web-surface` WASI transport. The Swift runner
|
|
35
|
+
emits structured raster-surface records on stdout, and the browser host draws
|
|
36
|
+
rectangles and text into a canvas. It does not load a terminal emulator and does
|
|
37
|
+
not depend on `ghostty-web` or `ghostty-vt.wasm`.
|
|
38
|
+
|
|
39
|
+
`web-surface` is the default `SwiftTUIWASI` browser transport. WebHost still
|
|
40
|
+
sets `TUIGUI_TRANSPORT=surface` explicitly so generated app environments are
|
|
41
|
+
self-describing.
|
|
42
|
+
|
|
43
|
+
## API
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createWebHostApp } from "@swifttui/web";
|
|
47
|
+
|
|
48
|
+
const controller = await createWebHostApp({
|
|
49
|
+
mount: document.getElementById("app")!,
|
|
50
|
+
manifestUrl: new URL("./scene-manifest.json", import.meta.url),
|
|
51
|
+
style: {
|
|
52
|
+
palette: {
|
|
53
|
+
foreground: "#eceff4",
|
|
54
|
+
background: "#1e222a",
|
|
55
|
+
cursor: "#56b6c2",
|
|
56
|
+
selectionBackground: "#2e3440",
|
|
57
|
+
selectionForeground: "#eceff4",
|
|
58
|
+
},
|
|
59
|
+
theme: {
|
|
60
|
+
foreground: "#eceff4",
|
|
61
|
+
background: "#1e222a",
|
|
62
|
+
tint: "#56b6c2",
|
|
63
|
+
link: "#5ba3ff",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await controller.switchScene("dashboard");
|
|
69
|
+
controller.setStyle({ cursorBlink: true, theme: { tint: "#79c0ff" } });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For a static WASI-hosted app, use the WASI subpath:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createWasmSceneRuntimeFactory } from "@swifttui/web/wasi";
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Worker entrypoints can delegate to:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { startWasmSceneWorker } from "@swifttui/web/wasi-worker";
|
|
82
|
+
|
|
83
|
+
startWasmSceneWorker();
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Scripts
|
|
87
|
+
|
|
88
|
+
- `bun test`
|
|
89
|
+
- `bun run build:manifest -- --app <AppExecutable>`
|
|
90
|
+
- `bun run build:wasm -- --app <AppExecutable>`
|
|
91
|
+
- `bun run build:web`
|
|
92
|
+
- `bun run build -- --app <AppExecutable>`
|
|
93
|
+
- `bun run dev`
|
|
94
|
+
|
|
95
|
+
`build:manifest`, `build:wasm`, and `build` delegate manifest/WASI packaging to
|
|
96
|
+
`@swifttui/build`. `build:wasm` and `build` default to
|
|
97
|
+
`--configuration release`; pass `--configuration debug` for local
|
|
98
|
+
debug-oriented wasm builds.
|
|
99
|
+
|
|
100
|
+
The build flow is intentionally small:
|
|
101
|
+
|
|
102
|
+
1. `build:manifest` captures `TUIGUI_MODE=manifest` output from the Swift app by invoking `swiftly run swift`.
|
|
103
|
+
2. `build:wasm` copies the app's wasm artifact into `dist/assets/app.wasm`,
|
|
104
|
+
validates it with the browser `WebAssembly` API, then keeps the stripped
|
|
105
|
+
artifact only if stripping still produces browser-parseable wasm.
|
|
106
|
+
3. `build:web` bundles `index.html` and the browser entrypoint with Bun.
|
|
107
|
+
|
|
108
|
+
## Notes
|
|
109
|
+
|
|
110
|
+
- Scene switching is controller-managed and retains existing scene runtimes.
|
|
111
|
+
- Terminal styling is host-owned through `WebHostTerminalStyle`, which carries
|
|
112
|
+
one active palette/theme pair plus the runtime payload sent into SwiftTUI.
|
|
113
|
+
- Hosts that want multiple themes swap entire `WebHostTerminalStyle` objects;
|
|
114
|
+
the library does not provide a built-in mode switcher.
|
|
115
|
+
- `BrowserWASIBridge` sets `TUIGUI_TRANSPORT=surface` and decodes surface
|
|
116
|
+
frames before handing them to the canvas runtime.
|
package/cli.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
buildAppWasm,
|
|
7
|
+
buildSwiftTUIWebApp,
|
|
8
|
+
generateSceneManifest,
|
|
9
|
+
type WasmBuildConfiguration,
|
|
10
|
+
} from "../build/index.ts";
|
|
11
|
+
|
|
12
|
+
void runCli(process.argv.slice(2));
|
|
13
|
+
|
|
14
|
+
async function runCli(argv: string[]): Promise<void> {
|
|
15
|
+
const command = argv[0] ?? "build";
|
|
16
|
+
const flags = parseFlags(argv.slice(1));
|
|
17
|
+
const packagePath = resolve(flags["package-path"] ?? "../../");
|
|
18
|
+
const distPath = resolve(flags["dist"] ?? "./dist");
|
|
19
|
+
const appExecutable = flags.app ?? flags.product ?? flags["app-product"] ?? "";
|
|
20
|
+
const wasmConfiguration = parseWasmBuildConfiguration(flags.configuration ?? "release");
|
|
21
|
+
|
|
22
|
+
switch (command) {
|
|
23
|
+
case "build:manifest":
|
|
24
|
+
assertAppExecutable(appExecutable);
|
|
25
|
+
await generateSceneManifest({
|
|
26
|
+
packagePath,
|
|
27
|
+
outputPath: join(distPath, "scene-manifest.json"),
|
|
28
|
+
appExecutable,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
case "build:wasm":
|
|
32
|
+
assertAppExecutable(appExecutable);
|
|
33
|
+
await buildAppWasm({
|
|
34
|
+
configuration: wasmConfiguration,
|
|
35
|
+
packagePath,
|
|
36
|
+
outputDirectory: distPath,
|
|
37
|
+
product: appExecutable,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
case "build:web":
|
|
41
|
+
await bunBuildWeb({
|
|
42
|
+
outputDirectory: distPath,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
case "build":
|
|
46
|
+
assertAppExecutable(appExecutable);
|
|
47
|
+
await buildSwiftTUIWebApp({
|
|
48
|
+
configuration: wasmConfiguration,
|
|
49
|
+
packagePath,
|
|
50
|
+
outputDirectory: distPath,
|
|
51
|
+
product: appExecutable,
|
|
52
|
+
});
|
|
53
|
+
await bunBuildWeb({
|
|
54
|
+
outputDirectory: distPath,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
case "dev":
|
|
58
|
+
await bunServe(packagePath, distPath);
|
|
59
|
+
return;
|
|
60
|
+
default:
|
|
61
|
+
throw new Error(`unknown command: ${command}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function bunBuildWeb(options: {
|
|
66
|
+
outputDirectory: string;
|
|
67
|
+
}): Promise<void> {
|
|
68
|
+
await mkdir(options.outputDirectory, { recursive: true });
|
|
69
|
+
const proc = Bun.spawn({
|
|
70
|
+
cmd: [
|
|
71
|
+
"bun",
|
|
72
|
+
"build",
|
|
73
|
+
"./index.html",
|
|
74
|
+
"--outdir",
|
|
75
|
+
options.outputDirectory,
|
|
76
|
+
],
|
|
77
|
+
stdout: "pipe",
|
|
78
|
+
stderr: "pipe",
|
|
79
|
+
});
|
|
80
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
81
|
+
new Response(proc.stdout).text(),
|
|
82
|
+
new Response(proc.stderr).text(),
|
|
83
|
+
proc.exited,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
if (exitCode !== 0) {
|
|
87
|
+
throw new Error([stdout, stderr].filter(Boolean).join("\n").trim() || "web build failed");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function bunServe(
|
|
92
|
+
packagePath: string,
|
|
93
|
+
distPath: string
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const server = Bun.serve({
|
|
96
|
+
port: Number(process.env.PORT ?? "3000"),
|
|
97
|
+
development: {
|
|
98
|
+
hmr: true,
|
|
99
|
+
console: true,
|
|
100
|
+
},
|
|
101
|
+
fetch(request) {
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
if (url.pathname === "/") {
|
|
104
|
+
return new Response(Bun.file(join(distPath, "index.html")));
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname === "/scene-manifest.json") {
|
|
107
|
+
return new Response(Bun.file(join(distPath, "scene-manifest.json")));
|
|
108
|
+
}
|
|
109
|
+
if (url.pathname.startsWith("/assets/")) {
|
|
110
|
+
return new Response(Bun.file(join(distPath, url.pathname.slice(1))));
|
|
111
|
+
}
|
|
112
|
+
if (url.pathname.startsWith("/src/")) {
|
|
113
|
+
return new Response(Bun.file(join(packagePath, url.pathname.slice(1))));
|
|
114
|
+
}
|
|
115
|
+
return new Response("not found", { status: 404 });
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log(`WebHost dev server running at ${server.url.href}`);
|
|
120
|
+
await new Promise<void>(() => {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseFlags(
|
|
124
|
+
argv: string[]
|
|
125
|
+
): Record<string, string> {
|
|
126
|
+
const flags: Record<string, string> = {};
|
|
127
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
128
|
+
const value = argv[index];
|
|
129
|
+
if (!value.startsWith("--")) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const equalsIndex = value.indexOf("=");
|
|
133
|
+
if (equalsIndex !== -1) {
|
|
134
|
+
flags[value.slice(2, equalsIndex)] = value.slice(equalsIndex + 1);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const name = value.slice(2);
|
|
138
|
+
const next = argv[index + 1];
|
|
139
|
+
if (next && !next.startsWith("--")) {
|
|
140
|
+
flags[name] = next;
|
|
141
|
+
index += 1;
|
|
142
|
+
} else {
|
|
143
|
+
flags[name] = "true";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return flags;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseWasmBuildConfiguration(
|
|
150
|
+
value: string
|
|
151
|
+
): WasmBuildConfiguration {
|
|
152
|
+
switch (value) {
|
|
153
|
+
case "debug":
|
|
154
|
+
return "debug";
|
|
155
|
+
case "release":
|
|
156
|
+
return "release";
|
|
157
|
+
default:
|
|
158
|
+
throw new Error(`unsupported wasm build configuration: ${value}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function assertAppExecutable(
|
|
163
|
+
value: string
|
|
164
|
+
): asserts value is string {
|
|
165
|
+
if (!value) {
|
|
166
|
+
throw new Error("missing --app or --product flag");
|
|
167
|
+
}
|
|
168
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>WebHost</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
html,
|
|
13
|
+
body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
height: 100%;
|
|
16
|
+
background: #000;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#webhost-root,
|
|
20
|
+
.webhost-scene-root,
|
|
21
|
+
.webhost-scene {
|
|
22
|
+
width: 100%;
|
|
23
|
+
height: 100%;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.webhost-scene__header {
|
|
27
|
+
display: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.webhost-scene__terminal {
|
|
31
|
+
width: 100%;
|
|
32
|
+
height: 100%;
|
|
33
|
+
min-height: 100%;
|
|
34
|
+
touch-action: none;
|
|
35
|
+
-webkit-user-select: none;
|
|
36
|
+
user-select: none;
|
|
37
|
+
cursor: default;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.webhost-scene__terminal canvas {
|
|
41
|
+
touch-action: none;
|
|
42
|
+
cursor: default;
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
45
|
+
<script type="module" src="./src/browser.ts"></script>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<main id="webhost-root"></main>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
package/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./src/WebHostApp.ts";
|
|
2
|
+
export * from "./src/WebHostSceneManifest.ts";
|
|
3
|
+
export * from "./src/WebHostTerminalStyle.ts";
|
|
4
|
+
export * from "./src/WebHostSurfaceTransport.ts";
|
|
5
|
+
export * from "./src/WebHostSceneRuntime.ts";
|
|
6
|
+
export * from "./src/WebSocketSceneBridge.ts";
|
|
7
|
+
export * from "./src/wasi/BrowserWASIBridge.ts";
|
|
8
|
+
export * from "./src/wasi/StdIOPipe.ts";
|
package/manifest.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/WebHostSceneManifest.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@swifttui/web",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts",
|
|
8
|
+
"./manifest": "./manifest.ts",
|
|
9
|
+
"./style.css": "./style.css",
|
|
10
|
+
"./testing": "./testing.ts",
|
|
11
|
+
"./wasi": "./wasi.ts",
|
|
12
|
+
"./wasi-worker": "./wasi-worker.ts",
|
|
13
|
+
"./websocket": "./websocket.ts"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build:manifest": "bun run --cwd ../build build:manifest -- --dist ../web/dist",
|
|
18
|
+
"build:wasm": "bun run --cwd ../build build:wasm -- --dist ../web/dist",
|
|
19
|
+
"build:web": "bun run cli.ts build:web",
|
|
20
|
+
"build": "bun run cli.ts build",
|
|
21
|
+
"dev": "bun run cli.ts dev",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@bjorn3/browser_wasi_shim": "^0.4.2"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/bun": "1.3.13"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"typescript": "^5"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WebHostAccessibilityAnnouncement,
|
|
3
|
+
WebHostAccessibilityNode,
|
|
4
|
+
} from "./WebHostSurfaceTransport.ts";
|
|
5
|
+
|
|
6
|
+
interface AccessibilityTreeMetrics {
|
|
7
|
+
cellWidth: number;
|
|
8
|
+
cellHeight: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AccessibilityTreePresentationOptions {
|
|
12
|
+
synchronizeFocus?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RoleMapping {
|
|
16
|
+
role?: string;
|
|
17
|
+
level?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class AccessibilityTreeMounter {
|
|
21
|
+
readonly element: HTMLElement;
|
|
22
|
+
readonly announcerElement: HTMLElement;
|
|
23
|
+
|
|
24
|
+
private readonly nodesById = new Map<string, HTMLElement>();
|
|
25
|
+
private previousLabelsById = new Map<string, string>();
|
|
26
|
+
private hasLiveRegionBaseline = false;
|
|
27
|
+
|
|
28
|
+
constructor() {
|
|
29
|
+
this.element = document.createElement("div");
|
|
30
|
+
this.element.className = "webhost-scene__accessibility-tree";
|
|
31
|
+
applyScreenReaderOnlyStyle(this.element);
|
|
32
|
+
|
|
33
|
+
this.announcerElement = document.createElement("div");
|
|
34
|
+
this.announcerElement.className = "webhost-scene__accessibility-announcer";
|
|
35
|
+
this.announcerElement.setAttribute("aria-atomic", "true");
|
|
36
|
+
applyScreenReaderOnlyStyle(this.announcerElement);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
present(
|
|
40
|
+
nodes: WebHostAccessibilityNode[],
|
|
41
|
+
metrics: AccessibilityTreeMetrics,
|
|
42
|
+
announcements: WebHostAccessibilityAnnouncement[] = [],
|
|
43
|
+
options: AccessibilityTreePresentationOptions = {}
|
|
44
|
+
): void {
|
|
45
|
+
this.element.replaceChildren();
|
|
46
|
+
this.nodesById.clear();
|
|
47
|
+
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
const element = this.elementForNode(node, metrics);
|
|
50
|
+
this.nodesById.set(node.id, element);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const node of nodes) {
|
|
54
|
+
const element = this.nodesById.get(node.id);
|
|
55
|
+
if (!element) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parent = node.parentId ? this.nodesById.get(node.parentId) : undefined;
|
|
60
|
+
(parent ?? this.element).appendChild(element);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.announceLiveRegionChanges(nodes, announcements);
|
|
64
|
+
|
|
65
|
+
const focused = nodes.find((node) => node.isFocused);
|
|
66
|
+
if ((options.synchronizeFocus ?? true) && focused) {
|
|
67
|
+
this.nodesById.get(focused.id)?.focus?.({ preventScroll: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private elementForNode(
|
|
72
|
+
node: WebHostAccessibilityNode,
|
|
73
|
+
metrics: AccessibilityTreeMetrics
|
|
74
|
+
): HTMLElement {
|
|
75
|
+
const element = document.createElement("div");
|
|
76
|
+
element.id = `swifttui-a11y-${stableDOMId(node.id)}`;
|
|
77
|
+
element.dataset.accessibilityId = node.id;
|
|
78
|
+
element.tabIndex = node.isFocused ? 0 : -1;
|
|
79
|
+
|
|
80
|
+
const role = roleMapping(node.role);
|
|
81
|
+
if (role.role) {
|
|
82
|
+
element.setAttribute("role", role.role);
|
|
83
|
+
}
|
|
84
|
+
if (role.level !== undefined) {
|
|
85
|
+
element.setAttribute("aria-level", String(role.level));
|
|
86
|
+
}
|
|
87
|
+
if (node.label) {
|
|
88
|
+
element.setAttribute("aria-label", node.label);
|
|
89
|
+
}
|
|
90
|
+
if (node.hint) {
|
|
91
|
+
element.setAttribute("aria-description", node.hint);
|
|
92
|
+
}
|
|
93
|
+
if (node.liveRegion) {
|
|
94
|
+
element.setAttribute("aria-live", node.liveRegion);
|
|
95
|
+
}
|
|
96
|
+
if (node.isFocused) {
|
|
97
|
+
element.dataset.focused = "true";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const [x, y, width, height] = node.rect;
|
|
101
|
+
element.style.position = "absolute";
|
|
102
|
+
element.style.left = `${x * metrics.cellWidth}px`;
|
|
103
|
+
element.style.top = `${y * metrics.cellHeight}px`;
|
|
104
|
+
element.style.width = `${Math.max(1, width) * metrics.cellWidth}px`;
|
|
105
|
+
element.style.height = `${Math.max(1, height) * metrics.cellHeight}px`;
|
|
106
|
+
|
|
107
|
+
return element;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private announceLiveRegionChanges(
|
|
111
|
+
nodes: WebHostAccessibilityNode[],
|
|
112
|
+
announcements: WebHostAccessibilityAnnouncement[]
|
|
113
|
+
): void {
|
|
114
|
+
const candidates = nodes.filter(
|
|
115
|
+
(node) => node.liveRegion && node.liveRegion !== "off" && node.label
|
|
116
|
+
);
|
|
117
|
+
const currentLabelsById = new Map(candidates.map((node) => [node.id, node.label ?? ""]));
|
|
118
|
+
const imperativeAssertive = announcements.filter(
|
|
119
|
+
(announcement) => announcement.politeness === "assertive"
|
|
120
|
+
);
|
|
121
|
+
const imperativePolite = announcements.filter(
|
|
122
|
+
(announcement) => announcement.politeness === "polite"
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (!this.hasLiveRegionBaseline) {
|
|
126
|
+
this.previousLabelsById = currentLabelsById;
|
|
127
|
+
this.hasLiveRegionBaseline = true;
|
|
128
|
+
this.publishAnnouncements([], imperativeAssertive, [], imperativePolite);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const changed = candidates.filter((node) => {
|
|
133
|
+
const previous = this.previousLabelsById.get(node.id);
|
|
134
|
+
return previous !== undefined && previous !== node.label;
|
|
135
|
+
});
|
|
136
|
+
this.previousLabelsById = currentLabelsById;
|
|
137
|
+
|
|
138
|
+
const assertive = changed.filter((node) => node.liveRegion === "assertive");
|
|
139
|
+
const polite = changed.filter((node) => node.liveRegion === "polite");
|
|
140
|
+
this.publishAnnouncements(assertive, imperativeAssertive, polite, imperativePolite);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private publishAnnouncements(
|
|
144
|
+
assertive: WebHostAccessibilityNode[],
|
|
145
|
+
imperativeAssertive: WebHostAccessibilityAnnouncement[],
|
|
146
|
+
polite: WebHostAccessibilityNode[],
|
|
147
|
+
imperativePolite: WebHostAccessibilityAnnouncement[]
|
|
148
|
+
): void {
|
|
149
|
+
const ordered = [...assertive, ...imperativeAssertive, ...polite, ...imperativePolite];
|
|
150
|
+
if (ordered.length === 0) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const politeness = assertive.length > 0 || imperativeAssertive.length > 0
|
|
155
|
+
? "assertive"
|
|
156
|
+
: "polite";
|
|
157
|
+
this.announcerElement.setAttribute("aria-live", politeness);
|
|
158
|
+
this.announcerElement.textContent = ordered.map((entry) => {
|
|
159
|
+
if ("message" in entry) {
|
|
160
|
+
return entry.message;
|
|
161
|
+
}
|
|
162
|
+
return entry.label ?? "";
|
|
163
|
+
}).join("\n");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function applyScreenReaderOnlyStyle(
|
|
168
|
+
element: HTMLElement
|
|
169
|
+
): void {
|
|
170
|
+
element.style.position = "absolute";
|
|
171
|
+
element.style.left = "0";
|
|
172
|
+
element.style.top = "0";
|
|
173
|
+
element.style.width = "1px";
|
|
174
|
+
element.style.height = "1px";
|
|
175
|
+
element.style.overflow = "hidden";
|
|
176
|
+
element.style.clipPath = "inset(50%)";
|
|
177
|
+
element.style.whiteSpace = "nowrap";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function roleMapping(
|
|
181
|
+
role: string
|
|
182
|
+
): RoleMapping {
|
|
183
|
+
const heading = /^heading\(level: ([0-9]+)\)$/.exec(role);
|
|
184
|
+
if (heading) {
|
|
185
|
+
return {
|
|
186
|
+
role: "heading",
|
|
187
|
+
level: Math.max(1, Math.min(6, Number(heading[1]))),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const custom = /^custom\((.+)\)$/.exec(role);
|
|
192
|
+
if (custom) {
|
|
193
|
+
return { role: custom[1] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
switch (role) {
|
|
197
|
+
case "alert":
|
|
198
|
+
case "button":
|
|
199
|
+
case "cell":
|
|
200
|
+
case "checkbox":
|
|
201
|
+
case "grid":
|
|
202
|
+
case "group":
|
|
203
|
+
case "link":
|
|
204
|
+
case "list":
|
|
205
|
+
case "menu":
|
|
206
|
+
case "region":
|
|
207
|
+
case "separator":
|
|
208
|
+
case "slider":
|
|
209
|
+
case "status":
|
|
210
|
+
case "tab":
|
|
211
|
+
case "table":
|
|
212
|
+
case "timer":
|
|
213
|
+
return { role };
|
|
214
|
+
case "columnHeader":
|
|
215
|
+
return { role: "columnheader" };
|
|
216
|
+
case "confirmationDialog":
|
|
217
|
+
case "sheet":
|
|
218
|
+
return { role: "dialog" };
|
|
219
|
+
case "disclosureGroup":
|
|
220
|
+
case "scrollView":
|
|
221
|
+
case "scrollViewWithIndicators":
|
|
222
|
+
case "section":
|
|
223
|
+
return { role: "region" };
|
|
224
|
+
case "image":
|
|
225
|
+
return { role: "img" };
|
|
226
|
+
case "menuItem":
|
|
227
|
+
return { role: "menuitem" };
|
|
228
|
+
case "picker":
|
|
229
|
+
return { role: "combobox" };
|
|
230
|
+
case "progressBar":
|
|
231
|
+
return { role: "progressbar" };
|
|
232
|
+
case "rowHeader":
|
|
233
|
+
return { role: "rowheader" };
|
|
234
|
+
case "secureField":
|
|
235
|
+
case "textEditor":
|
|
236
|
+
case "textField":
|
|
237
|
+
return { role: "textbox" };
|
|
238
|
+
case "stepper":
|
|
239
|
+
return { role: "spinbutton" };
|
|
240
|
+
case "tabPanel":
|
|
241
|
+
return { role: "tabpanel" };
|
|
242
|
+
case "tableRow":
|
|
243
|
+
return { role: "row" };
|
|
244
|
+
case "tabView":
|
|
245
|
+
return { role: "tablist" };
|
|
246
|
+
case "toggle":
|
|
247
|
+
return { role: "checkbox" };
|
|
248
|
+
default:
|
|
249
|
+
return { role: "group" };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function stableDOMId(
|
|
254
|
+
id: string
|
|
255
|
+
): string {
|
|
256
|
+
return Array.from(id).map((character) => {
|
|
257
|
+
if (/^[a-zA-Z0-9_-]$/.test(character)) {
|
|
258
|
+
return character;
|
|
259
|
+
}
|
|
260
|
+
return `-${character.codePointAt(0)?.toString(16) ?? "0"}-`;
|
|
261
|
+
}).join("");
|
|
262
|
+
}
|