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,298 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ import { captureInteractionState } from "../core/capture";
15
+ import { getRepliesForParent } from "../core/comment-threads";
16
+ import { commentToLiveState } from "../core/restore";
17
+ import { normalizeAnnotationViewport } from "../core/normalize-viewport";
18
+ import { createRemoteStorageAdapter } from "../core/storage";
19
+ import type { CommentAnnotation, CommentStore } from "../core/types";
20
+
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ export const CommentContext = createContext<CommentStore<any> | null>(null);
23
+
24
+ type CommentProviderProps<TState> = {
25
+ children: ReactNode;
26
+ slug: string;
27
+ getLiveState: () => TState;
28
+ onRestore: (live: TState) => void;
29
+ };
30
+
31
+ export function CommentProvider<TState>({
32
+ children,
33
+ slug,
34
+ getLiveState,
35
+ onRestore,
36
+ }: CommentProviderProps<TState>) {
37
+ const [annotations, setAnnotations] = useState<CommentAnnotation<TState>[]>([]);
38
+ const [storageReady, setStorageReady] = useState(false);
39
+ const [storageError, setStorageError] = useState<string | null>(null);
40
+
41
+ const getLiveStateRef = useRef(getLiveState);
42
+ const onRestoreRef = useRef(onRestore);
43
+ useEffect(() => { getLiveStateRef.current = getLiveState; }, [getLiveState]);
44
+ useEffect(() => { onRestoreRef.current = onRestore; }, [onRestore]);
45
+
46
+ const handleLoadError = useCallback((message: string) => {
47
+ setStorageError(message);
48
+ }, []);
49
+
50
+ const storage = useMemo(
51
+ () =>
52
+ createRemoteStorageAdapter<CommentAnnotation<TState>>(
53
+ slug,
54
+ undefined,
55
+ handleLoadError,
56
+ ),
57
+ [slug, handleLoadError],
58
+ );
59
+
60
+ const persistAnnotation = useCallback(
61
+ async (annotation: CommentAnnotation<TState>) => {
62
+ await storage.upsert(annotation);
63
+ },
64
+ [storage],
65
+ );
66
+
67
+ useEffect(() => {
68
+ let cancelled = false;
69
+
70
+ async function loadAnnotations() {
71
+ setStorageReady(false);
72
+ setStorageError(null);
73
+
74
+ try {
75
+ const stored = await storage.load();
76
+ if (cancelled) return;
77
+ setAnnotations(stored);
78
+ } catch (error) {
79
+ if (cancelled) return;
80
+ const message =
81
+ error instanceof Error
82
+ ? error.message
83
+ : "Failed to load comments.";
84
+ setStorageError(message);
85
+ setAnnotations([]);
86
+ } finally {
87
+ if (!cancelled) setStorageReady(true);
88
+ }
89
+ }
90
+
91
+ void loadAnnotations();
92
+
93
+ const reloadFromRemote = () => {
94
+ if (document.visibilityState !== "visible") return;
95
+ void Promise.resolve(storage.load()).then((stored) => {
96
+ if (!cancelled) setAnnotations(stored);
97
+ }).catch(() => {
98
+ // Keep showing the current comments if a background refresh fails.
99
+ });
100
+ };
101
+
102
+ document.addEventListener("visibilitychange", reloadFromRemote);
103
+ window.addEventListener("focus", reloadFromRemote);
104
+
105
+ return () => {
106
+ cancelled = true;
107
+ document.removeEventListener("visibilitychange", reloadFromRemote);
108
+ window.removeEventListener("focus", reloadFromRemote);
109
+ };
110
+ }, [storage]);
111
+
112
+ const addAnnotation = useCallback(
113
+ (fields: Omit<CommentAnnotation<TState>, "interactionState">): CommentAnnotation<TState> => {
114
+ if (!storageReady || storageError) {
115
+ throw new Error(storageError ?? "Comments are still loading.");
116
+ }
117
+
118
+ const { captureViewport, viewportBoundingBox } = normalizeAnnotationViewport(fields);
119
+ const fullAnnotation: CommentAnnotation<TState> = {
120
+ ...fields,
121
+ captureViewport,
122
+ viewportBoundingBox,
123
+ interactionState: captureInteractionState(getLiveStateRef.current()),
124
+ };
125
+ setAnnotations((prev) => [...prev, fullAnnotation]);
126
+ void persistAnnotation(fullAnnotation).catch(() => {
127
+ // errors are reported via onError in the remote adapter
128
+ });
129
+ return fullAnnotation;
130
+ },
131
+ [storageReady, storageError, persistAnnotation],
132
+ );
133
+
134
+ const addReply = useCallback(
135
+ (parentId: string, comment: string): CommentAnnotation<TState> => {
136
+ if (!storageReady || storageError) {
137
+ throw new Error(storageError ?? "Comments are still loading.");
138
+ }
139
+
140
+ const parent = annotations.find((a) => a.id === parentId && !a.deleted);
141
+ if (!parent) {
142
+ throw new Error("Parent comment not found.");
143
+ }
144
+
145
+ const trimmed = comment.trim();
146
+ if (!trimmed) {
147
+ throw new Error("Reply cannot be empty.");
148
+ }
149
+
150
+ const reply: CommentAnnotation<TState> = {
151
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
152
+ parentId: parent.id,
153
+ comment: trimmed,
154
+ timestamp: Date.now(),
155
+ x: parent.x,
156
+ y: parent.y,
157
+ element: parent.element,
158
+ elementPath: parent.elementPath,
159
+ targetId: parent.targetId,
160
+ positionAnchor: parent.positionAnchor,
161
+ interactionState: parent.interactionState,
162
+ channel: parent.channel,
163
+ };
164
+
165
+ setAnnotations((prev) => [...prev, reply]);
166
+ void persistAnnotation(reply).catch(() => {});
167
+ return reply;
168
+ },
169
+ [annotations, storageReady, storageError, persistAnnotation],
170
+ );
171
+
172
+ const updateAnnotation = useCallback(
173
+ (id: string, comment: string) => {
174
+ let nextAnnotation: CommentAnnotation<TState> | undefined;
175
+ setAnnotations((prev) => {
176
+ const current = prev.find((a) => a.id === id);
177
+ if (!current) return prev;
178
+ nextAnnotation = { ...current, comment };
179
+ return prev.map((a) => (a.id === id ? nextAnnotation! : a));
180
+ });
181
+ if (nextAnnotation) {
182
+ void persistAnnotation(nextAnnotation).catch(() => {});
183
+ }
184
+ },
185
+ [persistAnnotation],
186
+ );
187
+
188
+ const resolveAnnotation = useCallback(
189
+ (id: string) => {
190
+ let nextAnnotation: CommentAnnotation<TState> | undefined;
191
+ setAnnotations((prev) => {
192
+ const current = prev.find((a) => a.id === id);
193
+ if (!current) return prev;
194
+ nextAnnotation = {
195
+ ...current,
196
+ status: current.status === "resolved" ? "pending" : "resolved",
197
+ };
198
+ return prev.map((a) => (a.id === id ? nextAnnotation! : a));
199
+ });
200
+ if (nextAnnotation) {
201
+ void persistAnnotation(nextAnnotation).catch(() => {});
202
+ }
203
+ },
204
+ [persistAnnotation],
205
+ );
206
+
207
+ const deleteAnnotation = useCallback(
208
+ (id: string) => {
209
+ let toDelete: CommentAnnotation<TState>[] = [];
210
+ setAnnotations((prev) => {
211
+ const target = prev.find((a) => a.id === id);
212
+ if (!target) return prev;
213
+ const replyIds = new Set(
214
+ getRepliesForParent(prev, id).map((reply) => reply.id),
215
+ );
216
+ toDelete = [
217
+ target,
218
+ ...prev.filter((a) => replyIds.has(a.id)),
219
+ ];
220
+ const removeIds = new Set([id, ...replyIds]);
221
+ return prev.filter((a) => !removeIds.has(a.id));
222
+ });
223
+ for (const annotation of toDelete) {
224
+ void persistAnnotation({ ...annotation, deleted: true }).catch(() => {});
225
+ }
226
+ },
227
+ [persistAnnotation],
228
+ );
229
+
230
+ const clearAllAnnotations = useCallback(() => {
231
+ let visible: CommentAnnotation<TState>[] = [];
232
+ setAnnotations((prev) => {
233
+ visible = prev;
234
+ return [];
235
+ });
236
+ for (const annotation of visible) {
237
+ void persistAnnotation({ ...annotation, deleted: true }).catch(() => {});
238
+ }
239
+ }, [persistAnnotation]);
240
+
241
+ const restoreComment = useCallback(
242
+ (id: string): CommentAnnotation<TState> | undefined => {
243
+ const annotation = annotations.find((a) => a.id === id);
244
+ if (!annotation) return undefined;
245
+ const live = commentToLiveState(annotation.interactionState);
246
+ onRestoreRef.current(live);
247
+ return annotation;
248
+ },
249
+ [annotations],
250
+ );
251
+
252
+ const value = useMemo(
253
+ (): CommentStore<TState> => ({
254
+ slug,
255
+ annotations,
256
+ storageReady,
257
+ storageError,
258
+ addAnnotation,
259
+ addReply,
260
+ updateAnnotation,
261
+ resolveAnnotation,
262
+ deleteAnnotation,
263
+ clearAllAnnotations,
264
+ restoreComment,
265
+ }),
266
+ [
267
+ slug,
268
+ annotations,
269
+ storageReady,
270
+ storageError,
271
+ addAnnotation,
272
+ addReply,
273
+ updateAnnotation,
274
+ resolveAnnotation,
275
+ deleteAnnotation,
276
+ clearAllAnnotations,
277
+ restoreComment,
278
+ ],
279
+ );
280
+
281
+ return (
282
+ <CommentContext.Provider value={value}>
283
+ {children}
284
+ </CommentContext.Provider>
285
+ );
286
+ }
287
+
288
+ export function useCommentStore<TState>(): CommentStore<TState> {
289
+ const context = useContext(CommentContext);
290
+ if (!context) {
291
+ throw new Error("useCommentStore must be used within CommentProvider");
292
+ }
293
+ return context as CommentStore<TState>;
294
+ }
295
+
296
+ export function useCommentStoreOptional<TState>(): CommentStore<TState> | null {
297
+ return useContext(CommentContext) as CommentStore<TState> | null;
298
+ }
@@ -0,0 +1,10 @@
1
+ // Type declarations for SCSS modules
2
+ declare module "*.module.scss" {
3
+ const classes: { [key: string]: string };
4
+ export default classes;
5
+ }
6
+
7
+ declare module "*.scss" {
8
+ const content: { [key: string]: string };
9
+ export default content;
10
+ }
@@ -0,0 +1,253 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { createPortal } from "react-dom";
6
+
7
+ import {
8
+ getAnnotationMarkerPosition,
9
+ getAnnotationOutlineBox,
10
+ getMarkerViewportPosition,
11
+ getPrototypeScrollContainer,
12
+ resolveAnnotationTargetById,
13
+ resolveAnnotationTargetElement,
14
+ type AnnotationTargetOptions,
15
+ } from "../core/annotation-target";
16
+ import {
17
+ resolveThreadRootId,
18
+ } from "../core/comment-threads";
19
+ import { parseComputedStylesString } from "../core/element-identification";
20
+ import type { CommentAnnotation } from "../core/types";
21
+ import { useLayoutShift } from "../hooks/useLayoutShift";
22
+ import { useCommentStore } from "../react/CommentProvider";
23
+ import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme";
24
+ import { AnnotationPopupCSS } from "./annotation-popup";
25
+ import { AnnotationMarker } from "./annotation-marker";
26
+ import styles from "./capture-styles.module.scss";
27
+
28
+ type CommentAnnotationOverlayProps<TState = unknown> = {
29
+ annotation: CommentAnnotation<TState>;
30
+ basePath?: string;
31
+ onDismiss?: () => void;
32
+ readOnly?: boolean;
33
+ resolveTargetOptions?: AnnotationTargetOptions;
34
+ };
35
+
36
+ export function CommentAnnotationOverlay<TState = unknown>({
37
+ annotation,
38
+ basePath = "/canvas",
39
+ onDismiss,
40
+ readOnly = false,
41
+ resolveTargetOptions,
42
+ }: CommentAnnotationOverlayProps<TState>) {
43
+ const router = useRouter();
44
+ const { annotations, updateAnnotation, deleteAnnotation } =
45
+ useCommentStore<TState>();
46
+ const { useLightTheme, commentTheme } = usePrototypeToolTheme();
47
+ const [mounted, setMounted] = useState(false);
48
+ const [ready, setReady] = useState(false);
49
+ const [scrollY, setScrollY] = useState(0);
50
+ const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
51
+ const [isExiting, setIsExiting] = useState(false);
52
+ const layoutVersion = useLayoutShift();
53
+
54
+ useEffect(() => {
55
+ setMounted(true);
56
+ setScrollY(window.scrollY);
57
+
58
+ const frame = requestAnimationFrame(() => {
59
+ setReady(true);
60
+ });
61
+
62
+ return () => cancelAnimationFrame(frame);
63
+ }, []);
64
+
65
+ const rootId = resolveThreadRootId(annotations, annotation.id);
66
+ const rootAnnotation = useMemo(
67
+ () => annotations.find((item) => item.id === rootId) ?? annotation,
68
+ [annotations, rootId, annotation],
69
+ );
70
+
71
+ const refreshTarget = useCallback(() => {
72
+ setScrollY(window.scrollY);
73
+ setTargetElement(
74
+ resolveAnnotationTargetElement(
75
+ rootAnnotation,
76
+ window.scrollY,
77
+ resolveTargetOptions,
78
+ ),
79
+ );
80
+ }, [rootAnnotation, resolveTargetOptions]);
81
+
82
+ useEffect(() => {
83
+ if (!ready) return;
84
+
85
+ refreshTarget();
86
+
87
+ const handleScroll = () => refreshTarget();
88
+ const handleResize = () => refreshTarget();
89
+
90
+ window.addEventListener("scroll", handleScroll, { passive: true });
91
+ window.addEventListener("resize", handleResize);
92
+
93
+ const scrollContainer = getPrototypeScrollContainer();
94
+ scrollContainer?.addEventListener("scroll", handleScroll, { passive: true });
95
+
96
+ return () => {
97
+ window.removeEventListener("scroll", handleScroll);
98
+ window.removeEventListener("resize", handleResize);
99
+ scrollContainer?.removeEventListener("scroll", handleScroll);
100
+ };
101
+ }, [ready, refreshTarget, layoutVersion]);
102
+
103
+ const navigateAway = useCallback(() => {
104
+ setIsExiting(true);
105
+ window.setTimeout(() => {
106
+ if (onDismiss) {
107
+ onDismiss();
108
+ } else {
109
+ router.push(basePath);
110
+ }
111
+ }, 150);
112
+ }, [router, basePath, onDismiss]);
113
+
114
+ const handleSave = useCallback(
115
+ (comment: string) => {
116
+ updateAnnotation(rootAnnotation.id, comment);
117
+ },
118
+ [rootAnnotation.id, updateAnnotation],
119
+ );
120
+
121
+ const handleDelete = useCallback(() => {
122
+ deleteAnnotation(rootAnnotation.id);
123
+ navigateAway();
124
+ }, [rootAnnotation.id, deleteAnnotation, navigateAway]);
125
+
126
+ if (!mounted || !ready) {
127
+ return null;
128
+ }
129
+
130
+ const targetMissing =
131
+ Boolean(rootAnnotation.targetId) &&
132
+ !targetElement &&
133
+ !resolveAnnotationTargetById(rootAnnotation.targetId!);
134
+
135
+ const outlineBox = getAnnotationOutlineBox(rootAnnotation, targetElement, scrollY);
136
+ const positionStyle = getAnnotationMarkerPosition(rootAnnotation, targetElement, scrollY);
137
+ const markerViewport = getMarkerViewportPosition(rootAnnotation, scrollY, targetElement);
138
+ const popupLeft = Math.max(160, Math.min(window.innerWidth - 160, markerViewport.x));
139
+ const popupPosition =
140
+ markerViewport.y > window.innerHeight - 290
141
+ ? { bottom: window.innerHeight - markerViewport.y + 20 }
142
+ : { top: markerViewport.y + 20 };
143
+
144
+ const markerLayerClassName = rootAnnotation.isFixed
145
+ ? styles.fixedMarkersLayer
146
+ : styles.markersLayer;
147
+
148
+ return createPortal(
149
+ (
150
+ <div
151
+ className={styles.captureScope}
152
+ data-prototype-comment-theme={commentTheme}
153
+ data-prototype-comment-accent="blue"
154
+ data-prototype-comment-root=""
155
+ >
156
+ {!readOnly && (
157
+ <div className={markerLayerClassName} style={{ zIndex: 99999 }}>
158
+ <AnnotationMarker
159
+ annotation={{
160
+ ...rootAnnotation,
161
+ status: rootAnnotation.status ?? "pending",
162
+ }}
163
+ globalIndex={0}
164
+ layerIndex={0}
165
+ layerSize={1}
166
+ isExiting={false}
167
+ isClearing={false}
168
+ isAnimated
169
+ isHovered={false}
170
+ isDeleting={false}
171
+ isEditingAny
172
+ renumberFrom={null}
173
+ markerClickBehavior="edit"
174
+ positionStyle={positionStyle}
175
+ onHoverEnter={() => {}}
176
+ onHoverLeave={() => {}}
177
+ onClick={() => {}}
178
+ />
179
+ </div>
180
+ )}
181
+
182
+ {outlineBox ? (
183
+ <div
184
+ className={styles.targetHighlightLayer}
185
+ data-comment-target-highlight=""
186
+ >
187
+ <div
188
+ className={`${rootAnnotation.isMultiSelect ? styles.multiSelectOutline : styles.singleSelectOutline} ${styles.enter}`}
189
+ style={{
190
+ left: outlineBox.x,
191
+ top: outlineBox.y,
192
+ width: outlineBox.width,
193
+ height: outlineBox.height,
194
+ ...(rootAnnotation.isMultiSelect
195
+ ? {}
196
+ : {
197
+ borderColor:
198
+ "color-mix(in srgb, var(--prototype-comment-color-accent) 60%, transparent)",
199
+ backgroundColor:
200
+ "color-mix(in srgb, var(--prototype-comment-color-accent) 5%, transparent)",
201
+ }),
202
+ }}
203
+ />
204
+ </div>
205
+ ) : null}
206
+
207
+ <div className={styles.overlay} style={{ zIndex: 99999 }}>
208
+ {targetMissing && (
209
+ <div
210
+ className={styles.targetUnresolvedBanner}
211
+ style={{
212
+ position: "fixed",
213
+ top: 12,
214
+ left: "50%",
215
+ transform: "translateX(-50%)",
216
+ zIndex: 100000,
217
+ }}
218
+ >
219
+ Original target not found — position may be approximate
220
+ </div>
221
+ )}
222
+
223
+ <AnnotationPopupCSS
224
+ element={rootAnnotation.element}
225
+ selectedText={rootAnnotation.selectedText}
226
+ computedStyles={parseComputedStylesString(
227
+ rootAnnotation.computedStyles,
228
+ )}
229
+ placeholder="Edit your feedback..."
230
+ initialValue={rootAnnotation.comment}
231
+ submitLabel="Save"
232
+ readOnly={readOnly}
233
+ onSubmit={handleSave}
234
+ onCancel={navigateAway}
235
+ onDelete={readOnly ? undefined : handleDelete}
236
+ isExiting={isExiting}
237
+ lightMode={useLightTheme}
238
+ accentColor={
239
+ rootAnnotation.isMultiSelect
240
+ ? "var(--prototype-comment-color-green)"
241
+ : "var(--prototype-comment-color-accent)"
242
+ }
243
+ style={{
244
+ left: popupLeft,
245
+ ...popupPosition,
246
+ }}
247
+ />
248
+ </div>
249
+ </div>
250
+ ) as any,
251
+ document.body,
252
+ );
253
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useMemo, type ReactNode } from "react";
4
+
5
+ import { useCommentStore } from "../react/CommentProvider";
6
+
7
+ type CommentDetailPageProps<TState> = {
8
+ id: string;
9
+ basePath?: string;
10
+ renderRestoredView: (props: { route: string }) => ReactNode;
11
+ renderNotFound?: (props: { basePath: string }) => ReactNode;
12
+ };
13
+
14
+ export function CommentDetailPage<TState>({
15
+ id,
16
+ basePath = "/canvas",
17
+ renderRestoredView,
18
+ renderNotFound,
19
+ }: CommentDetailPageProps<TState>) {
20
+ const { annotations, restoreComment } = useCommentStore<TState>();
21
+
22
+ const annotation = useMemo(
23
+ () => annotations.find((item) => item.id === id),
24
+ [annotations, id]
25
+ );
26
+
27
+ useLayoutEffect(() => {
28
+ if (annotation) {
29
+ restoreComment(annotation.id);
30
+ }
31
+ }, [annotation, restoreComment]);
32
+
33
+ if (!annotation) {
34
+ if (renderNotFound) {
35
+ return <>{renderNotFound({ basePath })}</>;
36
+ }
37
+
38
+ return (
39
+ <div style={{ padding: "40px 24px", textAlign: "center", color: "rgba(255,255,255,0.5)" }}>
40
+ <p style={{ margin: 0, fontSize: "14px" }}>No comment exists with this id.</p>
41
+ <a
42
+ href={basePath}
43
+ style={{
44
+ display: "inline-block",
45
+ marginTop: "16px",
46
+ fontSize: "14px",
47
+ color: "rgba(255,255,255,0.8)",
48
+ }}>
49
+ Back to canvas
50
+ </a>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ return <>{renderRestoredView({ route: annotation.interactionState.route })}</>;
56
+ }
@@ -0,0 +1,28 @@
1
+ import { cn } from "@prototype/lib/utils";
2
+
3
+ type CommentHeaderCountProps = {
4
+ unresolved: number;
5
+ total: number;
6
+ className?: string;
7
+ };
8
+
9
+ export function CommentHeaderCount({
10
+ unresolved,
11
+ total,
12
+ className,
13
+ }: CommentHeaderCountProps) {
14
+ if (total === 0) return null;
15
+
16
+ const base = cn("ml-1.5 tabular-nums", className);
17
+
18
+ if (unresolved === total) {
19
+ return <span className={cn(base, "text-foreground")}>{total}</span>;
20
+ }
21
+
22
+ return (
23
+ <span className={cn(base, "text-foreground")}>
24
+ {unresolved}
25
+ <span className="text-muted-foreground"> / {total}</span>
26
+ </span>
27
+ );
28
+ }