@kahitsan/ksui 0.20.0 → 0.22.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.20.0",
3
+ "version": "0.22.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",
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent, screen } from "@solidjs/testing-library";
3
+ import BadgeSelect, { type BadgeSelectOption } from "./BadgeSelect";
4
+
5
+ // BadgeSelect is the generic, domain-free inline picker lifted from kserp's
6
+ // RoleBadgeSelect (U1). The caller injects options, the value↔label mapping,
7
+ // and (optionally) the badge tone; nothing here is role-specific. The popup
8
+ // renders into a Portal (document.body), so popup assertions use `screen`.
9
+
10
+ const OPTIONS: BadgeSelectOption[] = [
11
+ { value: "admin", label: "Admin", description: "Full access" },
12
+ { value: "member", label: "Member" },
13
+ ];
14
+
15
+ describe("BadgeSelect", () => {
16
+ it("renders the selected option's label on the trigger", () => {
17
+ const { getByRole } = render(() => (
18
+ <BadgeSelect value="admin" options={OPTIONS} onChange={() => {}} />
19
+ ));
20
+ expect(getByRole("button").textContent).toContain("Admin");
21
+ });
22
+
23
+ it("falls back to the raw value when no option matches", () => {
24
+ const { getByRole } = render(() => (
25
+ <BadgeSelect value="ghost" options={OPTIONS} onChange={() => {}} />
26
+ ));
27
+ expect(getByRole("button").textContent).toContain("ghost");
28
+ });
29
+
30
+ it("opens the popup and emits the chosen value on select", async () => {
31
+ const onChange = vi.fn();
32
+ const { getByRole } = render(() => (
33
+ <BadgeSelect value="admin" options={OPTIONS} onChange={onChange} />
34
+ ));
35
+ fireEvent.click(getByRole("button"));
36
+ const memberOpt = await screen.findByText("Member");
37
+ fireEvent.click(memberOpt);
38
+ expect(onChange).toHaveBeenCalledWith("member");
39
+ });
40
+
41
+ it("does not emit when re-selecting the current value", async () => {
42
+ const onChange = vi.fn();
43
+ const { getByRole } = render(() => (
44
+ <BadgeSelect value="admin" options={OPTIONS} onChange={onChange} />
45
+ ));
46
+ fireEvent.click(getByRole("button"));
47
+ // Wait for the popup, then click the option row for the active value.
48
+ await screen.findByText("Member");
49
+ const adminRows = screen.getAllByText("Admin");
50
+ fireEvent.click(adminRows[adminRows.length - 1]);
51
+ expect(onChange).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("applies the injected badgeClass to the trigger (DI tone)", () => {
55
+ const { getByRole } = render(() => (
56
+ <BadgeSelect
57
+ value="admin"
58
+ options={OPTIONS}
59
+ onChange={() => {}}
60
+ badgeClass={(v) => (v === "admin" ? "tone-special" : "tone-default")}
61
+ />
62
+ ));
63
+ expect(getByRole("button").className).toContain("tone-special");
64
+ });
65
+
66
+ it("disables the trigger when disabled", () => {
67
+ const { getByRole } = render(() => (
68
+ <BadgeSelect value="admin" options={OPTIONS} disabled onChange={() => {}} />
69
+ ));
70
+ expect((getByRole("button") as HTMLButtonElement).disabled).toBe(true);
71
+ });
72
+
73
+ it("exposes listbox/option ARIA semantics when open", async () => {
74
+ const { getByRole } = render(() => (
75
+ <BadgeSelect value="admin" options={OPTIONS} onChange={() => {}} />
76
+ ));
77
+ const trigger = getByRole("button");
78
+ expect(trigger.getAttribute("aria-haspopup")).toBe("listbox");
79
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
80
+ fireEvent.click(trigger);
81
+ const listbox = await screen.findByRole("listbox");
82
+ expect(listbox).toBeTruthy();
83
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
84
+ // The active option advertises its selected state to assistive tech.
85
+ const selected = screen.getAllByRole("option").find((o) => o.getAttribute("aria-selected") === "true");
86
+ expect(selected?.textContent).toContain("Admin");
87
+ });
88
+ });
@@ -0,0 +1,229 @@
1
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
2
+ import { Portal } from "solid-js/web";
3
+
4
+ export interface BadgeSelectOption {
5
+ /** Stable identity of the option (what `value` matches and `onChange` emits). */
6
+ value: string;
7
+ label: string;
8
+ description?: string;
9
+ }
10
+
11
+ export interface BadgeSelectProps {
12
+ /** Currently-selected option value. */
13
+ value: string;
14
+ options: BadgeSelectOption[];
15
+ disabled?: boolean;
16
+ loading?: boolean;
17
+ /** Map the selected value to the trigger badge's classes (DI). Defaults to a
18
+ * neutral zinc chip — the caller injects its own tone mapping. */
19
+ badgeClass?: (value: string) => string;
20
+ onChange: (next: string) => void | Promise<void>;
21
+ /** Show the inline search box when options exceed this count. Default 5. */
22
+ searchThreshold?: number;
23
+ searchPlaceholder?: string;
24
+ /** Tooltip on the trigger when enabled. Default "Click to change". */
25
+ title?: string;
26
+ /** Empty-state copy when there are no options at all. Default "No options". */
27
+ emptyLabel?: string;
28
+ /** Empty-state copy when a search filters everything out. Default "No matching options". */
29
+ emptyFilteredLabel?: string;
30
+ /** Copy shown while `loading`. Default "Loading…". */
31
+ loadingLabel?: string;
32
+ testId?: string;
33
+ }
34
+
35
+ const POPUP_MIN_WIDTH = 200;
36
+ const POPUP_MAX_HEIGHT = 320;
37
+ const DEFAULT_SEARCH_THRESHOLD = 5;
38
+
39
+ // Inline, click-to-edit badge picker. Renders as a clickable badge; clicking
40
+ // opens a dropdown anchored to the badge with all options. When more than
41
+ // `searchThreshold` options are present an inline search box auto-appears so a
42
+ // long list stays scannable. Selecting commits immediately via onChange.
43
+ //
44
+ // The popup is rendered into a Portal with `position: fixed`, so it escapes
45
+ // ancestors that have `overflow: hidden` (e.g. rounded card containers) and
46
+ // flips above the trigger when there isn't enough room below it.
47
+ //
48
+ // Domain-free: the caller supplies the options, the value↔label mapping, and
49
+ // (optionally) the badge tone via `badgeClass`. Nothing here knows what the
50
+ // values mean.
51
+ //
52
+ // Intentionally distinct from SearchableSelect: this is a badge-scoped single
53
+ // select — a compact inline chip trigger plus per-value tone DI (`badgeClass`),
54
+ // not a form-control picker. Kept separate so neither component grows a
55
+ // trigger-shape/styling switch; do not merge them.
56
+ export default function BadgeSelect(props: BadgeSelectProps): JSX.Element {
57
+ const [open, setOpen] = createSignal(false);
58
+ const [busy, setBusy] = createSignal(false);
59
+ const [query, setQuery] = createSignal("");
60
+ const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
61
+ let triggerRef: HTMLButtonElement | undefined;
62
+ let popupRef: HTMLDivElement | undefined;
63
+ let searchRef: HTMLInputElement | undefined;
64
+
65
+ const threshold = () => props.searchThreshold ?? DEFAULT_SEARCH_THRESHOLD;
66
+ const showSearch = () => (props.options?.length ?? 0) > threshold();
67
+
68
+ const filtered = createMemo(() => {
69
+ const q = query().trim().toLowerCase();
70
+ if (!q) return props.options;
71
+ return props.options.filter(
72
+ (o) =>
73
+ o.label.toLowerCase().includes(q) ||
74
+ o.value.toLowerCase().includes(q) ||
75
+ (o.description?.toLowerCase().includes(q) ?? false),
76
+ );
77
+ });
78
+
79
+ const defaultBadge = () => "bg-zinc-800 text-zinc-400 border border-transparent";
80
+ const badgeClass = () => props.badgeClass?.(props.value) ?? defaultBadge();
81
+
82
+ const emptyStateMessage = () => {
83
+ if (props.loading) return props.loadingLabel ?? "Loading…";
84
+ return query() ? (props.emptyFilteredLabel ?? "No matching options") : (props.emptyLabel ?? "No options");
85
+ };
86
+
87
+ const updatePosition = () => {
88
+ if (!triggerRef) return;
89
+ const rect = triggerRef.getBoundingClientRect();
90
+ const vpHeight = window.innerHeight;
91
+ const vpWidth = window.innerWidth;
92
+ const width = Math.max(POPUP_MIN_WIDTH, rect.width);
93
+ const spaceBelow = vpHeight - rect.bottom;
94
+ const spaceAbove = rect.top;
95
+ const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
96
+ const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
97
+ const maxHeight = Math.max(
98
+ 160,
99
+ Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
100
+ );
101
+ const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
102
+ setPopupStyle({
103
+ position: "fixed",
104
+ top: `${top}px`,
105
+ left: `${left}px`,
106
+ width: `${width}px`,
107
+ "max-height": `${maxHeight}px`,
108
+ });
109
+ };
110
+
111
+ createEffect(() => {
112
+ if (!open()) return;
113
+ setQuery("");
114
+ updatePosition();
115
+ if (showSearch()) queueMicrotask(() => searchRef?.focus());
116
+
117
+ const onDocClick = (e: MouseEvent) => {
118
+ const target = e.target as Node;
119
+ if (triggerRef?.contains(target)) return;
120
+ if (popupRef?.contains(target)) return;
121
+ setOpen(false);
122
+ };
123
+ const onEsc = (e: KeyboardEvent) => {
124
+ if (e.key === "Escape") setOpen(false);
125
+ };
126
+ const onReflow = () => updatePosition();
127
+
128
+ document.addEventListener("mousedown", onDocClick);
129
+ document.addEventListener("keydown", onEsc);
130
+ window.addEventListener("resize", onReflow);
131
+ window.addEventListener("scroll", onReflow, true);
132
+ onCleanup(() => {
133
+ document.removeEventListener("mousedown", onDocClick);
134
+ document.removeEventListener("keydown", onEsc);
135
+ window.removeEventListener("resize", onReflow);
136
+ window.removeEventListener("scroll", onReflow, true);
137
+ });
138
+ });
139
+
140
+ const handleSelect = async (next: string) => {
141
+ if (next === props.value) {
142
+ setOpen(false);
143
+ return;
144
+ }
145
+ setBusy(true);
146
+ try {
147
+ await props.onChange(next);
148
+ } finally {
149
+ setBusy(false);
150
+ setOpen(false);
151
+ }
152
+ };
153
+
154
+ const currentLabel = () =>
155
+ props.options.find((o) => o.value === props.value)?.label ?? props.value;
156
+
157
+ return (
158
+ <div class="relative inline-block">
159
+ <button
160
+ ref={triggerRef}
161
+ type="button"
162
+ data-testid={props.testId}
163
+ aria-haspopup="listbox"
164
+ aria-expanded={open()}
165
+ disabled={props.disabled || busy()}
166
+ onClick={() => !props.disabled && setOpen((o) => !o)}
167
+ class={`text-xs font-medium px-1.5 py-0.5 rounded cursor-pointer transition-colors hover:ring-1 hover:ring-amber-500/40 ${badgeClass()} ${
168
+ props.disabled ? "cursor-not-allowed opacity-60" : ""
169
+ }`}
170
+ title={props.disabled ? "" : (props.title ?? "Click to change")}
171
+ >
172
+ {busy() ? "…" : currentLabel()}
173
+ </button>
174
+ <Show when={open()}>
175
+ <Portal>
176
+ <div
177
+ ref={popupRef}
178
+ role="listbox"
179
+ class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
180
+ style={popupStyle()}
181
+ >
182
+ <Show when={showSearch()}>
183
+ <div class="px-2 py-1.5 border-b border-zinc-800">
184
+ <input
185
+ ref={searchRef}
186
+ type="text"
187
+ value={query()}
188
+ onInput={(e) => setQuery(e.currentTarget.value)}
189
+ placeholder={props.searchPlaceholder ?? "Search…"}
190
+ class="w-full px-2 py-1 text-xs bg-zinc-950 border border-zinc-800 rounded text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-amber-500/50"
191
+ />
192
+ </div>
193
+ </Show>
194
+ <div class="flex-1 overflow-y-auto">
195
+ <Show
196
+ when={!props.loading && filtered().length > 0}
197
+ fallback={<div class="px-3 py-2 text-xs text-zinc-500">{emptyStateMessage()}</div>}
198
+ >
199
+ <For each={filtered()}>
200
+ {(opt) => (
201
+ <button
202
+ type="button"
203
+ role="option"
204
+ aria-selected={opt.value === props.value}
205
+ onClick={() => handleSelect(opt.value)}
206
+ class={`w-full text-left px-3 py-2 text-xs hover:bg-amber-500/10 transition-colors flex items-center justify-between gap-2 ${
207
+ opt.value === props.value ? "text-amber-400" : "text-zinc-200"
208
+ }`}
209
+ >
210
+ <span class="flex flex-col">
211
+ <span class="font-medium">{opt.label}</span>
212
+ <Show when={opt.description}>
213
+ <span class="text-[10px] text-zinc-500">{opt.description}</span>
214
+ </Show>
215
+ </span>
216
+ <Show when={opt.value === props.value}>
217
+ <span class="text-amber-400" aria-hidden="true">✓</span>
218
+ </Show>
219
+ </button>
220
+ )}
221
+ </For>
222
+ </Show>
223
+ </div>
224
+ </div>
225
+ </Portal>
226
+ </Show>
227
+ </div>
228
+ );
229
+ }
@@ -84,6 +84,18 @@ describe("FileField", () => {
84
84
  expect(getByTestId("ff-drop")).toBeTruthy();
85
85
  });
86
86
 
87
+ it("loads a pre-seeded image value via the presigned (https) URL into the preview", async () => {
88
+ const presignUrl = vi.fn(async () => "https://signed.example/preview.png?sig=abc");
89
+ const { getByTestId } = render(() => (
90
+ <FileField testId="ff" value={imageHandle} onUpload={vi.fn(async () => imageHandle)} presignUrl={presignUrl} />
91
+ ));
92
+ // A value handle present at mount presigns eagerly and shows the done card.
93
+ expect(getByTestId("ff-done")).toBeTruthy();
94
+ await waitFor(() => expect(presignUrl).toHaveBeenCalledWith(imageHandle));
95
+ const img = (await waitFor(() => getByTestId("ff-preview"))) as HTMLImageElement;
96
+ expect(img.getAttribute("src")).toBe("https://signed.example/preview.png?sig=abc");
97
+ });
98
+
87
99
  it("renders a non-image handle with a file icon (no presign)", () => {
88
100
  const presignUrl = vi.fn(async () => "x");
89
101
  const { getByTestId } = render(() => (
@@ -0,0 +1,105 @@
1
+ // FlowGraph tests: the pure layout (lanes, dimensions, bipartite/layered) and
2
+ // the SVG render (nodes, edges, empty state, node selection).
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { render, fireEvent } from "@solidjs/testing-library";
5
+ import FlowGraph from "./FlowGraph";
6
+ import { layoutGraph, DEFAULT_METRICS, type GraphEdge, type GraphNode } from "../../utils/graph";
7
+
8
+ const { nodeW, gapX, pad } = DEFAULT_METRICS;
9
+
10
+ describe("layoutGraph", () => {
11
+ it("layers roots→leaves by longest path", () => {
12
+ const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }];
13
+ const edges: GraphEdge[] = [{ from: "a", to: "b" }, { from: "b", to: "c" }];
14
+ const { byId } = layoutGraph(nodes, edges, "layered");
15
+ expect(byId.get("a")!.lane).toBe(0);
16
+ expect(byId.get("b")!.lane).toBe(1);
17
+ expect(byId.get("c")!.lane).toBe(2);
18
+ });
19
+
20
+ it("uses the longest path when a node has two incoming depths", () => {
21
+ // a→c and a→b→c: c must sit past b, not just past a.
22
+ const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }];
23
+ const edges: GraphEdge[] = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "a", to: "c" }];
24
+ const { byId } = layoutGraph(nodes, edges, "layered");
25
+ expect(byId.get("c")!.lane).toBe(2);
26
+ });
27
+
28
+ it("splits bipartite by incoming degree, honoring explicit lanes for isolated sinks", () => {
29
+ const nodes: GraphNode[] = [
30
+ { id: "role", label: "admin" },
31
+ { id: "p1", label: "view" },
32
+ { id: "p2", label: "delete", lane: 1 }, // ungranted permission, no edge
33
+ ];
34
+ const edges: GraphEdge[] = [{ from: "role", to: "p1" }];
35
+ const { byId } = layoutGraph(nodes, edges, "bipartite");
36
+ expect(byId.get("role")!.lane).toBe(0);
37
+ expect(byId.get("p1")!.lane).toBe(1);
38
+ expect(byId.get("p2")!.lane).toBe(1); // pinned, despite no incoming edge
39
+ });
40
+
41
+ it("does not spin forever on a cycle", () => {
42
+ const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }];
43
+ const edges: GraphEdge[] = [{ from: "a", to: "b" }, { from: "b", to: "a" }];
44
+ const { nodes: out } = layoutGraph(nodes, edges, "layered");
45
+ expect(out).toHaveLength(2);
46
+ });
47
+
48
+ it("positions the second lane one node-width + gap past the first", () => {
49
+ const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }];
50
+ const { byId } = layoutGraph(nodes, [{ from: "a", to: "b" }], "layered");
51
+ expect(byId.get("a")!.x).toBe(pad);
52
+ expect(byId.get("b")!.x).toBe(pad + nodeW + gapX);
53
+ });
54
+
55
+ it("ignores edges that dangle off the node set", () => {
56
+ const { nodes } = layoutGraph([{ id: "a", label: "A" }], [{ from: "a", to: "ghost" }], "layered");
57
+ expect(nodes).toHaveLength(1);
58
+ });
59
+ });
60
+
61
+ describe("FlowGraph", () => {
62
+ it("renders a node per input node and an svg", () => {
63
+ const { getByTestId } = render(() => (
64
+ <FlowGraph
65
+ testId="fg"
66
+ nodes={[{ id: "a", label: "Alpha", sublabel: "base" }, { id: "b", label: "Beta" }]}
67
+ edges={[{ from: "a", to: "b" }]}
68
+ />
69
+ ));
70
+ expect(getByTestId("fg-svg")).toBeTruthy();
71
+ expect(getByTestId("fg-node-a")).toBeTruthy();
72
+ expect(getByTestId("fg-node-b")).toBeTruthy();
73
+ });
74
+
75
+ it("shows the empty state when there are no nodes", () => {
76
+ const { getByTestId, queryByTestId } = render(() => (
77
+ <FlowGraph testId="fg" nodes={[]} edges={[]} emptyLabel="No connections" />
78
+ ));
79
+ expect(getByTestId("fg-empty").textContent).toContain("No connections");
80
+ expect(queryByTestId("fg-svg")).toBeNull();
81
+ });
82
+
83
+ it("makes nodes interactive only when onNodeSelect is supplied", () => {
84
+ const onNodeSelect = vi.fn();
85
+ const { getByTestId } = render(() => (
86
+ <FlowGraph
87
+ testId="fg"
88
+ nodes={[{ id: "a", label: "Alpha" }]}
89
+ edges={[]}
90
+ onNodeSelect={onNodeSelect}
91
+ />
92
+ ));
93
+ const node = getByTestId("fg-node-a");
94
+ expect(node.getAttribute("role")).toBe("button");
95
+ fireEvent.click(node);
96
+ expect(onNodeSelect).toHaveBeenCalledWith("a");
97
+ });
98
+
99
+ it("does not mark nodes as buttons without a handler", () => {
100
+ const { getByTestId } = render(() => (
101
+ <FlowGraph testId="fg" nodes={[{ id: "a", label: "Alpha" }]} edges={[]} />
102
+ ));
103
+ expect(getByTestId("fg-node-a").getAttribute("role")).toBeNull();
104
+ });
105
+ });
@@ -0,0 +1,216 @@
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.
5
+ //
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).
11
+
12
+ import type { Component, JSX } from "solid-js";
13
+ import { For, Show, createMemo } from "solid-js";
14
+ import {
15
+ DEFAULT_METRICS,
16
+ layoutGraph,
17
+ type GraphEdge,
18
+ type GraphLayout,
19
+ type GraphNode,
20
+ type PositionedNode,
21
+ } from "../../utils/graph";
22
+
23
+ const STYLE_ID = "ksui-flow-graph-style";
24
+
25
+ function ensureStyle(): void {
26
+ if (typeof document === "undefined") return;
27
+ if (document.getElementById(STYLE_ID)) return;
28
+ const style = document.createElement("style");
29
+ style.id = STYLE_ID;
30
+ style.textContent = `
31
+ .ksui-fg-wrap{width:100%;overflow:auto;}
32
+ .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;}
35
+ .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;}
54
+ .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));}
55
+ `;
56
+ document.head.appendChild(style);
57
+ }
58
+
59
+ export interface FlowGraphProps {
60
+ nodes: GraphNode[];
61
+ edges: GraphEdge[];
62
+ /** "layered" (default) flows roots→leaves; "bipartite" splits source/sink. */
63
+ layout?: GraphLayout;
64
+ /** Shown when there are no nodes to draw. */
65
+ emptyLabel?: string;
66
+ /** Accessible description of the whole graph (the svg's aria-label). */
67
+ ariaLabel?: string;
68
+ /** When supplied, nodes become buttons that fire this with the node id. */
69
+ onNodeSelect?: (id: string) => void;
70
+ testId?: string;
71
+ }
72
+
73
+ /** SVG has no text overflow; trim to keep labels inside the node box. */
74
+ function clip(text: string, max: number): string {
75
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
76
+ }
77
+
78
+ const { nodeW, nodeH } = DEFAULT_METRICS;
79
+
80
+ export const FlowGraph: Component<FlowGraphProps> = (props) => {
81
+ ensureStyle();
82
+ const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
83
+
84
+ const laid = createMemo(() =>
85
+ layoutGraph(props.nodes, props.edges, props.layout ?? "layered"),
86
+ );
87
+
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}`;
96
+ };
97
+
98
+ const activate = (e: KeyboardEvent, id: string) => {
99
+ if (e.key === "Enter" || e.key === " ") {
100
+ e.preventDefault();
101
+ props.onNodeSelect?.(id);
102
+ }
103
+ };
104
+
105
+ return (
106
+ <div class="ksui-fg-wrap" data-testid={tid("root")}>
107
+ <Show
108
+ when={laid().nodes.length > 0}
109
+ fallback={
110
+ <p class="ksui-fg-empty" data-testid={tid("empty")}>
111
+ {props.emptyLabel ?? "Nothing to show yet."}
112
+ </p>
113
+ }
114
+ >
115
+ <svg
116
+ class="ksui-fg-svg"
117
+ viewBox={`0 0 ${laid().width} ${laid().height}`}
118
+ width={laid().width}
119
+ height={laid().height}
120
+ role="img"
121
+ aria-label={props.ariaLabel ?? "Relationship graph"}
122
+ data-testid={tid("svg")}
123
+ >
124
+ <defs>
125
+ <marker
126
+ id="ksui-fg-arrow"
127
+ viewBox="0 0 8 8"
128
+ refX="7"
129
+ refY="4"
130
+ markerWidth="6"
131
+ markerHeight="6"
132
+ orient="auto-start-reverse"
133
+ >
134
+ <path d="M0 0 L8 4 L0 8 z" fill="var(--ksui-fg-edge,rgba(255,255,255,0.35))" />
135
+ </marker>
136
+ </defs>
137
+
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}
165
+ />
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>
205
+ </Show>
206
+ </g>
207
+ );
208
+ }}
209
+ </For>
210
+ </svg>
211
+ </Show>
212
+ </div>
213
+ ) as JSX.Element;
214
+ };
215
+
216
+ export default FlowGraph;
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export { default as KpiCard, type KpiCardProps, type KpiTone } from "./component
64
64
  export { default as RadioCardGroup } from "./components/base/RadioCardGroup";
65
65
  export { default as FormErrorBanner } from "./components/base/FormErrorBanner";
66
66
  export { default as TagPill } from "./components/base/TagPill";
67
+ export { default as BadgeSelect, type BadgeSelectProps, type BadgeSelectOption } from "./components/base/BadgeSelect";
67
68
  export { default as DateTile, type DateTileProps } from "./components/base/DateTile";
68
69
  export { default as Button, type ButtonProps, type ButtonIntent, type ButtonVariant } from "./components/base/Button";
69
70
  export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleValue } from "./components/base/ThemeToggle";
@@ -172,6 +173,11 @@ export { default as FlowRunner, type FlowRunnerProps } from "./components/compos
172
173
  // renders it with validated props, falling back safely on miss/mismatch.
173
174
  export { default as CustomRenderer, type CustomRendererProps } from "./components/composite/CustomRenderer";
174
175
 
176
+ // FlowGraph — read-only renderer for a declarative directed graph (the static
177
+ // companion to FlowRunner). Domain-free: host supplies typed nodes/edges; used
178
+ // for plugin-connection and role→permission visualizations.
179
+ export { default as FlowGraph, type FlowGraphProps } from "./components/composite/FlowGraph";
180
+
175
181
  // ---------------------------------------------------------------------------
176
182
  // Utils (not components)
177
183
  // ---------------------------------------------------------------------------
@@ -233,6 +239,20 @@ export type {
233
239
  FlowInput,
234
240
  } from "./utils/flow";
235
241
 
242
+ // FlowGraph model (pure): the node/edge types + the dependency-free layout the
243
+ // renderer uses. Exported so hosts can type their graph data and, if needed,
244
+ // pre-compute layout off the DOM.
245
+ export { layoutGraph, DEFAULT_METRICS } from "./utils/graph";
246
+ export type {
247
+ GraphNode,
248
+ GraphEdge,
249
+ GraphLayout,
250
+ GraphAccent,
251
+ GraphMetrics,
252
+ PositionedNode,
253
+ GraphLayoutResult,
254
+ } from "./utils/graph";
255
+
236
256
  // U8 — in-process, build-time custom renderer registry (no eval/remote code) and
237
257
  // its consumes-schema validator. Hosts register renderers at startup.
238
258
  export {
@@ -0,0 +1,154 @@
1
+ // Domain-free directed-graph model + a deterministic, dependency-free layout.
2
+ // Powers the FlowGraph renderer. No DOM, no solid — pure functions so the
3
+ // layout is unit-testable in isolation. ksui ships no graph library (solid +
4
+ // lucide only), so layering is a small longest-path pass, not dagre/d3.
5
+
6
+ export type GraphAccent = "primary" | "info" | "success" | "danger" | "muted";
7
+
8
+ export interface GraphNode {
9
+ /** Stable unique id; edges reference nodes by this. */
10
+ id: string;
11
+ label: string;
12
+ /** Secondary line under the label (e.g. a tier, a category, a count). */
13
+ sublabel?: string;
14
+ accent?: GraphAccent;
15
+ /**
16
+ * Explicit column index. Overrides automatic layering when set — required for
17
+ * a clean bipartite split when a sink node has no edges (e.g. an ungranted
18
+ * permission still belongs in the right column).
19
+ */
20
+ lane?: number;
21
+ }
22
+
23
+ export interface GraphEdge {
24
+ from: string;
25
+ to: string;
26
+ label?: string;
27
+ /**
28
+ * Render the connector dashed — e.g. "requires" vs a solid "provides", or a
29
+ * denied grant vs an allowed one.
30
+ */
31
+ dashed?: boolean;
32
+ accent?: GraphAccent;
33
+ }
34
+
35
+ export type GraphLayout = "layered" | "bipartite";
36
+
37
+ export interface PositionedNode extends GraphNode {
38
+ lane: number;
39
+ row: number;
40
+ x: number;
41
+ y: number;
42
+ }
43
+
44
+ export interface GraphLayoutResult {
45
+ nodes: PositionedNode[];
46
+ /** id → positioned node, for edge endpoint lookup. */
47
+ byId: Map<string, PositionedNode>;
48
+ width: number;
49
+ height: number;
50
+ }
51
+
52
+ export interface GraphMetrics {
53
+ nodeW: number;
54
+ nodeH: number;
55
+ gapX: number;
56
+ gapY: number;
57
+ pad: number;
58
+ }
59
+
60
+ export const DEFAULT_METRICS: GraphMetrics = {
61
+ nodeW: 168,
62
+ nodeH: 48,
63
+ gapX: 72,
64
+ gapY: 18,
65
+ pad: 16,
66
+ };
67
+
68
+ /** Count incoming edges per node id, ignoring edges that dangle off the set. */
69
+ function incomingDegree(ids: Set<string>, edges: GraphEdge[]): Map<string, number> {
70
+ const incoming = new Map<string, number>();
71
+ for (const id of ids) incoming.set(id, 0);
72
+ for (const e of edges) {
73
+ if (ids.has(e.from) && ids.has(e.to)) incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1);
74
+ }
75
+ return incoming;
76
+ }
77
+
78
+ /** Assign each node a lane (column index). Explicit `node.lane` always wins. */
79
+ function assignLanes(
80
+ nodes: GraphNode[],
81
+ edges: GraphEdge[],
82
+ layout: GraphLayout,
83
+ ): Map<string, number> {
84
+ const ids = new Set(nodes.map((n) => n.id));
85
+ const pinned = new Set(nodes.filter((n) => typeof n.lane === "number").map((n) => n.id));
86
+ const lane = new Map<string, number>();
87
+ for (const n of nodes) if (typeof n.lane === "number") lane.set(n.id, n.lane);
88
+
89
+ const incoming = incomingDegree(ids, edges);
90
+
91
+ if (layout === "bipartite") {
92
+ // Sources (nothing points at them) on the left, sinks on the right.
93
+ for (const n of nodes) {
94
+ if (lane.has(n.id)) continue;
95
+ lane.set(n.id, (incoming.get(n.id) ?? 0) > 0 ? 1 : 0);
96
+ }
97
+ return lane;
98
+ }
99
+
100
+ // Layered: longest-path layering. Roots (no incoming) start at 0; relax each
101
+ // edge so a target sits at least one lane past its source. Cap the passes at
102
+ // node-count so a cycle can't spin forever.
103
+ for (const n of nodes) if (!lane.has(n.id)) lane.set(n.id, 0);
104
+ const live = edges.filter((e) => ids.has(e.from) && ids.has(e.to));
105
+ for (let pass = 0; pass < nodes.length; pass++) {
106
+ let changed = false;
107
+ for (const e of live) {
108
+ if (pinned.has(e.to)) continue; // don't move a caller-pinned node
109
+ const want = (lane.get(e.from) ?? 0) + 1;
110
+ if (want > (lane.get(e.to) ?? 0)) {
111
+ lane.set(e.to, want);
112
+ changed = true;
113
+ }
114
+ }
115
+ if (!changed) break;
116
+ }
117
+ return lane;
118
+ }
119
+
120
+ /**
121
+ * Compute node positions for the graph. Lanes flow left→right; within a lane,
122
+ * nodes stack top-down in input order (stable, so the render is deterministic).
123
+ */
124
+ export function layoutGraph(
125
+ nodes: GraphNode[],
126
+ edges: GraphEdge[],
127
+ layout: GraphLayout = "layered",
128
+ metrics: GraphMetrics = DEFAULT_METRICS,
129
+ ): GraphLayoutResult {
130
+ const { nodeW, nodeH, gapX, gapY, pad } = metrics;
131
+ const lane = assignLanes(nodes, edges, layout);
132
+
133
+ const nextRow = new Map<number, number>(); // lane → next free row
134
+ const positioned: PositionedNode[] = nodes.map((n) => {
135
+ const l = lane.get(n.id) ?? 0;
136
+ const r = nextRow.get(l) ?? 0;
137
+ nextRow.set(l, r + 1);
138
+ return {
139
+ ...n,
140
+ lane: l,
141
+ row: r,
142
+ x: pad + l * (nodeW + gapX),
143
+ y: pad + r * (nodeH + gapY),
144
+ };
145
+ });
146
+
147
+ const lanes = positioned.reduce((m, n) => Math.max(m, n.lane + 1), 0);
148
+ const maxRows = [...nextRow.values()].reduce((m, v) => Math.max(m, v), 0);
149
+ const width = lanes > 0 ? pad * 2 + lanes * nodeW + (lanes - 1) * gapX : pad * 2;
150
+ const height = maxRows > 0 ? pad * 2 + maxRows * nodeH + (maxRows - 1) * gapY : pad * 2;
151
+
152
+ const byId = new Map(positioned.map((n) => [n.id, n]));
153
+ return { nodes: positioned, byId, width, height };
154
+ }