summaryrefslogtreecommitdiffhomepage
path: root/src/features/surface-host
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/surface-host')
-rw-r--r--src/features/surface-host/index.ts4
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts72
-rw-r--r--src/features/surface-host/logic/message-queue.ts38
-rw-r--r--src/features/surface-host/logic/plan.test.ts468
-rw-r--r--src/features/surface-host/logic/plan.ts210
-rw-r--r--src/features/surface-host/logic/table.test.ts78
-rw-r--r--src/features/surface-host/logic/table.ts66
-rw-r--r--src/features/surface-host/logic/todo.test.ts110
-rw-r--r--src/features/surface-host/logic/todo.ts32
-rw-r--r--src/features/surface-host/logic/types.ts78
-rw-r--r--src/features/surface-host/ui/Button.svelte30
-rw-r--r--src/features/surface-host/ui/MessageQueueList.svelte30
-rw-r--r--src/features/surface-host/ui/Number.svelte72
-rw-r--r--src/features/surface-host/ui/Progress.svelte12
-rw-r--r--src/features/surface-host/ui/Selector.svelte50
-rw-r--r--src/features/surface-host/ui/StatTable.svelte30
-rw-r--r--src/features/surface-host/ui/SurfaceTable.svelte14
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte87
-rw-r--r--src/features/surface-host/ui/TodoList.svelte106
-rw-r--r--src/features/surface-host/ui/Toggle.svelte36
20 files changed, 810 insertions, 813 deletions
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index 8f289f1..1e21e3d 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -4,6 +4,6 @@ export { default as SurfaceView } from "./ui/SurfaceView.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "surface-host",
- description: "Generic renderer for backend-declared surfaces",
+ name: "surface-host",
+ description: "Generic renderer for backend-declared surfaces",
} as const;
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index ce078d9..8d55eb7 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -3,46 +3,46 @@ import { describe, expect, it } from "vitest";
import { parseMessageQueuePayload } from "./message-queue";
const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({
- id,
- text,
- queuedAt,
+ id,
+ text,
+ queuedAt,
});
describe("parseMessageQueuePayload", () => {
- it("parses a well-formed payload with messages", () => {
- const data = parseMessageQueuePayload({
- messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
- });
- expect(data).toEqual({
- messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
- });
- });
+ it("parses a well-formed payload with messages", () => {
+ const data = parseMessageQueuePayload({
+ messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
+ });
+ expect(data).toEqual({
+ messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
+ });
+ });
- it("parses an empty-messages payload (queue is empty)", () => {
- expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] });
- });
+ it("parses an empty-messages payload (queue is empty)", () => {
+ expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] });
+ });
- it("preserves message order", () => {
- const data = parseMessageQueuePayload({
- messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")],
- });
- expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]);
- });
+ it("preserves message order", () => {
+ const data = parseMessageQueuePayload({
+ messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")],
+ });
+ expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]);
+ });
- it.each([
- ["null", null],
- ["a number", 7],
- ["a string", "nope"],
- ["missing messages key", { foo: [] }],
- ["messages not an array", { messages: "x" }],
- ["entry not an object", { messages: ["x"] }],
- ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }],
- ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }],
- ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }],
- ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }],
- ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }],
- ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }],
- ])("returns null for invalid payload: %s", (_label, payload) => {
- expect(parseMessageQueuePayload(payload)).toBeNull();
- });
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing messages key", { foo: [] }],
+ ["messages not an array", { messages: "x" }],
+ ["entry not an object", { messages: ["x"] }],
+ ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }],
+ ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }],
+ ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }],
+ ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }],
+ ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }],
+ ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseMessageQueuePayload(payload)).toBeNull();
+ });
});
diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts
index a8e1567..79707a5 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -14,31 +14,31 @@ import type { QueuedMessage } from "@dispatch/wire";
* payload shape.
*/
export interface MessageQueueData {
- readonly messages: readonly QueuedMessage[];
+ readonly messages: readonly QueuedMessage[];
}
function isQueuedMessage(v: unknown): v is QueuedMessage {
- if (typeof v !== "object" || v === null) return false;
- const o = v as Record<string, unknown>;
- return (
- typeof o.id === "string" &&
- typeof o.text === "string" &&
- typeof o.queuedAt === "number" &&
- Number.isFinite(o.queuedAt)
- );
+ if (typeof v !== "object" || v === null) return false;
+ const o = v as Record<string, unknown>;
+ return (
+ typeof o.id === "string" &&
+ typeof o.text === "string" &&
+ typeof o.queuedAt === "number" &&
+ Number.isFinite(o.queuedAt)
+ );
}
export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null {
- if (typeof payload !== "object" || payload === null) return null;
- const obj = payload as Record<string, unknown>;
- const raw = obj.messages;
- if (!Array.isArray(raw)) return null;
- const messages: QueuedMessage[] = [];
- for (const entry of raw) {
- if (!isQueuedMessage(entry)) return null;
- messages.push(entry);
- }
- return { messages };
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+ const raw = obj.messages;
+ if (!Array.isArray(raw)) return null;
+ const messages: QueuedMessage[] = [];
+ for (const entry of raw) {
+ if (!isQueuedMessage(entry)) return null;
+ messages.push(entry);
+ }
+ return { messages };
}
/** The `rendererId` the message-queue extension's `custom` surface field uses. */
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
index be296a7..9c8a34d 100644
--- a/src/features/surface-host/logic/plan.test.ts
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -4,247 +4,247 @@ import { buildInvoke, groupRenderFields, planSurface } from "./plan";
import type { FieldView } from "./types";
const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({
- id: "test-surface",
- region: "test",
- title: "Test Surface",
- fields,
+ 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 number field to a NumberFieldView, carrying optional hints", () => {
- const plan = planSurface(
- makeSpec({
- kind: "number",
- label: "Interval",
- value: 240,
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- }),
- );
- expect(plan.fields).toEqual([
- {
- kind: "number",
- label: "Interval",
- value: 240,
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- },
- ]);
- });
-
- it("omits absent number hints (no max key when undefined)", () => {
- const plan = planSurface(
- makeSpec({
- kind: "number",
- label: "Interval",
- value: 240,
- min: 1,
- action: { actionId: "set" },
- }),
- );
- const field = plan.fields[0];
- expect(field).not.toHaveProperty("max");
- expect(field).not.toHaveProperty("step");
- expect(field).not.toHaveProperty("unit");
- });
-
- 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) => ("label" in f ? f.label : null))).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);
- const first = plan.fields[0];
- expect(first && "label" in first ? first.label : null).toBe("Known");
- });
-
- it("carries custom fields through verbatim, preserving order", () => {
- 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(3);
- expect(plan.fields[1]).toEqual({
- kind: "custom",
- rendererId: "chart",
- payload: { data: [1, 2, 3] },
- });
- });
-
- it("returns empty fields for an empty spec", () => {
- const plan = planSurface(makeSpec());
- expect(plan.fields).toEqual([]);
- });
-
- it("keeps every custom field (render-time decides whether to show each)", () => {
- const plan = planSurface(
- makeSpec(
- { kind: "custom", rendererId: "x", payload: null },
- { kind: "custom", rendererId: "y", payload: 42 },
- ),
- );
- expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]);
- });
+ 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 number field to a NumberFieldView, carrying optional hints", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ }),
+ );
+ expect(plan.fields).toEqual([
+ {
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ },
+ ]);
+ });
+
+ it("omits absent number hints (no max key when undefined)", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ min: 1,
+ action: { actionId: "set" },
+ }),
+ );
+ const field = plan.fields[0];
+ expect(field).not.toHaveProperty("max");
+ expect(field).not.toHaveProperty("step");
+ expect(field).not.toHaveProperty("unit");
+ });
+
+ 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) => ("label" in f ? f.label : null))).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);
+ const first = plan.fields[0];
+ expect(first && "label" in first ? first.label : null).toBe("Known");
+ });
+
+ it("carries custom fields through verbatim, preserving order", () => {
+ 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(3);
+ expect(plan.fields[1]).toEqual({
+ kind: "custom",
+ rendererId: "chart",
+ payload: { data: [1, 2, 3] },
+ });
+ });
+
+ it("returns empty fields for an empty spec", () => {
+ const plan = planSurface(makeSpec());
+ expect(plan.fields).toEqual([]);
+ });
+
+ it("keeps every custom field (render-time decides whether to show each)", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "custom", rendererId: "x", payload: null },
+ { kind: "custom", rendererId: "y", payload: 42 },
+ ),
+ );
+ expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]);
+ });
});
describe("groupRenderFields", () => {
- const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value });
- const toggle = (label: string): FieldView => ({
- kind: "toggle",
- label,
- value: false,
- action: { actionId: label },
- });
-
- it("coalesces consecutive stats into a single stats group", () => {
- const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]);
- expect(groups).toHaveLength(1);
- expect(groups[0]).toEqual({
- type: "stats",
- stats: [
- { kind: "stat", label: "a", value: "1" },
- { kind: "stat", label: "b", value: "2" },
- { kind: "stat", label: "c", value: "3" },
- ],
- });
- });
-
- it("keeps non-stat fields as standalone groups and preserves order", () => {
- const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]);
- expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
- const first = groups[0];
- const last = groups[2];
- if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping");
- expect(first.stats.map((s) => s.label)).toEqual(["a"]);
- expect(last.stats.map((s) => s.label)).toEqual(["b"]);
- });
-
- it("starts a new stats run after an interrupting field", () => {
- const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]);
- expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
- });
-
- it("returns no groups for an empty field list", () => {
- expect(groupRenderFields([])).toEqual([]);
- });
+ const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value });
+ const toggle = (label: string): FieldView => ({
+ kind: "toggle",
+ label,
+ value: false,
+ action: { actionId: label },
+ });
+
+ it("coalesces consecutive stats into a single stats group", () => {
+ const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]).toEqual({
+ type: "stats",
+ stats: [
+ { kind: "stat", label: "a", value: "1" },
+ { kind: "stat", label: "b", value: "2" },
+ { kind: "stat", label: "c", value: "3" },
+ ],
+ });
+ });
+
+ it("keeps non-stat fields as standalone groups and preserves order", () => {
+ const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]);
+ expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
+ const first = groups[0];
+ const last = groups[2];
+ if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping");
+ expect(first.stats.map((s) => s.label)).toEqual(["a"]);
+ expect(last.stats.map((s) => s.label)).toEqual(["b"]);
+ });
+
+ it("starts a new stats run after an interrupting field", () => {
+ const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]);
+ expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
+ });
+
+ it("returns no groups for an empty field list", () => {
+ expect(groupRenderFields([])).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");
- });
+ 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
index 89088c3..c8f82b9 100644
--- a/src/features/surface-host/logic/plan.ts
+++ b/src/features/surface-host/logic/plan.ts
@@ -1,20 +1,20 @@
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
import type {
- FieldView,
- NumberFieldView,
- RenderGroup,
- StatFieldView,
- SurfaceRenderPlan,
+ FieldView,
+ NumberFieldView,
+ RenderGroup,
+ StatFieldView,
+ SurfaceRenderPlan,
} from "./types";
const KNOWN_KINDS = new Set([
- "toggle",
- "progress",
- "selector",
- "stat",
- "number",
- "button",
- "custom",
+ "toggle",
+ "progress",
+ "selector",
+ "stat",
+ "number",
+ "button",
+ "custom",
]);
/**
@@ -25,73 +25,73 @@ const KNOWN_KINDS = new Set([
* decision (unknown `rendererId` → skipped there), not a planning one.
*/
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 "number": {
- // Carry optional hints only when present (exactOptionalPropertyTypes).
- const view: NumberFieldView = {
- kind: "number",
- label: field.label,
- value: field.value,
- action: field.action,
- ...(field.min !== undefined ? { min: field.min } : {}),
- ...(field.max !== undefined ? { max: field.max } : {}),
- ...(field.step !== undefined ? { step: field.step } : {}),
- ...(field.unit !== undefined ? { unit: field.unit } : {}),
- };
- fields.push(view);
- break;
- }
- case "button":
- fields.push({
- kind: "button",
- label: field.label,
- action: field.action,
- });
- break;
- case "custom":
- fields.push({
- kind: "custom",
- rendererId: field.rendererId,
- payload: field.payload,
- });
- break;
- }
- }
- return { fields };
+ 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 "number": {
+ // Carry optional hints only when present (exactOptionalPropertyTypes).
+ const view: NumberFieldView = {
+ kind: "number",
+ label: field.label,
+ value: field.value,
+ action: field.action,
+ ...(field.min !== undefined ? { min: field.min } : {}),
+ ...(field.max !== undefined ? { max: field.max } : {}),
+ ...(field.step !== undefined ? { step: field.step } : {}),
+ ...(field.unit !== undefined ? { unit: field.unit } : {}),
+ };
+ fields.push(view);
+ break;
+ }
+ case "button":
+ fields.push({
+ kind: "button",
+ label: field.label,
+ action: field.action,
+ });
+ break;
+ case "custom":
+ fields.push({
+ kind: "custom",
+ rendererId: field.rendererId,
+ payload: field.payload,
+ });
+ break;
+ }
+ }
+ return { fields };
}
/**
@@ -100,24 +100,24 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
* other field stays a standalone `field` group. Order is preserved. Pure.
*/
export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] {
- const groups: RenderGroup[] = [];
- let run: StatFieldView[] = [];
- const flush = (): void => {
- if (run.length > 0) {
- groups.push({ type: "stats", stats: run });
- run = [];
- }
- };
- for (const field of fields) {
- if (field.kind === "stat") {
- run.push(field);
- } else {
- flush();
- groups.push({ type: "field", field });
- }
- }
- flush();
- return groups;
+ const groups: RenderGroup[] = [];
+ let run: StatFieldView[] = [];
+ const flush = (): void => {
+ if (run.length > 0) {
+ groups.push({ type: "stats", stats: run });
+ run = [];
+ }
+ };
+ for (const field of fields) {
+ if (field.kind === "stat") {
+ run.push(field);
+ } else {
+ flush();
+ groups.push({ type: "field", field });
+ }
+ }
+ flush();
+ return groups;
}
/**
@@ -126,13 +126,13 @@ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] {
* for button the payload is omitted.
*/
export function buildInvoke(
- surfaceId: string,
- field: Extract<FieldView, { action: unknown }>,
- value?: unknown,
+ 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;
+ 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/table.test.ts b/src/features/surface-host/logic/table.test.ts
index e55b3f7..6fb558a 100644
--- a/src/features/surface-host/logic/table.test.ts
+++ b/src/features/surface-host/logic/table.test.ts
@@ -2,46 +2,46 @@ import { describe, expect, it } from "vitest";
import { parseTablePayload } from "./table";
describe("parseTablePayload", () => {
- it("parses a well-formed table payload", () => {
- const data = parseTablePayload({
- columns: ["Name", "Version"],
- rows: [
- ["alpha", "1.0"],
- ["beta", "2.3"],
- ],
- });
- expect(data).toEqual({
- columns: ["Name", "Version"],
- rows: [
- ["alpha", "1.0"],
- ["beta", "2.3"],
- ],
- });
- });
+ it("parses a well-formed table payload", () => {
+ const data = parseTablePayload({
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ });
+ expect(data).toEqual({
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ });
+ });
- it("coerces numeric and boolean cells to strings", () => {
- const data = parseTablePayload({
- columns: ["k", "n", "b"],
- rows: [["x", 42, true]],
- });
- expect(data?.rows[0]).toEqual(["x", "42", "true"]);
- });
+ it("coerces numeric and boolean cells to strings", () => {
+ const data = parseTablePayload({
+ columns: ["k", "n", "b"],
+ rows: [["x", 42, true]],
+ });
+ expect(data?.rows[0]).toEqual(["x", "42", "true"]);
+ });
- it("accepts an empty rows array", () => {
- expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] });
- });
+ it("accepts an empty rows array", () => {
+ expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] });
+ });
- it.each([
- ["null", null],
- ["a number", 7],
- ["a string", "nope"],
- ["missing columns", { rows: [] }],
- ["missing rows", { columns: ["A"] }],
- ["non-string column", { columns: [1], rows: [] }],
- ["row that is not an array", { columns: ["A"], rows: ["x"] }],
- ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }],
- ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }],
- ])("returns null for invalid payload: %s", (_label, payload) => {
- expect(parseTablePayload(payload)).toBeNull();
- });
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing columns", { rows: [] }],
+ ["missing rows", { columns: ["A"] }],
+ ["non-string column", { columns: [1], rows: [] }],
+ ["row that is not an array", { columns: ["A"], rows: ["x"] }],
+ ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }],
+ ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseTablePayload(payload)).toBeNull();
+ });
});
diff --git a/src/features/surface-host/logic/table.ts b/src/features/surface-host/logic/table.ts
index 027553c..5d2b831 100644
--- a/src/features/surface-host/logic/table.ts
+++ b/src/features/surface-host/logic/table.ts
@@ -9,46 +9,46 @@
*/
export interface TableData {
- readonly columns: readonly string[];
- readonly rows: readonly (readonly string[])[];
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
}
function isStringArray(v: unknown): v is unknown[] {
- return Array.isArray(v);
+ return Array.isArray(v);
}
function coerceCell(v: unknown): string | null {
- if (typeof v === "string") return v;
- if (typeof v === "number" && Number.isFinite(v)) return String(v);
- if (typeof v === "boolean") return String(v);
- return null;
+ if (typeof v === "string") return v;
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
+ if (typeof v === "boolean") return String(v);
+ return null;
}
export function parseTablePayload(payload: unknown): TableData | null {
- if (typeof payload !== "object" || payload === null) return null;
- const obj = payload as Record<string, unknown>;
-
- const rawColumns = obj.columns;
- const rawRows = obj.rows;
- if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null;
-
- const columns: string[] = [];
- for (const col of rawColumns) {
- if (typeof col !== "string") return null;
- columns.push(col);
- }
-
- const rows: string[][] = [];
- for (const row of rawRows) {
- if (!Array.isArray(row)) return null;
- const cells: string[] = [];
- for (const cell of row) {
- const c = coerceCell(cell);
- if (c === null) return null;
- cells.push(c);
- }
- rows.push(cells);
- }
-
- return { columns, rows };
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+
+ const rawColumns = obj.columns;
+ const rawRows = obj.rows;
+ if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null;
+
+ const columns: string[] = [];
+ for (const col of rawColumns) {
+ if (typeof col !== "string") return null;
+ columns.push(col);
+ }
+
+ const rows: string[][] = [];
+ for (const row of rawRows) {
+ if (!Array.isArray(row)) return null;
+ const cells: string[] = [];
+ for (const cell of row) {
+ const c = coerceCell(cell);
+ if (c === null) return null;
+ cells.push(c);
+ }
+ rows.push(cells);
+ }
+
+ return { columns, rows };
}
diff --git a/src/features/surface-host/logic/todo.test.ts b/src/features/surface-host/logic/todo.test.ts
index 225ecde..66ff036 100644
--- a/src/features/surface-host/logic/todo.test.ts
+++ b/src/features/surface-host/logic/todo.test.ts
@@ -2,66 +2,66 @@ import { describe, expect, it } from "vitest";
import { parseTodoPayload, type TodoItem } from "./todo";
const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({
- content,
- status,
+ content,
+ status,
});
describe("parseTodoPayload", () => {
- it("parses a well-formed payload with items", () => {
- const data = parseTodoPayload({
- todos: [
- item("Write tests", "in_progress"),
- item("Ship it", "pending"),
- item("Read docs", "completed"),
- ],
- });
- expect(data).toEqual({
- todos: [
- item("Write tests", "in_progress"),
- item("Ship it", "pending"),
- item("Read docs", "completed"),
- ],
- });
- });
+ it("parses a well-formed payload with items", () => {
+ const data = parseTodoPayload({
+ todos: [
+ item("Write tests", "in_progress"),
+ item("Ship it", "pending"),
+ item("Read docs", "completed"),
+ ],
+ });
+ expect(data).toEqual({
+ todos: [
+ item("Write tests", "in_progress"),
+ item("Ship it", "pending"),
+ item("Read docs", "completed"),
+ ],
+ });
+ });
- it("parses an empty-todos payload", () => {
- expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] });
- });
+ it("parses an empty-todos payload", () => {
+ expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] });
+ });
- it("preserves item order", () => {
- const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] });
- expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]);
- });
+ it("preserves item order", () => {
+ const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] });
+ expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]);
+ });
- it("accepts all four status values", () => {
- const data = parseTodoPayload({
- todos: [
- item("p", "pending"),
- item("i", "in_progress"),
- item("c", "completed"),
- item("x", "cancelled"),
- ],
- });
- expect(data?.todos.map((t) => t.status)).toEqual([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
- ]);
- });
+ it("accepts all four status values", () => {
+ const data = parseTodoPayload({
+ todos: [
+ item("p", "pending"),
+ item("i", "in_progress"),
+ item("c", "completed"),
+ item("x", "cancelled"),
+ ],
+ });
+ expect(data?.todos.map((t) => t.status)).toEqual([
+ "pending",
+ "in_progress",
+ "completed",
+ "cancelled",
+ ]);
+ });
- it.each([
- ["null", null],
- ["a number", 7],
- ["a string", "nope"],
- ["missing todos key", { foo: [] }],
- ["todos not an array", { todos: "x" }],
- ["entry not an object", { todos: ["x"] }],
- ["entry missing content", { todos: [{ status: "pending" }] }],
- ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }],
- ["entry missing status", { todos: [{ content: "x" }] }],
- ["entry with invalid status", { todos: [item("x", "done" as never)] }],
- ])("returns null for invalid payload: %s", (_label, payload) => {
- expect(parseTodoPayload(payload)).toBeNull();
- });
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing todos key", { foo: [] }],
+ ["todos not an array", { todos: "x" }],
+ ["entry not an object", { todos: ["x"] }],
+ ["entry missing content", { todos: [{ status: "pending" }] }],
+ ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }],
+ ["entry missing status", { todos: [{ content: "x" }] }],
+ ["entry with invalid status", { todos: [item("x", "done" as never)] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseTodoPayload(payload)).toBeNull();
+ });
});
diff --git a/src/features/surface-host/logic/todo.ts b/src/features/surface-host/logic/todo.ts
index e442e78..8b8a5ef 100644
--- a/src/features/surface-host/logic/todo.ts
+++ b/src/features/surface-host/logic/todo.ts
@@ -16,33 +16,33 @@
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
export interface TodoItem {
- readonly content: string;
- readonly status: TodoStatus;
+ readonly content: string;
+ readonly status: TodoStatus;
}
export interface TodoData {
- readonly todos: readonly TodoItem[];
+ readonly todos: readonly TodoItem[];
}
const STATUSES = new Set<string>(["pending", "in_progress", "completed", "cancelled"]);
function isTodoItem(v: unknown): v is TodoItem {
- if (typeof v !== "object" || v === null) return false;
- const o = v as Record<string, unknown>;
- return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status);
+ if (typeof v !== "object" || v === null) return false;
+ const o = v as Record<string, unknown>;
+ return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status);
}
export function parseTodoPayload(payload: unknown): TodoData | null {
- if (typeof payload !== "object" || payload === null) return null;
- const obj = payload as Record<string, unknown>;
- const raw = obj.todos;
- if (!Array.isArray(raw)) return null;
- const todos: TodoItem[] = [];
- for (const entry of raw) {
- if (!isTodoItem(entry)) return null;
- todos.push(entry);
- }
- return { todos };
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+ const raw = obj.todos;
+ if (!Array.isArray(raw)) return null;
+ const todos: TodoItem[] = [];
+ for (const entry of raw) {
+ if (!isTodoItem(entry)) return null;
+ todos.push(entry);
+ }
+ return { todos };
}
/** The `rendererId` the `todo` extension's `custom` surface field uses. */
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
index 23f8757..11c222f 100644
--- a/src/features/surface-host/logic/types.ts
+++ b/src/features/surface-host/logic/types.ts
@@ -2,33 +2,33 @@ 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;
+ 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;
+ 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;
+ 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;
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
}
/**
@@ -37,21 +37,21 @@ export interface StatFieldView {
* the spec omits them). The renderer posts the new number as the action payload.
*/
export interface NumberFieldView {
- readonly kind: "number";
- readonly label: string;
- readonly value: number;
- readonly min?: number;
- readonly max?: number;
- readonly step?: number;
- readonly unit?: string;
- readonly action: ActionRef;
+ readonly kind: "number";
+ readonly label: string;
+ readonly value: number;
+ readonly min?: number;
+ readonly max?: number;
+ readonly step?: number;
+ readonly unit?: string;
+ readonly action: ActionRef;
}
/** Normalised view-model for a button field. */
export interface ButtonFieldView {
- readonly kind: "button";
- readonly label: string;
- readonly action: ActionRef;
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
}
/**
@@ -60,24 +60,24 @@ export interface ButtonFieldView {
* never a surface id) and gracefully skips ids it has no renderer for.
*/
export interface CustomFieldView {
- readonly kind: "custom";
- readonly rendererId: string;
- readonly payload: unknown;
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
}
/** A normalised field view-model — one entry per renderable field kind. */
export type FieldView =
- | ToggleFieldView
- | ProgressFieldView
- | SelectorFieldView
- | StatFieldView
- | NumberFieldView
- | ButtonFieldView
- | CustomFieldView;
+ | ToggleFieldView
+ | ProgressFieldView
+ | SelectorFieldView
+ | StatFieldView
+ | NumberFieldView
+ | ButtonFieldView
+ | CustomFieldView;
/** The output of `planSurface`: the ordered list of renderable fields. */
export interface SurfaceRenderPlan {
- readonly fields: readonly FieldView[];
+ readonly fields: readonly FieldView[];
}
/**
@@ -86,5 +86,5 @@ export interface SurfaceRenderPlan {
* GENERIC presentation rule keyed on field kind — it never inspects a surface id.
*/
export type RenderGroup =
- | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
- | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };
+ | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
+ | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };
diff --git a/src/features/surface-host/ui/Button.svelte b/src/features/surface-host/ui/Button.svelte
index 62d7acf..ee9097c 100644
--- a/src/features/surface-host/ui/Button.svelte
+++ b/src/features/surface-host/ui/Button.svelte
@@ -1,21 +1,21 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { ButtonFieldView } from "../logic/types";
+ 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();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleClick() {
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- });
- }
+ 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/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte
index 12de970..554fa02 100644
--- a/src/features/surface-host/ui/MessageQueueList.svelte
+++ b/src/features/surface-host/ui/MessageQueueList.svelte
@@ -1,22 +1,22 @@
<script lang="ts">
- import { parseMessageQueuePayload } from "../logic/message-queue";
+ import { parseMessageQueuePayload } from "../logic/message-queue";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- // Parse defensively; an unparseable payload yields null → render nothing
- // (graceful skip, per the custom-field contract).
- const data = $derived(parseMessageQueuePayload(payload));
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseMessageQueuePayload(payload));
</script>
{#if data !== null && data.messages.length > 0}
- <ul class="flex flex-col gap-1 text-sm">
- {#each data.messages as msg (msg.id)}
- <li class="rounded-box bg-base-200 px-3 py-2">
- <p class="whitespace-pre-wrap">{msg.text}</p>
- <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
- {new Date(msg.queuedAt).toLocaleTimeString()}
- </time>
- </li>
- {/each}
- </ul>
+ <ul class="flex flex-col gap-1 text-sm">
+ {#each data.messages as msg (msg.id)}
+ <li class="rounded-box bg-base-200 px-3 py-2">
+ <p class="whitespace-pre-wrap">{msg.text}</p>
+ <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
+ {new Date(msg.queuedAt).toLocaleTimeString()}
+ </time>
+ </li>
+ {/each}
+ </ul>
{/if}
diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte
index 0f3323d..5a67087 100644
--- a/src/features/surface-host/ui/Number.svelte
+++ b/src/features/surface-host/ui/Number.svelte
@@ -1,43 +1,43 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { NumberFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { NumberFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric
- // input (the backend also floors/validates); send the new number as payload.
- function commit(event: Event) {
- const target = event.target as HTMLInputElement;
- const next = target.valueAsNumber;
- if (Number.isNaN(next)) return;
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: next,
- });
- }
+ // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric
+ // input (the backend also floors/validates); send the new number as payload.
+ function commit(event: Event) {
+ const target = event.target as HTMLInputElement;
+ const next = target.valueAsNumber;
+ if (Number.isNaN(next)) return;
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: next,
+ });
+ }
</script>
<label class="flex items-center justify-between gap-2 text-sm">
- <span>{field.label}</span>
- <span class="flex items-center gap-1">
- <input
- type="number"
- class="input input-bordered input-sm w-24"
- value={field.value}
- min={field.min}
- max={field.max}
- step={field.step}
- onchange={commit}
- />
- {#if field.unit}
- <span class="opacity-60">{field.unit}</span>
- {/if}
- </span>
+ <span>{field.label}</span>
+ <span class="flex items-center gap-1">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ value={field.value}
+ min={field.min}
+ max={field.max}
+ step={field.step}
+ onchange={commit}
+ />
+ {#if field.unit}
+ <span class="opacity-60">{field.unit}</span>
+ {/if}
+ </span>
</label>
diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte
index cba9e0f..e291c79 100644
--- a/src/features/surface-host/ui/Progress.svelte
+++ b/src/features/surface-host/ui/Progress.svelte
@@ -1,13 +1,13 @@
<script lang="ts">
- import type { ProgressFieldView } from "../logic/types";
+ import type { ProgressFieldView } from "../logic/types";
- let { field }: { field: ProgressFieldView } = $props();
+ let { field }: { field: ProgressFieldView } = $props();
- const percent = $derived(Math.round(field.value * 100));
+ const percent = $derived(Math.round(field.value * 100));
</script>
<div>
- <span>{field.label}</span>
- <progress max="100" value={percent}>{percent}%</progress>
- <span>{percent}%</span>
+ <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
index 2da104f..4cb3536 100644
--- a/src/features/surface-host/ui/Selector.svelte
+++ b/src/features/surface-host/ui/Selector.svelte
@@ -1,32 +1,32 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { SelectorFieldView } from "../logic/types";
+ 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();
+ 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,
- });
- }
+ 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>
+ {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/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte
index 415423f..c559352 100644
--- a/src/features/surface-host/ui/StatTable.svelte
+++ b/src/features/surface-host/ui/StatTable.svelte
@@ -1,21 +1,21 @@
<script lang="ts">
- import type { StatFieldView } from "../logic/types";
+ import type { StatFieldView } from "../logic/types";
- // Renders a run of stat fields as one aligned label/value table. Headerless:
- // the column semantics aren't known generically, but the two-column layout
- // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
- let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
+ // Renders a run of stat fields as one aligned label/value table. Headerless:
+ // the column semantics aren't known generically, but the two-column layout
+ // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
+ let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
</script>
<div class="overflow-x-auto">
- <table class="table table-sm">
- <tbody>
- {#each stats as stat, i (i)}
- <tr>
- <th class="font-medium">{stat.label}</th>
- <td class="text-right tabular-nums">{stat.value}</td>
- </tr>
- {/each}
- </tbody>
- </table>
+ <table class="table table-sm">
+ <tbody>
+ {#each stats as stat, i (i)}
+ <tr>
+ <th class="font-medium">{stat.label}</th>
+ <td class="text-right tabular-nums">{stat.value}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
</div>
diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte
index 764cc36..e47c122 100644
--- a/src/features/surface-host/ui/SurfaceTable.svelte
+++ b/src/features/surface-host/ui/SurfaceTable.svelte
@@ -1,14 +1,14 @@
<script lang="ts">
- import Table from "../../../components/Table.svelte";
- import { parseTablePayload } from "../logic/table";
+ import Table from "../../../components/Table.svelte";
+ import { parseTablePayload } from "../logic/table";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- // Parse defensively; an unparseable payload yields null → render nothing
- // (graceful skip, per the custom-field contract).
- const data = $derived(parseTablePayload(payload));
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseTablePayload(payload));
</script>
{#if data !== null}
- <Table columns={data.columns} rows={data.rows} />
+ <Table columns={data.columns} rows={data.rows} />
{/if}
diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte
index 3f92e3b..aed8d03 100644
--- a/src/features/surface-host/ui/SurfaceView.svelte
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -1,52 +1,49 @@
<script lang="ts">
- import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { groupRenderFields, planSurface } from "../logic/plan";
- import Button from "./Button.svelte";
- import MessageQueueList from "./MessageQueueList.svelte";
- import Number from "./Number.svelte";
- import Progress from "./Progress.svelte";
- import Selector from "./Selector.svelte";
- import StatTable from "./StatTable.svelte";
- import SurfaceTable from "./SurfaceTable.svelte";
- import TodoList from "./TodoList.svelte";
- import Toggle from "./Toggle.svelte";
+ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+ import { groupRenderFields, planSurface } from "../logic/plan";
+ import Button from "./Button.svelte";
+ import MessageQueueList from "./MessageQueueList.svelte";
+ import Number from "./Number.svelte";
+ import Progress from "./Progress.svelte";
+ import Selector from "./Selector.svelte";
+ import StatTable from "./StatTable.svelte";
+ import SurfaceTable from "./SurfaceTable.svelte";
+ import TodoList from "./TodoList.svelte";
+ import Toggle from "./Toggle.svelte";
- let {
- spec,
- onInvoke,
- }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
+ let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
- const plan = $derived(planSurface(spec));
- // Consecutive stats render together as one aligned table; everything else is
- // a standalone widget. Grouping keys on field KIND only — never the surface id.
- const groups = $derived(groupRenderFields(plan.fields));
+ const plan = $derived(planSurface(spec));
+ // Consecutive stats render together as one aligned table; everything else is
+ // a standalone widget. Grouping keys on field KIND only — never the surface id.
+ const groups = $derived(groupRenderFields(plan.fields));
</script>
<article>
- <h2>{spec.title}</h2>
- {#each groups as group, i (i)}
- {#if group.type === "stats"}
- <StatTable stats={group.stats} />
- {:else if group.field.kind === "toggle"}
- <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "progress"}
- <Progress field={group.field} />
- {:else if group.field.kind === "selector"}
- <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "number"}
- <Number field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "button"}
- <Button field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "custom"}
- <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
- unknown ids gracefully render nothing. -->
- {#if group.field.rendererId === "table"}
- <SurfaceTable payload={group.field.payload} />
- {:else if group.field.rendererId === "message-queue"}
- <MessageQueueList payload={group.field.payload} />
- {:else if group.field.rendererId === "todo"}
- <TodoList payload={group.field.payload} />
- {/if}
- {/if}
- {/each}
+ <h2>{spec.title}</h2>
+ {#each groups as group, i (i)}
+ {#if group.type === "stats"}
+ <StatTable stats={group.stats} />
+ {:else if group.field.kind === "toggle"}
+ <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "progress"}
+ <Progress field={group.field} />
+ {:else if group.field.kind === "selector"}
+ <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "number"}
+ <Number field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "button"}
+ <Button field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "custom"}
+ <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
+ unknown ids gracefully render nothing. -->
+ {#if group.field.rendererId === "table"}
+ <SurfaceTable payload={group.field.payload} />
+ {:else if group.field.rendererId === "message-queue"}
+ <MessageQueueList payload={group.field.payload} />
+ {:else if group.field.rendererId === "todo"}
+ <TodoList payload={group.field.payload} />
+ {/if}
+ {/if}
+ {/each}
</article>
diff --git a/src/features/surface-host/ui/TodoList.svelte b/src/features/surface-host/ui/TodoList.svelte
index b7b2183..e9b1b58 100644
--- a/src/features/surface-host/ui/TodoList.svelte
+++ b/src/features/surface-host/ui/TodoList.svelte
@@ -1,61 +1,61 @@
<script lang="ts">
- import { parseTodoPayload } from "../logic/todo";
+ import { parseTodoPayload } from "../logic/todo";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- const data = $derived(parseTodoPayload(payload));
+ const data = $derived(parseTodoPayload(payload));
</script>
{#if data !== null && data.todos.length > 0}
- <ul class="flex flex-col gap-1">
- {#each data.todos as todo, i (i)}
- <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm">
- <!-- Status indicator -->
- <span class="mt-0.5 shrink-0">
- {#if todo.status === "in_progress"}
- <span class="block h-4 w-4 rounded-full bg-primary"></span>
- {:else if todo.status === "completed"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-success"
- >
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {:else if todo.status === "cancelled"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-base-content/40"
- >
- <line x1="18" y1="6" x2="6" y2="18"></line>
- <line x1="6" y1="6" x2="18" y2="18"></line>
- </svg>
- {:else}
- <!-- pending: empty circle -->
- <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span>
- {/if}
- </span>
+ <ul class="flex flex-col gap-1">
+ {#each data.todos as todo, i (i)}
+ <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm">
+ <!-- Status indicator -->
+ <span class="mt-0.5 shrink-0">
+ {#if todo.status === "in_progress"}
+ <span class="block h-4 w-4 rounded-full bg-primary"></span>
+ {:else if todo.status === "completed"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {:else if todo.status === "cancelled"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-base-content/40"
+ >
+ <line x1="18" y1="6" x2="6" y2="18"></line>
+ <line x1="6" y1="6" x2="18" y2="18"></line>
+ </svg>
+ {:else}
+ <!-- pending: empty circle -->
+ <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span>
+ {/if}
+ </span>
- <!-- Content -->
- <span
- class:flex-1={true}
- class:line-through={todo.status === "completed" || todo.status === "cancelled"}
- class:opacity-50={todo.status === "completed" || todo.status === "cancelled"}
- >
- {todo.content}
- </span>
- </li>
- {/each}
- </ul>
+ <!-- Content -->
+ <span
+ class:flex-1={true}
+ class:line-through={todo.status === "completed" || todo.status === "cancelled"}
+ class:opacity-50={todo.status === "completed" || todo.status === "cancelled"}
+ >
+ {todo.content}
+ </span>
+ </li>
+ {/each}
+ </ul>
{/if}
diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte
index aec8f4e..0326851 100644
--- a/src/features/surface-host/ui/Toggle.svelte
+++ b/src/features/surface-host/ui/Toggle.svelte
@@ -1,25 +1,25 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { ToggleFieldView } from "../logic/types";
+ 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();
+ 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,
- });
- }
+ 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}
+ <input type="checkbox" checked={field.value} onchange={handleChange} />
+ {field.label}
</label>