summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 22:08:16 +0900
committerAdam Malczewski <[email protected]>2026-06-06 22:08:16 +0900
commite1c8cf3257cb33457aa882c548f5195ecc0f9854 (patch)
treed355147cdab8eb77917ad02caedf26b3d8d0be57
downloaddispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.tar.gz
dispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.zip
Slice 1: surface system + WS transport + composition root
Pure-core feature libraries assembled at the composition root: - core/protocol: pure reducer over surface catalog/spec/error messages - features/surface-host: generic field-kind interpreter (toggle/progress/ selector/stat/button) + pure plan logic; no surface-id special-casing - adapters/ws: injected WebSocket client (effects at the edge) - app: composition root store (Svelte 5 runes over the pure reducer), host-relative surface WS URL resolution (resolveWsUrl), root App.svelte Verified green: svelte-check 0/0, vitest 84 passed, biome clean, vite build ok.
-rw-r--r--.dispatch/package-agent.md69
-rw-r--r--.dispatch/rules/frontend-inject-transport.md7
-rw-r--r--.dispatch/rules/frontend-interpreter-generic.md8
-rw-r--r--.dispatch/rules/frontend-no-ambient-state.md7
-rw-r--r--.dispatch/rules/frontend-pure-core.md7
-rw-r--r--.dispatch/ui-contract.reference.md169
-rw-r--r--.gitignore7
-rw-r--r--AGENTS.md74
-rw-r--r--GLOSSARY.md30
-rw-r--r--ORCHESTRATOR.md126
-rw-r--r--README.md103
-rw-r--r--biome.json10
-rw-r--r--bun.lock472
-rw-r--r--index.html12
-rw-r--r--package.json32
-rw-r--r--src/App.svelte7
-rw-r--r--src/adapters/ws/index.test.ts234
-rw-r--r--src/adapters/ws/index.ts98
-rw-r--r--src/adapters/ws/logic.test.ts195
-rw-r--r--src/adapters/ws/logic.ts91
-rw-r--r--src/app/App.svelte53
-rw-r--r--src/app/index.ts3
-rw-r--r--src/app/resolve-ws-url.test.ts53
-rw-r--r--src/app/resolve-ws-url.ts27
-rw-r--r--src/app/store.svelte.ts103
-rw-r--r--src/app/store.test.ts220
-rw-r--r--src/core/protocol/index.ts2
-rw-r--r--src/core/protocol/reducer.test.ts151
-rw-r--r--src/core/protocol/reducer.ts82
-rw-r--r--src/core/protocol/types.ts22
-rw-r--r--src/features/surface-host/index.ts3
-rw-r--r--src/features/surface-host/logic/plan.test.ts161
-rw-r--r--src/features/surface-host/logic/plan.ts74
-rw-r--r--src/features/surface-host/logic/types.ts52
-rw-r--r--src/features/surface-host/ui/Button.svelte21
-rw-r--r--src/features/surface-host/ui/Progress.svelte13
-rw-r--r--src/features/surface-host/ui/Selector.svelte32
-rw-r--r--src/features/surface-host/ui/Stat.svelte10
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte33
-rw-r--r--src/features/surface-host/ui/Toggle.svelte25
-rw-r--r--src/main.ts9
-rw-r--r--src/vite-env.d.ts2
-rw-r--r--svelte.config.js5
-rw-r--r--tsconfig.json18
-rw-r--r--vite.config.ts23
-rw-r--r--vitest-setup.ts1
46 files changed, 2956 insertions, 0 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md
new file mode 100644
index 0000000..a5666e4
--- /dev/null
+++ b/.dispatch/package-agent.md
@@ -0,0 +1,69 @@
+<!-- ORCHESTRATOR-ONLY meta (see ORCHESTRATOR.md): every FE summon is assembled as
+ package-agent.md + the inlined .dispatch/rules/* + the per-summon TASK block.
+ This is the base for ALL FE owner-agents; nothing here is restated per summon. -->
+
+# Frontend Owner-Agent — Brief
+
+You are the **sole owner-agent for exactly ONE unit** — a single feature module /
+directory under `src/`. Your unit + job are in the **TASK** at the end. You build
+it, test it, and write a report — nothing else. If no single unit is named, stop.
+
+## Hard guardrails (NON-NEGOTIABLE)
+- **Single-writer, directory-scoped.** Read/create/edit any file inside your unit's
+ directory. Never create or edit anything OUTSIDE it — not another feature, not
+ `src/app/` (the composition root), not root config (`package.json`,
+ `tsconfig.json`, `vite.config.ts`, `biome.json`), not the harness, not the
+ backend repo (`../arch-rewrite`).
+- **Need a change outside your unit?** Do NOT make it — write a CHANGE-REQUEST in
+ your report (a sibling's public export, a backend contract, root config,
+ composition wiring). The orchestrator dispatches it.
+- **No workspace/dep wiring.** Don't `bun install` or edit root config; list a new
+ dep / wiring need as a CR.
+- **No git** (no commits, branches, pushes, resets).
+
+## What you may read (visibility)
+- **Your own unit:** every file, freely.
+- **The contract you consume:** reproduced IN-REPO at
+ `.dispatch/ui-contract.reference.md` — read THAT. Your code imports
+ `@dispatch/ui-contract` normally, but **do NOT read `node_modules/@dispatch/*`** — it
+ symlinks to the backend repo (OUTSIDE this repo) and a headless permission prompt will
+ HANG the run (see "Headless read boundary").
+- **Sibling units — PUBLIC SURFACE only:** their `index.ts` exports. Don't read
+ their internals (needing them ⇒ the contract is incomplete → report a CR).
+
+## Headless read boundary (you run non-interactively)
+You run HEADLESS: a Read of any file OUTSIDE this repo (`dispatch-web/`) triggers a
+permission prompt that CANNOT be answered → the run HANGS until aborted. Use Read/Edit ONLY
+within `dispatch-web/`. If you believe you need a file outside your scope, do NOT attempt
+the read — STOP and write the need in your report, then end.
+
+## Engineering standard (the inlined `.dispatch/rules/*` govern; in brief)
+- **Pure core / injected shell.** Decision logic is `input → output`: zero DOM,
+ zero `fetch`/WS, zero Svelte. Put it in a `.ts` module (e.g. `logic/`) that tests
+ with NO mounting and NO mocks. Effects are INJECTED (props or an `adapter/`).
+- **Svelte-thin.** `.svelte` files wire props/events to pure logic + render; no
+ business logic. (biome lints `.ts`/`.js` only; `svelte-check` owns `.svelte`.)
+- **No ambient state.** Own state explicitly; runes wrap the pure reducer;
+ subscriptions are disposed on unmount.
+- **Tests, asymmetric.** Pure logic → vitest with ZERO internal mocks (never
+ `vi.mock` of our own modules). Components → a few `@testing-library/svelte`
+ tests; don't chase coverage there, don't mock siblings. Faking the OUTERMOST
+ edge (a fake socket/fetch/clock) is the only allowed mock.
+- **Isolation over DRY.** Self-contained over a shared helper wired between
+ features. The only shared surfaces are the imported contracts.
+- **Strict TS:** respect `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`.
+
+## Verify before finishing — YOUR UNIT IN ISOLATION
+Run, and paste the output into your report:
+- `bunx svelte-check --tsconfig ./tsconfig.json` → 0 errors
+- `bunx vitest run src/<your-dir>` → all pass (count goes up)
+- `bunx biome check src/<your-dir>` → clean
+The orchestrator runs the authoritative full `typecheck`/`test`/`check`/`build`.
+
+## Report (REQUIRED) → `reports/<your-unit>.md`
+1. Files created/changed. 2. Public surface you expose (exported types/functions/
+components). 3. New test names + the isolated-verify output. 4. Change-requests
+(sibling export, backend contract, root config, composition wiring) — explicit and
+actionable.
+
+Your specific **TASK** follows at the end of this prompt.
diff --git a/.dispatch/rules/frontend-inject-transport.md b/.dispatch/rules/frontend-inject-transport.md
new file mode 100644
index 0000000..fbe83d7
--- /dev/null
+++ b/.dispatch/rules/frontend-inject-transport.md
@@ -0,0 +1,7 @@
+# Rule: inject the transport; parsers are pure
+
+The WS/NDJSON framing + parsing is a PURE function (bytes/messages → typed events);
+the socket/fetch is INJECTED. Test the parser with crafted chunk inputs (and
+trace-replay-style fixtures), never a live connection. The op-protocol core is a
+pure state machine: `reduce(intent, incoming) → { viewModel, outgoingCommands }`;
+the carrier (WebSocket) is the injected shell.
diff --git a/.dispatch/rules/frontend-interpreter-generic.md b/.dispatch/rules/frontend-interpreter-generic.md
new file mode 100644
index 0000000..557f2d4
--- /dev/null
+++ b/.dispatch/rules/frontend-interpreter-generic.md
@@ -0,0 +1,8 @@
+# Rule: the surface interpreter is generic
+
+The surface interpreter switches on field KINDS (toggle/progress/selector/stat/
+button/custom), NEVER on a surface id. An `if (surface.id === "...")` imports a
+feature's identity into the platform and breaks isolation (guardrail 1). An
+unknown field `kind` or a `custom` `rendererId` with no registered renderer →
+GRACEFUL SKIP, never a crash. Render from the spec; the backend owns what a
+surface contains.
diff --git a/.dispatch/rules/frontend-no-ambient-state.md b/.dispatch/rules/frontend-no-ambient-state.md
new file mode 100644
index 0000000..663bf1a
--- /dev/null
+++ b/.dispatch/rules/frontend-no-ambient-state.md
@@ -0,0 +1,7 @@
+# Rule: no ambient state (frontend)
+
+State is owned per-unit and passed explicitly. NO module-global mutable store
+reached from everywhere — that is the old FE's "tools leak across tabs" /
+"model resets on tab switch" bug class. Svelte runes (`$state`) are a THIN
+reactive wrapper over a pure reducer, never the home of logic. Subscriptions are
+owned and disposed on unmount (no orphaned or duplicate subscriptions).
diff --git a/.dispatch/rules/frontend-pure-core.md b/.dispatch/rules/frontend-pure-core.md
new file mode 100644
index 0000000..fa7bc2e
--- /dev/null
+++ b/.dispatch/rules/frontend-pure-core.md
@@ -0,0 +1,7 @@
+# Rule: pure core / injected shell (frontend)
+
+Decision logic — reducers, view-models, formatters, parsers — is pure
+(input → output): NO DOM, NO `fetch`/WebSocket, NO Svelte import. Put it in a
+`.ts` module that tests with zero mounting and zero mocks. Effects (socket, fetch,
+IndexedDB, clock) are INJECTED at the edges (props or an adapter). This is for
+testability, not purity dogma — stop where it would only add ceremony.
diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md
new file mode 100644
index 0000000..3962fc1
--- /dev/null
+++ b/.dispatch/ui-contract.reference.md
@@ -0,0 +1,169 @@
+# `@dispatch/ui-contract` — in-repo reference (read THIS, not node_modules)
+
+> This MIRRORS the backend's `@dispatch/ui-contract` package source so headless FE agents can
+> read the surface contract WITHOUT following the `file:` dep symlink out of this repo (which
+> hangs on a permission prompt). Your CODE still imports `@dispatch/ui-contract` normally — this
+> file is for READING only.
+>
+> **Orchestrator:** this is a SNAPSHOT — regenerate it whenever `ui-contract` changes.
+
+```ts
+/**
+ * UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
+ *
+ * A SURFACE is a "data transportation surface": a typed description of what data an
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI.
+ * Any client renders a surface in its own idiom (web/Svelte, CLI, future TUI/mobile).
+ * Types-only, zero runtime, zero `@dispatch/*` deps.
+ */
+
+/** Where a surface mounts — a coarse, semantic placement hint, NOT layout/CSS. Open string. */
+export type Region = string;
+
+/** A typed reference to a backend action a field can invoke (client posts payload back). */
+export interface ActionRef {
+ readonly actionId: string;
+}
+
+/** One selectable option in a `selector` field. */
+export interface SurfaceOption {
+ readonly value: string;
+ readonly label: string;
+}
+
+/** A field within a surface — a SEMANTIC value, not a widget. `kind` is the discriminant. */
+export type SurfaceField =
+ | ToggleField
+ | ProgressField
+ | SelectorField
+ | StatField
+ | ButtonField
+ | CustomField;
+
+/** A boolean setting plus the action that flips it. */
+export interface ToggleField {
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
+}
+
+/** A bounded ratio in [0, 1] with a label. Read-only. */
+export interface ProgressField {
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
+}
+
+/** An enum choice: the current value, the options, and the action that sets it. */
+export interface SelectorField {
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
+}
+
+/** A read-only labelled scalar readout. */
+export interface StatField {
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
+}
+
+/** A labelled action trigger. */
+export interface ButtonField {
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
+}
+
+/**
+ * The escape hatch: data that fits no semantic field kind. Opaque `payload` + a
+ * `rendererId`; clients WITH a renderer for that id show it, others GRACEFULLY SKIP.
+ */
+export interface CustomField {
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
+}
+
+/** A surface: an ordered set of fields mounted in a region, with a title. */
+export interface SurfaceSpec {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+ readonly fields: readonly SurfaceField[];
+}
+
+/** A surface-catalog entry — discovery metadata only (no field data). */
+export interface SurfaceCatalogEntry {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+}
+
+/** The surface catalog: the list of available surfaces a client can choose to show. */
+export type SurfaceCatalog = readonly SurfaceCatalogEntry[];
+
+/** A live update for a subscribed surface. v1 carries the full new spec. */
+export interface SurfaceUpdate {
+ readonly surfaceId: string;
+ readonly spec: SurfaceSpec;
+}
+
+// ── Surface WebSocket protocol (slice 1: surfaces only) ──────────────────────
+
+/** A client → server message on the surface channel. */
+export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
+
+export interface SubscribeMessage {
+ readonly type: "subscribe";
+ readonly surfaceId: string;
+}
+
+export interface UnsubscribeMessage {
+ readonly type: "unsubscribe";
+ readonly surfaceId: string;
+}
+
+/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */
+export interface InvokeMessage {
+ readonly type: "invoke";
+ readonly surfaceId: string;
+ readonly actionId: string;
+ readonly payload?: unknown;
+}
+
+/** A server → client message on the surface channel. */
+export type SurfaceServerMessage =
+ | CatalogMessage
+ | SurfaceMessage
+ | SurfaceUpdateMessage
+ | SurfaceErrorMessage;
+
+/** The current surface catalog (sent on connect and whenever it changes). */
+export interface CatalogMessage {
+ readonly type: "catalog";
+ readonly catalog: SurfaceCatalog;
+}
+
+/** The full current spec for a surface the client just subscribed to. */
+export interface SurfaceMessage {
+ readonly type: "surface";
+ readonly spec: SurfaceSpec;
+}
+
+/** A live update for a subscribed surface. */
+export interface SurfaceUpdateMessage {
+ readonly type: "update";
+ readonly update: SurfaceUpdate;
+}
+
+/** A surface-scoped error. */
+export interface SurfaceErrorMessage {
+ readonly type: "error";
+ readonly surfaceId?: string;
+ readonly message: string;
+}
+```
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8edf324
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.DS_Store
+*.log
+reports/
+prompts/
+.env
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9f077ba
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,74 @@
+# Dispatch Web — Constitution (root AGENTS.md)
+
+> Loaded every session. Non-obvious, project-specific rules only. If a fresh
+> frontier model could infer it from the code, it is NOT here (P6).
+> Full design + rationale: `../arch-rewrite/notes/frontend-design.md` (and the
+> backend's `notes/restructure-plan.md` §1 for P1–P8).
+
+## What this is
+The **web frontend** for Dispatch — a SEPARATE repo from the backend
+(`../arch-rewrite`). It is a **thin shell + pure feature libraries + a surface
+host**, NOT a default-SvelteKit ball of mud. It consumes the backend's typed
+contracts (`@dispatch/ui-contract` + the wire types) over HTTP + a WebSocket.
+There is **no mandatory "chat is the root"**: the app is a COMPOSITION of feature
+modules + surfaces, assembled at the composition root; legitimate frontends may
+compose without chat at all.
+
+## Stack
+Bun + Vite + Svelte 5 (runes) + TypeScript (strict). Biome for lint/format
+(tabs, double quotes, semicolons, width 100) — **biome covers `.ts`/`.js` ONLY;
+`.svelte` correctness is `svelte-check`'s job** (biome can't read Svelte template
+semantics — it flags template-used vars as unused). Vitest + `@testing-library/
+svelte` for tests.
+
+## The non-negotiable rules
+- **Pure core / injected shell.** Decision logic (reducers, view-models,
+ formatters, parsers) is pure `input → output`: zero DOM, zero `fetch`/WebSocket,
+ zero Svelte import. Effects (WebSocket, fetch, IndexedDB, history, clipboard)
+ are INJECTED at the edges. State is a pure reducer; Svelte runes are a THIN
+ reactive wrapper over it, never the home of logic.
+- **No ambient state.** State is owned per-unit and passed explicitly. No
+ module-global mutable store reached from everywhere — that is the old FE's
+ "tools leak across tabs" / "model resets on tab switch" bug class.
+- **Components are thin.** A `.svelte` file wires props/events to pure logic and
+ renders; it holds no business logic.
+- **One owner per unit.** Each feature module / file has exactly ONE editing
+ agent. To change another unit you report the need up — you do not edit it.
+- **Contracts are the only cross-unit surface.** You see other units' public
+ exports (`index.ts`) and the imported `@dispatch/ui-contract` / wire types —
+ never their internals. Needing internals ⇒ the contract is incomplete; report it.
+- **The surface interpreter is GENERIC.** It switches on field KINDS
+ (toggle/progress/...), NEVER on a surface id. No `if (surface.id === "...")` —
+ that imports a feature's identity and breaks isolation.
+- **Typed coupling.** Cross-feature links are typed imports/callbacks; no
+ stringly-typed event bus. Discovery-by-id (surface catalog, subscribe) is
+ sanctioned DATA flow, not a code reference.
+
+## Backend seam (cross-repo)
+The backend is `../arch-rewrite` (separate repo; `lsp references` does NOT span
+the boundary). You consume `@dispatch/ui-contract` (surfaces) + the wire types as
+a pinned dependency. Need a backend contract change? REPORT IT UP — the
+orchestrator carries it across (couriered via the user). Never reach into the
+backend repo.
+
+## Surfaces (the modular UI mechanism)
+A **surface** is backend-declared, frontend-agnostic data (fields + values +
+actions), rendered generically. See `frontend-design.md` §4. You render surfaces;
+you never special-case a specific one.
+
+## Commands
+- `bun run typecheck` — svelte-check
+- `bun run test` — vitest
+- `bun run check` — biome (`.ts`/`.js`)
+- `bun run build` — vite build
+
+## Reports
+Finish a task → write `reports/<your-unit>.md` (gitignored): what you built, the
+public surface, test/typecheck/build output, and any contract gaps / change-
+requests (incl. backend-contract CRs for the orchestrator to courier).
+
+## Vocabulary
+Use `GLOSSARY.md`. Shared backend terms are canonical (conversation, turn, step,
+chunk, AgentEvent, model name, …). FE terms: surface, region, field kind,
+action / action ref, surface catalog. **"view" is RESERVED** (old-Dispatch sidebar
+UX, future). Never invent a synonym.
diff --git a/GLOSSARY.md b/GLOSSARY.md
new file mode 100644
index 0000000..4bce7a7
--- /dev/null
+++ b/GLOSSARY.md
@@ -0,0 +1,30 @@
+# Glossary — canonical vocabulary (dispatch-web)
+
+> One name per concept. Shared backend terms are adopted VERBATIM (no drift).
+> New term? The orchestrator proposes the standard name and the user confirms
+> before it lands (§5.6). "Aliases to avoid" maps wrong names back to the canonical.
+
+## Shared with the backend (canonical — do NOT redefine)
+| Term | Meaning | Aliases to avoid |
+|---|---|---|
+| **conversation** | A single thread of turns with persisted history, id'd by `conversationId`. | tab, session, thread, chat |
+| **conversationId** | The string id threading multi-turn history. | tabId, sessionId, chatId |
+| **turn** | One user message → assistant response cycle (may span steps). | — |
+| **step** | One LLM round-trip within a turn. | iteration |
+| **chunk** | One ordered piece of a message (text/thinking/tool-call/result), append-only. | block, segment |
+| **AgentEvent** | An outward event the runtime emits during a turn (text-delta, tool-call, usage, done, turn-sealed, …). | — |
+| **model name** | The selectable id in `<credentialName>/<model>` form. | model id, model reference |
+| **model catalog** | The list of available model names. | model list |
+
+## Frontend-specific
+| Term | Meaning | Aliases to avoid |
+|---|---|---|
+| **surface** | A backend-declared, frontend-agnostic data contribution (fields + values + actions); rendered generically by any client. NOT UI/styling. | widget, panel-data |
+| **region** | Where a surface mounts — a coarse, semantic placement hint (NOT layout/CSS). | slot (clashes with Svelte `<slot>`) |
+| **field kind** | The semantic type of a surface field (toggle/progress/selector/stat/button/custom). | widget type, control type |
+| **action / action ref** | A backend-invokable action; a field carries an *action ref* the client posts back. (Backend calls this a `command` for now.) | — |
+| **surface catalog** | The list of available surfaces (metadata) the FE fetches to discover them (`GET /surfaces`). | capability manifest |
+| **view** | RESERVED — the old-Dispatch sidebar affordance (settings / feature views); a FUTURE FE concept, NOT a surface. | (do not reuse) |
+| **feature module** | A self-contained FE feature (chat, history explorer, …); feature-as-a-library, composed at the root. | — |
+| **composition root** | The single place (`src/app/`) that imports + wires feature modules + the surface host. | — |
+| **surface interpreter** | The generic renderer: field kind → component. Knows kinds, never surface ids. | — |
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
new file mode 100644
index 0000000..5da54fb
--- /dev/null
+++ b/ORCHESTRATOR.md
@@ -0,0 +1,126 @@
+# ORCHESTRATOR.md — driving dispatch-web (the frontend)
+
+> **You are the orchestrator for the frontend repo.** You do NOT write feature
+> code. You plan, author contracts/config/harness, summon owner-agents (one per
+> unit), verify, resolve, keep the build green. Read fully before acting. Also
+> read: `AGENTS.md` (the FE constitution you enforce), `GLOSSARY.md`,
+> `.dispatch/rules/`, and the design home `../arch-rewrite/notes/frontend-design.md`
+> (+ `../arch-rewrite/notes/restructure-plan.md` §1 for P1–P8). This MIRRORS the
+> backend's `../arch-rewrite/ORCHESTRATOR.md` — read that for the deep rationale.
+
+## 0. Mental model
+The frontend is a **composition of feature modules + a surface host**, built with
+the backend's methodology (pure core / inject effects / no ambient state / typed
+contracts / one owner per unit / asymmetric testing). The team structure is
+isomorphic to the module structure: agents communicate only through contracts. The
+surface system (backend-declared, frontend-agnostic UI) is the modular UI
+mechanism — see `frontend-design.md` §4. No feature (not even chat) is the
+mandatory structural root.
+
+## 1. The golden workflow
+1. Plan the unit(s); respect dependency-topological order; one agent owns one unit.
+2. Overlap/vocab check vs `GLOSSARY.md` before naming anything new (§5.6 — ask the
+ user before coining a term).
+3. Write the per-summon TASK to `prompts/<unit>.md` (gitignored).
+4. Summon via `opencode run` (§2). Parallelize disjoint units only.
+5. Verify the report + independently re-run typecheck/test/check/build (§4).
+6. Resolve contract gaps / CRs (§5).
+7. Commit the milestone; update progress (`frontend-design.md` / a tasks log).
+
+## 2. Summoning agents (`opencode run`)
+**Working dir:** the repo root `/home/tradam/projects/dispatch/dispatch-web` (so the
+agent's `lsp` tool uses THIS repo's TS server).
+**Model:** `opencode-go/mimo-v2.5-pro` for building.
+**Invocation:** concatenate the brief + the scoped rules + the TASK; redirect output
+to a log file; never use `-f`.
+```bash
+cd /home/tradam/projects/dispatch/dispatch-web && \
+opencode run --dir /home/tradam/projects/dispatch/dispatch-web \
+ -m opencode-go/mimo-v2.5-pro \
+ "$(cat .dispatch/package-agent.md)
+$(cat .dispatch/rules/frontend-pure-core.md .dispatch/rules/frontend-no-ambient-state.md)
+
+## TASK
+$(cat prompts/<unit>.md)" \
+ > reports/<unit>.run.log 2>&1
+```
+**MANDATORY — capture output to a file, never display it** (the stream is huge and
+will crash the harness). Read the agent's `reports/<unit>.md`; `grep`/`tail` the log
+only for a specific error.
+**Run discipline:** do NOT background; large timeout (e.g. 1800000 ms). One
+non-backgrounded run per summon; parallel summons ONLY for disjoint file sets
+(single-writer). `AGENTS.md` is auto-loaded by opencode — never `cat` it.
+
+**GOTCHA — headless cross-repo read = HANG.** An agent's Read of any file OUTSIDE `--dir`
+(here `dispatch-web/`) triggers a permission prompt that CANNOT be answered headlessly → the
+run wedges until aborted. The `@dispatch/ui-contract` `file:` dep symlinks OUT of this repo, so
+reading `node_modules/@dispatch/*` hangs. Mitigation (in place): the contract is mirrored in-repo
+at `.dispatch/ui-contract.reference.md` — agents read THAT; the brief forbids `node_modules/
+@dispatch/*` reads. **Regenerate that snapshot whenever `ui-contract` changes.** Agents are told:
+if you'd need a file outside your scope, report it and STOP — never attempt the read.
+
+### `.dispatch/rules/` scoping map (inline ONLY the matching rows)
+- **Every FE agent:** `frontend-pure-core.md`, `frontend-no-ambient-state.md`.
+- **Surface interpreter / renderer / field-component unit:** + `frontend-interpreter-generic.md`.
+- **Transport / protocol / WS-client unit:** + `frontend-inject-transport.md`.
+
+## 3. The per-summon `prompts/<unit>.md` is JUST the TASK
+The invariant guardrails live in `package-agent.md` + the inlined rules. The TASK
+states only the non-inferable, project-specific job: your unit's directory; the job
++ algorithm naming the contract types involved; the contract file(s) to read
+(`@dispatch/ui-contract`, a sibling's `index.ts`); the required named test cases.
+
+## 4. Verification (re-run yourself — trust nothing)
+```bash
+cd /home/tradam/projects/dispatch/dispatch-web
+bun run typecheck # svelte-check — 0 errors
+bun run test # vitest — note the pass count
+bun run check # biome (.ts/.js) — clean
+bun run build # vite build — succeeds
+git status --short # confirm the agent stayed in its lane
+```
+Trust = contracts + public surfaces + green checks + the report — NOT reading impl.
+For pure units, confirm tests use NO internal `vi.mock` of our modules.
+
+## 5. Errors, CRs, cross-repo
+- **FE contract change** (a shared FE type / service handle): the owner edits it,
+ runs `lsp references`, reports the consumer list; the orchestrator dispatches the
+ fan-out.
+- **CR for build/config** (root tsconfig/vite/biome/package.json): the orchestrator
+ edits directly. **CR for impl** (a sibling, composition wiring in `src/app/`): the
+ orchestrator SUMMONS the owning agent.
+- **BACKEND contract change (cross-repo):** `lsp references` does NOT span the two
+ repos. The FE pins `@dispatch/ui-contract` + wire types as a dependency. A needed
+ backend change is reported UP and **couriered by the user** to the backend
+ orchestrator; on the new version, re-pin + fan out FE consumers. NEVER edit the
+ backend repo.
+
+## 6. Restrictions (NEVER violate)
+- Single-writer: never two agents on one file.
+- The orchestrator never reads/edits feature impl (`.ts`/`.svelte`). It MAY edit:
+ (a) locally-mirrored/consumed contract pins, (b) build/config (tsconfig, vite,
+ biome, package.json, .gitignore), (c) harness/docs (this file, AGENTS.md,
+ GLOSSARY.md, `.dispatch/`, prompts/, reports/). The composition root (`src/app/`)
+ changes ONLY via a summoned owner. Roadblock → ask the user.
+- The surface interpreter is GENERIC (no surface-id special-casing).
+- biome covers `.ts`/`.js`; `.svelte` correctness is `svelte-check`'s.
+
+## 7. Repo geography
+```
+/home/tradam/projects/dispatch/dispatch-web (THIS repo)
+ AGENTS.md ORCHESTRATOR.md GLOSSARY.md
+ .dispatch/{package-agent.md, rules/frontend-*.md}
+ src/app/ composition root (imports + wires feature modules + surface host)
+ src/core/ PURE: transcript · cache · surfaces (interpreter) · protocol · wire
+ src/features/<unit>/ logic/ (pure) · ui/ (svelte) · adapter/ (effects)
+ src/adapters/ injected browser effects: WS client, fetch, IndexedDB, history
+ prompts/ (gitignored) reports/ (gitignored)
+```
+Backend (SEPARATE repo, contracts only): `/home/tradam/projects/dispatch/arch-rewrite`
+— consume `@dispatch/ui-contract` (`file:` dep) + the wire types. Do NOT edit it.
+
+## 8. Status
+Slice 1 (surface system + WS) in progress — plan in
+`../arch-rewrite/notes/frontend-design.md` §10. Scaffold verified (svelte-check +
+biome + `vite build` green; `@dispatch/ui-contract` linked). Dev server:
+`bun run dev` (port 24204).
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1d6e60c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+# Dispatch Web
+
+The **web frontend** for [Dispatch](../arch-rewrite) — a separate repo built to the same
+methodology (thin shell + pure feature libraries + a backend-driven *surface* host). It consumes
+the backend's typed contracts over HTTP + a WebSocket and ships no business logic the backend
+doesn't expose.
+
+- **Stack:** Bun + Vite + Svelte 5 (runes) + TypeScript (strict). Biome (lint/format), Vitest +
+ `@testing-library/svelte` (tests).
+- **Slice 1 (current):** the **surface system** — connects to the backend's surface WebSocket,
+ fetches the surface *catalog*, and renders any backend-declared *surface* generically (e.g. the
+ live "Loaded Extensions" surface). Chat UI is a later slice.
+
+---
+
+## Prerequisites
+
+- [Bun](https://bun.sh) (v1.3+).
+- **The backend repo as a sibling directory** — this repo links `@dispatch/ui-contract` from
+ `../arch-rewrite` via a `file:` dependency:
+ ```
+ dispatch/
+ arch-rewrite/ # the backend (Dispatch server)
+ dispatch-web/ # this repo
+ ```
+- The **backend server running** for surfaces to appear (see `../arch-rewrite/README.md`).
+
+```sh
+cd dispatch-web
+bun install # links @dispatch/ui-contract from ../arch-rewrite
+```
+
+---
+
+## Run it (visit locally)
+
+```sh
+# 1) start the backend (sibling repo) — HTTP :24203 + surface WS :24205
+cd ../arch-rewrite && bun run dev
+
+# 2) start this dev server — Vite on :24204
+cd ../dispatch-web && bun run dev
+```
+
+Open **http://localhost:24204**. You'll see the surface catalog (e.g. "Loaded Extensions"); the
+frontend connects to the backend's surface WebSocket at `ws://localhost:24205` (override with
+`VITE_WS_URL`).
+
+> **Tip — run both at once with live reload:** the backend repo ships `../arch-rewrite/bin/up`
+> (also `bun run dev:all` there) which starts the backend (`bun --watch`) + this dev server
+> (Vite HMR) together; **Ctrl-C stops both**.
+
+---
+
+## Visiting over a LAN / Tailscale
+
+The dev server is configured (`vite.config.ts`) to bind **all interfaces** (`server.host: true`)
+and accept **any Host header** (`server.allowedHosts: true`) — so it's reachable from another
+device on your tailnet. This is safe ONLY because you run it on a **private/local network, not
+exposed to the internet** (`allowedHosts: true` disables Vite's DNS-rebinding host check).
+
+When browsing from a **different device than the one running the backend**, set two things:
+
+1. **Reach the dev server:** open `http://<this-machine-tailscale-name>:24204`. (The backend's Bun
+ servers already bind all interfaces, so `:24203`/`:24205` are reachable over Tailscale too.)
+2. **Point the frontend at the backend's WebSocket.** The WS URL runs in *your browser*, so
+ `localhost` would mean *your* device — set it to the backend host. Create `dispatch-web/.env`:
+ ```sh
+ VITE_WS_URL=ws://<backend-machine-tailscale-name>:24205
+ ```
+ Vite auto-loads `.env`; restart `bun run dev` after changing it.
+
+---
+
+## Structure (slice 1)
+
+```
+src/
+ app/ composition root — owns protocol state (runes), wires the socket, renders
+ core/protocol/ PURE op-protocol reducer (catalog/subscribe/update/invoke) — zero I/O
+ features/surface-host/ the generic surface interpreter + thin field components (toggle/…)
+ adapters/ws/ injected WebSocket client (pure codec + reconnect; socket injected)
+```
+
+The surface vocabulary (`SurfaceSpec`, field kinds, the WS protocol) is the backend's
+`@dispatch/ui-contract`, mirrored in-repo for reference at `.dispatch/ui-contract.reference.md`.
+
+---
+
+## Development
+
+```sh
+bun run dev # Vite dev server (:24204)
+bun run build # production build → dist/
+bun run typecheck # svelte-check
+bun run test # vitest
+bun run check # biome (.ts/.js; .svelte correctness is svelte-check's job)
+```
+
+## Documentation
+
+- **Design + plan:** `../arch-rewrite/notes/frontend-design.md`
+- **Build rules:** `AGENTS.md` · **Orchestration:** `ORCHESTRATOR.md` · **Vocabulary:** `GLOSSARY.md`
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..7179b96
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
+ "assist": { "actions": { "source": { "organizeImports": "on" } } },
+ "linter": { "enabled": true, "rules": { "recommended": true } },
+ "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 100 },
+ "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } },
+ "files": {
+ "includes": ["**", "!**/node_modules", "!**/dist", "!**/build", "!**/*.svelte"]
+ }
+}
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..a9725c7
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,472 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "dispatch-web",
+ "dependencies": {
+ "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.16",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/svelte": "^5.2.0",
+ "@tsconfig/svelte": "^5.0.0",
+ "jsdom": "^25.0.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0",
+ "vitest": "^3.0.0",
+ },
+ },
+ },
+ "packages": {
+ "@adobe/css-tools": ["@adobe/[email protected]", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
+
+ "@asamuzakjp/css-color": ["@asamuzakjp/[email protected]", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
+
+ "@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
+
+ "@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
+
+ "@biomejs/cli-darwin-arm64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
+
+ "@biomejs/cli-darwin-x64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
+
+ "@biomejs/cli-linux-arm64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
+
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
+
+ "@biomejs/cli-linux-x64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
+
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
+
+ "@biomejs/cli-win32-arm64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
+
+ "@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
+
+ "@csstools/color-helpers": ["@csstools/[email protected]", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
+
+ "@csstools/css-calc": ["@csstools/[email protected]", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
+
+ "@csstools/css-color-parser": ["@csstools/[email protected]", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
+
+ "@csstools/css-parser-algorithms": ["@csstools/[email protected]", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
+
+ "@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
+
+ "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}],
+
+ "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+ "@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+ "@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+ "@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+ "@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw=="],
+
+ "@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
+
+ "@sveltejs/load-config": ["@sveltejs/[email protected]", "", {}, "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA=="],
+
+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
+
+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
+
+ "@testing-library/dom": ["@testing-library/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/[email protected]", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
+
+ "@testing-library/svelte": ["@testing-library/[email protected]", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="],
+
+ "@testing-library/svelte-core": ["@testing-library/[email protected]", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="],
+
+ "@tsconfig/svelte": ["@tsconfig/[email protected]", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="],
+
+ "@types/aria-query": ["@types/[email protected]", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
+ "@types/chai": ["@types/[email protected]", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
+ "@types/deep-eql": ["@types/[email protected]", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
+ "@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
+ "@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="],
+
+ "@vitest/mocker": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="],
+
+ "@vitest/pretty-format": ["@vitest/[email protected]", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA=="],
+
+ "@vitest/runner": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q=="],
+
+ "@vitest/snapshot": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw=="],
+
+ "@vitest/spy": ["@vitest/[email protected]", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg=="],
+
+ "@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg=="],
+
+ "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+ "ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "ansi-styles": ["[email protected]", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
+ "aria-query": ["[email protected]", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
+ "asynckit": ["[email protected]", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "cac": ["[email protected]", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+
+ "call-bind-apply-helpers": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "chai": ["[email protected]", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
+
+ "check-error": ["[email protected]", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+
+ "chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "combined-stream": ["[email protected]", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
+ "css.escape": ["[email protected]", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
+ "cssstyle": ["[email protected]", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
+
+ "data-urls": ["[email protected]", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
+
+ "debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decimal.js": ["[email protected]", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
+ "deep-eql": ["[email protected]", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
+
+ "deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "delayed-stream": ["[email protected]", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
+ "dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
+
+ "dom-accessibility-api": ["[email protected]", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
+ "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "entities": ["[email protected]", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-module-lexer": ["[email protected]", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
+ "es-object-atoms": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
+
+ "es-set-tostringtag": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
+ "esm-env": ["[email protected]", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "esrap": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="],
+
+ "estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
+ "expect-type": ["[email protected]", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
+ "fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "form-data": ["[email protected]", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
+ "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "get-intrinsic": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-proto": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "has-symbols": ["[email protected]", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["[email protected]", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+
+ "html-encoding-sniffer": ["[email protected]", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
+
+ "http-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+
+ "https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
+ "iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
+ "indent-string": ["[email protected]", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
+ "is-potential-custom-element-name": ["[email protected]", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
+ "is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
+ "js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "jsdom": ["[email protected]", "", { "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.12", "parse5": "^7.1.2", "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^2.11.2" }, "optionalPeers": ["canvas"] }, "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw=="],
+
+ "kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "locate-character": ["[email protected]", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "loupe": ["[email protected]", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
+
+ "lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
+ "lz-string": ["[email protected]", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
+ "magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "min-indent": ["[email protected]", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
+ "mri": ["[email protected]", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
+
+ "nwsapi": ["[email protected]", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
+
+ "parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "pathval": ["[email protected]", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+
+ "picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
+
+ "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
+
+ "pretty-format": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
+ "punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "react-is": ["[email protected]", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
+ "readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+ "redent": ["[email protected]", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
+ "rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="],
+
+ "rrweb-cssom": ["[email protected]", "", {}, "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="],
+
+ "sade": ["[email protected]", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
+
+ "safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "saxes": ["[email protected]", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
+ "siginfo": ["[email protected]", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
+ "source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "stackback": ["[email protected]", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "std-env": ["[email protected]", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+
+ "strip-indent": ["[email protected]", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
+ "strip-literal": ["[email protected]", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
+
+ "svelte": ["[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1lDf8TLqpxyAt3xgybfytWPJQbaUD6TiDgpiCLH0BKrKEwzecB9pjuNVnEJMpzH018xUzo6oxheK2HT0oa2RoQ=="],
+
+ "svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "0.1.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ=="],
+
+ "symbol-tree": ["[email protected]", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
+ "tinybench": ["[email protected]", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["[email protected]", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+
+ "tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "tinypool": ["[email protected]", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
+
+ "tinyrainbow": ["[email protected]", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
+
+ "tinyspy": ["[email protected]", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
+
+ "tldts": ["[email protected]", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
+
+ "tldts-core": ["[email protected]", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
+
+ "tough-cookie": ["[email protected]", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
+
+ "tr46": ["[email protected]", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
+
+ "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
+
+ "vite-node": ["[email protected]", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
+
+ "vitefu": ["[email protected]", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
+
+ "vitest": ["[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="],
+
+ "w3c-xmlserializer": ["[email protected]", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
+
+ "webidl-conversions": ["[email protected]", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+
+ "whatwg-encoding": ["[email protected]", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
+
+ "whatwg-mimetype": ["[email protected]", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
+ "whatwg-url": ["[email protected]", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
+
+ "why-is-node-running": ["[email protected]", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
+ "ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
+
+ "xml-name-validator": ["[email protected]", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
+
+ "xmlchars": ["[email protected]", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
+ "zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+
+ "@testing-library/dom/aria-query": ["[email protected]", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["[email protected]", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
+ "cssstyle/rrweb-cssom": ["[email protected]", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
+
+ "strip-literal/js-tokens": ["[email protected]", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+
+ "svelte/aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..32b56aa
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Dispatch</title>
+ </head>
+ <body>
+ <div id="app"></div>
+ <script type="module" src="/src/main.ts"></script>
+ </body>
+</html>
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..244baf1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "dispatch-web",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json",
+ "test": "vitest run --passWithNoTests",
+ "test:watch": "vitest",
+ "check": "biome check .",
+ "check:fix": "biome check --write ."
+ },
+ "dependencies": {
+ "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.16",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/svelte": "^5.2.0",
+ "@tsconfig/svelte": "^5.0.0",
+ "jsdom": "^25.0.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0",
+ "vitest": "^3.0.0"
+ }
+}
diff --git a/src/App.svelte b/src/App.svelte
new file mode 100644
index 0000000..ffd5543
--- /dev/null
+++ b/src/App.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ import { App, createAppStore } from "./app";
+
+ const store = createAppStore();
+</script>
+
+<App {store} />
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts
new file mode 100644
index 0000000..92b8753
--- /dev/null
+++ b/src/adapters/ws/index.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it, vi } from "vitest";
+import type { WebSocketLike } from "./index";
+import { createSurfaceSocket } from "./index";
+
+interface FakeSocket extends WebSocketLike {
+ sent: string[];
+ resolveOpen(): void;
+ invokeMessage(data: string): void;
+ invokeClose(): void;
+}
+
+function fakeSocket(): FakeSocket {
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
+ const sent: string[] = [];
+
+ const ws: FakeSocket = {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return onclose;
+ },
+ set onclose(fn) {
+ onclose = fn;
+ },
+ resolveOpen() {
+ onopen?.();
+ },
+ invokeMessage(data: string) {
+ onmessage?.({ data });
+ },
+ invokeClose() {
+ onclose?.({ code: 1000, reason: "" });
+ },
+ sent,
+ };
+ return ws;
+}
+
+describe("createSurfaceSocket", () => {
+ it("sends queued messages once socket opens", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ handle.send({ type: "subscribe", surfaceId: "s1" });
+ handle.send({ type: "subscribe", surfaceId: "s2" });
+ expect(ws.sent).toHaveLength(0);
+
+ ws.resolveOpen();
+ expect(ws.sent).toHaveLength(2);
+ expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" });
+ expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" });
+ });
+
+ it("sends immediately when socket is already open", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.sent.length = 0;
+
+ handle.send({ type: "subscribe", surfaceId: "s1" });
+ expect(ws.sent).toHaveLength(1);
+ });
+
+ it("routes inbound messages to onMessage via parseServerMessage", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] }));
+ expect(onMessage).toHaveBeenCalledOnce();
+ expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] });
+ });
+
+ it("drops malformed inbound messages silently", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage("not json");
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it("auto-reconnects on close and fires onReopen after successful reconnect", () => {
+ vi.useFakeTimers();
+ try {
+ const sockets: ReturnType<typeof fakeSocket>[] = [];
+ const onMessage = vi.fn();
+ const onReopen = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onReopen,
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+
+ expect(sockets).toHaveLength(1);
+ sockets[0]?.resolveOpen();
+
+ // Simulate close
+ sockets[0]?.invokeClose();
+
+ // Fast-forward past the backoff delay
+ vi.advanceTimersByTime(600);
+
+ expect(sockets).toHaveLength(2);
+ // onReopen should NOT have fired yet (socket not open)
+ expect(onReopen).not.toHaveBeenCalled();
+
+ sockets[1]?.resolveOpen();
+ expect(onReopen).toHaveBeenCalledOnce();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not fire onReopen on initial connect", () => {
+ const ws = fakeSocket();
+ const onReopen = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ onReopen,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ expect(onReopen).not.toHaveBeenCalled();
+ });
+
+ it("close() prevents further reconnects", () => {
+ vi.useFakeTimers();
+ try {
+ const sockets: ReturnType<typeof fakeSocket>[] = [];
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+
+ sockets[0]?.resolveOpen();
+ sockets[0]?.invokeClose();
+ handle.close();
+
+ vi.advanceTimersByTime(10_000);
+ expect(sockets).toHaveLength(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("close() prevents further sends", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.sent.length = 0;
+ handle.close();
+
+ handle.send({ type: "subscribe", surfaceId: "s1" });
+ expect(ws.sent).toHaveLength(0);
+ });
+
+ it("queues multiple sends before open and flushes in order", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ handle.send({ type: "subscribe", surfaceId: "a" });
+ handle.send({ type: "subscribe", surfaceId: "b" });
+ handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 });
+ ws.resolveOpen();
+
+ expect(ws.sent).toHaveLength(3);
+ expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" });
+ expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" });
+ expect(JSON.parse(ws.sent[2] ?? "")).toEqual({
+ type: "invoke",
+ surfaceId: "a",
+ actionId: "x",
+ payload: 1,
+ });
+ });
+});
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts
new file mode 100644
index 0000000..40eda2b
--- /dev/null
+++ b/src/adapters/ws/index.ts
@@ -0,0 +1,98 @@
+import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
+import { nextBackoffMs, parseServerMessage, serialize } from "./logic";
+
+export interface WebSocketLike {
+ send(data: string): void;
+ close(): void;
+ onopen: (() => void) | null;
+ onmessage: ((ev: { data: string }) => void) | null;
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
+}
+
+export interface SurfaceSocketOptions {
+ url: string;
+ onMessage: (msg: SurfaceServerMessage) => void;
+ onReopen?: () => void;
+ socketFactory?: (url: string) => WebSocketLike;
+}
+
+export interface SurfaceSocketHandle {
+ send(msg: SurfaceClientMessage): void;
+ close(): void;
+}
+
+export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHandle {
+ const factory =
+ opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike);
+
+ let socket: WebSocketLike | null = null;
+ let disposed = false;
+ let reconnectAttempt = 0;
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ let isOpen = false;
+ const queue: string[] = [];
+
+ function connect(isReconnect: boolean): void {
+ socket = factory(opts.url);
+ isOpen = false;
+
+ socket.onopen = () => {
+ if (disposed) return;
+ isOpen = true;
+ reconnectAttempt = 0;
+ for (const raw of queue.splice(0)) {
+ socket?.send(raw);
+ }
+ if (isReconnect) {
+ opts.onReopen?.();
+ }
+ };
+
+ socket.onmessage = (ev) => {
+ if (disposed) return;
+ const msg = parseServerMessage(ev.data);
+ if (msg !== null) {
+ opts.onMessage(msg);
+ }
+ };
+
+ socket.onclose = () => {
+ if (disposed) return;
+ isOpen = false;
+ scheduleReconnect();
+ };
+ }
+
+ function scheduleReconnect(): void {
+ const delay = nextBackoffMs(reconnectAttempt);
+ reconnectAttempt++;
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ if (disposed) return;
+ connect(true);
+ }, delay);
+ }
+
+ connect(false);
+
+ return {
+ send(msg: SurfaceClientMessage): void {
+ if (disposed) return;
+ const raw = serialize(msg);
+ if (isOpen) {
+ socket?.send(raw);
+ } else {
+ queue.push(raw);
+ }
+ },
+ close(): void {
+ disposed = true;
+ if (reconnectTimer !== null) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ socket?.close();
+ socket = null;
+ },
+ };
+}
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
new file mode 100644
index 0000000..62ae6a0
--- /dev/null
+++ b/src/adapters/ws/logic.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from "vitest";
+import { nextBackoffMs, parseServerMessage, serialize } from "./logic";
+
+describe("serialize", () => {
+ it("serializes a subscribe message", () => {
+ const msg = { type: "subscribe" as const, surfaceId: "s1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes an unsubscribe message", () => {
+ const msg = { type: "unsubscribe" as const, surfaceId: "s1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes an invoke message with payload", () => {
+ const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes an invoke message without payload", () => {
+ const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+});
+
+describe("parseServerMessage", () => {
+ it("parses a catalog message", () => {
+ const data = JSON.stringify({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "r", title: "S1" }],
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "r", title: "S1" }],
+ });
+ });
+
+ it("parses a surface message", () => {
+ const data = JSON.stringify({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ });
+ });
+
+ it("parses an update message", () => {
+ const data = JSON.stringify({
+ type: "update",
+ update: {
+ surfaceId: "s1",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ },
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "update",
+ update: {
+ surfaceId: "s1",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ },
+ });
+ });
+
+ it("parses an error message with surfaceId", () => {
+ const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" });
+ });
+
+ it("parses an error message without surfaceId", () => {
+ const data = JSON.stringify({ type: "error", message: "global boom" });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "error", message: "global boom" });
+ });
+
+ it("returns null for malformed JSON", () => {
+ expect(parseServerMessage("not json")).toBeNull();
+ expect(parseServerMessage("{broken")).toBeNull();
+ expect(parseServerMessage("")).toBeNull();
+ });
+
+ it("returns null for non-object JSON", () => {
+ expect(parseServerMessage("42")).toBeNull();
+ expect(parseServerMessage('"hello"')).toBeNull();
+ expect(parseServerMessage("null")).toBeNull();
+ expect(parseServerMessage("true")).toBeNull();
+ expect(parseServerMessage("[1,2,3]")).toBeNull();
+ });
+
+ it("returns null for unknown type", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull();
+ });
+
+ it("returns null when type is missing", () => {
+ expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull();
+ });
+
+ it("returns null when type is not a string", () => {
+ expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull();
+ });
+
+ it("returns null for catalog with non-array catalog field", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull();
+ });
+
+ it("returns null for surface with missing spec fields", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull();
+ });
+
+ it("returns null for surface with non-object spec", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull();
+ });
+
+ it("returns null for update with missing update field", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull();
+ });
+
+ it("returns null for update with invalid spec", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })),
+ ).toBeNull();
+ });
+
+ it("returns null for error with non-string message", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull();
+ });
+
+ it("returns null for error with invalid surfaceId type", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })),
+ ).toBeNull();
+ });
+});
+
+describe("round-trip: parseServerMessage(serialize(...))", () => {
+ it("round-trips a subscribe message through serialize only", () => {
+ const msg = { type: "subscribe" as const, surfaceId: "s1" };
+ const wire = serialize(msg);
+ expect(JSON.parse(wire)).toEqual(msg);
+ });
+
+ it("round-trips an invoke message with payload", () => {
+ const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false };
+ const wire = serialize(msg);
+ expect(JSON.parse(wire)).toEqual(msg);
+ });
+});
+
+describe("nextBackoffMs", () => {
+ it("returns a positive number", () => {
+ expect(nextBackoffMs(0)).toBeGreaterThan(0);
+ });
+
+ it("is capped at 30s + jitter (at most ~36s)", () => {
+ for (let i = 0; i < 100; i++) {
+ expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000);
+ }
+ });
+
+ it("starts around 500ms (±20% jitter)", () => {
+ for (let i = 0; i < 100; i++) {
+ const ms = nextBackoffMs(0);
+ expect(ms).toBeGreaterThanOrEqual(400);
+ expect(ms).toBeLessThanOrEqual(600);
+ }
+ });
+
+ it("grows exponentially with attempt", () => {
+ const averages = [0, 1, 2, 3].map((attempt) => {
+ let sum = 0;
+ for (let i = 0; i < 200; i++) {
+ sum += nextBackoffMs(attempt);
+ }
+ return sum / 200;
+ });
+ for (let i = 1; i < averages.length; i++) {
+ const prev = averages[i - 1];
+ if (prev === undefined) throw new Error("unreachable");
+ expect(averages[i]).toBeGreaterThan(prev);
+ }
+ });
+
+ it("treats negative attempt as 0", () => {
+ for (let i = 0; i < 50; i++) {
+ const ms = nextBackoffMs(-5);
+ expect(ms).toBeGreaterThanOrEqual(400);
+ expect(ms).toBeLessThanOrEqual(600);
+ }
+ });
+});
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
new file mode 100644
index 0000000..83a5802
--- /dev/null
+++ b/src/adapters/ws/logic.ts
@@ -0,0 +1,91 @@
+import type {
+ CatalogMessage,
+ SurfaceClientMessage,
+ SurfaceErrorMessage,
+ SurfaceMessage,
+ SurfaceServerMessage,
+ SurfaceUpdateMessage,
+} from "@dispatch/ui-contract";
+
+const VALID_SERVER_TYPES = new Set(["catalog", "surface", "update", "error"]);
+
+/** Serialize a client message to a JSON string for the wire. */
+export function serialize(msg: SurfaceClientMessage): string {
+ return JSON.stringify(msg);
+}
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+ return v !== null && typeof v === "object" && !Array.isArray(v);
+}
+
+/**
+ * Parse a raw server message string into a typed SurfaceServerMessage.
+ * Returns null for malformed JSON or shapes that don't match the protocol.
+ */
+export function parseServerMessage(data: string): SurfaceServerMessage | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ return null;
+ }
+ if (!isRecord(parsed)) {
+ return null;
+ }
+ const t = parsed.type;
+ if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) {
+ return null;
+ }
+ switch (t) {
+ case "catalog": {
+ if (!Array.isArray(parsed.catalog)) return null;
+ return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] };
+ }
+ case "surface": {
+ const spec = parsed.spec;
+ if (!isRecord(spec)) return null;
+ if (typeof spec.id !== "string") return null;
+ if (typeof spec.region !== "string") return null;
+ if (typeof spec.title !== "string") return null;
+ if (!Array.isArray(spec.fields)) return null;
+ return { type: "surface", spec: spec as unknown as SurfaceMessage["spec"] };
+ }
+ case "update": {
+ const update = parsed.update;
+ if (!isRecord(update)) return null;
+ if (typeof update.surfaceId !== "string") return null;
+ const spec = update.spec;
+ if (!isRecord(spec)) return null;
+ if (typeof spec.id !== "string") return null;
+ if (typeof spec.region !== "string") return null;
+ if (typeof spec.title !== "string") return null;
+ if (!Array.isArray(spec.fields)) return null;
+ return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] };
+ }
+ case "error": {
+ if (typeof parsed.message !== "string") return null;
+ const surfaceId = parsed.surfaceId;
+ if (surfaceId !== undefined && typeof surfaceId !== "string") return null;
+ const msg: SurfaceErrorMessage =
+ surfaceId !== undefined
+ ? { type: "error", surfaceId, message: parsed.message }
+ : { type: "error", message: parsed.message };
+ return msg;
+ }
+ default:
+ return null;
+ }
+}
+
+/**
+ * Bounded exponential backoff with jitter.
+ * Base: 500ms, doubles each attempt, caps at 30s, adds ±20% jitter.
+ */
+export function nextBackoffMs(attempt: number): number {
+ const base = 500;
+ const max = 30_000;
+ const exponential = base * 2 ** Math.max(0, attempt);
+ const capped = Math.min(exponential, max);
+ const jitter = 0.8 + Math.random() * 0.4;
+ return Math.round(capped * jitter);
+}
diff --git a/src/app/App.svelte b/src/app/App.svelte
new file mode 100644
index 0000000..2619a39
--- /dev/null
+++ b/src/app/App.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import { SurfaceView } from "../features/surface-host";
+ import type { AppStore } from "./store.svelte";
+
+ let { store }: { store: AppStore } = $props();
+
+ function handleSelect(surfaceId: string) {
+ store.select(surfaceId);
+ }
+
+ function handleInvoke(msg: InvokeMessage) {
+ store.invoke(msg.surfaceId, msg.actionId, msg.payload);
+ }
+</script>
+
+<main>
+ <h1>Dispatch</h1>
+
+ {#if store.lastError}
+ <div role="alert">
+ <strong>Error:</strong>
+ {store.lastError.message}
+ </div>
+ {/if}
+
+ <section>
+ <h2>Surfaces</h2>
+ {#if store.catalog.length === 0}
+ <p>No surfaces available</p>
+ {:else}
+ <ul>
+ {#each store.catalog as entry (entry.id)}
+ <li>
+ <button
+ aria-current={entry.id === store.selectedId ? "true" : undefined}
+ onclick={() => handleSelect(entry.id)}
+ >
+ {entry.title}
+ <span>({entry.region})</span>
+ </button>
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+
+ {#if store.selectedSpec}
+ <section>
+ <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} />
+ </section>
+ {/if}
+</main>
diff --git a/src/app/index.ts b/src/app/index.ts
new file mode 100644
index 0000000..f94b554
--- /dev/null
+++ b/src/app/index.ts
@@ -0,0 +1,3 @@
+export { default as App } from "./App.svelte";
+export type { AppStore } from "./store.svelte";
+export { createAppStore } from "./store.svelte";
diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts
new file mode 100644
index 0000000..24c2f24
--- /dev/null
+++ b/src/app/resolve-ws-url.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { resolveWsUrl } from "./resolve-ws-url";
+
+describe("resolveWsUrl", () => {
+ it("explicit url wins over everything", () => {
+ const result = resolveWsUrl(
+ { VITE_WS_URL: "wss://env.example.com:9999" },
+ { protocol: "https:", hostname: "page.example.com" },
+ );
+ expect(result).toBe("wss://env.example.com:9999");
+ });
+
+ it("VITE_WS_URL wins over derivation", () => {
+ const result = resolveWsUrl(
+ { VITE_WS_URL: "wss://env.example.com:9999" },
+ { protocol: "https:", hostname: "page.example.com" },
+ );
+ expect(result).toBe("wss://env.example.com:9999");
+ });
+
+ it("derives ws://<hostname>:24205 from http location", () => {
+ const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" });
+ expect(result).toBe("ws://100.126.75.103:24205");
+ });
+
+ it("derives wss://<hostname>:24205 from https location", () => {
+ const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" });
+ expect(result).toBe("wss://arch-razer:24205");
+ });
+
+ it("uses VITE_WS_PORT when set", () => {
+ const result = resolveWsUrl(
+ { VITE_WS_PORT: "3000" },
+ { protocol: "http:", hostname: "localhost" },
+ );
+ expect(result).toBe("ws://localhost:3000");
+ });
+
+ it("falls back to ws://localhost:24205 when location is missing", () => {
+ const result = resolveWsUrl({});
+ expect(result).toBe("ws://localhost:24205");
+ });
+
+ it("VITE_WS_URL empty string treated as unset", () => {
+ const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" });
+ expect(result).toBe("ws://myhost:24205");
+ });
+
+ it("VITE_WS_PORT empty string falls back to default", () => {
+ const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" });
+ expect(result).toBe("ws://localhost:24205");
+ });
+});
diff --git a/src/app/resolve-ws-url.ts b/src/app/resolve-ws-url.ts
new file mode 100644
index 0000000..a264606
--- /dev/null
+++ b/src/app/resolve-ws-url.ts
@@ -0,0 +1,27 @@
+export interface WsUrlEnv {
+ readonly VITE_WS_URL?: string;
+ readonly VITE_WS_PORT?: string;
+}
+
+export interface WsUrlLocation {
+ readonly protocol: string;
+ readonly hostname: string;
+}
+
+const DEFAULT_PORT = "24205";
+const DEFAULT_FALLBACK = "ws://localhost:24205";
+
+export function resolveWsUrl(env: WsUrlEnv, location?: WsUrlLocation): string {
+ if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") {
+ return env.VITE_WS_URL;
+ }
+
+ if (location === undefined) {
+ return DEFAULT_FALLBACK;
+ }
+
+ const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
+ const port =
+ env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT;
+ return `${wsProtocol}://${location.hostname}:${port}`;
+}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
new file mode 100644
index 0000000..6b7a910
--- /dev/null
+++ b/src/app/store.svelte.ts
@@ -0,0 +1,103 @@
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { WebSocketLike } from "../adapters/ws";
+import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws";
+import {
+ applyServerMessage,
+ initialState,
+ type ProtocolState,
+ invoke as protocolInvoke,
+ subscribe as protocolSubscribe,
+ unsubscribe as protocolUnsubscribe,
+} from "../core/protocol";
+import { resolveWsUrl } from "./resolve-ws-url";
+
+export interface AppStore {
+ readonly catalog: ProtocolState["catalog"];
+ readonly selectedId: string | null;
+ readonly selectedSpec: SurfaceSpec | null;
+ readonly lastError: ProtocolState["lastError"];
+ select(surfaceId: string): void;
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void;
+ dispose(): void;
+}
+
+export function createAppStore(opts?: {
+ url?: string;
+ socketFactory?: (url: string) => WebSocketLike;
+}): AppStore {
+ let protocol = $state<ProtocolState>(initialState());
+ let selectedId = $state<string | null>(null);
+
+ let socket: ReturnType<typeof createSurfaceSocket> | null = null;
+
+ function handleServerMessage(msg: SurfaceServerMessage): void {
+ protocol = applyServerMessage(protocol, msg);
+ }
+
+ const wsLocation = typeof location !== "undefined" ? location : undefined;
+ const url =
+ opts?.url ??
+ resolveWsUrl(
+ { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT },
+ wsLocation,
+ );
+ const socketOpts: SurfaceSocketOptions = {
+ url,
+ onMessage: handleServerMessage,
+ onReopen() {
+ if (selectedId !== null) {
+ const result = protocolSubscribe(protocol, selectedId);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ }
+ },
+ };
+ if (opts?.socketFactory !== undefined) {
+ socketOpts.socketFactory = opts.socketFactory;
+ }
+ socket = createSurfaceSocket(socketOpts);
+
+ return {
+ get catalog() {
+ return protocol.catalog;
+ },
+ get selectedId() {
+ return selectedId;
+ },
+ get selectedSpec() {
+ if (selectedId === null) return null;
+ return protocol.subscriptions.get(selectedId) ?? null;
+ },
+ get lastError() {
+ return protocol.lastError;
+ },
+ select(surfaceId: string): void {
+ if (selectedId !== null && selectedId !== surfaceId) {
+ const unsub = protocolUnsubscribe(protocol, selectedId);
+ protocol = unsub.state;
+ for (const msg of unsub.outgoing) {
+ socket?.send(msg);
+ }
+ }
+ selectedId = surfaceId;
+ const sub = protocolSubscribe(protocol, surfaceId);
+ protocol = sub.state;
+ for (const msg of sub.outgoing) {
+ socket?.send(msg);
+ }
+ },
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void {
+ const result = protocolInvoke(protocol, surfaceId, actionId, payload);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ socket?.send(msg);
+ }
+ },
+ dispose(): void {
+ socket?.close();
+ socket = null;
+ },
+ };
+}
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
new file mode 100644
index 0000000..b521975
--- /dev/null
+++ b/src/app/store.test.ts
@@ -0,0 +1,220 @@
+import type { SurfaceServerMessage } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import type { WebSocketLike } from "../adapters/ws";
+import { createAppStore } from "./store.svelte";
+
+interface FakeSocket extends WebSocketLike {
+ sent: string[];
+ resolveOpen(): void;
+ feedMessage(data: SurfaceServerMessage): void;
+}
+
+function fakeSocket(): FakeSocket {
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ const sent: string[] = [];
+
+ const ws: FakeSocket = {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return null;
+ },
+ set onclose(_fn) {},
+ resolveOpen() {
+ onopen?.();
+ },
+ feedMessage(msg: SurfaceServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ sent,
+ };
+ return ws;
+}
+
+describe("createAppStore", () => {
+ it("starts with empty catalog and no selection", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ expect(store.catalog).toEqual([]);
+ expect(store.selectedId).toBeNull();
+ expect(store.selectedSpec).toBeNull();
+ expect(store.lastError).toBeNull();
+
+ store.dispose();
+ });
+
+ it("updates catalog when catalog message arrives", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ ws.feedMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ expect(store.catalog).toHaveLength(2);
+ expect(store.catalog[0]?.id).toBe("s1");
+ expect(store.catalog[1]?.id).toBe("s2");
+
+ store.dispose();
+ });
+
+ it("select sends subscribe and sets selectedId", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ ws.feedMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ ws.sent.length = 0;
+ store.select("s1");
+
+ expect(store.selectedId).toBe("s1");
+ const subscribeMsg = ws.sent.find((s) => {
+ const parsed = JSON.parse(s);
+ return parsed.type === "subscribe" && parsed.surfaceId === "s1";
+ });
+ expect(subscribeMsg).toBeTruthy();
+
+ store.dispose();
+ });
+
+ it("selecting a different surface unsubscribes from previous", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ ws.feedMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ ws.sent.length = 0;
+ store.select("s1");
+ store.select("s2");
+
+ const unsubscribeMsg = ws.sent.find((s) => {
+ const parsed = JSON.parse(s);
+ return parsed.type === "unsubscribe" && parsed.surfaceId === "s1";
+ });
+ expect(unsubscribeMsg).toBeTruthy();
+
+ const subscribeMsg = ws.sent.find((s) => {
+ const parsed = JSON.parse(s);
+ return parsed.type === "subscribe" && parsed.surfaceId === "s2";
+ });
+ expect(subscribeMsg).toBeTruthy();
+
+ store.dispose();
+ });
+
+ it("surface message updates selectedSpec", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ ws.feedMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ store.select("s1");
+
+ ws.feedMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
+ },
+ });
+
+ expect(store.selectedSpec).not.toBeNull();
+ expect(store.selectedSpec?.id).toBe("s1");
+ expect(store.selectedSpec?.fields).toHaveLength(1);
+
+ store.dispose();
+ });
+
+ it("invoke sends an invoke message", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ store.invoke("s1", "toggle-dark", true);
+
+ const invokeMsg = ws.sent.find((s) => {
+ const parsed = JSON.parse(s);
+ return (
+ parsed.type === "invoke" &&
+ parsed.surfaceId === "s1" &&
+ parsed.actionId === "toggle-dark" &&
+ parsed.payload === true
+ );
+ });
+ expect(invokeMsg).toBeTruthy();
+
+ store.dispose();
+ });
+
+ it("error message updates lastError", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ ws.feedMessage({
+ type: "error",
+ message: "Something went wrong",
+ });
+
+ expect(store.lastError).not.toBeNull();
+ expect(store.lastError?.message).toBe("Something went wrong");
+
+ store.dispose();
+ });
+
+ it("dispose closes the socket", () => {
+ const ws = fakeSocket();
+ const closeSpy = { called: false };
+ const origClose = ws.close.bind(ws);
+ ws.close = () => {
+ closeSpy.called = true;
+ origClose();
+ };
+
+ const store = createAppStore({ socketFactory: () => ws });
+ ws.resolveOpen();
+
+ store.dispose();
+ expect(closeSpy.called).toBe(true);
+ });
+});
diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts
new file mode 100644
index 0000000..25174ea
--- /dev/null
+++ b/src/core/protocol/index.ts
@@ -0,0 +1,2 @@
+export { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer";
+export type { ProtocolResult, ProtocolState } from "./types";
diff --git a/src/core/protocol/reducer.test.ts b/src/core/protocol/reducer.test.ts
new file mode 100644
index 0000000..57e12f2
--- /dev/null
+++ b/src/core/protocol/reducer.test.ts
@@ -0,0 +1,151 @@
+import { describe, expect, it } from "vitest";
+import { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer";
+
+const makeSpec = (id: string, title = id) => ({
+ id,
+ region: "test",
+ title,
+ fields: [],
+});
+
+describe("initialState", () => {
+ it("returns empty catalog, no subscriptions, no error", () => {
+ const s = initialState();
+ expect(s.catalog).toEqual([]);
+ expect(s.subscriptions.size).toBe(0);
+ expect(s.lastError).toBeNull();
+ });
+});
+
+describe("applyServerMessage — catalog", () => {
+ it("replaces the catalog", () => {
+ const s = initialState();
+ const catalog = [
+ { id: "a", region: "r", title: "A" },
+ { id: "b", region: "r", title: "B" },
+ ];
+ const next = applyServerMessage(s, { type: "catalog", catalog });
+ expect(next.catalog).toEqual(catalog);
+ });
+});
+
+describe("applyServerMessage — surface", () => {
+ it("sets the spec for a subscribed surface", () => {
+ let s = initialState();
+ const result = subscribe(s, "s1");
+ s = result.state;
+ const spec = makeSpec("s1", "Surface 1");
+ const next = applyServerMessage(s, { type: "surface", spec });
+ expect(next.subscriptions.get("s1")).toEqual(spec);
+ });
+
+ it("ignores a surface message for a non-subscribed surface", () => {
+ const s = initialState();
+ const spec = makeSpec("unknown");
+ const next = applyServerMessage(s, { type: "surface", spec });
+ expect(next.subscriptions.has("unknown")).toBe(false);
+ });
+});
+
+describe("applyServerMessage — update", () => {
+ it("replaces spec for a subscribed surface", () => {
+ let s = initialState();
+ s = subscribe(s, "s1").state;
+ s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1", "V1") });
+ const next = applyServerMessage(s, {
+ type: "update",
+ update: { surfaceId: "s1", spec: makeSpec("s1", "V2") },
+ });
+ expect(next.subscriptions.get("s1")?.title).toBe("V2");
+ });
+
+ it("ignores an update for a non-subscribed surface", () => {
+ const s = initialState();
+ const next = applyServerMessage(s, {
+ type: "update",
+ update: { surfaceId: "nope", spec: makeSpec("nope") },
+ });
+ expect(next.subscriptions.has("nope")).toBe(false);
+ });
+});
+
+describe("applyServerMessage — error", () => {
+ it("records the error without throwing", () => {
+ const s = initialState();
+ const err = { type: "error" as const, surfaceId: "s1", message: "boom" };
+ const next = applyServerMessage(s, err);
+ expect(next.lastError).toEqual(err);
+ });
+
+ it("records error without surfaceId", () => {
+ const s = initialState();
+ const err = { type: "error" as const, message: "global boom" };
+ const next = applyServerMessage(s, err);
+ expect(next.lastError).toEqual(err);
+ });
+});
+
+describe("subscribe", () => {
+ it("emits exactly one subscribe message", () => {
+ const s = initialState();
+ const result = subscribe(s, "s1");
+ expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]);
+ expect(result.outgoing).toHaveLength(1);
+ });
+
+ it("adds the surface to subscriptions with null spec", () => {
+ const s = initialState();
+ const result = subscribe(s, "s1");
+ expect(result.state.subscriptions.get("s1")).toBeNull();
+ });
+
+ it("is idempotent — second subscribe is a no-op", () => {
+ let s = initialState();
+ s = subscribe(s, "s1").state;
+ const result = subscribe(s, "s1");
+ expect(result.outgoing).toEqual([]);
+ expect(result.state).toBe(s);
+ });
+});
+
+describe("unsubscribe", () => {
+ it("emits unsubscribe and drops the spec", () => {
+ let s = initialState();
+ s = subscribe(s, "s1").state;
+ s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1") });
+ const result = unsubscribe(s, "s1");
+ expect(result.outgoing).toEqual([{ type: "unsubscribe", surfaceId: "s1" }]);
+ expect(result.state.subscriptions.has("s1")).toBe(false);
+ });
+
+ it("is a no-op if not subscribed", () => {
+ const s = initialState();
+ const result = unsubscribe(s, "nope");
+ expect(result.outgoing).toEqual([]);
+ expect(result.state).toBe(s);
+ });
+});
+
+describe("invoke", () => {
+ it("emits the correct InvokeMessage", () => {
+ const s = initialState();
+ const result = invoke(s, "s1", "toggle", true);
+ expect(result.outgoing).toEqual([
+ { type: "invoke", surfaceId: "s1", actionId: "toggle", payload: true },
+ ]);
+ });
+
+ it("omits payload when not provided", () => {
+ const s = initialState();
+ const result = invoke(s, "s1", "click");
+ expect(result.outgoing).toEqual([
+ { type: "invoke", surfaceId: "s1", actionId: "click", payload: undefined },
+ ]);
+ });
+
+ it("does not mutate state", () => {
+ const s = initialState();
+ const result = invoke(s, "s1", "a1");
+ expect(result.state).toBe(s);
+ });
+});
diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts
new file mode 100644
index 0000000..992a918
--- /dev/null
+++ b/src/core/protocol/reducer.ts
@@ -0,0 +1,82 @@
+import type {
+ InvokeMessage,
+ SubscribeMessage,
+ SurfaceServerMessage,
+ UnsubscribeMessage,
+} from "@dispatch/ui-contract";
+import type { ProtocolResult, ProtocolState } from "./types";
+
+/** The initial protocol state: empty catalog, no subscriptions, no error. */
+export function initialState(): ProtocolState {
+ return {
+ catalog: [],
+ subscriptions: new Map(),
+ lastError: null,
+ };
+}
+
+/** Fold an inbound server message into the next protocol state. */
+export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState {
+ switch (msg.type) {
+ case "catalog":
+ return { ...state, catalog: msg.catalog };
+
+ case "surface": {
+ const surfaceId = msg.spec.id;
+ if (!state.subscriptions.has(surfaceId)) return state;
+ const subs = new Map(state.subscriptions);
+ subs.set(surfaceId, msg.spec);
+ return { ...state, subscriptions: subs };
+ }
+
+ case "update": {
+ const surfaceId = msg.update.surfaceId;
+ if (!state.subscriptions.has(surfaceId)) return state;
+ const subs = new Map(state.subscriptions);
+ subs.set(surfaceId, msg.update.spec);
+ return { ...state, subscriptions: subs };
+ }
+
+ case "error":
+ return { ...state, lastError: msg };
+ }
+}
+
+/**
+ * Subscribe to a surface. Idempotent: if already subscribed, returns the same
+ * state with no outgoing message.
+ */
+export function subscribe(state: ProtocolState, surfaceId: string): ProtocolResult {
+ if (state.subscriptions.has(surfaceId)) {
+ return { state, outgoing: [] };
+ }
+ const subs = new Map(state.subscriptions);
+ subs.set(surfaceId, null);
+ const outgoing: SubscribeMessage = { type: "subscribe", surfaceId };
+ return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] };
+}
+
+/**
+ * Unsubscribe from a surface. Drops the local spec and emits one unsubscribe.
+ * If not subscribed, returns the same state with no outgoing.
+ */
+export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult {
+ if (!state.subscriptions.has(surfaceId)) {
+ return { state, outgoing: [] };
+ }
+ const subs = new Map(state.subscriptions);
+ subs.delete(surfaceId);
+ const outgoing: UnsubscribeMessage = { type: "unsubscribe", surfaceId };
+ return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] };
+}
+
+/** Invoke a field's action on a surface. Emits an InvokeMessage; no state change. */
+export function invoke(
+ state: ProtocolState,
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+): ProtocolResult {
+ const outgoing: InvokeMessage = { type: "invoke", surfaceId, actionId, payload };
+ return { state, outgoing: [outgoing] };
+}
diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts
new file mode 100644
index 0000000..effec0d
--- /dev/null
+++ b/src/core/protocol/types.ts
@@ -0,0 +1,22 @@
+import type {
+ SurfaceCatalog,
+ SurfaceClientMessage,
+ SurfaceErrorMessage,
+ SurfaceSpec,
+} from "@dispatch/ui-contract";
+
+/** The client-side view of the surface protocol state. */
+export interface ProtocolState {
+ /** The latest catalog received from the server (empty until first CatalogMessage). */
+ readonly catalog: SurfaceCatalog;
+ /** Surfaces the client intends to be subscribed to; null = subscribed but no spec yet. */
+ readonly subscriptions: ReadonlyMap<string, SurfaceSpec | null>;
+ /** The last error received from the server, if any. */
+ readonly lastError: SurfaceErrorMessage | null;
+}
+
+/** A state transition result: the next state plus any outgoing messages to send. */
+export interface ProtocolResult {
+ readonly state: ProtocolState;
+ readonly outgoing: readonly SurfaceClientMessage[];
+}
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
new file mode 100644
index 0000000..afa3127
--- /dev/null
+++ b/src/features/surface-host/index.ts
@@ -0,0 +1,3 @@
+export { buildInvoke, planSurface } from "./logic/plan";
+export type { FieldView, SurfaceRenderPlan } from "./logic/types";
+export { default as SurfaceView } from "./ui/SurfaceView.svelte";
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
new file mode 100644
index 0000000..50d6f11
--- /dev/null
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -0,0 +1,161 @@
+import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import { buildInvoke, planSurface } from "./plan";
+
+const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({
+ id: "test-surface",
+ region: "test",
+ title: "Test Surface",
+ fields,
+});
+
+describe("planSurface", () => {
+ it("maps a toggle field to a ToggleFieldView", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }),
+ );
+ expect(plan.fields).toEqual([
+ { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } },
+ ]);
+ });
+
+ it("maps a progress field to a ProgressFieldView", () => {
+ const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 }));
+ expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]);
+ });
+
+ it("maps a selector field to a SelectorFieldView", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "selector",
+ label: "Model",
+ value: "gpt-4",
+ options: [
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5", label: "GPT-3.5" },
+ ],
+ action: { actionId: "set-model" },
+ }),
+ );
+ expect(plan.fields).toEqual([
+ {
+ kind: "selector",
+ label: "Model",
+ value: "gpt-4",
+ options: [
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5", label: "GPT-3.5" },
+ ],
+ action: { actionId: "set-model" },
+ },
+ ]);
+ });
+
+ it("maps a stat field to a StatFieldView", () => {
+ const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" }));
+ expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]);
+ });
+
+ it("maps a button field to a ButtonFieldView", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }),
+ );
+ expect(plan.fields).toEqual([
+ { kind: "button", label: "Retry", action: { actionId: "retry" } },
+ ]);
+ });
+
+ it("preserves field order", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "stat", label: "A", value: "1" },
+ { kind: "toggle", label: "B", value: false, action: { actionId: "b" } },
+ { kind: "progress", label: "C", value: 0.5 },
+ { kind: "button", label: "D", action: { actionId: "d" } },
+ ),
+ );
+ expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]);
+ });
+
+ it("drops unknown field kinds gracefully", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "stat", label: "Known", value: "ok" }, {
+ kind: "future-kind" as "stat",
+ label: "Unknown",
+ value: "?",
+ } as SurfaceField),
+ );
+ expect(plan.fields).toHaveLength(1);
+ expect(plan.fields[0]?.label).toBe("Known");
+ });
+
+ it("drops custom fields (no renderer registered)", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "stat", label: "Before", value: "1" },
+ { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } },
+ { kind: "stat", label: "After", value: "2" },
+ ),
+ );
+ expect(plan.fields).toHaveLength(2);
+ expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]);
+ });
+
+ it("returns empty fields for an empty spec", () => {
+ const plan = planSurface(makeSpec());
+ expect(plan.fields).toEqual([]);
+ });
+
+ it("drops all fields when all are custom", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "custom", rendererId: "x", payload: null },
+ { kind: "custom", rendererId: "y", payload: 42 },
+ ),
+ );
+ expect(plan.fields).toEqual([]);
+ });
+});
+
+describe("buildInvoke", () => {
+ it("builds an invoke message for a toggle field", () => {
+ const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } };
+ const msg = buildInvoke("s1", field, true);
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true });
+ });
+
+ it("builds an invoke message for a selector field", () => {
+ const field = {
+ kind: "selector" as const,
+ label: "S",
+ value: "a",
+ options: [],
+ action: { actionId: "sel" },
+ };
+ const msg = buildInvoke("s1", field, "b");
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" });
+ });
+
+ it("builds an invoke message without payload for a button field", () => {
+ const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
+ const msg = buildInvoke("s1", field);
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" });
+ });
+
+ it("omits payload key when value is undefined", () => {
+ const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
+ const msg = buildInvoke("s1", field, undefined);
+ expect(msg).not.toHaveProperty("payload");
+ });
+
+ it("uses the field's actionId, not a surface-level id", () => {
+ const field = {
+ kind: "toggle" as const,
+ label: "X",
+ value: true,
+ action: { actionId: "custom-action-123" },
+ };
+ const msg = buildInvoke("surf", field, false);
+ expect(msg.actionId).toBe("custom-action-123");
+ });
+});
diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts
new file mode 100644
index 0000000..5b4530b
--- /dev/null
+++ b/src/features/surface-host/logic/plan.ts
@@ -0,0 +1,74 @@
+import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { FieldView, SurfaceRenderPlan } from "./types";
+
+const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]);
+
+/**
+ * Validate and normalise a SurfaceSpec into a renderable plan.
+ * Keeps known field kinds in order; drops unknown kinds and `custom` fields
+ * (no renderer registry yet — graceful skip, never throw).
+ */
+export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
+ const fields: FieldView[] = [];
+ for (const field of spec.fields) {
+ if (!KNOWN_KINDS.has(field.kind)) continue;
+ switch (field.kind) {
+ case "toggle":
+ fields.push({
+ kind: "toggle",
+ label: field.label,
+ value: field.value,
+ action: field.action,
+ });
+ break;
+ case "progress":
+ fields.push({
+ kind: "progress",
+ label: field.label,
+ value: field.value,
+ });
+ break;
+ case "selector":
+ fields.push({
+ kind: "selector",
+ label: field.label,
+ value: field.value,
+ options: field.options,
+ action: field.action,
+ });
+ break;
+ case "stat":
+ fields.push({
+ kind: "stat",
+ label: field.label,
+ value: field.value,
+ });
+ break;
+ case "button":
+ fields.push({
+ kind: "button",
+ label: field.label,
+ action: field.action,
+ });
+ break;
+ }
+ }
+ return { fields };
+}
+
+/**
+ * Construct a typed `invoke` client message for an actionable field.
+ * For toggle the payload is the new boolean; for selector the chosen value;
+ * for button the payload is omitted.
+ */
+export function buildInvoke(
+ surfaceId: string,
+ field: Extract<FieldView, { action: unknown }>,
+ value?: unknown,
+): InvokeMessage {
+ const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId };
+ if (value !== undefined) {
+ return { ...base, payload: value };
+ }
+ return base;
+}
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
new file mode 100644
index 0000000..f24438a
--- /dev/null
+++ b/src/features/surface-host/logic/types.ts
@@ -0,0 +1,52 @@
+import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract";
+
+/** Normalised view-model for a toggle field. */
+export interface ToggleFieldView {
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
+}
+
+/** Normalised view-model for a progress field. */
+export interface ProgressFieldView {
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
+}
+
+/** Normalised view-model for a selector field. */
+export interface SelectorFieldView {
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
+}
+
+/** Normalised view-model for a stat field. */
+export interface StatFieldView {
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
+}
+
+/** Normalised view-model for a button field. */
+export interface ButtonFieldView {
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
+}
+
+/** A normalised field view-model — one entry per renderable field kind. */
+export type FieldView =
+ | ToggleFieldView
+ | ProgressFieldView
+ | SelectorFieldView
+ | StatFieldView
+ | ButtonFieldView;
+
+/** The output of `planSurface`: the ordered list of renderable fields. */
+export interface SurfaceRenderPlan {
+ readonly fields: readonly FieldView[];
+}
diff --git a/src/features/surface-host/ui/Button.svelte b/src/features/surface-host/ui/Button.svelte
new file mode 100644
index 0000000..62d7acf
--- /dev/null
+++ b/src/features/surface-host/ui/Button.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { ButtonFieldView } from "../logic/types";
+
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
+
+ function handleClick() {
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ });
+ }
+</script>
+
+<button onclick={handleClick}>{field.label}</button>
diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte
new file mode 100644
index 0000000..cba9e0f
--- /dev/null
+++ b/src/features/surface-host/ui/Progress.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import type { ProgressFieldView } from "../logic/types";
+
+ let { field }: { field: ProgressFieldView } = $props();
+
+ const percent = $derived(Math.round(field.value * 100));
+</script>
+
+<div>
+ <span>{field.label}</span>
+ <progress max="100" value={percent}>{percent}%</progress>
+ <span>{percent}%</span>
+</div>
diff --git a/src/features/surface-host/ui/Selector.svelte b/src/features/surface-host/ui/Selector.svelte
new file mode 100644
index 0000000..2da104f
--- /dev/null
+++ b/src/features/surface-host/ui/Selector.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { SelectorFieldView } from "../logic/types";
+
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
+
+ function handleChange(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: target.value,
+ });
+ }
+</script>
+
+<label>
+ {field.label}
+ <select onchange={handleChange}>
+ {#each field.options as option (option.value)}
+ <option value={option.value} selected={option.value === field.value}>
+ {option.label}
+ </option>
+ {/each}
+ </select>
+</label>
diff --git a/src/features/surface-host/ui/Stat.svelte b/src/features/surface-host/ui/Stat.svelte
new file mode 100644
index 0000000..e184dab
--- /dev/null
+++ b/src/features/surface-host/ui/Stat.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import type { StatFieldView } from "../logic/types";
+
+ let { field }: { field: StatFieldView } = $props();
+</script>
+
+<dl>
+ <dt>{field.label}</dt>
+ <dd>{field.value}</dd>
+</dl>
diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte
new file mode 100644
index 0000000..4207913
--- /dev/null
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+ import { planSurface } from "../logic/plan";
+ import Button from "./Button.svelte";
+ import Progress from "./Progress.svelte";
+ import Selector from "./Selector.svelte";
+ import Stat from "./Stat.svelte";
+ import Toggle from "./Toggle.svelte";
+
+ let {
+ spec,
+ onInvoke,
+ }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
+
+ const plan = $derived(planSurface(spec));
+</script>
+
+<article>
+ <h2>{spec.title}</h2>
+ {#each plan.fields as field (field)}
+ {#if field.kind === "toggle"}
+ <Toggle {field} surfaceId={spec.id} {onInvoke} />
+ {:else if field.kind === "progress"}
+ <Progress {field} />
+ {:else if field.kind === "selector"}
+ <Selector {field} surfaceId={spec.id} {onInvoke} />
+ {:else if field.kind === "stat"}
+ <Stat {field} />
+ {:else if field.kind === "button"}
+ <Button {field} surfaceId={spec.id} {onInvoke} />
+ {/if}
+ {/each}
+</article>
diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte
new file mode 100644
index 0000000..aec8f4e
--- /dev/null
+++ b/src/features/surface-host/ui/Toggle.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { ToggleFieldView } from "../logic/types";
+
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
+
+ function handleChange() {
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: !field.value,
+ });
+ }
+</script>
+
+<label>
+ <input type="checkbox" checked={field.value} onchange={handleChange} />
+ {field.label}
+</label>
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..f58cfe2
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,9 @@
+import { mount } from "svelte";
+import App from "./App.svelte";
+
+const target = document.getElementById("app");
+if (!target) {
+ throw new Error("missing #app mount target");
+}
+
+export default mount(App, { target });
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..4078e74
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+/// <reference types="svelte" />
+/// <reference types="vite/client" />
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..f77d881
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+export default {
+ preprocess: vitePreprocess(),
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..02d6f89
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["vite/client"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "exactOptionalPropertyTypes": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.d.ts", "vitest-setup.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..fab62a0
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,23 @@
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import { defineConfig } from "vitest/config";
+
+// Dev server on the reserved FRONTEND_PORT (24204). Vitest config lives here too
+// (jsdom + globals) so component tests run without extra config.
+export default defineConfig({
+ plugins: [svelte()],
+ // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN /
+ // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true`
+ // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set
+ // VITE_WS_URL to the backend's reachable host when browsing from another device.)
+ server: { port: 24204, host: true, allowedHosts: true },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest-setup.ts"],
+ // Svelte 5's exports map resolves `svelte` → server build under the default
+ // condition; force the browser build so component tests can mount().
+ resolve: {
+ conditions: ["browser"],
+ },
+ },
+});
diff --git a/vitest-setup.ts b/vitest-setup.ts
new file mode 100644
index 0000000..f149f27
--- /dev/null
+++ b/vitest-setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";