From 81e80f99902264ef14c0ffd2153497c192accdec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 25 Jun 2026 05:58:16 +0000 Subject: [PATCH 1/9] feat(react-grab): resolve style edits to design tokens from CSS variables Co-authored-by: Aiden Bai --- .changeset/style-design-tokens.md | 5 + apps/e2e-app-vite/src/index.css | 7 + packages/react-grab/e2e/edit-panel.spec.ts | 49 +++++ packages/react-grab/src/core/edit-mode.ts | 2 + packages/react-grab/src/types.ts | 13 ++ .../src/utils/collect-design-tokens.ts | 201 ++++++++++++++++++ .../src/utils/format-edit-prompt.ts | 72 +++++-- 7 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 .changeset/style-design-tokens.md create mode 100644 packages/react-grab/src/utils/collect-design-tokens.ts diff --git a/.changeset/style-design-tokens.md b/.changeset/style-design-tokens.md new file mode 100644 index 000000000..f96349516 --- /dev/null +++ b/.changeset/style-design-tokens.md @@ -0,0 +1,5 @@ +--- +"react-grab": minor +--- + +Style mode now resolves committed values to the project's design tokens when copying. Tokens are derived from the CSS custom properties already defined in the page's cascade, so this works for any library that exposes design tokens as CSS variables (shadcn/ui, Radix, Chakra, MUI, Tailwind v4 `@theme`, Panda, vanilla-extract, …) rather than a single hard-coded framework. When a tweaked color matches a token, or a length matches a token whose name shares the property's family (spacing/size/radius/font-size/…), the copied CSS annotates the declaration with a `/* var(--token) */` hint and the prompt nudges the agent to prefer the token over the raw value. diff --git a/apps/e2e-app-vite/src/index.css b/apps/e2e-app-vite/src/index.css index f1d8c73cd..a7afd251e 100644 --- a/apps/e2e-app-vite/src/index.css +++ b/apps/e2e-app-vite/src/index.css @@ -1 +1,8 @@ @import "tailwindcss"; + +/* Library-agnostic design tokens used by the design-token prompt e2e test. + Definition-only (never referenced), so they add no visual change. */ +:root { + --rg-test-space-4: 16px; + --rg-test-brand: #123456; +} diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index 293f1de40..712e68946 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -1510,6 +1510,55 @@ test.describe("Style Panel", () => { expect(clipboardContent).not.toContain("padding-top: 8px;"); }); + test("copied prompt annotates a length matching a design token", async ({ reactGrab }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "p-4"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("16px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect + .poll(() => reactGrab.getClipboardContent()) + .toContain("/* var(--rg-test-space-4) */"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-top: 16px; /* var(--rg-test-space-4) */"); + expect(clipboardContent).toContain("Prefer the design token"); + }); + + test("copied prompt annotates a color matching a design token", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "text-[#123456]"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "color")) + .not.toBe(""); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect + .poll(() => reactGrab.getClipboardContent()) + .toContain("/* var(--rg-test-brand) */"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("color: #123456; /* var(--rg-test-brand) */"); + }); + + test("copied prompt leaves a non-token length unannotated", async ({ reactGrab }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + // 17px has no matching design token, so the prompt keeps the raw value. + await setSearchInputValue(reactGrab.page, "p-[17px]"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("17px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 17px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).not.toContain("/* var("); + expect(clipboardContent).not.toContain("Prefer the design token"); + }); + test("header Copy button appears after a pending tweak and submits", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); expect(await isHeaderCopyButtonVisible(reactGrab.page)).toBe(false); diff --git a/packages/react-grab/src/core/edit-mode.ts b/packages/react-grab/src/core/edit-mode.ts index 8747807ea..b8e959883 100644 --- a/packages/react-grab/src/core/edit-mode.ts +++ b/packages/react-grab/src/core/edit-mode.ts @@ -7,6 +7,7 @@ import type { PreviewStyles, } from "../types.js"; import { buildEditableProperties } from "../utils/build-editable-properties.js"; +import { collectDesignTokens } from "../utils/collect-design-tokens.js"; import { createElementBounds } from "../utils/create-element-bounds.js"; import { formatSessionEditsPrompt } from "../utils/format-edit-prompt.js"; import { getTagName } from "../utils/get-tag-name.js"; @@ -244,6 +245,7 @@ export const createEditModeController = ( filePath: record.filePath, lineNumber: record.lineNumber, edits: [...record.edits], + designTokens: collectDesignTokens(record.element), }); } return Array.from(entryByElement.values()); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index accefb823..2f6dc18b2 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -234,10 +234,23 @@ export type PendingEdit = NumericPendingEdit | ColorPendingEdit | EnumPendingEdi export type PendingEdits = PendingEdit[]; +export interface DesignTokenResolver { + // Whether the page exposes any color/length design tokens at all. + readonly hasTokens: boolean; + // Returns the matching custom property name (e.g. "--primary") for a hex + // color, or null when no token resolves to that value. + matchColor: (hex: string) => string | null; + // Returns the matching custom property name for a px length, gated on the + // token name sharing the css property's family so unrelated scales that + // happen to share a value (font-size vs spacing) don't cross-match. + matchLength: (px: number, cssProperty: string) => string | null; +} + export interface PendingEditsEntry { filePath: string; lineNumber: number; edits: PendingEdits; + designTokens?: DesignTokenResolver; } export interface PreviewStyles { diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts new file mode 100644 index 000000000..72e8fc2fb --- /dev/null +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -0,0 +1,201 @@ +import { EDIT_ROOT_FONT_SIZE_PX } from "../constants.js"; +import type { DesignTokenResolver } from "../types.js"; +import { parseAnyColor } from "./parse-any-color.js"; + +// Design tokens are surfaced through CSS custom properties regardless of +// the styling library (shadcn, Radix, Chakra, MUI, Tailwind v4 `@theme`, +// Panda, vanilla-extract, …), so reading the cascade's `--*` declarations +// keeps token resolution library-agnostic instead of hard-coding one +// framework's scale. + +type TokenFamily = + | "spacing" + | "size" + | "radius" + | "font-size" + | "line-height" + | "letter-spacing" + | "border-width"; + +interface LengthToken { + name: string; + families: ReadonlySet; +} + +const LENGTH_VALUE_PATTERN = /^(-?\d*\.?\d+)(px|rem)$/i; + +const FAMILY_NAME_KEYWORDS: ReadonlyArray<{ keyword: string; family: TokenFamily }> = [ + { keyword: "letter", family: "letter-spacing" }, + { keyword: "tracking", family: "letter-spacing" }, + { keyword: "leading", family: "line-height" }, + { keyword: "line-height", family: "line-height" }, + { keyword: "lineheight", family: "line-height" }, + { keyword: "radius", family: "radius" }, + { keyword: "rounded", family: "radius" }, + { keyword: "corner", family: "radius" }, + { keyword: "font-size", family: "font-size" }, + { keyword: "fontsize", family: "font-size" }, + { keyword: "text", family: "font-size" }, + { keyword: "stroke", family: "border-width" }, + { keyword: "border-width", family: "border-width" }, + { keyword: "borderwidth", family: "border-width" }, + { keyword: "space", family: "spacing" }, + { keyword: "gap", family: "spacing" }, + { keyword: "gutter", family: "spacing" }, + { keyword: "inset", family: "spacing" }, + { keyword: "padding", family: "spacing" }, + { keyword: "margin", family: "spacing" }, + { keyword: "size", family: "size" }, + { keyword: "width", family: "size" }, + { keyword: "height", family: "size" }, +]; + +const familiesFromTokenName = (tokenName: string): Set => { + const normalizedName = tokenName.toLowerCase(); + const families = new Set(); + for (const { keyword, family } of FAMILY_NAME_KEYWORDS) { + if (normalizedName.includes(keyword)) families.add(family); + } + return families; +}; + +const familyForCssProperty = (cssProperty: string): TokenFamily | null => { + if (cssProperty.includes("radius")) return "radius"; + if (cssProperty.endsWith("-width") && cssProperty.includes("border")) return "border-width"; + if (cssProperty === "font-size") return "font-size"; + if (cssProperty === "line-height") return "line-height"; + if (cssProperty === "letter-spacing") return "letter-spacing"; + if ( + cssProperty.startsWith("padding") || + cssProperty.startsWith("margin") || + cssProperty.endsWith("gap") || + cssProperty === "top" || + cssProperty === "right" || + cssProperty === "bottom" || + cssProperty === "left" || + cssProperty.startsWith("inset") + ) { + return "spacing"; + } + if ( + cssProperty === "width" || + cssProperty === "height" || + cssProperty.startsWith("min-") || + cssProperty.startsWith("max-") + ) { + return "size"; + } + return null; +}; + +const lengthValueToPx = (rawValue: string): number | null => { + const match = rawValue.trim().match(LENGTH_VALUE_PATTERN); + if (!match) return null; + const magnitude = Number.parseFloat(match[1]); + if (!Number.isFinite(magnitude)) return null; + const px = match[2].toLowerCase() === "rem" ? magnitude * EDIT_ROOT_FONT_SIZE_PX : magnitude; + return Math.round(px); +}; + +// A token reference is only useful if it is shorter / more semantic than +// the literal, so ties resolve to the shortest name (then alphabetically) +// for a deterministic, library-independent pick. +const preferToken = (candidate: string, incumbent: string | null): boolean => { + if (incumbent === null) return true; + if (candidate.length !== incumbent.length) return candidate.length < incumbent.length; + return candidate < incumbent; +}; + +const collectCustomPropertyNames = (): Set => { + const names = new Set(); + for (const styleSheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + // Cross-origin stylesheets throw on `.cssRules` access. + rules = styleSheet.cssRules; + } catch { + continue; + } + collectNamesFromRules(rules, names); + } + return names; +}; + +const collectNamesFromRules = (rules: CSSRuleList, names: Set) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule) { + const declaration = rule.style; + for (let propertyIndex = 0; propertyIndex < declaration.length; propertyIndex++) { + const propertyName = declaration.item(propertyIndex); + if (propertyName.startsWith("--")) names.add(propertyName); + } + } else if (rule instanceof CSSGroupingRule) { + collectNamesFromRules(rule.cssRules, names); + } + } +}; + +export const collectDesignTokens = (element: Element): DesignTokenResolver => { + if (typeof document === "undefined") return EMPTY_RESOLVER; + + const customPropertyNames = collectCustomPropertyNames(); + if (customPropertyNames.size === 0) return EMPTY_RESOLVER; + + const computed = getComputedStyle(element); + const colorNameByHex = new Map(); + const lengthTokensByPx = new Map(); + + for (const tokenName of customPropertyNames) { + const rawValue = computed.getPropertyValue(tokenName).trim(); + if (!rawValue) continue; + + const lengthPx = lengthValueToPx(rawValue); + if (lengthPx !== null) { + const tokens = lengthTokensByPx.get(lengthPx); + const lengthToken: LengthToken = { + name: tokenName, + families: familiesFromTokenName(tokenName), + }; + if (tokens) tokens.push(lengthToken); + else lengthTokensByPx.set(lengthPx, [lengthToken]); + continue; + } + + const colorHex = parseAnyColor(rawValue); + if (colorHex) { + const normalizedHex = colorHex.toLowerCase(); + const incumbent = colorNameByHex.get(normalizedHex) ?? null; + if (preferToken(tokenName, incumbent)) colorNameByHex.set(normalizedHex, tokenName); + } + } + + if (colorNameByHex.size === 0 && lengthTokensByPx.size === 0) return EMPTY_RESOLVER; + + const matchColor = (hex: string): string | null => { + const normalizedHex = parseAnyColor(hex)?.toLowerCase(); + return normalizedHex ? (colorNameByHex.get(normalizedHex) ?? null) : null; + }; + + const matchLength = (px: number, cssProperty: string): string | null => { + const tokens = lengthTokensByPx.get(Math.round(px)); + if (!tokens) return null; + const family = familyForCssProperty(cssProperty); + let best: string | null = null; + for (const token of tokens) { + // A purely numeric value (16px) collides across unrelated scales + // (font-size, spacing, radius), so only a token whose name signals + // the same family is a trustworthy match. + if (family === null || !token.families.has(family)) continue; + if (preferToken(token.name, best)) best = token.name; + } + return best; + }; + + return { hasTokens: true, matchColor, matchLength }; +}; + +const EMPTY_RESOLVER: DesignTokenResolver = { + hasTokens: false, + matchColor: () => null, + matchLength: () => null, +}; diff --git a/packages/react-grab/src/utils/format-edit-prompt.ts b/packages/react-grab/src/utils/format-edit-prompt.ts index f82c712fa..016a6eb31 100644 --- a/packages/react-grab/src/utils/format-edit-prompt.ts +++ b/packages/react-grab/src/utils/format-edit-prompt.ts @@ -1,7 +1,12 @@ import { OPACITY_PERCENT_MAX } from "../constants.js"; -import type { PendingEdit, PendingEditsEntry } from "../types.js"; +import type { DesignTokenResolver, PendingEdit, PendingEditsEntry } from "../types.js"; import { formatDisplayValue } from "./format-css-value.js"; +interface CssDeclaration { + edit: PendingEdit; + cssValue: string; +} + const formatCssValue = (pendingEdit: PendingEdit): string => { if (pendingEdit.kind === "color" || pendingEdit.kind === "enum") return pendingEdit.value; if (pendingEdit.key === "opacity" && pendingEdit.unit === "%") { @@ -10,41 +15,78 @@ const formatCssValue = (pendingEdit: PendingEdit): string => { return `${formatDisplayValue(pendingEdit.value)}${pendingEdit.unit}`; }; +const tokenForDeclaration = ( + cssProperty: string, + declaration: CssDeclaration, + designTokens: DesignTokenResolver | undefined, +): string | null => { + if (!designTokens?.hasTokens) return null; + const { edit } = declaration; + if (edit.kind === "color") return designTokens.matchColor(edit.value); + if (edit.kind === "numeric" && edit.unit === "px") { + return designTokens.matchLength(edit.value, cssProperty); + } + return null; +}; + const formatEntryCss = (pendingEditsEntry: PendingEditsEntry): string[] => { - const valueByCssProperty = new Map(); + const declarationByCssProperty = new Map(); for (const pendingEdit of pendingEditsEntry.edits) { const cssValue = formatCssValue(pendingEdit); for (const cssProperty of pendingEdit.cssProperties) { - valueByCssProperty.set(cssProperty, cssValue); + declarationByCssProperty.set(cssProperty, { edit: pendingEdit, cssValue }); } } - return Array.from( - valueByCssProperty, - ([cssProperty, cssValue]) => `${cssProperty}: ${cssValue};`, - ); + return Array.from(declarationByCssProperty, ([cssProperty, declaration]) => { + const tokenName = tokenForDeclaration(cssProperty, declaration, pendingEditsEntry.designTokens); + const tokenHint = tokenName ? ` /* var(${tokenName}) */` : ""; + return `${cssProperty}: ${declaration.cssValue};${tokenHint}`; + }); }; +const TOKEN_HINT_MARKER = "/* var("; + const formatEntryHeader = (pendingEditsEntry: PendingEditsEntry): string => pendingEditsEntry.filePath ? `${pendingEditsEntry.filePath}:${pendingEditsEntry.lineNumber}` : ""; +const wrapCssBlock = (cssLines: string[]): string => ["```css", ...cssLines, "```"].join("\n"); + +interface FormattedEntry { + header: string; + cssLines: string[]; +} + export const formatSessionEditsPrompt = (pendingEditsEntries: PendingEditsEntry[]): string => { if (pendingEditsEntries.length === 0) return ""; + const formattedEntries: FormattedEntry[] = pendingEditsEntries.map((pendingEditsEntry) => ({ + header: formatEntryHeader(pendingEditsEntry), + cssLines: formatEntryCss(pendingEditsEntry), + })); + const sections: string[] = [ "Apply these style changes canonically (CSS = visual intent; choose the source change that best expresses the underlying layout intent):", ]; - if (pendingEditsEntries.length === 1) { - const header = formatEntryHeader(pendingEditsEntries[0]); - if (header) sections.push(`\n${header}`); - sections.push(["```css", ...formatEntryCss(pendingEditsEntries[0]), "```"].join("\n")); + const hasTokenHint = formattedEntries.some((entry) => + entry.cssLines.some((line) => line.includes(TOKEN_HINT_MARKER)), + ); + if (hasTokenHint) { + sections.push( + "Prefer the design token shown in each `/* var(--token) */` comment over the raw value when it matches the project's intent.", + ); + } + + if (formattedEntries.length === 1) { + const [onlyEntry] = formattedEntries; + if (onlyEntry.header) sections.push(`\n${onlyEntry.header}`); + sections.push(wrapCssBlock(onlyEntry.cssLines)); return sections.join("\n"); } - for (const pendingEditsEntry of pendingEditsEntries) { - const header = formatEntryHeader(pendingEditsEntry); - sections.push(header ? `\n${header}` : ""); - sections.push(["```css", ...formatEntryCss(pendingEditsEntry), "```"].join("\n")); + for (const formattedEntry of formattedEntries) { + sections.push(formattedEntry.header ? `\n${formattedEntry.header}` : ""); + sections.push(wrapCssBlock(formattedEntry.cssLines)); } return sections.join("\n"); }; From 41538b44b79834e59a7a5cfaed0fdd5a22a8106d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 25 Jun 2026 06:58:43 +0000 Subject: [PATCH 2/9] chore: bump changeset to patch Co-authored-by: Aiden Bai --- .changeset/style-design-tokens.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/style-design-tokens.md b/.changeset/style-design-tokens.md index f96349516..eb04cb399 100644 --- a/.changeset/style-design-tokens.md +++ b/.changeset/style-design-tokens.md @@ -1,5 +1,5 @@ --- -"react-grab": minor +"react-grab": patch --- Style mode now resolves committed values to the project's design tokens when copying. Tokens are derived from the CSS custom properties already defined in the page's cascade, so this works for any library that exposes design tokens as CSS variables (shadcn/ui, Radix, Chakra, MUI, Tailwind v4 `@theme`, Panda, vanilla-extract, …) rather than a single hard-coded framework. When a tweaked color matches a token, or a length matches a token whose name shares the property's family (spacing/size/radius/font-size/…), the copied CSS annotates the declaration with a `/* var(--token) */` hint and the prompt nudges the agent to prefer the token over the raw value. From 41011ee5c7165ba43fdd4928b6ed8db89b137e16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 25 Jun 2026 11:34:29 +0000 Subject: [PATCH 3/9] feat(react-grab): snap Style-panel arrow stepping to the design-token scale Co-authored-by: Aiden Bai --- .changeset/style-design-tokens.md | 2 + apps/e2e-app-vite/src/index.css | 1 + packages/react-grab/e2e/edit-panel.spec.ts | 50 ++++++++++++++++++- .../src/components/edit-panel/index.tsx | 2 +- .../components/edit-panel/step-property.ts | 28 ++++++++--- packages/react-grab/src/core/edit-mode.ts | 7 ++- packages/react-grab/src/types.ts | 6 +++ .../src/utils/collect-design-tokens.ts | 48 ++++++++++++++++-- 8 files changed, 129 insertions(+), 15 deletions(-) diff --git a/.changeset/style-design-tokens.md b/.changeset/style-design-tokens.md index eb04cb399..7ff0cc648 100644 --- a/.changeset/style-design-tokens.md +++ b/.changeset/style-design-tokens.md @@ -3,3 +3,5 @@ --- Style mode now resolves committed values to the project's design tokens when copying. Tokens are derived from the CSS custom properties already defined in the page's cascade, so this works for any library that exposes design tokens as CSS variables (shadcn/ui, Radix, Chakra, MUI, Tailwind v4 `@theme`, Panda, vanilla-extract, …) rather than a single hard-coded framework. When a tweaked color matches a token, or a length matches a token whose name shares the property's family (spacing/size/radius/font-size/…), the copied CSS annotates the declaration with a `/* var(--token) */` hint and the prompt nudges the agent to prefer the token over the raw value. + +Arrow-key stepping in the Style panel now snaps a px property through that token scale (e.g. `←`/`→` walk the spacing tokens) instead of always nudging ±1px. Shift keeps the coarse raw step, and a value sitting outside the scale falls back to a raw step so stepping never dead-ends. diff --git a/apps/e2e-app-vite/src/index.css b/apps/e2e-app-vite/src/index.css index a7afd251e..2223772ed 100644 --- a/apps/e2e-app-vite/src/index.css +++ b/apps/e2e-app-vite/src/index.css @@ -4,5 +4,6 @@ Definition-only (never referenced), so they add no visual change. */ :root { --rg-test-space-4: 16px; + --rg-test-space-6: 24px; --rg-test-brand: #123456; } diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index 712e68946..ed59d729b 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -855,6 +855,51 @@ test.describe("Style Panel", () => { expect(await getActivePropertyValue(reactGrab.page)).toBe("96px"); }); + test("ArrowRight/ArrowLeft snap a length through the design-token scale", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "padding"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); + + // p-2 (8px) starts inside the [--rg-test-space-4: 16px, --rg-test-space-6: 24px] + // scale, so the arrows snap to the next/previous token rather than ±1px. + await reactGrab.page.keyboard.press("ArrowRight"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("16px"); + + await reactGrab.page.keyboard.press("ArrowRight"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + + await reactGrab.page.keyboard.press("ArrowLeft"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("16px"); + }); + + test("ArrowRight past the top of the token scale falls back to a raw step", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "padding"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); + + // 8px → 16px → 24px walks the token scale; the next press is at the + // scale's max token, so it nudges by a raw pixel instead of dead-ending. + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.keyboard.press("ArrowRight"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + await reactGrab.page.keyboard.press("ArrowRight"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("25px"); + }); + test("ArrowUp / ArrowDown navigate the list, not the value", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); const initialActivePropertyKey = await getActivePropertyKey(reactGrab.page); @@ -1703,9 +1748,12 @@ test.describe("Style Panel", () => { test("copy reverts a switched-away element whose tweak was undone", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); const buttonStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); + const buttonOriginalValue = (await getActivePropertyValue(reactGrab.page)) ?? ""; await reactGrab.page.keyboard.press("ArrowRight"); await reactGrab.page.waitForTimeout(80); - await reactGrab.page.keyboard.press("ArrowLeft"); + // Token snapping makes ArrowRight/ArrowLeft asymmetric, so restore the + // exact original value to net the button's edits back to zero. + await setSearchInputValue(reactGrab.page, buttonOriginalValue.replace(/[^\d.-]/g, "")); await reactGrab.page.waitForTimeout(80); await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true }); diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index e10e3b942..486a266d6 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -276,7 +276,7 @@ const EditPanelBody: Component = (props) => { ): EditableProperty | null => { const property = activeProperty(); if (!property) return null; - const nextValue = stepProperty(property, direction, shift); + const nextValue = stepProperty(property, direction, shift, props.state.designTokens); if (nextValue === null) { flashActiveKey(direction === 1 ? "right" : "left"); return null; diff --git a/packages/react-grab/src/components/edit-panel/step-property.ts b/packages/react-grab/src/components/edit-panel/step-property.ts index 43821cddd..69f30fb02 100644 --- a/packages/react-grab/src/components/edit-panel/step-property.ts +++ b/packages/react-grab/src/components/edit-panel/step-property.ts @@ -1,5 +1,5 @@ import { EDIT_SHIFT_STEP_MULTIPLIER } from "../../constants.js"; -import type { EditableProperty } from "../../types.js"; +import type { DesignTokenResolver, EditableProperty } from "../../types.js"; import { clampToRange } from "../../utils/clamp-to-range.js"; import { roundEditableNumericValue } from "../../utils/format-css-value.js"; import { pickNextOption } from "../../utils/pick-next-option.js"; @@ -10,23 +10,37 @@ export const stepProperty = ( property: EditableProperty, direction: 1 | -1, shift: boolean, + designTokens?: DesignTokenResolver, ): number | string | null => { if (property.kind === "enum") { const next = pickNextOption(property.options, property.value, direction); return next?.value ?? null; } if (property.kind !== "numeric") return null; - const multiplier = shift ? EDIT_SHIFT_STEP_MULTIPLIER : 1; + // Widen the range to include out-of-range originals (text-9xl is // 128px against a 96px font-size cap) so the first step nudges from // the real value instead of teleporting to the clamp bound — // possibly against the pressed direction. + const lowerBound = Math.min(property.min, property.value); + const upperBound = Math.max(property.max, property.value); + + // A plain arrow on a px property walks the project's token scale so values + // snap through the design system; Shift keeps the coarse raw step, and an + // off-scale value falls through to the raw step below so it never dead-ends. + if (!shift && property.unit === "px" && designTokens?.hasTokens) { + const tokenStep = designTokens.stepLength(property.value, direction, property.cssProperties[0]); + if (tokenStep !== null) { + const clampedTokenStep = roundEditableNumericValue( + clampToRange(tokenStep, lowerBound, upperBound), + ); + if (clampedTokenStep !== property.value) return clampedTokenStep; + } + } + + const multiplier = shift ? EDIT_SHIFT_STEP_MULTIPLIER : 1; const candidate = roundEditableNumericValue( - clampToRange( - property.value + direction * multiplier, - Math.min(property.min, property.value), - Math.max(property.max, property.value), - ), + clampToRange(property.value + direction * multiplier, lowerBound, upperBound), ); return candidate === property.value ? null : candidate; }; diff --git a/packages/react-grab/src/core/edit-mode.ts b/packages/react-grab/src/core/edit-mode.ts index b8e959883..6cb4a75e0 100644 --- a/packages/react-grab/src/core/edit-mode.ts +++ b/packages/react-grab/src/core/edit-mode.ts @@ -1,5 +1,6 @@ import { createSignal, type Accessor } from "solid-js"; import type { + DesignTokenResolver, EditPanelState, PendingEdits, PendingEditsEntry, @@ -28,6 +29,7 @@ interface EditSessionRecord { filePath: string; lineNumber: number; edits: PendingEdits; + designTokens?: DesignTokenResolver; } interface EditModeDependencies { @@ -86,6 +88,7 @@ const toSessionRecord = (currentState: EditPanelState, edits: PendingEdits): Edi filePath: currentState.filePath ?? "", lineNumber: currentState.lineNumber ?? 0, edits, + designTokens: currentState.designTokens, }); export const createEditModeController = ( @@ -153,6 +156,7 @@ export const createEditModeController = ( componentName: overrides.componentName, tagName: overrides.tagName ?? getTagName(element), initialSearchQuery: overrides.initialSearchQuery, + designTokens: collectDesignTokens(element), }); resolveComponentNameIntoState(element); @@ -189,6 +193,7 @@ export const createEditModeController = ( preview: createPreviewStyles(element), tagName: getTagName(element), hasSessionEdits: sessionRecords.some((record) => record.edits.length > 0), + designTokens: collectDesignTokens(element), }); // The store's selection source still points at the previous element, so @@ -245,7 +250,7 @@ export const createEditModeController = ( filePath: record.filePath, lineNumber: record.lineNumber, edits: [...record.edits], - designTokens: collectDesignTokens(record.element), + designTokens: record.designTokens, }); } return Array.from(entryByElement.values()); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 2f6dc18b2..0dc47ea72 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -244,6 +244,11 @@ export interface DesignTokenResolver { // token name sharing the css property's family so unrelated scales that // happen to share a value (font-size vs spacing) don't cross-match. matchLength: (px: number, cssProperty: string) => string | null; + // Returns the next/previous px value on the css property's token scale + // relative to `px`, or null when there is no token scale for the family or + // `px` sits outside it (so the caller can fall back to a raw step instead of + // dead-ending or teleporting across the scale). + stepLength: (px: number, direction: 1 | -1, cssProperty: string) => number | null; } export interface PendingEditsEntry { @@ -272,6 +277,7 @@ export interface EditPanelState { htmlPreview?: string; initialSearchQuery?: string; hasSessionEdits?: boolean; + designTokens?: DesignTokenResolver; } export interface ContextMenuAction { diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts index 72e8fc2fb..bdd40d9fb 100644 --- a/packages/react-grab/src/utils/collect-design-tokens.ts +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -144,6 +144,7 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { const computed = getComputedStyle(element); const colorNameByHex = new Map(); const lengthTokensByPx = new Map(); + const lengthPxByFamily = new Map>(); for (const tokenName of customPropertyNames) { const rawValue = computed.getPropertyValue(tokenName).trim(); @@ -151,13 +152,16 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { const lengthPx = lengthValueToPx(rawValue); if (lengthPx !== null) { + const families = familiesFromTokenName(tokenName); const tokens = lengthTokensByPx.get(lengthPx); - const lengthToken: LengthToken = { - name: tokenName, - families: familiesFromTokenName(tokenName), - }; + const lengthToken: LengthToken = { name: tokenName, families }; if (tokens) tokens.push(lengthToken); else lengthTokensByPx.set(lengthPx, [lengthToken]); + for (const family of families) { + const familyValues = lengthPxByFamily.get(family); + if (familyValues) familyValues.add(lengthPx); + else lengthPxByFamily.set(family, new Set([lengthPx])); + } continue; } @@ -171,11 +175,44 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { if (colorNameByHex.size === 0 && lengthTokensByPx.size === 0) return EMPTY_RESOLVER; + const sortedLengthPxByFamily = new Map(); + for (const [family, values] of lengthPxByFamily) { + sortedLengthPxByFamily.set( + family, + Array.from(values).sort((left, right) => left - right), + ); + } + const matchColor = (hex: string): string | null => { const normalizedHex = parseAnyColor(hex)?.toLowerCase(); return normalizedHex ? (colorNameByHex.get(normalizedHex) ?? null) : null; }; + const stepLength = (px: number, direction: 1 | -1, cssProperty: string): number | null => { + const family = familyForCssProperty(cssProperty); + if (family === null) return null; + const scale = sortedLengthPxByFamily.get(family); + if (!scale || scale.length === 0) return null; + const current = Math.round(px); + // Above the largest token the user is fine-tuning past the scale, so both + // directions defer to the raw step rather than teleporting back onto it. + if (current > scale[scale.length - 1]) return null; + if (direction === 1) { + // The first token greater than the current value — this also pulls a + // below-scale value (8px under a 16px floor) up onto the scale. + for (const value of scale) { + if (value > current) return value; + } + return null; + } + // Below the smallest token there is no lower token to snap down to. + if (current <= scale[0]) return null; + for (let scaleIndex = scale.length - 1; scaleIndex >= 0; scaleIndex--) { + if (scale[scaleIndex] < current) return scale[scaleIndex]; + } + return null; + }; + const matchLength = (px: number, cssProperty: string): string | null => { const tokens = lengthTokensByPx.get(Math.round(px)); if (!tokens) return null; @@ -191,11 +228,12 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { return best; }; - return { hasTokens: true, matchColor, matchLength }; + return { hasTokens: true, matchColor, matchLength, stepLength }; }; const EMPTY_RESOLVER: DesignTokenResolver = { hasTokens: false, matchColor: () => null, matchLength: () => null, + stepLength: () => null, }; From 4d5d413d4ae5bdc460d4ed4e3186276c21227d78 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 10:29:31 +0000 Subject: [PATCH 4/9] fix(react-grab): detect lab/oklch token colors and Tailwind --spacing grid Co-authored-by: Aiden Bai --- .changeset/style-design-tokens.md | 4 +- .../src/utils/collect-design-tokens.ts | 48 +++++++++++++------ .../react-grab/src/utils/parse-any-color.ts | 22 ++++++++- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/.changeset/style-design-tokens.md b/.changeset/style-design-tokens.md index 7ff0cc648..ac90c278f 100644 --- a/.changeset/style-design-tokens.md +++ b/.changeset/style-design-tokens.md @@ -4,4 +4,6 @@ Style mode now resolves committed values to the project's design tokens when copying. Tokens are derived from the CSS custom properties already defined in the page's cascade, so this works for any library that exposes design tokens as CSS variables (shadcn/ui, Radix, Chakra, MUI, Tailwind v4 `@theme`, Panda, vanilla-extract, …) rather than a single hard-coded framework. When a tweaked color matches a token, or a length matches a token whose name shares the property's family (spacing/size/radius/font-size/…), the copied CSS annotates the declaration with a `/* var(--token) */` hint and the prompt nudges the agent to prefer the token over the raw value. -Arrow-key stepping in the Style panel now snaps a px property through that token scale (e.g. `←`/`→` walk the spacing tokens) instead of always nudging ±1px. Shift keeps the coarse raw step, and a value sitting outside the scale falls back to a raw step so stepping never dead-ends. +Arrow-key stepping in the Style panel now snaps a px property through that token scale (e.g. `←`/`→` walk the spacing tokens) instead of always nudging ±1px. Shift keeps the coarse raw step, and a value sitting outside the scale falls back to a raw step so stepping never dead-ends. When a framework exposes spacing as a single base unit instead of discrete tokens (Tailwind v4's `--spacing`), stepping walks that base-unit grid. + +Color tokens are resolved through the browser's own rasterizer, so modern wide-gamut values that `getComputedStyle` returns — `lab()`, `lch()`, `oklab()`, `oklch()`, `color()` — are matched too (these are what Tailwind v4 / shadcn themes compile to), not just hex/rgb/hsl. diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts index bdd40d9fb..43cf37748 100644 --- a/packages/react-grab/src/utils/collect-design-tokens.ts +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -145,6 +145,9 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { const colorNameByHex = new Map(); const lengthTokensByPx = new Map(); const lengthPxByFamily = new Map>(); + // Tailwind exposes spacing (and sizing) as `calc(var(--spacing) * N)` rather + // than discrete per-step tokens, so the single base unit is the scale. + let spacingBaseUnitPx: number | null = null; for (const tokenName of customPropertyNames) { const rawValue = computed.getPropertyValue(tokenName).trim(); @@ -152,6 +155,7 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { const lengthPx = lengthValueToPx(rawValue); if (lengthPx !== null) { + if (tokenName === "--spacing" && lengthPx > 0) spacingBaseUnitPx = lengthPx; const families = familiesFromTokenName(tokenName); const tokens = lengthTokensByPx.get(lengthPx); const lengthToken: LengthToken = { name: tokenName, families }; @@ -191,24 +195,40 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { const stepLength = (px: number, direction: 1 | -1, cssProperty: string): number | null => { const family = familyForCssProperty(cssProperty); if (family === null) return null; - const scale = sortedLengthPxByFamily.get(family); - if (!scale || scale.length === 0) return null; const current = Math.round(px); - // Above the largest token the user is fine-tuning past the scale, so both - // directions defer to the raw step rather than teleporting back onto it. - if (current > scale[scale.length - 1]) return null; - if (direction === 1) { - // The first token greater than the current value — this also pulls a - // below-scale value (8px under a 16px floor) up onto the scale. - for (const value of scale) { - if (value > current) return value; + + // A discrete token scale (Radix/Chakra spacing, Tailwind `--text-*`, …) + // wins when present: snap to its neighbours and defer off-scale values to + // the raw step. + const scale = sortedLengthPxByFamily.get(family); + if (scale && scale.length >= 2) { + // Above the largest token the user is fine-tuning past the scale. + if (current > scale[scale.length - 1]) return null; + if (direction === 1) { + // The first token greater than current also pulls a below-scale value + // (8px under a 16px floor) up onto the scale. + for (const value of scale) { + if (value > current) return value; + } + return null; + } + // Below the smallest token there is no lower token to snap down to. + if (current <= scale[0]) return null; + for (let scaleIndex = scale.length - 1; scaleIndex >= 0; scaleIndex--) { + if (scale[scaleIndex] < current) return scale[scaleIndex]; } return null; } - // Below the smallest token there is no lower token to snap down to. - if (current <= scale[0]) return null; - for (let scaleIndex = scale.length - 1; scaleIndex >= 0; scaleIndex--) { - if (scale[scaleIndex] < current) return scale[scaleIndex]; + + // No discrete scale: walk Tailwind's spacing/sizing grid (multiples of the + // `--spacing` base unit) so the arrows still move by the design system step. + if ((family === "spacing" || family === "size") && spacingBaseUnitPx) { + const gridIndex = + direction === 1 + ? Math.floor(current / spacingBaseUnitPx) + 1 + : Math.ceil(current / spacingBaseUnitPx) - 1; + const next = gridIndex * spacingBaseUnitPx; + return next < 0 ? null : next; } return null; }; diff --git a/packages/react-grab/src/utils/parse-any-color.ts b/packages/react-grab/src/utils/parse-any-color.ts index 0d34ddcb8..5fbe71080 100644 --- a/packages/react-grab/src/utils/parse-any-color.ts +++ b/packages/react-grab/src/utils/parse-any-color.ts @@ -98,10 +98,28 @@ const getCanvasContext = (): CanvasRenderingContext2D | null => { if (canvasContext) return canvasContext; if (typeof document === "undefined") return null; const canvas = document.createElement("canvas"); - canvasContext = canvas.getContext("2d"); + canvasContext = canvas.getContext("2d", { willReadFrequently: true }); return canvasContext; }; +// Last resort for colors the canvas accepts but reflects back in a non-sRGB +// form rather than `#rgb`/`rgb()` — modern wide-gamut syntaxes like `lab()`, +// `lch()`, `oklab()`, and `color()`. Computed design tokens commonly land here +// (Tailwind v4 / shadcn resolve `oklch(...)` to `lab(...)` in getComputedStyle), +// so painting one pixel and reading it back lets the browser do the gamut +// conversion to sRGB bytes for us. +const rasterizeColorToHex = ( + canvasContext2d: CanvasRenderingContext2D, + cssColor: string, +): string | null => { + canvasContext2d.clearRect(0, 0, 1, 1); + canvasContext2d.fillStyle = cssColor; + canvasContext2d.fillRect(0, 0, 1, 1); + const pixel = canvasContext2d.getImageData(0, 0, 1, 1).data; + if (pixel[3] === 0) return EDIT_TRANSPARENT_COLOR_HEX; + return rgbaChannelsToHex(pixel[0], pixel[1], pixel[2], pixel[3] / 255); +}; + export const parseAnyColor = (input: string): string | null => { const trimmedColorInput = input.trim(); if (!trimmedColorInput) return null; @@ -133,5 +151,5 @@ export const parseAnyColor = (input: string): string | null => { } if (resolved.startsWith("#") && resolved.length === OPAQUE_HEX_LENGTH) return resolved; if (resolved.startsWith("rgb")) return rgbStringToHex(resolved); - return null; + return rasterizeColorToHex(canvasContext2d, trimmedColorInput); }; From 536af9ea0a29e55d0c9a1675116735094bd88db6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 04:00:45 +0000 Subject: [PATCH 5/9] refactor(react-grab): decompose stepLength helpers and drop prompt token marker scan Co-authored-by: Aiden Bai --- .../src/utils/collect-design-tokens.ts | 66 ++++++++++--------- .../src/utils/format-edit-prompt.ts | 35 +++++----- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts index 43cf37748..9d783510f 100644 --- a/packages/react-grab/src/utils/collect-design-tokens.ts +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -106,6 +106,40 @@ const preferToken = (candidate: string, incumbent: string | null): boolean => { return candidate < incumbent; }; +// Discrete scales (Radix/Chakra spacing, Tailwind `--text-*`, …) snap to the +// neighbouring token; an off-scale value returns null so the caller can fall +// back to a raw step instead of teleporting across the scale. +const nextValueInScale = ( + scale: readonly number[], + current: number, + direction: 1 | -1, +): number | null => { + if (current > scale[scale.length - 1]) return null; + if (direction === 1) { + // The first token greater than current also pulls a below-scale value + // (8px under a 16px floor) up onto the scale. + for (const value of scale) { + if (value > current) return value; + } + return null; + } + // Below the smallest token there is no lower token to snap down to. + if (current <= scale[0]) return null; + for (let scaleIndex = scale.length - 1; scaleIndex >= 0; scaleIndex--) { + if (scale[scaleIndex] < current) return scale[scaleIndex]; + } + return null; +}; + +// Tailwind exposes spacing/sizing as `calc(var(--spacing) * N)` with a single +// base unit rather than discrete tokens, so the arrows walk that grid. +const nextValueOnGrid = (current: number, direction: 1 | -1, unitPx: number): number | null => { + const gridIndex = + direction === 1 ? Math.floor(current / unitPx) + 1 : Math.ceil(current / unitPx) - 1; + const next = gridIndex * unitPx; + return next < 0 ? null : next; +}; + const collectCustomPropertyNames = (): Set => { const names = new Set(); for (const styleSheet of Array.from(document.styleSheets)) { @@ -197,38 +231,10 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { if (family === null) return null; const current = Math.round(px); - // A discrete token scale (Radix/Chakra spacing, Tailwind `--text-*`, …) - // wins when present: snap to its neighbours and defer off-scale values to - // the raw step. const scale = sortedLengthPxByFamily.get(family); - if (scale && scale.length >= 2) { - // Above the largest token the user is fine-tuning past the scale. - if (current > scale[scale.length - 1]) return null; - if (direction === 1) { - // The first token greater than current also pulls a below-scale value - // (8px under a 16px floor) up onto the scale. - for (const value of scale) { - if (value > current) return value; - } - return null; - } - // Below the smallest token there is no lower token to snap down to. - if (current <= scale[0]) return null; - for (let scaleIndex = scale.length - 1; scaleIndex >= 0; scaleIndex--) { - if (scale[scaleIndex] < current) return scale[scaleIndex]; - } - return null; - } - - // No discrete scale: walk Tailwind's spacing/sizing grid (multiples of the - // `--spacing` base unit) so the arrows still move by the design system step. + if (scale && scale.length >= 2) return nextValueInScale(scale, current, direction); if ((family === "spacing" || family === "size") && spacingBaseUnitPx) { - const gridIndex = - direction === 1 - ? Math.floor(current / spacingBaseUnitPx) + 1 - : Math.ceil(current / spacingBaseUnitPx) - 1; - const next = gridIndex * spacingBaseUnitPx; - return next < 0 ? null : next; + return nextValueOnGrid(current, direction, spacingBaseUnitPx); } return null; }; diff --git a/packages/react-grab/src/utils/format-edit-prompt.ts b/packages/react-grab/src/utils/format-edit-prompt.ts index 016a6eb31..7283aa445 100644 --- a/packages/react-grab/src/utils/format-edit-prompt.ts +++ b/packages/react-grab/src/utils/format-edit-prompt.ts @@ -29,7 +29,15 @@ const tokenForDeclaration = ( return null; }; -const formatEntryCss = (pendingEditsEntry: PendingEditsEntry): string[] => { +interface FormattedEntry { + header: string; + cssLines: string[]; + usesToken: boolean; +} + +const formatEntryCss = ( + pendingEditsEntry: PendingEditsEntry, +): { lines: string[]; usesToken: boolean } => { const declarationByCssProperty = new Map(); for (const pendingEdit of pendingEditsEntry.edits) { const cssValue = formatCssValue(pendingEdit); @@ -37,41 +45,34 @@ const formatEntryCss = (pendingEditsEntry: PendingEditsEntry): string[] => { declarationByCssProperty.set(cssProperty, { edit: pendingEdit, cssValue }); } } - return Array.from(declarationByCssProperty, ([cssProperty, declaration]) => { + let usesToken = false; + const lines = Array.from(declarationByCssProperty, ([cssProperty, declaration]) => { const tokenName = tokenForDeclaration(cssProperty, declaration, pendingEditsEntry.designTokens); + if (tokenName) usesToken = true; const tokenHint = tokenName ? ` /* var(${tokenName}) */` : ""; return `${cssProperty}: ${declaration.cssValue};${tokenHint}`; }); + return { lines, usesToken }; }; -const TOKEN_HINT_MARKER = "/* var("; - const formatEntryHeader = (pendingEditsEntry: PendingEditsEntry): string => pendingEditsEntry.filePath ? `${pendingEditsEntry.filePath}:${pendingEditsEntry.lineNumber}` : ""; const wrapCssBlock = (cssLines: string[]): string => ["```css", ...cssLines, "```"].join("\n"); -interface FormattedEntry { - header: string; - cssLines: string[]; -} - export const formatSessionEditsPrompt = (pendingEditsEntries: PendingEditsEntry[]): string => { if (pendingEditsEntries.length === 0) return ""; - const formattedEntries: FormattedEntry[] = pendingEditsEntries.map((pendingEditsEntry) => ({ - header: formatEntryHeader(pendingEditsEntry), - cssLines: formatEntryCss(pendingEditsEntry), - })); + const formattedEntries: FormattedEntry[] = pendingEditsEntries.map((pendingEditsEntry) => { + const { lines, usesToken } = formatEntryCss(pendingEditsEntry); + return { header: formatEntryHeader(pendingEditsEntry), cssLines: lines, usesToken }; + }); const sections: string[] = [ "Apply these style changes canonically (CSS = visual intent; choose the source change that best expresses the underlying layout intent):", ]; - const hasTokenHint = formattedEntries.some((entry) => - entry.cssLines.some((line) => line.includes(TOKEN_HINT_MARKER)), - ); - if (hasTokenHint) { + if (formattedEntries.some((entry) => entry.usesToken)) { sections.push( "Prefer the design token shown in each `/* var(--token) */` comment over the raw value when it matches the project's intent.", ); From cddbb8563c31d3ba183d25f3062b8c3853c1c55d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 11:32:26 +0000 Subject: [PATCH 6/9] feat(react-grab): Alt/Option arrow does a fine raw step that bypasses token snapping Co-authored-by: Aiden Bai --- .changeset/style-design-tokens.md | 2 +- packages/react-grab/e2e/edit-panel.spec.ts | 17 ++++++++++++++ .../src/components/edit-panel/index.tsx | 23 ++++++++++++------- .../{shift-tracker.ts => modifier-tracker.ts} | 17 ++++++++------ .../components/edit-panel/step-controller.ts | 16 +++++++++---- .../components/edit-panel/step-property.ts | 8 ++++--- 6 files changed, 59 insertions(+), 24 deletions(-) rename packages/react-grab/src/components/edit-panel/{shift-tracker.ts => modifier-tracker.ts} (52%) diff --git a/.changeset/style-design-tokens.md b/.changeset/style-design-tokens.md index ac90c278f..2f5e6c212 100644 --- a/.changeset/style-design-tokens.md +++ b/.changeset/style-design-tokens.md @@ -4,6 +4,6 @@ Style mode now resolves committed values to the project's design tokens when copying. Tokens are derived from the CSS custom properties already defined in the page's cascade, so this works for any library that exposes design tokens as CSS variables (shadcn/ui, Radix, Chakra, MUI, Tailwind v4 `@theme`, Panda, vanilla-extract, …) rather than a single hard-coded framework. When a tweaked color matches a token, or a length matches a token whose name shares the property's family (spacing/size/radius/font-size/…), the copied CSS annotates the declaration with a `/* var(--token) */` hint and the prompt nudges the agent to prefer the token over the raw value. -Arrow-key stepping in the Style panel now snaps a px property through that token scale (e.g. `←`/`→` walk the spacing tokens) instead of always nudging ±1px. Shift keeps the coarse raw step, and a value sitting outside the scale falls back to a raw step so stepping never dead-ends. When a framework exposes spacing as a single base unit instead of discrete tokens (Tailwind v4's `--spacing`), stepping walks that base-unit grid. +Arrow-key stepping in the Style panel now snaps a px property through that token scale (e.g. `←`/`→` walk the spacing tokens) instead of always nudging ±1px. `Shift` keeps the coarse raw step (×10) and `Alt`/`Option` does a fine raw ±1px step — both opt out of snapping so values can land between tokens. A value sitting outside the scale falls back to a raw step so stepping never dead-ends. When a framework exposes spacing as a single base unit instead of discrete tokens (Tailwind v4's `--spacing`), stepping walks that base-unit grid. Color tokens are resolved through the browser's own rasterizer, so modern wide-gamut values that `getComputedStyle` returns — `lab()`, `lch()`, `oklab()`, `oklch()`, `color()` — are matched too (these are what Tailwind v4 / shadcn themes compile to), not just hex/rgb/hsl. diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index ed59d729b..88f61c6c2 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -900,6 +900,23 @@ test.describe("Style Panel", () => { .toBe("25px"); }); + test("Alt+ArrowRight does a fine raw step instead of snapping to a token", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "padding"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); + + // Plain ArrowRight would snap 8px up to the 16px token; Alt opts out for + // a precise ±1px nudge so values can land between tokens. + await reactGrab.page.keyboard.down("Alt"); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.keyboard.up("Alt"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("9px"); + }); + test("ArrowUp / ArrowDown navigate the list, not the value", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); const initialActivePropertyKey = await getActivePropertyKey(reactGrab.page); diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index 486a266d6..c3b7e6ac9 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -47,7 +47,7 @@ import { EditPanelCopyButton } from "./copy-button.js"; import { createDiscardConfirmation } from "./discard-confirmation.js"; import { PropertyList } from "./property-list.js"; import { arePropertyValuesEqual } from "./property-values-equal.js"; -import { createShiftTracker } from "./shift-tracker.js"; +import { createModifierTracker } from "./modifier-tracker.js"; import { createStepController } from "./step-controller.js"; import { stepProperty } from "./step-property.js"; import { createStyleStore } from "./style-store.js"; @@ -266,17 +266,19 @@ const EditPanelBody: Component = (props) => { if (options.source === "keyboard") pointerMovePromptHandoff.arm(); }; - const isShiftHeld = createShiftTracker(); + const isShiftHeld = createModifierTracker((event) => event.shiftKey); + const isAltHeld = createModifierTracker((event) => event.altKey); const stepActiveProperty = ( direction: 1 | -1, shift: boolean, + alt: boolean, fromRepeat: boolean, source: "keyboard" | "pointer", ): EditableProperty | null => { const property = activeProperty(); if (!property) return null; - const nextValue = stepProperty(property, direction, shift, props.state.designTokens); + const nextValue = stepProperty(property, direction, shift, alt, props.state.designTokens); if (nextValue === null) { flashActiveKey(direction === 1 ? "right" : "left"); return null; @@ -290,16 +292,21 @@ const EditPanelBody: Component = (props) => { return property; }; - const stepFromKeyboard = (direction: 1 | -1, shift: boolean, fromRepeat: boolean) => { - if (!stepActiveProperty(direction, shift, fromRepeat, "keyboard")) return; + const stepFromKeyboard = ( + direction: 1 | -1, + shift: boolean, + alt: boolean, + fromRepeat: boolean, + ) => { + if (!stepActiveProperty(direction, shift, alt, fromRepeat, "keyboard")) return; setIsCompact(true); }; const stepFromPointer = (direction: 1 | -1) => { - stepActiveProperty(direction, false, false, "pointer"); + stepActiveProperty(direction, false, false, false, "pointer"); }; - const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld }); + const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld, isAltHeld }); const commitActive = (rawValue: number | string, source: "keyboard" | "pointer") => { const property = activeProperty(); @@ -420,7 +427,7 @@ const EditPanelBody: Component = (props) => { if (!event.repeat) getCurrentColorPickerTrigger()?.(); return; } - stepController.pressArrow(key, event.repeat, event.shiftKey); + stepController.pressArrow(key, event.repeat, event.shiftKey, event.altKey); }; const keyHandlers: Record void> = { diff --git a/packages/react-grab/src/components/edit-panel/shift-tracker.ts b/packages/react-grab/src/components/edit-panel/modifier-tracker.ts similarity index 52% rename from packages/react-grab/src/components/edit-panel/shift-tracker.ts rename to packages/react-grab/src/components/edit-panel/modifier-tracker.ts index 5f78da9ea..3687a66b1 100644 --- a/packages/react-grab/src/components/edit-panel/shift-tracker.ts +++ b/packages/react-grab/src/components/edit-panel/modifier-tracker.ts @@ -1,13 +1,16 @@ import { createSignal, onCleanup, onMount, type Accessor } from "solid-js"; -// Window blur resets because chord / alt-tab usually swallows the -// keyup event and leaves the flag stuck. -export const createShiftTracker = (): Accessor => { - const [isShiftHeld, setIsShiftHeld] = createSignal(false); +// Tracks whether a keyboard modifier is currently held. Window blur resets it +// because a chord / alt-tab usually swallows the keyup event and leaves the +// flag stuck. +export const createModifierTracker = ( + readModifier: (event: KeyboardEvent) => boolean, +): Accessor => { + const [isHeld, setIsHeld] = createSignal(false); onMount(() => { - const updateFromEvent = (event: KeyboardEvent) => setIsShiftHeld(event.shiftKey); - const clear = () => setIsShiftHeld(false); + const updateFromEvent = (event: KeyboardEvent) => setIsHeld(readModifier(event)); + const clear = () => setIsHeld(false); window.addEventListener("keydown", updateFromEvent, { capture: true }); window.addEventListener("keyup", updateFromEvent, { capture: true }); window.addEventListener("blur", clear); @@ -18,5 +21,5 @@ export const createShiftTracker = (): Accessor => { }); }); - return isShiftHeld; + return isHeld; }; diff --git a/packages/react-grab/src/components/edit-panel/step-controller.ts b/packages/react-grab/src/components/edit-panel/step-controller.ts index 5cd3b149e..b266b463f 100644 --- a/packages/react-grab/src/components/edit-panel/step-controller.ts +++ b/packages/react-grab/src/components/edit-panel/step-controller.ts @@ -10,13 +10,14 @@ type Direction = 1 | -1; const getDirectionForKey = (key: ArrowKey): Direction => (key === "ArrowLeft" ? -1 : 1); interface StepControllerOptions { - step: (direction: Direction, shift: boolean, isRepeat: boolean) => void; + step: (direction: Direction, shift: boolean, alt: boolean, isRepeat: boolean) => void; isShiftHeld: Accessor; + isAltHeld: Accessor; } export interface StepController { readonly heldDirection: Accessor<-1 | 0 | 1>; - pressArrow: (key: ArrowKey, isRepeat: boolean, shiftKey: boolean) => void; + pressArrow: (key: ArrowKey, isRepeat: boolean, shiftKey: boolean, altKey: boolean) => void; releaseKey: (key: string) => void; cancelRepeat: () => void; } @@ -46,17 +47,22 @@ export const createStepController = (options: StepControllerOptions): StepContro const direction = getDirectionForKey(key); repeatInitialDelayId = setTimeout(() => { repeatIntervalId = setInterval(() => { - options.step(direction, options.isShiftHeld(), true); + options.step(direction, options.isShiftHeld(), options.isAltHeld(), true); }, EDIT_STEP_REPEAT_INTERVAL_MS); }, EDIT_STEP_REPEAT_INITIAL_DELAY_MS); }; - const pressArrow = (key: ArrowKey, isRepeat: boolean, shiftKey: boolean): void => { + const pressArrow = ( + key: ArrowKey, + isRepeat: boolean, + shiftKey: boolean, + altKey: boolean, + ): void => { if (isRepeat) return; const direction = getDirectionForKey(key); startRepeat(key); setHeldDirection(direction); - options.step(direction, shiftKey, false); + options.step(direction, shiftKey, altKey, false); }; const releaseKey = (key: string) => { diff --git a/packages/react-grab/src/components/edit-panel/step-property.ts b/packages/react-grab/src/components/edit-panel/step-property.ts index 69f30fb02..964fd864c 100644 --- a/packages/react-grab/src/components/edit-panel/step-property.ts +++ b/packages/react-grab/src/components/edit-panel/step-property.ts @@ -10,6 +10,7 @@ export const stepProperty = ( property: EditableProperty, direction: 1 | -1, shift: boolean, + alt: boolean, designTokens?: DesignTokenResolver, ): number | string | null => { if (property.kind === "enum") { @@ -26,9 +27,10 @@ export const stepProperty = ( const upperBound = Math.max(property.max, property.value); // A plain arrow on a px property walks the project's token scale so values - // snap through the design system; Shift keeps the coarse raw step, and an - // off-scale value falls through to the raw step below so it never dead-ends. - if (!shift && property.unit === "px" && designTokens?.hasTokens) { + // snap through the design system. Both modifiers opt out of snapping for a + // raw step — Shift coarse (×10), Alt fine (±1) — and an off-scale value + // falls through to the raw step below so it never dead-ends. + if (!shift && !alt && property.unit === "px" && designTokens?.hasTokens) { const tokenStep = designTokens.stepLength(property.value, direction, property.cssProperties[0]); if (tokenStep !== null) { const clampedTokenStep = roundEditableNumericValue( From 33b17537679abf1ed0a7be7e61917c53a5079570 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 11:47:14 +0000 Subject: [PATCH 7/9] refactor(react-grab): single-family token classification, reuse parseNumericValue, clearer names Co-authored-by: Aiden Bai --- .../src/components/edit-panel/index.tsx | 18 ++-- .../components/edit-panel/step-controller.ts | 10 +- .../components/edit-panel/step-property.ts | 8 +- .../src/utils/collect-design-tokens.ts | 96 ++++++++++--------- 4 files changed, 70 insertions(+), 62 deletions(-) diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index c3b7e6ac9..18fa57307 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -271,14 +271,20 @@ const EditPanelBody: Component = (props) => { const stepActiveProperty = ( direction: 1 | -1, - shift: boolean, - alt: boolean, + shiftHeld: boolean, + altHeld: boolean, fromRepeat: boolean, source: "keyboard" | "pointer", ): EditableProperty | null => { const property = activeProperty(); if (!property) return null; - const nextValue = stepProperty(property, direction, shift, alt, props.state.designTokens); + const nextValue = stepProperty( + property, + direction, + shiftHeld, + altHeld, + props.state.designTokens, + ); if (nextValue === null) { flashActiveKey(direction === 1 ? "right" : "left"); return null; @@ -294,11 +300,11 @@ const EditPanelBody: Component = (props) => { const stepFromKeyboard = ( direction: 1 | -1, - shift: boolean, - alt: boolean, + shiftHeld: boolean, + altHeld: boolean, fromRepeat: boolean, ) => { - if (!stepActiveProperty(direction, shift, alt, fromRepeat, "keyboard")) return; + if (!stepActiveProperty(direction, shiftHeld, altHeld, fromRepeat, "keyboard")) return; setIsCompact(true); }; diff --git a/packages/react-grab/src/components/edit-panel/step-controller.ts b/packages/react-grab/src/components/edit-panel/step-controller.ts index b266b463f..c22a00887 100644 --- a/packages/react-grab/src/components/edit-panel/step-controller.ts +++ b/packages/react-grab/src/components/edit-panel/step-controller.ts @@ -10,14 +10,14 @@ type Direction = 1 | -1; const getDirectionForKey = (key: ArrowKey): Direction => (key === "ArrowLeft" ? -1 : 1); interface StepControllerOptions { - step: (direction: Direction, shift: boolean, alt: boolean, isRepeat: boolean) => void; + step: (direction: Direction, shiftHeld: boolean, altHeld: boolean, isRepeat: boolean) => void; isShiftHeld: Accessor; isAltHeld: Accessor; } export interface StepController { readonly heldDirection: Accessor<-1 | 0 | 1>; - pressArrow: (key: ArrowKey, isRepeat: boolean, shiftKey: boolean, altKey: boolean) => void; + pressArrow: (key: ArrowKey, isRepeat: boolean, shiftHeld: boolean, altHeld: boolean) => void; releaseKey: (key: string) => void; cancelRepeat: () => void; } @@ -55,14 +55,14 @@ export const createStepController = (options: StepControllerOptions): StepContro const pressArrow = ( key: ArrowKey, isRepeat: boolean, - shiftKey: boolean, - altKey: boolean, + shiftHeld: boolean, + altHeld: boolean, ): void => { if (isRepeat) return; const direction = getDirectionForKey(key); startRepeat(key); setHeldDirection(direction); - options.step(direction, shiftKey, altKey, false); + options.step(direction, shiftHeld, altHeld, false); }; const releaseKey = (key: string) => { diff --git a/packages/react-grab/src/components/edit-panel/step-property.ts b/packages/react-grab/src/components/edit-panel/step-property.ts index 964fd864c..65de9d8df 100644 --- a/packages/react-grab/src/components/edit-panel/step-property.ts +++ b/packages/react-grab/src/components/edit-panel/step-property.ts @@ -9,8 +9,8 @@ import { pickNextOption } from "../../utils/pick-next-option.js"; export const stepProperty = ( property: EditableProperty, direction: 1 | -1, - shift: boolean, - alt: boolean, + shiftHeld: boolean, + altHeld: boolean, designTokens?: DesignTokenResolver, ): number | string | null => { if (property.kind === "enum") { @@ -30,7 +30,7 @@ export const stepProperty = ( // snap through the design system. Both modifiers opt out of snapping for a // raw step — Shift coarse (×10), Alt fine (±1) — and an off-scale value // falls through to the raw step below so it never dead-ends. - if (!shift && !alt && property.unit === "px" && designTokens?.hasTokens) { + if (!shiftHeld && !altHeld && property.unit === "px" && designTokens?.hasTokens) { const tokenStep = designTokens.stepLength(property.value, direction, property.cssProperties[0]); if (tokenStep !== null) { const clampedTokenStep = roundEditableNumericValue( @@ -40,7 +40,7 @@ export const stepProperty = ( } } - const multiplier = shift ? EDIT_SHIFT_STEP_MULTIPLIER : 1; + const multiplier = shiftHeld ? EDIT_SHIFT_STEP_MULTIPLIER : 1; const candidate = roundEditableNumericValue( clampToRange(property.value + direction * multiplier, lowerBound, upperBound), ); diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts index 9d783510f..5707a2863 100644 --- a/packages/react-grab/src/utils/collect-design-tokens.ts +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -1,6 +1,7 @@ -import { EDIT_ROOT_FONT_SIZE_PX } from "../constants.js"; import type { DesignTokenResolver } from "../types.js"; +import { roundEditableNumericValue } from "./format-css-value.js"; import { parseAnyColor } from "./parse-any-color.js"; +import { parseNumericValue } from "./parse-numeric-value.js"; // Design tokens are surfaced through CSS custom properties regardless of // the styling library (shadcn, Radix, Chakra, MUI, Tailwind v4 `@theme`, @@ -19,11 +20,13 @@ type TokenFamily = interface LengthToken { name: string; - families: ReadonlySet; + family: TokenFamily; } -const LENGTH_VALUE_PATTERN = /^(-?\d*\.?\d+)(px|rem)$/i; - +// Ordered most-specific first so first-match-wins resolves `--font-size-*` to +// font-size before the generic "size" keyword (and `letter`/`leading` before +// the geometric families), preventing typography tokens from leaking into the +// width/height scale. const FAMILY_NAME_KEYWORDS: ReadonlyArray<{ keyword: string; family: TokenFamily }> = [ { keyword: "letter", family: "letter-spacing" }, { keyword: "tracking", family: "letter-spacing" }, @@ -50,13 +53,12 @@ const FAMILY_NAME_KEYWORDS: ReadonlyArray<{ keyword: string; family: TokenFamily { keyword: "height", family: "size" }, ]; -const familiesFromTokenName = (tokenName: string): Set => { +const familyForTokenName = (tokenName: string): TokenFamily | null => { const normalizedName = tokenName.toLowerCase(); - const families = new Set(); for (const { keyword, family } of FAMILY_NAME_KEYWORDS) { - if (normalizedName.includes(keyword)) families.add(family); + if (normalizedName.includes(keyword)) return family; } - return families; + return null; }; const familyForCssProperty = (cssProperty: string): TokenFamily | null => { @@ -89,12 +91,12 @@ const familyForCssProperty = (cssProperty: string): TokenFamily | null => { }; const lengthValueToPx = (rawValue: string): number | null => { - const match = rawValue.trim().match(LENGTH_VALUE_PATTERN); - if (!match) return null; - const magnitude = Number.parseFloat(match[1]); - if (!Number.isFinite(magnitude)) return null; - const px = match[2].toLowerCase() === "rem" ? magnitude * EDIT_ROOT_FONT_SIZE_PX : magnitude; - return Math.round(px); + const parsed = parseNumericValue(rawValue); + // Only true lengths (px/rem/em resolve to px); reject %, unitless ratios, + // calc(), and keywords so font-weight/line-height values aren't mistaken for + // a length scale. + if (!parsed || parsed.unit !== "px") return null; + return roundEditableNumericValue(parsed.value); }; // A token reference is only useful if it is shorter / more semantic than @@ -141,7 +143,7 @@ const nextValueOnGrid = (current: number, direction: 1 | -1, unitPx: number): nu }; const collectCustomPropertyNames = (): Set => { - const names = new Set(); + const customPropertyNames = new Set(); for (const styleSheet of Array.from(document.styleSheets)) { let rules: CSSRuleList; try { @@ -150,52 +152,58 @@ const collectCustomPropertyNames = (): Set => { } catch { continue; } - collectNamesFromRules(rules, names); + collectNamesFromRules(rules, customPropertyNames); } - return names; + return customPropertyNames; }; -const collectNamesFromRules = (rules: CSSRuleList, names: Set) => { +const collectNamesFromRules = (rules: CSSRuleList, customPropertyNames: Set) => { for (const rule of Array.from(rules)) { if (rule instanceof CSSStyleRule) { const declaration = rule.style; for (let propertyIndex = 0; propertyIndex < declaration.length; propertyIndex++) { const propertyName = declaration.item(propertyIndex); - if (propertyName.startsWith("--")) names.add(propertyName); + if (propertyName.startsWith("--")) customPropertyNames.add(propertyName); } } else if (rule instanceof CSSGroupingRule) { - collectNamesFromRules(rule.cssRules, names); + collectNamesFromRules(rule.cssRules, customPropertyNames); } } }; +const EMPTY_RESOLVER: DesignTokenResolver = { + hasTokens: false, + matchColor: () => null, + matchLength: () => null, + stepLength: () => null, +}; + export const collectDesignTokens = (element: Element): DesignTokenResolver => { if (typeof document === "undefined") return EMPTY_RESOLVER; const customPropertyNames = collectCustomPropertyNames(); if (customPropertyNames.size === 0) return EMPTY_RESOLVER; - const computed = getComputedStyle(element); + const computedStyle = getComputedStyle(element); const colorNameByHex = new Map(); const lengthTokensByPx = new Map(); const lengthPxByFamily = new Map>(); - // Tailwind exposes spacing (and sizing) as `calc(var(--spacing) * N)` rather - // than discrete per-step tokens, so the single base unit is the scale. + // Tailwind derives every spacing/sizing step from one `--spacing` base unit + // instead of emitting a discrete scale, so capture it for nextValueOnGrid. let spacingBaseUnitPx: number | null = null; for (const tokenName of customPropertyNames) { - const rawValue = computed.getPropertyValue(tokenName).trim(); + const rawValue = computedStyle.getPropertyValue(tokenName).trim(); if (!rawValue) continue; const lengthPx = lengthValueToPx(rawValue); if (lengthPx !== null) { if (tokenName === "--spacing" && lengthPx > 0) spacingBaseUnitPx = lengthPx; - const families = familiesFromTokenName(tokenName); - const tokens = lengthTokensByPx.get(lengthPx); - const lengthToken: LengthToken = { name: tokenName, families }; - if (tokens) tokens.push(lengthToken); - else lengthTokensByPx.set(lengthPx, [lengthToken]); - for (const family of families) { + const family = familyForTokenName(tokenName); + if (family !== null) { + const tokensAtPx = lengthTokensByPx.get(lengthPx); + if (tokensAtPx) tokensAtPx.push({ name: tokenName, family }); + else lengthTokensByPx.set(lengthPx, [{ name: tokenName, family }]); const familyValues = lengthPxByFamily.get(family); if (familyValues) familyValues.add(lengthPx); else lengthPxByFamily.set(family, new Set([lengthPx])); @@ -240,26 +248,20 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { }; const matchLength = (px: number, cssProperty: string): string | null => { - const tokens = lengthTokensByPx.get(Math.round(px)); - if (!tokens) return null; const family = familyForCssProperty(cssProperty); - let best: string | null = null; - for (const token of tokens) { - // A purely numeric value (16px) collides across unrelated scales - // (font-size, spacing, radius), so only a token whose name signals - // the same family is a trustworthy match. - if (family === null || !token.families.has(family)) continue; - if (preferToken(token.name, best)) best = token.name; + if (family === null) return null; + const tokensAtPx = lengthTokensByPx.get(Math.round(px)); + if (!tokensAtPx) return null; + // A purely numeric value (16px) collides across unrelated scales + // (font-size, spacing, radius), so only a token in the same family is a + // trustworthy match. + let bestTokenName: string | null = null; + for (const token of tokensAtPx) { + if (token.family !== family) continue; + if (preferToken(token.name, bestTokenName)) bestTokenName = token.name; } - return best; + return bestTokenName; }; return { hasTokens: true, matchColor, matchLength, stepLength }; }; - -const EMPTY_RESOLVER: DesignTokenResolver = { - hasTokens: false, - matchColor: () => null, - matchLength: () => null, - stepLength: () => null, -}; From c16fb7306d7d14e8252db004ae1b1ad00c1a6da0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 11:52:09 +0000 Subject: [PATCH 8/9] perf(react-grab): cache custom-property name scan; restore lone-token snapping Co-authored-by: Aiden Bai --- .../src/utils/collect-design-tokens.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts index 5707a2863..8a5eaf3ca 100644 --- a/packages/react-grab/src/utils/collect-design-tokens.ts +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -142,7 +142,17 @@ const nextValueOnGrid = (current: number, direction: 1 | -1, unitPx: number): nu return next < 0 ? null : next; }; +// The custom-property *names* are document-global and unchanged across element +// switches, so the (O(rules)) stylesheet walk is memoized. Keying on the sheet +// count invalidates it when stylesheets are added/removed (HMR, dynamic styles) +// — token name sets effectively never change without that. Values still resolve +// per element since custom properties cascade/scope. +let cachedNames: { styleSheetCount: number; names: Set } | null = null; + const collectCustomPropertyNames = (): Set => { + const styleSheetCount = document.styleSheets.length; + if (cachedNames && cachedNames.styleSheetCount === styleSheetCount) return cachedNames.names; + const customPropertyNames = new Set(); for (const styleSheet of Array.from(document.styleSheets)) { let rules: CSSRuleList; @@ -154,6 +164,7 @@ const collectCustomPropertyNames = (): Set => { } collectNamesFromRules(rules, customPropertyNames); } + cachedNames = { styleSheetCount, names: customPropertyNames }; return customPropertyNames; }; @@ -240,10 +251,15 @@ export const collectDesignTokens = (element: Element): DesignTokenResolver => { const current = Math.round(px); const scale = sortedLengthPxByFamily.get(family); + // A real multi-step scale always wins. if (scale && scale.length >= 2) return nextValueInScale(scale, current, direction); + // Spacing/sizing without a discrete scale ride Tailwind's `--spacing` grid. if ((family === "spacing" || family === "size") && spacingBaseUnitPx) { return nextValueOnGrid(current, direction, spacingBaseUnitPx); } + // A lone token (e.g. a single `--radius`) can still be reached from nearby + // values rather than dead-ending on a raw step. + if (scale) return nextValueInScale(scale, current, direction); return null; }; From c47a0ef8e2643786bfce6fd6f957c4af7f02a3db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 12:10:31 +0000 Subject: [PATCH 9/9] fix(react-grab): symmetric token snapping at scale edges (above-max snaps down) Co-authored-by: Aiden Bai --- packages/react-grab/e2e/edit-panel.spec.ts | 7 +++++-- packages/react-grab/src/utils/collect-design-tokens.ts | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index 88f61c6c2..619d3564d 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -836,9 +836,12 @@ test.describe("Style Panel", () => { await reactGrab.page.waitForTimeout(80); expect(await getActivePropertyValue(reactGrab.page)).toBe("200px"); - // ArrowLeft → 199 proves the step came off the real 200, not a - // clamp to the 96px max (which would land on 95). + // Alt opts out of token snapping for a raw step: ArrowLeft → 199 proves + // the step came off the real 200, not a clamp to the 96px max (which + // would land on 95). + await reactGrab.page.keyboard.down("Alt"); await reactGrab.page.keyboard.press("ArrowLeft"); + await reactGrab.page.keyboard.up("Alt"); await expect.poll(() => getActivePropertyValue(reactGrab.page)).toBe("199px"); }); diff --git a/packages/react-grab/src/utils/collect-design-tokens.ts b/packages/react-grab/src/utils/collect-design-tokens.ts index 8a5eaf3ca..3b0e70477 100644 --- a/packages/react-grab/src/utils/collect-design-tokens.ts +++ b/packages/react-grab/src/utils/collect-design-tokens.ts @@ -116,17 +116,16 @@ const nextValueInScale = ( current: number, direction: 1 | -1, ): number | null => { - if (current > scale[scale.length - 1]) return null; if (direction === 1) { - // The first token greater than current also pulls a below-scale value - // (8px under a 16px floor) up onto the scale. + // First token above current. Also pulls a below-scale value up onto the + // floor; yields null past the top token so the caller falls back to raw. for (const value of scale) { if (value > current) return value; } return null; } - // Below the smallest token there is no lower token to snap down to. - if (current <= scale[0]) return null; + // First token below current. Also pulls an above-scale value down onto the + // top token; yields null past the floor so the caller falls back to raw. for (let scaleIndex = scale.length - 1; scaleIndex >= 0; scaleIndex--) { if (scale[scaleIndex] < current) return scale[scaleIndex]; }