proto-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/AGENTS.md +350 -0
  2. package/README.md +279 -0
  3. package/components.json +18 -0
  4. package/package.json +73 -0
  5. package/scripts/download-mobbin-reference-images.mjs +112 -0
  6. package/scripts/lib/host-config.example.sh +48 -0
  7. package/scripts/lib/host-config.sh +25 -0
  8. package/scripts/lib/resolve-host-root.mjs +18 -0
  9. package/scripts/lib/source-path.sh +49 -0
  10. package/scripts/link-source-db.sh +128 -0
  11. package/scripts/link-source.sh +40 -0
  12. package/scripts/publish-package.sh +147 -0
  13. package/scripts/share-prototype.sh +279 -0
  14. package/scripts/sync-from-source.sh +74 -0
  15. package/scripts/verify-prototype-component-ids.mjs +151 -0
  16. package/scripts/verify-prototype-preview-states.mjs +135 -0
  17. package/src/app.ts +4 -0
  18. package/src/components/platform-ui/code-block.tsx +21 -0
  19. package/src/components/platform-ui/controls-panel-options.tsx +71 -0
  20. package/src/components/platform-ui/controls-panel-select.tsx +438 -0
  21. package/src/components/platform-ui/empty-state.tsx +15 -0
  22. package/src/components/platform-ui/icon-button.tsx +18 -0
  23. package/src/components/platform-ui/panel-select.tsx +123 -0
  24. package/src/components/platform-ui/panel.tsx +97 -0
  25. package/src/components/platform-ui/review-dropdown-menu.tsx +35 -0
  26. package/src/components/platform-ui/sidebar.tsx +293 -0
  27. package/src/components/platform-ui/toolbar-icon-button.tsx +34 -0
  28. package/src/components/platform-ui/toolbar.tsx +37 -0
  29. package/src/components/prototype-gallery-client.tsx +57 -0
  30. package/src/components/prototype-provider.tsx +20 -0
  31. package/src/components/prototypes/mini-pill-label.tsx +37 -0
  32. package/src/components/prototypes/prototype-change-log-panel.tsx +207 -0
  33. package/src/components/prototypes/prototype-comment-provider.tsx +61 -0
  34. package/src/components/prototypes/prototype-comment-toolbar.tsx +65 -0
  35. package/src/components/prototypes/prototype-control.tsx +31 -0
  36. package/src/components/prototypes/prototype-controls.tsx +553 -0
  37. package/src/components/prototypes/prototype-create-exploration-modal.tsx +117 -0
  38. package/src/components/prototypes/prototype-create-state-modal.tsx +117 -0
  39. package/src/components/prototypes/prototype-default-state-map-page.tsx +66 -0
  40. package/src/components/prototypes/prototype-design-brief-modal.tsx +128 -0
  41. package/src/components/prototypes/prototype-design-brief-panel.tsx +525 -0
  42. package/src/components/prototypes/prototype-design-context-panel.tsx +65 -0
  43. package/src/components/prototypes/prototype-floating-pill.module.scss +1341 -0
  44. package/src/components/prototypes/prototype-mobbin-gallery.tsx +330 -0
  45. package/src/components/prototypes/prototype-reference-docs.tsx +222 -0
  46. package/src/components/prototypes/prototype-review-chrome.module.scss +630 -0
  47. package/src/components/prototypes/prototype-review-chrome.tsx +1241 -0
  48. package/src/components/prototypes/prototype-review-dropdown-menu-item.tsx +1 -0
  49. package/src/components/prototypes/prototype-review-dropdown-menu-separator.tsx +1 -0
  50. package/src/components/prototypes/prototype-review-panel-menu-item.tsx +1 -0
  51. package/src/components/prototypes/prototype-review-panel-menu-separator.tsx +1 -0
  52. package/src/components/prototypes/prototype-review-panel-select.tsx +4 -0
  53. package/src/components/prototypes/prototype-review-sidebar.tsx +167 -0
  54. package/src/components/prototypes/prototype-share-link-restore.tsx +75 -0
  55. package/src/components/prototypes/prototype-share-mode.tsx +206 -0
  56. package/src/components/prototypes/prototype-spec-panel-content.tsx +538 -0
  57. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +336 -0
  58. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +1023 -0
  59. package/src/components/prototypes/prototype-state-canvas.tsx +38 -0
  60. package/src/components/prototypes/prototype-state-screenshot-preview.tsx +47 -0
  61. package/src/components/prototypes/prototype-target.tsx +120 -0
  62. package/src/components/prototypes/prototype-tweak-highlight.tsx +132 -0
  63. package/src/components/prototypes/prototype-tweaks.tsx +48 -0
  64. package/src/components/prototypes/prototype-variant-explorer.tsx +347 -0
  65. package/src/components/prototypes/prototype-variant-rationale-panel.tsx +49 -0
  66. package/src/components/prototypes/prototype-variant-tabs.tsx +207 -0
  67. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +120 -0
  68. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +89 -0
  69. package/src/components/prototypes/state-map-annotation-tooltip.tsx +137 -0
  70. package/src/components/prototypes/state-map-wireframe-highlight.module.scss +25 -0
  71. package/src/components/prototypes/state-map-wireframe-highlight.tsx +82 -0
  72. package/src/components/shell/prototype-gallery-shell.tsx +79 -0
  73. package/src/components/shell/prototype-screenshot-capture.tsx +45 -0
  74. package/src/components/shell/prototype-shell.module.scss +194 -0
  75. package/src/components/shell/prototype-shell.tsx +160 -0
  76. package/src/components/shell/prototype-state-screenshot-capture.tsx +68 -0
  77. package/src/components/ui/button.tsx +58 -0
  78. package/src/components/ui/dialog.module.scss +81 -0
  79. package/src/components/ui/dialog.tsx +127 -0
  80. package/src/components/ui/dropdown-menu.tsx +200 -0
  81. package/src/components/ui/input.tsx +22 -0
  82. package/src/components/ui/label.tsx +26 -0
  83. package/src/components/ui/option-select.tsx +81 -0
  84. package/src/components/ui/scroll-area.tsx +48 -0
  85. package/src/components/ui/separator.tsx +31 -0
  86. package/src/components/ui/slider.tsx +28 -0
  87. package/src/components/ui/switch.tsx +29 -0
  88. package/src/components/ui/tabs.tsx +55 -0
  89. package/src/components/ui/toggle-group.tsx +80 -0
  90. package/src/components/ui/tooltip.tsx +32 -0
  91. package/src/config/create-prototype-gallery-page.tsx +65 -0
  92. package/src/config/create-prototype-host-app.tsx +121 -0
  93. package/src/config/create-prototype-page.tsx +41 -0
  94. package/src/config/create-prototype-site-layout.tsx +28 -0
  95. package/src/config/define-prototype-config.ts +7 -0
  96. package/src/config/prototype-icon.tsx +25 -0
  97. package/src/config/prototype-site-layout-client.tsx +35 -0
  98. package/src/config/with-prototype.ts +20 -0
  99. package/src/index.ts +303 -0
  100. package/src/lib/pr-split/build-pr-agent-prompt.ts +68 -0
  101. package/src/lib/pr-split/build-pr-split-prototype-url.ts +12 -0
  102. package/src/lib/pr-split/pr-split-highlight.ts +6 -0
  103. package/src/lib/pr-split/pr-split-types.ts +58 -0
  104. package/src/lib/pr-split/resolve-source-preview-url.ts +45 -0
  105. package/src/lib/pr-split/use-pr-vercel-previews.ts +69 -0
  106. package/src/lib/prototype-comments/core/annotation-channel.ts +41 -0
  107. package/src/lib/prototype-comments/core/annotation-status.ts +60 -0
  108. package/src/lib/prototype-comments/core/annotation-target.ts +567 -0
  109. package/src/lib/prototype-comments/core/capture-theme.ts +41 -0
  110. package/src/lib/prototype-comments/core/capture.ts +11 -0
  111. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +47 -0
  112. package/src/lib/prototype-comments/core/comment-threads.ts +52 -0
  113. package/src/lib/prototype-comments/core/element-identification.ts +615 -0
  114. package/src/lib/prototype-comments/core/export.ts +35 -0
  115. package/src/lib/prototype-comments/core/freeze-animations.ts +266 -0
  116. package/src/lib/prototype-comments/core/identify-element.ts +76 -0
  117. package/src/lib/prototype-comments/core/normalize-viewport.ts +35 -0
  118. package/src/lib/prototype-comments/core/react-detection.ts +704 -0
  119. package/src/lib/prototype-comments/core/restore.ts +7 -0
  120. package/src/lib/prototype-comments/core/screenshot.ts +428 -0
  121. package/src/lib/prototype-comments/core/source-location.ts +906 -0
  122. package/src/lib/prototype-comments/core/storage.ts +80 -0
  123. package/src/lib/prototype-comments/core/types.ts +79 -0
  124. package/src/lib/prototype-comments/core/validation.ts +17 -0
  125. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +61 -0
  126. package/src/lib/prototype-comments/index.ts +58 -0
  127. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +1398 -0
  128. package/src/lib/prototype-comments/react/CommentProvider.tsx +298 -0
  129. package/src/lib/prototype-comments/scss.d.ts +10 -0
  130. package/src/lib/prototype-comments/ui/CommentAnnotationOverlay.tsx +253 -0
  131. package/src/lib/prototype-comments/ui/CommentDetailPage.tsx +56 -0
  132. package/src/lib/prototype-comments/ui/CommentHeaderCount.tsx +28 -0
  133. package/src/lib/prototype-comments/ui/CommentThread.tsx +331 -0
  134. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +780 -0
  135. package/src/lib/prototype-comments/ui/CommentsListPage.tsx +25 -0
  136. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +77 -0
  137. package/src/lib/prototype-comments/ui/annotation-marker/index.tsx +200 -0
  138. package/src/lib/prototype-comments/ui/annotation-marker/styles.module.scss +194 -0
  139. package/src/lib/prototype-comments/ui/annotation-popup/index.tsx +335 -0
  140. package/src/lib/prototype-comments/ui/annotation-popup/styles.module.scss +469 -0
  141. package/src/lib/prototype-comments/ui/capture-styles.module.scss +2225 -0
  142. package/src/lib/prototype-comments/ui/comment-highlight.ts +48 -0
  143. package/src/lib/prototype-comments/ui/comment-thread.module.scss +203 -0
  144. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +253 -0
  145. package/src/lib/prototype-comments/ui/comments-sidebar.module.scss +123 -0
  146. package/src/lib/prototype-comments/ui/icon-transitions.module.scss +51 -0
  147. package/src/lib/prototype-comments/ui/icons.tsx +1086 -0
  148. package/src/lib/prototype-comments/ui/is-comment-detail-path.ts +7 -0
  149. package/src/lib/prototypes/capture-prototype-viewport.ts +32 -0
  150. package/src/lib/prototypes/changelog-meta-context.tsx +162 -0
  151. package/src/lib/prototypes/changelog-meta-storage.ts +62 -0
  152. package/src/lib/prototypes/changelog-meta.ts +95 -0
  153. package/src/lib/prototypes/create-prototype-registry.ts +74 -0
  154. package/src/lib/prototypes/create-state-screenshot-thumbnail.ts +46 -0
  155. package/src/lib/prototypes/design-exploration-default-storage.ts +68 -0
  156. package/src/lib/prototypes/design-exploration-default.ts +51 -0
  157. package/src/lib/prototypes/design-exploration-types.ts +203 -0
  158. package/src/lib/prototypes/prototype-comment-registry.tsx +145 -0
  159. package/src/lib/prototypes/prototype-comment-review-bridge.tsx +40 -0
  160. package/src/lib/prototypes/prototype-comment-review-state.ts +57 -0
  161. package/src/lib/prototypes/prototype-component-registry.ts +40 -0
  162. package/src/lib/prototypes/prototype-config-types.ts +34 -0
  163. package/src/lib/prototypes/prototype-preview-state-registry.ts +443 -0
  164. package/src/lib/prototypes/prototype-preview-state-types.ts +89 -0
  165. package/src/lib/prototypes/prototype-review-context.tsx +905 -0
  166. package/src/lib/prototypes/prototype-review-url.ts +134 -0
  167. package/src/lib/prototypes/prototype-share-link.ts +175 -0
  168. package/src/lib/prototypes/prototype-state-canvas-constants.ts +118 -0
  169. package/src/lib/prototypes/prototype-state-canvas-types.ts +159 -0
  170. package/src/lib/prototypes/prototype-state-screenshot.ts +39 -0
  171. package/src/lib/prototypes/prototype-tweak-registry.ts +96 -0
  172. package/src/lib/prototypes/prototype-tweak-types.ts +31 -0
  173. package/src/lib/prototypes/reference-docs.ts +137 -0
  174. package/src/lib/prototypes/screenshot-manifest.ts +33 -0
  175. package/src/lib/prototypes/screenshot-src.ts +7 -0
  176. package/src/lib/prototypes/share-command.ts +8 -0
  177. package/src/lib/prototypes/state-map-annotation.ts +70 -0
  178. package/src/lib/prototypes/use-design-exploration-default.ts +114 -0
  179. package/src/lib/prototypes/use-persisted-local-state.ts +140 -0
  180. package/src/lib/prototypes/use-prototype-comments.ts +63 -0
  181. package/src/lib/prototypes/use-prototype-mobile-viewport-frame.ts +46 -0
  182. package/src/lib/prototypes/use-prototype-state-screenshot-src.ts +71 -0
  183. package/src/lib/prototypes/use-prototype-tool-theme.tsx +115 -0
  184. package/src/lib/prototypes/use-prototype-viewport-frame.ts +34 -0
  185. package/src/lib/prototypes/use-register-prototype-reference-docs.ts +16 -0
  186. package/src/lib/prototypes/use-snap-corner-position.ts +417 -0
  187. package/src/lib/prototypes/use-toolbar-hover-panel.ts +98 -0
  188. package/src/lib/prototypes/variant-set-lucide-icon.tsx +55 -0
  189. package/src/lib/tool-portal.ts +33 -0
  190. package/src/lib/use-copy-to-clipboard.ts +81 -0
  191. package/src/lib/utils.ts +6 -0
  192. package/src/lib/vercel-preview/parse-vercel-github-comment.ts +81 -0
  193. package/src/server/create-host-api-route.ts +100 -0
  194. package/src/server/create-prototype-api-routes.ts +428 -0
  195. package/src/server/gallery/create-gallery-api.ts +56 -0
  196. package/src/server/gallery/folders-route.ts +156 -0
  197. package/src/server/gallery/gallery-env.ts +39 -0
  198. package/src/server/gallery/gallery-route.ts +150 -0
  199. package/src/server/gallery/gallery-types.ts +15 -0
  200. package/src/server/gallery/image-route.ts +59 -0
  201. package/src/server/gallery/item-route.ts +92 -0
  202. package/src/server/pr-vercel-preview-route.ts +162 -0
  203. package/src/server/redis/client.ts +55 -0
  204. package/src/server/redis/prototype-changelog-meta.ts +31 -0
  205. package/src/server/redis/prototype-comments.ts +92 -0
  206. package/src/server/redis/prototype-design-exploration-default.ts +32 -0
  207. package/src/server.ts +51 -0
  208. package/src/styles/globals.css +624 -0
  209. package/tsconfig.json +19 -0
@@ -0,0 +1,52 @@
1
+ import type { CommentAnnotationFields } from "./types";
2
+ import { isVisibleAnnotation } from "./annotation-status";
3
+
4
+ export function isRootAnnotation(
5
+ annotation: Pick<CommentAnnotationFields, "parentId">,
6
+ ): boolean {
7
+ return !annotation.parentId;
8
+ }
9
+
10
+ export function getThreadRootId(
11
+ annotation: Pick<CommentAnnotationFields, "id" | "parentId">,
12
+ ): string {
13
+ return annotation.parentId ?? annotation.id;
14
+ }
15
+
16
+ export function getRepliesForParent<T extends CommentAnnotationFields>(
17
+ annotations: readonly T[],
18
+ parentId: string,
19
+ ): T[] {
20
+ return annotations
21
+ .filter(
22
+ (a) =>
23
+ isVisibleAnnotation(a) &&
24
+ a.parentId === parentId,
25
+ )
26
+ .sort((a, b) => a.timestamp - b.timestamp);
27
+ }
28
+
29
+ export function getRootAnnotations<T extends CommentAnnotationFields>(
30
+ annotations: readonly T[],
31
+ ): T[] {
32
+ return annotations.filter(
33
+ (a) => isVisibleAnnotation(a) && isRootAnnotation(a),
34
+ );
35
+ }
36
+
37
+ export function findAnnotationById<T extends CommentAnnotationFields>(
38
+ annotations: readonly T[],
39
+ id: string,
40
+ ): T | undefined {
41
+ return annotations.find((a) => a.id === id && isVisibleAnnotation(a));
42
+ }
43
+
44
+ /** Resolve selection to the root comment when a reply id is passed. */
45
+ export function resolveThreadRootId<T extends CommentAnnotationFields>(
46
+ annotations: readonly T[],
47
+ id: string,
48
+ ): string {
49
+ const annotation = findAnnotationById(annotations, id);
50
+ if (!annotation) return id;
51
+ return getThreadRootId(annotation);
52
+ }
@@ -0,0 +1,615 @@
1
+ // =============================================================================
2
+ // Element Identification Utilities
3
+ // =============================================================================
4
+
5
+ // =============================================================================
6
+ // Shadow DOM Helpers
7
+ // =============================================================================
8
+
9
+ /**
10
+ * Gets the parent element, crossing shadow DOM boundaries.
11
+ * When inside a shadow root with no parentElement, returns the shadow host.
12
+ */
13
+ function getParentElement(element: Element): Element | null {
14
+ if (element.parentElement) {
15
+ return element.parentElement;
16
+ }
17
+ const root = element.getRootNode();
18
+ if (root instanceof ShadowRoot) {
19
+ return root.host;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Finds the closest ancestor matching a selector, crossing shadow DOM boundaries.
26
+ */
27
+ export function closestCrossingShadow(element: Element, selector: string): Element | null {
28
+ let current: Element | null = element;
29
+ while (current) {
30
+ if (current.matches(selector)) return current;
31
+ current = getParentElement(current);
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Checks if an element is inside a shadow DOM
38
+ */
39
+ export function isInShadowDOM(element: Element): boolean {
40
+ return element.getRootNode() instanceof ShadowRoot;
41
+ }
42
+
43
+ /**
44
+ * Gets the shadow host for an element, or null if not in shadow DOM
45
+ */
46
+ export function getShadowHost(element: Element): Element | null {
47
+ const root = element.getRootNode();
48
+ if (root instanceof ShadowRoot) {
49
+ return root.host;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // =============================================================================
55
+ // Element Path Utilities
56
+ // =============================================================================
57
+
58
+ /**
59
+ * Gets a readable path for an element (e.g., "article > section > p")
60
+ * Supports elements inside shadow DOM by crossing shadow boundaries.
61
+ */
62
+ export function getElementPath(target: HTMLElement, maxDepth = 4): string {
63
+ const parts: string[] = [];
64
+ let current: HTMLElement | null = target;
65
+ let depth = 0;
66
+
67
+ while (current && depth < maxDepth) {
68
+ const tag = current.tagName.toLowerCase();
69
+
70
+ // Skip generic wrappers
71
+ if (tag === "html" || tag === "body") break;
72
+
73
+ // Get identifier
74
+ let identifier = tag;
75
+ if (current.id) {
76
+ identifier = `#${current.id}`;
77
+ } else if (current.className && typeof current.className === "string") {
78
+ const meaningfulClass = current.className
79
+ .split(/\s+/)
80
+ .find(c => c.length > 2 && !c.match(/^[a-z]{1,2}$/) && !c.match(/[A-Z0-9]{5,}/));
81
+ if (meaningfulClass) {
82
+ identifier = `.${meaningfulClass.split("_")[0]}`;
83
+ }
84
+ }
85
+
86
+ // Mark shadow boundary crossings
87
+ const nextParent = getParentElement(current);
88
+ if (!current.parentElement && nextParent) {
89
+ identifier = `⟨shadow⟩ ${identifier}`;
90
+ }
91
+
92
+ parts.unshift(identifier);
93
+ current = nextParent as HTMLElement | null;
94
+ depth++;
95
+ }
96
+
97
+ return parts.join(" > ");
98
+ }
99
+
100
+ /**
101
+ * Identifies an element and returns a human-readable name + path
102
+ */
103
+ export function identifyElement(target: HTMLElement): { name: string; path: string } {
104
+ const path = getElementPath(target);
105
+
106
+ if (target.dataset.element) {
107
+ return { name: target.dataset.element, path };
108
+ }
109
+
110
+ const tag = target.tagName.toLowerCase();
111
+
112
+ // SVG elements
113
+ if (["path", "circle", "rect", "line", "g"].includes(tag)) {
114
+ // Try to find parent SVG context (crossing shadow boundaries)
115
+ const svg = closestCrossingShadow(target, "svg");
116
+ if (svg) {
117
+ const parent = getParentElement(svg);
118
+ if (parent instanceof HTMLElement) {
119
+ const parentName = identifyElement(parent).name;
120
+ return { name: `graphic in ${parentName}`, path };
121
+ }
122
+ }
123
+ return { name: "graphic element", path };
124
+ }
125
+ if (tag === "svg") {
126
+ const parent = getParentElement(target);
127
+ if (parent?.tagName.toLowerCase() === "button") {
128
+ const btnText = parent.textContent?.trim();
129
+ return { name: btnText ? `icon in "${btnText}" button` : "button icon", path };
130
+ }
131
+ return { name: "icon", path };
132
+ }
133
+
134
+ // Interactive elements
135
+ if (tag === "button") {
136
+ const text = target.textContent?.trim();
137
+ const ariaLabel = target.getAttribute("aria-label");
138
+ if (ariaLabel) return { name: `button [${ariaLabel}]`, path };
139
+ return { name: text ? `button "${text.slice(0, 25)}"` : "button", path };
140
+ }
141
+ if (tag === "a") {
142
+ const text = target.textContent?.trim();
143
+ const href = target.getAttribute("href");
144
+ if (text) return { name: `link "${text.slice(0, 25)}"`, path };
145
+ if (href) return { name: `link to ${href.slice(0, 30)}`, path };
146
+ return { name: "link", path };
147
+ }
148
+ if (tag === "input") {
149
+ const type = target.getAttribute("type") || "text";
150
+ const placeholder = target.getAttribute("placeholder");
151
+ const name = target.getAttribute("name");
152
+ if (placeholder) return { name: `input "${placeholder}"`, path };
153
+ if (name) return { name: `input [${name}]`, path };
154
+ return { name: `${type} input`, path };
155
+ }
156
+
157
+ // Headings
158
+ if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) {
159
+ const text = target.textContent?.trim();
160
+ return { name: text ? `${tag} "${text.slice(0, 35)}"` : tag, path };
161
+ }
162
+
163
+ // Text elements
164
+ if (tag === "p") {
165
+ const text = target.textContent?.trim();
166
+ if (text) return { name: `paragraph: "${text.slice(0, 40)}${text.length > 40 ? '...' : ''}"`, path };
167
+ return { name: "paragraph", path };
168
+ }
169
+ if (tag === "span" || tag === "label") {
170
+ const text = target.textContent?.trim();
171
+ if (text && text.length < 40) return { name: `"${text}"`, path };
172
+ return { name: tag, path };
173
+ }
174
+ if (tag === "li") {
175
+ const text = target.textContent?.trim();
176
+ if (text && text.length < 40) return { name: `list item: "${text.slice(0, 35)}"`, path };
177
+ return { name: "list item", path };
178
+ }
179
+ if (tag === "blockquote") return { name: "blockquote", path };
180
+ if (tag === "code") {
181
+ const text = target.textContent?.trim();
182
+ if (text && text.length < 30) return { name: `code: \`${text}\``, path };
183
+ return { name: "code", path };
184
+ }
185
+ if (tag === "pre") return { name: "code block", path };
186
+
187
+ // Media
188
+ if (tag === "img") {
189
+ const alt = target.getAttribute("alt");
190
+ return { name: alt ? `image "${alt.slice(0, 30)}"` : "image", path };
191
+ }
192
+ if (tag === "video") return { name: "video", path };
193
+
194
+ // Containers - try to infer meaningful name
195
+ if (["div", "section", "article", "nav", "header", "footer", "aside", "main"].includes(tag)) {
196
+ const className = target.className;
197
+ const role = target.getAttribute("role");
198
+ const ariaLabel = target.getAttribute("aria-label");
199
+
200
+ if (ariaLabel) return { name: `${tag} [${ariaLabel}]`, path };
201
+ if (role) return { name: `${role}`, path };
202
+
203
+ if (typeof className === "string" && className) {
204
+ const words = className
205
+ .split(/[\s_-]+/)
206
+ .map((c) => c.replace(/[A-Z0-9]{5,}.*$/, "")) // Remove CSS module hashes
207
+ .filter((c) => c.length > 2 && !/^[a-z]{1,2}$/.test(c))
208
+ .slice(0, 2);
209
+ if (words.length > 0) return { name: words.join(" "), path };
210
+ }
211
+
212
+ return { name: tag === "div" ? "container" : tag, path };
213
+ }
214
+
215
+ return { name: tag, path };
216
+ }
217
+
218
+ /**
219
+ * Gets text content from element and siblings for context
220
+ */
221
+ export function getNearbyText(element: HTMLElement): string {
222
+ const texts: string[] = [];
223
+
224
+ // Own text
225
+ const ownText = element.textContent?.trim();
226
+ if (ownText && ownText.length < 100) {
227
+ texts.push(ownText);
228
+ }
229
+
230
+ // Previous sibling text
231
+ const prev = element.previousElementSibling;
232
+ if (prev) {
233
+ const prevText = prev.textContent?.trim();
234
+ if (prevText && prevText.length < 50) {
235
+ texts.unshift(`[before: "${prevText.slice(0, 40)}"]`);
236
+ }
237
+ }
238
+
239
+ // Next sibling text
240
+ const next = element.nextElementSibling;
241
+ if (next) {
242
+ const nextText = next.textContent?.trim();
243
+ if (nextText && nextText.length < 50) {
244
+ texts.push(`[after: "${nextText.slice(0, 40)}"]`);
245
+ }
246
+ }
247
+
248
+ return texts.join(" ");
249
+ }
250
+
251
+ /**
252
+ * Simplified element identifier for animation feedback (less verbose)
253
+ */
254
+ export function identifyAnimationElement(target: HTMLElement): string {
255
+ // Allow explicit labeling via data attribute
256
+ if (target.dataset.element) return target.dataset.element;
257
+
258
+ const tag = target.tagName.toLowerCase();
259
+
260
+ // SVG elements
261
+ if (tag === "path") return "path";
262
+ if (tag === "circle") return "circle";
263
+ if (tag === "rect") return "rectangle";
264
+ if (tag === "line") return "line";
265
+ if (tag === "ellipse") return "ellipse";
266
+ if (tag === "polygon") return "polygon";
267
+ if (tag === "g") return "group";
268
+ if (tag === "svg") return "svg";
269
+
270
+ // Interactive elements
271
+ if (tag === "button") {
272
+ const text = target.textContent?.trim();
273
+ return text ? `button "${text}"` : "button";
274
+ }
275
+ if (tag === "input") {
276
+ const type = target.getAttribute("type") || "text";
277
+ return `input (${type})`;
278
+ }
279
+
280
+ // Text elements
281
+ if (tag === "span" || tag === "p" || tag === "label") {
282
+ const text = target.textContent?.trim();
283
+ if (text && text.length < 30) return `"${text}"`;
284
+ return "text";
285
+ }
286
+
287
+ // Containers - try to infer purpose from class name
288
+ if (tag === "div") {
289
+ const className = target.className;
290
+ if (typeof className === "string" && className) {
291
+ const words = className
292
+ .split(/[\s_-]+/)
293
+ .map(c => c.replace(/[A-Z0-9]{5,}.*$/, ""))
294
+ .filter(c => c.length > 2 && !/^[a-z]{1,2}$/.test(c))
295
+ .slice(0, 2);
296
+ if (words.length > 0) {
297
+ return words.join(" ");
298
+ }
299
+ }
300
+ return "container";
301
+ }
302
+
303
+ return tag;
304
+ }
305
+
306
+ /**
307
+ * Gets nearby sibling elements for structural context.
308
+ * Supports elements inside shadow DOM.
309
+ */
310
+ export function getNearbyElements(element: HTMLElement): string {
311
+ const parent = getParentElement(element);
312
+ if (!parent) return "";
313
+
314
+ // Get siblings from the correct source
315
+ const elementRoot = element.getRootNode();
316
+ const children = (elementRoot instanceof ShadowRoot && element.parentElement)
317
+ ? Array.from(element.parentElement.children)
318
+ : Array.from(parent.children);
319
+
320
+ const siblings = children.filter(
321
+ (child) => child !== element && child instanceof HTMLElement
322
+ ) as HTMLElement[];
323
+
324
+ if (siblings.length === 0) return "";
325
+
326
+ // Get concise identifiers for up to 4 nearby siblings
327
+ const siblingIds = siblings.slice(0, 4).map((sib) => {
328
+ const tag = sib.tagName.toLowerCase();
329
+ const className = sib.className;
330
+
331
+ // Get first meaningful class
332
+ let cls = "";
333
+ if (typeof className === "string" && className) {
334
+ const meaningful = className
335
+ .split(/\s+/)
336
+ .map((c) => c.replace(/[_][a-zA-Z0-9]{5,}.*$/, "")) // Remove module hashes
337
+ .find((c) => c.length > 2 && !/^[a-z]{1,2}$/.test(c));
338
+ if (meaningful) cls = `.${meaningful}`;
339
+ }
340
+
341
+ // For buttons/links, include short text
342
+ if (tag === "button" || tag === "a") {
343
+ const text = sib.textContent?.trim().slice(0, 15);
344
+ if (text) return `${tag}${cls} "${text}"`;
345
+ }
346
+
347
+ return `${tag}${cls}`;
348
+ });
349
+
350
+ // Add parent context
351
+ const parentTag = parent.tagName.toLowerCase();
352
+ let parentId = parentTag;
353
+ if (typeof parent.className === "string" && parent.className) {
354
+ const parentCls = parent.className
355
+ .split(/\s+/)
356
+ .map((c) => c.replace(/[_][a-zA-Z0-9]{5,}.*$/, ""))
357
+ .find((c) => c.length > 2 && !/^[a-z]{1,2}$/.test(c));
358
+ if (parentCls) parentId = `.${parentCls}`;
359
+ }
360
+
361
+ const total = parent.children.length;
362
+ const suffix = total > siblingIds.length + 1 ? ` (${total} total in ${parentId})` : "";
363
+
364
+ return siblingIds.join(", ") + suffix;
365
+ }
366
+
367
+ /**
368
+ * Gets CSS class names from an element (cleaned of module hashes)
369
+ */
370
+ export function getElementClasses(target: HTMLElement): string {
371
+ const className = target.className;
372
+ if (typeof className !== "string" || !className) return "";
373
+
374
+ // Split and clean class names (remove module hashes like _abc123)
375
+ const classes = className
376
+ .split(/\s+/)
377
+ .filter(c => c.length > 0)
378
+ .map(c => {
379
+ // Keep the meaningful part before the hash
380
+ const match = c.match(/^([a-zA-Z][a-zA-Z0-9_-]*?)(?:_[a-zA-Z0-9]{5,})?$/);
381
+ return match ? match[1] : c;
382
+ })
383
+ .filter((c, i, arr) => arr.indexOf(c) === i); // dedupe
384
+
385
+ return classes.join(", ");
386
+ }
387
+
388
+ /**
389
+ * Gets key computed styles for an element (useful for styling issues)
390
+ */
391
+ export function getComputedStylesSnapshot(target: HTMLElement): string {
392
+ if (typeof window === "undefined") return "";
393
+
394
+ const styles = window.getComputedStyle(target);
395
+ const parts: string[] = [];
396
+
397
+ // Color & text
398
+ const color = styles.color;
399
+ const bg = styles.backgroundColor;
400
+ if (color && color !== "rgb(0, 0, 0)") parts.push(`color: ${color}`);
401
+ if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") parts.push(`bg: ${bg}`);
402
+
403
+ // Typography
404
+ const fontSize = styles.fontSize;
405
+ const fontWeight = styles.fontWeight;
406
+ if (fontSize) parts.push(`font: ${fontSize}`);
407
+ if (fontWeight && fontWeight !== "400" && fontWeight !== "normal") parts.push(`weight: ${fontWeight}`);
408
+
409
+ // Spacing
410
+ const padding = styles.padding;
411
+ const margin = styles.margin;
412
+ if (padding && padding !== "0px") parts.push(`padding: ${padding}`);
413
+ if (margin && margin !== "0px") parts.push(`margin: ${margin}`);
414
+
415
+ // Layout
416
+ const display = styles.display;
417
+ const position = styles.position;
418
+ if (display && display !== "block" && display !== "inline") parts.push(`display: ${display}`);
419
+ if (position && position !== "static") parts.push(`position: ${position}`);
420
+
421
+ // Border
422
+ const borderRadius = styles.borderRadius;
423
+ if (borderRadius && borderRadius !== "0px") parts.push(`radius: ${borderRadius}`);
424
+
425
+ return parts.join(", ");
426
+ }
427
+
428
+ // Values to filter out when collecting computed styles (browser defaults / uninteresting)
429
+ const DEFAULT_STYLE_VALUES = new Set([
430
+ "none", "normal", "auto", "0px", "rgba(0, 0, 0, 0)", "transparent", "static", "visible"
431
+ ]);
432
+
433
+ // Element type categories for style property selection
434
+ const TEXT_ELEMENTS = new Set([
435
+ "p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "li", "td", "th",
436
+ "blockquote", "figcaption", "caption", "legend", "dt", "dd", "pre", "code",
437
+ "em", "strong", "b", "i", "a", "time", "cite", "q"
438
+ ]);
439
+ const FORM_INPUT_ELEMENTS = new Set(["input", "textarea", "select"]);
440
+ const MEDIA_ELEMENTS = new Set(["img", "video", "canvas", "svg"]);
441
+ const CONTAINER_ELEMENTS = new Set([
442
+ "div", "section", "article", "nav", "header", "footer", "aside", "main",
443
+ "ul", "ol", "form", "fieldset"
444
+ ]);
445
+
446
+ /**
447
+ * Gets key computed styles for the annotation popup display.
448
+ * Returns different properties based on element type to show the most relevant
449
+ * CSS properties for debugging (e.g., typography for text, layout for containers).
450
+ */
451
+ export function getDetailedComputedStyles(target: HTMLElement): Record<string, string> {
452
+ if (typeof window === "undefined") return {};
453
+
454
+ const styles = window.getComputedStyle(target);
455
+ const result: Record<string, string> = {};
456
+ const tag = target.tagName.toLowerCase();
457
+
458
+ // Select relevant properties based on element type
459
+ let properties: string[];
460
+
461
+ if (TEXT_ELEMENTS.has(tag)) {
462
+ // Typography-focused for text elements
463
+ properties = ["color", "fontSize", "fontWeight", "fontFamily", "lineHeight"];
464
+ } else if (tag === "button" || (tag === "a" && target.getAttribute("role") === "button")) {
465
+ // Appearance and spacing for interactive elements
466
+ properties = ["backgroundColor", "color", "padding", "borderRadius", "fontSize"];
467
+ } else if (FORM_INPUT_ELEMENTS.has(tag)) {
468
+ // Form styling
469
+ properties = ["backgroundColor", "color", "padding", "borderRadius", "fontSize"];
470
+ } else if (MEDIA_ELEMENTS.has(tag)) {
471
+ // Dimensions for media
472
+ properties = ["width", "height", "objectFit", "borderRadius"];
473
+ } else if (CONTAINER_ELEMENTS.has(tag)) {
474
+ // Layout-focused for containers
475
+ properties = ["display", "padding", "margin", "gap", "backgroundColor"];
476
+ } else {
477
+ // Default fallback
478
+ properties = ["color", "fontSize", "margin", "padding", "backgroundColor"];
479
+ }
480
+
481
+ for (const prop of properties) {
482
+ const cssPropertyName = prop.replace(/([A-Z])/g, "-$1").toLowerCase();
483
+ const value = styles.getPropertyValue(cssPropertyName);
484
+ if (value && !DEFAULT_STYLE_VALUES.has(value)) {
485
+ result[prop] = value;
486
+ }
487
+ }
488
+
489
+ return result;
490
+ }
491
+
492
+ // Comprehensive list of CSS properties for forensic output
493
+ const FORENSIC_PROPERTIES = [
494
+ // Colors
495
+ "color", "backgroundColor", "borderColor",
496
+ // Typography
497
+ "fontSize", "fontWeight", "fontFamily", "lineHeight", "letterSpacing", "textAlign",
498
+ // Box model
499
+ "width", "height", "padding", "margin", "border", "borderRadius",
500
+ // Layout & positioning
501
+ "display", "position", "top", "right", "bottom", "left", "zIndex",
502
+ "flexDirection", "justifyContent", "alignItems", "gap",
503
+ // Visual effects
504
+ "opacity", "visibility", "overflow", "boxShadow",
505
+ // Transform
506
+ "transform",
507
+ ];
508
+
509
+ /**
510
+ * Gets full computed styles for forensic output.
511
+ * Returns a comprehensive semicolon-separated string of all relevant CSS properties
512
+ * for maximum debugging detail in the forensic output format.
513
+ */
514
+ export function getForensicComputedStyles(target: HTMLElement): string {
515
+ if (typeof window === "undefined") return "";
516
+
517
+ const styles = window.getComputedStyle(target);
518
+ const parts: string[] = [];
519
+
520
+ for (const prop of FORENSIC_PROPERTIES) {
521
+ const cssPropertyName = prop.replace(/([A-Z])/g, "-$1").toLowerCase();
522
+ const value = styles.getPropertyValue(cssPropertyName);
523
+ if (value && !DEFAULT_STYLE_VALUES.has(value)) {
524
+ parts.push(`${cssPropertyName}: ${value}`);
525
+ }
526
+ }
527
+
528
+ return parts.join("; ");
529
+ }
530
+
531
+ /**
532
+ * Parses a forensic computed styles string back into a Record.
533
+ * Inverse of getForensicComputedStyles - used when editing annotations.
534
+ */
535
+ export function parseComputedStylesString(
536
+ stylesStr: string | undefined,
537
+ ): Record<string, string> | undefined {
538
+ if (!stylesStr) return undefined;
539
+
540
+ const result: Record<string, string> = {};
541
+ const parts = stylesStr.split(";").map((p) => p.trim()).filter(Boolean);
542
+
543
+ for (const part of parts) {
544
+ const colonIndex = part.indexOf(":");
545
+ if (colonIndex > 0) {
546
+ const key = part.slice(0, colonIndex).trim();
547
+ const value = part.slice(colonIndex + 1).trim();
548
+ if (key && value) {
549
+ result[key] = value;
550
+ }
551
+ }
552
+ }
553
+
554
+ return Object.keys(result).length > 0 ? result : undefined;
555
+ }
556
+
557
+ /**
558
+ * Gets accessibility information for an element
559
+ */
560
+ export function getAccessibilityInfo(target: HTMLElement): string {
561
+ const parts: string[] = [];
562
+
563
+ const role = target.getAttribute("role");
564
+ const ariaLabel = target.getAttribute("aria-label");
565
+ const ariaDescribedBy = target.getAttribute("aria-describedby");
566
+ const tabIndex = target.getAttribute("tabindex");
567
+ const ariaHidden = target.getAttribute("aria-hidden");
568
+
569
+ if (role) parts.push(`role="${role}"`);
570
+ if (ariaLabel) parts.push(`aria-label="${ariaLabel}"`);
571
+ if (ariaDescribedBy) parts.push(`aria-describedby="${ariaDescribedBy}"`);
572
+ if (tabIndex) parts.push(`tabindex=${tabIndex}`);
573
+ if (ariaHidden === "true") parts.push("aria-hidden");
574
+
575
+ // Check focusability
576
+ const focusable = target.matches("a, button, input, select, textarea, [tabindex]");
577
+ if (focusable) parts.push("focusable");
578
+
579
+ return parts.join(", ");
580
+ }
581
+
582
+ /**
583
+ * Gets full DOM ancestry path (for forensic mode).
584
+ * Supports elements inside shadow DOM by marking shadow boundary crossings.
585
+ */
586
+ export function getFullElementPath(target: HTMLElement): string {
587
+ const parts: string[] = [];
588
+ let current: HTMLElement | null = target;
589
+
590
+ while (current && current.tagName.toLowerCase() !== "html") {
591
+ const tag = current.tagName.toLowerCase();
592
+ let identifier = tag;
593
+
594
+ if (current.id) {
595
+ identifier = `${tag}#${current.id}`;
596
+ } else if (current.className && typeof current.className === "string") {
597
+ const cls = current.className
598
+ .split(/\s+/)
599
+ .map(c => c.replace(/[_][a-zA-Z0-9]{5,}.*$/, ""))
600
+ .find(c => c.length > 2);
601
+ if (cls) identifier = `${tag}.${cls}`;
602
+ }
603
+
604
+ // Mark shadow boundary crossings
605
+ const nextParent = getParentElement(current);
606
+ if (!current.parentElement && nextParent) {
607
+ identifier = `⟨shadow⟩ ${identifier}`;
608
+ }
609
+
610
+ parts.unshift(identifier);
611
+ current = nextParent as HTMLElement | null;
612
+ }
613
+
614
+ return parts.join(" > ");
615
+ }
@@ -0,0 +1,35 @@
1
+ import type { CommentAnnotation } from "./types";
2
+
3
+ export function formatAnnotationWithState<TState>(
4
+ annotation: CommentAnnotation<TState>,
5
+ formatState?: (state: TState & { capturedAt: string; route: string }) => string,
6
+ ): string {
7
+ const lines = [
8
+ `### ${annotation.element}: ${annotation.comment}`,
9
+ "",
10
+ `- **Route:** ${annotation.interactionState.route}`,
11
+ `- **Captured at:** ${annotation.interactionState.capturedAt}`,
12
+ ];
13
+
14
+ if (annotation.screenshot) {
15
+ lines.push("", "Screenshot captured at comment time (base64 JPEG attached to annotation).");
16
+ }
17
+
18
+ if (formatState) {
19
+ lines.push("", formatState(annotation.interactionState));
20
+ } else {
21
+ lines.push("", "```json", JSON.stringify(annotation.interactionState, null, 2), "```");
22
+ }
23
+
24
+ return lines.join("\n");
25
+ }
26
+
27
+ export function formatAllAnnotations<TState>(
28
+ annotations: CommentAnnotation<TState>[],
29
+ formatState?: (state: TState & { capturedAt: string; route: string }) => string,
30
+ ): string {
31
+ if (annotations.length === 0) return "";
32
+ return annotations
33
+ .map((annotation) => formatAnnotationWithState(annotation, formatState))
34
+ .join("\n\n---\n\n");
35
+ }