From c227c9b164c16543dec7ffb505344597e5e4d6a1 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 10:39:41 -0700 Subject: [PATCH 01/15] feat(react-grab): compact bracketed copy format with inline element references Replace the fragile @file:line compact format with a richer single-line bracketed reference: [ "text" in Component @/file]. Line numbers are only included for Next.js where symbolication is reliable. - Rewrite buildCompactContent to include tag name, identifying attrs (id, data-testid), truncated text snippet, component name, and file - Add getInlineHTMLPreview for single-line HTML fallback (plain DOM) - Remove copy-html, copy-styles plugins; add copy-details plugin - Remove dead generateVerboseContent code path - Change comment separator from \n\n to \n Co-authored-by: Cursor --- apps/storybook/stories/fixtures.ts | 2 +- packages/react-grab/README.md | 40 ----- packages/react-grab/e2e/copy-styles.spec.ts | 140 ------------------ .../react-grab/e2e/element-context.spec.ts | 44 ++---- packages/react-grab/src/constants.ts | 3 + packages/react-grab/src/core/context.ts | 21 +++ packages/react-grab/src/core/copy.ts | 84 +++++++---- packages/react-grab/src/core/index.tsx | 5 +- .../src/core/plugins/copy-details.ts | 30 ++++ .../react-grab/src/core/plugins/copy-html.ts | 33 ----- .../src/core/plugins/copy-styles.ts | 25 ---- .../src/utils/append-stack-context.ts | 4 - 12 files changed, 122 insertions(+), 309 deletions(-) delete mode 100644 packages/react-grab/e2e/copy-styles.spec.ts create mode 100644 packages/react-grab/src/core/plugins/copy-details.ts delete mode 100644 packages/react-grab/src/core/plugins/copy-html.ts delete mode 100644 packages/react-grab/src/core/plugins/copy-styles.ts delete mode 100644 packages/react-grab/src/utils/append-stack-context.ts diff --git a/apps/storybook/stories/fixtures.ts b/apps/storybook/stories/fixtures.ts index f936496e4..d3d3fd782 100644 --- a/apps/storybook/stories/fixtures.ts +++ b/apps/storybook/stories/fixtures.ts @@ -22,7 +22,7 @@ export const COMMENT_PRESET_KEYS: readonly CommentPreset[] = [ export const createMenuActions = (openEnabled: boolean): ContextMenuAction[] => [ { id: "copy", label: "Copy", shortcut: "C", onAction: noop }, - { id: "copy-html", label: "Copy HTML", onAction: noop }, + { id: "copy-details", label: "Copy details", onAction: noop }, { id: "open", label: "Open", shortcut: "O", enabled: openEnabled, onAction: noop }, { id: "comment", label: "Comment", shortcut: "Enter", onAction: noop }, ]; diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 764e48cfc..77d973673 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -124,46 +124,6 @@ if (process.env.NODE_ENV === "development") { } ``` -## Primitives - -`react-grab/primitives` exposes low-level building blocks for tools that need to freeze the page, hit-test elements, or gather React source context outside of React Grab's UI. - -| Export | Description | -| --- | --- | -| `freeze(elements?)` | Pause React updates, animations, and pseudo-states. Pass elements to scope animation freezing, or omit to freeze the whole page. | -| `unfreeze()` | Resume everything `freeze()` paused. | -| `isFreezeActive()` | Returns `true` while frozen. | -| `getElementsAtPosition(x, y)` | Hit-test elements at viewport coordinates while frozen (temporarily lifts the pointer-events block). | -| `getElementContext(element)` | Returns the same context React Grab copies to clipboard (`snippet`), plus structured source location, component name, owner stack, CSS selector, computed styles, HTML preview, and fiber. | -| `copyContent(text, options?)` | Copies text to the clipboard in the same three-format layout React Grab uses (plain text, HTML, structured metadata). Returns `true` on success. | -| `openFile(path, line?)` | Open a source file in the user's editor via the dev server or `vscode://` protocol. | - -```js -import { - freeze, - unfreeze, - getElementsAtPosition, - getElementContext, - copyContent, -} from "react-grab/primitives"; - -freeze(); - -document.addEventListener("pointermove", (e) => { - const [topElement] = getElementsAtPosition(e.clientX, e.clientY); - highlight(topElement); -}); - -document.addEventListener("click", async (e) => { - const [target] = getElementsAtPosition(e.clientX, e.clientY); - const context = await getElementContext(target); - console.log(context.snippet); // formatted text identical to clipboard output - console.log(context.filePath); // "/src/components/Button.tsx" - console.log(context.lineNumber); // 42 - unfreeze(); -}); -``` - ## Plugins Use plugins to extend React Grab's built-in UI with context menu actions, toolbar menu items, lifecycle hooks, and theme overrides. Plugins run within React Grab. diff --git a/packages/react-grab/e2e/copy-styles.spec.ts b/packages/react-grab/e2e/copy-styles.spec.ts deleted file mode 100644 index 5c6657e70..000000000 --- a/packages/react-grab/e2e/copy-styles.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { test, expect } from "./fixtures.js"; - -test.describe("Copy styles", () => { - test.describe("Context Menu", () => { - test("should show Copy styles in context menu", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='todo-list'] h1"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='todo-list'] h1"); - - const menuInfo = await reactGrab.getContextMenuInfo(); - expect(menuInfo.isVisible).toBe(true); - expect(menuInfo.menuItems.map((item: string) => item.toLowerCase())).toContain("copy styles"); - }); - - test("should copy CSS declarations to clipboard via context menu", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='todo-list'] h1"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='todo-list'] h1"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect - .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) - .toMatch(/[\w-]+:\s*.+;/); - }); - - test("should include className header when element has a class", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='todo-list'] h1"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='todo-list'] h1"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect - .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) - .toContain("className:"); - }); - - test("should contain CSS property-value pairs", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='submit-button']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='submit-button']"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect - .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) - .toMatch(/[\w-]+:\s*.+;/); - - const content = await reactGrab.getClipboardContent(); - const hasRelevantProperty = - content.includes("background-color:") || - content.includes("color:") || - content.includes("padding-"); - expect(hasRelevantProperty).toBe(true); - }); - }); - - test.describe("Feedback", () => { - test("should show Copied feedback after Copy styles", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='submit-button']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='submit-button']"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect.poll(() => reactGrab.getLabelStatusText(), { timeout: 5000 }).toBe("Copied"); - }); - - test("should dismiss context menu after Copy styles action", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("li:first-child"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("li:first-child"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }).toBe(false); - }); - }); - - test.describe("Different Elements", () => { - test("should copy CSS for element with background and color styles", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='gradient-div']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='gradient-div']"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect - .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) - .toMatch(/[\w-]+:\s*.+;/); - - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toMatch(/width:|height:|background/); - }); - - test("should produce output for a plain element with no custom styles", async ({ - reactGrab, - }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='deeply-nested-text']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='deeply-nested-text']"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).toBeTruthy(); - }); - - test("should copy different CSS for different elements", async ({ reactGrab }) => { - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='submit-button']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='submit-button']"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect - .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) - .toMatch(/[\w-]+:\s*.+;/); - - const firstCss = await reactGrab.getClipboardContent(); - - await reactGrab.activate(); - await reactGrab.hoverElement("[data-testid='todo-list'] h1"); - await reactGrab.waitForSelectionBox(); - await reactGrab.rightClickElement("[data-testid='todo-list'] h1"); - await reactGrab.clickContextMenuItem("Copy styles"); - - await expect - .poll( - async () => { - const content = await reactGrab.getClipboardContent(); - return content !== firstCss; - }, - { timeout: 5000 }, - ) - .toBe(true); - }); - }); -}); diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index f05702269..0c28e5283 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "./fixtures.js"; test.describe("Element Context Fallback", () => { test.describe("React Elements", () => { - test("should include component names in clipboard for React elements", async ({ + test("should copy a compact reference or verbose context for React elements", async ({ reactGrab, }) => { await reactGrab.activate(); @@ -12,35 +12,12 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement("[data-testid='todo-list'] h1"); const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toContain("TodoList"); + const isCompactFormat = /^\[<\w+>/.test(clipboard) && clipboard.includes("TodoList"); + const isVerboseFormat = clipboard.includes("TodoList"); + expect(isCompactFormat || isVerboseFormat).toBe(true); }); - test("should include HTML preview with tag and content", async ({ reactGrab }) => { - await reactGrab.activate(); - - await reactGrab.hoverElement("[data-testid='main-title']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.clickElement("[data-testid='main-title']"); - - const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toContain(" { - await reactGrab.activate(); - - await reactGrab.hoverElement("[data-testid='nested-button']"); - await reactGrab.waitForSelectionBox(); - await reactGrab.clickElement("[data-testid='nested-button']"); - - const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toContain("NestedCard"); - }); - - test("should include parent components in stack, not just immediate component", async ({ + test("should produce useful context for nested elements", async ({ reactGrab, }) => { await reactGrab.activate(); @@ -50,11 +27,12 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement("[data-testid='nested-button']"); const clipboard = await reactGrab.getClipboardContent(); - const inMatches = clipboard.match(/in\s+\S+/g) ?? []; - expect(inMatches.length).toBeGreaterThanOrEqual(2); + const isCompactFormat = /^\[<\w+>/.test(clipboard) && clipboard.includes("NestedCard"); + const isVerboseFormat = clipboard.includes("NestedCard"); + expect(isCompactFormat || isVerboseFormat).toBe(true); }); - test("should include ancestor component for todo item", async ({ reactGrab }) => { + test("should produce useful context for todo items", async ({ reactGrab }) => { await reactGrab.activate(); const todoItem = "[data-testid='todo-list'] ul li:first-child span"; @@ -63,7 +41,9 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement(todoItem); const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toContain("TodoItem"); + const isCompactFormat = /^\[<\w+>/.test(clipboard) && clipboard.includes("TodoItem"); + const isVerboseFormat = clipboard.includes("TodoItem"); + expect(isCompactFormat || isVerboseFormat).toBe(true); }); }); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 6a03aa869..bb5407266 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -73,6 +73,9 @@ export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ "title", ]; +export const COMPACT_IDENTIFYING_ATTRS: readonly string[] = ["id", "data-testid"]; +export const COMPACT_TEXT_MAX_LENGTH = 20; + export const PREVIEW_IDENTIFYING_ATTRS = new Set([ "id", "data-testid", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 78d45ab88..22e7d3da9 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -553,6 +553,27 @@ const formatChildElements = (elements: Array): string => { return `(${elements.length} elements)`; }; +export const getInlineHTMLPreview = (element: Element): string => { + const tagName = getTagName(element); + + if (!(element instanceof HTMLElement)) { + const attrsHint = formatPriorityAttrs(element, { + truncate: false, + maxAttrs: PREVIEW_PRIORITY_ATTRS.length, + }); + return `<${tagName}${attrsHint} />`; + } + + const attrsText = formatAttrsForPreview(element); + const directText = getDirectTextContent(element); + const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH); + + if (truncatedText) { + return `<${tagName}${attrsText}>${truncatedText}`; + } + return `<${tagName}${attrsText} />`; +}; + export const getHTMLPreview = (element: Element): string => { const tagName = getTagName(element); const attrsText = formatAttrsForPreview(element); diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index da2761ca5..fe70ec937 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,7 +1,14 @@ -import { copyContent, type ReactGrabEntry } from "../utils/copy-content.js"; -import { generateSnippet } from "../utils/generate-snippet.js"; -import { joinSnippets } from "../utils/join-snippets.js"; +import { + resolveSource, + checkIsNextProject, + getComponentDisplayName, + getInlineHTMLPreview, +} from "./context.js"; +import { COMPACT_IDENTIFYING_ATTRS, COMPACT_TEXT_MAX_LENGTH } from "../constants.js"; +import { copyContent } from "../utils/copy-content.js"; +import { getTagName } from "../utils/get-tag-name.js"; import { normalizeError } from "../utils/normalize-error.js"; +import { truncateString } from "../utils/truncate-string.js"; interface CopyOptions { maxContextLines?: number; @@ -18,6 +25,44 @@ interface CopyHooks { onCopyError: (error: Error) => void; } +const buildCompactContent = async (elements: Element[]): Promise => { + const isNextProject = checkIsNextProject(); + const uniqueReferences = new Set(); + + for (const element of elements) { + const tagName = getTagName(element); + const source = await resolveSource(element); + const componentName = source?.componentName ?? getComponentDisplayName(element); + + if (source || componentName) { + let identifyingAttrs = ""; + for (const attrName of COMPACT_IDENTIFYING_ATTRS) { + const attrValue = element.getAttribute(attrName); + if (attrValue) { + identifyingAttrs += ` ${attrName}="${attrValue}"`; + } + } + const directText = element.textContent?.trim() ?? ""; + const textSnippet = directText + ? ` "${truncateString(directText, COMPACT_TEXT_MAX_LENGTH)}"` + : ""; + let inner = `<${tagName}${identifyingAttrs}>${textSnippet}`; + if (componentName) { + inner += ` in ${componentName}`; + } + if (source) { + const lineReference = isNextProject && source.lineNumber ? `:${source.lineNumber}` : ""; + inner += ` @${source.filePath}${lineReference}`; + } + uniqueReferences.add(`[${inner}]`); + } else { + uniqueReferences.add(`[${getInlineHTMLPreview(element)}]`); + } + } + + return uniqueReferences.size > 0 ? [...uniqueReferences].join("\n") : null; +}; + export const tryCopyWithFallback = async ( options: CopyOptions, hooks: CopyHooks, @@ -30,40 +75,17 @@ export const tryCopyWithFallback = async ( await hooks.onBeforeCopy(elements); try { - let generatedContent: string; - let entries: ReactGrabEntry[] | undefined; - - if (options.getContent) { - generatedContent = await options.getContent(elements); - } else { - const rawSnippets = await generateSnippet(elements, { - maxLines: options.maxContextLines, - }); - const transformedSnippets = await Promise.all( - rawSnippets.map((snippet, index) => - snippet.trim() ? hooks.transformSnippet(snippet, elements[index]) : Promise.resolve(""), - ), - ); - const snippetElementPairs = transformedSnippets - .map((snippet, index) => ({ snippet, element: elements[index] })) - .filter(({ snippet }) => snippet.trim()); - - generatedContent = joinSnippets(snippetElementPairs.map(({ snippet }) => snippet)); - entries = snippetElementPairs.map(({ snippet, element }) => ({ - tagName: element.localName, - content: snippet, - commentText: extraPrompt, - })); - } + const generatedContent = options.getContent + ? await options.getContent(elements) + : await buildCompactContent(elements); - if (generatedContent.trim()) { + if (generatedContent?.trim()) { const transformedContent = await hooks.transformCopyContent(generatedContent, elements); - copiedContent = extraPrompt ? `${extraPrompt}\n\n${transformedContent}` : transformedContent; + copiedContent = extraPrompt ? `${extraPrompt}\n${transformedContent}` : transformedContent; didCopy = copyContent(copiedContent, { componentName: options.componentName, - entries, }); } } catch (error) { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 941bd1ea1..d3e58be88 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -123,8 +123,7 @@ import { loadToolbarState, saveToolbarState } from "../components/toolbar/state. import { copyPlugin } from "./plugins/copy.js"; import { commentPlugin } from "./plugins/comment.js"; import { openPlugin } from "./plugins/open.js"; -import { copyHtmlPlugin } from "./plugins/copy-html.js"; -import { copyStylesPlugin } from "./plugins/copy-styles.js"; +import { copyDetailsPlugin } from "./plugins/copy-details.js"; import { freezeAnimations, freezeAllAnimations, @@ -147,7 +146,7 @@ import { generateId } from "../utils/generate-id.js"; import { logRecoverableError } from "../utils/log-recoverable-error.js"; import { getNearestEdge } from "../utils/get-nearest-edge.js"; -const builtInPlugins = [copyPlugin, commentPlugin, copyHtmlPlugin, copyStylesPlugin, openPlugin]; +const builtInPlugins = [copyPlugin, commentPlugin, copyDetailsPlugin, openPlugin]; interface CopyWithLabelOptions { element: Element; diff --git a/packages/react-grab/src/core/plugins/copy-details.ts b/packages/react-grab/src/core/plugins/copy-details.ts new file mode 100644 index 000000000..bb000f94f --- /dev/null +++ b/packages/react-grab/src/core/plugins/copy-details.ts @@ -0,0 +1,30 @@ +import { copyContent } from "../../utils/copy-content.js"; +import { generateSnippet } from "../../utils/generate-snippet.js"; +import { joinSnippets } from "../../utils/join-snippets.js"; +import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js"; + +export const copyDetailsPlugin = createPendingSelectionPlugin({ + name: "copy-details", + contextMenuAction: (api, hooks) => ({ + id: "copy-details", + label: "Copy details", + showInToolbarMenu: true, + onAction: async (context) => { + await context.performWithFeedback(async () => { + const rawSnippets = await generateSnippet(context.elements); + const nonEmptySnippets = rawSnippets.filter((snippet) => snippet.trim()); + if (nonEmptySnippets.length === 0) return false; + const joinedContent = joinSnippets(nonEmptySnippets); + const transformedContent = await context.hooks.transformHtmlContent( + joinedContent, + context.elements, + ); + if (!transformedContent) return false; + return copyContent(transformedContent, { + componentName: context.componentName, + tagName: context.tagName, + }); + }); + }, + }), +}); diff --git a/packages/react-grab/src/core/plugins/copy-html.ts b/packages/react-grab/src/core/plugins/copy-html.ts deleted file mode 100644 index 34aadf08a..000000000 --- a/packages/react-grab/src/core/plugins/copy-html.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { appendStackContext } from "../../utils/append-stack-context.js"; -import { copyContent } from "../../utils/copy-content.js"; -import { stripInternalAttributes } from "../../utils/strip-internal-attributes.js"; -import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js"; - -export const copyHtmlPlugin = createPendingSelectionPlugin({ - name: "copy-html", - contextMenuAction: (api) => ({ - id: "copy-html", - label: "Copy HTML", - showInToolbarMenu: true, - onAction: async (context) => { - await context.performWithFeedback(async () => { - const combinedHtml = context.elements - .map((element) => stripInternalAttributes(element.outerHTML)) - .join("\n\n"); - - const transformedHtml = await context.hooks.transformHtmlContent( - combinedHtml, - context.elements, - ); - - if (!transformedHtml) return false; - - const stackContext = await api.getStackContext(context.element); - return copyContent(appendStackContext(transformedHtml, stackContext), { - componentName: context.componentName, - tagName: context.tagName, - }); - }); - }, - }), -}); diff --git a/packages/react-grab/src/core/plugins/copy-styles.ts b/packages/react-grab/src/core/plugins/copy-styles.ts deleted file mode 100644 index 054db10fa..000000000 --- a/packages/react-grab/src/core/plugins/copy-styles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { appendStackContext } from "../../utils/append-stack-context.js"; -import { copyContent } from "../../utils/copy-content.js"; -import { extractElementCss, disposeBaselineStyles } from "../../utils/extract-element-css.js"; -import { createPendingSelectionPlugin } from "./create-pending-selection-plugin.js"; - -export const copyStylesPlugin = createPendingSelectionPlugin({ - name: "copy-styles", - contextMenuAction: (api) => ({ - id: "copy-styles", - label: "Copy styles", - showInToolbarMenu: true, - onAction: async (context) => { - await context.performWithFeedback(async () => { - const combinedCss = context.elements.map(extractElementCss).join("\n\n"); - - const stackContext = await api.getStackContext(context.element); - return copyContent(appendStackContext(combinedCss, stackContext), { - componentName: context.componentName, - tagName: context.tagName, - }); - }); - }, - }), - cleanup: disposeBaselineStyles, -}); diff --git a/packages/react-grab/src/utils/append-stack-context.ts b/packages/react-grab/src/utils/append-stack-context.ts deleted file mode 100644 index 252e659aa..000000000 --- a/packages/react-grab/src/utils/append-stack-context.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const appendStackContext = (content: string, stackContext: string): string => { - if (!stackContext) return content; - return `${content}\n${stackContext}`; -}; From b8833a9b6a31d06c3b4151d6abb671205890d187 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 10:46:41 -0700 Subject: [PATCH 02/15] fix: lint unused params, use getDirectTextContent, bump text limit to 30 - Prefix unused params in copy-details plugin with _ - Switch from element.textContent to getDirectTextContent to avoid noisy concatenated text from container elements - Increase COMPACT_TEXT_MAX_LENGTH from 20 to 30 so short sentences like "This is deeply nested content" aren't truncated mid-word - Update drag-selection tests to assert component names instead of nested text content Co-authored-by: Cursor --- apps/website-v2/next-env.d.ts | 6 ++++++ packages/react-grab/e2e/drag-selection.spec.ts | 5 ++--- packages/react-grab/src/constants.ts | 2 +- packages/react-grab/src/core/context.ts | 2 +- packages/react-grab/src/core/copy.ts | 3 ++- packages/react-grab/src/core/plugins/copy-details.ts | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 apps/website-v2/next-env.d.ts diff --git a/apps/website-v2/next-env.d.ts b/apps/website-v2/next-env.d.ts new file mode 100644 index 000000000..9edff1c7c --- /dev/null +++ b/apps/website-v2/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/react-grab/e2e/drag-selection.spec.ts b/packages/react-grab/e2e/drag-selection.spec.ts index a6edf9b0a..b015318df 100644 --- a/packages/react-grab/e2e/drag-selection.spec.ts +++ b/packages/react-grab/e2e/drag-selection.spec.ts @@ -131,7 +131,7 @@ test.describe("Drag Selection", () => { const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Buy groceries"); + expect(clipboardContent).toContain("TodoItem"); }); test("should cancel drag selection on Escape", async ({ reactGrab }) => { @@ -199,8 +199,7 @@ test.describe("Drag Selection", () => { const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); - expect(clipboardContent).toContain("Buy groceries"); - expect(clipboardContent).toContain("Write tests"); + expect(clipboardContent).toContain("TodoItem"); }); test("should show visual feedback during drag", async ({ reactGrab }) => { diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index bb5407266..e40bfd280 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -74,7 +74,7 @@ export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ ]; export const COMPACT_IDENTIFYING_ATTRS: readonly string[] = ["id", "data-testid"]; -export const COMPACT_TEXT_MAX_LENGTH = 20; +export const COMPACT_TEXT_MAX_LENGTH = 30; export const PREVIEW_IDENTIFYING_ATTRS = new Set([ "id", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 22e7d3da9..bd3defb86 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -532,7 +532,7 @@ const formatAttrsForPreview = (element: Element): string => { return identifyingParts.join("") + remainingParts.join("") + classAttr; }; -const getDirectTextContent = (element: Element): string => { +export const getDirectTextContent = (element: Element): string => { let directText = ""; for (const node of element.childNodes) { if (node.nodeType === Node.TEXT_NODE) { diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index fe70ec937..42097f830 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -2,6 +2,7 @@ import { resolveSource, checkIsNextProject, getComponentDisplayName, + getDirectTextContent, getInlineHTMLPreview, } from "./context.js"; import { COMPACT_IDENTIFYING_ATTRS, COMPACT_TEXT_MAX_LENGTH } from "../constants.js"; @@ -42,7 +43,7 @@ const buildCompactContent = async (elements: Element[]): Promise identifyingAttrs += ` ${attrName}="${attrValue}"`; } } - const directText = element.textContent?.trim() ?? ""; + const directText = getDirectTextContent(element); const textSnippet = directText ? ` "${truncateString(directText, COMPACT_TEXT_MAX_LENGTH)}"` : ""; diff --git a/packages/react-grab/src/core/plugins/copy-details.ts b/packages/react-grab/src/core/plugins/copy-details.ts index bb000f94f..9b1170d68 100644 --- a/packages/react-grab/src/core/plugins/copy-details.ts +++ b/packages/react-grab/src/core/plugins/copy-details.ts @@ -5,7 +5,7 @@ import { createPendingSelectionPlugin } from "./create-pending-selection-plugin. export const copyDetailsPlugin = createPendingSelectionPlugin({ name: "copy-details", - contextMenuAction: (api, hooks) => ({ + contextMenuAction: (_api, _hooks) => ({ id: "copy-details", label: "Copy details", showInToolbarMenu: true, From 13bedf95a97e40db49ef5867b6f5ece3bdd5d546 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 10:54:56 -0700 Subject: [PATCH 03/15] fix: update e2e tests for compact clipboard format Tests now assert component names (TodoItem, TodoList) instead of element text content, which the compact format omits for elements whose text lives in child nodes. Co-authored-by: Cursor --- packages/react-grab/e2e/context-menu.spec.ts | 2 +- .../react-grab/e2e/disabled-elements.spec.ts | 2 +- .../react-grab/e2e/drag-selection.spec.ts | 4 +-- .../react-grab/e2e/keyboard-shortcuts.spec.ts | 2 +- .../react-grab/e2e/shift-multi-select.spec.ts | 27 +++++-------------- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts index 284b93fd4..4fc3bd988 100644 --- a/packages/react-grab/e2e/context-menu.spec.ts +++ b/packages/react-grab/e2e/context-menu.spec.ts @@ -299,7 +299,7 @@ test.describe("Context Menu", () => { await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Walk the dog"); + expect(clipboardContent).toContain("TodoItem"); }); }); diff --git a/packages/react-grab/e2e/disabled-elements.spec.ts b/packages/react-grab/e2e/disabled-elements.spec.ts index 5afa770be..bf16af3f4 100644 --- a/packages/react-grab/e2e/disabled-elements.spec.ts +++ b/packages/react-grab/e2e/disabled-elements.spec.ts @@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => { await reactGrab.waitForSelectionBox(); await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Pointer Events None"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container"); }); test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/drag-selection.spec.ts b/packages/react-grab/e2e/drag-selection.spec.ts index b015318df..9cf1a9c19 100644 --- a/packages/react-grab/e2e/drag-selection.spec.ts +++ b/packages/react-grab/e2e/drag-selection.spec.ts @@ -131,7 +131,7 @@ test.describe("Drag Selection", () => { const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("TodoItem"); + expect(clipboardContent).toContain("TodoList"); }); test("should cancel drag selection on Escape", async ({ reactGrab }) => { @@ -199,7 +199,7 @@ test.describe("Drag Selection", () => { const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); - expect(clipboardContent).toContain("TodoItem"); + expect(clipboardContent).toContain("TodoList"); }); test("should show visual feedback during drag", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/keyboard-shortcuts.spec.ts b/packages/react-grab/e2e/keyboard-shortcuts.spec.ts index e10ef9b5b..232ebf3b0 100644 --- a/packages/react-grab/e2e/keyboard-shortcuts.spec.ts +++ b/packages/react-grab/e2e/keyboard-shortcuts.spec.ts @@ -43,7 +43,7 @@ test.describe("Keyboard Shortcuts", () => { await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Walk the dog"); + expect(clipboardContent).toContain("TodoItem"); }); test("should keep overlay active while navigating with arrow keys", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/shift-multi-select.spec.ts b/packages/react-grab/e2e/shift-multi-select.spec.ts index 14e805f92..060455597 100644 --- a/packages/react-grab/e2e/shift-multi-select.spec.ts +++ b/packages/react-grab/e2e/shift-multi-select.spec.ts @@ -42,9 +42,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Write tests"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); }); test("should show the pending hover target while shift multi-selecting", async ({ @@ -323,9 +321,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Walk the dog"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).not.toContain("Buy groceries"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); }); test("should not auto-copy until shift is released", async ({ reactGrab }) => { @@ -351,7 +347,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); }); test("should reset accumulated selection when a non-shift click follows", async ({ @@ -388,10 +384,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.activate(); await reactGrab.page.mouse.click(lastBox.x + lastBox.width / 2, lastBox.y + lastBox.height / 2); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Write tests"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).not.toContain("Buy groceries"); - expect(clipboardContent).not.toContain("Walk the dog"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); }); test("should not commit accumulated selection when shift releases over an open context menu", async ({ @@ -511,9 +504,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); await reactGrab.page.mouse.up(); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Walk the dog"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); const userSelectStyle = await reactGrab.page.evaluate(() => document.body.style.userSelect); expect(userSelectStyle).toBe(""); @@ -594,9 +585,7 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Walk the dog"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); }); test("should extend existing drag selection with shift+click", async ({ reactGrab }) => { @@ -630,8 +619,6 @@ test.describe("Shift Multi-Select", () => { await reactGrab.page.keyboard.up("Shift"); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toContain("Write tests"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem"); }); }); From bc9cf8943df2706c101f3d4187d9795eeaeb5fb6 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 11:00:50 -0700 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20remove=20tautological=20tests,=20dead=20transformSn?= =?UTF-8?q?ippet=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify element-context test assertions to directly verify compact format and component name instead of tautological OR - Remove transformSnippet from CopyHooks and call site since the compact format bypasses per-element snippet transforms Co-authored-by: Cursor --- packages/react-grab/e2e/element-context.spec.ts | 15 ++++++--------- packages/react-grab/src/core/copy.ts | 1 - packages/react-grab/src/core/index.tsx | 1 - 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 0c28e5283..7a722bf4d 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -12,9 +12,8 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement("[data-testid='todo-list'] h1"); const clipboard = await reactGrab.getClipboardContent(); - const isCompactFormat = /^\[<\w+>/.test(clipboard) && clipboard.includes("TodoList"); - const isVerboseFormat = clipboard.includes("TodoList"); - expect(isCompactFormat || isVerboseFormat).toBe(true); + expect(clipboard).toMatch(/^\[<\w+>/); + expect(clipboard).toContain("TodoList"); }); test("should produce useful context for nested elements", async ({ @@ -27,9 +26,8 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement("[data-testid='nested-button']"); const clipboard = await reactGrab.getClipboardContent(); - const isCompactFormat = /^\[<\w+>/.test(clipboard) && clipboard.includes("NestedCard"); - const isVerboseFormat = clipboard.includes("NestedCard"); - expect(isCompactFormat || isVerboseFormat).toBe(true); + expect(clipboard).toMatch(/^\[<\w+>/); + expect(clipboard).toContain("NestedCard"); }); test("should produce useful context for todo items", async ({ reactGrab }) => { @@ -41,9 +39,8 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement(todoItem); const clipboard = await reactGrab.getClipboardContent(); - const isCompactFormat = /^\[<\w+>/.test(clipboard) && clipboard.includes("TodoItem"); - const isVerboseFormat = clipboard.includes("TodoItem"); - expect(isCompactFormat || isVerboseFormat).toBe(true); + expect(clipboard).toMatch(/^\[<\w+>/); + expect(clipboard).toContain("TodoItem"); }); }); diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 42097f830..41ce3ee2e 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -19,7 +19,6 @@ interface CopyOptions { interface CopyHooks { onBeforeCopy: (elements: Element[]) => Promise; - transformSnippet: (snippet: string, element: Element) => Promise; transformCopyContent: (content: string, elements: Element[]) => Promise; onAfterCopy: (elements: Element[], success: boolean) => void; onCopySuccess: (elements: Element[], content: string) => void; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index d3e58be88..0f0d237bc 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -845,7 +845,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, { onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, - transformSnippet: pluginRegistry.hooks.transformSnippet, transformCopyContent: pluginRegistry.hooks.transformCopyContent, onAfterCopy: pluginRegistry.hooks.onAfterCopy, onCopySuccess: (copiedElements: Element[], content: string) => { From 18abe920c246dec389b980e1e103e0642599f1a2 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 11:06:30 -0700 Subject: [PATCH 05/15] fix: regex for compact format with identifying attrs The \w+ pattern fails when attrs like data-testid follow the tag name. Use [\s>] to match either a space (attrs present) or > (no attrs). Co-authored-by: Cursor --- packages/react-grab/e2e/element-context.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 7a722bf4d..632d4b56d 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -12,7 +12,7 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement("[data-testid='todo-list'] h1"); const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toMatch(/^\[<\w+>/); + expect(clipboard).toMatch(/^\[<\w+[\s>]/); expect(clipboard).toContain("TodoList"); }); @@ -26,7 +26,7 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement("[data-testid='nested-button']"); const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toMatch(/^\[<\w+>/); + expect(clipboard).toMatch(/^\[<\w+[\s>]/); expect(clipboard).toContain("NestedCard"); }); @@ -39,7 +39,7 @@ test.describe("Element Context Fallback", () => { await reactGrab.clickElement(todoItem); const clipboard = await reactGrab.getClipboardContent(); - expect(clipboard).toMatch(/^\[<\w+>/); + expect(clipboard).toMatch(/^\[<\w+[\s>]/); expect(clipboard).toContain("TodoItem"); }); }); From 87e71cd0511f580ebad8a657d2e973d4b694650a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 11:16:59 -0700 Subject: [PATCH 06/15] fix: remove dead maxContextLines option, drop accidental next-env.d.ts - Remove maxContextLines from CopyOptions and call site since the compact format doesn't use it - Remove accidentally committed apps/website-v2/next-env.d.ts Co-authored-by: Cursor --- packages/react-grab/src/core/copy.ts | 1 - packages/react-grab/src/core/index.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 41ce3ee2e..41d1de23e 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -12,7 +12,6 @@ import { normalizeError } from "../utils/normalize-error.js"; import { truncateString } from "../utils/truncate-string.js"; interface CopyOptions { - maxContextLines?: number; getContent?: (elements: Element[]) => Promise | string; componentName?: string; } diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 0f0d237bc..b20abcc6d 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -839,7 +839,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return tryCopyWithFallback( { - maxContextLines: pluginRegistry.store.options.maxContextLines, getContent: pluginRegistry.store.options.getContent, componentName: elementName, }, From bc6c51a356e2ee3c92bd2a8013842624b9ea3ff3 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 11:17:09 -0700 Subject: [PATCH 07/15] chore: remove accidentally committed next-env.d.ts Co-authored-by: Cursor --- apps/website-v2/next-env.d.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/website-v2/next-env.d.ts diff --git a/apps/website-v2/next-env.d.ts b/apps/website-v2/next-env.d.ts deleted file mode 100644 index 9edff1c7c..000000000 --- a/apps/website-v2/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 22c28cbf7652fa083ae58e4b08386c9e7401c919 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 11:30:53 -0700 Subject: [PATCH 08/15] refactor: deduplicate getFallbackContext SVG branch via getInlineHTMLPreview Co-authored-by: Cursor --- packages/react-grab/src/core/context.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index bd3defb86..f0125d963 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -459,16 +459,11 @@ export const getElementContext = async ( }; const getFallbackContext = (element: Element): string => { - const tagName = getTagName(element); - if (!(element instanceof HTMLElement)) { - const attrsHint = formatPriorityAttrs(element, { - truncate: false, - maxAttrs: PREVIEW_PRIORITY_ATTRS.length, - }); - return `<${tagName}${attrsHint} />`; + return getInlineHTMLPreview(element); } + const tagName = getTagName(element); const attrsText = formatAttrsForPreview(element); const directText = getDirectTextContent(element); const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH); From f9583fd4e1146fc6214f5fd27e1c6f7ac47ffffa Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 20:00:45 -0700 Subject: [PATCH 09/15] fix: assert pointer-events-none element, not parent container Co-authored-by: Cursor --- packages/react-grab/e2e/disabled-elements.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/e2e/disabled-elements.spec.ts b/packages/react-grab/e2e/disabled-elements.spec.ts index bf16af3f4..b5141346d 100644 --- a/packages/react-grab/e2e/disabled-elements.spec.ts +++ b/packages/react-grab/e2e/disabled-elements.spec.ts @@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => { await reactGrab.waitForSelectionBox(); await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("pointer-events-none"); }); test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => { From e465d34250e23e4a458c5fa6a6a9ce8c63549a71 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 21:18:54 -0700 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20revert=20disabled-elements=20test?= =?UTF-8?q?=20=E2=80=94=20click=20selects=20container,=20not=20child?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The child has pointer-events: none so the click falls through to the parent container. The clipboard correctly shows disabled-test-container. Co-authored-by: Cursor --- packages/react-grab/e2e/disabled-elements.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/e2e/disabled-elements.spec.ts b/packages/react-grab/e2e/disabled-elements.spec.ts index b5141346d..bf16af3f4 100644 --- a/packages/react-grab/e2e/disabled-elements.spec.ts +++ b/packages/react-grab/e2e/disabled-elements.spec.ts @@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => { await reactGrab.waitForSelectionBox(); await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await expect.poll(() => reactGrab.getClipboardContent()).toContain("pointer-events-none"); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container"); }); test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => { From 25a14ad487f77827a2e20c8309e571dd31aae3f7 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 13 May 2026 23:05:23 -0700 Subject: [PATCH 11/15] refactor: use array-join pattern for compact reference assembly Co-authored-by: Cursor --- packages/react-grab/src/core/copy.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 41d1de23e..8ba660ea6 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -45,15 +45,14 @@ const buildCompactContent = async (elements: Element[]): Promise const textSnippet = directText ? ` "${truncateString(directText, COMPACT_TEXT_MAX_LENGTH)}"` : ""; - let inner = `<${tagName}${identifyingAttrs}>${textSnippet}`; - if (componentName) { - inner += ` in ${componentName}`; - } + + const parts = [`<${tagName}${identifyingAttrs}>${textSnippet}`]; + if (componentName) parts.push(`in ${componentName}`); if (source) { const lineReference = isNextProject && source.lineNumber ? `:${source.lineNumber}` : ""; - inner += ` @${source.filePath}${lineReference}`; + parts.push(`@${source.filePath}${lineReference}`); } - uniqueReferences.add(`[${inner}]`); + uniqueReferences.add(`[${parts.join(" ")}]`); } else { uniqueReferences.add(`[${getInlineHTMLPreview(element)}]`); } From 3218543c8c2c681d6d872ec1302c7eb3c6c97388 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 14 May 2026 16:43:35 -0700 Subject: [PATCH 12/15] perf: parallelize resolveSource calls with Promise.all Extract formatCompactReference so source resolution runs concurrently for all elements instead of sequentially in a loop. Co-authored-by: Cursor --- packages/react-grab/src/core/copy.ts | 65 +++++++++++++++------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 8ba660ea6..85a507614 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -24,40 +24,47 @@ interface CopyHooks { onCopyError: (error: Error) => void; } -const buildCompactContent = async (elements: Element[]): Promise => { - const isNextProject = checkIsNextProject(); - const uniqueReferences = new Set(); - - for (const element of elements) { - const tagName = getTagName(element); - const source = await resolveSource(element); - const componentName = source?.componentName ?? getComponentDisplayName(element); +const formatCompactReference = ( + element: Element, + source: Awaited>, + isNextProject: boolean, +): string => { + const tagName = getTagName(element); + const componentName = source?.componentName ?? getComponentDisplayName(element); - if (source || componentName) { - let identifyingAttrs = ""; - for (const attrName of COMPACT_IDENTIFYING_ATTRS) { - const attrValue = element.getAttribute(attrName); - if (attrValue) { - identifyingAttrs += ` ${attrName}="${attrValue}"`; - } - } - const directText = getDirectTextContent(element); - const textSnippet = directText - ? ` "${truncateString(directText, COMPACT_TEXT_MAX_LENGTH)}"` - : ""; + if (!source && !componentName) { + return `[${getInlineHTMLPreview(element)}]`; + } - const parts = [`<${tagName}${identifyingAttrs}>${textSnippet}`]; - if (componentName) parts.push(`in ${componentName}`); - if (source) { - const lineReference = isNextProject && source.lineNumber ? `:${source.lineNumber}` : ""; - parts.push(`@${source.filePath}${lineReference}`); - } - uniqueReferences.add(`[${parts.join(" ")}]`); - } else { - uniqueReferences.add(`[${getInlineHTMLPreview(element)}]`); + let identifyingAttrs = ""; + for (const attrName of COMPACT_IDENTIFYING_ATTRS) { + const attrValue = element.getAttribute(attrName); + if (attrValue) { + identifyingAttrs += ` ${attrName}="${attrValue}"`; } } + const directText = getDirectTextContent(element); + const textSnippet = directText + ? ` "${truncateString(directText, COMPACT_TEXT_MAX_LENGTH)}"` + : ""; + const parts = [`<${tagName}${identifyingAttrs}>${textSnippet}`]; + if (componentName) parts.push(`in ${componentName}`); + if (source) { + const lineReference = isNextProject && source.lineNumber ? `:${source.lineNumber}` : ""; + parts.push(`@${source.filePath}${lineReference}`); + } + return `[${parts.join(" ")}]`; +}; + +const buildCompactContent = async (elements: Element[]): Promise => { + const isNextProject = checkIsNextProject(); + const resolvedSources = await Promise.all(elements.map(resolveSource)); + const uniqueReferences = new Set( + elements.map((element, index) => + formatCompactReference(element, resolvedSources[index], isNextProject), + ), + ); return uniqueReferences.size > 0 ? [...uniqueReferences].join("\n") : null; }; From edb969ce64b7868f1447210d930792815a448049 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 15 May 2026 02:25:35 -0700 Subject: [PATCH 13/15] fix: remove dead transformSnippet hook and maxContextLines option - Remove transformSnippet from PluginHooks and plugin registry since the compact format bypasses per-element snippet transforms - Remove maxContextLines from Options, plugin registry, script options parsing, and init defaults since it's no longer consumed - Update architecture.md to reference copy-details instead of the deleted copy-html and copy-styles plugins Co-authored-by: Cursor --- packages/react-grab/docs/architecture.md | 5 ++--- packages/react-grab/src/core/index.tsx | 2 -- packages/react-grab/src/core/plugin-registry.ts | 7 +------ packages/react-grab/src/types.ts | 2 -- packages/react-grab/src/utils/get-script-options.ts | 3 --- 5 files changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/react-grab/docs/architecture.md b/packages/react-grab/docs/architecture.md index 90a3d68b6..6f1b2e235 100644 --- a/packages/react-grab/docs/architecture.md +++ b/packages/react-grab/docs/architecture.md @@ -23,7 +23,7 @@ The runtime has two main concerns: initialization and the interaction lifecycle. When the package is imported in a browser environment, [index.ts](../src/index.ts) checks that `window` exists and `__REACT_GRAB_DISABLED__` is not set, then calls `init()`. The returned API object is assigned to `window.__REACT_GRAB__`, any plugins that were registered via `registerPlugin()` before initialization completed are flushed from a pending queue, and a `react-grab:init` custom event is dispatched so other code on the page can react to the tool being ready. If `init()` is called in an SSR environment or when the tool is disabled, it returns a no-op API object with the same shape so that consumers don't need to guard every call. -Inside `init()`, the first thing that happens is merging any options provided programmatically with options parsed from the `data-options` JSON attribute on the script tag (via `getScriptOptions()`). Then `createPluginRegistry` and `createGrabStore` are called to set up the reactive state. A single `AbortController` manages all event listeners on `window` and `document`, so that teardown is a single `abort()` call. The five built-in plugins (copy, comment, open, copy-html, copy-styles) are registered through the same `register()` path that external plugins use. Finally, the renderer is loaded asynchronously via `import("../components/renderer.js")` and mounted into a Shadow DOM container created by [utils/mount-root.ts](../src/utils/mount-root.ts). +Inside `init()`, the first thing that happens is merging any options provided programmatically with options parsed from the `data-options` JSON attribute on the script tag (via `getScriptOptions()`). Then `createPluginRegistry` and `createGrabStore` are called to set up the reactive state. A single `AbortController` manages all event listeners on `window` and `document`, so that teardown is a single `abort()` call. The four built-in plugins (copy, comment, copy-details, open) are registered through the same `register()` path that external plugins use. Finally, the renderer is loaded asynchronously via `import("../components/renderer.js")` and mounted into a Shadow DOM container created by [utils/mount-root.ts](../src/utils/mount-root.ts). ### The interaction lifecycle @@ -202,5 +202,4 @@ The five built-in plugins are registered during `init()` through the same `regis - **copy** registers the default "Copy" context-menu action that calls the copy flow. - **comment** registers the "Comment" action that enters prompt mode. - **open** registers the "Open in editor" action that calls `openFile` with the resolved source location, running the URL through the `transformOpenFileUrl` hook pipeline first. -- **copy-html** registers "Copy HTML" which copies the element's `outerHTML` with stack context appended. -- **copy-styles** registers "Copy styles" which extracts the element's computed CSS (compared against a baseline from a hidden iframe) and copies it with stack context. +- **copy-details** registers "Copy details" which copies the full verbose HTML preview with the component stack. diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index b20abcc6d..e200958e0 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -70,7 +70,6 @@ import { INPUT_FOCUS_ACTIVATION_DELAY_MS, INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS, DEFAULT_KEY_HOLD_DURATION_MS, - DEFAULT_MAX_CONTEXT_LINES, MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS, ZOOM_DETECTION_THRESHOLD, WINDOW_REFOCUS_GRACE_PERIOD_MS, @@ -192,7 +191,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { activationMode: "toggle", keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS, allowActivationInsideInput: true, - maxContextLines: DEFAULT_MAX_CONTEXT_LINES, ...scriptOptions, ...rawOptions, }; diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index f91fad38d..690affc03 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -20,7 +20,7 @@ import type { ActionContext, } from "../types.js"; import { DEFAULT_THEME, deepMergeTheme } from "./theme.js"; -import { DEFAULT_KEY_HOLD_DURATION_MS, DEFAULT_MAX_CONTEXT_LINES } from "../constants.js"; +import { DEFAULT_KEY_HOLD_DURATION_MS } from "../constants.js"; interface RegisteredPlugin { plugin: Plugin; @@ -31,7 +31,6 @@ interface OptionsState { activationMode: ActivationMode; keyHoldDuration: number; allowActivationInsideInput: boolean; - maxContextLines: number; activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; freezeReactUpdates: boolean; @@ -41,7 +40,6 @@ const DEFAULT_OPTIONS: OptionsState = { activationMode: "toggle", keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS, allowActivationInsideInput: true, - maxContextLines: DEFAULT_MAX_CONTEXT_LINES, activationKey: undefined, getContent: undefined, freezeReactUpdates: true, @@ -105,7 +103,6 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { "activationMode", "keyHoldDuration", "allowActivationInsideInput", - "maxContextLines", "activationKey", "getContent", "freezeReactUpdates", @@ -306,8 +303,6 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { callHookReduceSync("transformActionContext", context), transformOpenFileUrl: (url: string, filePath: string, lineNumber?: number) => callHookReduceSync("transformOpenFileUrl", url, filePath, lineNumber), - transformSnippet: async (snippet: string, element: Element) => - callHookReduce("transformSnippet", snippet, element), }; return { diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 1405dffef..18d6b60de 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -218,7 +218,6 @@ export interface PluginHooks { ) => AgentContext | Promise; transformActionContext?: (context: ActionContext) => ActionContext; transformOpenFileUrl?: (url: string, filePath: string, lineNumber?: number) => string; - transformSnippet?: (snippet: string, element: Element) => string | Promise; } export interface PluginConfig { @@ -243,7 +242,6 @@ export interface Options { activationMode?: ActivationMode; keyHoldDuration?: number; allowActivationInsideInput?: boolean; - maxContextLines?: number; activationKey?: ActivationKey; getContent?: (elements: Element[]) => Promise | string; /** diff --git a/packages/react-grab/src/utils/get-script-options.ts b/packages/react-grab/src/utils/get-script-options.ts index 98d1c37da..a9fa7dd72 100644 --- a/packages/react-grab/src/utils/get-script-options.ts +++ b/packages/react-grab/src/utils/get-script-options.ts @@ -21,9 +21,6 @@ const parseOptionsFromJson = (rawValue: unknown): Partial | null => { if (typeof rawValue.allowActivationInsideInput === "boolean") { parsedOptions.allowActivationInsideInput = rawValue.allowActivationInsideInput; } - if (typeof rawValue.maxContextLines === "number" && Number.isFinite(rawValue.maxContextLines)) { - parsedOptions.maxContextLines = rawValue.maxContextLines; - } if (typeof rawValue.activationKey === "string") { parsedOptions.activationKey = rawValue.activationKey; } From e3a6b3c4f291c604267baff2ad9d2a33a8da0b2f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 15 May 2026 02:36:49 -0700 Subject: [PATCH 14/15] fix: sanitize quotes in compact references, export disposeBaselineStyles - Replace double quotes with single quotes in attr values and text snippets to prevent malformed bracket output - Re-export disposeBaselineStyles from primitives so consumers can clean up the baseline iframe created by extractElementCss Co-authored-by: Cursor --- packages/react-grab/src/core/copy.ts | 7 ++++--- packages/react-grab/src/primitives.ts | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 85a507614..083dd5664 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -40,12 +40,13 @@ const formatCompactReference = ( for (const attrName of COMPACT_IDENTIFYING_ATTRS) { const attrValue = element.getAttribute(attrName); if (attrValue) { - identifyingAttrs += ` ${attrName}="${attrValue}"`; + identifyingAttrs += ` ${attrName}="${attrValue.replaceAll('"', "'")}"`; } } const directText = getDirectTextContent(element); - const textSnippet = directText - ? ` "${truncateString(directText, COMPACT_TEXT_MAX_LENGTH)}"` + const sanitizedText = directText.replaceAll('"', "'"); + const textSnippet = sanitizedText + ? ` "${truncateString(sanitizedText, COMPACT_TEXT_MAX_LENGTH)}"` : ""; const parts = [`<${tagName}${identifyingAttrs}>${textSnippet}`]; diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index 3091fd4b7..3513203f3 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -21,7 +21,7 @@ import { Fiber, getFiberFromHostInstance } from "bippy"; import type { StackFrame } from "bippy/source"; export type { StackFrame }; import { createElementSelector } from "./utils/create-element-selector.js"; -import { extractElementCss } from "./utils/extract-element-css.js"; +import { extractElementCss, disposeBaselineStyles } from "./utils/extract-element-css.js"; import { openFile as openFileAsync } from "./utils/open-file.js"; export interface ReactGrabElementContext { @@ -163,3 +163,5 @@ export const isFreezeActive = (): boolean => { export const openFile = async (filePath: string, lineNumber?: number): Promise => { await openFileAsync(filePath, lineNumber); }; + +export { disposeBaselineStyles }; From 2e4dd2ac7f2af040840fa03ae92c6326e70318fd Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 15 May 2026 04:30:54 -0700 Subject: [PATCH 15/15] fix: restore primitives API documentation in README The Primitives section was accidentally removed when copy-styles and copy-html plugins were deleted. The API itself is unchanged. Co-authored-by: Cursor --- packages/react-grab/README.md | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 77d973673..764e48cfc 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -124,6 +124,46 @@ if (process.env.NODE_ENV === "development") { } ``` +## Primitives + +`react-grab/primitives` exposes low-level building blocks for tools that need to freeze the page, hit-test elements, or gather React source context outside of React Grab's UI. + +| Export | Description | +| --- | --- | +| `freeze(elements?)` | Pause React updates, animations, and pseudo-states. Pass elements to scope animation freezing, or omit to freeze the whole page. | +| `unfreeze()` | Resume everything `freeze()` paused. | +| `isFreezeActive()` | Returns `true` while frozen. | +| `getElementsAtPosition(x, y)` | Hit-test elements at viewport coordinates while frozen (temporarily lifts the pointer-events block). | +| `getElementContext(element)` | Returns the same context React Grab copies to clipboard (`snippet`), plus structured source location, component name, owner stack, CSS selector, computed styles, HTML preview, and fiber. | +| `copyContent(text, options?)` | Copies text to the clipboard in the same three-format layout React Grab uses (plain text, HTML, structured metadata). Returns `true` on success. | +| `openFile(path, line?)` | Open a source file in the user's editor via the dev server or `vscode://` protocol. | + +```js +import { + freeze, + unfreeze, + getElementsAtPosition, + getElementContext, + copyContent, +} from "react-grab/primitives"; + +freeze(); + +document.addEventListener("pointermove", (e) => { + const [topElement] = getElementsAtPosition(e.clientX, e.clientY); + highlight(topElement); +}); + +document.addEventListener("click", async (e) => { + const [target] = getElementsAtPosition(e.clientX, e.clientY); + const context = await getElementContext(target); + console.log(context.snippet); // formatted text identical to clipboard output + console.log(context.filePath); // "/src/components/Button.tsx" + console.log(context.lineNumber); // 42 + unfreeze(); +}); +``` + ## Plugins Use plugins to extend React Grab's built-in UI with context menu actions, toolbar menu items, lifecycle hooks, and theme overrides. Plugins run within React Grab.