proto-plugin 0.1.0 → 0.1.2

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 (91) hide show
  1. package/AGENTS.md +308 -29
  2. package/README.md +55 -13
  3. package/package.json +1 -1
  4. package/scripts/lib/host-config.example.sh +1 -1
  5. package/scripts/publish-package.sh +41 -0
  6. package/scripts/verify-prototype-preview-states.mjs +57 -0
  7. package/src/components/platform-ui/controls-panel-select.tsx +4 -3
  8. package/src/components/platform-ui/prototype-tool-dialog.tsx +117 -0
  9. package/src/components/platform-ui/sidebar.tsx +34 -16
  10. package/src/components/prototype-gallery-client.tsx +77 -37
  11. package/src/components/prototypes/design-exploration-variant-preview-shell.tsx +43 -0
  12. package/src/components/prototypes/prototype-brief-field.tsx +33 -0
  13. package/src/components/prototypes/prototype-change-log-panel.tsx +4 -0
  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-sidebar.tsx +11 -28
  24. package/src/components/prototypes/prototype-spec-panel-content.tsx +5 -5
  25. package/src/components/prototypes/prototype-spec-panel-empty-state.tsx +53 -0
  26. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +20 -47
  27. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +4 -42
  28. package/src/components/prototypes/prototype-variant-explorer.tsx +32 -78
  29. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +4 -3
  30. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +7 -7
  31. package/src/components/shell/prototype-component-library-empty-state.tsx +59 -0
  32. package/src/components/shell/prototype-gallery-nav.tsx +124 -0
  33. package/src/components/shell/prototype-gallery-shell.tsx +45 -13
  34. package/src/components/shell/prototype-link-source-modal.tsx +100 -0
  35. package/src/components/ui/button.tsx +3 -1
  36. package/src/components/ui/dialog.tsx +43 -4
  37. package/src/config/create-prototype-component-library-page.tsx +74 -0
  38. package/src/config/create-prototype-gallery-page.tsx +26 -23
  39. package/src/config/create-prototype-host-app.tsx +3 -15
  40. package/src/config/create-prototype-site-layout.tsx +14 -1
  41. package/src/config/create-prototype-state-map-page.tsx +44 -0
  42. package/src/config/prototype-site-layout-client.tsx +20 -6
  43. package/src/config/with-prototype.ts +21 -0
  44. package/src/index.ts +54 -10
  45. package/src/lib/copy-text-with-attachments.ts +35 -0
  46. package/src/lib/format-source-directory-name.ts +12 -0
  47. package/src/lib/platform-ui/sidebar-interactive-cursor.ts +19 -0
  48. package/src/lib/pr-split/build-pr-split-plan-prompt.ts +81 -0
  49. package/src/lib/pr-split/pr-split-highlight.ts +3 -0
  50. package/src/lib/prototype-comments/core/annotation-target.ts +12 -3
  51. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +24 -0
  52. package/src/lib/prototype-comments/core/screenshot.ts +68 -31
  53. package/src/lib/prototype-comments/core/storage.ts +3 -6
  54. package/src/lib/prototype-comments/core/types.ts +2 -0
  55. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +9 -2
  56. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +45 -10
  57. package/src/lib/prototype-comments/react/CommentProvider.tsx +19 -0
  58. package/src/lib/prototype-comments/ui/CommentModeToggleButton.tsx +34 -0
  59. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +8 -16
  60. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +5 -19
  61. package/src/lib/prototype-comments/ui/capture-styles.module.scss +5 -1
  62. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +0 -7
  63. package/src/lib/prototypes/build-design-exploration-renderers.ts +44 -0
  64. package/src/lib/prototypes/comment-storage-setup-prompt.ts +124 -0
  65. package/src/lib/prototypes/create-prototype-prompt.ts +96 -0
  66. package/src/lib/prototypes/create-prototype-registry.ts +5 -0
  67. package/src/lib/prototypes/design-exploration-types.ts +261 -10
  68. package/src/lib/prototypes/link-source-prompt.ts +93 -0
  69. package/src/lib/prototypes/populate-component-library-prompt.ts +64 -0
  70. package/src/lib/prototypes/prototype-config-types.ts +2 -0
  71. package/src/lib/prototypes/prototype-layout-classes.ts +19 -0
  72. package/src/lib/prototypes/prototype-preview-state-registry.ts +5 -2
  73. package/src/lib/prototypes/prototype-review-context.tsx +4 -4
  74. package/src/lib/prototypes/prototype-state-canvas-constants.ts +1 -1
  75. package/src/lib/prototypes/prototype-state-canvas-types.ts +0 -2
  76. package/src/lib/prototypes/prototype-storage-status.ts +31 -0
  77. package/src/lib/prototypes/sort-prototypes-by-creation-date.ts +43 -0
  78. package/src/lib/prototypes/use-prototype-tool-theme.tsx +1 -1
  79. package/src/lib/prototypes/use-toolbar-hover-panel.ts +12 -1
  80. package/src/lib/prototypes/variant-set-lucide-icon.tsx +24 -0
  81. package/src/lib/tool-portal.ts +24 -0
  82. package/src/lib/use-copy-to-clipboard.ts +7 -13
  83. package/src/server/create-host-api-route.ts +7 -16
  84. package/src/server/create-prototype-api-routes.ts +0 -115
  85. package/src/server/prototype-storage-status-route.ts +29 -0
  86. package/src/server.ts +0 -8
  87. package/src/styles/globals.css +359 -71
  88. package/src/lib/prototypes/design-exploration-default-storage.ts +0 -68
  89. package/src/lib/prototypes/design-exploration-default.ts +0 -51
  90. package/src/lib/prototypes/use-design-exploration-default.ts +0 -114
  91. package/src/server/redis/prototype-design-exploration-default.ts +0 -32
@@ -0,0 +1,35 @@
1
+ import { toast } from "sonner";
2
+
3
+ function copyTextWithExecCommand(text: string): boolean {
4
+ try {
5
+ const textarea = document.createElement("textarea");
6
+ textarea.value = text;
7
+ textarea.setAttribute("readonly", "");
8
+ textarea.style.position = "fixed";
9
+ textarea.style.left = "-9999px";
10
+ textarea.style.top = "0";
11
+ document.body.appendChild(textarea);
12
+ textarea.select();
13
+ const ok = document.execCommand("copy");
14
+ document.body.removeChild(textarea);
15
+ return ok;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export async function copyTextWithAttachments(text: string): Promise<boolean> {
22
+ try {
23
+ await navigator.clipboard.writeText(text);
24
+ return true;
25
+ } catch {
26
+ if (copyTextWithExecCommand(text)) {
27
+ return true;
28
+ }
29
+
30
+ toast.error("Copy error", {
31
+ description: "Failed to copy prompt to clipboard",
32
+ });
33
+ return false;
34
+ }
35
+ }
@@ -0,0 +1,12 @@
1
+ /** Last path segment of SOURCE_PATH for gallery chrome, e.g. `/path/to/proto-site` → `proto-site`. */
2
+ export function formatSourceDirectoryName(
3
+ sourcePath: string | undefined,
4
+ ): string | undefined {
5
+ const trimmed = sourcePath?.trim();
6
+ if (!trimmed) return undefined;
7
+
8
+ const normalized = trimmed.replace(/[/\\]+$/, "");
9
+ const segments = normalized.split(/[/\\]/);
10
+ const name = segments.at(-1);
11
+ return name || undefined;
12
+ }
@@ -0,0 +1,19 @@
1
+ /** Interactive targets inside review/platform sidebars that should show pointer. */
2
+ export const SIDEBAR_INTERACTIVE_POINTER_SELECTOR =
3
+ ":is(button:not(:disabled), [role='button']:not([aria-disabled='true']), a[href], summary, label:has([role='switch']), label:has(input[type='checkbox']))";
4
+
5
+ export const SIDEBAR_ROOT_SELECTORS = [
6
+ "[data-prototype-review-sidebar]",
7
+ "[data-comments-sidebar]",
8
+ "[data-platform-sidebar]",
9
+ "#prototype-comments-sidebar-root",
10
+ ] as const;
11
+
12
+ export function buildSidebarInteractivePointerCss(options?: {
13
+ important?: boolean;
14
+ }): string {
15
+ const suffix = options?.important ? " !important" : "";
16
+ return SIDEBAR_ROOT_SELECTORS.map(
17
+ (root) => `${root} ${SIDEBAR_INTERACTIVE_POINTER_SELECTOR}`,
18
+ ).join(",\n") + ` { cursor: pointer${suffix}; }`;
19
+ }
@@ -0,0 +1,81 @@
1
+ export type PrSplitPlanPromptOptions = {
2
+ slug: string;
3
+ origin?: string;
4
+ sourceRepo?: string;
5
+ sourceWorkPath?: string;
6
+ };
7
+
8
+ export function buildPrSplitPlanPrompt({
9
+ slug,
10
+ origin = "http://localhost:1985",
11
+ sourceRepo = "calcom/cal.diy",
12
+ sourceWorkPath = "apps/web",
13
+ }: PrSplitPlanPromptOptions): string {
14
+ const prototypePath = `src/prototypes/${slug}/`;
15
+ const configPath = `${prototypePath}_components/pr-split-config.ts`;
16
+ const wireframesPath = `${prototypePath}_components/pr-split-wireframes.tsx`;
17
+ const previewUrl = `${origin}/prototypes/${slug}?reviewPanel=spec`;
18
+
19
+ return [
20
+ `Create a PR split spec for the \`${slug}\` prototype. **First identify everything that changed**, then propose a merge-ordered PR plan and implement the spec in this repo.`,
21
+ "",
22
+ "## Phase 1 — Audit what changed",
23
+ "",
24
+ "1. Read the prototype at `" + prototypePath + "` — especially `index.tsx`, any `*-preview-states.ts`, all `*-design-exploration-config.tsx` files, mock data, and `component-ids.ts`.",
25
+ "2. For each design exploration, compare the **baseline** option (marked **Original** in the brief) to the **default variant** in code (`DEFAULT_*_VARIANT` constants). Baselines = production / pre-exploration UI; defaults = chosen shipping direction.",
26
+ "3. Compare against production in the source app (`SOURCE_PATH` / `source/` symlink, repo `" + sourceRepo + "`). Find the real page under `" + sourceWorkPath + "` and list layout, behavior, and data-model deltas.",
27
+ "4. Run `git log` and `git diff` on `" + prototypePath + "` to capture the full change history (including uncommitted work).",
28
+ "5. Read preview states in `*-preview-states.ts` — each distinct reviewer mode may map to a PR slice.",
29
+ "6. Note explorations that should **stay in the design brief only** (alternate layout or entry-point variants not chosen for shipping).",
30
+ "",
31
+ "## Phase 2 — Plan the PR breakdown",
32
+ "",
33
+ "Propose **3–7 small, merge-ordered PRs** for `" + sourceRepo + "`. For each PR, define:",
34
+ "",
35
+ "- `order`, `title`, `description` (2–3 sentences of source-repo work)",
36
+ "- `size` (`Small` | `Medium` | `Large`)",
37
+ "- `targetId` — one registered id from `component-ids.ts`",
38
+ "- `wireframeId` — key for a full-page schematic wireframe renderer",
39
+ "- `liveState` — restores the prototype preview for that slice",
40
+ "- Dependencies on earlier PRs in the series",
41
+ "",
42
+ "Grouping guidance:",
43
+ "- Ship data / filter logic before UI entry points that depend on it",
44
+ "- Keep the primary layout change as its own PR",
45
+ "- Empty states, modals, and polish can follow once foundation PRs land",
46
+ "- Do not bundle unrelated design-brief alternates into shipping PRs",
47
+ "",
48
+ "Present the plan as a numbered list with a short dependency diagram before implementing.",
49
+ "",
50
+ "## Phase 3 — Implement the PR split spec (this repo)",
51
+ "",
52
+ "Follow `packages/prototype/AGENTS.md` → **PR split specs**:",
53
+ "",
54
+ "1. Create `" + configPath + "` with `PR_SPLIT_CONFIG` and `PR_SPLIT_ENTRIES`",
55
+ " - `slug: \"" + slug + "\"`",
56
+ " - `sourceRepo: \"" + sourceRepo + "\"`, `sourceWorkPath: \"" + sourceWorkPath + "\"`",
57
+ " - `writeEntryToSearchParams` restores each entry's `liveState`",
58
+ " - `scopeNote` guardrail: do not ship unchosen design-brief variants",
59
+ "2. Create `" + wireframesPath + "` — full-page schematic wireframes (see **Wireframe rules** below)",
60
+ "3. Wire `" + prototypePath + "index.tsx` — parse/sync `?prSplit=`, call `setSpecContent(<PrototypeSpecPanelContent ...>)`, `handlePrNavigate` with `focusShareTarget`",
61
+ "4. Update `component-ids.ts` — add `spec-panel-content`, wireframe ids, etc.",
62
+ "5. Verify each PR card restores the correct prototype state and highlights the right component",
63
+ "",
64
+ "### Wireframe rules (mandatory)",
65
+ "",
66
+ "Each PR card preview must show the **full prototype page shell** — sidebar, header/toolbar, and main content area as reviewers see them in the live preview. Do **not** draw cropped excerpts, zoomed-in component slices, or short strips that only show the changed region.",
67
+ "",
68
+ "- Scaffold every wireframe from the prototype's real page layout (match the shell layout component)",
69
+ "- Use low-fidelity `Wire` blocks; apply `PR_TARGET_HIGHLIGHT_BORDER` / `PR_TARGET_HIGHLIGHT_FILL` only on the PR target",
70
+ "- Fill the card preview area: wrap the wireframe root in `PR_SPLIT_WIREFRAME_FRAME_CLASS` (`aspect-video w-full overflow-hidden`) so the schematic reads as a full screen, not a tiny excerpt",
71
+ "- The highlight shows where the slice lands on the page; surrounding chrome provides context",
72
+ "",
73
+ "Prototype preview: " + previewUrl,
74
+ "",
75
+ "After the spec is wired, summarize the final PR sequence for the user. Do not open source PRs until the plan is approved.",
76
+ ].join("\n");
77
+ }
78
+
79
+ export function buildPrSplitPlanCopyText(options: PrSplitPlanPromptOptions): string {
80
+ return buildPrSplitPlanPrompt(options);
81
+ }
@@ -4,3 +4,6 @@ export const PR_TARGET_HIGHLIGHT_BORDER =
4
4
 
5
5
  export const PR_TARGET_HIGHLIGHT_FILL =
6
6
  "color-mix(in srgb, var(--prototype-comment-color-blue) 6%, transparent)";
7
+
8
+ /** Root wrapper for PR split wireframes — full-page schematic, not a cropped excerpt. */
9
+ export const PR_SPLIT_WIREFRAME_FRAME_CLASS = "aspect-video w-full overflow-hidden";
@@ -1,4 +1,5 @@
1
1
  import { getElementClasses } from "./element-identification";
2
+ import { getPrototypeScreenshotRoot } from "@prototype/lib/tool-portal";
2
3
 
3
4
  export const PROTOTYPE_TARGET_ATTR = "data-prototype-target";
4
5
  const PROTOTYPE_SCREENSHOT_ROOT = "[data-prototype-screenshot]";
@@ -37,9 +38,17 @@ export function getCommentsSidebarWidth(): number {
37
38
  }
38
39
 
39
40
  export function getCommentsSidebarInset(): number {
40
- if (!document.documentElement.hasAttribute("data-comments-sidebar-open")) return 0;
41
- // Inline sidebar already shrinks the prototype content area — no coordinate offset needed.
42
- if (document.documentElement.hasAttribute("data-comments-sidebar-inline")) return 0;
41
+ const isOpen =
42
+ document.documentElement.hasAttribute("data-comments-sidebar-open") ||
43
+ document.documentElement.hasAttribute("data-prototype-review-sidebar-open");
44
+ if (!isOpen) return 0;
45
+
46
+ const isInline =
47
+ document.documentElement.hasAttribute("data-comments-sidebar-inline") ||
48
+ document.documentElement.hasAttribute("data-prototype-review-sidebar-inline") ||
49
+ getPrototypeScreenshotRoot() != null;
50
+ if (isInline) return 0;
51
+
43
52
  return getCommentsSidebarWidth();
44
53
  }
45
54
 
@@ -1,4 +1,5 @@
1
1
  import { closestCrossingShadow } from "./element-identification";
2
+ import { buildSidebarInteractivePointerCss } from "@prototype/lib/platform-ui/sidebar-interactive-cursor";
2
3
 
3
4
  /** DOM regions where comment capture must not attach (plugin chrome, not prototype UI). */
4
5
  export const COMMENT_CAPTURE_BLOCKED_SELECTORS = [
@@ -21,6 +22,28 @@ export function isCommentCaptureBlockedTarget(target: HTMLElement): boolean {
21
22
  );
22
23
  }
23
24
 
25
+ /** Plugin chrome where interacting should exit comment mode (not the prototype canvas). */
26
+ export const COMMENT_MODE_CHROME_SELECTORS = [
27
+ "[data-prototype-review-trigger]",
28
+ "[data-prototype-review-sidebar]",
29
+ "[data-prototype-review-panel]",
30
+ "[data-feedback-toolbar]",
31
+ "[data-comment-capture-toolbar]",
32
+ "[data-comments-sidebar]",
33
+ "#prototype-comments-sidebar-root",
34
+ ] as const;
35
+
36
+ export const COMMENT_MODE_TOGGLE_SELECTOR = "[data-prototype-comment-mode-toggle]";
37
+
38
+ export function isCommentModeChromeInteraction(target: HTMLElement): boolean {
39
+ if (closestCrossingShadow(target, COMMENT_MODE_TOGGLE_SELECTOR) != null) {
40
+ return false;
41
+ }
42
+ return COMMENT_MODE_CHROME_SELECTORS.some(
43
+ (selector) => closestCrossingShadow(target, selector) != null,
44
+ );
45
+ }
46
+
24
47
  /** CSS :not() chain for descendants that should not receive comment-mode crosshair. */
25
48
  export function buildCommentCaptureCursorExclusionCss(): string {
26
49
  return COMMENT_CAPTURE_BLOCKED_SELECTORS.flatMap((selector) => [
@@ -43,5 +66,6 @@ export function buildCommentCaptureCursorStyles(): string {
43
66
  ${blockedCursorReset} {
44
67
  cursor: auto !important;
45
68
  }
69
+ ${buildSidebarInteractivePointerCss({ important: true })}
46
70
  `;
47
71
  }
@@ -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
+ }