proto-plugin 0.1.1 → 0.1.3

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 (109) hide show
  1. package/AGENTS.md +308 -29
  2. package/README.md +48 -6
  3. package/package.json +2 -1
  4. package/scripts/lib/host-config.example.sh +1 -1
  5. package/scripts/verify-prototype-preview-states.mjs +57 -0
  6. package/src/components/platform-ui/controls-panel-select.tsx +4 -3
  7. package/src/components/platform-ui/prototype-tool-dialog.tsx +117 -0
  8. package/src/components/platform-ui/sidebar.tsx +34 -16
  9. package/src/components/prototype-gallery-client.tsx +77 -37
  10. package/src/components/prototypes/design-exploration-variant-preview-shell.tsx +43 -0
  11. package/src/components/prototypes/prototype-brief-field.tsx +33 -0
  12. package/src/components/prototypes/prototype-change-log-panel.tsx +59 -55
  13. package/src/components/prototypes/prototype-comment-provider.tsx +0 -1
  14. package/src/components/prototypes/prototype-comment-storage-empty-state.tsx +41 -0
  15. package/src/components/prototypes/prototype-create-exploration-modal.tsx +41 -64
  16. package/src/components/prototypes/prototype-create-prototype-modal.tsx +102 -0
  17. package/src/components/prototypes/prototype-create-state-modal.tsx +31 -70
  18. package/src/components/prototypes/prototype-default-state-map-page.tsx +4 -4
  19. package/src/components/prototypes/prototype-design-brief-modal.tsx +10 -10
  20. package/src/components/prototypes/prototype-design-brief-panel.tsx +261 -231
  21. package/src/components/prototypes/prototype-floating-pill.module.scss +16 -9
  22. package/src/components/prototypes/prototype-mobbin-gallery.tsx +18 -2
  23. package/src/components/prototypes/prototype-review-chrome.tsx +21 -2
  24. package/src/components/prototypes/prototype-review-sidebar.tsx +7 -37
  25. package/src/components/prototypes/prototype-spec-panel-content.tsx +5 -5
  26. package/src/components/prototypes/prototype-spec-panel-empty-state.tsx +53 -0
  27. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +20 -47
  28. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +4 -42
  29. package/src/components/prototypes/prototype-variant-explorer.tsx +32 -78
  30. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +4 -3
  31. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +7 -7
  32. package/src/components/shell/prototype-component-library-empty-state.tsx +59 -0
  33. package/src/components/shell/prototype-gallery-nav.tsx +124 -0
  34. package/src/components/shell/prototype-gallery-shell.tsx +45 -13
  35. package/src/components/shell/prototype-link-source-modal.tsx +100 -0
  36. package/src/components/shell/prototype-plugin-update-popup.tsx +74 -0
  37. package/src/components/ui/button.tsx +4 -2
  38. package/src/components/ui/dialog.tsx +44 -5
  39. package/src/components/ui/dropdown-menu.tsx +2 -2
  40. package/src/components/ui/input.tsx +1 -1
  41. package/src/components/ui/label.tsx +1 -1
  42. package/src/components/ui/option-select.tsx +3 -3
  43. package/src/components/ui/scroll-area.tsx +1 -1
  44. package/src/components/ui/separator.tsx +1 -1
  45. package/src/components/ui/slider.tsx +1 -1
  46. package/src/components/ui/switch.tsx +1 -1
  47. package/src/components/ui/tabs.tsx +1 -1
  48. package/src/components/ui/toggle-group.tsx +1 -1
  49. package/src/components/ui/tooltip.tsx +2 -2
  50. package/src/config/create-prototype-component-library-page.tsx +74 -0
  51. package/src/config/create-prototype-gallery-page.tsx +26 -23
  52. package/src/config/create-prototype-host-app.tsx +3 -15
  53. package/src/config/create-prototype-site-layout.tsx +14 -1
  54. package/src/config/create-prototype-state-map-page.tsx +44 -0
  55. package/src/config/prototype-site-layout-client.tsx +23 -6
  56. package/src/config/with-prototype.ts +54 -0
  57. package/src/index.ts +71 -11
  58. package/src/lib/copy-text-with-attachments.ts +35 -0
  59. package/src/lib/format-source-directory-name.ts +12 -0
  60. package/src/lib/platform-ui/sidebar-interactive-cursor.ts +19 -0
  61. package/src/lib/pr-split/build-pr-split-plan-prompt.ts +81 -0
  62. package/src/lib/pr-split/pr-split-highlight.ts +3 -0
  63. package/src/lib/prototype-comments/core/annotation-target.ts +12 -3
  64. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +24 -0
  65. package/src/lib/prototype-comments/core/screenshot.ts +68 -31
  66. package/src/lib/prototype-comments/core/storage.ts +3 -6
  67. package/src/lib/prototype-comments/core/types.ts +2 -0
  68. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +9 -2
  69. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +45 -10
  70. package/src/lib/prototype-comments/react/CommentProvider.tsx +19 -0
  71. package/src/lib/prototype-comments/ui/CommentModeToggleButton.tsx +34 -0
  72. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +8 -16
  73. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +5 -19
  74. package/src/lib/prototype-comments/ui/capture-styles.module.scss +5 -1
  75. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +0 -7
  76. package/src/lib/prototypes/build-design-exploration-renderers.ts +44 -0
  77. package/src/lib/prototypes/changelog-description-prompt.ts +58 -0
  78. package/src/lib/prototypes/comment-storage-setup-prompt.ts +124 -0
  79. package/src/lib/prototypes/create-prototype-prompt.ts +96 -0
  80. package/src/lib/prototypes/create-prototype-registry.ts +5 -0
  81. package/src/lib/prototypes/design-exploration-types.ts +261 -10
  82. package/src/lib/prototypes/link-source-prompt.ts +93 -0
  83. package/src/lib/prototypes/populate-component-library-prompt.ts +64 -0
  84. package/src/lib/prototypes/proto-plugin-version.ts +19 -0
  85. package/src/lib/prototypes/prototype-config-types.ts +2 -0
  86. package/src/lib/prototypes/prototype-layout-classes.ts +19 -0
  87. package/src/lib/prototypes/prototype-preview-state-registry.ts +5 -2
  88. package/src/lib/prototypes/prototype-review-context.tsx +4 -4
  89. package/src/lib/prototypes/prototype-state-canvas-constants.ts +1 -1
  90. package/src/lib/prototypes/prototype-state-canvas-types.ts +0 -2
  91. package/src/lib/prototypes/prototype-storage-status.ts +31 -0
  92. package/src/lib/prototypes/sort-prototypes-by-creation-date.ts +43 -0
  93. package/src/lib/prototypes/upgrade-proto-plugin-prompt.ts +40 -0
  94. package/src/lib/prototypes/use-proto-plugin-version-check.ts +67 -0
  95. package/src/lib/prototypes/use-prototype-tool-theme.tsx +1 -1
  96. package/src/lib/prototypes/use-toolbar-hover-panel.ts +12 -1
  97. package/src/lib/prototypes/variant-set-lucide-icon.tsx +24 -0
  98. package/src/lib/tool-portal.ts +24 -0
  99. package/src/lib/use-copy-to-clipboard.ts +7 -13
  100. package/src/server/create-host-api-route.ts +14 -16
  101. package/src/server/create-prototype-api-routes.ts +0 -115
  102. package/src/server/proto-plugin-version-route.ts +145 -0
  103. package/src/server/prototype-storage-status-route.ts +29 -0
  104. package/src/server.ts +2 -8
  105. package/src/styles/globals.css +357 -69
  106. package/src/lib/prototypes/design-exploration-default-storage.ts +0 -68
  107. package/src/lib/prototypes/design-exploration-default.ts +0 -51
  108. package/src/lib/prototypes/use-design-exploration-default.ts +0 -114
  109. package/src/server/redis/prototype-design-exploration-default.ts +0 -32
@@ -11,6 +11,7 @@
11
11
  // Cache the import result so we only try once
12
12
  let _domCaptureModule: {
13
13
  domToCanvas: (node: Node, options?: Record<string, unknown>) => Promise<HTMLCanvasElement>;
14
+ domToJpeg: (node: Node, options?: Record<string, unknown>) => Promise<string>;
14
15
  } | null | undefined; // null = tried and failed, undefined = not tried yet
15
16
 
16
17
  async function getDomCapture() {
@@ -36,6 +37,10 @@ import {
36
37
  getCommentsSidebarInset,
37
38
  getPrototypeScrollContainer,
38
39
  } from "./annotation-target";
40
+ import {
41
+ getPrototypeScreenshotRoot,
42
+ PROTOTYPE_COMMENTS_SIDEBAR_ROOT_ID,
43
+ } from "@prototype/lib/tool-portal";
39
44
  import type { CaptureViewport } from "./types";
40
45
 
41
46
  type CaptureUiRestore = {
@@ -43,7 +48,7 @@ type CaptureUiRestore = {
43
48
  };
44
49
 
45
50
  /**
46
- * Hide feedback overlays during capture while leaving the comments sidebar untouched.
51
+ * Hide feedback overlays and the comments sidebar during capture.
47
52
  */
48
53
  async function prepareDomForCapture(): Promise<CaptureUiRestore> {
49
54
  // Map ensures each element is hidden/restored once. The review toolbar matches
@@ -52,7 +57,6 @@ async function prepareDomForCapture(): Promise<CaptureUiRestore> {
52
57
 
53
58
  const hideElement = (el: Element) => {
54
59
  const html = el as HTMLElement;
55
- if (html.closest("[data-comments-sidebar]")) return;
56
60
  if (hidden.has(html)) return;
57
61
  hidden.set(html, html.style.visibility);
58
62
  html.style.visibility = "hidden";
@@ -66,18 +70,16 @@ async function prepareDomForCapture(): Promise<CaptureUiRestore> {
66
70
  "[data-annotation-marker]",
67
71
  "[data-annotation-popup]",
68
72
  "[data-comment-capture-overlay]",
73
+ `#${PROTOTYPE_COMMENTS_SIDEBAR_ROOT_ID}`,
74
+ "[data-comments-sidebar]",
75
+ "[data-prototype-review-sidebar]",
69
76
  ];
70
77
 
71
78
  for (const selector of captureUiSelectors) {
72
79
  document.querySelectorAll(selector).forEach(hideElement);
73
80
  }
74
81
 
75
- // Hide standalone overlay roots (e.g. restore overlay), but keep the comments sidebar visible
76
- // so cropSidebarFromCanvas can still trim it from the captured image.
77
- document.querySelectorAll("[data-prototype-comment-root]").forEach((root) => {
78
- if (root.querySelector("[data-comments-sidebar]")) return;
79
- hideElement(root);
80
- });
82
+ document.querySelectorAll("[data-prototype-comment-root]").forEach(hideElement);
81
83
 
82
84
  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
83
85
  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
@@ -111,6 +113,31 @@ function cropSidebarFromCanvas(
111
113
  return cropped;
112
114
  }
113
115
 
116
+ function drawCanvasPreservingAspect(
117
+ ctx: CanvasRenderingContext2D,
118
+ source: HTMLCanvasElement,
119
+ destWidth: number,
120
+ destHeight: number,
121
+ ) {
122
+ const targetAspect = destWidth / destHeight;
123
+ const sourceAspect = source.width / source.height;
124
+
125
+ let sx = 0;
126
+ let sy = 0;
127
+ let sw = source.width;
128
+ let sh = source.height;
129
+
130
+ if (sourceAspect > targetAspect) {
131
+ sw = source.height * targetAspect;
132
+ sx = (source.width - sw) / 2;
133
+ } else if (sourceAspect < targetAspect) {
134
+ sh = source.width / targetAspect;
135
+ sy = 0;
136
+ }
137
+
138
+ ctx.drawImage(source, sx, sy, sw, sh, 0, 0, destWidth, destHeight);
139
+ }
140
+
114
141
  export type ViewportScreenshotResult = {
115
142
  dataUrl: string;
116
143
  captureViewport: CaptureViewport;
@@ -129,33 +156,49 @@ export async function captureViewportScreenshot(
129
156
  return null;
130
157
  }
131
158
 
132
- const sidebarInset = getCommentsSidebarInset();
133
- const inlineContent = document.querySelector("[data-prototype-screenshot]");
134
- const isInlineSidebar = document.documentElement.hasAttribute("data-comments-sidebar-inline");
159
+ const screenshotRoot = getPrototypeScreenshotRoot();
160
+ const sidebarInset = screenshotRoot ? 0 : getCommentsSidebarInset();
135
161
  const { restore } = await prepareDomForCapture();
136
162
 
137
163
  try {
138
- const captureTarget = isInlineSidebar && inlineContent instanceof HTMLElement
139
- ? inlineContent
140
- : document.documentElement;
141
-
142
- const targetRect = captureTarget.getBoundingClientRect();
143
- const viewportWidth = isInlineSidebar && inlineContent instanceof HTMLElement
144
- ? targetRect.width
164
+ const captureTarget = screenshotRoot ?? document.documentElement;
165
+ const viewportWidth = screenshotRoot
166
+ ? screenshotRoot.clientWidth
145
167
  : window.innerWidth;
146
- const viewportHeight = isInlineSidebar && inlineContent instanceof HTMLElement
147
- ? targetRect.height
168
+ const viewportHeight = screenshotRoot
169
+ ? screenshotRoot.clientHeight
148
170
  : window.innerHeight;
149
171
  const contentWidth = Math.max(1, viewportWidth - sidebarInset);
150
172
  const maxWidth = 1200;
151
- const scale = Math.min(1, maxWidth / viewportWidth);
152
- const outW = Math.round(viewportWidth * scale);
173
+ const scale = Math.min(1, maxWidth / contentWidth);
174
+ const scrollContainer = getPrototypeScrollContainer();
175
+ const captureViewport: CaptureViewport = {
176
+ width: screenshotRoot ? viewportWidth : contentWidth,
177
+ height: viewportHeight,
178
+ scrollY: window.scrollY,
179
+ scrollTop: scrollContainer?.scrollTop ?? 0,
180
+ };
181
+
182
+ if (screenshotRoot) {
183
+ const dataUrl = await mod.domToJpeg(captureTarget, {
184
+ backgroundColor: "#ffffff",
185
+ timeout: 5000,
186
+ width: viewportWidth,
187
+ height: viewportHeight,
188
+ quality,
189
+ scale,
190
+ });
191
+
192
+ return { dataUrl, captureViewport };
193
+ }
194
+
195
+ const outW = Math.round(contentWidth * scale);
153
196
  const outH = Math.round(viewportHeight * scale);
154
197
 
155
198
  const domCanvas = await mod.domToCanvas(captureTarget, {
156
199
  backgroundColor: "#ffffff",
157
200
  timeout: 5000,
158
- width: viewportWidth,
201
+ width: window.innerWidth,
159
202
  height: viewportHeight,
160
203
  });
161
204
 
@@ -165,18 +208,12 @@ export async function captureViewportScreenshot(
165
208
  const ctx = canvas.getContext("2d");
166
209
  if (!ctx) return null;
167
210
 
168
- ctx.drawImage(domCanvas, 0, 0, outW, outH);
211
+ drawCanvasPreservingAspect(ctx, domCanvas, outW, outH);
169
212
 
170
213
  const cropped = cropSidebarFromCanvas(canvas, sidebarInset, scale);
171
- const scrollContainer = getPrototypeScrollContainer();
172
214
  return {
173
215
  dataUrl: cropped.toDataURL("image/jpeg", quality),
174
- captureViewport: {
175
- width: contentWidth,
176
- height: viewportHeight,
177
- scrollY: window.scrollY,
178
- scrollTop: scrollContainer?.scrollTop ?? 0,
179
- },
216
+ captureViewport,
180
217
  };
181
218
  } catch (err) {
182
219
  console.warn("[prototype-comments] Viewport capture failed:", err);
@@ -1,3 +1,4 @@
1
+ import { COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE } from "@prototype/lib/prototypes/comment-storage-setup-prompt";
1
2
  import { isValidAnnotation } from "./validation";
2
3
 
3
4
  export interface CommentStorageAdapter<T extends { id: string }> {
@@ -30,9 +31,7 @@ export function createRemoteStorageAdapter<T extends { id: string }>(
30
31
  );
31
32
 
32
33
  if (response.status === 503) {
33
- throw new CommentStorageError(
34
- "Comment storage is not configured. Set Upstash Redis env vars.",
35
- );
34
+ throw new CommentStorageError(COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE);
36
35
  }
37
36
 
38
37
  if (!response.ok) {
@@ -48,9 +47,7 @@ export function createRemoteStorageAdapter<T extends { id: string }>(
48
47
  const response = await fetch(listUrl);
49
48
 
50
49
  if (response.status === 503) {
51
- throw new CommentStorageError(
52
- "Comment storage is not configured. Set Upstash Redis env vars.",
53
- );
50
+ throw new CommentStorageError(COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE);
54
51
  }
55
52
 
56
53
  if (!response.ok) {
@@ -66,6 +66,8 @@ export type CommentStore<TState> = {
66
66
  slug: string;
67
67
  annotations: CommentAnnotation<TState>[];
68
68
  storageReady: boolean;
69
+ /** False when server env vars are missing; null while checking. */
70
+ storageConfigured: boolean | null;
69
71
  storageError: string | null;
70
72
  addAnnotation: (
71
73
  fields: Omit<CommentAnnotation<TState>, "interactionState">,
@@ -17,7 +17,8 @@ export function useLayoutShift(): number {
17
17
  for (const mutation of mutations) {
18
18
  if (
19
19
  mutation.type === "attributes" &&
20
- mutation.attributeName === "data-comments-sidebar-open"
20
+ (mutation.attributeName === "data-comments-sidebar-open" ||
21
+ mutation.attributeName === "data-prototype-review-sidebar-open")
21
22
  ) {
22
23
  bump();
23
24
  break;
@@ -26,7 +27,13 @@ export function useLayoutShift(): number {
26
27
  });
27
28
  mutationObserver.observe(document.documentElement, {
28
29
  attributes: true,
29
- attributeFilter: ["data-comments-sidebar-open", "data-comments-sidebar-resizing", "style"],
30
+ attributeFilter: [
31
+ "data-comments-sidebar-open",
32
+ "data-prototype-review-sidebar-open",
33
+ "data-comments-sidebar-resizing",
34
+ "data-prototype-review-sidebar-resizing",
35
+ "style",
36
+ ],
30
37
  });
31
38
 
32
39
  const handleTransitionEnd = (event: TransitionEvent) => {
@@ -53,6 +53,7 @@ import { getRootAnnotations, resolveThreadRootId } from "../core/comment-threads
53
53
  import {
54
54
  buildCommentCaptureCursorStyles,
55
55
  isCommentCaptureBlockedTarget,
56
+ isCommentModeChromeInteraction,
56
57
  } from "../core/comment-capture-blocked";
57
58
  import { closestCrossingShadow } from "../core/element-identification";
58
59
  import { injectCaptureColorTokens } from "../core/capture-theme";
@@ -209,6 +210,13 @@ export function CommentCaptureToolbar<TState = unknown>({
209
210
  const [scrollY, setScrollY] = useState(0);
210
211
 
211
212
  const isFeedbackActive = isGiveAMomentMode;
213
+ const isChangelogModeActive =
214
+ isGiveAMomentMode &&
215
+ integratedReview &&
216
+ review?.captureChannel === "changelog";
217
+ const isCommentModeActive =
218
+ isGiveAMomentMode &&
219
+ (!integratedReview || review?.captureChannel === "comment");
212
220
  const annotationTargetOptions = useMemo(() => {
213
221
  if (typeof resolveTargetOptions === "function") {
214
222
  return resolveTargetOptions();
@@ -354,6 +362,18 @@ export function CommentCaptureToolbar<TState = unknown>({
354
362
  setIsGiveAMomentMode((active) => !active);
355
363
  }, [integratedReview, review]);
356
364
 
365
+ const exitCommentMode = useCallback(() => {
366
+ setPendingMultiSelectElements([]);
367
+ setPendingAnnotation(null);
368
+ setPendingExiting(false);
369
+ setHoverInfo(null);
370
+ setIsGiveAMomentMode(false);
371
+ modifiersHeldRef.current = { cmd: false, shift: false };
372
+ if (document.activeElement instanceof HTMLElement) {
373
+ document.activeElement.blur();
374
+ }
375
+ }, []);
376
+
357
377
  const cancelAnnotation = useCallback(() => {
358
378
  setPendingExiting(true);
359
379
  originalSetTimeout(() => {
@@ -983,6 +1003,28 @@ export function CommentCaptureToolbar<TState = unknown>({
983
1003
  createMultiSelectPendingAnnotation,
984
1004
  ]);
985
1005
 
1006
+ useEffect(() => {
1007
+ if (!isCommentModeActive) return;
1008
+
1009
+ const handleKeyDown = (event: KeyboardEvent) => {
1010
+ if (event.key !== "Escape") return;
1011
+ exitCommentMode();
1012
+ };
1013
+
1014
+ const handlePointerDown = (event: PointerEvent) => {
1015
+ const target = (event.composedPath()[0] || event.target) as HTMLElement;
1016
+ if (!isCommentModeChromeInteraction(target)) return;
1017
+ exitCommentMode();
1018
+ };
1019
+
1020
+ document.addEventListener("keydown", handleKeyDown, true);
1021
+ document.addEventListener("pointerdown", handlePointerDown, true);
1022
+ return () => {
1023
+ document.removeEventListener("keydown", handleKeyDown, true);
1024
+ document.removeEventListener("pointerdown", handlePointerDown, true);
1025
+ };
1026
+ }, [exitCommentMode, isCommentModeActive]);
1027
+
986
1028
  useEffect(() => {
987
1029
  const handleKeyDown = (e: KeyboardEvent) => {
988
1030
  const isTyping =
@@ -1000,7 +1042,7 @@ export function CommentCaptureToolbar<TState = unknown>({
1000
1042
  return;
1001
1043
  }
1002
1044
  if (pendingAnnotation) return;
1003
- if (isGiveAMomentMode) {
1045
+ if (isChangelogModeActive) {
1004
1046
  setIsGiveAMomentMode(false);
1005
1047
  e.preventDefault();
1006
1048
  if (document.activeElement instanceof HTMLElement) {
@@ -1028,7 +1070,7 @@ export function CommentCaptureToolbar<TState = unknown>({
1028
1070
  document.addEventListener("keydown", handleKeyDown);
1029
1071
  return () => document.removeEventListener("keydown", handleKeyDown);
1030
1072
  }, [
1031
- isGiveAMomentMode,
1073
+ isChangelogModeActive,
1032
1074
  pendingAnnotation,
1033
1075
  pendingMultiSelectElements.length,
1034
1076
  activateToolbar,
@@ -1052,14 +1094,6 @@ export function CommentCaptureToolbar<TState = unknown>({
1052
1094
  }
1053
1095
  }, [isActive, integratedReview, prototypeSlug]);
1054
1096
 
1055
- const isChangelogModeActive =
1056
- isGiveAMomentMode &&
1057
- integratedReview &&
1058
- review.captureChannel === "changelog";
1059
- const isCommentModeActive =
1060
- isGiveAMomentMode &&
1061
- (!integratedReview || review.captureChannel === "comment");
1062
-
1063
1097
  const bridgeValue = useMemo<CommentCaptureBridgeValue>(
1064
1098
  () => ({
1065
1099
  onSelect: handleCommentSelect,
@@ -1175,6 +1209,7 @@ export function CommentCaptureToolbar<TState = unknown>({
1175
1209
  e.stopPropagation();
1176
1210
  setIsGiveAMomentMode((active) => !active);
1177
1211
  }}
1212
+ data-prototype-comment-mode-toggle
1178
1213
  data-active={isGiveAMomentMode}
1179
1214
  aria-label={
1180
1215
  isGiveAMomentMode
@@ -15,6 +15,8 @@ import { captureInteractionState } from "../core/capture";
15
15
  import { getRepliesForParent } from "../core/comment-threads";
16
16
  import { commentToLiveState } from "../core/restore";
17
17
  import { normalizeAnnotationViewport } from "../core/normalize-viewport";
18
+ import { COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE } from "@prototype/lib/prototypes/comment-storage-setup-prompt";
19
+ import { fetchPrototypeStorageStatus, isPrototypeStorageFullyConfigured } from "@prototype/lib/prototypes/prototype-storage-status";
18
20
  import { createRemoteStorageAdapter } from "../core/storage";
19
21
  import type { CommentAnnotation, CommentStore } from "../core/types";
20
22
 
@@ -36,6 +38,7 @@ export function CommentProvider<TState>({
36
38
  }: CommentProviderProps<TState>) {
37
39
  const [annotations, setAnnotations] = useState<CommentAnnotation<TState>[]>([]);
38
40
  const [storageReady, setStorageReady] = useState(false);
41
+ const [storageConfigured, setStorageConfigured] = useState<boolean | null>(null);
39
42
  const [storageError, setStorageError] = useState<string | null>(null);
40
43
 
41
44
  const getLiveStateRef = useRef(getLiveState);
@@ -70,8 +73,21 @@ export function CommentProvider<TState>({
70
73
  async function loadAnnotations() {
71
74
  setStorageReady(false);
72
75
  setStorageError(null);
76
+ setStorageConfigured(null);
73
77
 
74
78
  try {
79
+ const status = await fetchPrototypeStorageStatus();
80
+ if (cancelled) return;
81
+
82
+ const fullyConfigured = isPrototypeStorageFullyConfigured(status);
83
+ setStorageConfigured(fullyConfigured);
84
+
85
+ if (!fullyConfigured) {
86
+ setStorageError(COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE);
87
+ setAnnotations([]);
88
+ return;
89
+ }
90
+
75
91
  const stored = await storage.load();
76
92
  if (cancelled) return;
77
93
  setAnnotations(stored);
@@ -83,6 +99,7 @@ export function CommentProvider<TState>({
83
99
  : "Failed to load comments.";
84
100
  setStorageError(message);
85
101
  setAnnotations([]);
102
+ setStorageConfigured((current) => current ?? true);
86
103
  } finally {
87
104
  if (!cancelled) setStorageReady(true);
88
105
  }
@@ -254,6 +271,7 @@ export function CommentProvider<TState>({
254
271
  slug,
255
272
  annotations,
256
273
  storageReady,
274
+ storageConfigured,
257
275
  storageError,
258
276
  addAnnotation,
259
277
  addReply,
@@ -267,6 +285,7 @@ export function CommentProvider<TState>({
267
285
  slug,
268
286
  annotations,
269
287
  storageReady,
288
+ storageConfigured,
270
289
  storageError,
271
290
  addAnnotation,
272
291
  addReply,
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { IconButton } from "@prototype/components/platform-ui/icon-button";
4
+ import { cn } from "@prototype/lib/utils";
5
+ import { MessageSquarePlus } from "lucide-react";
6
+
7
+ type CommentModeToggleButtonProps = {
8
+ isActive: boolean;
9
+ onClick: () => void;
10
+ };
11
+
12
+ export function CommentModeToggleButton({
13
+ isActive,
14
+ onClick,
15
+ }: CommentModeToggleButtonProps) {
16
+ return (
17
+ <IconButton
18
+ type="button"
19
+ variant="chrome"
20
+ size="icon"
21
+ onClick={onClick}
22
+ onMouseDown={(event) => event.preventDefault()}
23
+ data-prototype-comment-mode-toggle
24
+ aria-label={isActive ? "Exit comment mode" : "Add comment"}
25
+ aria-pressed={isActive}
26
+ className={cn(
27
+ "size-8 shrink-0",
28
+ isActive && "bg-accent text-accent-foreground hover:bg-accent/90 hover:text-accent-foreground",
29
+ )}
30
+ >
31
+ <MessageSquarePlus size={16} strokeWidth={2} />
32
+ </IconButton>
33
+ );
34
+ }
@@ -584,28 +584,20 @@ function CommentCard<TState>({
584
584
  <div className={cardStyles.screenshotInner}>
585
585
  <div
586
586
  className={cardStyles.screenshot}
587
- style={
588
- isSidebarLayout
589
- ? undefined
590
- : { aspectRatio: aspectRatio ?? "16 / 9" }
591
- }
587
+ style={{
588
+ aspectRatio: aspectRatio ?? "16 / 9",
589
+ ...(isSidebarLayout ? { maxHeight: 220 } : {}),
590
+ }}
592
591
  >
593
592
  {annotation.screenshot ? (
594
593
  <>
595
594
  <img
596
595
  src={annotation.screenshot}
597
596
  alt={annotation.comment}
598
- className={
599
- isSidebarLayout ? cardStyles.screenshotImgSidebar : undefined
600
- }
601
- style={
602
- isSidebarLayout
603
- ? { objectPosition: screenshotObjectPosition }
604
- : {
605
- ...s.screenshotImg,
606
- objectPosition: screenshotObjectPosition,
607
- }
608
- }
597
+ style={{
598
+ ...s.screenshotImg,
599
+ objectPosition: screenshotObjectPosition,
600
+ }}
609
601
  />
610
602
  {viewportBox && viewport && (
611
603
  <div
@@ -3,14 +3,9 @@
3
3
  import { countUnresolvedAnnotations } from "../core/annotation-status";
4
4
  import { useCommentStore } from "../react/CommentProvider";
5
5
  import { CommentHeaderCount } from "./CommentHeaderCount";
6
+ import { CommentModeToggleButton } from "./CommentModeToggleButton";
6
7
  import { CommentsGrid } from "./CommentsGrid";
7
- import {
8
- SIDEBAR_ICON_BUTTON_CLASS,
9
- Sidebar,
10
- } from "@prototype/components/platform-ui/sidebar";
11
- import { IconButton } from "@prototype/components/platform-ui/icon-button";
12
- import { cn } from "@prototype/lib/utils";
13
- import { MessageSquarePlus } from "lucide-react";
8
+ import { Sidebar } from "@prototype/components/platform-ui/sidebar";
14
9
 
15
10
  type CommentsSidebarProps = {
16
11
  open: boolean;
@@ -47,19 +42,10 @@ export function CommentsSidebar({
47
42
  widthCssVar="--comments-sidebar-width"
48
43
  headerActions={
49
44
  onToggleCommentMode ? (
50
- <IconButton
51
- type="button"
45
+ <CommentModeToggleButton
46
+ isActive={isCommentModeActive}
52
47
  onClick={onToggleCommentMode}
53
- onMouseDown={(event) => event.preventDefault()}
54
- aria-label={isCommentModeActive ? "Exit comment mode" : "Add comment"}
55
- aria-pressed={isCommentModeActive}
56
- className={cn(
57
- SIDEBAR_ICON_BUTTON_CLASS,
58
- isCommentModeActive && "bg-accent text-accent-foreground",
59
- )}
60
- >
61
- <MessageSquarePlus size={16} strokeWidth={2} />
62
- </IconButton>
48
+ />
63
49
  ) : null
64
50
  }
65
51
  >
@@ -870,9 +870,13 @@
870
870
  }
871
871
 
872
872
  :global(html[data-comments-sidebar-open]) .markersLayer,
873
+ :global(html[data-prototype-review-sidebar-open]) .markersLayer,
873
874
  :global(html[data-comments-sidebar-open]) .fixedMarkersLayer,
875
+ :global(html[data-prototype-review-sidebar-open]) .fixedMarkersLayer,
874
876
  :global(html[data-comments-sidebar-open]) .overlay,
875
- :global(html[data-comments-sidebar-open]) .targetHighlightLayer {
877
+ :global(html[data-prototype-review-sidebar-open]) .overlay,
878
+ :global(html[data-comments-sidebar-open]) .targetHighlightLayer,
879
+ :global(html[data-prototype-review-sidebar-open]) .targetHighlightLayer {
876
880
  right: var(--comments-sidebar-width, 400px);
877
881
  }
878
882
 
@@ -42,13 +42,6 @@
42
42
  transition: border-color 0.2s cubic-bezier(0.215, 0.61, 0.355, 1);
43
43
  }
44
44
 
45
- .screenshotImgSidebar {
46
- display: block;
47
- width: 100%;
48
- height: auto;
49
- max-height: 220px;
50
- }
51
-
52
45
  .cardCondensed .screenshot {
53
46
  border-bottom-color: transparent;
54
47
  }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type {
4
+ DesignExplorationBaselineOption,
5
+ DesignExplorationVariantOption,
6
+ } from "./design-exploration-types";
7
+ import { getDesignExplorationDisplayOptions } from "./design-exploration-types";
8
+
9
+ /**
10
+ * Builds a renderer entry for every design exploration option. Prefer this over
11
+ * hand-rolled `Object.fromEntries` maps so each variant gets a preview and new
12
+ * options cannot silently return `null`.
13
+ */
14
+ export function buildDesignExplorationRenderers<TVariant extends string>(
15
+ options: readonly DesignExplorationVariantOption<TVariant>[],
16
+ renderVariant: (variant: TVariant) => ReactNode,
17
+ baseline?: DesignExplorationBaselineOption<TVariant>,
18
+ ): Record<TVariant, () => ReactNode> {
19
+ const displayOptions = baseline
20
+ ? getDesignExplorationDisplayOptions([...options], baseline)
21
+ : options;
22
+ const renderers = {} as Record<TVariant, () => ReactNode>;
23
+
24
+ for (const option of displayOptions) {
25
+ const variant = option.value as TVariant;
26
+ renderers[variant] = () => {
27
+ const node = renderVariant(variant);
28
+
29
+ if (process.env.NODE_ENV !== "production" && isEmptyRendererOutput(node)) {
30
+ console.warn(
31
+ `[prototype] Design exploration renderer for "${variant}" returned no preview content.`,
32
+ );
33
+ }
34
+
35
+ return node;
36
+ };
37
+ }
38
+
39
+ return renderers;
40
+ }
41
+
42
+ function isEmptyRendererOutput(node: ReactNode): boolean {
43
+ return node == null || node === false;
44
+ }
@@ -0,0 +1,58 @@
1
+ export function buildChangelogDescriptionPrompt({
2
+ slug,
3
+ description,
4
+ origin = "http://localhost:3003",
5
+ }: {
6
+ slug: string;
7
+ description: string;
8
+ origin?: string;
9
+ }): string {
10
+ const prototypeUrl = `${origin}/prototypes/${slug}`;
11
+ const trimmed = description.trim();
12
+
13
+ const lines = [
14
+ `Update the "${slug}" prototype to match the following change log overview.`,
15
+ "",
16
+ "## Description",
17
+ ];
18
+
19
+ if (trimmed) {
20
+ lines.push(trimmed);
21
+ } else {
22
+ lines.push(
23
+ "Summarize what changed in this prototype and why:",
24
+ "- What changed:",
25
+ "- Why:",
26
+ );
27
+ }
28
+
29
+ lines.push(
30
+ "",
31
+ "## Prototype",
32
+ `- Slug: \`${slug}\``,
33
+ `- Preview: ${prototypeUrl}`,
34
+ `- Work in: \`src/prototypes/${slug}/\``,
35
+ "",
36
+ "## Instructions",
37
+ "1. Review the overview and anchored changelog entries in the prototype review sidebar.",
38
+ "2. Apply any missing updates so the prototype matches the described changes.",
39
+ "3. Do not persist this overview in KV — it is prompt-only.",
40
+ );
41
+
42
+ return lines.join("\n");
43
+ }
44
+
45
+ export function buildChangelogDescriptionCopyText({
46
+ slug,
47
+ description,
48
+ origin,
49
+ }: {
50
+ slug: string;
51
+ description: string;
52
+ origin?: string;
53
+ }): string {
54
+ const prompt = buildChangelogDescriptionPrompt({ slug, description, origin });
55
+ const trimmed = description.trim();
56
+ if (!trimmed) return prompt;
57
+ return `${trimmed}\n\n---\n\n${prompt}`;
58
+ }