diff options
Diffstat (limited to 'src/features/surface-host')
| -rw-r--r-- | src/features/surface-host/index.ts | 3 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.test.ts | 161 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.ts | 74 | ||||
| -rw-r--r-- | src/features/surface-host/logic/types.ts | 52 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Button.svelte | 21 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Progress.svelte | 13 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Selector.svelte | 32 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Stat.svelte | 10 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceView.svelte | 33 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Toggle.svelte | 25 |
10 files changed, 424 insertions, 0 deletions
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> |
