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.ts6
-rw-r--r--src/features/surface-host/logic/plan.test.ts64
-rw-r--r--src/features/surface-host/logic/plan.ts43
-rw-r--r--src/features/surface-host/logic/table.test.ts47
-rw-r--r--src/features/surface-host/logic/table.ts54
-rw-r--r--src/features/surface-host/logic/types.ts23
-rw-r--r--src/features/surface-host/ui/Stat.svelte10
-rw-r--r--src/features/surface-host/ui/StatTable.svelte21
-rw-r--r--src/features/surface-host/ui/SurfaceTable.svelte14
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte36
10 files changed, 282 insertions, 36 deletions
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index afa3127..8f289f1 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -1,3 +1,9 @@
export { buildInvoke, planSurface } from "./logic/plan";
export type { FieldView, SurfaceRenderPlan } from "./logic/types";
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",
+} as const;
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
index 50d6f11..a5727b4 100644
--- a/src/features/surface-host/logic/plan.test.ts
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -1,6 +1,7 @@
import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract";
import { describe, expect, it } from "vitest";
-import { buildInvoke, planSurface } from "./plan";
+import { buildInvoke, groupRenderFields, planSurface } from "./plan";
+import type { FieldView } from "./types";
const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({
id: "test-surface",
@@ -74,7 +75,7 @@ describe("planSurface", () => {
{ kind: "button", label: "D", action: { actionId: "d" } },
),
);
- expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]);
+ expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]);
});
it("drops unknown field kinds gracefully", () => {
@@ -86,10 +87,11 @@ describe("planSurface", () => {
} as SurfaceField),
);
expect(plan.fields).toHaveLength(1);
- expect(plan.fields[0]?.label).toBe("Known");
+ const first = plan.fields[0];
+ expect(first && "label" in first ? first.label : null).toBe("Known");
});
- it("drops custom fields (no renderer registered)", () => {
+ it("carries custom fields through verbatim, preserving order", () => {
const plan = planSurface(
makeSpec(
{ kind: "stat", label: "Before", value: "1" },
@@ -97,8 +99,12 @@ describe("planSurface", () => {
{ kind: "stat", label: "After", value: "2" },
),
);
- expect(plan.fields).toHaveLength(2);
- expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]);
+ 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", () => {
@@ -106,14 +112,56 @@ describe("planSurface", () => {
expect(plan.fields).toEqual([]);
});
- it("drops all fields when all are custom", () => {
+ 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).toEqual([]);
+ 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([]);
});
});
diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts
index 5b4530b..769f9f9 100644
--- a/src/features/surface-host/logic/plan.ts
+++ b/src/features/surface-host/logic/plan.ts
@@ -1,12 +1,14 @@
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
-import type { FieldView, SurfaceRenderPlan } from "./types";
+import type { FieldView, RenderGroup, StatFieldView, SurfaceRenderPlan } from "./types";
-const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]);
+const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button", "custom"]);
/**
* 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).
+ * Keeps known field kinds in order (including `custom`, carried through verbatim
+ * for the renderer to dispatch on `rendererId`); drops unknown kinds — graceful
+ * skip, never throw. Whether a `custom` field actually renders is a RENDER-time
+ * decision (unknown `rendererId` → skipped there), not a planning one.
*/
export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
const fields: FieldView[] = [];
@@ -51,12 +53,45 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
action: field.action,
});
break;
+ case "custom":
+ fields.push({
+ kind: "custom",
+ rendererId: field.rendererId,
+ payload: field.payload,
+ });
+ break;
}
}
return { fields };
}
/**
+ * Coalesce a field list into render groups: maximal runs of consecutive `stat`
+ * fields become one `stats` group (rendered as a single aligned table), every
+ * 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;
+}
+
+/**
* 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.
diff --git a/src/features/surface-host/logic/table.test.ts b/src/features/surface-host/logic/table.test.ts
new file mode 100644
index 0000000..e55b3f7
--- /dev/null
+++ b/src/features/surface-host/logic/table.test.ts
@@ -0,0 +1,47 @@
+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("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.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
new file mode 100644
index 0000000..027553c
--- /dev/null
+++ b/src/features/surface-host/logic/table.ts
@@ -0,0 +1,54 @@
+/**
+ * Pure parser for the `rendererId: "table"` custom-field payload.
+ *
+ * This is the FRONTEND-side renderer contract for tabular custom fields: a
+ * backend that wants a table emits a `custom` field with `rendererId: "table"`
+ * and a payload of `{ columns: string[]; rows: (string|number)[][] }`. Cells are
+ * coerced to strings. Anything that does not match the shape returns `null`, so
+ * the renderer gracefully skips it (never throws on hostile/partial data).
+ */
+
+export interface TableData {
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
+}
+
+function isStringArray(v: unknown): v is unknown[] {
+ 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;
+}
+
+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 };
+}
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
index f24438a..d1888a2 100644
--- a/src/features/surface-host/logic/types.ts
+++ b/src/features/surface-host/logic/types.ts
@@ -38,15 +38,36 @@ export interface ButtonFieldView {
readonly action: ActionRef;
}
+/**
+ * Normalised view-model for a custom (escape-hatch) field. The plan carries it
+ * through verbatim; the renderer dispatches on `rendererId` (a renderer KIND,
+ * 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;
+}
+
/** A normalised field view-model — one entry per renderable field kind. */
export type FieldView =
| ToggleFieldView
| ProgressFieldView
| SelectorFieldView
| StatFieldView
- | ButtonFieldView;
+ | ButtonFieldView
+ | CustomFieldView;
/** The output of `planSurface`: the ordered list of renderable fields. */
export interface SurfaceRenderPlan {
readonly fields: readonly FieldView[];
}
+
+/**
+ * A render group: a maximal run of consecutive `stat` fields (rendered together
+ * as one aligned label/value table), or a single non-stat field. Grouping is a
+ * 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> };
diff --git a/src/features/surface-host/ui/Stat.svelte b/src/features/surface-host/ui/Stat.svelte
deleted file mode 100644
index e184dab..0000000
--- a/src/features/surface-host/ui/Stat.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-<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/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte
new file mode 100644
index 0000000..415423f
--- /dev/null
+++ b/src/features/surface-host/ui/StatTable.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ 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();
+</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>
+</div>
diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte
new file mode 100644
index 0000000..764cc36
--- /dev/null
+++ b/src/features/surface-host/ui/SurfaceTable.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import Table from "../../../components/Table.svelte";
+ import { parseTablePayload } from "../logic/table";
+
+ 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));
+</script>
+
+{#if data !== null}
+ <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 4207913..5210e8c 100644
--- a/src/features/surface-host/ui/SurfaceView.svelte
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -1,10 +1,11 @@
<script lang="ts">
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { planSurface } from "../logic/plan";
+ import { groupRenderFields, 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 StatTable from "./StatTable.svelte";
+ import SurfaceTable from "./SurfaceTable.svelte";
import Toggle from "./Toggle.svelte";
let {
@@ -13,21 +14,30 @@
}: { 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));
</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} />
+ {#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 === "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} />
+ {/if}
{/if}
{/each}
</article>