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.
- package/AGENTS.md +308 -29
- package/README.md +55 -13
- package/package.json +1 -1
- package/scripts/lib/host-config.example.sh +1 -1
- package/scripts/publish-package.sh +41 -0
- package/scripts/verify-prototype-preview-states.mjs +57 -0
- package/src/components/platform-ui/controls-panel-select.tsx +4 -3
- package/src/components/platform-ui/prototype-tool-dialog.tsx +117 -0
- package/src/components/platform-ui/sidebar.tsx +34 -16
- package/src/components/prototype-gallery-client.tsx +77 -37
- package/src/components/prototypes/design-exploration-variant-preview-shell.tsx +43 -0
- package/src/components/prototypes/prototype-brief-field.tsx +33 -0
- package/src/components/prototypes/prototype-change-log-panel.tsx +4 -0
- package/src/components/prototypes/prototype-comment-storage-empty-state.tsx +41 -0
- package/src/components/prototypes/prototype-create-exploration-modal.tsx +41 -64
- package/src/components/prototypes/prototype-create-prototype-modal.tsx +102 -0
- package/src/components/prototypes/prototype-create-state-modal.tsx +31 -70
- package/src/components/prototypes/prototype-default-state-map-page.tsx +4 -4
- package/src/components/prototypes/prototype-design-brief-modal.tsx +10 -10
- package/src/components/prototypes/prototype-design-brief-panel.tsx +261 -231
- package/src/components/prototypes/prototype-floating-pill.module.scss +16 -9
- package/src/components/prototypes/prototype-mobbin-gallery.tsx +18 -2
- package/src/components/prototypes/prototype-review-sidebar.tsx +11 -28
- package/src/components/prototypes/prototype-spec-panel-content.tsx +5 -5
- package/src/components/prototypes/prototype-spec-panel-empty-state.tsx +53 -0
- package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +20 -47
- package/src/components/prototypes/prototype-state-canvas-overlay.tsx +4 -42
- package/src/components/prototypes/prototype-variant-explorer.tsx +32 -78
- package/src/components/prototypes/prototype-vercel-preview-modal.tsx +4 -3
- package/src/components/prototypes/state-map-annotation-tooltip.module.scss +7 -7
- package/src/components/shell/prototype-component-library-empty-state.tsx +59 -0
- package/src/components/shell/prototype-gallery-nav.tsx +124 -0
- package/src/components/shell/prototype-gallery-shell.tsx +45 -13
- package/src/components/shell/prototype-link-source-modal.tsx +100 -0
- package/src/components/ui/button.tsx +3 -1
- package/src/components/ui/dialog.tsx +43 -4
- package/src/config/create-prototype-component-library-page.tsx +74 -0
- package/src/config/create-prototype-gallery-page.tsx +26 -23
- package/src/config/create-prototype-host-app.tsx +3 -15
- package/src/config/create-prototype-site-layout.tsx +14 -1
- package/src/config/create-prototype-state-map-page.tsx +44 -0
- package/src/config/prototype-site-layout-client.tsx +20 -6
- package/src/config/with-prototype.ts +21 -0
- package/src/index.ts +54 -10
- package/src/lib/copy-text-with-attachments.ts +35 -0
- package/src/lib/format-source-directory-name.ts +12 -0
- package/src/lib/platform-ui/sidebar-interactive-cursor.ts +19 -0
- package/src/lib/pr-split/build-pr-split-plan-prompt.ts +81 -0
- package/src/lib/pr-split/pr-split-highlight.ts +3 -0
- package/src/lib/prototype-comments/core/annotation-target.ts +12 -3
- package/src/lib/prototype-comments/core/comment-capture-blocked.ts +24 -0
- package/src/lib/prototype-comments/core/screenshot.ts +68 -31
- package/src/lib/prototype-comments/core/storage.ts +3 -6
- package/src/lib/prototype-comments/core/types.ts +2 -0
- package/src/lib/prototype-comments/hooks/useLayoutShift.ts +9 -2
- package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +45 -10
- package/src/lib/prototype-comments/react/CommentProvider.tsx +19 -0
- package/src/lib/prototype-comments/ui/CommentModeToggleButton.tsx +34 -0
- package/src/lib/prototype-comments/ui/CommentsGrid.tsx +8 -16
- package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +5 -19
- package/src/lib/prototype-comments/ui/capture-styles.module.scss +5 -1
- package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +0 -7
- package/src/lib/prototypes/build-design-exploration-renderers.ts +44 -0
- package/src/lib/prototypes/comment-storage-setup-prompt.ts +124 -0
- package/src/lib/prototypes/create-prototype-prompt.ts +96 -0
- package/src/lib/prototypes/create-prototype-registry.ts +5 -0
- package/src/lib/prototypes/design-exploration-types.ts +261 -10
- package/src/lib/prototypes/link-source-prompt.ts +93 -0
- package/src/lib/prototypes/populate-component-library-prompt.ts +64 -0
- package/src/lib/prototypes/prototype-config-types.ts +2 -0
- package/src/lib/prototypes/prototype-layout-classes.ts +19 -0
- package/src/lib/prototypes/prototype-preview-state-registry.ts +5 -2
- package/src/lib/prototypes/prototype-review-context.tsx +4 -4
- package/src/lib/prototypes/prototype-state-canvas-constants.ts +1 -1
- package/src/lib/prototypes/prototype-state-canvas-types.ts +0 -2
- package/src/lib/prototypes/prototype-storage-status.ts +31 -0
- package/src/lib/prototypes/sort-prototypes-by-creation-date.ts +43 -0
- package/src/lib/prototypes/use-prototype-tool-theme.tsx +1 -1
- package/src/lib/prototypes/use-toolbar-hover-panel.ts +12 -1
- package/src/lib/prototypes/variant-set-lucide-icon.tsx +24 -0
- package/src/lib/tool-portal.ts +24 -0
- package/src/lib/use-copy-to-clipboard.ts +7 -13
- package/src/server/create-host-api-route.ts +7 -16
- package/src/server/create-prototype-api-routes.ts +0 -115
- package/src/server/prototype-storage-status-route.ts +29 -0
- package/src/server.ts +0 -8
- package/src/styles/globals.css +359 -71
- package/src/lib/prototypes/design-exploration-default-storage.ts +0 -68
- package/src/lib/prototypes/design-exploration-default.ts +0 -51
- package/src/lib/prototypes/use-design-exploration-default.ts +0 -114
- 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
133
|
-
const
|
|
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 =
|
|
139
|
-
|
|
140
|
-
|
|
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 =
|
|
147
|
-
?
|
|
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 /
|
|
152
|
-
const
|
|
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:
|
|
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
|
|
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: [
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|