@kahitsan/ksui 0.23.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.23.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">
@@ -1,27 +1,66 @@
1
- // FlowGraph (Vision §9 companion to FlowRunner): a renderer for a DECLARATIVE
2
- // node graph. Where FlowRunner *executes* a server-driven flow, FlowGraph
3
- // *draws* it a flow's trigger→form→call→effect chain, a plugin-connection
4
- // map, any directed graph. With `interactive` it becomes a pan/zoom CANVAS;
5
- // with `animated` the edges show flow direction as marching dashes.
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.
6
6
  //
7
- // Composite because it composes the pure graph model (utils/graph) with SVG
8
- // layout + interaction. Domain-free: it knows nothing about plugins or roles;
9
- // the host supplies typed nodes/edges and optional handlers. Self-contained CSS
10
- // (ksui-fg-* unscoped classes + CSS custom props); no Tailwind, no host-brand
11
- // classes (standalone-library rule). No graph/canvas library pan/zoom is a
12
- // plain SVG group transform, animation is a CSS keyframe.
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.
13
13
 
14
14
  import type { Component, JSX } from "solid-js";
15
- import { For, Show, createMemo, createSignal } 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";
16
29
  import {
17
- DEFAULT_METRICS,
18
30
  layoutGraph,
31
+ type GraphDirection,
19
32
  type GraphEdge,
20
33
  type GraphLayout,
34
+ type GraphMetrics,
21
35
  type GraphNode,
22
36
  type PositionedNode,
23
37
  } from "../../utils/graph";
24
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
+
25
64
  const STYLE_ID = "ksui-flow-graph-style";
26
65
 
27
66
  function ensureStyle(): void {
@@ -30,39 +69,47 @@ function ensureStyle(): void {
30
69
  const style = document.createElement("style");
31
70
  style.id = STYLE_ID;
32
71
  style.textContent = `
33
- .ksui-fg-wrap{width:100%;overflow:auto;position:relative;}
34
- .ksui-fg-wrap.interactive{overflow:hidden;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:8px;background:var(--ksui-fg-canvas,rgba(0,0,0,0.18));cursor:grab;touch-action:none;}
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;}
35
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;}
36
77
  .ksui-fg-svg{display:block;max-width:100%;height:auto;font-family:inherit;}
78
+ .ksui-fg-wrap.scroll .ksui-fg-svg{margin:0 auto;}
37
79
  .ksui-fg-wrap.interactive .ksui-fg-svg{max-width:none;width:100%;height:100%;}
38
- .ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.22));stroke-width:1.5;}
39
- .ksui-fg-edge.dashed{stroke-dasharray:4 4;}
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;}
40
82
  .ksui-fg-edge.primary{stroke:var(--ksui-fg-primary,#c9a961);}
41
- .ksui-fg-edge.info{stroke:#3b82f6;}
42
- .ksui-fg-edge.success{stroke:#22c55e;}
43
- .ksui-fg-edge.danger{stroke:#ef4444;}
44
- .ksui-fg-edge.muted{stroke:rgba(255,255,255,0.14);}
45
- .ksui-fg-edge.flow{stroke-dasharray:5 5;animation:ksui-fg-march .7s linear infinite;}
46
- @keyframes ksui-fg-march{to{stroke-dashoffset:-10;}}
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;}}
47
89
  @media (prefers-reduced-motion:reduce){.ksui-fg-edge.flow{animation:none;}}
48
- .ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.7));font-size:9px;}
49
- .ksui-fg-elabel-bg{fill:var(--ksui-fg-bg,#18181b);opacity:0.82;}
50
- .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;}
51
- .ksui-fg-node.primary .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);fill:rgba(201,169,97,0.08);}
52
- .ksui-fg-node.info .ksui-fg-box{stroke:#3b82f6;fill:rgba(59,130,246,0.08);}
53
- .ksui-fg-node.success .ksui-fg-box{stroke:#22c55e;fill:rgba(34,197,94,0.08);}
54
- .ksui-fg-node.danger .ksui-fg-box{stroke:#ef4444;fill:rgba(239,68,68,0.08);}
55
- .ksui-fg-node.muted .ksui-fg-box{stroke:rgba(255,255,255,0.16);fill:rgba(255,255,255,0.02);}
56
- .ksui-fg-node.clickable{cursor:pointer;}
57
- .ksui-fg-node.clickable:hover .ksui-fg-box{fill:rgba(255,255,255,0.10);}
58
- .ksui-fg-node.clickable:focus{outline:none;}
59
- .ksui-fg-node.clickable:focus-visible .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);stroke-width:2;}
60
- .ksui-fg-label{fill:var(--ksui-fg-fg,#e4e4e7);font-size:12px;font-weight:600;}
61
- .ksui-fg-sublabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.55));font-size:9.5px;text-transform:uppercase;letter-spacing:0.04em;}
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;}
62
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));}
63
110
  .ksui-fg-controls{position:absolute;right:8px;bottom:8px;display:flex;gap:4px;z-index:1;}
64
- .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-bg,#18181b);color:var(--ksui-fg-fg,#e4e4e7);font-size:14px;line-height:1;cursor:pointer;user-select:none;}
65
- .ksui-fg-ctrl:hover{background:rgba(255,255,255,0.08);}
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;}
66
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;}
67
114
  `;
68
115
  document.head.appendChild(style);
@@ -77,99 +124,256 @@ export interface FlowGraphProps {
77
124
  emptyLabel?: string;
78
125
  /** Accessible description of the whole graph (the svg's aria-label). */
79
126
  ariaLabel?: string;
80
- /** 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. */
81
128
  onNodeSelect?: (id: string) => void;
82
- /** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. */
129
+ /** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. Ignored for
130
+ * vertical direction, which scrolls instead. */
83
131
  interactive?: boolean;
84
- /** Animate edges as marching dashes flowing toward the arrowhead. */
132
+ /** Animate connectors as marching dashes flowing toward the arrowhead. */
85
133
  animated?: boolean;
86
- /** Canvas viewport height in px when interactive (default 360). */
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. */
87
139
  height?: number;
88
140
  testId?: string;
89
141
  }
90
142
 
91
- /** 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). */
92
144
  function clip(text: string, max: number): string {
93
145
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
94
146
  }
95
147
 
96
- const { nodeW, nodeH } = DEFAULT_METRICS;
97
- const ZOOM_MIN = 0.3;
98
- const ZOOM_MAX = 3;
99
-
100
148
  export const FlowGraph: Component<FlowGraphProps> = (props) => {
101
149
  ensureStyle();
102
150
  const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
103
151
 
104
- const laid = createMemo(() => layoutGraph(props.nodes, props.edges, props.layout ?? "layered"));
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;
156
+ const laid = createMemo(() =>
157
+ layoutGraph(props.nodes, props.edges, props.layout ?? "layered", METRICS, props.direction),
158
+ );
159
+
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()));
105
236
 
106
- // Pan/zoom view transform (interactive mode only).
107
237
  const [view, setView] = createSignal({ x: 0, y: 0, k: 1 });
108
238
  const [grabbing, setGrabbing] = createSignal(false);
109
239
  let svgRef: SVGSVGElement | undefined;
110
240
  let dragging = false;
111
241
  let lastX = 0;
112
242
  let lastY = 0;
113
- let moved = false; // distinguishes a pan from a node click
243
+ let moved = false;
114
244
 
115
- const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
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
+ });
116
289
 
290
+ const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
117
291
  const zoomBy = (factor: number, cx?: number, cy?: number) => {
118
292
  const v = view();
119
293
  const k = clampK(v.k * factor);
120
- // Keep the point (cx,cy) under the cursor fixed while zooming.
121
294
  const px = cx ?? (svgRef?.clientWidth ?? 0) / 2;
122
295
  const py = cy ?? (svgRef?.clientHeight ?? 0) / 2;
123
296
  setView({ x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k), k });
124
297
  };
125
-
126
298
  const onWheel = (e: WheelEvent) => {
127
- if (!props.interactive) return;
299
+ if (!canvas()) return;
128
300
  e.preventDefault();
129
301
  const rect = svgRef?.getBoundingClientRect();
130
302
  zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - (rect?.left ?? 0), e.clientY - (rect?.top ?? 0));
131
303
  };
132
-
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;
133
309
  const onPointerDown = (e: PointerEvent) => {
134
- if (!props.interactive) return;
135
- dragging = true;
310
+ if (!canvas()) return;
136
311
  moved = false;
137
- setGrabbing(true);
138
312
  lastX = e.clientX;
139
313
  lastY = e.clientY;
140
314
  svgRef?.setPointerCapture(e.pointerId);
315
+ if (pendingNode) {
316
+ nodeDrag = pendingNode;
317
+ pendingNode = null;
318
+ } else {
319
+ dragging = true;
320
+ setGrabbing(true);
321
+ }
141
322
  };
142
323
  const onPointerMove = (e: PointerEvent) => {
143
- if (!dragging) return;
324
+ if (!dragging && !nodeDrag) return;
144
325
  const dx = e.clientX - lastX;
145
326
  const dy = e.clientY - lastY;
146
327
  if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
147
328
  lastX = e.clientX;
148
329
  lastY = e.clientY;
149
- setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
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
+ }
150
340
  };
151
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);
152
346
  dragging = false;
347
+ nodeDrag = null;
348
+ pendingNode = null;
153
349
  setGrabbing(false);
154
350
  try {
155
351
  svgRef?.releasePointerCapture(e.pointerId);
156
352
  } catch {
157
- /* pointer already released */
353
+ /* already released */
158
354
  }
159
355
  };
160
356
 
161
- // A cubic bezier from a source node's right edge to a target's left edge.
162
- const edgePath = (s: PositionedNode, t: PositionedNode): string => {
163
- const x1 = s.x + nodeW;
164
- const y1 = s.y + nodeH / 2;
165
- const x2 = t.x;
166
- const y2 = t.y + nodeH / 2;
167
- const dx = Math.max(36, (x2 - x1) / 2);
168
- return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
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}`;
169
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
+ });
170
374
 
171
375
  const selectNode = (id: string) => {
172
- if (moved) return; // a pan ended over the node — don't treat it as a click
376
+ if (moved) return;
173
377
  props.onNodeSelect?.(id);
174
378
  };
175
379
  const activate = (e: KeyboardEvent, id: string) => {
@@ -178,15 +382,20 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
178
382
  props.onNodeSelect?.(id);
179
383
  }
180
384
  };
181
-
182
385
  const groupTransform = () =>
183
- props.interactive ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
386
+ canvas() ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
184
387
 
185
388
  return (
186
389
  <div
187
390
  class="ksui-fg-wrap"
188
- classList={{ interactive: !!props.interactive, grabbing: grabbing() }}
189
- style={props.interactive ? { height: `${props.height ?? 360}px` } : undefined}
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
+ }
190
399
  data-testid={tid("root")}
191
400
  >
192
401
  <Show
@@ -200,11 +409,11 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
200
409
  <svg
201
410
  ref={svgRef}
202
411
  class="ksui-fg-svg"
203
- viewBox={props.interactive ? undefined : `0 0 ${laid().width} ${laid().height}`}
204
- width={props.interactive ? "100%" : laid().width}
205
- height={props.interactive ? "100%" : laid().height}
412
+ viewBox={canvas() ? undefined : `0 0 ${laid().width} ${laid().height}`}
413
+ width={canvas() ? "100%" : laid().width}
414
+ height={canvas() ? "100%" : laid().height}
206
415
  role="img"
207
- aria-label={props.ariaLabel ?? "Relationship graph"}
416
+ aria-label={props.ariaLabel ?? "Flow graph"}
208
417
  data-testid={tid("svg")}
209
418
  onWheel={onWheel}
210
419
  onPointerDown={onPointerDown}
@@ -222,43 +431,48 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
222
431
  markerHeight="6"
223
432
  orient="auto-start-reverse"
224
433
  >
225
- <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))" />
226
435
  </marker>
227
436
  </defs>
228
437
 
229
438
  <g transform={groupTransform()}>
230
- {/* Edges first so nodes paint on top of the connectors. */}
439
+ {/* 1) Connectors — drawn first so the opaque cards paint over them. */}
231
440
  <For each={props.edges}>
232
441
  {(e) => {
233
- const s = () => laid().byId.get(e.from);
234
- const t = () => laid().byId.get(e.to);
442
+ const s = () => drawn(e.from);
443
+ const t = () => drawn(e.to);
235
444
  return (
236
445
  <Show when={s() && t()}>
237
446
  {(() => {
238
- const src = s() as PositionedNode;
239
- const dst = t() as PositionedNode;
240
- const mx = (src.x + nodeW + dst.x) / 2;
241
- const my = (src.y + dst.y) / 2 + nodeH / 2;
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);
242
456
  return (
243
- <g>
457
+ <g classList={{ "ksui-fg-dim": edgeDim(e) }}>
244
458
  <path
245
459
  class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""} ${
246
460
  props.animated ? "flow" : ""
247
461
  }`}
248
- d={edgePath(src, dst)}
462
+ d={edgePath(a(), b())}
249
463
  marker-end="url(#ksui-fg-arrow)"
250
464
  />
251
465
  <Show when={e.label}>
252
466
  <rect
253
467
  class="ksui-fg-elabel-bg"
254
- x={mx - clip(e.label!, 18).length * 2.6 - 3}
255
- y={my - 7}
256
- width={clip(e.label!, 18).length * 5.2 + 6}
468
+ x={m().x - lbl().length * 2.7 - 3}
469
+ y={m().y - 7}
470
+ width={lbl().length * 5.4 + 6}
257
471
  height={12}
258
- rx={2}
472
+ rx={3}
259
473
  />
260
- <text class="ksui-fg-elabel" x={mx} y={my + 2} text-anchor="middle">
261
- {clip(e.label!, 18)}
474
+ <text class="ksui-fg-elabel" x={m().x} y={m().y + 2} text-anchor="middle">
475
+ {lbl()}
262
476
  </text>
263
477
  </Show>
264
478
  </g>
@@ -269,30 +483,79 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
269
483
  }}
270
484
  </For>
271
485
 
272
- {/* Nodes */}
486
+ {/* 2) Node cards (opaque HTML via foreignObject — the blueprint look). */}
273
487
  <For each={laid().nodes}>
274
- {(n) => {
275
- const interactiveNode = () => typeof props.onNodeSelect === "function";
488
+ {(base) => {
489
+ const n = () => pos(base);
490
+ const clickable = () => typeof props.onNodeSelect === "function";
276
491
  return (
277
- <g
278
- class={`ksui-fg-node ${n.accent ?? ""} ${interactiveNode() ? "clickable" : ""}`}
279
- transform={`translate(${n.x} ${n.y})`}
280
- data-testid={tid(`node-${n.id}`)}
281
- role={interactiveNode() ? "button" : undefined}
282
- tabindex={interactiveNode() ? 0 : undefined}
283
- aria-label={n.sublabel ? `${n.label} ${n.sublabel}` : n.label}
284
- onClick={interactiveNode() ? () => selectNode(n.id) : undefined}
285
- onKeyDown={interactiveNode() ? (ev) => activate(ev, n.id) : undefined}
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}
286
508
  >
287
- <rect class="ksui-fg-box" width={nodeW} height={nodeH} rx={8} />
288
- <text class="ksui-fg-label" x={12} y={n.sublabel ? 20 : nodeH / 2 + 4}>
289
- {clip(n.label, 24)}
290
- </text>
291
- <Show when={n.sublabel}>
292
- <text class="ksui-fg-sublabel" x={12} y={34}>
293
- {clip(n.sublabel!, 28)}
294
- </text>
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
+ />
295
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>
296
559
  </g>
297
560
  );
298
561
  }}
@@ -300,14 +563,9 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
300
563
  </g>
301
564
  </svg>
302
565
 
303
- <Show when={props.interactive}>
566
+ <Show when={canvas()}>
304
567
  <div class="ksui-fg-controls" data-testid={tid("controls")}>
305
- <button
306
- type="button"
307
- class="ksui-fg-ctrl"
308
- aria-label="Zoom in"
309
- onClick={() => zoomBy(1.2)}
310
- >
568
+ <button type="button" class="ksui-fg-ctrl" aria-label="Zoom in" onClick={() => zoomBy(1.2)}>
311
569
  +
312
570
  </button>
313
571
  <button
@@ -321,9 +579,9 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
321
579
  <button
322
580
  type="button"
323
581
  class="ksui-fg-ctrl"
324
- aria-label="Reset view"
582
+ aria-label="Fit to view"
325
583
  data-testid={tid("reset")}
326
- onClick={() => setView({ x: 0, y: 0, k: 1 })}
584
+ onClick={fitView}
327
585
  >
328
586
 
329
587
  </button>
@@ -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