@kahitsan/ksui 0.19.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,149 @@
1
+ // U7 — flow node model (Vision §9). The CLIENT renders only UI-EFFECT nodes;
2
+ // ALL authority/data/branching lives server-side. The client never decides the
3
+ // graph — it renders the current node and POSTs each step to a host-injected
4
+ // `advance(state, input) => Promise<nextNode>` resolver. ksui ships NO server
5
+ // logic and NO `command`/`call(peer)` node kinds: those carry authority and run
6
+ // on the kernel (§9 "authority is server-side, presentation is client-side").
7
+ //
8
+ // This file is PURE (no solid-js, no component imports) so the node model + its
9
+ // type guards unit-test under plain node, mirroring the resource `spec.ts` split.
10
+
11
+ /** A single field a flow `form` node asks the user to fill (§10, UI subset). */
12
+ export interface FlowFormField {
13
+ readonly key: string;
14
+ readonly label: string;
15
+ /** Widget hint the runner maps to an input; defaults to "text". */
16
+ readonly type?: "text" | "textarea" | "number" | "select" | "toggle";
17
+ readonly required?: boolean;
18
+ readonly placeholder?: string;
19
+ /** For "select": the choices. */
20
+ readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }>;
21
+ }
22
+
23
+ /** A choice the user picks; its `value` is the input POSTed to `advance` (§9). */
24
+ export interface FlowChoiceOption {
25
+ readonly value: string;
26
+ readonly label: string;
27
+ /** Visual emphasis hint; the runner styles primary distinctly. */
28
+ readonly intent?: "primary" | "neutral" | "danger";
29
+ }
30
+
31
+ // ---- The UI-effect node kinds the CLIENT renders (and ONLY these) ----------
32
+
33
+ /** A form node: collect typed values, submit continues the flow (§10). */
34
+ export interface FlowFormNode {
35
+ readonly kind: "form";
36
+ /** Server-assigned node id, echoed back so the server correlates the step. */
37
+ readonly id: string;
38
+ readonly title?: string;
39
+ readonly fields: readonly FlowFormField[];
40
+ readonly submitLabel?: string;
41
+ /** When true the runner offers a cancel affordance taking the onCancel edge. */
42
+ readonly cancelable?: boolean;
43
+ }
44
+
45
+ /** A display node: render server-provided read-only content, then continue. */
46
+ export interface FlowDisplayNode {
47
+ readonly kind: "display";
48
+ readonly id: string;
49
+ readonly title?: string;
50
+ /** Read-only text/markup string the server already rendered/sanitized. */
51
+ readonly body: string;
52
+ readonly continueLabel?: string;
53
+ }
54
+
55
+ /** A choice node: the user picks one option; its value is the step input. */
56
+ export interface FlowChoiceNode {
57
+ readonly kind: "choice";
58
+ readonly id: string;
59
+ readonly title?: string;
60
+ readonly prompt?: string;
61
+ readonly options: readonly FlowChoiceOption[];
62
+ }
63
+
64
+ /** A message node: a transient toast-like notice; continues automatically or on ack. */
65
+ export interface FlowMessageNode {
66
+ readonly kind: "message";
67
+ readonly id: string;
68
+ readonly text: string;
69
+ readonly tone?: "info" | "success" | "error";
70
+ readonly ackLabel?: string;
71
+ }
72
+
73
+ /**
74
+ * A terminal node: the flow is done. The client stops here and renders the
75
+ * outcome; it never calls `advance` from a terminal node. The server marks
76
+ * terminality — the client does not infer it.
77
+ */
78
+ export interface FlowTerminalNode {
79
+ readonly kind: "terminal";
80
+ readonly id: string;
81
+ readonly title?: string;
82
+ readonly message?: string;
83
+ readonly tone?: "success" | "error" | "info";
84
+ }
85
+
86
+ /** The discriminated union of client-renderable nodes (UI-effect + terminal). */
87
+ export type FlowNode =
88
+ | FlowFormNode
89
+ | FlowDisplayNode
90
+ | FlowChoiceNode
91
+ | FlowMessageNode
92
+ | FlowTerminalNode;
93
+
94
+ /** Opaque server state threaded through each step; the client never reads into it. */
95
+ export type FlowState = unknown;
96
+
97
+ /** The input a step submits back to the server (form values / choice value / ack). */
98
+ export type FlowInput = Record<string, unknown> | null;
99
+
100
+ /**
101
+ * The host-injected resolver that owns ALL authority + branching. Given the
102
+ * current opaque state and the step's input, it returns the next node (which may
103
+ * be terminal). ksui never decides what comes next — it only renders + POSTs.
104
+ */
105
+ export type FlowAdvance = (state: FlowState, input: FlowInput) => Promise<FlowNode>;
106
+
107
+ /** True for the terminal node kind (the runner stops calling `advance`). */
108
+ export function isTerminal(node: FlowNode): node is FlowTerminalNode {
109
+ return node.kind === "terminal";
110
+ }
111
+
112
+ /** True for a node the runner submits user input from (form/choice/message). */
113
+ export function collectsInput(node: FlowNode): node is FlowFormNode | FlowChoiceNode {
114
+ return node.kind === "form" || node.kind === "choice";
115
+ }
116
+
117
+ /**
118
+ * Build the input payload for a form node from its current field values,
119
+ * dropping undefined and coercing nothing (the server re-validates — §10.5).
120
+ * Pure helper so submission shaping is testable without a DOM.
121
+ */
122
+ export function formNodeInput(
123
+ node: FlowFormNode,
124
+ values: Record<string, unknown>,
125
+ ): Record<string, unknown> {
126
+ const out: Record<string, unknown> = {};
127
+ for (const f of node.fields) {
128
+ const v = values[f.key];
129
+ if (v !== undefined) out[f.key] = v;
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Which required form fields are still empty. Client-side gating is UX only;
136
+ * the kernel re-validates every rule server-side (§10.5) — this never authorizes.
137
+ */
138
+ export function missingRequired(
139
+ node: FlowFormNode,
140
+ values: Record<string, unknown>,
141
+ ): string[] {
142
+ const missing: string[] = [];
143
+ for (const f of node.fields) {
144
+ if (!f.required) continue;
145
+ const v = values[f.key];
146
+ if (v === undefined || v === null || v === "") missing.push(f.key);
147
+ }
148
+ return missing;
149
+ }
@@ -0,0 +1,87 @@
1
+ // U8 — registry + consumes-validation unit tests (pure, no DOM).
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ clearRenderers,
5
+ getRenderer,
6
+ hasRenderer,
7
+ registerRenderer,
8
+ unregisterRenderer,
9
+ validateConsumes,
10
+ type RendererDefinition,
11
+ } from "./renderers";
12
+
13
+ const noopDef = (id: string): RendererDefinition => ({
14
+ id,
15
+ consumes: { name: "string" },
16
+ emits: ["edit"],
17
+ render: () => null as never,
18
+ });
19
+
20
+ afterEach(() => clearRenderers());
21
+
22
+ describe("renderer registry", () => {
23
+ it("registers and looks up by id", () => {
24
+ registerRenderer(noopDef("card"));
25
+ expect(hasRenderer("card")).toBe(true);
26
+ expect(getRenderer("card")?.id).toBe("card");
27
+ });
28
+
29
+ it("re-registering the same id replaces (last write wins)", () => {
30
+ registerRenderer(noopDef("card"));
31
+ registerRenderer({ ...noopDef("card"), emits: ["pay"] });
32
+ expect(getRenderer("card")?.emits).toEqual(["pay"]);
33
+ });
34
+
35
+ it("unknown id is undefined, not a throw", () => {
36
+ expect(getRenderer("missing")).toBeUndefined();
37
+ expect(hasRenderer("missing")).toBe(false);
38
+ });
39
+
40
+ it("unregister removes a renderer", () => {
41
+ registerRenderer(noopDef("card"));
42
+ unregisterRenderer("card");
43
+ expect(hasRenderer("card")).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("validateConsumes", () => {
48
+ it("accepts a matching item", () => {
49
+ const r = validateConsumes(
50
+ { customer: "string", progress: "number", packages: "array", balance: "currency?" },
51
+ { customer: "Acme", progress: 3, packages: [], balance: 1200 },
52
+ );
53
+ expect(r.ok).toBe(true);
54
+ expect(r.errors).toEqual([]);
55
+ });
56
+
57
+ it("allows an optional field to be absent", () => {
58
+ const r = validateConsumes({ name: "string", balance: "currency?" }, { name: "x" });
59
+ expect(r.ok).toBe(true);
60
+ });
61
+
62
+ it("rejects a missing required field", () => {
63
+ const r = validateConsumes({ name: "string" }, {});
64
+ expect(r.ok).toBe(false);
65
+ expect(r.errors[0]).toContain("name");
66
+ });
67
+
68
+ it("rejects a type mismatch", () => {
69
+ const r = validateConsumes({ progress: "number" }, { progress: "nope" });
70
+ expect(r.ok).toBe(false);
71
+ expect(r.errors[0]).toContain("expected number");
72
+ });
73
+
74
+ it("rejects a non-object item", () => {
75
+ expect(validateConsumes({ a: "string" }, null).ok).toBe(false);
76
+ expect(validateConsumes({ a: "string" }, [] as unknown).ok).toBe(false);
77
+ });
78
+
79
+ it("ignores extra keys not in the schema", () => {
80
+ const r = validateConsumes({ name: "string" }, { name: "x", extra: 99 });
81
+ expect(r.ok).toBe(true);
82
+ });
83
+
84
+ it("any matches anything", () => {
85
+ expect(validateConsumes({ blob: "any" }, { blob: { x: 1 } }).ok).toBe(true);
86
+ });
87
+ });
@@ -0,0 +1,153 @@
1
+ // U8 — schema-bound custom renderer registry (Vision §8).
2
+ //
3
+ // A custom renderer is a REAL, in-process SolidJS component built on ksui
4
+ // primitives — never an iframe/VM. The Vision is explicit that runtime isolation
5
+ // is deliberately omitted (theming fidelity + performance); what keeps a renderer
6
+ // safe is that it holds NO authority: it receives only its declared `consumes`
7
+ // data and can only fire its declared `emits` (flow triggers, §9). It cannot read
8
+ // or write data or call peers.
9
+ //
10
+ // WHY a build-time, in-process registry (no eval, no remote code): the security
11
+ // boundary for code-bearing UI is SUPPLY-CHAIN, not sandbox (Vision §8 / §14 —
12
+ // the VS Code model). The registry holds only components linked into the bundle
13
+ // at build time, so trust comes from the package supply chain (signing, scanning,
14
+ // verified publishers), not from caging a string of code at runtime. There is no
15
+ // `eval`, no dynamic `import(url)`, no Function constructor anywhere here.
16
+
17
+ import type { Component } from "solid-js";
18
+
19
+ /** The set of primitive shapes a renderer can declare it consumes per field. */
20
+ export type ConsumeKind =
21
+ | "string"
22
+ | "number"
23
+ | "boolean"
24
+ | "enum"
25
+ | "array"
26
+ | "object"
27
+ | "currency"
28
+ | "any";
29
+
30
+ /**
31
+ * The input-schema contract a renderer declares (§8 `consumes`): a map of
32
+ * prop name → expected kind. A trailing "?" on the kind marks the field optional
33
+ * (e.g. `balance: "currency?"` in the Vision example). The registry validates an
34
+ * `item` against this before rendering and falls back when it doesn't match.
35
+ */
36
+ export type ConsumesSchema = Readonly<Record<string, `${ConsumeKind}` | `${ConsumeKind}?`>>;
37
+
38
+ /** The props a registered renderer receives. `emit` only fires declared triggers. */
39
+ export interface RendererProps {
40
+ /** The validated data object, shaped by the renderer's `consumes` schema. */
41
+ readonly item: Record<string, unknown>;
42
+ /**
43
+ * Fire one of the renderer's declared `emits` interaction points. The host
44
+ * wires each emit name to a flow trigger (§9); ksui never executes authority.
45
+ * An emit NOT in the declared set is dropped with a warning (a renderer cannot
46
+ * forge an interaction it never declared).
47
+ */
48
+ readonly emit: (event: string, payload?: unknown) => void;
49
+ }
50
+
51
+ /** A registry entry: the schema-bound contract plus the in-process component. */
52
+ export interface RendererDefinition {
53
+ /** Stable id a spec binds to (e.g. "availment-card"). */
54
+ readonly id: string;
55
+ /** Input schema the component consumes (§8). */
56
+ readonly consumes: ConsumesSchema;
57
+ /** Interaction points the component may emit (§8) → flow triggers (§9). */
58
+ readonly emits: readonly string[];
59
+ /** The real, in-process SolidJS component. Built on ksui primitives. */
60
+ readonly render: Component<RendererProps>;
61
+ }
62
+
63
+ /** Result of validating an item against a `consumes` schema. */
64
+ export interface ValidationResult {
65
+ readonly ok: boolean;
66
+ /** Human-readable reasons a prop failed (empty when ok). */
67
+ readonly errors: readonly string[];
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // The registry (module-scoped, in-process). Populated at build time by the host
72
+ // calling `registerRenderer` during startup wiring — never from network input.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const REGISTRY = new Map<string, RendererDefinition>();
76
+
77
+ /**
78
+ * Register an in-process renderer. Re-registering the same id REPLACES the prior
79
+ * one (last write wins) so a host can override a kit default deterministically.
80
+ */
81
+ export function registerRenderer(def: RendererDefinition): void {
82
+ REGISTRY.set(def.id, def);
83
+ }
84
+
85
+ /** Look up a registered renderer, or undefined when the id is unknown. */
86
+ export function getRenderer(id: string): RendererDefinition | undefined {
87
+ return REGISTRY.get(id);
88
+ }
89
+
90
+ /** True when an id is registered. */
91
+ export function hasRenderer(id: string): boolean {
92
+ return REGISTRY.has(id);
93
+ }
94
+
95
+ /** Remove a renderer (used by tests + hot-reload). */
96
+ export function unregisterRenderer(id: string): void {
97
+ REGISTRY.delete(id);
98
+ }
99
+
100
+ /** Clear the whole registry (test isolation). */
101
+ export function clearRenderers(): void {
102
+ REGISTRY.clear();
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Validation — does an item satisfy a renderer's declared `consumes` schema?
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function kindMatches(kind: ConsumeKind, value: unknown): boolean {
110
+ switch (kind) {
111
+ case "any":
112
+ return true;
113
+ case "string":
114
+ case "enum": // an enum value arrives as a string; the renderer maps it
115
+ return typeof value === "string";
116
+ case "number":
117
+ case "currency": // currency is a numeric amount in minor/major units
118
+ return typeof value === "number" && !Number.isNaN(value);
119
+ case "boolean":
120
+ return typeof value === "boolean";
121
+ case "array":
122
+ return Array.isArray(value);
123
+ case "object":
124
+ return typeof value === "object" && value !== null && !Array.isArray(value);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Validate `item` against a `consumes` schema. A field whose kind ends in "?" is
130
+ * optional — absent/undefined is allowed, but a present value still type-checks.
131
+ * Extra keys on `item` not named in the schema are IGNORED (the renderer simply
132
+ * won't read them); only declared fields are enforced.
133
+ */
134
+ export function validateConsumes(consumes: ConsumesSchema, item: unknown): ValidationResult {
135
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
136
+ return { ok: false, errors: ["item must be a non-null object"] };
137
+ }
138
+ const record = item as Record<string, unknown>;
139
+ const errors: string[] = [];
140
+ for (const [key, spec] of Object.entries(consumes)) {
141
+ const optional = spec.endsWith("?");
142
+ const kind = (optional ? spec.slice(0, -1) : spec) as ConsumeKind;
143
+ const value = record[key];
144
+ if (value === undefined || value === null) {
145
+ if (!optional) errors.push(`missing required "${key}" (${kind})`);
146
+ continue;
147
+ }
148
+ if (!kindMatches(kind, value)) {
149
+ errors.push(`"${key}" expected ${kind}, got ${typeof value}`);
150
+ }
151
+ }
152
+ return { ok: errors.length === 0, errors };
153
+ }