@sentropic/design-system-react 0.3.0 → 0.5.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.
Files changed (50) hide show
  1. package/dist/AreaChart.d.ts +16 -2
  2. package/dist/AreaChart.d.ts.map +1 -1
  3. package/dist/AreaChart.js +96 -1
  4. package/dist/AreaChart.js.map +1 -1
  5. package/dist/BarChart.d.ts +16 -2
  6. package/dist/BarChart.d.ts.map +1 -1
  7. package/dist/BarChart.js +90 -1
  8. package/dist/BarChart.js.map +1 -1
  9. package/dist/DataTable.d.ts +49 -2
  10. package/dist/DataTable.d.ts.map +1 -1
  11. package/dist/DataTable.js +144 -1
  12. package/dist/DataTable.js.map +1 -1
  13. package/dist/DonutChart.d.ts +19 -2
  14. package/dist/DonutChart.d.ts.map +1 -1
  15. package/dist/DonutChart.js +61 -1
  16. package/dist/DonutChart.js.map +1 -1
  17. package/dist/ForceGraph.d.ts +2 -2
  18. package/dist/ForceGraph.d.ts.map +1 -1
  19. package/dist/ForceGraph.js +1 -1
  20. package/dist/ForceGraph.js.map +1 -1
  21. package/dist/LineChart.d.ts +17 -2
  22. package/dist/LineChart.d.ts.map +1 -1
  23. package/dist/LineChart.js +92 -1
  24. package/dist/LineChart.js.map +1 -1
  25. package/dist/ScatterPlot.d.ts +19 -2
  26. package/dist/ScatterPlot.d.ts.map +1 -1
  27. package/dist/ScatterPlot.js +55 -1
  28. package/dist/ScatterPlot.js.map +1 -1
  29. package/dist/Sparkline.d.ts +13 -2
  30. package/dist/Sparkline.d.ts.map +1 -1
  31. package/dist/Sparkline.js +32 -1
  32. package/dist/Sparkline.js.map +1 -1
  33. package/dist/StackedBarChart.d.ts +20 -2
  34. package/dist/StackedBarChart.d.ts.map +1 -1
  35. package/dist/StackedBarChart.js +78 -1
  36. package/dist/StackedBarChart.js.map +1 -1
  37. package/dist/catalog.d.ts +75 -97
  38. package/dist/catalog.d.ts.map +1 -1
  39. package/dist/catalog.js +302 -124
  40. package/dist/catalog.js.map +1 -1
  41. package/dist/chartScale.d.ts +25 -0
  42. package/dist/chartScale.d.ts.map +1 -0
  43. package/dist/chartScale.js +71 -0
  44. package/dist/chartScale.js.map +1 -0
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/styles.css +38 -2
  50. package/package.json +1 -1
package/dist/catalog.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { classNames } from "./classNames.js";
4
+ // Normalises an action item so the rest of the component only deals with the
5
+ // React-native shape: `id` (from id|value) and `variant` (from variant|danger).
6
+ function actionId(item, index, prefix = "item") {
7
+ return item.id ?? item.value ?? (index === undefined ? undefined : `${prefix}-${index}`);
8
+ }
9
+ function isDangerAction(item) {
10
+ return item.variant === "danger" || item.danger === true;
11
+ }
4
12
  const DATA_TONES = [
5
13
  "category1",
6
14
  "category2",
@@ -137,21 +145,12 @@ function pct(value, min = 0, max = 100) {
137
145
  return 0;
138
146
  return clamp(((value - min) / (max - min)) * 100, 0, 100);
139
147
  }
140
- function pointsFrom(values, width, height) {
141
- const ys = values.map((entry) => (typeof entry === "number" ? entry : entry.y ?? entry.value ?? 0));
142
- const max = Math.max(...ys, 1);
143
- return ys
144
- .map((value, index) => {
145
- const x = ys.length === 1 ? width / 2 : (index / (ys.length - 1)) * width;
146
- const y = height - (value / max) * height;
147
- return `${x},${y}`;
148
- })
149
- .join(" ");
150
- }
151
- export function Accordion({ items, openIds, defaultOpenIds = [], allowMultiple = true, onChange, className, ...rest }) {
152
- const [open, setOpen] = useControlled(openIds, defaultOpenIds, onChange);
148
+ export function Accordion({ items, openIds, defaultOpenIds, open: openAlias, allowMultiple, multiple, onChange, className, ...rest }) {
149
+ const initialOpen = defaultOpenIds ?? openAlias ?? [];
150
+ const resolvedAllowMultiple = allowMultiple ?? multiple ?? true;
151
+ const [open, setOpen] = useControlled(openIds, initialOpen, onChange);
153
152
  const toggle = (id) => {
154
- const next = open.includes(id) ? open.filter((value) => value !== id) : allowMultiple ? [...open, id] : [id];
153
+ const next = open.includes(id) ? open.filter((value) => value !== id) : resolvedAllowMultiple ? [...open, id] : [id];
155
154
  setOpen(next);
156
155
  };
157
156
  return (_jsx("div", { ...rest, className: classNames("st-accordion", className), children: items.map((item, index) => {
@@ -165,46 +164,6 @@ export function Accordion({ items, openIds, defaultOpenIds = [], allowMultiple =
165
164
  export function Alert({ tone = "info", title, message, actions, children, className, ...rest }) {
166
165
  return (_jsxs("section", { ...rest, className: classNames("st-alert", `st-alert--${tone}`, className), role: tone === "warning" || tone === "error" ? "alert" : "status", children: [_jsxs("div", { className: "st-alert__content", children: [_jsx("h2", { className: "st-alert__title", children: title }), message ? _jsx("p", { className: "st-alert__message", children: message }) : null, children] }), actions ? _jsx("div", { className: "st-alert__actions", children: actions }) : null] }));
167
166
  }
168
- function LinearChart({ data, label, width = 320, height = 160, className, type }) {
169
- const classBase = type === "areaChart" ? "st-areaChart" : "st-lineChart";
170
- const points = pointsFrom(data, width, height);
171
- const accessibleLabel = label ?? (type === "areaChart" ? "Area chart" : "Line chart");
172
- return (_jsxs("figure", { className: classNames(classBase, className), "aria-label": accessibleLabel, children: [_jsx("span", { className: "st-visually-hidden", children: accessibleLabel }), _jsxs("svg", { viewBox: `0 0 `, "aria-hidden": "true", children: [_jsx("polyline", { className: `${classBase}__line`, points: points, fill: "none" }), type === "areaChart" ? _jsx("polygon", { className: "st-areaChart__area", points: `0,${height} ${points} ${width},${height}` }) : null, data.map((datum, index) => (_jsx("circle", { className: `${classBase}__dot`, cx: points.split(" ")[index]?.split(",")[0], cy: points.split(" ")[index]?.split(",")[1], r: "4" }, index)))] })] }));
173
- }
174
- export function AreaChart(props) {
175
- return _jsx(LinearChart, { ...props, type: "areaChart" });
176
- }
177
- export function LineChart(props) {
178
- return _jsx(LinearChart, { ...props, type: "lineChart" });
179
- }
180
- export function BarChart({ data, label = "Bar chart", width = 320, height = 160, className, ...rest }) {
181
- const max = Math.max(...data.map((datum) => datum.value ?? datum.y ?? 0), 1);
182
- return (_jsxs("figure", { ...rest, className: classNames("st-barChart", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: `0 0 `, "aria-hidden": "true", children: data.map((datum, index) => {
183
- const value = datum.value ?? datum.y ?? 0;
184
- const barWidth = width / Math.max(data.length, 1) - 8;
185
- const barHeight = (value / max) * height;
186
- return (_jsx("rect", { className: classNames("st-barChart__bar", `st-barChart__bar--${datum.tone ?? DATA_TONES[index % DATA_TONES.length]}`), x: index * (barWidth + 8) + 4, y: height - barHeight, width: barWidth, height: barHeight }, index));
187
- }) })] }));
188
- }
189
- export function DonutChart({ data, label = "Donut chart", className, ...rest }) {
190
- const total = data.reduce((sum, datum) => sum + (datum.value ?? datum.y ?? 0), 0);
191
- return (_jsxs("figure", { ...rest, className: classNames("st-donutChart", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsxs("svg", { viewBox: "0 0 120 120", "aria-hidden": "true", children: [data.map((datum, index) => (_jsx("circle", { className: classNames("st-donutChart__slice", `st-donutChart__slice--${datum.tone ?? DATA_TONES[index % DATA_TONES.length]}`), cx: "60", cy: "60", r: 36 - index * 3, fill: "none", strokeWidth: "8" }, index))), _jsx("text", { className: "st-donutChart__center", x: "60", y: "64", textAnchor: "middle", children: total })] })] }));
192
- }
193
- export function ScatterPlot({ data, label = "Scatter chart", className, ...rest }) {
194
- return (_jsxs("figure", { ...rest, className: classNames("st-scatterPlot", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: "0 0 320 160", "aria-hidden": "true", children: data.map((datum, index) => (_jsx("circle", { className: classNames("st-scatterPlot__point", `st-scatterPlot__point--${datum.tone ?? DATA_TONES[index % DATA_TONES.length]}`), cx: clamp(datum.x * 24 + 24, 12, 308), cy: clamp(148 - datum.y * 24, 12, 148), r: "5", children: _jsx("title", { children: datum.label ?? `${datum.x}, ${datum.y}` }) }, index))) })] }));
195
- }
196
- export function StackedBarChart({ data, label = "Stacked bar chart", className, ...rest }) {
197
- return (_jsxs("figure", { ...rest, className: classNames("st-stackedBar", className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: "0 0 320 160", "aria-hidden": "true", children: data.map((bar, barIndex) => {
198
- let x = 16;
199
- const total = Math.max(bar.segments.reduce((sum, segment) => sum + segment.value, 0), 1);
200
- return (_jsxs("g", { transform: `translate(0 ${barIndex * 32 + 16})`, children: [bar.segments.map((segment, index) => {
201
- const width = (segment.value / total) * 220;
202
- const rect = (_jsx("rect", { className: classNames("st-stackedBar__seg", `st-stackedBar__seg--${segment.tone ?? DATA_TONES[index % DATA_TONES.length]}`), x: x, y: "0", width: width, height: "20" }, segment.label));
203
- x += width;
204
- return rect;
205
- }), _jsx("text", { className: "st-stackedBar__categoryLabel", x: "250", y: "15", children: bar.label })] }, bar.label));
206
- }) })] }));
207
- }
208
167
  export function AspectRatio({ ratio = "16 / 9", className, style, children, ...rest }) {
209
168
  const aspectRatio = typeof ratio === "number" ? String(ratio) : ratio;
210
169
  return (_jsx("div", { ...rest, className: classNames("st-aspectRatio", className), style: { aspectRatio, ...style }, children: children }));
@@ -292,11 +251,12 @@ export function Combobox({ label, options, value, size = "md", placeholder = "Se
292
251
  selectOption(option);
293
252
  }, children: option.label }, option.value)))) : (_jsx("li", { className: "st-combobox__empty", role: "option", "aria-disabled": "true", "aria-selected": "false", children: noResultsLabel })) })) : null] }));
294
253
  }
295
- export function ContentSwitcher({ items, value, activeId, onChange, size = "md", className, ...rest }) {
254
+ export function ContentSwitcher({ items, value, activeId, onChange, onchange, size = "md", className, ...rest }) {
255
+ const handleChange = onChange ?? onchange;
296
256
  const current = value ?? activeId ?? idFrom(items[0] ?? {}, 0, "content");
297
257
  return (_jsx("div", { ...rest, className: classNames("st-contentSwitcher", `st-contentSwitcher--${size}`, className), role: "group", children: items.map((item, index) => {
298
258
  const itemId = idFrom(item, index, "content");
299
- return (_jsx("button", { type: "button", className: classNames("st-contentSwitcher__option st-contentSwitcher__button", itemId === current && "st-contentSwitcher__option--selected"), disabled: item.disabled, "aria-pressed": itemId === current, onClick: () => onChange?.(itemId), children: item.label }, itemId));
259
+ return (_jsx("button", { type: "button", className: classNames("st-contentSwitcher__option st-contentSwitcher__button", itemId === current && "st-contentSwitcher__option--selected"), disabled: item.disabled, "aria-pressed": itemId === current, onClick: () => handleChange?.(itemId), children: item.label }, itemId));
300
260
  }) }));
301
261
  }
302
262
  export function CopyButton({ text: copyText, value, label = "Copy", copiedLabel = "Copied", size = "md", className, onClick, ...rest }) {
@@ -307,11 +267,6 @@ export function CopyButton({ text: copyText, value, label = "Copy", copiedLabel
307
267
  onClick?.(event);
308
268
  }, children: _jsx("span", { className: "st-copyButton__label", children: copied ? copiedLabel : label }) }));
309
269
  }
310
- export function DataTable({ columns, rows, caption, size = "md", className, pageSize, page = 1, totalItems, ...rest }) {
311
- const visibleRows = pageSize ? rows.slice((page - 1) * pageSize, page * pageSize) : rows;
312
- const total = totalItems ?? rows.length;
313
- return (_jsxs("div", { className: "st-dataTable-wrap", children: [_jsxs("table", { ...rest, className: classNames("st-dataTable", `st-dataTable--${size}`, className), children: [caption ? _jsx("caption", { children: caption }) : null, _jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { children: column.label }, column.key)) }) }), _jsx("tbody", { children: visibleRows.map((row) => (_jsx("tr", { children: columns.map((column) => (_jsx("td", { className: classNames(column.align === "center" && "st-dataTable__cell--center", column.align === "end" && "st-dataTable__cell--end"), children: column.render?.(row, column) ?? text(row[column.key]) }, column.key))) }, row.id))) })] }), pageSize ? _jsx("div", { className: "st-dataTable__pagerStatus", children: `${(page - 1) * pageSize + 1}-${Math.min(page * pageSize, total)} of ${total}` }) : null] }));
314
- }
315
270
  export function DatePicker({ label, value, size = "md", className, ...rest }) {
316
271
  return (_jsx("div", { ...rest, className: classNames("st-datepicker", `st-datepicker--${size}`, className), children: _jsx(Field, { label: label, children: (inputId) => _jsx("input", { id: inputId, className: "st-control st-datepicker__control", type: "date", defaultValue: value }) }) }));
317
272
  }
@@ -338,7 +293,8 @@ export function Drawer({ open = false, title, description, footer, placement = "
338
293
  onClose?.();
339
294
  }, children: _jsxs("aside", { ...rest, ref: panelRef, className: classNames("st-drawer", `st-drawer--${placement}`, className), role: "dialog", "aria-modal": "true", "aria-label": text(title) || "Drawer", tabIndex: -1, onKeyDown: (event) => trapTabKey(event, panelRef.current), children: [_jsxs("div", { className: "st-drawer__header", children: [title ? _jsx("h2", { className: "st-drawer__title", children: title }) : null, _jsx("button", { ref: closeRef, type: "button", className: "st-drawer__close", onClick: onClose, "aria-label": "Close", children: "x" })] }), description ? _jsx("p", { className: "st-drawer__description", children: description }) : null, _jsx("div", { className: "st-drawer__body", children: children }), footer ? _jsx("div", { className: "st-drawer__footer", children: footer }) : null] }) }));
340
295
  }
341
- export function Dropdown({ label = "Select", options, value, open: controlledOpen, placeholder = "Select", onSelect, className, ...rest }) {
296
+ export function Dropdown({ label = "Select", options, value, open: controlledOpen, placeholder = "Select", onSelect, onselect, className, ...rest }) {
297
+ const handleSelect = onSelect ?? onselect;
342
298
  const hostRef = React.useRef(null);
343
299
  const itemRefs = React.useRef([]);
344
300
  const [open, setOpen] = useControlled(controlledOpen, false);
@@ -358,7 +314,7 @@ export function Dropdown({ label = "Select", options, value, open: controlledOpe
358
314
  setCurrent(option.value);
359
315
  setOpen(false);
360
316
  setActiveIndex(-1);
361
- onSelect?.(option.value);
317
+ handleSelect?.(option.value);
362
318
  };
363
319
  useOutsideMouseDown(open, hostRef, () => setOpen(false));
364
320
  useEscape(open, () => setOpen(false));
@@ -392,50 +348,126 @@ export function Dropdown({ label = "Select", options, value, open: controlledOpe
392
348
  export function EmptyState({ title, message, action, children, className, ...rest }) {
393
349
  return (_jsx("section", { ...rest, className: classNames("st-empty-state st-emptyState", className), children: _jsxs("div", { className: "st-empty-state__content", children: [_jsx("h2", { className: "st-empty-state__title st-emptyState__title", children: title }), message ? _jsx("p", { className: "st-empty-state__message st-emptyState__message", children: message }) : null, children, action ? _jsx("div", { className: "st-empty-state__action", children: action }) : null] }) }));
394
350
  }
351
+ function formatFileSize(bytes) {
352
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0)
353
+ return "";
354
+ if (bytes === 0)
355
+ return "0 B";
356
+ const units = ["B", "KB", "MB", "GB", "TB"];
357
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
358
+ const value = bytes / Math.pow(1024, i);
359
+ const formatted = value >= 10 || i === 0 ? value.toFixed(0) : value.toFixed(1);
360
+ return `${formatted} ${units[i]}`;
361
+ }
362
+ function fileItemName(item) {
363
+ return item.file?.name ?? item.name;
364
+ }
365
+ function fileItemSize(item) {
366
+ return item.file?.size ?? item.size;
367
+ }
395
368
  export function FileUploader({ label = "Upload", items = [], disabled = false, className, ...rest }) {
396
- return (_jsxs("div", { ...rest, className: classNames("st-fileUploader-field", className), children: [_jsxs("div", { className: classNames("st-fileUploader__dropzone", disabled && "st-fileUploader__dropzone--disabled"), children: [_jsx("span", { className: "st-fileUploader__trigger", children: label }), _jsx("input", { className: "st-fileUploader__input", type: "file", disabled: disabled, "aria-label": text(label) })] }), _jsx("ul", { className: "st-fileUploader__list", children: items.map((item, index) => (_jsxs("li", { className: classNames("st-fileUploader__item", item.status && `st-fileUploader__item--${item.status}`), children: [_jsx("span", { className: "st-fileUploader__itemName st-fileUploader__name", children: item.name }), item.error ? _jsx("span", { className: "st-fileUploader__itemError", children: item.error }) : null] }, item.id ?? item.name ?? index))) })] }));
369
+ return (_jsxs("div", { ...rest, className: classNames("st-fileUploader-field", className), children: [_jsxs("div", { className: classNames("st-fileUploader__dropzone", disabled && "st-fileUploader__dropzone--disabled"), children: [_jsx("span", { className: "st-fileUploader__trigger", children: label }), _jsx("input", { className: "st-fileUploader__input", type: "file", disabled: disabled, "aria-label": text(label) })] }), _jsx("ul", { className: "st-fileUploader__list", children: items.map((item, index) => {
370
+ const name = fileItemName(item);
371
+ const size = fileItemSize(item);
372
+ const sizeLabel = formatFileSize(size);
373
+ return (_jsxs("li", { className: classNames("st-fileUploader__item", item.status && `st-fileUploader__item--${item.status}`), children: [_jsx("span", { className: "st-fileUploader__itemName st-fileUploader__name", children: name }), sizeLabel ? _jsx("span", { className: "st-fileUploader__itemSize", children: sizeLabel }) : null, item.error ? _jsx("span", { className: "st-fileUploader__itemError", children: item.error }) : null] }, item.id ?? name ?? index));
374
+ }) })] }));
397
375
  }
398
376
  export function Footer({ brand, columns, links, copyright, className, ...rest }) {
399
377
  const groups = columns ?? (links ? [{ links }] : []);
400
378
  return (_jsxs("footer", { ...rest, className: classNames("st-footer", className), children: [_jsxs("div", { className: "st-footer__top", children: [brand ? _jsx("div", { className: "st-footer__brand", children: brand }) : null, _jsx("div", { className: "st-footer__columns", children: groups.map((group, index) => (_jsxs("nav", { children: [group.title ? _jsx("h2", { children: group.title }) : null, group.links.map((link) => (_jsx("a", { href: link.href, children: link.label }, link.href)))] }, index))) })] }), copyright ? _jsx("div", { className: "st-footer__copyright", children: copyright }) : null] }));
401
379
  }
380
+ /**
381
+ * Maps a dash style (or the legacy `weak` flag) to an SVG stroke-dasharray.
382
+ * Returns null for a solid stroke.
383
+ */
384
+ export function edgeDashArray(dash, weak) {
385
+ const effective = dash ?? (weak ? "dashed" : undefined);
386
+ switch (effective) {
387
+ case "dashed":
388
+ return "6 4";
389
+ case "dotted":
390
+ return "1 4";
391
+ case "long-dash":
392
+ return "12 6";
393
+ case "solid":
394
+ default:
395
+ return null;
396
+ }
397
+ }
402
398
  // ---------------------------------------------------------------------------
403
399
  // SVG path helpers for the various node shapes.
404
- // All shapes are centered at (0,0) and sized to inscribe within radius r.
400
+ // All shapes are centered at (0,0). Each shape is scaled so its filled area
401
+ // matches that of the reference circle (π·r²) — this keeps equal-weight nodes
402
+ // visually balanced rather than letting squares/diamonds read as "bigger".
403
+ //
404
+ // Per-shape scale factors (closed-form, area = π·r²):
405
+ // square / roundedbox : half-side = (√π)/2 · r ≈ 0.8862·r
406
+ // diamond : half-diag = √(π/2) · r ≈ 1.2533·r
407
+ // triangle (equilat.) : circumradius= √(π/(3√3/4)) · r ≈ 1.5551·r
408
+ // hexagon (regular) : circumradius= √(π/(3√3/2)) · r ≈ 1.0996·r
409
+ // star (5-pt, k=0.42) : outer radius= √(π/A₁) · r ≈ 1.5953·r
410
+ // where A₁ is the unit-star area (≈1.2343).
405
411
  // ---------------------------------------------------------------------------
412
+ const FORCE_GRAPH_STAR_INNER_RATIO = 0.42;
413
+ const FORCE_GRAPH_STAR_AREA_FACTOR = 1.5953498885642274; // √(π / unit-star-area)
414
+ // Format a coordinate: 4 dp, snapping floating-point near-zero (e.g. 9e-16)
415
+ // to a clean 0 so paths never contain scientific notation.
416
+ function forceGraphFmt(n) {
417
+ const v = Math.abs(n) < 1e-9 ? 0 : n;
418
+ return Number(v.toFixed(4)).toString();
419
+ }
406
420
  export function nodeShapePath(shape, r) {
407
421
  const s = shape ?? "dot";
408
422
  if (s === "dot" || s === "circle")
409
423
  return null; // use <circle>
410
424
  if (s === "diamond") {
411
- return `M 0 ${-r} L ${r} 0 L 0 ${r} L ${-r} 0 Z`;
425
+ const d = Math.sqrt(Math.PI / 2) * r; // half-diagonal
426
+ return `M 0 ${forceGraphFmt(-d)} L ${forceGraphFmt(d)} 0 L 0 ${forceGraphFmt(d)} L ${forceGraphFmt(-d)} 0 Z`;
412
427
  }
413
428
  if (s === "star") {
414
- const outer = r;
415
- const inner = r * 0.42;
429
+ const outer = FORCE_GRAPH_STAR_AREA_FACTOR * r;
430
+ const inner = outer * FORCE_GRAPH_STAR_INNER_RATIO;
416
431
  const pts = [];
417
432
  for (let i = 0; i < 10; i++) {
418
433
  const angle = (i * Math.PI) / 5 - Math.PI / 2;
419
434
  const rad = i % 2 === 0 ? outer : inner;
420
- pts.push(`${rad * Math.cos(angle)},${rad * Math.sin(angle)}`);
435
+ pts.push(`${forceGraphFmt(rad * Math.cos(angle))},${forceGraphFmt(rad * Math.sin(angle))}`);
421
436
  }
422
437
  return `M ${pts.join(" L ")} Z`;
423
438
  }
424
439
  if (s === "hexagon") {
440
+ const R = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 2)) * r; // circumradius
425
441
  const pts = [];
426
442
  for (let i = 0; i < 6; i++) {
427
443
  const angle = (i * Math.PI) / 3 - Math.PI / 6;
428
- pts.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
444
+ pts.push(`${forceGraphFmt(R * Math.cos(angle))},${forceGraphFmt(R * Math.sin(angle))}`);
429
445
  }
430
446
  return `M ${pts.join(" L ")} Z`;
431
447
  }
432
448
  if (s === "box" || s === "square") {
433
- const h = r * 0.85;
434
- return `M ${-h} ${-h} L ${h} ${-h} L ${h} ${h} L ${-h} ${h} Z`;
449
+ const h = (Math.sqrt(Math.PI) / 2) * r; // half-side, area = (2h)² = π·r²
450
+ return `M ${forceGraphFmt(-h)} ${forceGraphFmt(-h)} L ${forceGraphFmt(h)} ${forceGraphFmt(-h)} L ${forceGraphFmt(h)} ${forceGraphFmt(h)} L ${forceGraphFmt(-h)} ${forceGraphFmt(h)} Z`;
451
+ }
452
+ if (s === "roundedbox") {
453
+ const h = (Math.sqrt(Math.PI) / 2) * r; // same footprint as square
454
+ const rx = h * 0.6; // ≈ r·0.3 rounding radius (h ≈ 0.886·r)
455
+ // Rounded rectangle via arcs, clockwise from top edge.
456
+ return (`M ${forceGraphFmt(-h + rx)} ${forceGraphFmt(-h)} ` +
457
+ `L ${forceGraphFmt(h - rx)} ${forceGraphFmt(-h)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(h)} ${forceGraphFmt(-h + rx)} ` +
458
+ `L ${forceGraphFmt(h)} ${forceGraphFmt(h - rx)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(h - rx)} ${forceGraphFmt(h)} ` +
459
+ `L ${forceGraphFmt(-h + rx)} ${forceGraphFmt(h)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(-h)} ${forceGraphFmt(h - rx)} ` +
460
+ `L ${forceGraphFmt(-h)} ${forceGraphFmt(-h + rx)} A ${forceGraphFmt(rx)} ${forceGraphFmt(rx)} 0 0 1 ${forceGraphFmt(-h + rx)} ${forceGraphFmt(-h)} Z`);
435
461
  }
436
462
  if (s === "triangle") {
437
- const h = r * 1.1;
438
- return `M 0 ${-h} L ${h * 0.9} ${h * 0.6} L ${-h * 0.9} ${h * 0.6} Z`;
463
+ // Equilateral, centred at centroid; circumradius h so apex is up.
464
+ const h = Math.sqrt(Math.PI / ((3 * Math.sqrt(3)) / 4)) * r;
465
+ const pts = [];
466
+ for (let i = 0; i < 3; i++) {
467
+ const angle = (i * 2 * Math.PI) / 3 - Math.PI / 2;
468
+ pts.push(`${forceGraphFmt(h * Math.cos(angle))},${forceGraphFmt(h * Math.sin(angle))}`);
469
+ }
470
+ return `M ${pts.join(" L ")} Z`;
439
471
  }
440
472
  return null;
441
473
  }
@@ -573,9 +605,14 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
573
605
  }
574
606
  sn.x += sn.vx;
575
607
  sn.y += sn.vy;
576
- // Keep inside a padded viewport.
577
- sn.x = Math.max(nodeRadius * 2, Math.min(w - nodeRadius * 2, sn.x));
578
- sn.y = Math.max(nodeRadius * 2, Math.min(h - nodeRadius * 2, sn.y));
608
+ // Soft clamp: allow the layout to overflow the canvas so it keeps a
609
+ // natural shape (fit-to-content reframes it afterwards). The wide bound
610
+ // only guards against runaway coordinates, it no longer glues nodes to
611
+ // the four edges.
612
+ const padX = w * 0.5 + nodeRadius * 2;
613
+ const padY = h * 0.5 + nodeRadius * 2;
614
+ sn.x = Math.max(-padX, Math.min(w + padX, sn.x));
615
+ sn.y = Math.max(-padY, Math.min(h + padY, sn.y));
579
616
  }
580
617
  temperature *= cooling;
581
618
  }
@@ -584,7 +621,12 @@ function runForceGraphSimulation(ns, es, w, h, ticks, nodeRadius) {
584
621
  out.set(sn.id, { x: sn.x, y: sn.y });
585
622
  return out;
586
623
  }
587
- export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, className, ...rest }) {
624
+ // Curvature offset factor: how far (relative to chord length) the control
625
+ // point bows out at edgeCurve=1. Kept modest so edgeCurve≈0.15 reads "light".
626
+ const FORCE_GRAPH_CURVE_FACTOR = 0.5;
627
+ // Fit-to-content margin (per side, fraction of the content box).
628
+ const FORCE_GRAPH_CONTENT_MARGIN = 0.08;
629
+ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nodeRadius = 7, showLabels = true, iterations = 300, selectedIds = [], focusId = null, onSelect, onOpenEntity, onEdgeHover, legend, edgeCurve = 0.15, className, ...rest }) {
588
630
  // SSR-safe reduced-motion check (window may be undefined during SSR/tests).
589
631
  const prefersReducedMotion = typeof window !== "undefined" &&
590
632
  typeof window.matchMedia === "function" &&
@@ -615,6 +657,7 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
615
657
  }), [nodes, layout, width, height, nodeRadius, toneMap]);
616
658
  const positionedEdges = React.useMemo(() => {
617
659
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
660
+ const curve = Math.max(0, edgeCurve ?? 0);
618
661
  return edges
619
662
  .map((e, i) => {
620
663
  const a = layout.get(e.source);
@@ -623,22 +666,134 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
623
666
  return null;
624
667
  const srcNode = nodeById.get(e.source);
625
668
  const tgtNode = nodeById.get(e.target);
669
+ const x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
670
+ // Quadratic control point: midpoint pushed perpendicular to the chord.
671
+ let path = null;
672
+ let cx = (x1 + x2) / 2;
673
+ let cy = (y1 + y2) / 2;
674
+ if (curve > 0) {
675
+ const dx = x2 - x1;
676
+ const dy = y2 - y1;
677
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
678
+ const off = curve * dist * FORCE_GRAPH_CURVE_FACTOR;
679
+ // Unit perpendicular to the chord.
680
+ const px = -dy / dist;
681
+ const py = dx / dist;
682
+ cx = (x1 + x2) / 2 + px * off;
683
+ cy = (y1 + y2) / 2 + py * off;
684
+ path = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`;
685
+ }
686
+ const dashArray = edgeDashArray(e.dash, e.weak);
687
+ const strokeWidth = typeof e.width === "number" ? e.width : e.emphasis ? 2.5 : null;
626
688
  return {
627
689
  edge: e,
628
690
  i,
629
- x1: a.x,
630
- y1: a.y,
631
- x2: b.x,
632
- y2: b.y,
691
+ x1,
692
+ y1,
693
+ x2,
694
+ y2,
695
+ // Tooltip / label anchor follows the curve apex when curved.
696
+ midX: cx,
697
+ midY: cy,
698
+ path,
699
+ dashArray,
700
+ strokeWidth,
633
701
  srcLabel: srcNode?.label ?? e.source,
634
702
  tgtLabel: tgtNode?.label ?? e.target,
635
703
  };
636
704
  })
637
705
  .filter((e) => e !== null);
638
- }, [nodes, edges, layout]);
706
+ }, [nodes, edges, layout, edgeCurve]);
707
+ // ---------------------------------------------------------------------------
708
+ // Fit-to-content (Feature 5): after warmup the layout may extend beyond the
709
+ // nominal width/height. Compute the real content bounding-box (node centres
710
+ // ± radius) and frame it with an 8% margin on each side. The base viewBox is
711
+ // this frame (not the fixed 0,0,w,h), so the graph is centred and never
712
+ // clipped, whatever the aspect ratio. Zoom/pan stay relative to this frame.
713
+ // ---------------------------------------------------------------------------
714
+ const contentBox = React.useMemo(() => {
715
+ if (positionedNodes.length === 0) {
716
+ return { x: 0, y: 0, w: width, h: height };
717
+ }
718
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
719
+ for (const p of positionedNodes) {
720
+ // Use the worst-case extent for non-circular shapes (area-scaled) so the
721
+ // glyph (and a little label room) is never clipped.
722
+ const ext = p.r * 1.7;
723
+ minX = Math.min(minX, p.x - ext);
724
+ minY = Math.min(minY, p.y - ext);
725
+ maxX = Math.max(maxX, p.x + ext);
726
+ maxY = Math.max(maxY, p.y + ext);
727
+ }
728
+ let w = maxX - minX;
729
+ let h = maxY - minY;
730
+ // Guard against a degenerate (single node / collinear) box.
731
+ if (!(w > 0)) {
732
+ w = width;
733
+ minX = maxX - w / 2;
734
+ }
735
+ if (!(h > 0)) {
736
+ h = height;
737
+ minY = maxY - h / 2;
738
+ }
739
+ const mx = w * FORCE_GRAPH_CONTENT_MARGIN;
740
+ const my = h * FORCE_GRAPH_CONTENT_MARGIN;
741
+ return { x: minX - mx, y: minY - my, w: w + 2 * mx, h: h + 2 * my };
742
+ }, [positionedNodes, width, height]);
639
743
  const [hoveredNodeIndex, setHoveredNodeIndex] = React.useState(null);
640
744
  const [hoveredEdgeIndex, setHoveredEdgeIndex] = React.useState(null);
641
745
  const selectedSet = React.useMemo(() => new Set(selectedIds), [selectedIds]);
746
+ // Adjacency: id -> set of directly connected node ids. Used to keep the
747
+ // direct neighbours of selected/focused nodes fully visible (demand 6).
748
+ const adjacency = React.useMemo(() => {
749
+ const adj = new Map();
750
+ const add = (a, b) => {
751
+ let set = adj.get(a);
752
+ if (!set) {
753
+ set = new Set();
754
+ adj.set(a, set);
755
+ }
756
+ set.add(b);
757
+ };
758
+ for (const e of edges) {
759
+ add(e.source, e.target);
760
+ add(e.target, e.source);
761
+ }
762
+ return adj;
763
+ }, [edges]);
764
+ // True when a selection/focus is active — only then do we dim non-related
765
+ // nodes. The set of "active" ids = selected ∪ focus ∪ all their neighbours.
766
+ const hasActiveSelection = selectedSet.size > 0 || focusId != null;
767
+ const activeAndNeighbours = React.useMemo(() => {
768
+ const active = new Set(selectedSet);
769
+ if (focusId != null)
770
+ active.add(focusId);
771
+ // Expand to direct neighbours so they stay fully visible.
772
+ const withNeighbours = new Set(active);
773
+ for (const id of active) {
774
+ const nb = adjacency.get(id);
775
+ if (nb)
776
+ for (const n of nb)
777
+ withNeighbours.add(n);
778
+ }
779
+ return withNeighbours;
780
+ }, [selectedSet, focusId, adjacency]);
781
+ // A node is dimmed by selection when there IS an active selection and the
782
+ // node is neither selected/focused nor a direct neighbour of one.
783
+ function isSelectionDimmed(id) {
784
+ if (!hasActiveSelection)
785
+ return false;
786
+ return !activeAndNeighbours.has(id);
787
+ }
788
+ // An edge stays fully visible when at least one endpoint is in the
789
+ // selected/focused set (it is a connection of the selection).
790
+ function isEdgeSelectionDimmed(e) {
791
+ if (!hasActiveSelection)
792
+ return false;
793
+ const srcActive = selectedSet.has(e.source) || focusId === e.source;
794
+ const tgtActive = selectedSet.has(e.target) || focusId === e.target;
795
+ return !(srcActive || tgtActive);
796
+ }
642
797
  // Keyboard handler for a node element: Space/Enter → onSelect, Enter → onOpenEntity.
643
798
  function handleNodeKeydown(id, e) {
644
799
  if (e.key === "Enter" || e.key === " ") {
@@ -650,10 +805,11 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
650
805
  }
651
806
  }
652
807
  // ---------------------------------------------------------------------------
653
- // Zoom + pan state. Store zoom as a scale multiplier + pan offset so syncing
654
- // with width/height props is trivial.
655
- // vbW = width / zoomScale, vbH = height / zoomScale
656
- // vbX / vbY = pan offset in SVG coordinate space
808
+ // Zoom + pan state (framed by the fit-to-content box, Feature 5). The base
809
+ // frame is `contentBox` (not 0,0,w,h). Zoom is a scale multiplier and pan is
810
+ // an offset in SVG coords, both relative to that base frame:
811
+ // vbW = baseW / zoomScale, vbH = baseH / zoomScale
812
+ // vbX = baseX + panX, vbY = baseY + panY
657
813
  // ---------------------------------------------------------------------------
658
814
  const [zoomScale, setZoomScale] = React.useState(1);
659
815
  const [panX, setPanX] = React.useState(0);
@@ -662,10 +818,15 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
662
818
  const panStartRef = React.useRef({ x: 0, y: 0, panX: 0, panY: 0 });
663
819
  const svgRef = React.useRef(null);
664
820
  const [isPanning, setIsPanning] = React.useState(false);
665
- const vbW = width / zoomScale;
666
- const vbH = height / zoomScale;
667
- const vbX = panX;
668
- const vbY = panY;
821
+ // Base frame dimensions = fit-to-content box.
822
+ const baseW = contentBox.w;
823
+ const baseH = contentBox.h;
824
+ const baseX = contentBox.x;
825
+ const baseY = contentBox.y;
826
+ const vbW = baseW / zoomScale;
827
+ const vbH = baseH / zoomScale;
828
+ const vbX = baseX + panX;
829
+ const vbY = baseY + panY;
669
830
  function resetView() {
670
831
  setZoomScale(1);
671
832
  setPanX(0);
@@ -682,14 +843,17 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
682
843
  // Anchor zoom around the cursor position in SVG coords.
683
844
  if (svgRef.current) {
684
845
  const rect = svgRef.current.getBoundingClientRect();
685
- const cursorSvgX = panX + ((ev.clientX - rect.left) / rect.width) * (width / zoomScale);
686
- const cursorSvgY = panY + ((ev.clientY - rect.top) / rect.height) * (height / zoomScale);
687
- const newVbW = width / newScale;
688
- const newVbH = height / newScale;
689
- const ratioX = (cursorSvgX - panX) / (width / zoomScale);
690
- const ratioY = (cursorSvgY - panY) / (height / zoomScale);
691
- setPanX(cursorSvgX - ratioX * newVbW);
692
- setPanY(cursorSvgY - ratioY * newVbH);
846
+ const curW = baseW / zoomScale;
847
+ const curH = baseH / zoomScale;
848
+ const cursorSvgX = vbX + ((ev.clientX - rect.left) / rect.width) * curW;
849
+ const cursorSvgY = vbY + ((ev.clientY - rect.top) / rect.height) * curH;
850
+ const newVbW = baseW / newScale;
851
+ const newVbH = baseH / newScale;
852
+ const ratioX = (cursorSvgX - vbX) / curW;
853
+ const ratioY = (cursorSvgY - vbY) / curH;
854
+ // New top-left so the cursor anchor stays put, then back out the pan term.
855
+ setPanX(cursorSvgX - ratioX * newVbW - baseX);
856
+ setPanY(cursorSvgY - ratioY * newVbH - baseY);
693
857
  }
694
858
  setZoomScale(newScale);
695
859
  }
@@ -723,10 +887,15 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
723
887
  const hoveredNodeRelCount = hoveredNode
724
888
  ? positionedEdges.filter((e) => e.edge.source === hoveredNode.node.id || e.edge.target === hoveredNode.node.id).length
725
889
  : 0;
726
- return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) => (_jsxs(React.Fragment, { children: [_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: () => {
727
- setHoveredEdgeIndex(e.i);
728
- onEdgeHover?.(e.edge);
729
- }, onMouseLeave: () => setHoveredEdgeIndex(null) }), _jsx("line", { className: classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", hoveredEdgeIndex === e.i && "st-forceGraph__edge--hovered"), x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, pointerEvents: "none" })] }, e.i))) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
890
+ return (_jsxs("div", { ...rest, className: classNames("st-forceGraph", prefersReducedMotion && "st-forceGraph--static", className), role: "img", "aria-label": label, children: [_jsxs("svg", { ref: svgRef, viewBox: viewBox, preserveAspectRatio: "xMidYMid meet", width: "100%", height: "100%", focusable: "false", "aria-hidden": "true", className: classNames(isPanning && "st-forceGraph__svg--panning"), onWheel: handleWheel, onMouseDown: handleBgMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsx("g", { className: "st-forceGraph__edges", children: positionedEdges.map((e) => {
891
+ const onHitEnter = () => {
892
+ setHoveredEdgeIndex(e.i);
893
+ onEdgeHover?.(e.edge);
894
+ };
895
+ const onHitLeave = () => setHoveredEdgeIndex(null);
896
+ const edgeClass = classNames("st-forceGraph__edge", e.edge.weak && "st-forceGraph__edge--weak", e.edge.emphasis && "st-forceGraph__edge--emphasis", hoveredEdgeIndex === e.i && "st-forceGraph__edge--hovered", isEdgeSelectionDimmed(e.edge) && "st-forceGraph__edge--dim");
897
+ return (_jsxs(React.Fragment, { children: [e.path ? (_jsx("path", { className: "st-forceGraph__edgeHit", role: "presentation", d: e.path, fill: "none", onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })) : (_jsx("line", { className: "st-forceGraph__edgeHit", role: "presentation", x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, onMouseEnter: onHitEnter, onMouseLeave: onHitLeave })), e.path ? (_jsx("path", { className: edgeClass, d: e.path, fill: "none", strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, pointerEvents: "none" })) : (_jsx("line", { className: edgeClass, x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, strokeDasharray: e.dashArray ?? undefined, strokeWidth: e.strokeWidth ?? undefined, pointerEvents: "none" }))] }, e.i));
898
+ }) }), _jsx("g", { className: "st-forceGraph__nodes", children: positionedNodes.map((p) => {
730
899
  const ariaLabel = `${p.title}${p.node.group !== undefined ? `: ${p.node.group}` : ""}`;
731
900
  const pressed = selectedSet.has(p.node.id);
732
901
  const shapeProps = {
@@ -743,28 +912,32 @@ export function ForceGraph({ nodes, edges, label, width = 480, height = 360, nod
743
912
  onDoubleClick: () => onOpenEntity?.(p.node.id),
744
913
  onKeyDown: (e) => handleNodeKeydown(p.node.id, e),
745
914
  };
746
- return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, hoveredNodeIndex !== null && hoveredNodeIndex !== p.i && "st-forceGraph__node--dim", pressed && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus"), transform: `translate(${p.x} ${p.y})`, children: [p.shapePath ? (_jsx("path", { ...shapeProps, d: p.shapePath })) : (_jsx("circle", { ...shapeProps, r: p.r })), showLabels ? (_jsx("text", { className: "st-forceGraph__label", x: p.r + 3, y: 0, dominantBaseline: "middle", children: p.title })) : null] }, p.node.id));
915
+ return (_jsxs("g", { className: classNames("st-forceGraph__node", `st-forceGraph__node--${p.tone}`, ((hoveredNodeIndex !== null && hoveredNodeIndex !== p.i) ||
916
+ isSelectionDimmed(p.node.id)) &&
917
+ "st-forceGraph__node--dim", pressed && "st-forceGraph__node--selected", focusId === p.node.id && "st-forceGraph__node--focus"), transform: `translate(${p.x} ${p.y})`, children: [p.shapePath ? (_jsx("path", { ...shapeProps, d: p.shapePath })) : (_jsx("circle", { ...shapeProps, r: p.r })), showLabels ? (_jsx("text", { className: "st-forceGraph__label", x: p.r + 3, y: 0, dominantBaseline: "middle", children: p.title })) : null] }, p.node.id));
747
918
  }) })] }), hoveredNode ? (_jsxs("div", { className: "st-forceGraph__tooltip", role: "presentation", style: {
748
919
  left: `${((hoveredNode.x - vbX) / vbW) * 100}%`,
749
920
  top: `${((hoveredNode.y - vbY) / vbH) * 100}%`,
750
921
  }, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredNode.title }), hoveredNode.node.group !== undefined ? (_jsx("span", { className: "st-forceGraph__tooltipMeta", children: hoveredNode.node.group })) : null, hoveredNodeRelCount > 0 ? (_jsxs("span", { className: "st-forceGraph__tooltipMeta", children: [hoveredNodeRelCount, " relation", hoveredNodeRelCount === 1 ? "" : "s"] })) : null] })) : null, hoveredEdge ? (_jsxs("div", { className: "st-forceGraph__tooltip st-forceGraph__tooltip--edge", role: "presentation", style: {
751
- left: `${(((hoveredEdge.x1 + hoveredEdge.x2) / 2 - vbX) / vbW) * 100}%`,
752
- top: `${(((hoveredEdge.y1 + hoveredEdge.y2) / 2 - vbY) / vbH) * 100}%`,
922
+ left: `${((hoveredEdge.midX - vbX) / vbW) * 100}%`,
923
+ top: `${((hoveredEdge.midY - vbY) / vbH) * 100}%`,
753
924
  }, children: [_jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.srcLabel }), hoveredEdge.edge.relation ? (_jsx("span", { className: "st-forceGraph__tooltipRelation", children: hoveredEdge.edge.relation })) : null, _jsx("span", { className: "st-forceGraph__tooltipLabel", children: hoveredEdge.tgtLabel })] })) : null, isZoomed ? (_jsx("button", { className: "st-forceGraph__resetBtn", type: "button", "aria-label": "Reset view", onClick: resetView, children: "\u21BA" })) : null, legend && legend.length > 0 ? (_jsx("div", { className: "st-forceGraph__legend", "aria-label": "Graph legend", children: legend.map((entry, idx) => {
754
925
  const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
755
926
  const swatchTone = entry.tone ?? "category1";
927
+ const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null;
756
928
  return (_jsxs("div", { className: "st-forceGraph__legendEntry", children: [entry.shape !== undefined ? (
757
- // Node shape legend entry
758
- _jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-8 -8 16 16", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) })) : (
929
+ // Node shape legend entry (viewBox widened for area-scaled glyphs)
930
+ _jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "-13 -13 26 26", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-forceGraph__legendShape st-forceGraph__legendShape--${swatchTone}` })) })) : (
759
931
  // Edge style legend entry
760
- _jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak") }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
932
+ _jsx("svg", { className: "st-forceGraph__legendSwatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-forceGraph__legendEdge", entry.weak && "st-forceGraph__legendEdge--weak"), strokeDasharray: swatchDash ?? undefined }) })), _jsx("span", { className: "st-forceGraph__legendLabel", children: entry.label })] }, idx));
761
933
  }) })) : null] }));
762
934
  }
763
935
  export function GraphLegend({ entries, title, className, ...rest }) {
764
936
  return (_jsxs("div", { ...rest, className: classNames("st-graphLegend", className), "aria-label": title ?? "Graph legend", children: [title ? _jsx("p", { className: "st-graphLegend__title", children: title }) : null, _jsx("ul", { className: "st-graphLegend__list", role: "list", children: entries.map((entry, idx) => {
765
937
  const swatchPath = entry.shape !== undefined ? nodeShapePath(entry.shape, 7) : null;
766
938
  const swatchTone = entry.tone ?? "category1";
767
- return (_jsxs("li", { className: "st-graphLegend__entry", children: [entry.shape !== undefined ? (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "-8 -8 16 16", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) })) : (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-graphLegend__edge", entry.weak && "st-graphLegend__edge--weak") }) })), _jsx("span", { className: "st-graphLegend__label", children: entry.label })] }, idx));
939
+ const swatchDash = entry.shape === undefined ? edgeDashArray(entry.dash, entry.weak) : null;
940
+ return (_jsxs("li", { className: "st-graphLegend__entry", children: [entry.shape !== undefined ? (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "-13 -13 26 26", width: "16", height: "16", "aria-hidden": "true", children: swatchPath ? (_jsx("path", { d: swatchPath, className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) : (_jsx("circle", { r: "7", className: `st-graphLegend__shape st-graphLegend__shape--${swatchTone}` })) })) : (_jsx("svg", { className: "st-graphLegend__swatch", viewBox: "0 0 16 8", width: "16", height: "8", "aria-hidden": "true", children: _jsx("line", { x1: "0", y1: "4", x2: "16", y2: "4", className: classNames("st-graphLegend__edge", entry.weak && "st-graphLegend__edge--weak"), strokeDasharray: swatchDash ?? undefined }) })), _jsx("span", { className: "st-graphLegend__label", children: entry.label })] }, idx));
768
941
  }) })] }));
769
942
  }
770
943
  export function Form({ status = "idle", message, children, className, ...rest }) {
@@ -805,11 +978,15 @@ export function Link({ muted = false, standalone = false, disabled = false, clas
805
978
  export function LoadingState({ label, title, variant = "spinner", className, ...rest }) {
806
979
  return (_jsxs("section", { ...rest, className: classNames("st-loading", `st-loading--${variant}`, className), "aria-live": "polite", children: [_jsx("span", { className: "st-loading__spinner", "aria-hidden": "true" }), _jsx("span", { className: "st-loading__label", children: label ?? title ?? "Loading" })] }));
807
980
  }
981
+ function itemKind(item) {
982
+ const tagged = item;
983
+ return tagged.type ?? tagged.kind;
984
+ }
808
985
  function isDivider(item) {
809
- return "type" in item && item.type === "divider";
986
+ return itemKind(item) === "divider";
810
987
  }
811
988
  function isGroup(item) {
812
- return "type" in item && item.type === "group";
989
+ return itemKind(item) === "group";
813
990
  }
814
991
  export function Menu({ items, dense = false, onSelect, className, role, ...rest }) {
815
992
  const rootRef = React.useRef(null);
@@ -843,19 +1020,21 @@ export function Menu({ items, dense = false, onSelect, className, role, ...rest
843
1020
  if (isDivider(item))
844
1021
  return _jsx("div", { className: "st-menu__divider", role: "separator" }, item.id ?? index);
845
1022
  if (isGroup(item)) {
846
- return (_jsxs("section", { className: "st-menu__group", children: [_jsx("h3", { children: item.label }), item.items.map((child) => (_jsx("button", { type: "button", role: "menuitem", className: "st-menu__item", disabled: child.disabled, onClick: () => onSelect?.(child), onKeyDown: (event) => handleItemKeyDown(event, child), children: _jsx("span", { className: "st-menu__itemLabel", children: child.label }) }, child.id ?? text(child.label))))] }, item.id ?? index));
1023
+ return (_jsxs("section", { className: "st-menu__group", children: [_jsx("h3", { children: item.label }), (item.items ?? []).map((child) => (_jsx("button", { type: "button", role: "menuitem", className: classNames("st-menu__item", isDangerAction(child) && "st-menu__item--danger"), disabled: child.disabled, onClick: () => onSelect?.(child), onKeyDown: (event) => handleItemKeyDown(event, child), children: _jsx("span", { className: "st-menu__itemLabel", children: child.label }) }, actionId(child) ?? text(child.label))))] }, item.id ?? index));
847
1024
  }
848
- return (_jsx("button", { type: "button", role: "menuitem", disabled: item.disabled, className: classNames("st-menu__item", item.variant === "danger" && "st-menu__item--danger"), onClick: () => onSelect?.(item), onKeyDown: (event) => handleItemKeyDown(event, item), children: _jsx("span", { className: "st-menu__itemLabel", children: item.label }) }, item.id ?? text(item.label) ?? index));
1025
+ const action = item;
1026
+ return (_jsx("button", { type: "button", role: "menuitem", disabled: action.disabled, className: classNames("st-menu__item", isDangerAction(action) && "st-menu__item--danger"), onClick: () => onSelect?.(action), onKeyDown: (event) => handleItemKeyDown(event, action), children: _jsx("span", { className: "st-menu__itemLabel", children: action.label }) }, actionId(action) ?? text(action.label) ?? index));
849
1027
  }) }));
850
1028
  }
851
1029
  export function MenuPopover({ trigger, items = [], open = true, placement = "bottom-start", children, className, ...rest }) {
852
1030
  return (_jsxs("div", { ...rest, className: classNames("st-menuPopover", `st-menuPopover--${placement}`, className), children: [trigger, open ? _jsx("div", { className: "st-menuPopover__content", children: items.length ? _jsx(Menu, { items: items, role: "presentation" }) : children }) : null] }));
853
1031
  }
854
- export function MenuTriggerButton({ open = false, type = "button", className, children, ...rest }) {
855
- return (_jsx("button", { ...rest, type: type, className: classNames("st-menuTriggerButton st-button st-button--secondary st-button--sm", className), "aria-expanded": open, children: children }));
1032
+ export function MenuTriggerButton({ open, expanded, type = "button", className, children, ...rest }) {
1033
+ const isOpen = open ?? expanded ?? false;
1034
+ return (_jsx("button", { ...rest, type: type, className: classNames("st-menuTriggerButton st-button st-button--secondary st-button--sm", className), "aria-expanded": isOpen, children: children }));
856
1035
  }
857
1036
  export function MessageActions({ actions, visibility = "always", className, ...rest }) {
858
- return (_jsx("nav", { ...rest, className: classNames("st-messageActions", visibility === "hover" && "st-messageActions--hoverOnly", className), "aria-label": "Message actions", children: actions.map((action, index) => (_jsx("button", { type: "button", className: classNames("st-button st-button--ghost st-button--sm", action.variant === "danger" && "st-button--danger"), disabled: action.disabled, onClick: action.onClick, children: action.label }, action.id ?? index))) }));
1037
+ return (_jsx("nav", { ...rest, className: classNames("st-messageActions", visibility === "hover" && "st-messageActions--hoverOnly", className), "aria-label": "Message actions", children: actions.map((action, index) => (_jsx("button", { type: "button", className: classNames("st-button st-button--ghost st-button--sm", action.variant === "danger" && "st-button--danger"), disabled: action.disabled, "aria-label": action.label == null && action.icon != null ? text(action.id) || undefined : undefined, onClick: action.onClick, children: action.label ?? action.icon }, action.id ?? index))) }));
859
1038
  }
860
1039
  export function MessageStatusBadge({ status, tone, labels, className, ...rest }) {
861
1040
  const normalized = status === "sent" ? "completed" : status === "streaming" ? "processing" : status === "error" ? "failed" : status;
@@ -930,8 +1109,9 @@ export function Pagination({ page, pageSize = 10, totalItems, totalPages, pageCo
930
1109
  const visiblePages = Array.from({ length: pages }, (_, index) => index + 1);
931
1110
  return (_jsxs("nav", { ...rest, className: classNames("st-pagination", className), "aria-label": "Pagination", children: [_jsx("button", { type: "button", disabled: page <= 1, onClick: () => onPageChange?.(page - 1), children: "Previous" }), _jsx("span", { className: "st-pagination__page--active", children: totalItems ? `${start}-${end} of ${totalItems}` : `Page ${page} of ${pages}` }), _jsx("span", { className: "st-pagination__pages", children: visiblePages.map((pageNumber) => (_jsx("button", { type: "button", className: classNames("st-pagination__page", pageNumber === page && "st-pagination__page--active"), "aria-label": `Page ${pageNumber}`, "aria-current": pageNumber === page ? "page" : undefined, onClick: () => onPageChange?.(pageNumber), children: pageNumber }, pageNumber))) }), _jsx("button", { type: "button", disabled: page >= pages, onClick: () => onPageChange?.(page + 1), children: "Next" })] }));
932
1111
  }
933
- export function PaginationNav({ page = 1, totalPages = 1, previousHref, nextHref, className, ...rest }) {
934
- return (_jsxs("nav", { ...rest, className: classNames("st-paginationNav", className), "aria-label": "Pagination navigation", children: [previousHref ? _jsx("a", { href: previousHref, children: "Previous" }) : _jsx("button", { type: "button", disabled: page <= 1, children: "Previous" }), _jsx("ol", { className: "st-paginationNav__list", children: Array.from({ length: totalPages }, (_, index) => index + 1).map((item) => (_jsx("li", { children: _jsxs("button", { type: "button", className: classNames("st-paginationNav__page", item === page && "st-paginationNav__page--active"), children: ["Page ", item] }) }, item))) }), nextHref ? _jsx("a", { href: nextHref, children: "Next" }) : _jsx("button", { type: "button", disabled: page >= totalPages, children: "Next" })] }));
1112
+ export function PaginationNav({ page = 1, totalPages, pageCount, previousHref, nextHref, className, ...rest }) {
1113
+ const pages = totalPages ?? pageCount ?? 1;
1114
+ return (_jsxs("nav", { ...rest, className: classNames("st-paginationNav", className), "aria-label": "Pagination navigation", children: [previousHref ? _jsx("a", { href: previousHref, children: "Previous" }) : _jsx("button", { type: "button", disabled: page <= 1, children: "Previous" }), _jsx("ol", { className: "st-paginationNav__list", children: Array.from({ length: pages }, (_, index) => index + 1).map((item) => (_jsx("li", { children: _jsxs("button", { type: "button", className: classNames("st-paginationNav__page", item === page && "st-paginationNav__page--active"), children: ["Page ", item] }) }, item))) }), nextHref ? _jsx("a", { href: nextHref, children: "Next" }) : _jsx("button", { type: "button", disabled: page >= pages, children: "Next" })] }));
935
1115
  }
936
1116
  export function PasswordInput({ label, helperText, errorText, size = "md", className, ...rest }) {
937
1117
  const [shown, setShown] = React.useState(false);
@@ -978,9 +1158,6 @@ export function Slider({ label, size = "md", value, defaultValue, min = 0, max =
978
1158
  const numeric = Number(value ?? defaultValue ?? 0);
979
1159
  return (_jsxs("div", { className: classNames("st-slider", `st-slider--${size}`, className), children: [_jsxs("div", { className: "st-slider__header", children: [label ? _jsx("label", { className: "st-field__label", children: label }) : null, _jsx("span", { className: "st-slider__value", children: numeric })] }), _jsx("input", { ...rest, className: "st-slider__input", type: "range", min: min, max: max, defaultValue: defaultValue, value: value })] }));
980
1160
  }
981
- export function Sparkline({ data, label = "Sparkline", tone = "neutral", className, ...rest }) {
982
- return (_jsxs("figure", { ...rest, className: classNames("st-sparkline", `st-sparkline--${tone}`, className), "aria-label": label, children: [_jsx("span", { className: "st-visually-hidden", children: label }), _jsx("svg", { viewBox: "0 0 120 40", "aria-hidden": "true", children: _jsx("polyline", { className: "st-sparkline__line", points: pointsFrom(data, 120, 40), fill: "none" }) })] }));
983
- }
984
1161
  export function StreamingMessage({ text: messageText, events = [], mode = "live", className, ...rest }) {
985
1162
  return (_jsxs("section", { ...rest, className: classNames("st-streamingMessage", `st-streamingMessage--${mode}`, className), children: [_jsx("div", { className: "st-streamingMessage__text", children: messageText }), _jsx("ul", { className: "st-streamingMessage__trailList", children: events.map((event) => _jsx("li", { children: event.label }, event.id)) })] }));
986
1163
  }
@@ -993,11 +1170,12 @@ export function Switch({ label, helperText, className, ...rest }) {
993
1170
  export function Table({ columns, rows, caption = "Table", className, ...rest }) {
994
1171
  return (_jsx("div", { className: "st-table-wrap", children: _jsxs("table", { ...rest, className: classNames("st-table", className), children: [_jsx("caption", { children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { children: column.label }, column.key)) }) }), _jsx("tbody", { children: rows.map((row, index) => (_jsx("tr", { children: columns.map((column) => _jsx("td", { className: classNames(column.align === "center" && "st-table__cell--center", (column.align === "right" || column.align === "end") && "st-table__cell--right"), children: text(row[column.key]) }, column.key)) }, text(row.id) || index))) })] }) }));
995
1172
  }
996
- export function Tabs({ items, activeValue, activeId, label = "Tabs", onChange, className, ...rest }) {
1173
+ export function Tabs({ items, activeValue, activeId, label = "Tabs", onChange, onchange, className, ...rest }) {
1174
+ const handleChange = onChange ?? onchange;
997
1175
  const reactId = React.useId();
998
1176
  const tabRefs = React.useRef([]);
999
1177
  const first = items.find((item) => !item.disabled) ?? items[0];
1000
- const [current, setCurrent] = useControlled(activeValue ?? activeId, idFrom(first ?? {}, 0, "tab"), onChange);
1178
+ const [current, setCurrent] = useControlled(activeValue ?? activeId, idFrom(first ?? {}, 0, "tab"), handleChange);
1001
1179
  const itemIds = items.map((item, index) => idFrom(item, index, "tab"));
1002
1180
  const activeIndex = Math.max(0, itemIds.indexOf(current));
1003
1181
  const active = items[activeIndex] ?? first;