@kahitsan/ksui 0.22.0 → 0.23.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.23.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",
@@ -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,16 +1,18 @@
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 graph plugin connections, a
4
- // role→permission map, any directed graph the host hands it.
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.
5
6
  //
6
7
  // Composite because it composes the pure graph model (utils/graph) with SVG
7
8
  // 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).
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.
11
13
 
12
14
  import type { Component, JSX } from "solid-js";
13
- import { For, Show, createMemo } from "solid-js";
15
+ import { For, Show, createMemo, createSignal } from "solid-js";
14
16
  import {
15
17
  DEFAULT_METRICS,
16
18
  layoutGraph,
@@ -28,8 +30,11 @@ function ensureStyle(): void {
28
30
  const style = document.createElement("style");
29
31
  style.id = STYLE_ID;
30
32
  style.textContent = `
31
- .ksui-fg-wrap{width:100%;overflow:auto;}
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;}
35
+ .ksui-fg-wrap.interactive.grabbing{cursor:grabbing;}
32
36
  .ksui-fg-svg{display:block;max-width:100%;height:auto;font-family:inherit;}
37
+ .ksui-fg-wrap.interactive .ksui-fg-svg{max-width:none;width:100%;height:100%;}
33
38
  .ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.22));stroke-width:1.5;}
34
39
  .ksui-fg-edge.dashed{stroke-dasharray:4 4;}
35
40
  .ksui-fg-edge.primary{stroke:var(--ksui-fg-primary,#c9a961);}
@@ -37,6 +42,9 @@ function ensureStyle(): void {
37
42
  .ksui-fg-edge.success{stroke:#22c55e;}
38
43
  .ksui-fg-edge.danger{stroke:#ef4444;}
39
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;}}
47
+ @media (prefers-reduced-motion:reduce){.ksui-fg-edge.flow{animation:none;}}
40
48
  .ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.7));font-size:9px;}
41
49
  .ksui-fg-elabel-bg{fill:var(--ksui-fg-bg,#18181b);opacity:0.82;}
42
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;}
@@ -50,8 +58,12 @@ function ensureStyle(): void {
50
58
  .ksui-fg-node.clickable:focus{outline:none;}
51
59
  .ksui-fg-node.clickable:focus-visible .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);stroke-width:2;}
52
60
  .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;}
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;}
54
62
  .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
+ .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);}
66
+ .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
67
  `;
56
68
  document.head.appendChild(style);
57
69
  }
@@ -67,6 +79,12 @@ export interface FlowGraphProps {
67
79
  ariaLabel?: string;
68
80
  /** When supplied, nodes become buttons that fire this with the node id. */
69
81
  onNodeSelect?: (id: string) => void;
82
+ /** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. */
83
+ interactive?: boolean;
84
+ /** Animate edges as marching dashes flowing toward the arrowhead. */
85
+ animated?: boolean;
86
+ /** Canvas viewport height in px when interactive (default 360). */
87
+ height?: number;
70
88
  testId?: string;
71
89
  }
72
90
 
@@ -76,14 +94,69 @@ function clip(text: string, max: number): string {
76
94
  }
77
95
 
78
96
  const { nodeW, nodeH } = DEFAULT_METRICS;
97
+ const ZOOM_MIN = 0.3;
98
+ const ZOOM_MAX = 3;
79
99
 
80
100
  export const FlowGraph: Component<FlowGraphProps> = (props) => {
81
101
  ensureStyle();
82
102
  const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
83
103
 
84
- const laid = createMemo(() =>
85
- layoutGraph(props.nodes, props.edges, props.layout ?? "layered"),
86
- );
104
+ const laid = createMemo(() => layoutGraph(props.nodes, props.edges, props.layout ?? "layered"));
105
+
106
+ // Pan/zoom view transform (interactive mode only).
107
+ const [view, setView] = createSignal({ x: 0, y: 0, k: 1 });
108
+ const [grabbing, setGrabbing] = createSignal(false);
109
+ let svgRef: SVGSVGElement | undefined;
110
+ let dragging = false;
111
+ let lastX = 0;
112
+ let lastY = 0;
113
+ let moved = false; // distinguishes a pan from a node click
114
+
115
+ const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
116
+
117
+ const zoomBy = (factor: number, cx?: number, cy?: number) => {
118
+ const v = view();
119
+ const k = clampK(v.k * factor);
120
+ // Keep the point (cx,cy) under the cursor fixed while zooming.
121
+ const px = cx ?? (svgRef?.clientWidth ?? 0) / 2;
122
+ const py = cy ?? (svgRef?.clientHeight ?? 0) / 2;
123
+ setView({ x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k), k });
124
+ };
125
+
126
+ const onWheel = (e: WheelEvent) => {
127
+ if (!props.interactive) return;
128
+ e.preventDefault();
129
+ const rect = svgRef?.getBoundingClientRect();
130
+ zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - (rect?.left ?? 0), e.clientY - (rect?.top ?? 0));
131
+ };
132
+
133
+ const onPointerDown = (e: PointerEvent) => {
134
+ if (!props.interactive) return;
135
+ dragging = true;
136
+ moved = false;
137
+ setGrabbing(true);
138
+ lastX = e.clientX;
139
+ lastY = e.clientY;
140
+ svgRef?.setPointerCapture(e.pointerId);
141
+ };
142
+ const onPointerMove = (e: PointerEvent) => {
143
+ if (!dragging) return;
144
+ const dx = e.clientX - lastX;
145
+ const dy = e.clientY - lastY;
146
+ if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
147
+ lastX = e.clientX;
148
+ lastY = e.clientY;
149
+ setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
150
+ };
151
+ const endDrag = (e: PointerEvent) => {
152
+ dragging = false;
153
+ setGrabbing(false);
154
+ try {
155
+ svgRef?.releasePointerCapture(e.pointerId);
156
+ } catch {
157
+ /* pointer already released */
158
+ }
159
+ };
87
160
 
88
161
  // A cubic bezier from a source node's right edge to a target's left edge.
89
162
  const edgePath = (s: PositionedNode, t: PositionedNode): string => {
@@ -95,6 +168,10 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
95
168
  return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
96
169
  };
97
170
 
171
+ const selectNode = (id: string) => {
172
+ if (moved) return; // a pan ended over the node — don't treat it as a click
173
+ props.onNodeSelect?.(id);
174
+ };
98
175
  const activate = (e: KeyboardEvent, id: string) => {
99
176
  if (e.key === "Enter" || e.key === " ") {
100
177
  e.preventDefault();
@@ -102,8 +179,16 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
102
179
  }
103
180
  };
104
181
 
182
+ const groupTransform = () =>
183
+ props.interactive ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
184
+
105
185
  return (
106
- <div class="ksui-fg-wrap" data-testid={tid("root")}>
186
+ <div
187
+ class="ksui-fg-wrap"
188
+ classList={{ interactive: !!props.interactive, grabbing: grabbing() }}
189
+ style={props.interactive ? { height: `${props.height ?? 360}px` } : undefined}
190
+ data-testid={tid("root")}
191
+ >
107
192
  <Show
108
193
  when={laid().nodes.length > 0}
109
194
  fallback={
@@ -113,13 +198,19 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
113
198
  }
114
199
  >
115
200
  <svg
201
+ ref={svgRef}
116
202
  class="ksui-fg-svg"
117
- viewBox={`0 0 ${laid().width} ${laid().height}`}
118
- width={laid().width}
119
- height={laid().height}
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}
120
206
  role="img"
121
207
  aria-label={props.ariaLabel ?? "Relationship graph"}
122
208
  data-testid={tid("svg")}
209
+ onWheel={onWheel}
210
+ onPointerDown={onPointerDown}
211
+ onPointerMove={onPointerMove}
212
+ onPointerUp={endDrag}
213
+ onPointerCancel={endDrag}
123
214
  >
124
215
  <defs>
125
216
  <marker
@@ -135,79 +226,110 @@ export const FlowGraph: Component<FlowGraphProps> = (props) => {
135
226
  </marker>
136
227
  </defs>
137
228
 
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}
229
+ <g transform={groupTransform()}>
230
+ {/* Edges first so nodes paint on top of the connectors. */}
231
+ <For each={props.edges}>
232
+ {(e) => {
233
+ const s = () => laid().byId.get(e.from);
234
+ const t = () => laid().byId.get(e.to);
235
+ return (
236
+ <Show when={s() && t()}>
237
+ {(() => {
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;
242
+ return (
243
+ <g>
244
+ <path
245
+ class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""} ${
246
+ props.animated ? "flow" : ""
247
+ }`}
248
+ d={edgePath(src, dst)}
249
+ marker-end="url(#ksui-fg-arrow)"
165
250
  />
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}
251
+ <Show when={e.label}>
252
+ <rect
253
+ 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}
257
+ height={12}
258
+ rx={2}
259
+ />
260
+ <text class="ksui-fg-elabel" x={mx} y={my + 2} text-anchor="middle">
261
+ {clip(e.label!, 18)}
262
+ </text>
263
+ </Show>
264
+ </g>
265
+ );
266
+ })()}
267
+ </Show>
268
+ );
269
+ }}
270
+ </For>
271
+
272
+ {/* Nodes */}
273
+ <For each={laid().nodes}>
274
+ {(n) => {
275
+ const interactiveNode = () => typeof props.onNodeSelect === "function";
276
+ 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}
198
286
  >
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)}
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)}
204
290
  </text>
205
- </Show>
206
- </g>
207
- );
208
- }}
209
- </For>
291
+ <Show when={n.sublabel}>
292
+ <text class="ksui-fg-sublabel" x={12} y={34}>
293
+ {clip(n.sublabel!, 28)}
294
+ </text>
295
+ </Show>
296
+ </g>
297
+ );
298
+ }}
299
+ </For>
300
+ </g>
210
301
  </svg>
302
+
303
+ <Show when={props.interactive}>
304
+ <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
+ >
311
+ +
312
+ </button>
313
+ <button
314
+ type="button"
315
+ class="ksui-fg-ctrl"
316
+ aria-label="Zoom out"
317
+ onClick={() => zoomBy(1 / 1.2)}
318
+ >
319
+
320
+ </button>
321
+ <button
322
+ type="button"
323
+ class="ksui-fg-ctrl"
324
+ aria-label="Reset view"
325
+ data-testid={tid("reset")}
326
+ onClick={() => setView({ x: 0, y: 0, k: 1 })}
327
+ >
328
+
329
+ </button>
330
+ </div>
331
+ <span class="ksui-fg-hint">drag to pan · scroll to zoom</span>
332
+ </Show>
211
333
  </Show>
212
334
  </div>
213
335
  ) as JSX.Element;