proto-plugin 0.1.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 (209) hide show
  1. package/AGENTS.md +350 -0
  2. package/README.md +279 -0
  3. package/components.json +18 -0
  4. package/package.json +73 -0
  5. package/scripts/download-mobbin-reference-images.mjs +112 -0
  6. package/scripts/lib/host-config.example.sh +48 -0
  7. package/scripts/lib/host-config.sh +25 -0
  8. package/scripts/lib/resolve-host-root.mjs +18 -0
  9. package/scripts/lib/source-path.sh +49 -0
  10. package/scripts/link-source-db.sh +128 -0
  11. package/scripts/link-source.sh +40 -0
  12. package/scripts/publish-package.sh +147 -0
  13. package/scripts/share-prototype.sh +279 -0
  14. package/scripts/sync-from-source.sh +74 -0
  15. package/scripts/verify-prototype-component-ids.mjs +151 -0
  16. package/scripts/verify-prototype-preview-states.mjs +135 -0
  17. package/src/app.ts +4 -0
  18. package/src/components/platform-ui/code-block.tsx +21 -0
  19. package/src/components/platform-ui/controls-panel-options.tsx +71 -0
  20. package/src/components/platform-ui/controls-panel-select.tsx +438 -0
  21. package/src/components/platform-ui/empty-state.tsx +15 -0
  22. package/src/components/platform-ui/icon-button.tsx +18 -0
  23. package/src/components/platform-ui/panel-select.tsx +123 -0
  24. package/src/components/platform-ui/panel.tsx +97 -0
  25. package/src/components/platform-ui/review-dropdown-menu.tsx +35 -0
  26. package/src/components/platform-ui/sidebar.tsx +293 -0
  27. package/src/components/platform-ui/toolbar-icon-button.tsx +34 -0
  28. package/src/components/platform-ui/toolbar.tsx +37 -0
  29. package/src/components/prototype-gallery-client.tsx +57 -0
  30. package/src/components/prototype-provider.tsx +20 -0
  31. package/src/components/prototypes/mini-pill-label.tsx +37 -0
  32. package/src/components/prototypes/prototype-change-log-panel.tsx +207 -0
  33. package/src/components/prototypes/prototype-comment-provider.tsx +61 -0
  34. package/src/components/prototypes/prototype-comment-toolbar.tsx +65 -0
  35. package/src/components/prototypes/prototype-control.tsx +31 -0
  36. package/src/components/prototypes/prototype-controls.tsx +553 -0
  37. package/src/components/prototypes/prototype-create-exploration-modal.tsx +117 -0
  38. package/src/components/prototypes/prototype-create-state-modal.tsx +117 -0
  39. package/src/components/prototypes/prototype-default-state-map-page.tsx +66 -0
  40. package/src/components/prototypes/prototype-design-brief-modal.tsx +128 -0
  41. package/src/components/prototypes/prototype-design-brief-panel.tsx +525 -0
  42. package/src/components/prototypes/prototype-design-context-panel.tsx +65 -0
  43. package/src/components/prototypes/prototype-floating-pill.module.scss +1341 -0
  44. package/src/components/prototypes/prototype-mobbin-gallery.tsx +330 -0
  45. package/src/components/prototypes/prototype-reference-docs.tsx +222 -0
  46. package/src/components/prototypes/prototype-review-chrome.module.scss +630 -0
  47. package/src/components/prototypes/prototype-review-chrome.tsx +1241 -0
  48. package/src/components/prototypes/prototype-review-dropdown-menu-item.tsx +1 -0
  49. package/src/components/prototypes/prototype-review-dropdown-menu-separator.tsx +1 -0
  50. package/src/components/prototypes/prototype-review-panel-menu-item.tsx +1 -0
  51. package/src/components/prototypes/prototype-review-panel-menu-separator.tsx +1 -0
  52. package/src/components/prototypes/prototype-review-panel-select.tsx +4 -0
  53. package/src/components/prototypes/prototype-review-sidebar.tsx +167 -0
  54. package/src/components/prototypes/prototype-share-link-restore.tsx +75 -0
  55. package/src/components/prototypes/prototype-share-mode.tsx +206 -0
  56. package/src/components/prototypes/prototype-spec-panel-content.tsx +538 -0
  57. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +336 -0
  58. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +1023 -0
  59. package/src/components/prototypes/prototype-state-canvas.tsx +38 -0
  60. package/src/components/prototypes/prototype-state-screenshot-preview.tsx +47 -0
  61. package/src/components/prototypes/prototype-target.tsx +120 -0
  62. package/src/components/prototypes/prototype-tweak-highlight.tsx +132 -0
  63. package/src/components/prototypes/prototype-tweaks.tsx +48 -0
  64. package/src/components/prototypes/prototype-variant-explorer.tsx +347 -0
  65. package/src/components/prototypes/prototype-variant-rationale-panel.tsx +49 -0
  66. package/src/components/prototypes/prototype-variant-tabs.tsx +207 -0
  67. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +120 -0
  68. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +89 -0
  69. package/src/components/prototypes/state-map-annotation-tooltip.tsx +137 -0
  70. package/src/components/prototypes/state-map-wireframe-highlight.module.scss +25 -0
  71. package/src/components/prototypes/state-map-wireframe-highlight.tsx +82 -0
  72. package/src/components/shell/prototype-gallery-shell.tsx +79 -0
  73. package/src/components/shell/prototype-screenshot-capture.tsx +45 -0
  74. package/src/components/shell/prototype-shell.module.scss +194 -0
  75. package/src/components/shell/prototype-shell.tsx +160 -0
  76. package/src/components/shell/prototype-state-screenshot-capture.tsx +68 -0
  77. package/src/components/ui/button.tsx +58 -0
  78. package/src/components/ui/dialog.module.scss +81 -0
  79. package/src/components/ui/dialog.tsx +127 -0
  80. package/src/components/ui/dropdown-menu.tsx +200 -0
  81. package/src/components/ui/input.tsx +22 -0
  82. package/src/components/ui/label.tsx +26 -0
  83. package/src/components/ui/option-select.tsx +81 -0
  84. package/src/components/ui/scroll-area.tsx +48 -0
  85. package/src/components/ui/separator.tsx +31 -0
  86. package/src/components/ui/slider.tsx +28 -0
  87. package/src/components/ui/switch.tsx +29 -0
  88. package/src/components/ui/tabs.tsx +55 -0
  89. package/src/components/ui/toggle-group.tsx +80 -0
  90. package/src/components/ui/tooltip.tsx +32 -0
  91. package/src/config/create-prototype-gallery-page.tsx +65 -0
  92. package/src/config/create-prototype-host-app.tsx +121 -0
  93. package/src/config/create-prototype-page.tsx +41 -0
  94. package/src/config/create-prototype-site-layout.tsx +28 -0
  95. package/src/config/define-prototype-config.ts +7 -0
  96. package/src/config/prototype-icon.tsx +25 -0
  97. package/src/config/prototype-site-layout-client.tsx +35 -0
  98. package/src/config/with-prototype.ts +20 -0
  99. package/src/index.ts +303 -0
  100. package/src/lib/pr-split/build-pr-agent-prompt.ts +68 -0
  101. package/src/lib/pr-split/build-pr-split-prototype-url.ts +12 -0
  102. package/src/lib/pr-split/pr-split-highlight.ts +6 -0
  103. package/src/lib/pr-split/pr-split-types.ts +58 -0
  104. package/src/lib/pr-split/resolve-source-preview-url.ts +45 -0
  105. package/src/lib/pr-split/use-pr-vercel-previews.ts +69 -0
  106. package/src/lib/prototype-comments/core/annotation-channel.ts +41 -0
  107. package/src/lib/prototype-comments/core/annotation-status.ts +60 -0
  108. package/src/lib/prototype-comments/core/annotation-target.ts +567 -0
  109. package/src/lib/prototype-comments/core/capture-theme.ts +41 -0
  110. package/src/lib/prototype-comments/core/capture.ts +11 -0
  111. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +47 -0
  112. package/src/lib/prototype-comments/core/comment-threads.ts +52 -0
  113. package/src/lib/prototype-comments/core/element-identification.ts +615 -0
  114. package/src/lib/prototype-comments/core/export.ts +35 -0
  115. package/src/lib/prototype-comments/core/freeze-animations.ts +266 -0
  116. package/src/lib/prototype-comments/core/identify-element.ts +76 -0
  117. package/src/lib/prototype-comments/core/normalize-viewport.ts +35 -0
  118. package/src/lib/prototype-comments/core/react-detection.ts +704 -0
  119. package/src/lib/prototype-comments/core/restore.ts +7 -0
  120. package/src/lib/prototype-comments/core/screenshot.ts +428 -0
  121. package/src/lib/prototype-comments/core/source-location.ts +906 -0
  122. package/src/lib/prototype-comments/core/storage.ts +80 -0
  123. package/src/lib/prototype-comments/core/types.ts +79 -0
  124. package/src/lib/prototype-comments/core/validation.ts +17 -0
  125. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +61 -0
  126. package/src/lib/prototype-comments/index.ts +58 -0
  127. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +1398 -0
  128. package/src/lib/prototype-comments/react/CommentProvider.tsx +298 -0
  129. package/src/lib/prototype-comments/scss.d.ts +10 -0
  130. package/src/lib/prototype-comments/ui/CommentAnnotationOverlay.tsx +253 -0
  131. package/src/lib/prototype-comments/ui/CommentDetailPage.tsx +56 -0
  132. package/src/lib/prototype-comments/ui/CommentHeaderCount.tsx +28 -0
  133. package/src/lib/prototype-comments/ui/CommentThread.tsx +331 -0
  134. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +780 -0
  135. package/src/lib/prototype-comments/ui/CommentsListPage.tsx +25 -0
  136. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +77 -0
  137. package/src/lib/prototype-comments/ui/annotation-marker/index.tsx +200 -0
  138. package/src/lib/prototype-comments/ui/annotation-marker/styles.module.scss +194 -0
  139. package/src/lib/prototype-comments/ui/annotation-popup/index.tsx +335 -0
  140. package/src/lib/prototype-comments/ui/annotation-popup/styles.module.scss +469 -0
  141. package/src/lib/prototype-comments/ui/capture-styles.module.scss +2225 -0
  142. package/src/lib/prototype-comments/ui/comment-highlight.ts +48 -0
  143. package/src/lib/prototype-comments/ui/comment-thread.module.scss +203 -0
  144. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +253 -0
  145. package/src/lib/prototype-comments/ui/comments-sidebar.module.scss +123 -0
  146. package/src/lib/prototype-comments/ui/icon-transitions.module.scss +51 -0
  147. package/src/lib/prototype-comments/ui/icons.tsx +1086 -0
  148. package/src/lib/prototype-comments/ui/is-comment-detail-path.ts +7 -0
  149. package/src/lib/prototypes/capture-prototype-viewport.ts +32 -0
  150. package/src/lib/prototypes/changelog-meta-context.tsx +162 -0
  151. package/src/lib/prototypes/changelog-meta-storage.ts +62 -0
  152. package/src/lib/prototypes/changelog-meta.ts +95 -0
  153. package/src/lib/prototypes/create-prototype-registry.ts +74 -0
  154. package/src/lib/prototypes/create-state-screenshot-thumbnail.ts +46 -0
  155. package/src/lib/prototypes/design-exploration-default-storage.ts +68 -0
  156. package/src/lib/prototypes/design-exploration-default.ts +51 -0
  157. package/src/lib/prototypes/design-exploration-types.ts +203 -0
  158. package/src/lib/prototypes/prototype-comment-registry.tsx +145 -0
  159. package/src/lib/prototypes/prototype-comment-review-bridge.tsx +40 -0
  160. package/src/lib/prototypes/prototype-comment-review-state.ts +57 -0
  161. package/src/lib/prototypes/prototype-component-registry.ts +40 -0
  162. package/src/lib/prototypes/prototype-config-types.ts +34 -0
  163. package/src/lib/prototypes/prototype-preview-state-registry.ts +443 -0
  164. package/src/lib/prototypes/prototype-preview-state-types.ts +89 -0
  165. package/src/lib/prototypes/prototype-review-context.tsx +905 -0
  166. package/src/lib/prototypes/prototype-review-url.ts +134 -0
  167. package/src/lib/prototypes/prototype-share-link.ts +175 -0
  168. package/src/lib/prototypes/prototype-state-canvas-constants.ts +118 -0
  169. package/src/lib/prototypes/prototype-state-canvas-types.ts +159 -0
  170. package/src/lib/prototypes/prototype-state-screenshot.ts +39 -0
  171. package/src/lib/prototypes/prototype-tweak-registry.ts +96 -0
  172. package/src/lib/prototypes/prototype-tweak-types.ts +31 -0
  173. package/src/lib/prototypes/reference-docs.ts +137 -0
  174. package/src/lib/prototypes/screenshot-manifest.ts +33 -0
  175. package/src/lib/prototypes/screenshot-src.ts +7 -0
  176. package/src/lib/prototypes/share-command.ts +8 -0
  177. package/src/lib/prototypes/state-map-annotation.ts +70 -0
  178. package/src/lib/prototypes/use-design-exploration-default.ts +114 -0
  179. package/src/lib/prototypes/use-persisted-local-state.ts +140 -0
  180. package/src/lib/prototypes/use-prototype-comments.ts +63 -0
  181. package/src/lib/prototypes/use-prototype-mobile-viewport-frame.ts +46 -0
  182. package/src/lib/prototypes/use-prototype-state-screenshot-src.ts +71 -0
  183. package/src/lib/prototypes/use-prototype-tool-theme.tsx +115 -0
  184. package/src/lib/prototypes/use-prototype-viewport-frame.ts +34 -0
  185. package/src/lib/prototypes/use-register-prototype-reference-docs.ts +16 -0
  186. package/src/lib/prototypes/use-snap-corner-position.ts +417 -0
  187. package/src/lib/prototypes/use-toolbar-hover-panel.ts +98 -0
  188. package/src/lib/prototypes/variant-set-lucide-icon.tsx +55 -0
  189. package/src/lib/tool-portal.ts +33 -0
  190. package/src/lib/use-copy-to-clipboard.ts +81 -0
  191. package/src/lib/utils.ts +6 -0
  192. package/src/lib/vercel-preview/parse-vercel-github-comment.ts +81 -0
  193. package/src/server/create-host-api-route.ts +100 -0
  194. package/src/server/create-prototype-api-routes.ts +428 -0
  195. package/src/server/gallery/create-gallery-api.ts +56 -0
  196. package/src/server/gallery/folders-route.ts +156 -0
  197. package/src/server/gallery/gallery-env.ts +39 -0
  198. package/src/server/gallery/gallery-route.ts +150 -0
  199. package/src/server/gallery/gallery-types.ts +15 -0
  200. package/src/server/gallery/image-route.ts +59 -0
  201. package/src/server/gallery/item-route.ts +92 -0
  202. package/src/server/pr-vercel-preview-route.ts +162 -0
  203. package/src/server/redis/client.ts +55 -0
  204. package/src/server/redis/prototype-changelog-meta.ts +31 -0
  205. package/src/server/redis/prototype-comments.ts +92 -0
  206. package/src/server/redis/prototype-design-exploration-default.ts +32 -0
  207. package/src/server.ts +51 -0
  208. package/src/styles/globals.css +624 -0
  209. package/tsconfig.json +19 -0
@@ -0,0 +1,1023 @@
1
+ "use client";
2
+
3
+ import type {
4
+ PrototypeStateCanvasConfig,
5
+ PrototypeStateCanvasEdge,
6
+ PrototypeStateCanvasNode,
7
+ } from "@prototype/lib/prototypes/prototype-state-canvas-types";
8
+ import {
9
+ PROTOTYPE_STATE_CANVAS_MAX_SCALE,
10
+ PROTOTYPE_STATE_NODE_PREVIEW_HEIGHT,
11
+ PROTOTYPE_STATE_NODE_WIDTH,
12
+ getStateCanvasNodeBodyHeight,
13
+ getStateCanvasNodeHeight,
14
+ } from "@prototype/lib/prototypes/prototype-state-canvas-constants";
15
+ import { cn } from "@prototype/lib/utils";
16
+ import { Check, Maximize2, Minus, Plus, X } from "lucide-react";
17
+ import Link from "next/link";
18
+ import {
19
+ useCallback,
20
+ useEffect,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ } from "react";
25
+
26
+ import {
27
+ StateMapAnnotationTooltip,
28
+ getStateMapAnnotationAnchor,
29
+ type StateMapAnnotationTarget,
30
+ } from "./state-map-annotation-tooltip";
31
+ import {
32
+ StateMapHighlightProvider,
33
+ StateMapPreviewHighlightOverlay,
34
+ } from "./state-map-wireframe-highlight";
35
+ import styles from "./prototype-state-canvas-overlay.module.scss";
36
+
37
+ const NODE_WIDTH = PROTOTYPE_STATE_NODE_WIDTH;
38
+ const NODE_PREVIEW_HEIGHT = PROTOTYPE_STATE_NODE_PREVIEW_HEIGHT;
39
+ const NODE_BODY_HEIGHT = getStateCanvasNodeBodyHeight();
40
+ const CANVAS_INSET = 24;
41
+ const MIN_SCALE = 0.05;
42
+ const MAX_SCALE = PROTOTYPE_STATE_CANVAS_MAX_SCALE;
43
+ const ZOOM_WHEEL_SENSITIVITY = 0.003;
44
+ const ZOOM_MOMENTUM_DECAY = 5.5;
45
+ const ZOOM_MOMENTUM_STOP_VELOCITY = 0.02;
46
+ const MAX_ZOOM_VELOCITY = 5;
47
+ const ZOOM_BUTTON_FACTOR = 1.28;
48
+ const ANNOTATION_FADE_MS = 120;
49
+
50
+ function normalizeWheelDeltaY(
51
+ event: WheelEvent,
52
+ viewport: HTMLElement,
53
+ ): number {
54
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
55
+ return event.deltaY * 16;
56
+ }
57
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
58
+ return event.deltaY * viewport.clientHeight;
59
+ }
60
+ return event.deltaY;
61
+ }
62
+
63
+ function isZoomWheelEvent(event: WheelEvent): boolean {
64
+ return event.ctrlKey || event.metaKey;
65
+ }
66
+
67
+ type ViewTransform = {
68
+ panX: number;
69
+ panY: number;
70
+ scale: number;
71
+ };
72
+
73
+ function clampScale(scale: number) {
74
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
75
+ }
76
+
77
+ function zoomAtPoint(
78
+ transform: ViewTransform,
79
+ clientX: number,
80
+ clientY: number,
81
+ viewport: HTMLElement,
82
+ scaleFactor: number,
83
+ ): ViewTransform {
84
+ const rect = viewport.getBoundingClientRect();
85
+ const viewportX = clientX - rect.left;
86
+ const viewportY = clientY - rect.top;
87
+ const canvasX = (viewportX - CANVAS_INSET - transform.panX) / transform.scale;
88
+ const canvasY = (viewportY - CANVAS_INSET - transform.panY) / transform.scale;
89
+ const nextScale = clampScale(transform.scale * scaleFactor);
90
+
91
+ return {
92
+ scale: nextScale,
93
+ panX: viewportX - CANVAS_INSET - canvasX * nextScale,
94
+ panY: viewportY - CANVAS_INSET - canvasY * nextScale,
95
+ };
96
+ }
97
+
98
+ function centerTransformAtScale(
99
+ viewport: HTMLElement,
100
+ canvasWidth: number,
101
+ canvasHeight: number,
102
+ scale: number,
103
+ ): ViewTransform {
104
+ const clampedScale = clampScale(scale);
105
+ const scaledWidth = canvasWidth * clampedScale;
106
+ const scaledHeight = canvasHeight * clampedScale;
107
+
108
+ return {
109
+ scale: clampedScale,
110
+ panX: (viewport.clientWidth - scaledWidth) / 2 - CANVAS_INSET,
111
+ panY: (viewport.clientHeight - scaledHeight) / 2 - CANVAS_INSET,
112
+ };
113
+ }
114
+
115
+ function fitTransformToViewport(
116
+ viewport: HTMLElement,
117
+ canvasWidth: number,
118
+ canvasHeight: number,
119
+ ): ViewTransform {
120
+ const padding = 48;
121
+ const scale = clampScale(
122
+ Math.min(
123
+ (viewport.clientWidth - padding) / canvasWidth,
124
+ (viewport.clientHeight - padding) / canvasHeight,
125
+ ),
126
+ );
127
+
128
+ return centerTransformAtScale(viewport, canvasWidth, canvasHeight, scale);
129
+ }
130
+
131
+ function getNodeHeight(node: PrototypeStateCanvasNode): number {
132
+ return getStateCanvasNodeHeight(node.callouts);
133
+ }
134
+
135
+ type Point = { x: number; y: number };
136
+
137
+ function getNodeCenter(node: PrototypeStateCanvasNode): Point {
138
+ const height = getNodeHeight(node);
139
+ return {
140
+ x: node.x + NODE_WIDTH / 2,
141
+ y: node.y + height / 2,
142
+ };
143
+ }
144
+
145
+ type AnchorSide = "left" | "right" | "top" | "bottom";
146
+
147
+ // Use side anchors only when the target is significantly more horizontal than vertical.
148
+ const HORIZONTAL_RATIO_THRESHOLD = 2.5;
149
+ const ARROW_INSET = 5;
150
+ const CORNER_RADIUS = 10;
151
+
152
+ function getAnchorSide(node: PrototypeStateCanvasNode, target: Point): AnchorSide {
153
+ const center = getNodeCenter(node);
154
+ const dx = target.x - center.x;
155
+ const dy = target.y - center.y;
156
+ if (Math.abs(dx) > Math.abs(dy) * HORIZONTAL_RATIO_THRESHOLD) {
157
+ return dx > 0 ? "right" : "left";
158
+ }
159
+ return dy > 0 ? "bottom" : "top";
160
+ }
161
+
162
+ function getNodeBodyCenterY(node: PrototypeStateCanvasNode): number {
163
+ return node.y + NODE_BODY_HEIGHT / 2;
164
+ }
165
+
166
+ function getAnchorPoint(node: PrototypeStateCanvasNode, side: AnchorSide): Point {
167
+ const center = getNodeCenter(node);
168
+ const height = getNodeHeight(node);
169
+ const bodyCenterY = getNodeBodyCenterY(node);
170
+ switch (side) {
171
+ // Side anchors use the card body center so row neighbors stay aligned
172
+ // even when one node has nested-state callouts below the label.
173
+ case "left": return { x: node.x, y: bodyCenterY };
174
+ case "right": return { x: node.x + NODE_WIDTH, y: bodyCenterY };
175
+ case "top": return { x: center.x, y: node.y };
176
+ case "bottom": return { x: center.x, y: node.y + height };
177
+ }
178
+ }
179
+
180
+ // Move a boundary point AWAY from the node so arrowhead tips land cleanly.
181
+ function insetFromNode(p: Point, side: AnchorSide, inset: number): Point {
182
+ switch (side) {
183
+ case "left": return { x: p.x - inset, y: p.y };
184
+ case "right": return { x: p.x + inset, y: p.y };
185
+ case "top": return { x: p.x, y: p.y - inset };
186
+ case "bottom": return { x: p.x, y: p.y + inset };
187
+ }
188
+ }
189
+
190
+ function buildEdgePath(
191
+ from: PrototypeStateCanvasNode,
192
+ to: PrototypeStateCanvasNode,
193
+ insetBothEnds: boolean,
194
+ ): string {
195
+ const fromCenter = getNodeCenter(from);
196
+ const toCenter = getNodeCenter(to);
197
+
198
+ const fromSide = getAnchorSide(from, toCenter);
199
+ const toSide = getAnchorSide(to, fromCenter);
200
+
201
+ const rawStart = getAnchorPoint(from, fromSide);
202
+ const rawEnd = getAnchorPoint(to, toSide);
203
+
204
+ // Inset endpoints away from node boundaries so arrowheads sit just outside.
205
+ const start = insetBothEnds ? insetFromNode(rawStart, fromSide, ARROW_INSET) : rawStart;
206
+ const end = insetFromNode(rawEnd, toSide, ARROW_INSET);
207
+
208
+ const dx = end.x - start.x;
209
+ const dy = end.y - start.y;
210
+ const adx = Math.abs(dx);
211
+ const ady = Math.abs(dy);
212
+
213
+ // Near-straight paths: draw a plain line.
214
+ if (adx < 4 || ady < 4) {
215
+ return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
216
+ }
217
+
218
+ const sx = Math.sign(dx);
219
+ const sy = Math.sign(dy);
220
+ const r = Math.min(CORNER_RADIUS, adx / 2, ady / 2);
221
+
222
+ const bothHorizontal =
223
+ (fromSide === "left" || fromSide === "right") &&
224
+ (toSide === "left" || toSide === "right");
225
+
226
+ if (bothHorizontal) {
227
+ // Horizontal-first elbow (side-to-side with vertical offset)
228
+ const midX = start.x + dx / 2;
229
+ return (
230
+ `M ${start.x} ${start.y} ` +
231
+ `L ${midX - sx * r} ${start.y} ` +
232
+ `Q ${midX} ${start.y} ${midX} ${start.y + sy * r} ` +
233
+ `L ${midX} ${end.y - sy * r} ` +
234
+ `Q ${midX} ${end.y} ${midX + sx * r} ${end.y} ` +
235
+ `L ${end.x} ${end.y}`
236
+ );
237
+ }
238
+
239
+ // Vertical-first S-curve (top/bottom-anchored diagonal edges)
240
+ const midY = start.y + dy / 2;
241
+ return (
242
+ `M ${start.x} ${start.y} ` +
243
+ `L ${start.x} ${midY - sy * r} ` +
244
+ `Q ${start.x} ${midY} ${start.x + sx * r} ${midY} ` +
245
+ `L ${end.x - sx * r} ${midY} ` +
246
+ `Q ${end.x} ${midY} ${end.x} ${midY + sy * r} ` +
247
+ `L ${end.x} ${end.y}`
248
+ );
249
+ }
250
+
251
+ function detectBidirectionalEdges<T extends string>(
252
+ edges: PrototypeStateCanvasEdge<T>[],
253
+ ): { uniqueEdges: PrototypeStateCanvasEdge<T>[]; bidirectionalKeys: Set<string> } {
254
+ const allKeys = new Set(edges.map((e) => `${e.from}-->${e.to}`));
255
+ const seen = new Set<string>();
256
+ const uniqueEdges: PrototypeStateCanvasEdge<T>[] = [];
257
+ const bidirectionalKeys = new Set<string>();
258
+
259
+ for (const edge of edges) {
260
+ const key = `${edge.from}-->${edge.to}`;
261
+ const reverseKey = `${edge.to}-->${edge.from}`;
262
+ if (seen.has(key) || seen.has(reverseKey)) continue;
263
+ seen.add(key);
264
+ seen.add(reverseKey);
265
+ if (allKeys.has(reverseKey)) bidirectionalKeys.add(key);
266
+ uniqueEdges.push(edge);
267
+ }
268
+
269
+ return { uniqueEdges, bidirectionalKeys };
270
+ }
271
+
272
+ function deriveCanvasSize<T extends string>(
273
+ nodes: PrototypeStateCanvasNode<T>[],
274
+ explicitWidth?: number,
275
+ explicitHeight?: number,
276
+ ) {
277
+ const maxX = nodes.reduce((max, node) => Math.max(max, node.x + NODE_WIDTH), 0);
278
+ const maxY = nodes.reduce(
279
+ (max, node) => Math.max(max, node.y + getNodeHeight(node)),
280
+ 0,
281
+ );
282
+ return {
283
+ width: explicitWidth ?? maxX + 80,
284
+ height: explicitHeight ?? maxY + 80,
285
+ };
286
+ }
287
+
288
+ function isNodeActive<T extends string>(
289
+ node: PrototypeStateCanvasNode<T>,
290
+ activeStateId: T | undefined,
291
+ ): boolean {
292
+ if (activeStateId == null) return false;
293
+ if (node.id === activeStateId) return true;
294
+ return node.callouts?.some((callout) => callout.id === activeStateId) ?? false;
295
+ }
296
+
297
+ type HoverAnnotationSource<T extends string> = {
298
+ id: T;
299
+ label: string;
300
+ parentLabel?: string;
301
+ annotation?: string;
302
+ highlightRegions?: readonly string[];
303
+ };
304
+
305
+ function resolveNodePreviewId<T extends string>(
306
+ node: PrototypeStateCanvasNode<T>,
307
+ hoverTarget: StateMapAnnotationTarget | null,
308
+ ): T {
309
+ if (hoverTarget == null) return node.id;
310
+ if (hoverTarget.id === node.id) return node.id;
311
+ if (node.callouts?.some((callout) => callout.id === hoverTarget.id)) {
312
+ return hoverTarget.id as T;
313
+ }
314
+ return node.id;
315
+ }
316
+
317
+ function resolveHoverAnnotationSource<T extends string>(
318
+ node: PrototypeStateCanvasNode<T>,
319
+ hoverTarget: StateMapAnnotationTarget | null,
320
+ ): HoverAnnotationSource<T> | null {
321
+ if (hoverTarget == null) return null;
322
+ if (hoverTarget.id === node.id) {
323
+ return {
324
+ id: node.id,
325
+ label: node.label,
326
+ annotation: node.annotation,
327
+ highlightRegions: node.highlightRegions,
328
+ };
329
+ }
330
+
331
+ const callout = node.callouts?.find((entry) => entry.id === hoverTarget.id);
332
+ if (!callout) return null;
333
+
334
+ return {
335
+ id: callout.id,
336
+ label: callout.label,
337
+ parentLabel: node.label,
338
+ annotation: callout.annotation,
339
+ highlightRegions: callout.highlightRegions,
340
+ };
341
+ }
342
+
343
+ type PrototypeStateCanvasViewProps<T extends string> = {
344
+ config: PrototypeStateCanvasConfig<T>;
345
+ layout?: "page" | "overlay";
346
+ backHref?: string;
347
+ onClose?: () => void;
348
+ };
349
+
350
+ export function PrototypeStateCanvasView<T extends string>({
351
+ config,
352
+ layout = "overlay",
353
+ backHref,
354
+ onClose,
355
+ }: PrototypeStateCanvasViewProps<T>) {
356
+ const {
357
+ activeStateId,
358
+ onStateSelect,
359
+ nodes,
360
+ edges,
361
+ sections,
362
+ renderWireframe,
363
+ getHighlightRegions,
364
+ canvasWidth,
365
+ canvasHeight,
366
+ } = config;
367
+
368
+ const viewportRef = useRef<HTMLDivElement>(null);
369
+ const transformRef = useRef<ViewTransform>({ panX: 0, panY: 0, scale: 1 });
370
+ const zoomVelocityRef = useRef(0);
371
+ const zoomAnchorRef = useRef({ x: 0, y: 0 });
372
+ const zoomMomentumFrameRef = useRef<number | null>(null);
373
+ const zoomMomentumActiveRef = useRef(false);
374
+ const lastZoomMomentumTimeRef = useRef(0);
375
+ const lastZoomWheelTimeRef = useRef(0);
376
+ const [transform, setTransform] = useState<ViewTransform>({
377
+ panX: 0,
378
+ panY: 0,
379
+ scale: 1,
380
+ });
381
+ const [hoverTarget, setHoverTarget] = useState<StateMapAnnotationTarget | null>(
382
+ null,
383
+ );
384
+ const [displayedAnnotation, setDisplayedAnnotation] = useState<{
385
+ target: StateMapAnnotationTarget;
386
+ nodeId: string;
387
+ } | null>(null);
388
+ const [annotationVisible, setAnnotationVisible] = useState(false);
389
+ const annotationFadeFrameRef = useRef<number | null>(null);
390
+ const annotationFadeTimerRef = useRef<number | null>(null);
391
+ const [activeHighlightId, setActiveHighlightId] = useState<string | null>(null);
392
+ const hoverDismissTimerRef = useRef<number | null>(null);
393
+ const hasFitRef = useRef(false);
394
+
395
+ const clearAnnotationFadeTimers = useCallback(() => {
396
+ if (annotationFadeFrameRef.current != null) {
397
+ cancelAnimationFrame(annotationFadeFrameRef.current);
398
+ annotationFadeFrameRef.current = null;
399
+ }
400
+ if (annotationFadeTimerRef.current != null) {
401
+ window.clearTimeout(annotationFadeTimerRef.current);
402
+ annotationFadeTimerRef.current = null;
403
+ }
404
+ }, []);
405
+
406
+ const clearHoverDismissTimer = useCallback(() => {
407
+ if (hoverDismissTimerRef.current != null) {
408
+ window.clearTimeout(hoverDismissTimerRef.current);
409
+ hoverDismissTimerRef.current = null;
410
+ }
411
+ }, []);
412
+
413
+ const clearHoverTarget = useCallback(() => {
414
+ clearHoverDismissTimer();
415
+ setHoverTarget(null);
416
+ setActiveHighlightId(null);
417
+ }, [clearHoverDismissTimer]);
418
+
419
+ const scheduleHoverDismiss = useCallback(() => {
420
+ clearHoverDismissTimer();
421
+ hoverDismissTimerRef.current = window.setTimeout(() => {
422
+ clearHoverTarget();
423
+ }, 120);
424
+ }, [clearHoverDismissTimer, clearHoverTarget]);
425
+
426
+ const applyTransform = useCallback((next: ViewTransform) => {
427
+ transformRef.current = next;
428
+ setTransform(next);
429
+ }, []);
430
+
431
+ const stopZoomMomentum = useCallback(() => {
432
+ zoomMomentumActiveRef.current = false;
433
+ lastZoomMomentumTimeRef.current = 0;
434
+ lastZoomWheelTimeRef.current = 0;
435
+ if (zoomMomentumFrameRef.current != null) {
436
+ cancelAnimationFrame(zoomMomentumFrameRef.current);
437
+ zoomMomentumFrameRef.current = null;
438
+ }
439
+ zoomVelocityRef.current = 0;
440
+ }, []);
441
+
442
+ const stepZoomMomentum = useCallback(
443
+ (timestamp: number) => {
444
+ const viewport = viewportRef.current;
445
+ if (!viewport || !zoomMomentumActiveRef.current) {
446
+ zoomMomentumFrameRef.current = null;
447
+ return;
448
+ }
449
+
450
+ if (lastZoomMomentumTimeRef.current === 0) {
451
+ lastZoomMomentumTimeRef.current = timestamp;
452
+ zoomMomentumFrameRef.current = requestAnimationFrame(stepZoomMomentum);
453
+ return;
454
+ }
455
+
456
+ const dt = Math.min(48, timestamp - lastZoomMomentumTimeRef.current) / 1000;
457
+ lastZoomMomentumTimeRef.current = timestamp;
458
+
459
+ let velocity = zoomVelocityRef.current;
460
+ if (Math.abs(velocity) < ZOOM_MOMENTUM_STOP_VELOCITY) {
461
+ stopZoomMomentum();
462
+ return;
463
+ }
464
+
465
+ const anchor = zoomAnchorRef.current;
466
+ const next = zoomAtPoint(
467
+ transformRef.current,
468
+ anchor.x,
469
+ anchor.y,
470
+ viewport,
471
+ Math.exp(velocity * dt),
472
+ );
473
+ applyTransform(next);
474
+
475
+ velocity *= Math.exp(-ZOOM_MOMENTUM_DECAY * dt);
476
+ zoomVelocityRef.current = Math.max(
477
+ -MAX_ZOOM_VELOCITY,
478
+ Math.min(MAX_ZOOM_VELOCITY, velocity),
479
+ );
480
+ zoomMomentumFrameRef.current = requestAnimationFrame(stepZoomMomentum);
481
+ },
482
+ [applyTransform, stopZoomMomentum],
483
+ );
484
+
485
+ const ensureZoomMomentumLoop = useCallback(() => {
486
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
487
+ if (
488
+ zoomMomentumActiveRef.current &&
489
+ zoomMomentumFrameRef.current != null
490
+ ) {
491
+ return;
492
+ }
493
+ zoomMomentumActiveRef.current = true;
494
+ lastZoomMomentumTimeRef.current = 0;
495
+ zoomMomentumFrameRef.current = requestAnimationFrame(stepZoomMomentum);
496
+ }, [stepZoomMomentum]);
497
+
498
+ const addZoomImpulse = useCallback(
499
+ (event: WheelEvent, viewport: HTMLElement) => {
500
+ const deltaY = normalizeWheelDeltaY(event, viewport);
501
+ const logDelta = -deltaY * ZOOM_WHEEL_SENSITIVITY;
502
+ const now = event.timeStamp || performance.now();
503
+ const elapsedMs = lastZoomWheelTimeRef.current
504
+ ? Math.max(now - lastZoomWheelTimeRef.current, 8)
505
+ : 16;
506
+ lastZoomWheelTimeRef.current = now;
507
+
508
+ const rate = (logDelta / elapsedMs) * 1000;
509
+ zoomVelocityRef.current = Math.max(
510
+ -MAX_ZOOM_VELOCITY,
511
+ Math.min(
512
+ MAX_ZOOM_VELOCITY,
513
+ zoomVelocityRef.current * 0.35 + rate * 0.65,
514
+ ),
515
+ );
516
+ zoomAnchorRef.current = { x: event.clientX, y: event.clientY };
517
+ ensureZoomMomentumLoop();
518
+ },
519
+ [ensureZoomMomentumLoop],
520
+ );
521
+
522
+ const nodeMap = useMemo(
523
+ () => new Map(nodes.map((node) => [node.id, node])),
524
+ [nodes],
525
+ );
526
+
527
+ const { uniqueEdges, bidirectionalKeys } = useMemo(
528
+ () => detectBidirectionalEdges(edges),
529
+ [edges],
530
+ );
531
+
532
+ const size = useMemo(
533
+ () => deriveCanvasSize(nodes, canvasWidth, canvasHeight),
534
+ [canvasHeight, canvasWidth, nodes],
535
+ );
536
+
537
+ const hoverAnnotationNode = useMemo(() => {
538
+ if (!hoverTarget) return null;
539
+ return (
540
+ nodes.find(
541
+ (node) =>
542
+ node.id === hoverTarget.id ||
543
+ node.callouts?.some((callout) => callout.id === hoverTarget.id),
544
+ ) ?? null
545
+ );
546
+ }, [hoverTarget, nodes]);
547
+
548
+ const displayedAnnotationNode = useMemo(() => {
549
+ if (!displayedAnnotation) return null;
550
+ return nodeMap.get(displayedAnnotation.nodeId as T) ?? null;
551
+ }, [displayedAnnotation, nodeMap]);
552
+
553
+ const displayedAnnotationAnchor = useMemo(() => {
554
+ if (!displayedAnnotationNode || !displayedAnnotation) return null;
555
+ return getStateMapAnnotationAnchor(
556
+ displayedAnnotationNode,
557
+ displayedAnnotation.target.id,
558
+ );
559
+ }, [displayedAnnotation, displayedAnnotationNode]);
560
+
561
+ useEffect(() => {
562
+ if (hoverTarget && hoverAnnotationNode) {
563
+ const nextNodeId = hoverAnnotationNode.id;
564
+ const nextEntry = {
565
+ target: hoverTarget,
566
+ nodeId: nextNodeId,
567
+ };
568
+
569
+ if (
570
+ displayedAnnotation?.nodeId === nextNodeId &&
571
+ displayedAnnotation.target.id === hoverTarget.id
572
+ ) {
573
+ return;
574
+ }
575
+
576
+ const beginShow = () => {
577
+ setDisplayedAnnotation(nextEntry);
578
+ annotationFadeFrameRef.current = requestAnimationFrame(() => {
579
+ annotationFadeFrameRef.current = requestAnimationFrame(() => {
580
+ setAnnotationVisible(true);
581
+ annotationFadeFrameRef.current = null;
582
+ });
583
+ });
584
+ };
585
+
586
+ if (displayedAnnotation?.nodeId === nextNodeId) {
587
+ clearAnnotationFadeTimers();
588
+ setDisplayedAnnotation(nextEntry);
589
+ if (!annotationVisible) {
590
+ beginShow();
591
+ }
592
+ return;
593
+ }
594
+
595
+ if (displayedAnnotation != null) {
596
+ clearAnnotationFadeTimers();
597
+ setAnnotationVisible(false);
598
+ annotationFadeTimerRef.current = window.setTimeout(() => {
599
+ annotationFadeTimerRef.current = null;
600
+ beginShow();
601
+ }, ANNOTATION_FADE_MS);
602
+ return () => {
603
+ if (annotationFadeTimerRef.current != null) {
604
+ window.clearTimeout(annotationFadeTimerRef.current);
605
+ annotationFadeTimerRef.current = null;
606
+ }
607
+ };
608
+ }
609
+
610
+ clearAnnotationFadeTimers();
611
+ beginShow();
612
+ return;
613
+ }
614
+
615
+ if (!displayedAnnotation) return;
616
+
617
+ clearAnnotationFadeTimers();
618
+ setAnnotationVisible(false);
619
+ annotationFadeTimerRef.current = window.setTimeout(() => {
620
+ setDisplayedAnnotation(null);
621
+ annotationFadeTimerRef.current = null;
622
+ }, ANNOTATION_FADE_MS);
623
+
624
+ return () => {
625
+ if (annotationFadeTimerRef.current != null) {
626
+ window.clearTimeout(annotationFadeTimerRef.current);
627
+ annotationFadeTimerRef.current = null;
628
+ }
629
+ };
630
+ }, [
631
+ annotationVisible,
632
+ clearAnnotationFadeTimers,
633
+ displayedAnnotation,
634
+ hoverAnnotationNode,
635
+ hoverTarget,
636
+ ]);
637
+
638
+ useEffect(() => {
639
+ return () => clearAnnotationFadeTimers();
640
+ }, [clearAnnotationFadeTimers]);
641
+
642
+ const handleNodeSelect = useCallback(
643
+ (id: T) => {
644
+ setHoverTarget(null);
645
+ setActiveHighlightId(null);
646
+ onStateSelect(id);
647
+ onClose?.();
648
+ },
649
+ [onClose, onStateSelect],
650
+ );
651
+
652
+ const showAnnotationForTarget = useCallback(
653
+ (source: HoverAnnotationSource<T>, parentLabel?: string) => {
654
+ const annotation = source.annotation?.trim();
655
+ if (!annotation) return;
656
+
657
+ clearHoverDismissTimer();
658
+ setHoverTarget({
659
+ id: source.id,
660
+ label: source.label,
661
+ parentLabel,
662
+ annotation,
663
+ highlightRegions: source.highlightRegions,
664
+ });
665
+ },
666
+ [clearHoverDismissTimer],
667
+ );
668
+
669
+ useEffect(() => {
670
+ return () => clearHoverDismissTimer();
671
+ }, [clearHoverDismissTimer]);
672
+
673
+ const fitToView = useCallback(() => {
674
+ const viewport = viewportRef.current;
675
+ if (!viewport) return;
676
+ stopZoomMomentum();
677
+ applyTransform(fitTransformToViewport(viewport, size.width, size.height));
678
+ }, [applyTransform, size.height, size.width, stopZoomMomentum]);
679
+
680
+ useEffect(() => {
681
+ if (hasFitRef.current) return;
682
+ const viewport = viewportRef.current;
683
+ if (!viewport) return;
684
+ if (viewport.clientWidth === 0 || viewport.clientHeight === 0) return;
685
+
686
+ hasFitRef.current = true;
687
+ stopZoomMomentum();
688
+ applyTransform(fitTransformToViewport(viewport, size.width, size.height));
689
+ }, [applyTransform, size.height, size.width, stopZoomMomentum]);
690
+
691
+ useEffect(() => {
692
+ const viewport = viewportRef.current;
693
+ if (!viewport) return;
694
+
695
+ const handleWheel = (event: WheelEvent) => {
696
+ if (isZoomWheelEvent(event)) {
697
+ event.preventDefault();
698
+ const deltaY = normalizeWheelDeltaY(event, viewport);
699
+ const factor = Math.exp(-deltaY * ZOOM_WHEEL_SENSITIVITY);
700
+ const next = zoomAtPoint(
701
+ transformRef.current,
702
+ event.clientX,
703
+ event.clientY,
704
+ viewport,
705
+ factor,
706
+ );
707
+ applyTransform(next);
708
+ addZoomImpulse(event, viewport);
709
+ return;
710
+ }
711
+
712
+ stopZoomMomentum();
713
+ event.preventDefault();
714
+ applyTransform({
715
+ ...transformRef.current,
716
+ panX: transformRef.current.panX - event.deltaX,
717
+ panY: transformRef.current.panY - event.deltaY,
718
+ });
719
+ };
720
+
721
+ viewport.addEventListener("wheel", handleWheel, { passive: false });
722
+ return () => {
723
+ viewport.removeEventListener("wheel", handleWheel);
724
+ stopZoomMomentum();
725
+ };
726
+ }, [addZoomImpulse, applyTransform, stopZoomMomentum]);
727
+
728
+ const handleZoomBy = useCallback((factor: number) => {
729
+ const viewport = viewportRef.current;
730
+ if (!viewport) return;
731
+ stopZoomMomentum();
732
+ const rect = viewport.getBoundingClientRect();
733
+ applyTransform(
734
+ zoomAtPoint(
735
+ transformRef.current,
736
+ rect.left + rect.width / 2,
737
+ rect.top + rect.height / 2,
738
+ viewport,
739
+ factor,
740
+ ),
741
+ );
742
+ }, [applyTransform, stopZoomMomentum]);
743
+
744
+ return (
745
+ <div
746
+ className={cn(
747
+ layout === "page" ? styles.page : styles.overlay,
748
+ )}
749
+ data-prototype-state-canvas
750
+ role={layout === "page" ? undefined : "dialog"}
751
+ aria-modal={layout === "page" ? undefined : "true"}
752
+ aria-label="State map"
753
+ >
754
+ <div className={styles.header}>
755
+ <div>
756
+ <p className={styles.headerTitle}>State map</p>
757
+ <p className={styles.headerHint}>
758
+ Scroll to pan, pinch or ⌘ scroll to zoom. Click a state to preview it. Hover for planning notes.
759
+ </p>
760
+ </div>
761
+ {layout === "page" && backHref ? (
762
+ <Link
763
+ href={backHref}
764
+ className={styles.closeButton}
765
+ aria-label="Back to prototype"
766
+ >
767
+ <X size={16} strokeWidth={2} />
768
+ </Link>
769
+ ) : onClose ? (
770
+ <button
771
+ type="button"
772
+ className={styles.closeButton}
773
+ aria-label="Close state map"
774
+ onClick={onClose}
775
+ >
776
+ <X size={16} strokeWidth={2} />
777
+ </button>
778
+ ) : null}
779
+ </div>
780
+
781
+ <div ref={viewportRef} className={styles.canvasViewport}>
782
+ <div className={styles.zoomControls}>
783
+ <button
784
+ type="button"
785
+ className={styles.zoomButton}
786
+ aria-label="Zoom out"
787
+ onPointerDown={(event) => event.stopPropagation()}
788
+ onClick={() => handleZoomBy(1 / ZOOM_BUTTON_FACTOR)}
789
+ >
790
+ <Minus size={14} strokeWidth={2} aria-hidden />
791
+ </button>
792
+ <span className={styles.zoomLabel}>
793
+ {Math.round(transform.scale * 100)}%
794
+ </span>
795
+ <button
796
+ type="button"
797
+ className={styles.zoomButton}
798
+ aria-label="Zoom in"
799
+ onPointerDown={(event) => event.stopPropagation()}
800
+ onClick={() => handleZoomBy(ZOOM_BUTTON_FACTOR)}
801
+ >
802
+ <Plus size={14} strokeWidth={2} aria-hidden />
803
+ </button>
804
+ <button
805
+ type="button"
806
+ className={styles.zoomButton}
807
+ aria-label="Fit map to view"
808
+ onPointerDown={(event) => event.stopPropagation()}
809
+ onClick={fitToView}
810
+ >
811
+ <Maximize2 size={14} strokeWidth={2} aria-hidden />
812
+ </button>
813
+ </div>
814
+
815
+ <div
816
+ className={styles.canvasLayer}
817
+ style={{
818
+ width: size.width,
819
+ height: size.height,
820
+ transform: `translate(${transform.panX}px, ${transform.panY}px) scale(${transform.scale})`,
821
+ }}
822
+ >
823
+ <svg
824
+ className={styles.edges}
825
+ width={size.width}
826
+ height={size.height}
827
+ overflow="visible"
828
+ aria-hidden
829
+ >
830
+ <defs>
831
+ <marker
832
+ id="prototype-state-canvas-arrow"
833
+ markerWidth="8"
834
+ markerHeight="8"
835
+ refX="6"
836
+ refY="4"
837
+ orient="auto"
838
+ >
839
+ <path d="M0,0 L8,4 L0,8 Z" className={styles.edgeArrowHead} />
840
+ </marker>
841
+ <marker
842
+ id="prototype-state-canvas-arrow-start"
843
+ markerWidth="8"
844
+ markerHeight="8"
845
+ refX="6"
846
+ refY="4"
847
+ orient="auto-start-reverse"
848
+ >
849
+ <path d="M0,0 L8,4 L0,8 Z" className={styles.edgeArrowHead} />
850
+ </marker>
851
+ </defs>
852
+ {uniqueEdges.map((edge: PrototypeStateCanvasEdge<T>) => {
853
+ const from = nodeMap.get(edge.from);
854
+ const to = nodeMap.get(edge.to);
855
+ if (!from || !to) return null;
856
+ const isBidirectional = bidirectionalKeys.has(`${edge.from}-->${edge.to}`);
857
+ return (
858
+ <path
859
+ key={`${edge.from}-${edge.to}`}
860
+ d={buildEdgePath(from, to, isBidirectional)}
861
+ className={styles.edge}
862
+ markerEnd="url(#prototype-state-canvas-arrow)"
863
+ markerStart={isBidirectional ? "url(#prototype-state-canvas-arrow-start)" : undefined}
864
+ />
865
+ );
866
+ })}
867
+ </svg>
868
+
869
+ {sections?.map((section) => (
870
+ <div
871
+ key={section.label}
872
+ className={styles.sectionLabel}
873
+ style={{ top: section.y, left: 24 }}
874
+ >
875
+ {section.label}
876
+ </div>
877
+ ))}
878
+
879
+ {nodes.map((node) => {
880
+ const selected = isNodeActive(node, activeStateId);
881
+ const hoverSource = resolveHoverAnnotationSource(node, hoverTarget);
882
+ const previewNodeId = resolveNodePreviewId(node, hoverTarget);
883
+ const previewHighlights = getHighlightRegions?.(previewNodeId);
884
+ const previewHighlightRect =
885
+ activeHighlightId && previewHighlights
886
+ ? previewHighlights[activeHighlightId]
887
+ : undefined;
888
+ const nodeHighlightActive =
889
+ hoverSource != null &&
890
+ (hoverTarget?.id === node.id ||
891
+ node.callouts?.some((callout) => callout.id === hoverTarget?.id));
892
+
893
+ return (
894
+ <div
895
+ key={node.id}
896
+ className={cn(styles.nodeGroup, selected && styles.nodeGroupSelected)}
897
+ style={{ left: node.x, top: node.y, width: NODE_WIDTH }}
898
+ >
899
+ <button
900
+ type="button"
901
+ className={cn(
902
+ styles.node,
903
+ selected && node.id === activeStateId && styles.nodeSelected,
904
+ )}
905
+ aria-current={
906
+ node.id === activeStateId ? "true" : undefined
907
+ }
908
+ onPointerDown={(event) => event.stopPropagation()}
909
+ onClick={() => handleNodeSelect(node.id)}
910
+ onMouseEnter={() => {
911
+ showAnnotationForTarget({
912
+ id: node.id,
913
+ label: node.label,
914
+ annotation: node.annotation,
915
+ highlightRegions: node.highlightRegions,
916
+ });
917
+ }}
918
+ onMouseLeave={scheduleHoverDismiss}
919
+ >
920
+ <div className={styles.nodePreview}>
921
+ <StateMapHighlightProvider
922
+ highlightId={nodeHighlightActive ? activeHighlightId : null}
923
+ >
924
+ <div
925
+ className={styles.nodePreviewContent}
926
+ data-prototype-wireframe-preview
927
+ >
928
+ {renderWireframe(previewNodeId)}
929
+ {previewHighlightRect ? (
930
+ <StateMapPreviewHighlightOverlay rect={previewHighlightRect} />
931
+ ) : null}
932
+ </div>
933
+ </StateMapHighlightProvider>
934
+ </div>
935
+ <div className={styles.nodeLabel}>
936
+ <span className={styles.nodeLabelText}>{node.label}</span>
937
+ {node.id === activeStateId ? (
938
+ <Check
939
+ size={14}
940
+ strokeWidth={2}
941
+ className={styles.nodeSelectedIcon}
942
+ aria-hidden
943
+ />
944
+ ) : null}
945
+ </div>
946
+ </button>
947
+
948
+ {node.callouts?.length ? (
949
+ <div className={styles.calloutTray}>
950
+ {node.callouts.map((callout) => {
951
+ const calloutSelected = callout.id === activeStateId;
952
+ return (
953
+ <button
954
+ key={callout.id}
955
+ type="button"
956
+ className={cn(
957
+ styles.callout,
958
+ calloutSelected && styles.calloutSelected,
959
+ )}
960
+ aria-current={calloutSelected ? "true" : undefined}
961
+ onPointerDown={(event) => event.stopPropagation()}
962
+ onClick={() => handleNodeSelect(callout.id)}
963
+ onMouseEnter={() => {
964
+ showAnnotationForTarget(
965
+ {
966
+ id: callout.id,
967
+ label: callout.label,
968
+ annotation: callout.annotation,
969
+ highlightRegions: callout.highlightRegions,
970
+ },
971
+ node.label,
972
+ );
973
+ }}
974
+ onMouseLeave={scheduleHoverDismiss}
975
+ >
976
+ <span className={styles.calloutLabel}>
977
+ {callout.label}
978
+ </span>
979
+ {callout.hint ? (
980
+ <span className={styles.calloutHint}>
981
+ {callout.hint}
982
+ </span>
983
+ ) : null}
984
+ {calloutSelected ? (
985
+ <Check
986
+ size={12}
987
+ strokeWidth={2}
988
+ className={styles.calloutSelectedIcon}
989
+ aria-hidden
990
+ />
991
+ ) : null}
992
+ </button>
993
+ );
994
+ })}
995
+ </div>
996
+ ) : null}
997
+ </div>
998
+ );
999
+ })}
1000
+
1001
+ {displayedAnnotation && displayedAnnotationAnchor ? (
1002
+ <StateMapAnnotationTooltip
1003
+ target={displayedAnnotation.target}
1004
+ anchor={displayedAnnotationAnchor}
1005
+ visible={annotationVisible}
1006
+ activeHighlightId={activeHighlightId}
1007
+ onHighlightChange={setActiveHighlightId}
1008
+ onDismiss={scheduleHoverDismiss}
1009
+ onKeepOpen={clearHoverDismissTimer}
1010
+ />
1011
+ ) : null}
1012
+ </div>
1013
+ </div>
1014
+ </div>
1015
+ );
1016
+ }
1017
+
1018
+ /** @deprecated Use PrototypeStateCanvasView on a dedicated route instead. */
1019
+ export function PrototypeStateCanvasOverlay<T extends string>(
1020
+ props: PrototypeStateCanvasViewProps<T>,
1021
+ ) {
1022
+ return <PrototypeStateCanvasView {...props} layout="overlay" />;
1023
+ }