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'