@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.
Files changed (39) hide show
  1. package/AGENTS.md +52 -0
  2. package/README.md +116 -0
  3. package/cli.ts +168 -0
  4. package/index.html +50 -0
  5. package/index.ts +8 -0
  6. package/manifest.ts +1 -0
  7. package/package.json +33 -0
  8. package/src/AccessibilityTree.ts +262 -0
  9. package/src/BoxDrawingRenderer.ts +585 -0
  10. package/src/PublicEntrypointBoundary.test.ts +20 -0
  11. package/src/WebHostApp.test.ts +222 -0
  12. package/src/WebHostApp.ts +269 -0
  13. package/src/WebHostSceneManifest.test.ts +38 -0
  14. package/src/WebHostSceneManifest.ts +156 -0
  15. package/src/WebHostSceneRuntime.test.ts +1752 -0
  16. package/src/WebHostSceneRuntime.ts +955 -0
  17. package/src/WebHostSurfaceTransport.test.ts +362 -0
  18. package/src/WebHostSurfaceTransport.ts +648 -0
  19. package/src/WebHostTerminalStyle.test.ts +123 -0
  20. package/src/WebHostTerminalStyle.ts +471 -0
  21. package/src/WebHostTestFixtures.ts +10 -0
  22. package/src/WebSocketSceneBridge.test.ts +198 -0
  23. package/src/WebSocketSceneBridge.ts +233 -0
  24. package/src/browser.ts +59 -0
  25. package/src/wasi/BrowserWASIBridge.test.ts +168 -0
  26. package/src/wasi/BrowserWASIBridge.ts +167 -0
  27. package/src/wasi/SharedInputQueue.test.ts +146 -0
  28. package/src/wasi/SharedInputQueue.ts +199 -0
  29. package/src/wasi/StdIOPipe.ts +72 -0
  30. package/src/wasi/WasiPollScheduler.test.ts +176 -0
  31. package/src/wasi/WasiPollScheduler.ts +305 -0
  32. package/src/wasi/WasmSceneRuntime.ts +205 -0
  33. package/src/wasi/WasmSceneWorker.ts +182 -0
  34. package/style.css +15 -0
  35. package/testing.ts +1 -0
  36. package/tsconfig.json +29 -0
  37. package/wasi-worker.ts +1 -0
  38. package/wasi.ts +4 -0
  39. 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
+ }