@misoto22/design 0.6.1 → 0.7.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 (157) hide show
  1. package/README.md +12 -0
  2. package/dist/agent/Accordion.md +1 -1
  3. package/dist/agent/Alert.md +1 -1
  4. package/dist/agent/AppShell.md +1 -1
  5. package/dist/agent/ArchitectureFigure.md +48 -0
  6. package/dist/agent/AreaChart.md +1 -1
  7. package/dist/agent/Article.md +1 -1
  8. package/dist/agent/Avatar.md +1 -1
  9. package/dist/agent/Badge.md +1 -1
  10. package/dist/agent/BarChart.md +1 -1
  11. package/dist/agent/BarList.md +1 -1
  12. package/dist/agent/BigNumber.md +1 -1
  13. package/dist/agent/BoxPlot.md +1 -1
  14. package/dist/agent/Breadcrumb.md +1 -1
  15. package/dist/agent/BulletChart.md +1 -1
  16. package/dist/agent/Button.md +1 -1
  17. package/dist/agent/Calendar.md +1 -1
  18. package/dist/agent/Card.md +1 -1
  19. package/dist/agent/Checkbox.md +1 -1
  20. package/dist/agent/Collapsible.md +1 -1
  21. package/dist/agent/Combobox.md +1 -1
  22. package/dist/agent/Command.md +1 -1
  23. package/dist/agent/ComposedChart.md +1 -1
  24. package/dist/agent/ContextMenu.md +1 -1
  25. package/dist/agent/DataflowFigure.md +27 -0
  26. package/dist/agent/DatePicker.md +1 -1
  27. package/dist/agent/Diagram.md +1 -1
  28. package/dist/agent/DiagramCanvas.md +47 -0
  29. package/dist/agent/DiagramExportMenu.md +38 -0
  30. package/dist/agent/DiagramInspector.md +50 -0
  31. package/dist/agent/DiagramLegend.md +34 -0
  32. package/dist/agent/DiagramMinimap.md +48 -0
  33. package/dist/agent/DiagramToolbar.md +47 -0
  34. package/dist/agent/Dialog.md +1 -1
  35. package/dist/agent/DropdownMenu.md +1 -1
  36. package/dist/agent/EmptyState.md +1 -1
  37. package/dist/agent/ErrorState.md +1 -1
  38. package/dist/agent/Facet.md +1 -1
  39. package/dist/agent/Field.md +1 -1
  40. package/dist/agent/FigureBand.md +1 -1
  41. package/dist/agent/FloatingIconButton.md +1 -1
  42. package/dist/agent/FunnelChart.md +1 -1
  43. package/dist/agent/Heatmap.md +1 -1
  44. package/dist/agent/Histogram.md +1 -1
  45. package/dist/agent/Input.md +1 -1
  46. package/dist/agent/Kbd.md +1 -1
  47. package/dist/agent/LifecycleFigure.md +28 -0
  48. package/dist/agent/LineChart.md +1 -1
  49. package/dist/agent/LinkArrow.md +1 -1
  50. package/dist/agent/NativeSelect.md +1 -1
  51. package/dist/agent/NavItem.md +1 -1
  52. package/dist/agent/Pagination.md +1 -1
  53. package/dist/agent/PieChart.md +1 -1
  54. package/dist/agent/Popover.md +1 -1
  55. package/dist/agent/Progress.md +1 -1
  56. package/dist/agent/RadarChart.md +1 -1
  57. package/dist/agent/RadialChart.md +1 -1
  58. package/dist/agent/RadioGroup.md +1 -1
  59. package/dist/agent/SankeyChart.md +1 -1
  60. package/dist/agent/ScatterChart.md +1 -1
  61. package/dist/agent/ScrollArea.md +1 -1
  62. package/dist/agent/SearchableMenu.md +1 -1
  63. package/dist/agent/Select.md +1 -1
  64. package/dist/agent/Separator.md +1 -1
  65. package/dist/agent/SequenceFigure.md +28 -0
  66. package/dist/agent/Sheet.md +1 -1
  67. package/dist/agent/Skeleton.md +1 -1
  68. package/dist/agent/Slider.md +1 -1
  69. package/dist/agent/Sparkline.md +1 -1
  70. package/dist/agent/Spinner.md +1 -1
  71. package/dist/agent/StatusDot.md +1 -1
  72. package/dist/agent/StatusPill.md +1 -1
  73. package/dist/agent/Steps.md +1 -1
  74. package/dist/agent/Switch.md +1 -1
  75. package/dist/agent/Table.md +1 -1
  76. package/dist/agent/Tabs.md +1 -1
  77. package/dist/agent/Tag.md +1 -1
  78. package/dist/agent/Textarea.md +1 -1
  79. package/dist/agent/Toast.md +1 -1
  80. package/dist/agent/ToggleGroup.md +1 -1
  81. package/dist/agent/Tooltip.md +1 -1
  82. package/dist/agent/TreemapChart.md +1 -1
  83. package/dist/agent/WaterfallChart.md +1 -1
  84. package/dist/agent/WorkflowFigure.md +28 -0
  85. package/dist/agent/catalog.json +223 -3
  86. package/dist/agent/index.md +15 -1
  87. package/dist/charts/index.d.ts +2 -1
  88. package/dist/charts/lib/export.d.ts +33 -43
  89. package/dist/charts/lib/export.js +16 -158
  90. package/dist/charts/lib/export.js.map +1 -1
  91. package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.d.ts +49 -0
  92. package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.js +135 -0
  93. package/dist/diagrams/ArchitectureFigure/ArchitectureFigure.js.map +1 -0
  94. package/dist/diagrams/DataflowFigure/DataflowFigure.d.ts +28 -0
  95. package/dist/diagrams/DataflowFigure/DataflowFigure.js +129 -0
  96. package/dist/diagrams/DataflowFigure/DataflowFigure.js.map +1 -0
  97. package/dist/diagrams/DiagramCanvas/DiagramCanvas.d.ts +64 -0
  98. package/dist/diagrams/DiagramCanvas/DiagramCanvas.js +222 -0
  99. package/dist/diagrams/DiagramCanvas/DiagramCanvas.js.map +1 -0
  100. package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.d.ts +72 -0
  101. package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.js +125 -0
  102. package/dist/diagrams/DiagramExportMenu/DiagramExportMenu.js.map +1 -0
  103. package/dist/diagrams/DiagramInspector/DiagramInspector.d.ts +70 -0
  104. package/dist/diagrams/DiagramInspector/DiagramInspector.js +94 -0
  105. package/dist/diagrams/DiagramInspector/DiagramInspector.js.map +1 -0
  106. package/dist/diagrams/DiagramLegend/DiagramLegend.d.ts +42 -0
  107. package/dist/diagrams/DiagramLegend/DiagramLegend.js +35 -0
  108. package/dist/diagrams/DiagramLegend/DiagramLegend.js.map +1 -0
  109. package/dist/diagrams/DiagramMinimap/DiagramMinimap.d.ts +61 -0
  110. package/dist/diagrams/DiagramMinimap/DiagramMinimap.js +79 -0
  111. package/dist/diagrams/DiagramMinimap/DiagramMinimap.js.map +1 -0
  112. package/dist/diagrams/DiagramToolbar/DiagramToolbar.d.ts +48 -0
  113. package/dist/diagrams/DiagramToolbar/DiagramToolbar.js +53 -0
  114. package/dist/diagrams/DiagramToolbar/DiagramToolbar.js.map +1 -0
  115. package/dist/diagrams/LifecycleFigure/LifecycleFigure.d.ts +31 -0
  116. package/dist/diagrams/LifecycleFigure/LifecycleFigure.js +202 -0
  117. package/dist/diagrams/LifecycleFigure/LifecycleFigure.js.map +1 -0
  118. package/dist/diagrams/SequenceFigure/SequenceFigure.d.ts +38 -0
  119. package/dist/diagrams/SequenceFigure/SequenceFigure.js +231 -0
  120. package/dist/diagrams/SequenceFigure/SequenceFigure.js.map +1 -0
  121. package/dist/diagrams/WorkflowFigure/WorkflowFigure.d.ts +34 -0
  122. package/dist/diagrams/WorkflowFigure/WorkflowFigure.js +211 -0
  123. package/dist/diagrams/WorkflowFigure/WorkflowFigure.js.map +1 -0
  124. package/dist/diagrams/index.d.ts +16 -0
  125. package/dist/diagrams/index.js +19 -0
  126. package/dist/diagrams/index.js.map +1 -0
  127. package/dist/diagrams/lib/frame.d.ts +97 -0
  128. package/dist/diagrams/lib/frame.js +116 -0
  129. package/dist/diagrams/lib/frame.js.map +1 -0
  130. package/dist/diagrams/lib/geometry.d.ts +114 -0
  131. package/dist/diagrams/lib/geometry.js +113 -0
  132. package/dist/diagrams/lib/geometry.js.map +1 -0
  133. package/dist/diagrams/lib/legend.d.ts +32 -0
  134. package/dist/diagrams/lib/legend.js +84 -0
  135. package/dist/diagrams/lib/legend.js.map +1 -0
  136. package/dist/diagrams/lib/marks.d.ts +207 -0
  137. package/dist/diagrams/lib/marks.js +480 -0
  138. package/dist/diagrams/lib/marks.js.map +1 -0
  139. package/dist/diagrams/lib/route.d.ts +85 -0
  140. package/dist/diagrams/lib/route.js +178 -0
  141. package/dist/diagrams/lib/route.js.map +1 -0
  142. package/dist/diagrams/lib/wires.d.ts +59 -0
  143. package/dist/diagrams/lib/wires.js +90 -0
  144. package/dist/diagrams/lib/wires.js.map +1 -0
  145. package/dist/diagrams/spec.d.ts +353 -0
  146. package/dist/diagrams/spec.js +1 -0
  147. package/dist/diagrams/spec.js.map +1 -0
  148. package/dist/lib/svg-export.d.ts +138 -0
  149. package/dist/lib/svg-export.js +198 -0
  150. package/dist/lib/svg-export.js.map +1 -0
  151. package/dist/semantic.css +34 -0
  152. package/dist/styles.css +1 -1
  153. package/dist/tokens.d.ts +16 -0
  154. package/dist/tokens.js +82 -0
  155. package/dist/tokens.json +82 -0
  156. package/package.json +5 -1
  157. package/skills/misoto22-design/SKILL.md +21 -7
@@ -0,0 +1,222 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useImperativeHandle,
8
+ useRef,
9
+ useState
10
+ } from "react";
11
+ import { cn } from "../../lib/cn.js";
12
+ const MIN_SCALE = 0.35;
13
+ const MAX_SCALE = 4;
14
+ const STEP = 1.25;
15
+ function DiagramCanvas({
16
+ children,
17
+ className,
18
+ height = "24rem",
19
+ onViewChange,
20
+ controls = true,
21
+ label = "Diagram canvas",
22
+ ref
23
+ }) {
24
+ const uid = useId().replace(/:/g, "");
25
+ const frame = useRef(null);
26
+ const [view, setView] = useState({ scale: 1, x: 0, y: 0 });
27
+ const drag = useRef(null);
28
+ const apply = useCallback(
29
+ (next) => {
30
+ setView(next);
31
+ onViewChange?.(next);
32
+ },
33
+ [onViewChange]
34
+ );
35
+ const zoomAbout = useCallback(
36
+ (factor, originX, originY) => {
37
+ const box = frame.current?.getBoundingClientRect();
38
+ const cx = originX ?? (box ? box.width / 2 : 0);
39
+ const cy = originY ?? (box ? box.height / 2 : 0);
40
+ setView((current) => {
41
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, current.scale * factor));
42
+ const ratio = scale / current.scale;
43
+ const next = {
44
+ scale,
45
+ x: cx - (cx - current.x) * ratio,
46
+ y: cy - (cy - current.y) * ratio
47
+ };
48
+ onViewChange?.(next);
49
+ return next;
50
+ });
51
+ },
52
+ [onViewChange]
53
+ );
54
+ useImperativeHandle(
55
+ ref,
56
+ () => ({
57
+ zoomIn: () => zoomAbout(STEP),
58
+ zoomOut: () => zoomAbout(1 / STEP),
59
+ reset: () => apply({ scale: 1, x: 0, y: 0 }),
60
+ centerOn: (x, y) => {
61
+ const box = frame.current?.getBoundingClientRect();
62
+ if (!box) return;
63
+ setView((current) => {
64
+ const next = {
65
+ ...current,
66
+ x: box.width / 2 - x * current.scale,
67
+ y: box.height / 2 - y * current.scale
68
+ };
69
+ onViewChange?.(next);
70
+ return next;
71
+ });
72
+ },
73
+ view
74
+ }),
75
+ [apply, onViewChange, view, zoomAbout]
76
+ );
77
+ useEffect(() => {
78
+ const node = frame.current;
79
+ if (!node) return;
80
+ const onWheel = (event) => {
81
+ if (!event.ctrlKey && !event.metaKey) return;
82
+ event.preventDefault();
83
+ const box = node.getBoundingClientRect();
84
+ zoomAbout(
85
+ event.deltaY < 0 ? STEP : 1 / STEP,
86
+ event.clientX - box.left,
87
+ event.clientY - box.top
88
+ );
89
+ };
90
+ node.addEventListener("wheel", onWheel, { passive: false });
91
+ return () => node.removeEventListener("wheel", onWheel);
92
+ }, [zoomAbout]);
93
+ const onPointerDown = (event) => {
94
+ if (event.button !== 0) return;
95
+ drag.current = { pointer: event.pointerId, x: event.clientX - view.x, y: event.clientY - view.y };
96
+ event.currentTarget.setPointerCapture(event.pointerId);
97
+ };
98
+ const onPointerMove = (event) => {
99
+ const state = drag.current;
100
+ if (!state || state.pointer !== event.pointerId) return;
101
+ apply({ ...view, x: event.clientX - state.x, y: event.clientY - state.y });
102
+ };
103
+ const endDrag = (event) => {
104
+ if (drag.current?.pointer !== event.pointerId) return;
105
+ drag.current = null;
106
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
107
+ event.currentTarget.releasePointerCapture(event.pointerId);
108
+ }
109
+ };
110
+ return /* @__PURE__ */ jsxs(
111
+ "div",
112
+ {
113
+ className: cn(
114
+ "relative overflow-hidden rounded-(--radius-lg) border border-(--rule) bg-(--diagram-surface)",
115
+ className
116
+ ),
117
+ style: { height },
118
+ children: [
119
+ /* @__PURE__ */ jsx(
120
+ "div",
121
+ {
122
+ ref: frame,
123
+ role: "group",
124
+ "aria-label": label,
125
+ "aria-describedby": `${uid}-hint`,
126
+ tabIndex: 0,
127
+ className: "size-full cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-(--ring) focus-visible:ring-inset active:cursor-grabbing",
128
+ onPointerDown,
129
+ onPointerMove,
130
+ onPointerUp: endDrag,
131
+ onPointerCancel: endDrag,
132
+ onKeyDown: (event) => {
133
+ const nudge = event.shiftKey ? 80 : 24;
134
+ switch (event.key) {
135
+ case "+":
136
+ case "=":
137
+ event.preventDefault();
138
+ zoomAbout(STEP);
139
+ break;
140
+ case "-":
141
+ event.preventDefault();
142
+ zoomAbout(1 / STEP);
143
+ break;
144
+ case "0":
145
+ event.preventDefault();
146
+ apply({ scale: 1, x: 0, y: 0 });
147
+ break;
148
+ case "ArrowLeft":
149
+ event.preventDefault();
150
+ apply({ ...view, x: view.x + nudge });
151
+ break;
152
+ case "ArrowRight":
153
+ event.preventDefault();
154
+ apply({ ...view, x: view.x - nudge });
155
+ break;
156
+ case "ArrowUp":
157
+ event.preventDefault();
158
+ apply({ ...view, y: view.y + nudge });
159
+ break;
160
+ case "ArrowDown":
161
+ event.preventDefault();
162
+ apply({ ...view, y: view.y - nudge });
163
+ break;
164
+ default:
165
+ break;
166
+ }
167
+ },
168
+ children: /* @__PURE__ */ jsx(
169
+ "div",
170
+ {
171
+ className: "origin-top-left will-change-transform",
172
+ style: { transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` },
173
+ "data-diagram-stage": "",
174
+ children
175
+ }
176
+ )
177
+ }
178
+ ),
179
+ /* @__PURE__ */ jsx("p", { id: `${uid}-hint`, className: "sr-only", children: "Drag to pan. Plus and minus zoom, zero resets, the arrow keys pan." }),
180
+ controls && /* @__PURE__ */ jsxs("div", { className: "absolute bottom-3 end-3 flex items-center gap-px rounded-(--radius) border border-(--rule-2) bg-(--panel-bg) p-0.5", children: [
181
+ /* @__PURE__ */ jsx(CanvasButton, { label: "Zoom out", onClick: () => zoomAbout(1 / STEP), children: /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2212" }) }),
182
+ /* @__PURE__ */ jsxs(
183
+ "button",
184
+ {
185
+ type: "button",
186
+ onClick: () => apply({ scale: 1, x: 0, y: 0 }),
187
+ "aria-label": "Reset the view",
188
+ className: "min-w-12 rounded-(--radius-xs) px-1.5 py-1 mono-meta text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)",
189
+ children: [
190
+ Math.round(view.scale * 100),
191
+ "%"
192
+ ]
193
+ }
194
+ ),
195
+ /* @__PURE__ */ jsx(CanvasButton, { label: "Zoom in", onClick: () => zoomAbout(STEP), children: /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "+" }) })
196
+ ] })
197
+ ]
198
+ }
199
+ );
200
+ }
201
+ function CanvasButton({
202
+ label,
203
+ onClick,
204
+ children
205
+ }) {
206
+ return /* @__PURE__ */ jsx(
207
+ "button",
208
+ {
209
+ type: "button",
210
+ onClick,
211
+ "aria-label": label,
212
+ className: "flex size-7 items-center justify-center rounded-(--radius-xs) font-mono text-[13px] leading-none text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)",
213
+ children
214
+ }
215
+ );
216
+ }
217
+ var DiagramCanvas_default = DiagramCanvas;
218
+ export {
219
+ DiagramCanvas,
220
+ DiagramCanvas_default as default
221
+ };
222
+ //# sourceMappingURL=DiagramCanvas.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/diagrams/DiagramCanvas/DiagramCanvas.tsx"],"sourcesContent":["'use client'\n\nimport {\n useCallback,\n useEffect,\n useId,\n useImperativeHandle,\n useRef,\n useState,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n type Ref,\n} from 'react'\nimport { cn } from '../../lib/cn'\n\nconst MIN_SCALE = 0.35\nconst MAX_SCALE = 4\nconst STEP = 1.25\n\nexport interface CanvasView {\n scale: number\n x: number\n y: number\n}\n\n/** What a caller can do to the canvas from outside it. */\nexport interface DiagramCanvasHandle {\n zoomIn: () => void\n zoomOut: () => void\n reset: () => void\n /** Pans so a point in CONTENT coordinates sits in the middle of the frame. */\n centerOn: (x: number, y: number) => void\n view: CanvasView\n}\n\nexport interface DiagramCanvasProps {\n children: ReactNode\n className?: string\n /** How tall the frame is. Anything CSS accepts. */\n height?: string\n /** Told about every view change, for a minimap or a percentage readout. */\n onViewChange?: (view: CanvasView) => void\n /** Hides the built-in zoom controls, for a caller supplying its own. */\n controls?: boolean\n /** Names the region for assistive tech. */\n label?: string\n ref?: Ref<DiagramCanvasHandle>\n}\n\n/**\n * A frame that a picture larger than it can be moved around inside.\n *\n * Pan with a drag, zoom with the controls or with ⌘/Ctrl and the wheel, and\n * reset with a key. That is the whole of it — this is deliberately a VIEWPORT\n * and not a diagram editor: nothing here knows what a node is, so it works for\n * any oversized figure, an SVG, an image, a table that will not fold.\n *\n * Three decisions here are not obvious.\n *\n * **A plain wheel scrolls the page, not the diagram.** A canvas that swallows\n * the wheel is a scroll trap: a reader flicking down an article hits the figure\n * and the page stops moving for no reason they can see. Zooming needs the\n * modifier — which is also the platform gesture for zoom everywhere else, and\n * is what a trackpad pinch already sends.\n *\n * **The transform is on a wrapper, not on the content.** The child keeps its\n * own coordinate space, so a figure inside can still be measured, exported and\n * read by anything that walks it. Scaling the child directly would make every\n * `getBoundingClientRect` inside it a lie.\n *\n * **Keyboard first, and the frame is a real tab stop.** `+` / `-` / `0` and the\n * arrow keys move the view; the frame takes focus so they can be pressed at\n * all. A canvas that only answers a drag is a canvas half the readers cannot\n * operate.\n *\n * @example\n * <DiagramCanvas height=\"26rem\" label=\"Request path\">\n * <ArchitectureFigure spec={spec} heading={false} legend=\"hidden\" cards={false} />\n * </DiagramCanvas>\n */\nexport function DiagramCanvas({\n children,\n className,\n height = '24rem',\n onViewChange,\n controls = true,\n label = 'Diagram canvas',\n ref,\n}: DiagramCanvasProps) {\n const uid = useId().replace(/:/g, '')\n const frame = useRef<HTMLDivElement>(null)\n const [view, setView] = useState<CanvasView>({ scale: 1, x: 0, y: 0 })\n const drag = useRef<{ pointer: number; x: number; y: number } | null>(null)\n\n const apply = useCallback(\n (next: CanvasView) => {\n setView(next)\n onViewChange?.(next)\n },\n [onViewChange],\n )\n\n /**\n * Zooms about a fixed point in FRAME coordinates.\n *\n * The arithmetic is what makes the pointer stay on the thing it is over:\n * the content point under the cursor must land back under the cursor, so the\n * offset moves by the difference the scale change opened up. Without it, a\n * zoom drifts toward the origin and a reader chases the node they were\n * looking at.\n */\n const zoomAbout = useCallback(\n (factor: number, originX?: number, originY?: number) => {\n const box = frame.current?.getBoundingClientRect()\n const cx = originX ?? (box ? box.width / 2 : 0)\n const cy = originY ?? (box ? box.height / 2 : 0)\n\n setView((current) => {\n const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, current.scale * factor))\n const ratio = scale / current.scale\n const next = {\n scale,\n x: cx - (cx - current.x) * ratio,\n y: cy - (cy - current.y) * ratio,\n }\n onViewChange?.(next)\n return next\n })\n },\n [onViewChange],\n )\n\n useImperativeHandle(\n ref,\n () => ({\n zoomIn: () => zoomAbout(STEP),\n zoomOut: () => zoomAbout(1 / STEP),\n reset: () => apply({ scale: 1, x: 0, y: 0 }),\n centerOn: (x: number, y: number) => {\n const box = frame.current?.getBoundingClientRect()\n if (!box) return\n setView((current) => {\n const next = {\n ...current,\n x: box.width / 2 - x * current.scale,\n y: box.height / 2 - y * current.scale,\n }\n onViewChange?.(next)\n return next\n })\n },\n view,\n }),\n [apply, onViewChange, view, zoomAbout],\n )\n\n // Registered imperatively because React marks `wheel` passive, and a passive\n // listener cannot call preventDefault — so the modifier-wheel zoom would also\n // scroll the page underneath it.\n useEffect(() => {\n const node = frame.current\n if (!node) return\n\n const onWheel = (event: WheelEvent) => {\n if (!event.ctrlKey && !event.metaKey) return\n event.preventDefault()\n const box = node.getBoundingClientRect()\n zoomAbout(\n event.deltaY < 0 ? STEP : 1 / STEP,\n event.clientX - box.left,\n event.clientY - box.top,\n )\n }\n\n node.addEventListener('wheel', onWheel, { passive: false })\n return () => node.removeEventListener('wheel', onWheel)\n }, [zoomAbout])\n\n const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (event.button !== 0) return\n drag.current = { pointer: event.pointerId, x: event.clientX - view.x, y: event.clientY - view.y }\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n const state = drag.current\n if (!state || state.pointer !== event.pointerId) return\n apply({ ...view, x: event.clientX - state.x, y: event.clientY - state.y })\n }\n\n const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n if (drag.current?.pointer !== event.pointerId) return\n drag.current = null\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n }\n\n return (\n <div\n className={cn(\n 'relative overflow-hidden rounded-(--radius-lg) border border-(--rule) bg-(--diagram-surface)',\n className,\n )}\n style={{ height }}\n >\n <div\n ref={frame}\n role=\"group\"\n aria-label={label}\n aria-describedby={`${uid}-hint`}\n tabIndex={0}\n className=\"size-full cursor-grab touch-none select-none outline-none focus-visible:ring-2 focus-visible:ring-(--ring) focus-visible:ring-inset active:cursor-grabbing\"\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={endDrag}\n onPointerCancel={endDrag}\n onKeyDown={(event) => {\n const nudge = event.shiftKey ? 80 : 24\n switch (event.key) {\n case '+':\n case '=':\n event.preventDefault()\n zoomAbout(STEP)\n break\n case '-':\n event.preventDefault()\n zoomAbout(1 / STEP)\n break\n case '0':\n event.preventDefault()\n apply({ scale: 1, x: 0, y: 0 })\n break\n case 'ArrowLeft':\n event.preventDefault()\n apply({ ...view, x: view.x + nudge })\n break\n case 'ArrowRight':\n event.preventDefault()\n apply({ ...view, x: view.x - nudge })\n break\n case 'ArrowUp':\n event.preventDefault()\n apply({ ...view, y: view.y + nudge })\n break\n case 'ArrowDown':\n event.preventDefault()\n apply({ ...view, y: view.y - nudge })\n break\n default:\n break\n }\n }}\n >\n <div\n className=\"origin-top-left will-change-transform\"\n style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}\n data-diagram-stage=\"\"\n >\n {children}\n </div>\n </div>\n\n <p id={`${uid}-hint`} className=\"sr-only\">\n Drag to pan. Plus and minus zoom, zero resets, the arrow keys pan.\n </p>\n\n {controls && (\n <div className=\"absolute bottom-3 end-3 flex items-center gap-px rounded-(--radius) border border-(--rule-2) bg-(--panel-bg) p-0.5\">\n <CanvasButton label=\"Zoom out\" onClick={() => zoomAbout(1 / STEP)}>\n <span aria-hidden>−</span>\n </CanvasButton>\n <button\n type=\"button\"\n onClick={() => apply({ scale: 1, x: 0, y: 0 })}\n aria-label=\"Reset the view\"\n className=\"min-w-12 rounded-(--radius-xs) px-1.5 py-1 mono-meta text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {Math.round(view.scale * 100)}%\n </button>\n <CanvasButton label=\"Zoom in\" onClick={() => zoomAbout(STEP)}>\n <span aria-hidden>+</span>\n </CanvasButton>\n </div>\n )}\n </div>\n )\n}\n\nfunction CanvasButton({\n label,\n onClick,\n children,\n}: {\n label: string\n onClick: () => void\n children: ReactNode\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n aria-label={label}\n className=\"flex size-7 items-center justify-center rounded-(--radius-xs) font-mono text-[13px] leading-none text-(--ink-2) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)\"\n >\n {children}\n </button>\n )\n}\n\nexport default DiagramCanvas\n"],"mappings":"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","names":[]}
@@ -0,0 +1,72 @@
1
+ import * as react from 'react';
2
+ import { RefObject, ReactNode } from 'react';
3
+
4
+ /** The files a figure can be taken away as. */
5
+ type ExportFormat = 'png' | 'jpeg' | 'webp' | 'svg' | 'share-card';
6
+ /** What came of an export, for a page that wants to say so. */
7
+ interface ExportResult {
8
+ format: ExportFormat;
9
+ ok: boolean;
10
+ error?: Error;
11
+ }
12
+ interface DiagramExportMenuProps {
13
+ /**
14
+ * The `<svg>` to export, or an element containing exactly one.
15
+ *
16
+ * A ref rather than a selector, because a page can hold several figures and a
17
+ * selector would export whichever the document happened to reach first.
18
+ */
19
+ targetRef: RefObject<HTMLElement | SVGSVGElement | null>;
20
+ /** Names the file, and is printed on the share card. */
21
+ title: string;
22
+ /** Replaces the trigger. */
23
+ trigger?: ReactNode;
24
+ className?: string;
25
+ /** Runs instead of the built-in export — for a caller with its own pipeline. */
26
+ onExport?: (format: ExportFormat) => void | Promise<void>;
27
+ /** Told what happened, so a page can raise a toast. */
28
+ onResult?: (result: ExportResult) => void;
29
+ }
30
+ /**
31
+ * Taking the figure off the page: five files, one menu.
32
+ *
33
+ * WHY THE MENU DOES THE WORK rather than handing back a format: every one of
34
+ * these exports is the same six steps — find the `<svg>`, walk it with
35
+ * `getComputedStyle` to bake the custom properties into real colours,
36
+ * serialise, rasterise, name the file, hand it to the browser — and only the
37
+ * last two differ between them. A menu that emitted `'png'` and left the caller
38
+ * to do the rest would be a menu that every consumer reimplements, badly, and
39
+ * the interesting half (a serialised SVG resolves `var(--ink)` to nothing and
40
+ * comes out invisible) is exactly the half a caller would not know to write.
41
+ *
42
+ * `onExport` is still there for a page with its own pipeline — a server-side
43
+ * renderer, a different frame size — and taking it turns everything below into
44
+ * a no-op.
45
+ *
46
+ * What each format actually is, stated rather than implied.
47
+ *
48
+ * **SVG** is the artwork with resolved colours. It is editable and it is the
49
+ * only lossless one, but it carries no web fonts: a machine without the family
50
+ * renders it in a fallback, so type metrics will differ.
51
+ *
52
+ * **PNG, JPEG and WebP** are the browser's own rasteriser re-drawing that SVG
53
+ * at 2×. Not a screenshot — antialiasing and any effect a page stylesheet
54
+ * applied from OUTSIDE the `<svg>` are not in it. **JPEG has no alpha**, so it
55
+ * is flattened onto the resolved paper colour rather than onto black, which is
56
+ * what a transparent PNG becomes when a format with no transparency is asked
57
+ * to hold it.
58
+ *
59
+ * **The share card** is a 1200 × 630 frame with the title on it and the whole
60
+ * diagram letterboxed inside — never cropped. A card that cropped to fill the
61
+ * frame would be a picture of a different diagram.
62
+ */
63
+ declare function DiagramExportMenu({ targetRef, title, trigger, className, onExport, onResult, }: DiagramExportMenuProps): react.JSX.Element;
64
+ /**
65
+ * One figure, one format, one file.
66
+ *
67
+ * Exported so a page can wire a keyboard shortcut or its own button to exactly
68
+ * what the menu does, without reimplementing the six steps.
69
+ */
70
+ declare function exportFigure(target: HTMLElement | SVGSVGElement | null, title: string, format: ExportFormat): Promise<void>;
71
+
72
+ export { DiagramExportMenu, type DiagramExportMenuProps, type ExportFormat, type ExportResult, DiagramExportMenu as default, exportFigure };
@@ -0,0 +1,125 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { cn } from "../../lib/cn.js";
5
+ import {
6
+ downloadBlob,
7
+ exportFilename,
8
+ rasterize,
9
+ readToken,
10
+ serializeSvg
11
+ } from "../../lib/svg-export.js";
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuLabel,
16
+ DropdownMenuSeparator,
17
+ DropdownMenuTrigger
18
+ } from "../../components/DropdownMenu/DropdownMenu.js";
19
+ import { Button } from "../../components/Button/Button.js";
20
+ const SHARE_CARD = { width: 1200, height: 630 };
21
+ const ITEMS = [
22
+ { group: "Image", format: "png", title: "PNG", hint: "Lossless, 2\xD7 for retina" },
23
+ { group: "Image", format: "jpeg", title: "JPEG", hint: "Compact, flattened onto paper" },
24
+ { group: "Image", format: "webp", title: "WebP", hint: "Smaller, modern browsers" },
25
+ { group: "Vector", format: "svg", title: "SVG", hint: "Editable, colours resolved" },
26
+ { group: "Share", format: "share-card", title: "Share card", hint: "1200 \xD7 630 PNG" }
27
+ ];
28
+ function DiagramExportMenu({
29
+ targetRef,
30
+ title,
31
+ trigger,
32
+ className,
33
+ onExport,
34
+ onResult
35
+ }) {
36
+ const [busy, setBusy] = useState(null);
37
+ async function run(format) {
38
+ setBusy(format);
39
+ try {
40
+ if (onExport) {
41
+ await onExport(format);
42
+ } else {
43
+ await exportFigure(targetRef.current, title, format);
44
+ }
45
+ onResult?.({ format, ok: true });
46
+ } catch (cause) {
47
+ onResult?.({ format, ok: false, error: cause });
48
+ } finally {
49
+ setBusy(null);
50
+ }
51
+ }
52
+ const groups = [...new Set(ITEMS.map((item) => item.group))];
53
+ return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
54
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: trigger ?? /* @__PURE__ */ jsx(Button, { size: "sm", variant: "secondary", className, children: "Export" }) }),
55
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: "end", className: "w-64", children: groups.map((group, index) => /* @__PURE__ */ jsxs("div", { children: [
56
+ index > 0 && /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
57
+ /* @__PURE__ */ jsx(DropdownMenuLabel, { children: group }),
58
+ ITEMS.filter((item) => item.group === group).map((item) => /* @__PURE__ */ jsxs(
59
+ "button",
60
+ {
61
+ type: "button",
62
+ disabled: busy !== null,
63
+ onClick: () => void run(item.format),
64
+ className: cn(
65
+ "flex w-full flex-col items-start gap-0.5 rounded-(--radius-sm) px-2 py-1.5 text-start",
66
+ "transition-colors duration-(--duration-fast)",
67
+ "hover:bg-(--stone) focus-visible:bg-(--stone) focus-visible:outline-none",
68
+ "disabled:pointer-events-none disabled:opacity-(--disabled-opacity)"
69
+ ),
70
+ children: [
71
+ /* @__PURE__ */ jsxs("span", { className: "text-[13px] leading-tight text-(--ink)", children: [
72
+ item.title,
73
+ busy === item.format && " \u2026"
74
+ ] }),
75
+ /* @__PURE__ */ jsx("span", { className: "mono-meta text-(--ink-3-aa)", children: item.hint })
76
+ ]
77
+ },
78
+ item.format
79
+ ))
80
+ ] }, group)) })
81
+ ] });
82
+ }
83
+ async function exportFigure(target, title, format) {
84
+ const svg = findArtwork(target);
85
+ if (!svg) throw new Error("exportFigure: found no <svg> to export inside the given element");
86
+ const paper = readToken(svg, "--diagram-surface", "#ffffff");
87
+ const ink = readToken(svg, "--ink", "#101010");
88
+ const font = getComputedStyle(svg).fontFamily || "sans-serif";
89
+ if (format === "share-card") {
90
+ const markup2 = serializeSvg(svg, {
91
+ padding: 56,
92
+ background: paper,
93
+ title,
94
+ titleColor: ink,
95
+ titleFont: font,
96
+ frame: SHARE_CARD
97
+ });
98
+ const blob2 = await rasterize(markup2, "png", { scale: 1 });
99
+ downloadBlob(blob2, exportFilename(title, "png", "share-card"));
100
+ return;
101
+ }
102
+ const markup = serializeSvg(svg, { padding: 20, background: paper });
103
+ if (format === "svg") {
104
+ downloadBlob(new Blob([markup], { type: "image/svg+xml;charset=utf-8" }), exportFilename(title, "svg"));
105
+ return;
106
+ }
107
+ const blob = await rasterize(markup, format, {
108
+ scale: 2,
109
+ // JPEG has no alpha channel, so an unflattened export lands on black.
110
+ background: format === "jpeg" ? paper : void 0
111
+ });
112
+ downloadBlob(blob, exportFilename(title, format));
113
+ }
114
+ function findArtwork(element) {
115
+ if (!element) return null;
116
+ if (element.tagName.toLowerCase() === "svg") return element;
117
+ return element.querySelector("svg[data-diagram-artwork]") ?? element.querySelector("svg");
118
+ }
119
+ var DiagramExportMenu_default = DiagramExportMenu;
120
+ export {
121
+ DiagramExportMenu,
122
+ DiagramExportMenu_default as default,
123
+ exportFigure
124
+ };
125
+ //# sourceMappingURL=DiagramExportMenu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/diagrams/DiagramExportMenu/DiagramExportMenu.tsx"],"sourcesContent":["'use client'\n\nimport { useState, type ReactNode, type RefObject } from 'react'\nimport { cn } from '../../lib/cn'\nimport {\n downloadBlob,\n exportFilename,\n rasterize,\n readToken,\n serializeSvg,\n} from '../../lib/svg-export'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '../../components/DropdownMenu/DropdownMenu'\nimport { Button } from '../../components/Button/Button'\n\n/** The files a figure can be taken away as. */\nexport type ExportFormat = 'png' | 'jpeg' | 'webp' | 'svg' | 'share-card'\n\n/** What came of an export, for a page that wants to say so. */\nexport interface ExportResult {\n format: ExportFormat\n ok: boolean\n error?: Error\n}\n\n/** A share card is a fixed frame, because the surfaces that consume one are. */\nconst SHARE_CARD = { width: 1200, height: 630 } as const\n\nconst ITEMS: { format: ExportFormat; title: string; hint: string; group: string }[] = [\n { group: 'Image', format: 'png', title: 'PNG', hint: 'Lossless, 2× for retina' },\n { group: 'Image', format: 'jpeg', title: 'JPEG', hint: 'Compact, flattened onto paper' },\n { group: 'Image', format: 'webp', title: 'WebP', hint: 'Smaller, modern browsers' },\n { group: 'Vector', format: 'svg', title: 'SVG', hint: 'Editable, colours resolved' },\n { group: 'Share', format: 'share-card', title: 'Share card', hint: '1200 × 630 PNG' },\n]\n\nexport interface DiagramExportMenuProps {\n /**\n * The `<svg>` to export, or an element containing exactly one.\n *\n * A ref rather than a selector, because a page can hold several figures and a\n * selector would export whichever the document happened to reach first.\n */\n targetRef: RefObject<HTMLElement | SVGSVGElement | null>\n /** Names the file, and is printed on the share card. */\n title: string\n /** Replaces the trigger. */\n trigger?: ReactNode\n className?: string\n /** Runs instead of the built-in export — for a caller with its own pipeline. */\n onExport?: (format: ExportFormat) => void | Promise<void>\n /** Told what happened, so a page can raise a toast. */\n onResult?: (result: ExportResult) => void\n}\n\n/**\n * Taking the figure off the page: five files, one menu.\n *\n * WHY THE MENU DOES THE WORK rather than handing back a format: every one of\n * these exports is the same six steps — find the `<svg>`, walk it with\n * `getComputedStyle` to bake the custom properties into real colours,\n * serialise, rasterise, name the file, hand it to the browser — and only the\n * last two differ between them. A menu that emitted `'png'` and left the caller\n * to do the rest would be a menu that every consumer reimplements, badly, and\n * the interesting half (a serialised SVG resolves `var(--ink)` to nothing and\n * comes out invisible) is exactly the half a caller would not know to write.\n *\n * `onExport` is still there for a page with its own pipeline — a server-side\n * renderer, a different frame size — and taking it turns everything below into\n * a no-op.\n *\n * What each format actually is, stated rather than implied.\n *\n * **SVG** is the artwork with resolved colours. It is editable and it is the\n * only lossless one, but it carries no web fonts: a machine without the family\n * renders it in a fallback, so type metrics will differ.\n *\n * **PNG, JPEG and WebP** are the browser's own rasteriser re-drawing that SVG\n * at 2×. Not a screenshot — antialiasing and any effect a page stylesheet\n * applied from OUTSIDE the `<svg>` are not in it. **JPEG has no alpha**, so it\n * is flattened onto the resolved paper colour rather than onto black, which is\n * what a transparent PNG becomes when a format with no transparency is asked\n * to hold it.\n *\n * **The share card** is a 1200 × 630 frame with the title on it and the whole\n * diagram letterboxed inside — never cropped. A card that cropped to fill the\n * frame would be a picture of a different diagram.\n */\nexport function DiagramExportMenu({\n targetRef,\n title,\n trigger,\n className,\n onExport,\n onResult,\n}: DiagramExportMenuProps) {\n const [busy, setBusy] = useState<ExportFormat | null>(null)\n\n async function run(format: ExportFormat) {\n setBusy(format)\n try {\n if (onExport) {\n await onExport(format)\n } else {\n await exportFigure(targetRef.current, title, format)\n }\n onResult?.({ format, ok: true })\n } catch (cause) {\n // Never swallowed: a download is something the reader just asked for, and\n // a click that quietly does nothing is indistinguishable from a broken\n // button. The caller gets the error to put in front of them.\n onResult?.({ format, ok: false, error: cause as Error })\n } finally {\n setBusy(null)\n }\n }\n\n const groups = [...new Set(ITEMS.map((item) => item.group))]\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n {trigger ?? (\n <Button size=\"sm\" variant=\"secondary\" className={className}>\n Export\n </Button>\n )}\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-64\">\n {groups.map((group, index) => (\n <div key={group}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuLabel>{group}</DropdownMenuLabel>\n {ITEMS.filter((item) => item.group === group).map((item) => (\n <button\n key={item.format}\n type=\"button\"\n disabled={busy !== null}\n onClick={() => void run(item.format)}\n className={cn(\n 'flex w-full flex-col items-start gap-0.5 rounded-(--radius-sm) px-2 py-1.5 text-start',\n 'transition-colors duration-(--duration-fast)',\n 'hover:bg-(--stone) focus-visible:bg-(--stone) focus-visible:outline-none',\n 'disabled:pointer-events-none disabled:opacity-(--disabled-opacity)',\n )}\n >\n <span className=\"text-[13px] leading-tight text-(--ink)\">\n {item.title}\n {busy === item.format && ' …'}\n </span>\n <span className=\"mono-meta text-(--ink-3-aa)\">{item.hint}</span>\n </button>\n ))}\n </div>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\n/**\n * One figure, one format, one file.\n *\n * Exported so a page can wire a keyboard shortcut or its own button to exactly\n * what the menu does, without reimplementing the six steps.\n */\nexport async function exportFigure(\n target: HTMLElement | SVGSVGElement | null,\n title: string,\n format: ExportFormat,\n): Promise<void> {\n const svg = findArtwork(target)\n if (!svg) throw new Error('exportFigure: found no <svg> to export inside the given element')\n\n // Read against the LIVE element, so the file comes out in whichever theme the\n // reader is looking at rather than in whichever one the code assumed.\n const paper = readToken(svg, '--diagram-surface', '#ffffff')\n const ink = readToken(svg, '--ink', '#101010')\n const font = getComputedStyle(svg).fontFamily || 'sans-serif'\n\n if (format === 'share-card') {\n const markup = serializeSvg(svg, {\n padding: 56,\n background: paper,\n title,\n titleColor: ink,\n titleFont: font,\n frame: SHARE_CARD,\n })\n const blob = await rasterize(markup, 'png', { scale: 1 })\n downloadBlob(blob, exportFilename(title, 'png', 'share-card'))\n return\n }\n\n const markup = serializeSvg(svg, { padding: 20, background: paper })\n\n if (format === 'svg') {\n downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), exportFilename(title, 'svg'))\n return\n }\n\n const blob = await rasterize(markup, format, {\n scale: 2,\n // JPEG has no alpha channel, so an unflattened export lands on black.\n background: format === 'jpeg' ? paper : undefined,\n })\n downloadBlob(blob, exportFilename(title, format))\n}\n\n/**\n * The figure's own `<svg>`, not the first one in the subtree.\n *\n * The distinction is load-bearing: a toolbar sits inside the same wrapper and\n * every icon in it is an `<svg>`. Looking for the artwork marker first is what\n * stops an export of a diagram from being a picture of a chevron.\n */\nfunction findArtwork(element: HTMLElement | SVGSVGElement | null): SVGSVGElement | null {\n if (!element) return null\n if (element.tagName.toLowerCase() === 'svg') return element as SVGSVGElement\n return (\n element.querySelector<SVGSVGElement>('svg[data-diagram-artwork]') ??\n element.querySelector<SVGSVGElement>('svg')\n )\n}\n\nexport default DiagramExportMenu\n"],"mappings":"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","names":["markup","blob"]}
@@ -0,0 +1,70 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /** One fact about the selected thing: a short label and its value. */
5
+ interface InspectorFact {
6
+ label: string;
7
+ value: ReactNode;
8
+ /** Sets the value in mono — an id, a path, a version, a port. */
9
+ mono?: boolean;
10
+ }
11
+ /** A relationship the selected thing takes part in. */
12
+ interface InspectorLink {
13
+ id?: string;
14
+ /** Which way it runs, relative to the selected node. */
15
+ direction: 'in' | 'out';
16
+ label: string;
17
+ /** What sits at the other end. */
18
+ peer: string;
19
+ onSelect?: () => void;
20
+ }
21
+ interface DiagramInspectorProps {
22
+ /** The kicker over the title — what KIND of thing this panel is showing. */
23
+ eyebrow?: string;
24
+ title: string;
25
+ description?: ReactNode;
26
+ facts?: InspectorFact[];
27
+ links?: InspectorLink[];
28
+ /** Buttons under the facts — copy a link, open the source. */
29
+ actions?: ReactNode;
30
+ onClose?: () => void;
31
+ className?: string;
32
+ /** Renders as a floating panel over the surface rather than in the flow. */
33
+ floating?: boolean;
34
+ }
35
+ /**
36
+ * What the reader just picked, written out.
37
+ *
38
+ * A diagram can hold about eight words per node before it stops being a diagram
39
+ * and starts being a document with lines drawn on it. Everything past those
40
+ * eight — the port, the owning team, the file it was read out of, the six
41
+ * relationships it takes part in — belongs beside the picture rather than
42
+ * inside it, and this is that place.
43
+ *
44
+ * NOT A DIALOG, and that is deliberate. An inspector is a REGION that changes
45
+ * with the selection, not a modal. Giving it `role="dialog"` would trap focus
46
+ * and demand dismissal for something the reader never opened — they clicked a
47
+ * node, and the panel followed. So it is a labelled region with
48
+ * `aria-live="polite"`, which is what makes a screen reader announce the new
49
+ * selection without stealing the cursor from whatever the reader was doing.
50
+ *
51
+ * The relationships are real buttons when they carry `onSelect`, which is how
52
+ * the diagram becomes navigable from the keyboard: a reader can walk the graph
53
+ * peer by peer without ever touching the picture.
54
+ *
55
+ * @example
56
+ * <DiagramInspector
57
+ * eyebrow="Service"
58
+ * title="API Server"
59
+ * description="FastAPI, behind the load balancer."
60
+ * facts={[
61
+ * { label: 'Port', value: '8000', mono: true },
62
+ * { label: 'Id', value: 'api', mono: true },
63
+ * ]}
64
+ * links={[{ direction: 'out', label: 'SQL', peer: 'PostgreSQL' }]}
65
+ * onClose={() => setSelected(null)}
66
+ * />
67
+ */
68
+ declare function DiagramInspector({ eyebrow, title, description, facts, links, actions, onClose, className, floating, }: DiagramInspectorProps): react.JSX.Element;
69
+
70
+ export { DiagramInspector, type DiagramInspectorProps, type InspectorFact, type InspectorLink, DiagramInspector as default };
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { X } from "lucide-react";
4
+ import { cn } from "../../lib/cn.js";
5
+ function DiagramInspector({
6
+ eyebrow,
7
+ title,
8
+ description,
9
+ facts = [],
10
+ links = [],
11
+ actions,
12
+ onClose,
13
+ className,
14
+ floating
15
+ }) {
16
+ return /* @__PURE__ */ jsxs(
17
+ "section",
18
+ {
19
+ "aria-label": `${title} details`,
20
+ "aria-live": "polite",
21
+ className: cn(
22
+ "flex flex-col gap-3 rounded-(--radius-lg) border border-(--rule-2) bg-(--panel-bg) p-4",
23
+ floating && "absolute bottom-3 start-3 z-(--z-sticky) w-72 max-w-[calc(100%-1.5rem)]",
24
+ className
25
+ ),
26
+ children: [
27
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-3", children: [
28
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [
29
+ eyebrow && /* @__PURE__ */ jsx("span", { className: "eyebrow text-(--ink-3-aa)", children: eyebrow }),
30
+ /* @__PURE__ */ jsx("h3", { className: "m-0 font-sans text-[15px] leading-tight font-normal text-(--ink)", children: title })
31
+ ] }),
32
+ onClose && /* @__PURE__ */ jsx(
33
+ "button",
34
+ {
35
+ type: "button",
36
+ onClick: onClose,
37
+ "aria-label": "Clear the selection",
38
+ className: "-me-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-(--radius-xs) text-(--ink-3-aa) transition-colors duration-(--duration-fast) hover:bg-(--stone) hover:text-(--ink) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)",
39
+ children: /* @__PURE__ */ jsx(X, { size: 14, strokeWidth: 1.5, "aria-hidden": true })
40
+ }
41
+ )
42
+ ] }),
43
+ description && /* @__PURE__ */ jsx("p", { className: "m-0 text-[13px] leading-[1.55] text-(--ink-2)", children: description }),
44
+ facts.length > 0 && // A definition list, because that is what this is: each row is a term
45
+ // and its value, and a grid of divs tells a screen reader nothing about
46
+ // which value belongs to which label.
47
+ /* @__PURE__ */ jsx("dl", { className: "m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5", children: facts.map((fact) => /* @__PURE__ */ jsxs("div", { className: "contents", children: [
48
+ /* @__PURE__ */ jsx("dt", { className: "mono-meta text-(--ink-3-aa)", children: fact.label }),
49
+ /* @__PURE__ */ jsx(
50
+ "dd",
51
+ {
52
+ className: cn(
53
+ "m-0 min-w-0 break-words text-[13px] leading-snug text-(--ink)",
54
+ fact.mono && "font-mono text-[12px]"
55
+ ),
56
+ children: fact.value
57
+ }
58
+ )
59
+ ] }, fact.label)) }),
60
+ links.length > 0 && /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1 border-t border-(--rule) pt-3", children: [
61
+ /* @__PURE__ */ jsx("span", { className: "eyebrow text-(--ink-3-aa)", children: "Relationships" }),
62
+ /* @__PURE__ */ jsx("ul", { className: "m-0 flex list-none flex-col gap-0.5 p-0", children: links.map((link, index) => {
63
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
64
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "font-mono text-[12px] text-(--ink-3-aa)", children: link.direction === "out" ? "\u2192" : "\u2190" }),
65
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: link.direction === "out" ? "to" : "from" }),
66
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-[13px] text-(--ink)", children: link.peer }),
67
+ link.label && /* @__PURE__ */ jsx("span", { className: "mono-meta shrink-0 text-(--ink-3-aa)", children: link.label })
68
+ ] });
69
+ const row = "flex w-full items-baseline gap-2 rounded-(--radius-xs) px-1 py-1 text-start";
70
+ return /* @__PURE__ */ jsx("li", { children: link.onSelect ? /* @__PURE__ */ jsx(
71
+ "button",
72
+ {
73
+ type: "button",
74
+ onClick: link.onSelect,
75
+ className: cn(
76
+ row,
77
+ "transition-colors duration-(--duration-fast) hover:bg-(--stone) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ring)"
78
+ ),
79
+ children: body
80
+ }
81
+ ) : /* @__PURE__ */ jsx("span", { className: row, children: body }) }, link.id ?? index);
82
+ }) })
83
+ ] }),
84
+ actions && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap items-center gap-2 pt-1", children: actions })
85
+ ]
86
+ }
87
+ );
88
+ }
89
+ var DiagramInspector_default = DiagramInspector;
90
+ export {
91
+ DiagramInspector,
92
+ DiagramInspector_default as default
93
+ };
94
+ //# sourceMappingURL=DiagramInspector.js.map