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.
- package/AGENTS.md +350 -0
- package/README.md +279 -0
- package/components.json +18 -0
- package/package.json +73 -0
- package/scripts/download-mobbin-reference-images.mjs +112 -0
- package/scripts/lib/host-config.example.sh +48 -0
- package/scripts/lib/host-config.sh +25 -0
- package/scripts/lib/resolve-host-root.mjs +18 -0
- package/scripts/lib/source-path.sh +49 -0
- package/scripts/link-source-db.sh +128 -0
- package/scripts/link-source.sh +40 -0
- package/scripts/publish-package.sh +147 -0
- package/scripts/share-prototype.sh +279 -0
- package/scripts/sync-from-source.sh +74 -0
- package/scripts/verify-prototype-component-ids.mjs +151 -0
- package/scripts/verify-prototype-preview-states.mjs +135 -0
- package/src/app.ts +4 -0
- package/src/components/platform-ui/code-block.tsx +21 -0
- package/src/components/platform-ui/controls-panel-options.tsx +71 -0
- package/src/components/platform-ui/controls-panel-select.tsx +438 -0
- package/src/components/platform-ui/empty-state.tsx +15 -0
- package/src/components/platform-ui/icon-button.tsx +18 -0
- package/src/components/platform-ui/panel-select.tsx +123 -0
- package/src/components/platform-ui/panel.tsx +97 -0
- package/src/components/platform-ui/review-dropdown-menu.tsx +35 -0
- package/src/components/platform-ui/sidebar.tsx +293 -0
- package/src/components/platform-ui/toolbar-icon-button.tsx +34 -0
- package/src/components/platform-ui/toolbar.tsx +37 -0
- package/src/components/prototype-gallery-client.tsx +57 -0
- package/src/components/prototype-provider.tsx +20 -0
- package/src/components/prototypes/mini-pill-label.tsx +37 -0
- package/src/components/prototypes/prototype-change-log-panel.tsx +207 -0
- package/src/components/prototypes/prototype-comment-provider.tsx +61 -0
- package/src/components/prototypes/prototype-comment-toolbar.tsx +65 -0
- package/src/components/prototypes/prototype-control.tsx +31 -0
- package/src/components/prototypes/prototype-controls.tsx +553 -0
- package/src/components/prototypes/prototype-create-exploration-modal.tsx +117 -0
- package/src/components/prototypes/prototype-create-state-modal.tsx +117 -0
- package/src/components/prototypes/prototype-default-state-map-page.tsx +66 -0
- package/src/components/prototypes/prototype-design-brief-modal.tsx +128 -0
- package/src/components/prototypes/prototype-design-brief-panel.tsx +525 -0
- package/src/components/prototypes/prototype-design-context-panel.tsx +65 -0
- package/src/components/prototypes/prototype-floating-pill.module.scss +1341 -0
- package/src/components/prototypes/prototype-mobbin-gallery.tsx +330 -0
- package/src/components/prototypes/prototype-reference-docs.tsx +222 -0
- package/src/components/prototypes/prototype-review-chrome.module.scss +630 -0
- package/src/components/prototypes/prototype-review-chrome.tsx +1241 -0
- package/src/components/prototypes/prototype-review-dropdown-menu-item.tsx +1 -0
- package/src/components/prototypes/prototype-review-dropdown-menu-separator.tsx +1 -0
- package/src/components/prototypes/prototype-review-panel-menu-item.tsx +1 -0
- package/src/components/prototypes/prototype-review-panel-menu-separator.tsx +1 -0
- package/src/components/prototypes/prototype-review-panel-select.tsx +4 -0
- package/src/components/prototypes/prototype-review-sidebar.tsx +167 -0
- package/src/components/prototypes/prototype-share-link-restore.tsx +75 -0
- package/src/components/prototypes/prototype-share-mode.tsx +206 -0
- package/src/components/prototypes/prototype-spec-panel-content.tsx +538 -0
- package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +336 -0
- package/src/components/prototypes/prototype-state-canvas-overlay.tsx +1023 -0
- package/src/components/prototypes/prototype-state-canvas.tsx +38 -0
- package/src/components/prototypes/prototype-state-screenshot-preview.tsx +47 -0
- package/src/components/prototypes/prototype-target.tsx +120 -0
- package/src/components/prototypes/prototype-tweak-highlight.tsx +132 -0
- package/src/components/prototypes/prototype-tweaks.tsx +48 -0
- package/src/components/prototypes/prototype-variant-explorer.tsx +347 -0
- package/src/components/prototypes/prototype-variant-rationale-panel.tsx +49 -0
- package/src/components/prototypes/prototype-variant-tabs.tsx +207 -0
- package/src/components/prototypes/prototype-vercel-preview-modal.tsx +120 -0
- package/src/components/prototypes/state-map-annotation-tooltip.module.scss +89 -0
- package/src/components/prototypes/state-map-annotation-tooltip.tsx +137 -0
- package/src/components/prototypes/state-map-wireframe-highlight.module.scss +25 -0
- package/src/components/prototypes/state-map-wireframe-highlight.tsx +82 -0
- package/src/components/shell/prototype-gallery-shell.tsx +79 -0
- package/src/components/shell/prototype-screenshot-capture.tsx +45 -0
- package/src/components/shell/prototype-shell.module.scss +194 -0
- package/src/components/shell/prototype-shell.tsx +160 -0
- package/src/components/shell/prototype-state-screenshot-capture.tsx +68 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/dialog.module.scss +81 -0
- package/src/components/ui/dialog.tsx +127 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/option-select.tsx +81 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/slider.tsx +28 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/toggle-group.tsx +80 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/config/create-prototype-gallery-page.tsx +65 -0
- package/src/config/create-prototype-host-app.tsx +121 -0
- package/src/config/create-prototype-page.tsx +41 -0
- package/src/config/create-prototype-site-layout.tsx +28 -0
- package/src/config/define-prototype-config.ts +7 -0
- package/src/config/prototype-icon.tsx +25 -0
- package/src/config/prototype-site-layout-client.tsx +35 -0
- package/src/config/with-prototype.ts +20 -0
- package/src/index.ts +303 -0
- package/src/lib/pr-split/build-pr-agent-prompt.ts +68 -0
- package/src/lib/pr-split/build-pr-split-prototype-url.ts +12 -0
- package/src/lib/pr-split/pr-split-highlight.ts +6 -0
- package/src/lib/pr-split/pr-split-types.ts +58 -0
- package/src/lib/pr-split/resolve-source-preview-url.ts +45 -0
- package/src/lib/pr-split/use-pr-vercel-previews.ts +69 -0
- package/src/lib/prototype-comments/core/annotation-channel.ts +41 -0
- package/src/lib/prototype-comments/core/annotation-status.ts +60 -0
- package/src/lib/prototype-comments/core/annotation-target.ts +567 -0
- package/src/lib/prototype-comments/core/capture-theme.ts +41 -0
- package/src/lib/prototype-comments/core/capture.ts +11 -0
- package/src/lib/prototype-comments/core/comment-capture-blocked.ts +47 -0
- package/src/lib/prototype-comments/core/comment-threads.ts +52 -0
- package/src/lib/prototype-comments/core/element-identification.ts +615 -0
- package/src/lib/prototype-comments/core/export.ts +35 -0
- package/src/lib/prototype-comments/core/freeze-animations.ts +266 -0
- package/src/lib/prototype-comments/core/identify-element.ts +76 -0
- package/src/lib/prototype-comments/core/normalize-viewport.ts +35 -0
- package/src/lib/prototype-comments/core/react-detection.ts +704 -0
- package/src/lib/prototype-comments/core/restore.ts +7 -0
- package/src/lib/prototype-comments/core/screenshot.ts +428 -0
- package/src/lib/prototype-comments/core/source-location.ts +906 -0
- package/src/lib/prototype-comments/core/storage.ts +80 -0
- package/src/lib/prototype-comments/core/types.ts +79 -0
- package/src/lib/prototype-comments/core/validation.ts +17 -0
- package/src/lib/prototype-comments/hooks/useLayoutShift.ts +61 -0
- package/src/lib/prototype-comments/index.ts +58 -0
- package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +1398 -0
- package/src/lib/prototype-comments/react/CommentProvider.tsx +298 -0
- package/src/lib/prototype-comments/scss.d.ts +10 -0
- package/src/lib/prototype-comments/ui/CommentAnnotationOverlay.tsx +253 -0
- package/src/lib/prototype-comments/ui/CommentDetailPage.tsx +56 -0
- package/src/lib/prototype-comments/ui/CommentHeaderCount.tsx +28 -0
- package/src/lib/prototype-comments/ui/CommentThread.tsx +331 -0
- package/src/lib/prototype-comments/ui/CommentsGrid.tsx +780 -0
- package/src/lib/prototype-comments/ui/CommentsListPage.tsx +25 -0
- package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +77 -0
- package/src/lib/prototype-comments/ui/annotation-marker/index.tsx +200 -0
- package/src/lib/prototype-comments/ui/annotation-marker/styles.module.scss +194 -0
- package/src/lib/prototype-comments/ui/annotation-popup/index.tsx +335 -0
- package/src/lib/prototype-comments/ui/annotation-popup/styles.module.scss +469 -0
- package/src/lib/prototype-comments/ui/capture-styles.module.scss +2225 -0
- package/src/lib/prototype-comments/ui/comment-highlight.ts +48 -0
- package/src/lib/prototype-comments/ui/comment-thread.module.scss +203 -0
- package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +253 -0
- package/src/lib/prototype-comments/ui/comments-sidebar.module.scss +123 -0
- package/src/lib/prototype-comments/ui/icon-transitions.module.scss +51 -0
- package/src/lib/prototype-comments/ui/icons.tsx +1086 -0
- package/src/lib/prototype-comments/ui/is-comment-detail-path.ts +7 -0
- package/src/lib/prototypes/capture-prototype-viewport.ts +32 -0
- package/src/lib/prototypes/changelog-meta-context.tsx +162 -0
- package/src/lib/prototypes/changelog-meta-storage.ts +62 -0
- package/src/lib/prototypes/changelog-meta.ts +95 -0
- package/src/lib/prototypes/create-prototype-registry.ts +74 -0
- package/src/lib/prototypes/create-state-screenshot-thumbnail.ts +46 -0
- package/src/lib/prototypes/design-exploration-default-storage.ts +68 -0
- package/src/lib/prototypes/design-exploration-default.ts +51 -0
- package/src/lib/prototypes/design-exploration-types.ts +203 -0
- package/src/lib/prototypes/prototype-comment-registry.tsx +145 -0
- package/src/lib/prototypes/prototype-comment-review-bridge.tsx +40 -0
- package/src/lib/prototypes/prototype-comment-review-state.ts +57 -0
- package/src/lib/prototypes/prototype-component-registry.ts +40 -0
- package/src/lib/prototypes/prototype-config-types.ts +34 -0
- package/src/lib/prototypes/prototype-preview-state-registry.ts +443 -0
- package/src/lib/prototypes/prototype-preview-state-types.ts +89 -0
- package/src/lib/prototypes/prototype-review-context.tsx +905 -0
- package/src/lib/prototypes/prototype-review-url.ts +134 -0
- package/src/lib/prototypes/prototype-share-link.ts +175 -0
- package/src/lib/prototypes/prototype-state-canvas-constants.ts +118 -0
- package/src/lib/prototypes/prototype-state-canvas-types.ts +159 -0
- package/src/lib/prototypes/prototype-state-screenshot.ts +39 -0
- package/src/lib/prototypes/prototype-tweak-registry.ts +96 -0
- package/src/lib/prototypes/prototype-tweak-types.ts +31 -0
- package/src/lib/prototypes/reference-docs.ts +137 -0
- package/src/lib/prototypes/screenshot-manifest.ts +33 -0
- package/src/lib/prototypes/screenshot-src.ts +7 -0
- package/src/lib/prototypes/share-command.ts +8 -0
- package/src/lib/prototypes/state-map-annotation.ts +70 -0
- package/src/lib/prototypes/use-design-exploration-default.ts +114 -0
- package/src/lib/prototypes/use-persisted-local-state.ts +140 -0
- package/src/lib/prototypes/use-prototype-comments.ts +63 -0
- package/src/lib/prototypes/use-prototype-mobile-viewport-frame.ts +46 -0
- package/src/lib/prototypes/use-prototype-state-screenshot-src.ts +71 -0
- package/src/lib/prototypes/use-prototype-tool-theme.tsx +115 -0
- package/src/lib/prototypes/use-prototype-viewport-frame.ts +34 -0
- package/src/lib/prototypes/use-register-prototype-reference-docs.ts +16 -0
- package/src/lib/prototypes/use-snap-corner-position.ts +417 -0
- package/src/lib/prototypes/use-toolbar-hover-panel.ts +98 -0
- package/src/lib/prototypes/variant-set-lucide-icon.tsx +55 -0
- package/src/lib/tool-portal.ts +33 -0
- package/src/lib/use-copy-to-clipboard.ts +81 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/vercel-preview/parse-vercel-github-comment.ts +81 -0
- package/src/server/create-host-api-route.ts +100 -0
- package/src/server/create-prototype-api-routes.ts +428 -0
- package/src/server/gallery/create-gallery-api.ts +56 -0
- package/src/server/gallery/folders-route.ts +156 -0
- package/src/server/gallery/gallery-env.ts +39 -0
- package/src/server/gallery/gallery-route.ts +150 -0
- package/src/server/gallery/gallery-types.ts +15 -0
- package/src/server/gallery/image-route.ts +59 -0
- package/src/server/gallery/item-route.ts +92 -0
- package/src/server/pr-vercel-preview-route.ts +162 -0
- package/src/server/redis/client.ts +55 -0
- package/src/server/redis/prototype-changelog-meta.ts +31 -0
- package/src/server/redis/prototype-comments.ts +92 -0
- package/src/server/redis/prototype-design-exploration-default.ts +32 -0
- package/src/server.ts +51 -0
- package/src/styles/globals.css +624 -0
- 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
|
+
}
|