diff --git a/.gitignore b/.gitignore index 49792697..29320e07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/.claude/settings.local.json node_modules mcp/dist +extension/dist .DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 357c1589..98b9517f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ The toolbar appears in the bottom-right corner. Click to activate, then click an Agentation captures class names, selectors, and element positions so AI agents can `grep` for the exact code you're referring to. Instead of describing "the blue button in the sidebar," you give the agent `.sidebar > button.primary` and your feedback. +## Chrome Extension + +Use Agentation on any localhost page without adding it to your project. See [extension/README.md](./extension/README.md) for install instructions. + ## Requirements - React 18+ diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 00000000..12df2a39 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,16 @@ +# Chrome Extension + +Use Agentation on any localhost page without adding it to your project. + +## Install from release + +Download `agentation-extension.zip` from [Releases](https://github.com/benjitaylor/agentation/releases), unzip, then: +`chrome://extensions` → Developer mode → Load unpacked → select the folder. + +## Build from source + +```bash +pnpm extension:build +``` + +Then load `extension/` as an unpacked extension. diff --git a/extension/build.mjs b/extension/build.mjs new file mode 100644 index 00000000..7faa9ddf --- /dev/null +++ b/extension/build.mjs @@ -0,0 +1,111 @@ +import * as esbuild from "esbuild"; +import * as sass from "sass"; +import postcss from "postcss"; +import postcssModules from "postcss-modules"; +import * as path from "path"; +import * as fs from "fs"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const watch = process.argv.includes("--watch"); + +const pkg = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../package/package.json"), "utf-8") +); +const VERSION = pkg.version; + +// SCSS CSS Modules plugin — mirrors package/tsup.config.ts exactly +function scssModulesPlugin() { + return { + name: "scss-modules", + setup(build) { + build.onLoad({ filter: /\.scss$/ }, async (args) => { + const isModule = args.path.includes(".module."); + const parentDir = path.basename(path.dirname(args.path)); + const baseName = path.basename( + args.path, + isModule ? ".module.scss" : ".scss" + ); + const styleId = `${parentDir}-${baseName}`; + + const result = sass.compile(args.path); + let css = result.css; + + if (isModule) { + let classNames = {}; + const postcssResult = await postcss([ + postcssModules({ + getJSON(cssFileName, json) { + classNames = json; + }, + generateScopedName: "[name]__[local]___[hash:base64:5]", + }), + ]).process(css, { from: args.path }); + css = postcssResult.css; + + return { + contents: ` +const css = ${JSON.stringify(css)}; +const classNames = ${JSON.stringify(classNames)}; +if (typeof document !== 'undefined') { + let style = document.getElementById('feedback-tool-styles-${styleId}'); + if (!style) { + style = document.createElement('style'); + style.id = 'feedback-tool-styles-${styleId}'; + style.textContent = css; + document.head.appendChild(style); + } +} +export default classNames; +`, + loader: "js", + }; + } else { + return { + contents: ` +const css = ${JSON.stringify(css)}; +if (typeof document !== 'undefined') { + let style = document.getElementById('feedback-tool-styles-${styleId}'); + if (!style) { + style = document.createElement('style'); + style.id = 'feedback-tool-styles-${styleId}'; + style.textContent = css; + document.head.appendChild(style); + } +} +export default {}; +`, + loader: "js", + }; + } + }); + }, + }; +} + +const ctx = await esbuild.context({ + entryPoints: [path.resolve(__dirname, "src/content.tsx")], + bundle: true, + outfile: path.resolve(__dirname, "dist/content.js"), + format: "iife", + target: "chrome120", + jsx: "automatic", + jsxImportSource: "react", + plugins: [scssModulesPlugin()], + alias: { + agentation: path.resolve(__dirname, "../package/src/index.ts"), + }, + define: { + "process.env.NODE_ENV": '"production"', + __VERSION__: JSON.stringify(VERSION), + }, +}); + +if (watch) { + await ctx.watch(); + console.log("Watching for changes..."); +} else { + await ctx.rebuild(); + await ctx.dispose(); + console.log("Built extension/dist/content.js"); +} diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 00000000..c375e375 Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 00000000..ac8a0c2e Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 00000000..e174b002 Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 00000000..431a5825 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Agentation", + "description": "Visual feedback tool for AI coding agents. Annotate elements, add notes, copy structured output.", + "version": "0.1.0", + "permissions": ["storage", "activeTab", "tabs"], + "content_scripts": [ + { + "matches": [ + "http://localhost/*", + "http://127.0.0.1/*", + "https://localhost/*" + ], + "js": ["dist/content.js"], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 00000000..7297ce7f --- /dev/null +++ b/extension/package.json @@ -0,0 +1,24 @@ +{ + "name": "agentation-extension", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch", + "zip": "zip -r agentation-extension.zip manifest.json popup.html popup.js icons/ dist/content.js" + }, + "dependencies": { + "agentation": "workspace:*" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "esbuild": "^0.27.0", + "postcss": "^8.5.6", + "postcss-modules": "^6.0.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.97.2" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 00000000..5f0d8b58 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,67 @@ + + + + + + + +

Agentation

+

Visual feedback for AI coding agents

+ +
+ + Toolbar + Checking... +
+
+ + MCP Server + Checking... +
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 00000000..4f7254c0 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,45 @@ +const toolbarDot = document.getElementById("toolbar-dot"); +const toolbarValue = document.getElementById("toolbar-value"); +const mcpStatus = document.getElementById("mcp-status"); +const mcpDot = document.getElementById("mcp-dot"); +const mcpValue = document.getElementById("mcp-value"); + +// Check if the toolbar is active on the current tab by matching +// against the content script patterns from manifest.json +const CONTENT_SCRIPT_PATTERNS = [ + /^http:\/\/localhost(:\d+)?\//, + /^http:\/\/127\.0\.0\.1(:\d+)?\//, + /^https:\/\/localhost(:\d+)?\//, +]; + +chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const url = tabs[0]?.url || ""; + const isActive = CONTENT_SCRIPT_PATTERNS.some((p) => p.test(url)); + + if (isActive) { + toolbarDot.className = "dot active"; + toolbarValue.textContent = "Active"; + checkMcpHealth(); + } else { + toolbarDot.className = "dot inactive"; + toolbarValue.textContent = "Inactive"; + mcpStatus.style.display = "none"; + } +}); + +function checkMcpHealth() { + fetch("http://localhost:4747/health") + .then((res) => { + if (res.ok) { + mcpDot.className = "dot active"; + mcpValue.textContent = "Connected"; + } else { + mcpDot.className = "dot inactive"; + mcpValue.textContent = "Not responding"; + } + }) + .catch(() => { + mcpDot.className = "dot inactive"; + mcpValue.textContent = "Not running"; + }); +} diff --git a/extension/src/content.tsx b/extension/src/content.tsx new file mode 100644 index 00000000..2b2c0707 --- /dev/null +++ b/extension/src/content.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Agentation } from "agentation"; + +const MCP_DEFAULT_ENDPOINT = "http://localhost:4747"; + +function AgentationExtension() { + const [endpoint, setEndpoint] = React.useState(undefined); + + React.useEffect(() => { + fetch(`${MCP_DEFAULT_ENDPOINT}/health`) + .then((res) => { + if (res.ok) { + setEndpoint(MCP_DEFAULT_ENDPOINT); + } + }) + .catch(() => { + // MCP server not available — run in local-only mode + }); + }, []); + + return ; +} + +function mount() { + const container = document.createElement("div"); + container.id = "agentation-extension-root"; + document.body.appendChild(container); + + const root = ReactDOM.createRoot(container); + root.render( + + + + ); +} + +if (document.body) { + mount(); +} else { + document.addEventListener("DOMContentLoaded", mount); +} diff --git a/package.json b/package.json index 397c027f..dd017c73 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "mcp": "pnpm --filter agentation-mcp start", "publish:agentation": "pnpm --filter agentation publish --access public", "publish:mcp": "pnpm --filter agentation-mcp publish --access public", - "publish:all": "pnpm publish:agentation && pnpm publish:mcp" + "publish:all": "pnpm publish:agentation && pnpm publish:mcp", + "extension:build": "pnpm --filter agentation-extension build", + "extension:watch": "pnpm --filter agentation-extension watch", + "extension:zip": "pnpm --filter agentation-extension zip" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/package/src/components/icons.tsx b/package/src/components/icons.tsx index 43f7b961..8666661e 100644 --- a/package/src/components/icons.tsx +++ b/package/src/components/icons.tsx @@ -792,6 +792,25 @@ export const IconChevronRight = ({ size = 16 }: { size?: number }) => ( ); +// Pencil icon for draw mode +export const IconPencil = ({ size = 24 }: { size?: number }) => ( + + + + +); + // Animated Bunny mascot export const AnimatedBunny = ({ size = 20, diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index a2b26b15..740f45d9 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -38,6 +38,7 @@ import { IconEdit, IconChevronLeft, IconChevronRight, + IconPencil, } from "../icons"; import { identifyElement, @@ -237,6 +238,86 @@ function isElementFixed(element: HTMLElement): boolean { return false; } +function findStrokeAtPoint( + x: number, + y: number, + strokes: Array<{ points: Array<{ x: number; y: number }>; fixed: boolean }>, + threshold = 12, +): number | null { + const scrollY = window.scrollY; + // Reverse order — last drawn is on top + for (let i = strokes.length - 1; i >= 0; i--) { + const stroke = strokes[i]; + if (stroke.points.length < 2) continue; + for (let j = 0; j < stroke.points.length - 1; j++) { + const a = stroke.points[j]; + const b = stroke.points[j + 1]; + // Convert to viewport coords + const ay = stroke.fixed ? a.y : a.y - scrollY; + const by = stroke.fixed ? b.y : b.y - scrollY; + const ax = a.x; + const bx = b.x; + // Point-to-segment distance + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + let t = lenSq === 0 ? 0 : ((x - ax) * dx + (y - ay) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const projX = ax + t * dx; + const projY = ay + t * dy; + const dist = Math.hypot(x - projX, y - projY); + if (dist < threshold) return i; + } + } + return null; +} + +function classifyStrokeGesture( + points: Array<{ x: number; y: number }>, + fixed: boolean, +): string { + if (points.length < 2) return "Mark"; + const scrollY = window.scrollY; + const viewportPoints = fixed + ? points + : points.map((p) => ({ x: p.x, y: p.y - scrollY })); + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of viewportPoints) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + const bboxW = maxX - minX; + const bboxH = maxY - minY; + const bboxDiag = Math.hypot(bboxW, bboxH); + + const start = viewportPoints[0]; + const end = viewportPoints[viewportPoints.length - 1]; + const startEndDist = Math.hypot(end.x - start.x, end.y - start.y); + const closedLoop = startEndDist < bboxDiag * 0.35; + const aspectRatio = bboxW / Math.max(bboxH, 1); + + if (closedLoop && bboxDiag > 20) { + const edgeThreshold = Math.max(bboxW, bboxH) * 0.15; + let edgePoints = 0; + for (const p of viewportPoints) { + const nearLeft = p.x - minX < edgeThreshold; + const nearRight = maxX - p.x < edgeThreshold; + const nearTop = p.y - minY < edgeThreshold; + const nearBottom = maxY - p.y < edgeThreshold; + if ((nearLeft || nearRight) && (nearTop || nearBottom)) edgePoints++; + } + return edgePoints > viewportPoints.length * 0.15 ? "Box" : "Circle"; + } else if (aspectRatio > 3 && bboxH < 40) { + return "Underline"; + } else if (startEndDist > bboxDiag * 0.5) { + return "Arrow"; + } + return "Drawing"; +} + function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); @@ -460,6 +541,8 @@ export function PageFeedbackToolbarCSS({ }: PageFeedbackToolbarCSSProps = {}) { const [isActive, setIsActive] = useState(false); const [annotations, setAnnotations] = useState([]); + const annotationsRef = useRef([]); + annotationsRef.current = annotations; const [showMarkers, setShowMarkers] = useState(true); // Unified marker visibility state - controls both toolbar and eye toggle @@ -495,6 +578,8 @@ export function PageFeedbackToolbarCSS({ multiSelectElements?: HTMLElement[]; // Element reference for single-select (for live position queries) targetElement?: HTMLElement; + drawingIndex?: number; + strokeId?: string; } | null>(null); const [copied, setCopied] = useState(false); const [sendState, setSendState] = useState< @@ -503,6 +588,7 @@ export function PageFeedbackToolbarCSS({ const [cleared, setCleared] = useState(false); const [isClearing, setIsClearing] = useState(false); const [hoveredMarkerId, setHoveredMarkerId] = useState(null); + const [tooltipExitingId, setTooltipExitingId] = useState(null); const [hoveredTargetElement, setHoveredTargetElement] = useState(null); const [hoveredTargetElements, setHoveredTargetElements] = useState< @@ -530,6 +616,21 @@ export function PageFeedbackToolbarCSS({ const [isTransitioning, setIsTransitioning] = useState(false); const [tooltipsHidden, setTooltipsHidden] = useState(false); + // Draw mode state + const [isDrawMode, setIsDrawMode] = useState(false); + const [drawStrokes, setDrawStrokes] = useState; color: string; fixed: boolean }>>([]); + const drawStrokesRef = useRef(drawStrokes); + drawStrokesRef.current = drawStrokes; + const [hoveredDrawingIdx, setHoveredDrawingIdx] = useState(null); + const drawCanvasRef = useRef(null); + const isDrawingRef = useRef(false); + const currentStrokeRef = useRef>([]); + const dimAmountRef = useRef(0); + const visualHighlightRef = useRef(null); + const exitingStrokeIdRef = useRef(null); + const exitingAlphaRef = useRef(1); + + // Cmd+shift+click multi-select state const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState< Array<{ @@ -752,21 +853,25 @@ export function PageFeedbackToolbarCSS({ setMarkersVisible(true); setAnimatedMarkers(new Set()); // After enter animations complete, mark all as animated + // Must wait for max stagger delay (count * 20ms) + animation duration (250ms) + const enterMaxDelay = Math.max(0, annotations.length - 1) * 20; const timer = originalSetTimeout(() => { setAnimatedMarkers((prev) => { const newSet = new Set(prev); annotations.forEach((a) => newSet.add(a.id)); return newSet; }); - }, 350); + }, enterMaxDelay + 250 + 50); return () => clearTimeout(timer); } else if (markersVisible) { // Hide markers - start exit animation, then unmount + // Timeout must cover max stagger delay + animation duration (200ms) setMarkersExiting(true); + const maxDelay = Math.max(0, annotations.length - 1) * 20; const timer = originalSetTimeout(() => { setMarkersVisible(false); setMarkersExiting(false); - }, 250); + }, maxDelay + 200 + 50); // +50ms buffer return () => clearTimeout(timer); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1421,6 +1526,7 @@ export function PageFeedbackToolbarCSS({ setShowSettings(false); // Close settings when toolbar closes setPendingMultiSelectElements([]); // Clear multi-select modifiersHeldRef.current = { cmd: false, shift: false }; // Reset modifier tracking + setIsDrawMode(false); // Exit draw mode if (isFrozen) { unfreezeAnimations(); } @@ -1478,6 +1584,9 @@ export function PageFeedbackToolbarCSS({ [data-annotation-marker], [data-annotation-marker] * { cursor: pointer !important; } + html[data-drawing-hover], html[data-drawing-hover] * { + cursor: pointer !important; + } `; document.head.appendChild(style); @@ -1487,18 +1596,41 @@ export function PageFeedbackToolbarCSS({ }; }, [isActive]); + // Cursor change when hovering a drawing stroke (both draw mode and normal mode) + useEffect(() => { + if (hoveredDrawingIdx !== null && isActive) { + document.documentElement.setAttribute("data-drawing-hover", ""); + return () => document.documentElement.removeAttribute("data-drawing-hover"); + } + }, [hoveredDrawingIdx, isActive]); + // Handle mouse move useEffect(() => { - if (!isActive || pendingAnnotation) return; + if (!isActive || pendingAnnotation || isDrawMode) return; const handleMouseMove = (e: MouseEvent) => { // Use composedPath to get actual target inside shadow DOM const target = (e.composedPath()[0] || e.target) as HTMLElement; if (closestCrossingShadow(target, "[data-feedback-toolbar]")) { setHoverInfo(null); + // Only clear drawing hover when NOT over a marker — markers manage their own via handleMarkerHover + if (!target.closest("[data-annotation-marker]")) { + setHoveredDrawingIdx(null); + } return; } + // Check if hovering over a completed drawing stroke + if (drawStrokes.length > 0) { + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + if (strokeIdx !== null) { + setHoveredDrawingIdx(strokeIdx); + setHoverInfo(null); + return; + } + } + setHoveredDrawingIdx(null); + const elementUnder = deepElementFromPoint(e.clientX, e.clientY); if ( !elementUnder || @@ -1524,11 +1656,60 @@ export function PageFeedbackToolbarCSS({ document.addEventListener("mousemove", handleMouseMove); return () => document.removeEventListener("mousemove", handleMouseMove); - }, [isActive, pendingAnnotation, effectiveReactMode]); + }, [isActive, pendingAnnotation, isDrawMode, effectiveReactMode, drawStrokes]); + + // Start editing an annotation (right-click or click on drawing stroke) + const startEditAnnotation = useCallback((annotation: Annotation) => { + setEditingAnnotation(annotation); + setHoveredMarkerId(null); + setHoveredTargetElement(null); + setHoveredTargetElements([]); + + // Try to find elements at the annotation's position(s) for live tracking + if (annotation.elementBoundingBoxes?.length) { + // Cmd+shift+click: find element at each bounding box center + const elements: HTMLElement[] = []; + for (const bb of annotation.elementBoundingBoxes) { + const centerX = bb.x + bb.width / 2; + const centerY = bb.y + bb.height / 2 - window.scrollY; + const el = deepElementFromPoint(centerX, centerY); + if (el) elements.push(el); + } + setEditingTargetElements(elements); + setEditingTargetElement(null); + } else if (annotation.boundingBox) { + // Single element + const bb = annotation.boundingBox; + const centerX = bb.x + bb.width / 2; + // Convert document coords to viewport coords (unless fixed) + const centerY = annotation.isFixed + ? bb.y + bb.height / 2 + : bb.y + bb.height / 2 - window.scrollY; + const el = deepElementFromPoint(centerX, centerY); + + // Validate found element's size roughly matches stored bounding box + if (el) { + const elRect = el.getBoundingClientRect(); + const widthRatio = elRect.width / bb.width; + const heightRatio = elRect.height / bb.height; + if (widthRatio < 0.5 || heightRatio < 0.5) { + setEditingTargetElement(null); + } else { + setEditingTargetElement(el); + } + } else { + setEditingTargetElement(null); + } + setEditingTargetElements([]); + } else { + setEditingTargetElement(null); + setEditingTargetElements([]); + } + }, []); // Handle click useEffect(() => { - if (!isActive) return; + if (!isActive || isDrawMode) return; const handleClick = (e: MouseEvent) => { if (justFinishedDragRef.current) { @@ -1543,6 +1724,94 @@ export function PageFeedbackToolbarCSS({ if (closestCrossingShadow(target, "[data-annotation-popup]")) return; if (closestCrossingShadow(target, "[data-annotation-marker]")) return; + // Check if clicking on a completed drawing stroke + if (drawStrokes.length > 0 && !pendingAnnotation && !editingAnnotation) { + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + if (strokeIdx !== null) { + e.preventDefault(); + e.stopPropagation(); + + // If annotation already exists for this drawing, open it in edit mode + const existingAnnotation = annotations.find(a => a.strokeId === drawStrokes[strokeIdx]?.id || a.drawingIndex === strokeIdx); + if (existingAnnotation) { + startEditAnnotation(existingAnnotation); + return; + } + + const stroke = drawStrokes[strokeIdx]; + const scrollYNow = window.scrollY; + + // Temporarily hide canvas to find element underneath at click point + const canvas = drawCanvasRef.current; + if (canvas) canvas.style.visibility = "hidden"; + const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + if (canvas) canvas.style.visibility = ""; + + const gestureShape = classifyStrokeGesture(stroke.points, stroke.fixed); + let name = `Drawing: ${gestureShape}`; + let path = ""; + let reactComponents: string | null = null; + let nearbyText: string | undefined; + let cssClasses: string | undefined; + let fullPath: string | undefined; + let accessibility: string | undefined; + let computedStylesStr: string | undefined; + let computedStylesObj: Record | undefined; + let nearbyElements: string | undefined; + let isFixed = stroke.fixed; + let boundingBox: { x: number; y: number; width: number; height: number } | undefined; + + if (elementUnder) { + const info = identifyElementWithReact(elementUnder, effectiveReactMode); + name = `Drawing: ${gestureShape} → ${info.name}`; + path = info.path; + reactComponents = info.reactComponents; + nearbyText = getNearbyText(elementUnder); + cssClasses = getElementClasses(elementUnder); + fullPath = getFullElementPath(elementUnder); + accessibility = getAccessibilityInfo(elementUnder); + computedStylesStr = getForensicComputedStyles(elementUnder); + computedStylesObj = getDetailedComputedStyles(elementUnder); + nearbyElements = getNearbyElements(elementUnder); + const rect = elementUnder.getBoundingClientRect(); + boundingBox = { + x: rect.left, + y: isFixed ? rect.top : rect.top + scrollYNow, + width: rect.width, + height: rect.height, + }; + } + + // Position marker at click point (on the stroke) + const annX = (e.clientX / window.innerWidth) * 100; + const annY = isFixed ? e.clientY : e.clientY + scrollYNow; + + setPendingAnnotation({ + x: annX, + y: annY, + clientY: e.clientY, + element: name, + elementPath: path, + boundingBox, + nearbyText, + cssClasses, + isFixed, + fullPath, + accessibility, + computedStyles: computedStylesStr, + computedStylesObj, + nearbyElements, + reactComponents: reactComponents ?? undefined, + targetElement: elementUnder ?? undefined, + drawingIndex: strokeIdx, + strokeId: stroke.id, + }); + setHoverInfo(null); + setHoveredDrawingIdx(null); + return; + } + } + // Handle cmd+shift+click for multi-element selection if (e.metaKey && e.shiftKey && !pendingAnnotation && !editingAnnotation) { e.preventDefault(); @@ -1670,11 +1939,15 @@ export function PageFeedbackToolbarCSS({ return () => document.removeEventListener("click", handleClick, true); }, [ isActive, + isDrawMode, pendingAnnotation, editingAnnotation, settings.blockInteractions, effectiveReactMode, pendingMultiSelectElements, + drawStrokes, + annotations, + startEditAnnotation, ]); // Cmd+shift+click multi-select: keyup listener for modifier release @@ -1724,7 +1997,7 @@ export function PageFeedbackToolbarCSS({ // Multi-select drag - mousedown useEffect(() => { - if (!isActive || pendingAnnotation) return; + if (!isActive || pendingAnnotation || isDrawMode) return; const handleMouseDown = (e: MouseEvent) => { // Use composedPath to get actual target inside shadow DOM @@ -1784,7 +2057,7 @@ export function PageFeedbackToolbarCSS({ document.addEventListener("mousedown", handleMouseDown); return () => document.removeEventListener("mousedown", handleMouseDown); - }, [isActive, pendingAnnotation]); + }, [isActive, pendingAnnotation, isDrawMode]); // Multi-select drag - mousemove (fully optimized with direct DOM updates) useEffect(() => { @@ -2151,6 +2424,430 @@ export function PageFeedbackToolbarCSS({ return () => document.removeEventListener("mouseup", handleMouseUp); }, [isActive, isDragging]); + // Draw mode: redraw helper — dimAmount 0–1 controls non-hovered stroke opacity + const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, dimAmount = 0) => { + const scrollY = window.scrollY; + const dpr = window.devicePixelRatio || 1; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.save(); + ctx.scale(dpr, dpr); + + const tracePath = (stroke: typeof strokes[0], offsetY: number) => { + const p0 = stroke.points[0]; + ctx.moveTo(p0.x, p0.y - offsetY); + for (let i = 1; i < stroke.points.length - 1; i++) { + const curr = stroke.points[i]; + const next = stroke.points[i + 1]; + const midX = (curr.x + next.x) / 2; + const midY = (curr.y + next.y - 2 * offsetY) / 2; + ctx.quadraticCurveTo(curr.x, curr.y - offsetY, midX, midY); + } + const last = stroke.points[stroke.points.length - 1]; + ctx.lineTo(last.x, last.y - offsetY); + }; + + for (let si = 0; si < strokes.length; si++) { + const stroke = strokes[si]; + if (stroke.points.length < 2) continue; + const offsetY = stroke.fixed ? 0 : scrollY; + // Per-stroke alpha: dim non-hovered strokes, fade exiting stroke + let alpha = (hoveredIdx != null && si !== hoveredIdx) ? 1 - 0.7 * dimAmount : 1; + if (exitingStrokeIdRef.current && stroke.id === exitingStrokeIdRef.current) { + alpha *= exitingAlphaRef.current; + } + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.strokeStyle = stroke.color; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + tracePath(stroke, offsetY); + ctx.stroke(); + } + ctx.globalAlpha = 1; + ctx.restore(); + }, []); + + // Draw mode: drawing logic (also handles hover/click-to-annotate on completed strokes) + const drawClickStartRef = useRef<{ x: number; y: number; strokeIdx: number | null } | null>(null); + useEffect(() => { + if (!isDrawMode || !isActive) return; + + const canvas = drawCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + + const handleMouseDown = (e: MouseEvent) => { + // If an annotation popup is open, shake it instead of drawing + if (pendingAnnotation) { + popupRef.current?.shake(); + return; + } + if (editingAnnotation) { + editPopupRef.current?.shake(); + return; + } + + // Check if clicking on an existing stroke + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + drawClickStartRef.current = { x: e.clientX, y: e.clientY, strokeIdx }; + + isDrawingRef.current = true; + currentStrokeRef.current = [{ x: e.clientX, y: e.clientY }]; + ctx.save(); + ctx.scale(dpr, dpr); + ctx.beginPath(); + ctx.strokeStyle = settings.annotationColor; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.moveTo(e.clientX, e.clientY); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDrawingRef.current) { + // Hover detection on completed strokes (not actively drawing) + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + setHoveredDrawingIdx(strokeIdx); + if (strokeIdx !== null) canvas.setAttribute("data-stroke-hover", ""); + else canvas.removeAttribute("data-stroke-hover"); + return; + } + const point = { x: e.clientX, y: e.clientY }; + const prev = currentStrokeRef.current[currentStrokeRef.current.length - 1]; + // Skip points that are very close together (reduces jitter) + const dist = Math.hypot(point.x - prev.x, point.y - prev.y); + if (dist < 2) return; + currentStrokeRef.current.push(point); + // Quadratic curve to midpoint for smooth live drawing + const midX = (prev.x + point.x) / 2; + const midY = (prev.y + point.y) / 2; + ctx.quadraticCurveTo(prev.x, prev.y, midX, midY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(midX, midY); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!isDrawingRef.current) return; + isDrawingRef.current = false; + ctx.restore(); + const pts = currentStrokeRef.current; + + // Detect click (not drag) on existing stroke — open annotation popup + const clickStart = drawClickStartRef.current; + if (clickStart && clickStart.strokeIdx !== null && pts.length <= 3) { + const movedDist = Math.hypot(e.clientX - clickStart.x, e.clientY - clickStart.y); + if (movedDist < 5) { + // It's a click on an existing stroke + currentStrokeRef.current = []; + drawClickStartRef.current = null; + // Redraw to clear any partial stroke artifact + redrawCanvas(ctx, drawStrokes, clickStart.strokeIdx, dimAmountRef.current); + + const strokeIdx = clickStart.strokeIdx; + + // If annotation already exists for this drawing, open it in edit mode + const existingAnnotation = annotations.find(a => a.drawingIndex === strokeIdx); + if (existingAnnotation) { + startEditAnnotation(existingAnnotation); + setHoveredDrawingIdx(null); + return; + } + + const stroke = drawStrokes[strokeIdx]; + const scrollYNow = window.scrollY; + + // Use click position for element detection + const centerX = e.clientX; + const centerY = e.clientY; + + // Temporarily hide canvas to find element underneath + canvas.style.visibility = "hidden"; + const elementUnder = deepElementFromPoint(centerX, centerY); + canvas.style.visibility = ""; + + const gestureShape = classifyStrokeGesture(stroke.points, stroke.fixed); + let name = `Drawing: ${gestureShape}`; + let path = ""; + let reactComponents: string | null = null; + let nearbyText: string | undefined; + let cssClasses: string | undefined; + let fullPath: string | undefined; + let accessibility: string | undefined; + let computedStylesStr: string | undefined; + let computedStylesObj: Record | undefined; + let nearbyElements: string | undefined; + const isFixed = stroke.fixed; + let boundingBox: { x: number; y: number; width: number; height: number } | undefined; + + if (elementUnder) { + const info = identifyElementWithReact(elementUnder, effectiveReactMode); + name = `Drawing: ${gestureShape} → ${info.name}`; + path = info.path; + reactComponents = info.reactComponents; + nearbyText = getNearbyText(elementUnder); + cssClasses = getElementClasses(elementUnder); + fullPath = getFullElementPath(elementUnder); + accessibility = getAccessibilityInfo(elementUnder); + computedStylesStr = getForensicComputedStyles(elementUnder); + computedStylesObj = getDetailedComputedStyles(elementUnder); + nearbyElements = getNearbyElements(elementUnder); + const rect = elementUnder.getBoundingClientRect(); + boundingBox = { + x: rect.left, + y: isFixed ? rect.top : rect.top + scrollYNow, + width: rect.width, + height: rect.height, + }; + } + + // Position marker at click point (on the stroke) + const annX = (e.clientX / window.innerWidth) * 100; + const annY = isFixed ? e.clientY : e.clientY + scrollYNow; + + setPendingAnnotation({ + x: annX, + y: annY, + clientY: e.clientY, + element: name, + elementPath: path, + boundingBox, + nearbyText, + cssClasses, + isFixed, + fullPath, + accessibility, + computedStyles: computedStylesStr, + computedStylesObj, + nearbyElements, + reactComponents: reactComponents ?? undefined, + targetElement: elementUnder ?? undefined, + drawingIndex: strokeIdx, + strokeId: stroke.id, + }); + setHoverInfo(null); + setHoveredDrawingIdx(null); + return; + } + } + drawClickStartRef.current = null; + + if (pts.length > 1) { + // Determine if stroke is over fixed/sticky elements + // Check the bounding-box center — most reliable signal for what the stroke targets + canvas.style.visibility = "hidden"; + + const isElFixed = (el: HTMLElement): boolean => { + let node: HTMLElement | null = el; + while (node && node !== document.documentElement) { + const pos = getComputedStyle(node).position; + if (pos === "fixed" || pos === "sticky") return true; + node = node.parentElement; + } + return false; + }; + + // Bounding box center + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of pts) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const centerEl = deepElementFromPoint(centerX, centerY); + let isFixed = centerEl ? isElFixed(centerEl) : false; + + // If center is ambiguous (e.g. arrow spanning fixed→scrollable), sample edges too + if (!isFixed) { + let fixedCount = 0; + let totalSampled = 0; + const sampleCount = Math.min(6, pts.length); + const step = Math.max(1, Math.floor(pts.length / sampleCount)); + for (let i = 0; i < pts.length; i += step) { + const el = deepElementFromPoint(pts[i].x, pts[i].y); + if (!el) continue; + totalSampled++; + if (isElFixed(el)) fixedCount++; + } + // If majority of edge samples are fixed, override + if (totalSampled > 0 && fixedCount > totalSampled * 0.6) isFixed = true; + } + + // Fixed strokes stay in viewport coords; scrollable strokes convert to page coords + const finalPoints = isFixed + ? [...pts] + : pts.map(p => ({ x: p.x, y: p.y + window.scrollY })); + + const newStrokeIdx = drawStrokes.length; + const newStrokeId = crypto.randomUUID(); + const newStroke = { id: newStrokeId, points: finalPoints, color: settings.annotationColor, fixed: isFixed }; + + // Identify element underneath for annotation + const gestureShape = classifyStrokeGesture(finalPoints, isFixed); + let name = `Drawing: ${gestureShape}`; + let elPath = ""; + let reactComponents: string | null = null; + let nearbyText: string | undefined; + let cssClasses: string | undefined; + let fullPath: string | undefined; + let accessibility: string | undefined; + let computedStylesStr: string | undefined; + let computedStylesObj: Record | undefined; + let nearbyElements: string | undefined; + let boundingBox: { x: number; y: number; width: number; height: number } | undefined; + + if (centerEl) { + const info = identifyElementWithReact(centerEl, effectiveReactMode); + name = `Drawing: ${gestureShape} → ${info.name}`; + elPath = info.path; + reactComponents = info.reactComponents; + nearbyText = getNearbyText(centerEl); + cssClasses = getElementClasses(centerEl); + fullPath = getFullElementPath(centerEl); + accessibility = getAccessibilityInfo(centerEl); + computedStylesStr = getForensicComputedStyles(centerEl); + computedStylesObj = getDetailedComputedStyles(centerEl); + nearbyElements = getNearbyElements(centerEl); + const rect = centerEl.getBoundingClientRect(); + boundingBox = { + x: rect.left, + y: isFixed ? rect.top : rect.top + window.scrollY, + width: rect.width, + height: rect.height, + }; + } + + canvas.style.visibility = ""; + + setDrawStrokes(prev => [...prev, newStroke]); + + // Position marker at the end of the stroke (where the pen was released) + const lastPt = finalPoints[finalPoints.length - 1]; + const lastPtViewY = isFixed ? lastPt.y : lastPt.y - window.scrollY; + const annX = (lastPt.x / window.innerWidth) * 100; + const annY = lastPt.y; // Already in page coords (finalPoints are converted) + + setPendingAnnotation({ + x: annX, + y: annY, + clientY: lastPtViewY, + element: name, + elementPath: elPath, + boundingBox, + nearbyText, + cssClasses, + isFixed, + fullPath, + accessibility, + computedStyles: computedStylesStr, + computedStylesObj, + nearbyElements, + reactComponents: reactComponents ?? undefined, + targetElement: centerEl ?? undefined, + drawingIndex: newStrokeIdx, + strokeId: newStrokeId, + }); + setHoverInfo(null); + } + currentStrokeRef.current = []; + }; + + const handleMouseLeave = () => { + setHoveredDrawingIdx(null); + canvas.removeAttribute("data-stroke-hover"); + }; + + canvas.addEventListener("mousedown", handleMouseDown); + canvas.addEventListener("mousemove", handleMouseMove); + canvas.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("mouseleave", handleMouseLeave); + + return () => { + canvas.removeEventListener("mousedown", handleMouseDown); + canvas.removeEventListener("mousemove", handleMouseMove); + canvas.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [isDrawMode, isActive, settings.annotationColor, drawStrokes, annotations, effectiveReactMode, redrawCanvas, startEditAnnotation, pendingAnnotation, editingAnnotation]); + + // Draw mode: resize canvas, redraw on scroll + useEffect(() => { + if (!isActive) return; + const canvas = drawCanvasRef.current; + if (!canvas) return; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + canvas.style.width = window.innerWidth + "px"; + canvas.style.height = window.innerHeight + "px"; + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, dimAmountRef.current); + }; + + const onScroll = () => { + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, dimAmountRef.current); + }; + + resize(); + window.addEventListener("resize", resize); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("resize", resize); + window.removeEventListener("scroll", onScroll); + }; + }, [isActive, drawStrokes, redrawCanvas]); + + // Animate dim in/out when hovering drawings + useEffect(() => { + const canvas = drawCanvasRef.current; + if (!canvas || !isActive || drawStrokes.length === 0) return; + + const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? editingAnnotation?.drawingIndex ?? null; + const targetDim = effectiveHighlight != null ? 1 : 0; + + // Update visual highlight ref — keep old value during fade-out + if (effectiveHighlight != null) { + visualHighlightRef.current = effectiveHighlight; + } + + // Already at target — just redraw with current value + if (Math.abs(dimAmountRef.current - targetDim) < 0.01) { + dimAmountRef.current = targetDim; + if (targetDim === 0) visualHighlightRef.current = null; + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, targetDim); + return; + } + + let raf: number; + const animate = () => { + const diff = targetDim - dimAmountRef.current; + if (Math.abs(diff) < 0.01) { + dimAmountRef.current = targetDim; + if (targetDim === 0) visualHighlightRef.current = null; + } else { + dimAmountRef.current += diff * 0.25; + } + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, dimAmountRef.current); + if (Math.abs(dimAmountRef.current - targetDim) > 0.01) { + raf = requestAnimationFrame(animate); + } + }; + raf = requestAnimationFrame(animate); + return () => cancelAnimationFrame(raf); + }, [isActive, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, editingAnnotation?.drawingIndex, drawStrokes, redrawCanvas]); + // Fire webhook for annotation events - returns true on success, false on failure const fireWebhook = useCallback( async ( @@ -2209,6 +2906,8 @@ export function PageFeedbackToolbarCSS({ nearbyElements: pendingAnnotation.nearbyElements, reactComponents: pendingAnnotation.reactComponents, elementBoundingBoxes: pendingAnnotation.elementBoundingBoxes, + drawingIndex: pendingAnnotation.drawingIndex, + strokeId: pendingAnnotation.strokeId, // Protocol fields for server sync ...(endpoint && currentSessionId ? { @@ -2284,18 +2983,52 @@ export function PageFeedbackToolbarCSS({ // Cancel annotation with exit animation const cancelAnnotation = useCallback(() => { + const strokeId = pendingAnnotation?.strokeId; setPendingExiting(true); + + // Fade the linked stroke on canvas in parallel with popup exit + if (strokeId) { + exitingStrokeIdRef.current = strokeId; + exitingAlphaRef.current = 1; + const canvas = drawCanvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx) { + const start = performance.now(); + const fade = (now: number) => { + const t = Math.min((now - start) / 150, 1); + exitingAlphaRef.current = 1 - t; + redrawCanvas(ctx, drawStrokesRef.current, visualHighlightRef.current, dimAmountRef.current); + if (t < 1) requestAnimationFrame(fade); + }; + requestAnimationFrame(fade); + } + } + originalSetTimeout(() => { + exitingStrokeIdRef.current = null; + if (strokeId) { + const currentStrokes = drawStrokesRef.current; + const drawingIdx = currentStrokes.findIndex(s => s.id === strokeId); + if (drawingIdx >= 0) { + setDrawStrokes(prev => prev.filter(s => s.id !== strokeId)); + setAnnotations(prev => prev.map(a => + a.drawingIndex != null && a.drawingIndex > drawingIdx + ? { ...a, drawingIndex: a.drawingIndex - 1 } + : a + )); + } + } setPendingAnnotation(null); setPendingExiting(false); - }, 150); // Match exit animation duration - }, []); + }, 150); + }, [pendingAnnotation]); // Delete annotation with exit animation const deleteAnnotation = useCallback( (id: string) => { - const deletedIndex = annotations.findIndex((a) => a.id === id); - const deletedAnnotation = annotations[deletedIndex]; + const currentAnnotations = annotationsRef.current; + const deletedIndex = currentAnnotations.findIndex((a) => a.id === id); + const deletedAnnotation = currentAnnotations[deletedIndex]; // Close edit panel with exit animation if deleting the annotation being edited if (editingAnnotation?.id === id) { @@ -2327,9 +3060,46 @@ export function PageFeedbackToolbarCSS({ }); } - // Wait for exit animation then remove + // Fade the linked stroke on canvas in parallel with marker exit + if (deletedAnnotation?.strokeId) { + exitingStrokeIdRef.current = deletedAnnotation.strokeId; + exitingAlphaRef.current = 1; + const canvas = drawCanvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx) { + const start = performance.now(); + const fade = (now: number) => { + const t = Math.min((now - start) / 150, 1); + exitingAlphaRef.current = 1 - t; + redrawCanvas(ctx, drawStrokesRef.current, visualHighlightRef.current, dimAmountRef.current); + if (t < 1) requestAnimationFrame(fade); + }; + requestAnimationFrame(fade); + } + } + + // Wait for marker exit animation then remove annotation + linked stroke originalSetTimeout(() => { - setAnnotations((prev) => prev.filter((a) => a.id !== id)); + exitingStrokeIdRef.current = null; + // Use strokeId (stable) to find the correct stroke — drawingIndex can be + // stale if other strokes were added/removed during the 150ms wait + const latestAnn = annotationsRef.current.find(a => a.id === id); + const strokeId = latestAnn?.strokeId; + const currentStrokes = drawStrokesRef.current; + const drawingIdx = strokeId ? currentStrokes.findIndex(s => s.id === strokeId) : -1; + + if (drawingIdx >= 0) { + setDrawStrokes(prev => prev.filter(s => s.id !== strokeId)); + setAnnotations(prev => prev + .filter(a => a.id !== id) + .map(a => + a.drawingIndex != null && a.drawingIndex > drawingIdx + ? { ...a, drawingIndex: a.drawingIndex - 1 } + : a + )); + } else { + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + } setExitingMarkers((prev) => { const next = new Set(prev); next.delete(id); @@ -2338,76 +3108,43 @@ export function PageFeedbackToolbarCSS({ setDeletingMarkerId(null); // Trigger renumber animation for markers after deleted one - if (deletedIndex < annotations.length - 1) { - setRenumberFrom(deletedIndex); + const latestAnnotations = annotationsRef.current; + const currentIndex = latestAnnotations.findIndex(a => a.id === id); + if (currentIndex >= 0 && currentIndex < latestAnnotations.length - 1) { + setRenumberFrom(currentIndex); originalSetTimeout(() => setRenumberFrom(null), 200); } }, 150); }, - [annotations, editingAnnotation, onAnnotationDelete, fireWebhook, endpoint], + [editingAnnotation, onAnnotationDelete, fireWebhook, endpoint], ); - // Start editing an annotation (right-click) - const startEditAnnotation = useCallback((annotation: Annotation) => { - setEditingAnnotation(annotation); - setHoveredMarkerId(null); - setHoveredTargetElement(null); - setHoveredTargetElements([]); - - // Try to find elements at the annotation's position(s) for live tracking - if (annotation.elementBoundingBoxes?.length) { - // Cmd+shift+click: find element at each bounding box center - const elements: HTMLElement[] = []; - for (const bb of annotation.elementBoundingBoxes) { - const centerX = bb.x + bb.width / 2; - const centerY = bb.y + bb.height / 2 - window.scrollY; - const el = deepElementFromPoint(centerX, centerY); - if (el) elements.push(el); - } - setEditingTargetElements(elements); - setEditingTargetElement(null); - } else if (annotation.boundingBox) { - // Single element - const bb = annotation.boundingBox; - const centerX = bb.x + bb.width / 2; - // Convert document coords to viewport coords (unless fixed) - const centerY = annotation.isFixed - ? bb.y + bb.height / 2 - : bb.y + bb.height / 2 - window.scrollY; - const el = deepElementFromPoint(centerX, centerY); - - // Validate found element's size roughly matches stored bounding box - if (el) { - const elRect = el.getBoundingClientRect(); - const widthRatio = elRect.width / bb.width; - const heightRatio = elRect.height / bb.height; - if (widthRatio < 0.5 || heightRatio < 0.5) { - setEditingTargetElement(null); - } else { - setEditingTargetElement(el); - } - } else { - setEditingTargetElement(null); - } - setEditingTargetElements([]); - } else { - setEditingTargetElement(null); - setEditingTargetElements([]); - } - }, []); - // Handle marker hover - finds element(s) for live position tracking const handleMarkerHover = useCallback( (annotation: Annotation | null) => { if (!annotation) { + // Start tooltip exit animation synchronously (before clearing hoveredMarkerId) + if (hoveredMarkerId) { + setTooltipExitingId(hoveredMarkerId); + originalSetTimeout(() => setTooltipExitingId(null), 100); + } setHoveredMarkerId(null); setHoveredTargetElement(null); setHoveredTargetElements([]); + setHoveredDrawingIdx(null); return; } + setTooltipExitingId(null); setHoveredMarkerId(annotation.id); + // Highlight linked drawing stroke when marker is hovered + if (annotation.drawingIndex != null && annotation.drawingIndex < drawStrokes.length) { + setHoveredDrawingIdx(annotation.drawingIndex); + } else { + setHoveredDrawingIdx(null); + } + // Find elements at the annotation's position(s) for live tracking if (annotation.elementBoundingBoxes?.length) { // Cmd+shift+click: find element at each bounding box center @@ -2454,7 +3191,7 @@ export function PageFeedbackToolbarCSS({ setHoveredTargetElements([]); } }, - [], + [drawStrokes, hoveredMarkerId], ); // Update annotation (edit mode submit) @@ -2512,7 +3249,7 @@ export function PageFeedbackToolbarCSS({ // Clear all with staggered animation const clearAll = useCallback(() => { const count = annotations.length; - if (count === 0) return; + if (count === 0 && drawStrokes.length === 0) return; // Fire callback with all annotations before clearing onAnnotationsClear?.(annotations); @@ -2535,6 +3272,14 @@ export function PageFeedbackToolbarCSS({ setIsClearing(true); setCleared(true); + // Clear draw strokes + setDrawStrokes([]); + const canvas = drawCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + } + const totalAnimationTime = count * 30 + 200; originalSetTimeout(() => { setAnnotations([]); @@ -2544,7 +3289,7 @@ export function PageFeedbackToolbarCSS({ }, totalAnimationTime); originalSetTimeout(() => setCleared(false), 1500); - }, [pathname, annotations, onAnnotationsClear, fireWebhook, endpoint]); + }, [pathname, annotations, drawStrokes, onAnnotationsClear, fireWebhook, endpoint]); // Copy output const copyOutput = useCallback(async () => { @@ -2554,13 +3299,136 @@ export function PageFeedbackToolbarCSS({ window.location.search + window.location.hash : pathname; - const output = generateOutput( + let output = generateOutput( annotations, displayUrl, settings.outputDetail, effectiveReactMode, ); - if (!output) return; + if (!output && drawStrokes.length === 0) return; + if (!output) output = `## Page Feedback: ${displayUrl}\n`; + + // Describe draw strokes as text by detecting elements underneath + if (drawStrokes.length > 0) { + // Collect drawing indices that have linked annotations (skip those in standalone section) + const linkedDrawingIndices = new Set(); + for (const a of annotations) { + if (a.drawingIndex != null) linkedDrawingIndices.add(a.drawingIndex); + } + + // Temporarily hide the draw canvas so elementFromPoint hits real page elements + const canvas = drawCanvasRef.current; + if (canvas) canvas.style.visibility = "hidden"; + + const strokeDescriptions: string[] = []; + const scrollY = window.scrollY; + for (let strokeIdx = 0; strokeIdx < drawStrokes.length; strokeIdx++) { + // Skip strokes that have a linked annotation — their info is in the annotation output + if (linkedDrawingIndices.has(strokeIdx)) continue; + const stroke = drawStrokes[strokeIdx]; + if (stroke.points.length < 2) continue; + + // Get viewport coords for analysis (fixed strokes are already in viewport coords) + const viewportPoints = stroke.fixed + ? stroke.points + : stroke.points.map(p => ({ x: p.x, y: p.y - scrollY })); + + // Bounding box (viewport coords) + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of viewportPoints) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + const bboxW = maxX - minX; + const bboxH = maxY - minY; + const bboxDiag = Math.hypot(bboxW, bboxH); + + // Start/end analysis + const start = viewportPoints[0]; + const end = viewportPoints[viewportPoints.length - 1]; + const startEndDist = Math.hypot(end.x - start.x, end.y - start.y); + + // Gesture classification + let gesture: "circle" | "box" | "underline" | "arrow" | "drawing"; + const closedLoop = startEndDist < bboxDiag * 0.35; + const aspectRatio = bboxW / Math.max(bboxH, 1); + + if (closedLoop && bboxDiag > 20) { + // Closed loop — circle vs box: measure how many points hug the bbox edges + // Box strokes spend time near edges; circles stay more centered + const edgeThreshold = Math.max(bboxW, bboxH) * 0.15; + let edgePoints = 0; + for (const p of viewportPoints) { + const nearLeft = p.x - minX < edgeThreshold; + const nearRight = maxX - p.x < edgeThreshold; + const nearTop = p.y - minY < edgeThreshold; + const nearBottom = maxY - p.y < edgeThreshold; + if ((nearLeft || nearRight) && (nearTop || nearBottom)) edgePoints++; + } + // If many points are near corners, it's a box + gesture = edgePoints > viewportPoints.length * 0.15 ? "box" : "circle"; + } else if (aspectRatio > 3 && bboxH < 40) { + gesture = "underline"; + } else if (startEndDist > bboxDiag * 0.5) { + gesture = "arrow"; + } else { + gesture = "drawing"; + } + + // Sample elements along the stroke + const sampleCount = Math.min(10, viewportPoints.length); + const step = Math.max(1, Math.floor(viewportPoints.length / sampleCount)); + const seenElements = new Set(); + const elementNames: string[] = []; + + const samplePoints = [start]; + for (let i = step; i < viewportPoints.length - 1; i += step) { + samplePoints.push(viewportPoints[i]); + } + samplePoints.push(end); + + for (const p of samplePoints) { + const el = deepElementFromPoint(p.x, p.y); + if (!el || seenElements.has(el)) continue; + if (closestCrossingShadow(el, "[data-feedback-toolbar]")) continue; + seenElements.add(el); + const { name } = identifyElement(el); + if (!elementNames.includes(name)) { + elementNames.push(name); + } + } + + // Format description + const region = `${Math.round(minX)},${Math.round(minY)} → ${Math.round(maxX)},${Math.round(maxY)}`; + let desc: string; + + if ((gesture === "circle" || gesture === "box") && elementNames.length > 0) { + const verb = gesture === "box" ? "Boxed" : "Circled"; + desc = `${verb} **${elementNames[0]}**${elementNames.length > 1 ? ` (and ${elementNames.slice(1).join(", ")})` : ""} (region: ${region})`; + } else if (gesture === "underline" && elementNames.length > 0) { + desc = `Underlined **${elementNames[0]}** (${region})`; + } else if (gesture === "arrow" && elementNames.length >= 2) { + desc = `Arrow from **${elementNames[0]}** to **${elementNames[elementNames.length - 1]}** (${Math.round(start.x)},${Math.round(start.y)} → ${Math.round(end.x)},${Math.round(end.y)})`; + } else if (elementNames.length > 0) { + desc = `${gesture === "arrow" ? "Arrow" : "Drawing"} near **${elementNames.join("**, **")}** (region: ${region})`; + } else { + desc = `Drawing at ${region}`; + } + strokeDescriptions.push(desc); + } + + // Restore canvas + if (canvas) canvas.style.visibility = ""; + + if (strokeDescriptions.length > 0) { + output += `\n**Drawings:**\n`; + strokeDescriptions.forEach((d, i) => { + output += `${i + 1}. ${d}\n`; + }); + } + } if (copyToClipboard) { try { @@ -2581,6 +3449,7 @@ export function PageFeedbackToolbarCSS({ } }, [ annotations, + drawStrokes, pathname, settings.outputDetail, effectiveReactMode, @@ -2662,15 +3531,15 @@ export function PageFeedbackToolbarCSS({ // Constrain to viewport const padding = 20; - const wrapperWidth = 297; // .toolbar wrapper width + const wrapperWidth = 337; // .toolbar wrapper width const toolbarHeight = 44; // Content is right-aligned within wrapper via margin-left: auto // Calculate content width based on state const contentWidth = isActive ? connectionStatus === "connected" - ? 297 - : 257 + ? 337 + : 297 : 44; // collapsed circle // Content offset from wrapper left edge @@ -2751,7 +3620,7 @@ export function PageFeedbackToolbarCSS({ const constrainPosition = () => { const padding = 20; - const wrapperWidth = 297; // .toolbar wrapper width + const wrapperWidth = 337; // .toolbar wrapper width const toolbarHeight = 44; let newX = toolbarPosition.x; @@ -2803,6 +3672,11 @@ export function PageFeedbackToolbarCSS({ target.isContentEditable; if (e.key === "Escape") { + // Exit draw mode first if active + if (isDrawMode) { + setIsDrawMode(false); + return; + } // Clear multi-select if active if (pendingMultiSelectElements.length > 0) { setPendingMultiSelectElements([]); @@ -2824,6 +3698,21 @@ export function PageFeedbackToolbarCSS({ return; } + // Cmd+Z in draw mode: undo last stroke + if ((e.metaKey || e.ctrlKey) && (e.key === "z" || e.key === "Z") && isDrawMode && !e.shiftKey) { + e.preventDefault(); + setDrawStrokes(prev => { + const next = prev.slice(0, -1); + const canvas = drawCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, next); + } + return next; + }); + return; + } + // Skip other shortcuts if typing or modifier keys are held if (isTyping || e.metaKey || e.ctrlKey) return; @@ -2834,12 +3723,20 @@ export function PageFeedbackToolbarCSS({ toggleFreeze(); } + // "D" to toggle draw mode + if (e.key === "d" || e.key === "D") { + e.preventDefault(); + hideTooltipsUntilMouseLeave(); + setIsDrawMode(prev => !prev); + } + // "H" to toggle marker visibility if (e.key === "h" || e.key === "H") { if (annotations.length > 0) { e.preventDefault(); hideTooltipsUntilMouseLeave(); setShowMarkers((prev) => !prev); + if (isDrawMode) setIsDrawMode(false); } } @@ -2881,6 +3778,7 @@ export function PageFeedbackToolbarCSS({ return () => document.removeEventListener("keydown", handleKeyDown); }, [ isActive, + isDrawMode, pendingAnnotation, annotations.length, settings.webhookUrl, @@ -2890,6 +3788,7 @@ export function PageFeedbackToolbarCSS({ toggleFreeze, copyOutput, clearAll, + redrawCanvas, pendingMultiSelectElements, ]); @@ -3043,6 +3942,24 @@ export function PageFeedbackToolbarCSS({ +
+ + + {isDrawMode ? "Exit draw mode" : "Draw mode"} + D + +
+
+ {/* Draw canvas — outside overlay so it can fade on toolbar close */} + + {/* Markers layer - normal scrolling markers */}
{markersVisible && @@ -3613,6 +4539,7 @@ export function PageFeedbackToolbarCSS({ const showDeleteHover = showDeleteState && settings.markerClickBehavior === "delete"; + return (
!markersExiting && @@ -3667,9 +4596,9 @@ export function PageFeedbackToolbarCSS({ {globalIndex + 1} )} - {isHovered && !editingAnnotation && ( + {(isHovered || tooltipExitingId === annotation.id) && !editingAnnotation && (
@@ -3741,6 +4670,7 @@ export function PageFeedbackToolbarCSS({ const showDeleteHover = showDeleteState && settings.markerClickBehavior === "delete"; + return (
!markersExiting && @@ -3795,9 +4728,9 @@ export function PageFeedbackToolbarCSS({ {globalIndex + 1} )} - {isHovered && !editingAnnotation && ( + {(isHovered || tooltipExitingId === annotation.id) && !editingAnnotation && (
@@ -3852,7 +4785,8 @@ export function PageFeedbackToolbarCSS({ {hoverInfo?.rect && !pendingAnnotation && !isScrolling && - !isDragging && ( + !isDragging && + !isDrawMode && (
a.id === hoveredMarkerId, ); if (!hoveredAnnotation?.boundingBox) return null; + // Drawing-linked annotations highlight the stroke via canvas, not an element box + if (hoveredAnnotation.drawingIndex != null) return null; // Render individual element boxes if available (cmd+shift+click multi-select) if (hoveredAnnotation.elementBoundingBoxes?.length) { @@ -3984,7 +4920,7 @@ export function PageFeedbackToolbarCSS({ })()} {/* Hover tooltip */} - {hoverInfo && !pendingAnnotation && !isScrolling && !isDragging && ( + {hoverInfo && !pendingAnnotation && !isScrolling && !isDragging && !isDrawMode && (
- {/* Show element/area outline while adding annotation */} - {pendingAnnotation.multiSelectElements?.length + {/* Show element/area outline while adding annotation (skip for drawing-linked annotations — drawing is the highlight) */} + {pendingAnnotation.drawingIndex != null + ? null + : pendingAnnotation.multiSelectElements?.length ? // Cmd+shift+click multi-select: show individual boxes with live positions pendingAnnotation.multiSelectElements .filter((el) => document.contains(el)) @@ -4140,8 +5078,10 @@ export function PageFeedbackToolbarCSS({ {/* Edit annotation popup */} {editingAnnotation && ( <> - {/* Show element/area outline while editing */} - {editingAnnotation.elementBoundingBoxes?.length + {/* Show element/area outline while editing (skip for drawing-linked — drawing is the highlight) */} + {editingAnnotation.drawingIndex != null + ? null + : editingAnnotation.elementBoundingBoxes?.length ? // Cmd+shift+click: show individual element boxes (use live rects when available) (() => { // Use live positions from editingTargetElements when available diff --git a/package/src/components/page-toolbar-css/styles.module.scss b/package/src/components/page-toolbar-css/styles.module.scss index ea5102ee..56ab62b8 100644 --- a/package/src/components/page-toolbar-css/styles.module.scss +++ b/package/src/components/page-toolbar-css/styles.module.scss @@ -134,6 +134,17 @@ $green: #34c759; } } +@keyframes tooltipOut { + from { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(0.909); + } + to { + opacity: 0; + transform: translateX(-50%) translateY(2px) scale(0.891); + } +} + @keyframes hoverHighlightIn { from { opacity: 0; @@ -190,7 +201,7 @@ $green: #34c759; position: fixed; bottom: 1.25rem; right: 1.25rem; - width: 297px; + width: 337px; z-index: 100000; font-family: system-ui, @@ -262,10 +273,10 @@ $green: #34c759; height: 44px; border-radius: 1.5rem; padding: 0.375rem; - width: 257px; + width: 297px; &.serverConnected { - width: 297px; + width: 337px; } } } @@ -1007,6 +1018,10 @@ $green: #34c759; &.enter { animation: tooltipIn 0.1s ease-out forwards; } + + &.exit { + animation: tooltipOut 0.1s ease-out forwards; + } } .markerQuote { @@ -2045,6 +2060,27 @@ $green: #34c759; } } +// ============================================================================= +// Draw Canvas +// ============================================================================= + +.drawCanvas { + position: fixed; + inset: 0; + z-index: 99996; // Below markers (99998) and overlay (99997) + // !important needed to override .overlay > * { pointer-events: auto } + pointer-events: none !important; + + &.active { + pointer-events: auto !important; + cursor: crosshair !important; + + &[data-stroke-hover] { + cursor: pointer !important; + } + } +} + // ============================================================================= // Drag Selection // ============================================================================= diff --git a/package/src/types.ts b/package/src/types.ts index 08b6ec6c..265d2088 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -27,6 +27,8 @@ export type Annotation = { width: number; height: number; }>; // Individual bounding boxes for multi-select hover highlighting + drawingIndex?: number; // Index of linked drawing stroke (click-to-annotate) + strokeId?: string; // Unique ID of linked drawing stroke // Protocol fields (added when syncing to server) sessionId?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72fe188e..a5f6438b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,37 @@ importers: .: {} + extension: + dependencies: + agentation: + specifier: workspace:* + version: link:../package + devDependencies: + '@types/react': + specifier: ^18.2.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.2.0 + version: 18.3.7(@types/react@18.3.28) + esbuild: + specifier: ^0.27.0 + version: 0.27.2 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + postcss-modules: + specifier: ^6.0.1 + version: 6.0.1(postcss@8.5.6) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + sass: + specifier: ^1.97.2 + version: 1.97.3 + mcp: dependencies: '@modelcontextprotocol/sdk': @@ -114,6 +145,9 @@ importers: '@types/react-dom': specifier: ^18.2.0 version: 18.3.7(@types/react@18.3.28) + modern-screenshot: + specifier: ^4.6.8 + version: 4.6.8 sass: specifier: ^1.69.0 version: 1.97.3 @@ -597,24 +631,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -663,36 +701,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -753,66 +797,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1562,6 +1619,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + modern-screenshot@4.6.8: + resolution: {integrity: sha512-GJkv/yWPOJTlxj1LZDU2k474cDyOWL+LVaqTdDWQwQ5d8zIuTz1892+1cV9V0ZpK6HYZFo/+BNLBbierO9d2TA==} + motion-dom@12.33.0: resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==} @@ -1886,48 +1946,56 @@ packages: engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] + libc: glibc sass-embedded-linux-arm@1.97.3: resolution: {integrity: sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] + libc: glibc sass-embedded-linux-musl-arm64@1.97.3: resolution: {integrity: sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] + libc: musl sass-embedded-linux-musl-arm@1.97.3: resolution: {integrity: sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] + libc: musl sass-embedded-linux-musl-riscv64@1.97.3: resolution: {integrity: sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] + libc: musl sass-embedded-linux-musl-x64@1.97.3: resolution: {integrity: sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] + libc: musl sass-embedded-linux-riscv64@1.97.3: resolution: {integrity: sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] + libc: glibc sass-embedded-linux-x64@1.97.3: resolution: {integrity: sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] + libc: glibc sass-embedded-unknown-all@1.97.3: resolution: {integrity: sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==} @@ -3591,6 +3659,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + modern-screenshot@4.6.8: {} + motion-dom@12.33.0: dependencies: motion-utils: 12.29.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 645858de..1aadc401 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'package' - 'package/example' - 'mcp' + - 'extension'