@kahitsan/ksui 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ // U7 — FlowRunner component tests: renders each node kind, calls advance with the
2
+ // node's input, handles a terminal node + an error.
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { render, fireEvent, waitFor } from "@solidjs/testing-library";
5
+ import FlowRunner from "./FlowRunner";
6
+ import type { FlowNode } from "../../utils/flow";
7
+
8
+ const terminal: FlowNode = { kind: "terminal", id: "end", message: "Done" };
9
+
10
+ describe("FlowRunner", () => {
11
+ it("renders a form and calls advance with the form input on submit", async () => {
12
+ const advance = vi.fn(async () => terminal);
13
+ const onComplete = vi.fn();
14
+ const { getByTestId } = render(() => (
15
+ <FlowRunner
16
+ testId="fr"
17
+ initialNode={{
18
+ kind: "form",
19
+ id: "n1",
20
+ title: "Pay",
21
+ fields: [{ key: "amount", label: "Amount", type: "number", required: true }],
22
+ }}
23
+ advance={advance}
24
+ state={{ token: "abc" }}
25
+ onComplete={onComplete}
26
+ />
27
+ ));
28
+ fireEvent.input(getByTestId("fr-field-amount"), { target: { value: "50" } });
29
+ fireEvent.click(getByTestId("fr-submit"));
30
+ await waitFor(() => expect(advance).toHaveBeenCalledWith({ token: "abc" }, { amount: "50" }));
31
+ await waitFor(() => expect(getByTestId("fr-terminal")).toBeTruthy());
32
+ expect(onComplete).toHaveBeenCalled();
33
+ });
34
+
35
+ it("blocks submit while a required field is empty", () => {
36
+ const advance = vi.fn(async () => terminal);
37
+ const { getByTestId } = render(() => (
38
+ <FlowRunner
39
+ testId="fr"
40
+ initialNode={{
41
+ kind: "form",
42
+ id: "n1",
43
+ fields: [{ key: "amount", label: "Amount", required: true }],
44
+ }}
45
+ advance={advance}
46
+ />
47
+ ));
48
+ fireEvent.click(getByTestId("fr-submit"));
49
+ expect(advance).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("renders a choice and submits the picked value", async () => {
53
+ const advance = vi.fn(async () => terminal);
54
+ const { getByTestId } = render(() => (
55
+ <FlowRunner
56
+ testId="fr"
57
+ initialNode={{
58
+ kind: "choice",
59
+ id: "c1",
60
+ options: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }],
61
+ }}
62
+ advance={advance}
63
+ />
64
+ ));
65
+ fireEvent.click(getByTestId("fr-choice-yes"));
66
+ await waitFor(() => expect(advance).toHaveBeenCalledWith(undefined, { value: "yes" }));
67
+ });
68
+
69
+ it("renders a display node and continues with null input", async () => {
70
+ const advance = vi.fn(async () => terminal);
71
+ const { getByTestId } = render(() => (
72
+ <FlowRunner
73
+ testId="fr"
74
+ initialNode={{ kind: "display", id: "d1", body: "Review this" }}
75
+ advance={advance}
76
+ />
77
+ ));
78
+ expect(getByTestId("fr-display").textContent).toContain("Review this");
79
+ fireEvent.click(getByTestId("fr-continue"));
80
+ await waitFor(() => expect(advance).toHaveBeenCalledWith(undefined, null));
81
+ });
82
+
83
+ it("renders a message node and acks", async () => {
84
+ const advance = vi.fn(async () => terminal);
85
+ const { getByTestId } = render(() => (
86
+ <FlowRunner
87
+ testId="fr"
88
+ initialNode={{ kind: "message", id: "m1", text: "Heads up", tone: "info" }}
89
+ advance={advance}
90
+ />
91
+ ));
92
+ expect(getByTestId("fr-message").textContent).toContain("Heads up");
93
+ fireEvent.click(getByTestId("fr-ack"));
94
+ await waitFor(() => expect(advance).toHaveBeenCalled());
95
+ });
96
+
97
+ it("surfaces an error when advance rejects, without inventing the next node", async () => {
98
+ const advance = vi.fn(async () => {
99
+ throw new Error("server said no");
100
+ });
101
+ const { getByTestId, queryByTestId } = render(() => (
102
+ <FlowRunner
103
+ testId="fr"
104
+ initialNode={{ kind: "choice", id: "c1", options: [{ value: "go", label: "Go" }] }}
105
+ advance={advance}
106
+ />
107
+ ));
108
+ fireEvent.click(getByTestId("fr-choice-go"));
109
+ await waitFor(() => expect(getByTestId("fr-error").textContent).toContain("server said no"));
110
+ // still on the choice node — the client never invents a next node
111
+ expect(queryByTestId("fr-choice")).toBeTruthy();
112
+ });
113
+
114
+ it("takes the cancel path on a cancelable form", () => {
115
+ const onCancel = vi.fn();
116
+ const { getByTestId } = render(() => (
117
+ <FlowRunner
118
+ testId="fr"
119
+ initialNode={{ kind: "form", id: "n1", cancelable: true, fields: [] }}
120
+ advance={vi.fn(async () => terminal)}
121
+ onCancel={onCancel}
122
+ />
123
+ ));
124
+ fireEvent.click(getByTestId("fr-cancel"));
125
+ expect(onCancel).toHaveBeenCalled();
126
+ });
127
+ });
@@ -0,0 +1,348 @@
1
+ // U7 — FlowRunner (Vision §9): a client renderer for a SERVER-DRIVEN node graph.
2
+ // It renders the current UI-effect node (form / display / choice / message /
3
+ // terminal) and POSTs each step to the host-injected `advance(state, input)`
4
+ // resolver — which owns ALL authority, data and branching. The client never
5
+ // decides the graph: it shows the node, collects input, calls `advance`, and
6
+ // renders whatever node comes back, stopping at a terminal node.
7
+ //
8
+ // Composite because it composes the node model (utils/flow) with loading/terminal/
9
+ // error UI. Self-contained CSS (ksui-fr-* unscoped classes + CSS custom props);
10
+ // no Tailwind, no host-brand classes (standalone-library rule).
11
+
12
+ import type { Component, JSX } from "solid-js";
13
+ import { For, Match, Show, Switch, createSignal } from "solid-js";
14
+ import {
15
+ collectsInput,
16
+ formNodeInput,
17
+ isTerminal,
18
+ missingRequired,
19
+ type FlowAdvance,
20
+ type FlowChoiceNode,
21
+ type FlowDisplayNode,
22
+ type FlowFormNode,
23
+ type FlowInput,
24
+ type FlowMessageNode,
25
+ type FlowNode,
26
+ type FlowState,
27
+ } from "../../utils/flow";
28
+
29
+ const STYLE_ID = "ksui-flow-runner-style";
30
+
31
+ function ensureStyle(): void {
32
+ if (typeof document === "undefined") return;
33
+ if (document.getElementById(STYLE_ID)) return;
34
+ const style = document.createElement("style");
35
+ style.id = STYLE_ID;
36
+ style.textContent = `
37
+ .ksui-fr{display:flex;flex-direction:column;gap:0.875rem;color:var(--ksui-fr-fg,inherit);}
38
+ .ksui-fr-title{font-size:0.95rem;font-weight:600;margin:0;}
39
+ .ksui-fr-prompt,.ksui-fr-body{font-size:0.85rem;opacity:0.85;margin:0;}
40
+ .ksui-fr-field{display:flex;flex-direction:column;gap:0.25rem;}
41
+ .ksui-fr-label{font-size:0.78rem;font-weight:500;}
42
+ .ksui-fr-input{padding:0.5rem 0.625rem;border-radius:0.5rem;border:1px solid var(--ksui-fr-border,rgba(255,255,255,0.15));background:var(--ksui-fr-input-bg,rgba(255,255,255,0.04));color:inherit;font-size:0.85rem;}
43
+ .ksui-fr-actions{display:flex;gap:0.5rem;flex-wrap:wrap;}
44
+ .ksui-fr-btn{padding:0.5rem 0.875rem;border-radius:0.5rem;border:1px solid var(--ksui-fr-border,rgba(255,255,255,0.15));background:var(--ksui-fr-btn-bg,rgba(255,255,255,0.06));color:inherit;font-size:0.82rem;cursor:pointer;}
45
+ .ksui-fr-btn:disabled{opacity:0.5;cursor:not-allowed;}
46
+ .ksui-fr-btn.primary{background:var(--ksui-fr-primary,#c9a961);color:var(--ksui-fr-primary-fg,#18181b);border-color:transparent;}
47
+ .ksui-fr-btn.danger{background:var(--ksui-fr-danger,#ef4444);color:#fff;border-color:transparent;}
48
+ .ksui-fr-msg{padding:0.625rem 0.75rem;border-radius:0.5rem;font-size:0.85rem;}
49
+ .ksui-fr-msg.info{background:rgba(59,130,246,0.1);}
50
+ .ksui-fr-msg.success{background:rgba(34,197,94,0.12);}
51
+ .ksui-fr-msg.error{background:rgba(239,68,68,0.12);}
52
+ .ksui-fr-error{padding:0.625rem 0.75rem;border-radius:0.5rem;font-size:0.82rem;background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.3);}
53
+ .ksui-fr-loading{font-size:0.82rem;opacity:0.7;}
54
+ `;
55
+ document.head.appendChild(style);
56
+ }
57
+
58
+ export interface FlowRunnerProps {
59
+ /** The first node the server handed the client (the flow entry). */
60
+ initialNode: FlowNode;
61
+ /**
62
+ * Host-injected resolver — owns authority, data and branching (§9). Given the
63
+ * opaque state and the step input it returns the next node. ksui never decides.
64
+ */
65
+ advance: FlowAdvance;
66
+ /**
67
+ * Opaque flow state threaded into each `advance` call. The client never reads
68
+ * into it; the server correlates the step from it.
69
+ */
70
+ state?: FlowState;
71
+ /** Fired when a terminal node is reached (host closes the flow / refreshes). */
72
+ onComplete?: (node: FlowNode) => void;
73
+ /** Fired when the user cancels a cancelable form (host takes the onCancel edge). */
74
+ onCancel?: () => void;
75
+ /** Optional test id prefix. */
76
+ testId?: string;
77
+ }
78
+
79
+ export const FlowRunner: Component<FlowRunnerProps> = (props) => {
80
+ ensureStyle();
81
+ const [node, setNode] = createSignal<FlowNode>(props.initialNode);
82
+ const [busy, setBusy] = createSignal(false);
83
+ const [error, setError] = createSignal<string | null>(null);
84
+ // Form field values for the current form node (reset whenever the node changes).
85
+ const [values, setValues] = createSignal<Record<string, unknown>>({});
86
+
87
+ const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
88
+
89
+ // Submit `input` to the server resolver and swap in the node it returns. A
90
+ // rejected `advance` surfaces as an error state — the client never invents the
91
+ // next node, so a failure halts on the current step (re-submittable).
92
+ const submit = async (input: FlowInput) => {
93
+ setError(null);
94
+ setBusy(true);
95
+ try {
96
+ const next = await props.advance(props.state, input);
97
+ setNode(next);
98
+ setValues({});
99
+ if (isTerminal(next)) props.onComplete?.(next);
100
+ } catch (e) {
101
+ setError(e instanceof Error ? e.message : "Something went wrong");
102
+ } finally {
103
+ setBusy(false);
104
+ }
105
+ };
106
+
107
+ const setField = (key: string, v: unknown) =>
108
+ setValues((prev) => ({ ...prev, [key]: v }));
109
+
110
+ const submitForm = (n: FlowFormNode) => {
111
+ if (missingRequired(n, values()).length > 0) return; // UX gate; server re-validates
112
+ void submit(formNodeInput(n, values()));
113
+ };
114
+
115
+ return (
116
+ <div class="ksui-fr" data-testid={tid("root")}>
117
+ <Show when={error()}>
118
+ <div class="ksui-fr-error" role="alert" data-testid={tid("error")}>
119
+ {error()}
120
+ </div>
121
+ </Show>
122
+
123
+ <Switch>
124
+ <Match when={node().kind === "form"}>
125
+ {renderForm(node() as FlowFormNode, values(), setField, () => submitForm(node() as FlowFormNode), () => props.onCancel?.(), busy(), tid)}
126
+ </Match>
127
+ <Match when={node().kind === "display"}>
128
+ {renderDisplay(node() as FlowDisplayNode, () => void submit(null), busy(), tid)}
129
+ </Match>
130
+ <Match when={node().kind === "choice"}>
131
+ {renderChoice(node() as FlowChoiceNode, (value) => void submit({ value }), busy(), tid)}
132
+ </Match>
133
+ <Match when={node().kind === "message"}>
134
+ {renderMessage(node() as FlowMessageNode, () => void submit(null), busy(), tid)}
135
+ </Match>
136
+ <Match when={node().kind === "terminal"}>
137
+ {renderTerminal(node(), tid)}
138
+ </Match>
139
+ </Switch>
140
+
141
+ <Show when={busy()}>
142
+ <span class="ksui-fr-loading" data-testid={tid("loading")}>Working…</span>
143
+ </Show>
144
+ </div>
145
+ );
146
+ };
147
+
148
+ // ---- node renderers (plain functions returning JSX; no per-node reactivity needed) ----
149
+
150
+ function renderForm(
151
+ n: FlowFormNode,
152
+ values: Record<string, unknown>,
153
+ setField: (k: string, v: unknown) => void,
154
+ onSubmit: () => void,
155
+ onCancel: () => void,
156
+ busy: boolean,
157
+ tid: (s: string) => string | undefined,
158
+ ): JSX.Element {
159
+ const blocked = missingRequired(n, values).length > 0;
160
+ return (
161
+ <div class="ksui-fr" data-testid={tid("form")}>
162
+ <Show when={n.title}>
163
+ <h3 class="ksui-fr-title">{n.title}</h3>
164
+ </Show>
165
+ <For each={n.fields}>
166
+ {(f) => (
167
+ <div class="ksui-fr-field">
168
+ <label class="ksui-fr-label" for={`ksui-fr-${f.key}`}>
169
+ {f.label}
170
+ {f.required ? " *" : ""}
171
+ </label>
172
+ <Switch
173
+ fallback={
174
+ <input
175
+ id={`ksui-fr-${f.key}`}
176
+ class="ksui-fr-input"
177
+ type={f.type === "number" ? "number" : "text"}
178
+ placeholder={f.placeholder}
179
+ data-testid={tid(`field-${f.key}`)}
180
+ onInput={(e) => setField(f.key, e.currentTarget.value)}
181
+ />
182
+ }
183
+ >
184
+ <Match when={f.type === "textarea"}>
185
+ <textarea
186
+ id={`ksui-fr-${f.key}`}
187
+ class="ksui-fr-input"
188
+ placeholder={f.placeholder}
189
+ data-testid={tid(`field-${f.key}`)}
190
+ onInput={(e) => setField(f.key, e.currentTarget.value)}
191
+ />
192
+ </Match>
193
+ <Match when={f.type === "select"}>
194
+ <select
195
+ id={`ksui-fr-${f.key}`}
196
+ class="ksui-fr-input"
197
+ data-testid={tid(`field-${f.key}`)}
198
+ onChange={(e) => setField(f.key, e.currentTarget.value)}
199
+ >
200
+ <option value="" />
201
+ <For each={f.options ?? []}>
202
+ {(o) => <option value={o.value}>{o.label}</option>}
203
+ </For>
204
+ </select>
205
+ </Match>
206
+ <Match when={f.type === "toggle"}>
207
+ <input
208
+ id={`ksui-fr-${f.key}`}
209
+ type="checkbox"
210
+ data-testid={tid(`field-${f.key}`)}
211
+ onChange={(e) => setField(f.key, e.currentTarget.checked)}
212
+ />
213
+ </Match>
214
+ </Switch>
215
+ </div>
216
+ )}
217
+ </For>
218
+ <div class="ksui-fr-actions">
219
+ <button
220
+ type="button"
221
+ class="ksui-fr-btn primary"
222
+ disabled={busy || blocked}
223
+ data-testid={tid("submit")}
224
+ onClick={onSubmit}
225
+ >
226
+ {n.submitLabel ?? "Submit"}
227
+ </button>
228
+ <Show when={n.cancelable}>
229
+ <button
230
+ type="button"
231
+ class="ksui-fr-btn"
232
+ disabled={busy}
233
+ data-testid={tid("cancel")}
234
+ onClick={onCancel}
235
+ >
236
+ Cancel
237
+ </button>
238
+ </Show>
239
+ </div>
240
+ </div>
241
+ );
242
+ }
243
+
244
+ function renderDisplay(
245
+ n: FlowDisplayNode,
246
+ onContinue: () => void,
247
+ busy: boolean,
248
+ tid: (s: string) => string | undefined,
249
+ ): JSX.Element {
250
+ return (
251
+ <div class="ksui-fr" data-testid={tid("display")}>
252
+ <Show when={n.title}>
253
+ <h3 class="ksui-fr-title">{n.title}</h3>
254
+ </Show>
255
+ {/* Server-provided, already-sanitized read-only text. Rendered as text, not
256
+ innerHTML, so the client never trusts markup it didn't sanitize. */}
257
+ <p class="ksui-fr-body">{n.body}</p>
258
+ <div class="ksui-fr-actions">
259
+ <button
260
+ type="button"
261
+ class="ksui-fr-btn primary"
262
+ disabled={busy}
263
+ data-testid={tid("continue")}
264
+ onClick={onContinue}
265
+ >
266
+ {n.continueLabel ?? "Continue"}
267
+ </button>
268
+ </div>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ function renderChoice(
274
+ n: FlowChoiceNode,
275
+ onPick: (value: string) => void,
276
+ busy: boolean,
277
+ tid: (s: string) => string | undefined,
278
+ ): JSX.Element {
279
+ return (
280
+ <div class="ksui-fr" data-testid={tid("choice")}>
281
+ <Show when={n.title}>
282
+ <h3 class="ksui-fr-title">{n.title}</h3>
283
+ </Show>
284
+ <Show when={n.prompt}>
285
+ <p class="ksui-fr-prompt">{n.prompt}</p>
286
+ </Show>
287
+ <div class="ksui-fr-actions">
288
+ <For each={n.options}>
289
+ {(o) => (
290
+ <button
291
+ type="button"
292
+ class={`ksui-fr-btn ${o.intent ?? ""}`}
293
+ disabled={busy}
294
+ data-testid={tid(`choice-${o.value}`)}
295
+ onClick={() => onPick(o.value)}
296
+ >
297
+ {o.label}
298
+ </button>
299
+ )}
300
+ </For>
301
+ </div>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ function renderMessage(
307
+ n: FlowMessageNode,
308
+ onAck: () => void,
309
+ busy: boolean,
310
+ tid: (s: string) => string | undefined,
311
+ ): JSX.Element {
312
+ return (
313
+ <div class="ksui-fr" data-testid={tid("message")}>
314
+ <div class={`ksui-fr-msg ${n.tone ?? "info"}`} role="status">
315
+ {n.text}
316
+ </div>
317
+ <div class="ksui-fr-actions">
318
+ <button
319
+ type="button"
320
+ class="ksui-fr-btn"
321
+ disabled={busy}
322
+ data-testid={tid("ack")}
323
+ onClick={onAck}
324
+ >
325
+ {n.ackLabel ?? "OK"}
326
+ </button>
327
+ </div>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ function renderTerminal(n: FlowNode, tid: (s: string) => string | undefined): JSX.Element {
333
+ if (!isTerminal(n)) return <></>;
334
+ return (
335
+ <div class="ksui-fr" data-testid={tid("terminal")}>
336
+ <Show when={n.title}>
337
+ <h3 class="ksui-fr-title">{n.title}</h3>
338
+ </Show>
339
+ <Show when={n.message}>
340
+ <div class={`ksui-fr-msg ${n.tone ?? "success"}`} role="status">
341
+ {n.message}
342
+ </div>
343
+ </Show>
344
+ </div>
345
+ );
346
+ }
347
+
348
+ export default FlowRunner;
@@ -11,8 +11,10 @@ import {
11
11
  selectDefault,
12
12
  cleanLabel,
13
13
  validateForm,
14
+ resolveForeignValue,
14
15
  type ResourceUiSpec,
15
16
  type UiFieldSelect,
17
+ type UiColumn,
16
18
  } from "./spec";
17
19
 
18
20
  // A self-contained fixture exercising every helper branch (segmented + select
@@ -114,3 +116,25 @@ describe("formToBody", () => {
114
116
  .toEqual({ name: "X", kind: "customer", category: null, notes: "n" });
115
117
  });
116
118
  });
119
+
120
+ describe("resolveForeignValue (U4 foreign-data contract)", () => {
121
+ const plain: UiColumn = { key: "name", title: "Name", render: { type: "text" } };
122
+ const foreign: UiColumn = {
123
+ key: "balance",
124
+ title: "Balance",
125
+ render: { type: "text" },
126
+ foreign: { source: { peer: "financial-accounts", field: "balance" }, onError: "dash" },
127
+ };
128
+ const row = { id: 1, name: "Acme", balance: "ignored-own-value" };
129
+
130
+ it("reads the own row value for a plain column", () => {
131
+ expect(resolveForeignValue(plain, row)).toBe("Acme");
132
+ });
133
+ it("THROWS for a foreign column with no resolver wired (throw-on-unwired guard)", () => {
134
+ expect(() => resolveForeignValue(foreign, row)).toThrow(/foreign source.*no ForeignResolver/);
135
+ });
136
+ it("delegates to a wired resolver for a foreign column", () => {
137
+ const resolver = (s: { peer: string; field: string }) => `${s.peer}:${s.field}=42`;
138
+ expect(resolveForeignValue(foreign, row, resolver)).toBe("financial-accounts:balance=42");
139
+ });
140
+ });
@@ -42,6 +42,63 @@ export interface UiColumn {
42
42
  readonly title: string;
43
43
  readonly orderable?: boolean;
44
44
  readonly render: UiColumnRender;
45
+ /**
46
+ * Optional: the value is read from a PEER plugin, not this resource's own row
47
+ * (see UiForeignColumn). A foreign column is never sortable server-side.
48
+ */
49
+ readonly foreign?: UiForeignColumn;
50
+ }
51
+
52
+ // ---- U4: foreign data sources (declarative; resolved via the host consent model) ----
53
+
54
+ /**
55
+ * Declares that a column's value is sourced from a PEER plugin rather than the
56
+ * resource's own row. The cross-plugin read is mediated by the host's consent model
57
+ * (kernel IP1). This is a FORWARD CONTRACT: a spec may declare a foreign source before
58
+ * the runtime can serve it — `resolveForeignValue` throws until a resolver is wired,
59
+ * so a missing consent path fails loud, never silently renders an empty cell.
60
+ */
61
+ export interface UiForeignSource {
62
+ /** The peer plugin that owns the value (e.g. "financial-accounts"). */
63
+ readonly peer: string;
64
+ /** The field on the peer record this column reads. */
65
+ readonly field: string;
66
+ /** Opaque id on THIS row used to join to the peer (defaults to the column key). */
67
+ readonly joinKey?: string;
68
+ }
69
+
70
+ /** How a foreign column degrades when the peer read is unavailable/slow/denied. */
71
+ export type UiForeignOnError = "hide" | "dash" | "warn";
72
+
73
+ /** A column whose value comes from a peer plugin via the consent model. */
74
+ export interface UiForeignColumn {
75
+ readonly source: UiForeignSource;
76
+ /** Degrade policy when the peer read fails — never breaks the row. Defaults to "dash". */
77
+ readonly onError?: UiForeignOnError;
78
+ }
79
+
80
+ /** The consent-gated peer-read seam the host supplies (kernel IP1). */
81
+ export type ForeignResolver = (source: UiForeignSource, row: ResourceRow) => unknown;
82
+
83
+ /**
84
+ * Read a column's value, honouring a declared foreign source. A plain column reads
85
+ * `row[key]`. A foreign column requires a wired `ForeignResolver` — without one it
86
+ * THROWS (the throw-on-unwired guard), because foreign reads cannot resolve until the
87
+ * host consent model exists. Callers that catch this apply the column's `onError`.
88
+ */
89
+ export function resolveForeignValue(
90
+ col: UiColumn,
91
+ row: ResourceRow,
92
+ resolver?: ForeignResolver,
93
+ ): unknown {
94
+ if (!col.foreign) return row[col.key];
95
+ if (!resolver) {
96
+ throw new Error(
97
+ `ksui: column "${col.key}" declares a foreign source (peer "${col.foreign.source.peer}") ` +
98
+ `but no ForeignResolver is wired — foreign reads require the host consent model.`,
99
+ );
100
+ }
101
+ return resolver(col.foreign.source, row);
45
102
  }
46
103
 
47
104
  // ---- form fields -----------------------------------------------------------
package/src/index.ts CHANGED
@@ -160,6 +160,18 @@ export type {
160
160
  } from "./components/composite/resource/ResourcePage";
161
161
  export * from "./components/composite/resource/spec";
162
162
 
163
+ // U6 — declarative file/media field for the spec-driven form runtime. Value is an
164
+ // opaque asset handle; host injects onUpload + presignUrl (storage-agnostic).
165
+ export { default as FileField, type FileFieldProps, type AssetHandle, type FileFieldStatus } from "./components/composite/FileField";
166
+
167
+ // U7 — client renderer for a server-driven flow node graph. Host injects `advance`;
168
+ // the client only renders UI-effect nodes and POSTs each step (authority is server-side).
169
+ export { default as FlowRunner, type FlowRunnerProps } from "./components/composite/FlowRunner";
170
+
171
+ // U8 — schema-bound custom renderer: looks up an id in the in-process registry and
172
+ // renders it with validated props, falling back safely on miss/mismatch.
173
+ export { default as CustomRenderer, type CustomRendererProps } from "./components/composite/CustomRenderer";
174
+
163
175
  // ---------------------------------------------------------------------------
164
176
  // Utils (not components)
165
177
  // ---------------------------------------------------------------------------
@@ -199,6 +211,46 @@ export { highlightMatch, HighlightedText, matchesQuery, matchesAny } from "./uti
199
211
  export { confirm, type ConfirmOptions } from "./utils/confirm";
200
212
  export { useFocusTrap, autoFocusOnMount, lockPullToRefresh, unlockPullToRefresh } from "./utils/dom";
201
213
 
214
+ // U7 — flow node model (pure): the discriminated union of client-renderable
215
+ // UI-effect nodes + the host-injected `advance` resolver type + pure step helpers.
216
+ export {
217
+ isTerminal,
218
+ collectsInput,
219
+ formNodeInput,
220
+ missingRequired,
221
+ } from "./utils/flow";
222
+ export type {
223
+ FlowNode,
224
+ FlowFormNode,
225
+ FlowDisplayNode,
226
+ FlowChoiceNode,
227
+ FlowMessageNode,
228
+ FlowTerminalNode,
229
+ FlowFormField,
230
+ FlowChoiceOption,
231
+ FlowAdvance,
232
+ FlowState,
233
+ FlowInput,
234
+ } from "./utils/flow";
235
+
236
+ // U8 — in-process, build-time custom renderer registry (no eval/remote code) and
237
+ // its consumes-schema validator. Hosts register renderers at startup.
238
+ export {
239
+ registerRenderer,
240
+ getRenderer,
241
+ hasRenderer,
242
+ unregisterRenderer,
243
+ clearRenderers,
244
+ validateConsumes,
245
+ } from "./utils/renderers";
246
+ export type {
247
+ RendererDefinition,
248
+ RendererProps,
249
+ ConsumesSchema,
250
+ ConsumeKind,
251
+ ValidationResult,
252
+ } from "./utils/renderers";
253
+
202
254
  // Optional host integrations. Components degrade gracefully when these are not
203
255
  // configured; a host app opts in once at startup.
204
256
  export {
@@ -0,0 +1,41 @@
1
+ // U7 — flow node-model pure helpers (no DOM).
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ collectsInput,
5
+ formNodeInput,
6
+ isTerminal,
7
+ missingRequired,
8
+ type FlowFormNode,
9
+ } from "./flow";
10
+
11
+ const form: FlowFormNode = {
12
+ kind: "form",
13
+ id: "n1",
14
+ fields: [
15
+ { key: "amount", label: "Amount", type: "number", required: true },
16
+ { key: "note", label: "Note", type: "text" },
17
+ ],
18
+ };
19
+
20
+ describe("flow helpers", () => {
21
+ it("isTerminal flags only terminal nodes", () => {
22
+ expect(isTerminal({ kind: "terminal", id: "t" })).toBe(true);
23
+ expect(isTerminal(form)).toBe(false);
24
+ });
25
+
26
+ it("collectsInput flags form + choice", () => {
27
+ expect(collectsInput(form)).toBe(true);
28
+ expect(collectsInput({ kind: "choice", id: "c", options: [] })).toBe(true);
29
+ expect(collectsInput({ kind: "display", id: "d", body: "" })).toBe(false);
30
+ });
31
+
32
+ it("formNodeInput keeps declared fields, drops undefined", () => {
33
+ expect(formNodeInput(form, { amount: 5, stray: "x" })).toEqual({ amount: 5 });
34
+ });
35
+
36
+ it("missingRequired reports empty required fields only", () => {
37
+ expect(missingRequired(form, {})).toEqual(["amount"]);
38
+ expect(missingRequired(form, { amount: 5 })).toEqual([]);
39
+ expect(missingRequired(form, { amount: "" })).toEqual(["amount"]);
40
+ });
41
+ });