diff options
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"; |
