@kahitsan/ksui 0.22.0 → 0.24.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -205,7 +205,7 @@ function SingleComboBox<T>(props: ComboBoxSingleProps<T>): JSX.Element {
205
205
  <div
206
206
  ref={popupRef}
207
207
  data-testid={tid("popup")}
208
- class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
208
+ class="z-[10000] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
209
209
  style={eng.popupStyle()}
210
210
  >
211
211
  <div class="px-2 py-2 border-b border-zinc-800 flex items-center gap-2">
@@ -102,4 +102,39 @@ describe("FlowGraph", () => {
102
102
  ));
103
103
  expect(getByTestId("fg-node-a").getAttribute("role")).toBeNull();
104
104
  });
105
+
106
+ it("renders pan/zoom controls only when interactive", () => {
107
+ const staticGraph = render(() => (
108
+ <FlowGraph testId="s" nodes={[{ id: "a", label: "A" }]} edges={[]} />
109
+ ));
110
+ expect(staticGraph.queryByTestId("s-controls")).toBeNull();
111
+
112
+ const canvas = render(() => (
113
+ <FlowGraph testId="c" interactive nodes={[{ id: "a", label: "A" }]} edges={[]} />
114
+ ));
115
+ expect(canvas.getByTestId("c-controls")).toBeTruthy();
116
+ expect(canvas.getByTestId("c-reset")).toBeTruthy();
117
+ // interactive svg fills the viewport rather than sizing to content
118
+ expect(canvas.getByTestId("c-svg").getAttribute("width")).toBe("100%");
119
+ });
120
+
121
+ it("animates edges with the flow class only when animated", () => {
122
+ const { container } = render(() => (
123
+ <FlowGraph
124
+ testId="fg"
125
+ animated
126
+ nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
127
+ edges={[{ from: "a", to: "b" }]}
128
+ />
129
+ ));
130
+ expect(container.querySelector(".ksui-fg-edge.flow")).toBeTruthy();
131
+
132
+ const plain = render(() => (
133
+ <FlowGraph
134
+ nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
135
+ edges={[{ from: "a", to: "b" }]}
136
+ />
137
+ ));
138
+ expect(plain.container.querySelector(".ksui-fg-edge.flow")).toBeNull();
139
+ });
105
140
  });
@@ -1,25 +1,66 @@
1
- // FlowGraph (Vision §9 companion to FlowRunner): a read-only renderer for a
2
- // DECLARATIVE node graph. Where FlowRunner *executes* a server-driven flow,
3
- // FlowGraph *draws* a static relationship graphplugin connections, a
4
- // role→permission map, any directed graph the host hands it.
1
+ // FlowGraph a blueprint/automation-tool renderer for a declarative node graph
2
+ // (the projection of a FlowDefinition; see utils/flow-spec). Where FlowRunner
3
+ // *executes* a flow, FlowGraph *draws* it as connectable node cards the way a
4
+ // game-engine node editor or n8n shows behavior. With `interactive` it's a
5
+ // pan/zoom canvas; with `animated` the connectors flow toward the arrowhead.
5
6
  //
6
- // Composite because it composes the pure graph model (utils/graph) with SVG
7
- // layout + interaction. Domain-free: it knows nothing about plugins or roles;
8
- // the host supplies typed nodes/edges and an optional click handler. Self-
9
- // contained CSS (ksui-fg-* unscoped classes + CSS custom props); no Tailwind,
10
- // no host-brand classes (standalone-library rule).
7
+ // Composite: composes the pure graph model (utils/graph) with SVG edges + HTML
8
+ // node cards (foreignObject) + interaction. Domain-free; self-contained CSS
9
+ // (ksui-fg-* classes + CSS custom props); no Tailwind, no host-brand classes;
10
+ // no graph/canvas library pan/zoom is a group transform, layout is the pure
11
+ // layoutGraph. Connectors are drawn BEHIND the opaque cards so an edge never
12
+ // visibly crosses a node block.
11
13
 
12
14
  import type { Component, JSX } from "solid-js";
13
- import { For, Show, createMemo } from "solid-js";
15
+ import { Dynamic } from "solid-js/web";
16
+ import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
17
+ import Database from "lucide-solid/icons/database";
18
+ import MousePointerClick from "lucide-solid/icons/mouse-pointer-click";
19
+ import AppWindow from "lucide-solid/icons/app-window";
20
+ import DownloadCloud from "lucide-solid/icons/download-cloud";
21
+ import ArrowLeftRight from "lucide-solid/icons/arrow-left-right";
22
+ import Calculator from "lucide-solid/icons/calculator";
23
+ import GitBranch from "lucide-solid/icons/git-branch";
24
+ import Save from "lucide-solid/icons/save";
25
+ import Radio from "lucide-solid/icons/radio";
26
+ import Sparkles from "lucide-solid/icons/sparkles";
27
+ import CircleCheck from "lucide-solid/icons/circle-check-big";
28
+ import Circle from "lucide-solid/icons/circle";
14
29
  import {
15
- DEFAULT_METRICS,
16
30
  layoutGraph,
31
+ type GraphDirection,
17
32
  type GraphEdge,
18
33
  type GraphLayout,
34
+ type GraphMetrics,
19
35
  type GraphNode,
20
36
  type PositionedNode,
21
37
  } from "../../utils/graph";
22
38
 
39
+ type IconComp = Component<{ size?: number; class?: string }>;
40
+
41
+ // Blueprint node-kind → icon. Unknown kinds fall back to a plain dot.
42
+ const KIND_ICON: Record<string, IconComp> = {
43
+ data: Database,
44
+ trigger: MousePointerClick,
45
+ modal: AppWindow,
46
+ load: DownloadCloud,
47
+ call: ArrowLeftRight,
48
+ compute: Calculator,
49
+ condition: GitBranch,
50
+ commit: Save,
51
+ emit: Radio,
52
+ effect: Sparkles,
53
+ terminal: CircleCheck,
54
+ };
55
+ const iconFor = (kind?: string): IconComp => KIND_ICON[kind ?? ""] ?? Circle;
56
+
57
+ // Gaps are at least half the larger node dimension (max(190,60)/2 ≈ 95) on both
58
+ // axes, so nodes never crowd a neighbour from any side.
59
+ const METRICS: GraphMetrics = { nodeW: 190, nodeH: 60, gapX: 100, gapY: 100, pad: 28 };
60
+ const { nodeW, nodeH, pad } = METRICS;
61
+ const ZOOM_MIN = 0.3;
62
+ const ZOOM_MAX = 3;
63
+
23
64
  const STYLE_ID = "ksui-flow-graph-style";
24
65
 
25
66
  function ensureStyle(): void {
@@ -28,30 +69,48 @@ function ensureStyle(): void {
28
69
  const style = document.createElement("style");
29
70
  style.id = STYLE_ID;
30
71
  style.textContent = `
31
- .ksui-fg-wrap{width:100%;overflow:auto;}
72
+ .ksui-fg-wrap{width:100%;overflow:auto;position:relative;user-select:none;-webkit-user-select:none;}
73
+ .ksui-fg-card,.ksui-fg-title,.ksui-fg-kind,.ksui-fg-elabel{user-select:none;-webkit-user-select:none;}
74
+ .ksui-fg-wrap.interactive{overflow:hidden;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:10px;background:var(--ksui-fg-canvas,#101014);background-image:radial-gradient(var(--ksui-fg-dot,rgba(255,255,255,0.05)) 1px,transparent 1px);background-size:20px 20px;cursor:grab;touch-action:none;}
75
+ .ksui-fg-wrap.interactive.grabbing{cursor:grabbing;}
76
+ .ksui-fg-wrap.scroll{overflow:auto;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:10px;background:var(--ksui-fg-canvas,#101014);background-image:radial-gradient(var(--ksui-fg-dot,rgba(255,255,255,0.05)) 1px,transparent 1px);background-size:20px 20px;}
32
77
  .ksui-fg-svg{display:block;max-width:100%;height:auto;font-family:inherit;}
33
- .ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.22));stroke-width:1.5;}
34
- .ksui-fg-edge.dashed{stroke-dasharray:4 4;}
78
+ .ksui-fg-wrap.scroll .ksui-fg-svg{margin:0 auto;}
79
+ .ksui-fg-wrap.interactive .ksui-fg-svg{max-width:none;width:100%;height:100%;}
80
+ .ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.28));stroke-width:1.75;}
81
+ .ksui-fg-edge.dashed{stroke-dasharray:5 4;}
35
82
  .ksui-fg-edge.primary{stroke:var(--ksui-fg-primary,#c9a961);}
36
- .ksui-fg-edge.info{stroke:#3b82f6;}
37
- .ksui-fg-edge.success{stroke:#22c55e;}
38
- .ksui-fg-edge.danger{stroke:#ef4444;}
39
- .ksui-fg-edge.muted{stroke:rgba(255,255,255,0.14);}
40
- .ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.7));font-size:9px;}
41
- .ksui-fg-elabel-bg{fill:var(--ksui-fg-bg,#18181b);opacity:0.82;}
42
- .ksui-fg-box{fill:var(--ksui-fg-node-bg,rgba(255,255,255,0.04));stroke:var(--ksui-fg-node-border,rgba(255,255,255,0.16));stroke-width:1;}
43
- .ksui-fg-node.primary .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);fill:rgba(201,169,97,0.08);}
44
- .ksui-fg-node.info .ksui-fg-box{stroke:#3b82f6;fill:rgba(59,130,246,0.08);}
45
- .ksui-fg-node.success .ksui-fg-box{stroke:#22c55e;fill:rgba(34,197,94,0.08);}
46
- .ksui-fg-node.danger .ksui-fg-box{stroke:#ef4444;fill:rgba(239,68,68,0.08);}
47
- .ksui-fg-node.muted .ksui-fg-box{stroke:rgba(255,255,255,0.16);fill:rgba(255,255,255,0.02);}
48
- .ksui-fg-node.clickable{cursor:pointer;}
49
- .ksui-fg-node.clickable:hover .ksui-fg-box{fill:rgba(255,255,255,0.10);}
50
- .ksui-fg-node.clickable:focus{outline:none;}
51
- .ksui-fg-node.clickable:focus-visible .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);stroke-width:2;}
52
- .ksui-fg-label{fill:var(--ksui-fg-fg,#e4e4e7);font-size:12px;font-weight:600;}
53
- .ksui-fg-sublabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.55));font-size:9.5px;}
83
+ .ksui-fg-edge.info{stroke:#5b9bf0;}
84
+ .ksui-fg-edge.success{stroke:#43c478;}
85
+ .ksui-fg-edge.danger{stroke:#ef6a6a;}
86
+ .ksui-fg-edge.muted{stroke:rgba(255,255,255,0.2);}
87
+ .ksui-fg-edge.flow{stroke-dasharray:6 5;animation:ksui-fg-march .8s linear infinite;}
88
+ @keyframes ksui-fg-march{to{stroke-dashoffset:-11;}}
89
+ @media (prefers-reduced-motion:reduce){.ksui-fg-edge.flow{animation:none;}}
90
+ .ksui-fg-handle{fill:var(--ksui-fg-canvas,#101014);stroke:var(--ksui-fg-edge,rgba(255,255,255,0.4));stroke-width:1.5;}
91
+ .ksui-fg-node,.ksui-fg-edge,.ksui-fg-elabel,.ksui-fg-elabel-bg,.ksui-fg-handle{transition:opacity .12s ease;}
92
+ .ksui-fg-dim{opacity:0.14;}
93
+ .ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.78));font-size:9.5px;}
94
+ .ksui-fg-elabel-bg{fill:var(--ksui-fg-canvas,#101014);opacity:0.92;}
95
+ .ksui-fg-card{box-sizing:border-box;height:100%;display:flex;align-items:center;gap:9px;padding:0 11px;border-radius:9px;background:var(--ksui-fg-card,#1c1c22);border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.14));border-left-width:3px;overflow:hidden;}
96
+ .ksui-fg-node.clickable .ksui-fg-card{cursor:pointer;}
97
+ .ksui-fg-node.clickable .ksui-fg-card:hover{background:#23232b;}
98
+ .ksui-fg-node:focus{outline:none;}
99
+ .ksui-fg-node:focus-visible .ksui-fg-card{border-color:var(--ksui-fg-primary,#c9a961);}
100
+ .ksui-fg-chip{flex:none;width:30px;height:30px;border-radius:7px;display:flex;align-items:center;justify-content:center;color:#fff;}
101
+ .ksui-fg-txt{min-width:0;display:flex;flex-direction:column;line-height:1.15;}
102
+ .ksui-fg-title{font-size:12px;font-weight:600;color:var(--ksui-fg-fg,#ececef);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
103
+ .ksui-fg-kind{font-size:9px;text-transform:uppercase;letter-spacing:0.06em;color:var(--ksui-fg-muted,rgba(255,255,255,0.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
104
+ .ksui-fg-card.primary{border-left-color:#c9a961;} .ksui-fg-card.primary .ksui-fg-chip{background:#c9a961;color:#18181b;}
105
+ .ksui-fg-card.info{border-left-color:#5b9bf0;} .ksui-fg-card.info .ksui-fg-chip{background:#3f6fb0;}
106
+ .ksui-fg-card.success{border-left-color:#43c478;} .ksui-fg-card.success .ksui-fg-chip{background:#2f8e57;}
107
+ .ksui-fg-card.danger{border-left-color:#ef6a6a;} .ksui-fg-card.danger .ksui-fg-chip{background:#b04545;}
108
+ .ksui-fg-card.muted{border-left-color:rgba(255,255,255,0.3);} .ksui-fg-card.muted .ksui-fg-chip{background:#3a3a42;}
54
109
  .ksui-fg-empty{padding:1.75rem 1rem;text-align:center;font-size:0.82rem;color:var(--ksui-fg-muted,rgba(255,255,255,0.55));}
110
+ .ksui-fg-controls{position:absolute;right:8px;bottom:8px;display:flex;gap:4px;z-index:1;}
111
+ .ksui-fg-ctrl{width:24px;height:24px;display:grid;place-items:center;border-radius:5px;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.18));background:var(--ksui-fg-card,#1c1c22);color:var(--ksui-fg-fg,#ececef);font-size:14px;line-height:1;cursor:pointer;user-select:none;}
112
+ .ksui-fg-ctrl:hover{background:#23232b;}
113
+ .ksui-fg-hint{position:absolute;left:8px;bottom:8px;font-size:9.5px;color:var(--ksui-fg-muted,rgba(255,255,255,0.4));pointer-events:none;}
55
114
  `;
56
115
  document.head.appendChild(style);
57
116
  }
@@ -65,45 +124,280 @@ export interface FlowGraphProps {
65
124
  emptyLabel?: string;
66
125
  /** Accessible description of the whole graph (the svg's aria-label). */
67
126
  ariaLabel?: string;
68
- /** When supplied, nodes become buttons that fire this with the node id. */
127
+ /** When supplied, node cards become buttons that fire this with the node id. */
69
128
  onNodeSelect?: (id: string) => void;
129
+ /** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. Ignored for
130
+ * vertical direction, which scrolls instead. */
131
+ interactive?: boolean;
132
+ /** Animate connectors as marching dashes flowing toward the arrowhead. */
133
+ animated?: boolean;
134
+ /** "horizontal" (default) flows left→right; "vertical" flows top→bottom and
135
+ * renders in a scrollable panel (scroll to follow the flow). */
136
+ direction?: GraphDirection;
137
+ /** Viewport height in px — canvas height (horizontal) or max scroll height
138
+ * (vertical). Default 360. */
139
+ height?: number;
70
140
  testId?: string;
71
141
  }
72
142
 
73
- /** SVG has no text overflow; trim to keep labels inside the node box. */
143
+ /** Trim a label to fit the card (CSS ellipsis also guards, but keep SVG sane). */
74
144
  function clip(text: string, max: number): string {
75
145
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
76
146
  }
77
147
 
78
- const { nodeW, nodeH } = DEFAULT_METRICS;
79
-
80
148
  export const FlowGraph: Component<FlowGraphProps> = (props) => {
81
149
  ensureStyle();
82
150
  const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
83
151
 
152
+ const vertical = () => props.direction === "vertical";
153
+ // Pan/zoom canvas applies whenever interactive — in BOTH directions. Direction
154
+ // only changes the layout axis + how connectors leave/enter the cards.
155
+ const canvas = () => !!props.interactive;
84
156
  const laid = createMemo(() =>
85
- layoutGraph(props.nodes, props.edges, props.layout ?? "layered"),
157
+ layoutGraph(props.nodes, props.edges, props.layout ?? "layered", METRICS, props.direction),
86
158
  );
87
159
 
88
- // A cubic bezier from a source node's right edge to a target's left edge.
89
- const edgePath = (s: PositionedNode, t: PositionedNode): string => {
90
- const x1 = s.x + nodeW;
91
- const y1 = s.y + nodeH / 2;
92
- const x2 = t.x;
93
- const y2 = t.y + nodeH / 2;
94
- const dx = Math.max(36, (x2 - x1) / 2);
95
- return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
160
+ // Node ports: a node shows an INPUT handle only when something feeds it (so a
161
+ // trigger/root has none), and one OUTPUT handle per outgoing edge — a 2-branch
162
+ // condition therefore has two output handles, each driving a distinct edge.
163
+ const ports = createMemo(() => {
164
+ const incoming = new Set<string>();
165
+ const outBySource = new Map<string, GraphEdge[]>();
166
+ for (const e of props.edges) {
167
+ incoming.add(e.to);
168
+ const arr = outBySource.get(e.from) ?? [];
169
+ arr.push(e);
170
+ outBySource.set(e.from, arr);
171
+ }
172
+ return { incoming, outBySource };
173
+ });
174
+
175
+ // Order each node's outgoing edges by their TARGET's cross-axis position, so
176
+ // output handle 0 (leftmost/topmost) drives the edge whose target sits
177
+ // leftmost — branches then fan out without crossing (a condition's yes/no
178
+ // reach the correct sides). Falls back to declared order when positions tie.
179
+ const outRank = createMemo(() => {
180
+ const rank = new Map<GraphEdge, number>();
181
+ const cross = (id: string) => {
182
+ const p = laid().byId.get(id);
183
+ return p ? (vertical() ? p.x : p.y) : 0;
184
+ };
185
+ for (const es of ports().outBySource.values()) {
186
+ [...es]
187
+ .map((e, i) => ({ e, i }))
188
+ .sort((a, b) => cross(a.e.to) - cross(b.e.to) || a.i - b.i)
189
+ .forEach(({ e }, idx) => rank.set(e, idx));
190
+ }
191
+ return rank;
192
+ });
193
+
194
+ // The position of a node's i-th output handle (of n), spread along the leaving
195
+ // edge (bottom for vertical, right for horizontal). A single output centers.
196
+ const outHandle = (src: PositionedNode, i: number, n: number): { x: number; y: number } => {
197
+ const f = (i + 1) / (n + 1);
198
+ return vertical()
199
+ ? { x: src.x + nodeW * f, y: src.y + nodeH }
200
+ : { x: src.x + nodeW, y: src.y + nodeH * f };
201
+ };
202
+ const inHandle = (dst: PositionedNode): { x: number; y: number } =>
203
+ vertical()
204
+ ? { x: dst.x + nodeW / 2, y: dst.y }
205
+ : { x: dst.x, y: dst.y + nodeH / 2 };
206
+
207
+ // ── Node drag (move a node; edges follow) ──────────────────────────────────
208
+ const [offsets, setOffsets] = createSignal<Record<string, { dx: number; dy: number }>>({});
209
+ // The drawn position of a node = its laid-out slot + any drag offset.
210
+ const pos = (n: PositionedNode): PositionedNode => {
211
+ const o = offsets()[n.id];
212
+ return o ? { ...n, x: n.x + o.dx, y: n.y + o.dy } : n;
213
+ };
214
+ const drawn = (id: string): PositionedNode | undefined => {
215
+ const n = laid().byId.get(id);
216
+ return n ? pos(n) : undefined;
217
+ };
218
+
219
+ // ── Highlight (hover/click dims everything not connected) ──────────────────
220
+ const [hover, setHover] = createSignal<string | null>(null);
221
+ const [pinned, setPinned] = createSignal<string | null>(null);
222
+ const active = () => pinned() ?? hover();
223
+ const lit = createMemo<Set<string> | null>(() => {
224
+ const a = active();
225
+ if (!a) return null;
226
+ const s = new Set<string>([a]);
227
+ for (const e of props.edges) {
228
+ if (e.from === a) s.add(e.to);
229
+ if (e.to === a) s.add(e.from);
230
+ }
231
+ return s;
232
+ });
233
+ const nodeDim = (id: string) => lit() !== null && !lit()!.has(id);
234
+ const edgeDim = (e: GraphEdge) =>
235
+ lit() !== null && !(lit()!.has(e.from) && lit()!.has(e.to) && (e.from === active() || e.to === active()));
236
+
237
+ const [view, setView] = createSignal({ x: 0, y: 0, k: 1 });
238
+ const [grabbing, setGrabbing] = createSignal(false);
239
+ let svgRef: SVGSVGElement | undefined;
240
+ let dragging = false;
241
+ let lastX = 0;
242
+ let lastY = 0;
243
+ let moved = false;
244
+
245
+ // Fit the whole graph into the viewport (zoom-to-fit), centered, from the
246
+ // ACTUAL drawn bounding box (including any drag offsets) so the reset control
247
+ // always frames everything regardless of pan/zoom/drag state.
248
+ const fitView = () => {
249
+ if (!svgRef) return;
250
+ const rect = svgRef.getBoundingClientRect();
251
+ const vw = rect.width;
252
+ const vh = rect.height;
253
+ const ns = laid().nodes.map(pos);
254
+ if (!vw || !vh || ns.length === 0) return;
255
+ const minX = Math.min(...ns.map((n) => n.x)) - pad;
256
+ const minY = Math.min(...ns.map((n) => n.y)) - pad;
257
+ const maxX = Math.max(...ns.map((n) => n.x)) + nodeW + pad;
258
+ const maxY = Math.max(...ns.map((n) => n.y)) + nodeH + pad;
259
+ const gw = Math.max(1, maxX - minX);
260
+ const gh = Math.max(1, maxY - minY);
261
+ const k = Math.min(vw / gw, vh / gh, 1.4);
262
+ setView({ x: (vw - gw * k) / 2 - minX * k, y: (vh - gh * k) / 2 - minY * k, k });
263
+ };
264
+ // Default placement: NOT zoom-to-fit (keep 1:1), just pan so the graph's top is
265
+ // centered horizontally and visible — otherwise a centered layout can sit
266
+ // off-screen. Re-runs whenever the node SET changes (e.g. the flow selector
267
+ // switches flows), clearing prior drags, so a freshly-chosen flow is framed
268
+ // instead of leaving the view on the previous flow's now-empty region.
269
+ const nodeSig = createMemo(() => props.nodes.map((n) => n.id).join("|"));
270
+ let lastSig: string | null = null;
271
+ createEffect(() => {
272
+ const sig = nodeSig();
273
+ if (canvas() && sig !== lastSig && laid().nodes.length > 0 && svgRef) {
274
+ lastSig = sig;
275
+ setOffsets({});
276
+ requestAnimationFrame(() => {
277
+ if (!svgRef) return;
278
+ const vw = svgRef.clientWidth || 0;
279
+ const ns = laid().nodes;
280
+ if (!vw || ns.length === 0) return;
281
+ const minX = Math.min(...ns.map((n) => n.x));
282
+ const maxX = Math.max(...ns.map((n) => n.x)) + nodeW;
283
+ const minY = Math.min(...ns.map((n) => n.y));
284
+ // Center horizontally, align the content's actual top to a small margin.
285
+ setView({ x: vw / 2 - (minX + maxX) / 2, y: pad - minY, k: 1 });
286
+ });
287
+ }
288
+ });
289
+
290
+ const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
291
+ const zoomBy = (factor: number, cx?: number, cy?: number) => {
292
+ const v = view();
293
+ const k = clampK(v.k * factor);
294
+ const px = cx ?? (svgRef?.clientWidth ?? 0) / 2;
295
+ const py = cy ?? (svgRef?.clientHeight ?? 0) / 2;
296
+ setView({ x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k), k });
297
+ };
298
+ const onWheel = (e: WheelEvent) => {
299
+ if (!canvas()) return;
300
+ e.preventDefault();
301
+ const rect = svgRef?.getBoundingClientRect();
302
+ zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - (rect?.left ?? 0), e.clientY - (rect?.top ?? 0));
303
+ };
304
+ // A press that started on a node card (recorded by the card's handler) becomes
305
+ // a node-drag; a press on empty canvas pans. svgRef captures the pointer so the
306
+ // gesture continues even when it leaves the element.
307
+ let pendingNode: string | null = null;
308
+ let nodeDrag: string | null = null;
309
+ const onPointerDown = (e: PointerEvent) => {
310
+ if (!canvas()) return;
311
+ moved = false;
312
+ lastX = e.clientX;
313
+ lastY = e.clientY;
314
+ svgRef?.setPointerCapture(e.pointerId);
315
+ if (pendingNode) {
316
+ nodeDrag = pendingNode;
317
+ pendingNode = null;
318
+ } else {
319
+ dragging = true;
320
+ setGrabbing(true);
321
+ }
322
+ };
323
+ const onPointerMove = (e: PointerEvent) => {
324
+ if (!dragging && !nodeDrag) return;
325
+ const dx = e.clientX - lastX;
326
+ const dy = e.clientY - lastY;
327
+ if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
328
+ lastX = e.clientX;
329
+ lastY = e.clientY;
330
+ if (nodeDrag) {
331
+ const id = nodeDrag;
332
+ const k = view().k || 1;
333
+ setOffsets((o) => {
334
+ const cur = o[id] ?? { dx: 0, dy: 0 };
335
+ return { ...o, [id]: { dx: cur.dx + dx / k, dy: cur.dy + dy / k } };
336
+ });
337
+ } else {
338
+ setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
339
+ }
340
+ };
341
+ const endDrag = (e: PointerEvent) => {
342
+ // A press on a node that didn't move = a click → toggle its pinned highlight.
343
+ // A press on empty canvas that didn't move = a click-away → clear the focus.
344
+ if (nodeDrag && !moved) setPinned((p) => (p === nodeDrag ? null : nodeDrag));
345
+ else if (dragging && !moved) setPinned(null);
346
+ dragging = false;
347
+ nodeDrag = null;
348
+ pendingNode = null;
349
+ setGrabbing(false);
350
+ try {
351
+ svgRef?.releasePointerCapture(e.pointerId);
352
+ } catch {
353
+ /* already released */
354
+ }
355
+ };
356
+
357
+ // Bezier from a source output handle to a target input handle. Control points
358
+ // pulled along the flow axis (down for vertical, right for horizontal) so the
359
+ // curve bows through the gaps rather than cutting across rows.
360
+ // Control-point reach is capped so a long edge (e.g. a back-edge looping up)
361
+ // curves gently instead of ballooning its handles far past the endpoints.
362
+ const edgePath = (a: { x: number; y: number }, b: { x: number; y: number }): string => {
363
+ if (vertical()) {
364
+ const dy = Math.min(110, Math.max(34, Math.abs(b.y - a.y) * 0.5));
365
+ return `M ${a.x} ${a.y} C ${a.x} ${a.y + dy}, ${b.x} ${b.y - dy}, ${b.x} ${b.y}`;
366
+ }
367
+ const dx = Math.min(140, Math.max(46, Math.abs(b.x - a.x) * 0.5));
368
+ return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
96
369
  };
370
+ const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
371
+ x: (a.x + b.x) / 2,
372
+ y: (a.y + b.y) / 2,
373
+ });
97
374
 
375
+ const selectNode = (id: string) => {
376
+ if (moved) return;
377
+ props.onNodeSelect?.(id);
378
+ };
98
379
  const activate = (e: KeyboardEvent, id: string) => {
99
380
  if (e.key === "Enter" || e.key === " ") {
100
381
  e.preventDefault();
101
382
  props.onNodeSelect?.(id);
102
383
  }
103
384
  };
385
+ const groupTransform = () =>
386
+ canvas() ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
104
387
 
105
388
  return (
106
- <div class="ksui-fg-wrap" data-testid={tid("root")}>
389
+ <div
390
+ class="ksui-fg-wrap"
391
+ classList={{ interactive: canvas(), scroll: vertical(), grabbing: grabbing() }}
392
+ style={
393
+ canvas()
394
+ ? { height: `${props.height ?? 360}px` }
395
+ : vertical()
396
+ ? { "max-height": `${props.height ?? 360}px` }
397
+ : undefined
398
+ }
399
+ data-testid={tid("root")}
400
+ >
107
401
  <Show
108
402
  when={laid().nodes.length > 0}
109
403
  fallback={
@@ -113,13 +407,19 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
113
407
  }
114
408
  >
115
409
  <svg
410
+ ref={svgRef}
116
411
  class="ksui-fg-svg"
117
- viewBox={`0 0 ${laid().width} ${laid().height}`}
118
- width={laid().width}
119
- height={laid().height}
412
+ viewBox={canvas() ? undefined : `0 0 ${laid().width} ${laid().height}`}
413
+ width={canvas() ? "100%" : laid().width}
414
+ height={canvas() ? "100%" : laid().height}
120
415
  role="img"
121
- aria-label={props.ariaLabel ?? "Relationship graph"}
416
+ aria-label={props.ariaLabel ?? "Flow graph"}
122
417
  data-testid={tid("svg")}
418
+ onWheel={onWheel}
419
+ onPointerDown={onPointerDown}
420
+ onPointerMove={onPointerMove}
421
+ onPointerUp={endDrag}
422
+ onPointerCancel={endDrag}
123
423
  >
124
424
  <defs>
125
425
  <marker
@@ -131,83 +431,163 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
131
431
  markerHeight="6"
132
432
  orient="auto-start-reverse"
133
433
  >
134
- <path d="M0 0 L8 4 L0 8 z" fill="var(--ksui-fg-edge,rgba(255,255,255,0.35))" />
434
+ <path d="M0 0 L8 4 L0 8 z" fill="var(--ksui-fg-edge,rgba(255,255,255,0.45))" />
135
435
  </marker>
136
436
  </defs>
137
437
 
138
- {/* Edges first so nodes paint on top of the connectors. */}
139
- <For each={props.edges}>
140
- {(e) => {
141
- const s = () => laid().byId.get(e.from);
142
- const t = () => laid().byId.get(e.to);
143
- return (
144
- <Show when={s() && t()}>
145
- {(() => {
146
- const src = s() as PositionedNode;
147
- const dst = t() as PositionedNode;
148
- const mx = (src.x + nodeW + dst.x) / 2;
149
- const my = (src.y + dst.y) / 2 + nodeH / 2;
150
- return (
151
- <g>
152
- <path
153
- class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""}`}
154
- d={edgePath(src, dst)}
155
- marker-end="url(#ksui-fg-arrow)"
156
- />
157
- <Show when={e.label}>
158
- <rect
159
- class="ksui-fg-elabel-bg"
160
- x={mx - clip(e.label!, 18).length * 2.6 - 3}
161
- y={my - 7}
162
- width={clip(e.label!, 18).length * 5.2 + 6}
163
- height={12}
164
- rx={2}
438
+ <g transform={groupTransform()}>
439
+ {/* 1) Connectors — drawn first so the opaque cards paint over them. */}
440
+ <For each={props.edges}>
441
+ {(e) => {
442
+ const s = () => drawn(e.from);
443
+ const t = () => drawn(e.to);
444
+ return (
445
+ <Show when={s() && t()}>
446
+ {(() => {
447
+ // Reactive endpoints so the connector follows a dragged node;
448
+ // the output-handle slot is ranked by target position to avoid
449
+ // crossings.
450
+ const sibs = ports().outBySource.get(e.from) ?? [];
451
+ const a = () =>
452
+ outHandle(s() as PositionedNode, outRank().get(e) ?? 0, sibs.length || 1);
453
+ const b = () => inHandle(t() as PositionedNode);
454
+ const m = () => mid(a(), b());
455
+ const lbl = () => clip(e.label ?? "", 18);
456
+ return (
457
+ <g classList={{ "ksui-fg-dim": edgeDim(e) }}>
458
+ <path
459
+ class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""} ${
460
+ props.animated ? "flow" : ""
461
+ }`}
462
+ d={edgePath(a(), b())}
463
+ marker-end="url(#ksui-fg-arrow)"
165
464
  />
166
- <text class="ksui-fg-elabel" x={mx} y={my + 2} text-anchor="middle">
167
- {clip(e.label!, 18)}
168
- </text>
169
- </Show>
170
- </g>
171
- );
172
- })()}
173
- </Show>
174
- );
175
- }}
176
- </For>
177
-
178
- {/* Nodes */}
179
- <For each={laid().nodes}>
180
- {(n) => {
181
- const interactive = () => typeof props.onNodeSelect === "function";
182
- return (
183
- <g
184
- class={`ksui-fg-node ${n.accent ?? ""} ${interactive() ? "clickable" : ""}`}
185
- transform={`translate(${n.x} ${n.y})`}
186
- data-testid={tid(`node-${n.id}`)}
187
- role={interactive() ? "button" : undefined}
188
- tabindex={interactive() ? 0 : undefined}
189
- aria-label={n.sublabel ? `${n.label} — ${n.sublabel}` : n.label}
190
- onClick={interactive() ? () => props.onNodeSelect!(n.id) : undefined}
191
- onKeyDown={interactive() ? (ev) => activate(ev, n.id) : undefined}
192
- >
193
- <rect class="ksui-fg-box" width={nodeW} height={nodeH} rx={8} />
194
- <text
195
- class="ksui-fg-label"
196
- x={12}
197
- y={n.sublabel ? 20 : nodeH / 2 + 4}
198
- >
199
- {clip(n.label, 24)}
200
- </text>
201
- <Show when={n.sublabel}>
202
- <text class="ksui-fg-sublabel" x={12} y={34}>
203
- {clip(n.sublabel!, 28)}
204
- </text>
465
+ <Show when={e.label}>
466
+ <rect
467
+ class="ksui-fg-elabel-bg"
468
+ x={m().x - lbl().length * 2.7 - 3}
469
+ y={m().y - 7}
470
+ width={lbl().length * 5.4 + 6}
471
+ height={12}
472
+ rx={3}
473
+ />
474
+ <text class="ksui-fg-elabel" x={m().x} y={m().y + 2} text-anchor="middle">
475
+ {lbl()}
476
+ </text>
477
+ </Show>
478
+ </g>
479
+ );
480
+ })()}
205
481
  </Show>
206
- </g>
207
- );
208
- }}
209
- </For>
482
+ );
483
+ }}
484
+ </For>
485
+
486
+ {/* 2) Node cards (opaque HTML via foreignObject — the blueprint look). */}
487
+ <For each={laid().nodes}>
488
+ {(base) => {
489
+ const n = () => pos(base);
490
+ const clickable = () => typeof props.onNodeSelect === "function";
491
+ return (
492
+ <foreignObject
493
+ x={n().x}
494
+ y={n().y}
495
+ width={nodeW}
496
+ height={nodeH}
497
+ class="ksui-fg-node"
498
+ classList={{ clickable: clickable(), "ksui-fg-dim": nodeDim(base.id) }}
499
+ data-testid={tid(`node-${base.id}`)}
500
+ role={clickable() ? "button" : undefined}
501
+ tabindex={clickable() ? 0 : undefined}
502
+ aria-label={base.sublabel ? `${base.label} — ${base.sublabel}` : base.label}
503
+ onClick={clickable() ? () => selectNode(base.id) : undefined}
504
+ onKeyDown={clickable() ? (ev) => activate(ev, base.id) : undefined}
505
+ onPointerDown={canvas() ? () => (pendingNode = base.id) : undefined}
506
+ onPointerEnter={canvas() ? () => setHover(base.id) : undefined}
507
+ onPointerLeave={canvas() ? () => setHover(null) : undefined}
508
+ >
509
+ <div
510
+ // @ts-expect-error xmlns switches the foreignObject child back to HTML ns
511
+ xmlns="http://www.w3.org/1999/xhtml"
512
+ class={`ksui-fg-card ${base.accent ?? "muted"}`}
513
+ style={canvas() ? { cursor: "grab" } : { "pointer-events": "none" }}
514
+ >
515
+ <span class="ksui-fg-chip">
516
+ <Dynamic component={iconFor(base.kind)} size={15} />
517
+ </span>
518
+ <span class="ksui-fg-txt">
519
+ <span class="ksui-fg-title">{base.label}</span>
520
+ <Show when={base.sublabel}>
521
+ <span class="ksui-fg-kind">{base.sublabel}</span>
522
+ </Show>
523
+ </span>
524
+ </div>
525
+ </foreignObject>
526
+ );
527
+ }}
528
+ </For>
529
+
530
+ {/* 3) I/O port handles, over everything. INPUT only when fed (a
531
+ trigger/root has none); one OUTPUT per outgoing edge (a 2-branch
532
+ condition shows two). */}
533
+ <For each={laid().nodes}>
534
+ {(base) => {
535
+ const n = () => pos(base);
536
+ const outs = () => ports().outBySource.get(base.id) ?? [];
537
+ const hasIn = () => ports().incoming.has(base.id);
538
+ return (
539
+ <g classList={{ "ksui-fg-dim": nodeDim(base.id) }}>
540
+ {/* cx/cy are accessor-driven so handles follow a dragged node. */}
541
+ <Show when={hasIn()}>
542
+ <circle
543
+ class="ksui-fg-handle in"
544
+ cx={inHandle(n()).x}
545
+ cy={inHandle(n()).y}
546
+ r={3.5}
547
+ />
548
+ </Show>
549
+ <For each={outs()}>
550
+ {(_e, i) => (
551
+ <circle
552
+ class="ksui-fg-handle out"
553
+ cx={outHandle(n(), i(), outs().length).x}
554
+ cy={outHandle(n(), i(), outs().length).y}
555
+ r={3.5}
556
+ />
557
+ )}
558
+ </For>
559
+ </g>
560
+ );
561
+ }}
562
+ </For>
563
+ </g>
210
564
  </svg>
565
+
566
+ <Show when={canvas()}>
567
+ <div class="ksui-fg-controls" data-testid={tid("controls")}>
568
+ <button type="button" class="ksui-fg-ctrl" aria-label="Zoom in" onClick={() => zoomBy(1.2)}>
569
+ +
570
+ </button>
571
+ <button
572
+ type="button"
573
+ class="ksui-fg-ctrl"
574
+ aria-label="Zoom out"
575
+ onClick={() => zoomBy(1 / 1.2)}
576
+ >
577
+
578
+ </button>
579
+ <button
580
+ type="button"
581
+ class="ksui-fg-ctrl"
582
+ aria-label="Fit to view"
583
+ data-testid={tid("reset")}
584
+ onClick={fitView}
585
+ >
586
+
587
+ </button>
588
+ </div>
589
+ <span class="ksui-fg-hint">drag to pan · scroll to zoom</span>
590
+ </Show>
211
591
  </Show>
212
592
  </div>
213
593
  ) as JSX.Element;
@@ -169,7 +169,7 @@ export default function SearchableSelect(props: SearchableSelectProps): JSX.Elem
169
169
  <Portal>
170
170
  <div
171
171
  ref={popupRef}
172
- class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
172
+ class="z-[10000] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
173
173
  style={popupStyle()}
174
174
  >
175
175
  <div class="px-2 py-1.5 border-b border-zinc-800">
package/src/index.ts CHANGED
@@ -239,6 +239,14 @@ export type {
239
239
  FlowInput,
240
240
  } from "./utils/flow";
241
241
 
242
+ // Flow-spec (pure): the node-based PROGRAM model — defineFlow + node/edge
243
+ // builders producing a serializable FlowDefinition (the source of truth an
244
+ // author writes in code), plus flowToGraph which lowers it to the graph the
245
+ // FlowGraph canvas draws. This is the "authored in SDK code → parsed to a
246
+ // diagram" seam.
247
+ export { defineFlow, node, edge, flowToGraph } from "./utils/flow-spec";
248
+ export type { FlowDefinition, FlowNodeDef, FlowNodeKind, FlowPort } from "./utils/flow-spec";
249
+
242
250
  // FlowGraph model (pure): the node/edge types + the dependency-free layout the
243
251
  // renderer uses. Exported so hosts can type their graph data and, if needed,
244
252
  // pre-compute layout off the DOM.
@@ -0,0 +1,53 @@
1
+ // flow-spec: the node-based program model + its lowering to the graph the canvas
2
+ // draws. Tests the parse step (flowToGraph) and the dangling-edge guard.
3
+ import { describe, expect, it } from "vitest";
4
+ import { defineFlow, edge, node, flowToGraph } from "./flow-spec";
5
+
6
+ describe("defineFlow", () => {
7
+ it("returns the definition unchanged when edges are valid", () => {
8
+ const def = defineFlow({
9
+ id: "demo",
10
+ title: "Demo",
11
+ nodes: [
12
+ node("a", "trigger", "Start", { out: [edge("b")] }),
13
+ node("b", "terminal", "Done"),
14
+ ],
15
+ });
16
+ expect(def.nodes).toHaveLength(2);
17
+ });
18
+
19
+ it("throws on an edge to an unknown node", () => {
20
+ expect(() =>
21
+ defineFlow({
22
+ id: "bad",
23
+ title: "Bad",
24
+ nodes: [node("a", "trigger", "Start", { out: [edge("ghost")] })],
25
+ }),
26
+ ).toThrow(/unknown node "ghost"/);
27
+ });
28
+ });
29
+
30
+ describe("flowToGraph", () => {
31
+ it("lowers nodes (kind + detail) and edges (branch labels) to the graph model", () => {
32
+ const { nodes, edges } = flowToGraph({
33
+ id: "checkout",
34
+ title: "Checkout",
35
+ nodes: [
36
+ node("pick", "trigger", "Pick voucher", { out: [edge("validate", "voucher chosen")] }),
37
+ node("validate", "call", "Validate", { detail: "vouchers.validate", out: [edge("done")] }),
38
+ node("done", "effect", "Refresh"),
39
+ ],
40
+ });
41
+ expect(nodes.map((n) => n.kind)).toEqual(["trigger", "call", "effect"]);
42
+ // detail becomes the sublabel; kind falls back when no detail
43
+ expect(nodes[1].sublabel).toBe("vouchers.validate");
44
+ expect(nodes[0].sublabel).toBe("trigger");
45
+ // a named branch is dashed + labeled; the default "out" is solid + unlabeled
46
+ const branch = edges.find((e) => e.from === "pick")!;
47
+ expect(branch.label).toBe("voucher chosen");
48
+ expect(branch.dashed).toBe(true);
49
+ const plain = edges.find((e) => e.from === "validate")!;
50
+ expect(plain.dashed).toBe(false);
51
+ expect(plain.label).toBeUndefined();
52
+ });
53
+ });
@@ -0,0 +1,124 @@
1
+ // Node-based program model — a plugin's behavior as a graph of connectable
2
+ // nodes (game-engine / n8n style). This is the SOURCE OF TRUTH a viewer renders
3
+ // and (in future) an editor edits; the diagram is just its projection. Pure +
4
+ // serializable: no DOM, no solid, no app/domain assumptions. `flowToGraph`
5
+ // lowers a FlowDefinition into the generic GraphNode/GraphEdge the FlowGraph
6
+ // canvas draws — that lowering IS the "automatically visualized" step.
7
+
8
+ import type { GraphAccent, GraphEdge, GraphNode } from "./graph";
9
+
10
+ /**
11
+ * The kinds of node a behavior graph is built from. Spans the WHOLE plugin —
12
+ * data sources, UI triggers, modals, loads, peer calls, computations, branches,
13
+ * writes and events — so a non-technical reader sees the real behavior, not an
14
+ * RPC list.
15
+ */
16
+ export type FlowNodeKind =
17
+ | "data" // a data source / list (e.g. "list availments")
18
+ | "trigger" // a UI event that starts or continues the graph (button, selection)
19
+ | "modal" // opens an overlay / screen
20
+ | "load" // fetches/loads data into the current screen
21
+ | "call" // a cross-plugin capability call
22
+ | "compute" // a pure computation (e.g. apply a discount)
23
+ | "condition" // a branch
24
+ | "commit" // a mutation / write (a command)
25
+ | "emit" // emits a domain event
26
+ | "effect" // a UI effect (refresh / toast / navigate)
27
+ | "terminal"; // an end state
28
+
29
+ /** One outgoing connection from a node. `id` is the branch name ("out" = the
30
+ * single default output); `label` annotates the edge ("voucher chosen"). */
31
+ export interface FlowPort {
32
+ id: string;
33
+ to: string;
34
+ label?: string;
35
+ }
36
+
37
+ export interface FlowNodeDef {
38
+ id: string;
39
+ kind: FlowNodeKind;
40
+ label: string;
41
+ /** Secondary line: a `peer.method` for a call, the computed field for a
42
+ * compute, the data source for a load — what makes the node concrete. */
43
+ detail?: string;
44
+ /** Outgoing connections (control/data flow). Omitted/empty = a leaf. */
45
+ out?: FlowPort[];
46
+ }
47
+
48
+ export interface FlowDefinition {
49
+ id: string;
50
+ title: string;
51
+ /** Entry node id; defaults to the first node when omitted. */
52
+ entry?: string;
53
+ nodes: FlowNodeDef[];
54
+ }
55
+
56
+ /** Author a connection. `edge("charge")` or `edge("charge", "voucher chosen")`. */
57
+ export function edge(to: string, label?: string): FlowPort {
58
+ return label === undefined ? { id: "out", to } : { id: label, to, label };
59
+ }
60
+
61
+ /** Author one node. Sugar over the literal so authored graphs read top-down. */
62
+ export function node(
63
+ id: string,
64
+ kind: FlowNodeKind,
65
+ label: string,
66
+ opts?: { detail?: string; out?: FlowPort[] },
67
+ ): FlowNodeDef {
68
+ return { id, kind, label, detail: opts?.detail, out: opts?.out };
69
+ }
70
+
71
+ /** Identity + light validation: the authored graph IS the definition. Throws on
72
+ * a dangling edge so an author catches a typo'd target id immediately. */
73
+ export function defineFlow(def: FlowDefinition): FlowDefinition {
74
+ const ids = new Set(def.nodes.map((n) => n.id));
75
+ for (const n of def.nodes) {
76
+ for (const p of n.out ?? []) {
77
+ if (!ids.has(p.to)) {
78
+ throw new Error(`flow "${def.id}": node "${n.id}" connects to unknown node "${p.to}"`);
79
+ }
80
+ }
81
+ }
82
+ return def;
83
+ }
84
+
85
+ /** Visual accent per node kind — keeps the palette consistent across consumers. */
86
+ const KIND_ACCENT: Record<FlowNodeKind, GraphAccent> = {
87
+ data: "info",
88
+ trigger: "primary",
89
+ modal: "info",
90
+ load: "muted",
91
+ call: "success",
92
+ compute: "primary",
93
+ condition: "danger",
94
+ commit: "success",
95
+ emit: "info",
96
+ effect: "muted",
97
+ terminal: "muted",
98
+ };
99
+
100
+ /**
101
+ * Lower a FlowDefinition to the generic graph model the FlowGraph canvas draws.
102
+ * Node `kind` flows through (the renderer maps it to an icon); `detail` becomes
103
+ * the sublabel; branch ports become labeled edges. This is the parse step: one
104
+ * declarative source → one diagram, no hand-drawing.
105
+ */
106
+ export function flowToGraph(def: FlowDefinition): { nodes: GraphNode[]; edges: GraphEdge[] } {
107
+ const nodes: GraphNode[] = def.nodes.map((n) => ({
108
+ id: n.id,
109
+ label: n.label,
110
+ sublabel: n.detail ?? n.kind,
111
+ kind: n.kind,
112
+ accent: KIND_ACCENT[n.kind],
113
+ }));
114
+ const edges: GraphEdge[] = def.nodes.flatMap((n) =>
115
+ (n.out ?? []).map((p) => ({
116
+ from: n.id,
117
+ to: p.to,
118
+ label: p.label,
119
+ // A named (non-default) branch reads as conditional — draw it dashed.
120
+ dashed: p.id !== "out",
121
+ })),
122
+ );
123
+ return { nodes, edges };
124
+ }
Binary file