summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte7
-rw-r--r--src/adapters/ws/index.test.ts234
-rw-r--r--src/adapters/ws/index.ts98
-rw-r--r--src/adapters/ws/logic.test.ts195
-rw-r--r--src/adapters/ws/logic.ts91
-rw-r--r--src/app/App.svelte53
-rw-r--r--src/app/index.ts3
-rw-r--r--src/app/resolve-ws-url.test.ts53
-rw-r--r--src/app/resolve-ws-url.ts27
-rw-r--r--src/app/store.svelte.ts103
-rw-r--r--src/app/store.test.ts220
-rw-r--r--src/core/protocol/index.ts2
-rw-r--r--src/core/protocol/reducer.test.ts151
-rw-r--r--src/core/protocol/reducer.ts82
-rw-r--r--src/core/protocol/types.ts22
-rw-r--r--src/features/surface-host/index.ts3
-rw-r--r--src/features/surface-host/logic/plan.test.ts161
-rw-r--r--src/features/surface-host/logic/plan.ts74
-rw-r--r--src/features/surface-host/logic/types.ts52
-rw-r--r--src/features/surface-host/ui/Button.svelte21
-rw-r--r--src/features/surface-host/ui/Progress.svelte13
-rw-r--r--src/features/surface-host/ui/Selector.svelte32
-rw-r--r--src/features/surface-host/ui/Stat.svelte10
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte33
-rw-r--r--src/features/surface-host/ui/Toggle.svelte25
-rw-r--r--src/main.ts9
-rw-r--r--src/vite-env.d.ts2
27 files changed, 1776 insertions, 0 deletions
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" />