Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/style-design-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"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.

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.
8 changes: 8 additions & 0 deletions apps/e2e-app-vite/src/index.css
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
@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-space-6: 24px;
--rg-test-brand: #123456;
}
123 changes: 120 additions & 3 deletions packages/react-grab/e2e/edit-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand All @@ -855,6 +858,68 @@ 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("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);
Expand Down Expand Up @@ -1510,6 +1575,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);
Expand Down Expand Up @@ -1654,9 +1768,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 });
Expand Down
31 changes: 22 additions & 9 deletions packages/react-grab/src/components/edit-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -266,17 +266,25 @@ const EditPanelBody: Component<EditPanelBodyProps> = (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,
shiftHeld: boolean,
altHeld: boolean,
fromRepeat: boolean,
source: "keyboard" | "pointer",
): EditableProperty | null => {
const property = activeProperty();
if (!property) return null;
const nextValue = stepProperty(property, direction, shift);
const nextValue = stepProperty(
property,
direction,
shiftHeld,
altHeld,
props.state.designTokens,
);
if (nextValue === null) {
flashActiveKey(direction === 1 ? "right" : "left");
return null;
Expand All @@ -290,16 +298,21 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
return property;
};

const stepFromKeyboard = (direction: 1 | -1, shift: boolean, fromRepeat: boolean) => {
if (!stepActiveProperty(direction, shift, fromRepeat, "keyboard")) return;
const stepFromKeyboard = (
direction: 1 | -1,
shiftHeld: boolean,
altHeld: boolean,
fromRepeat: boolean,
) => {
if (!stepActiveProperty(direction, shiftHeld, altHeld, 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();
Expand Down Expand Up @@ -420,7 +433,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (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<string, (event: KeyboardEvent) => void> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<boolean> => {
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);
Expand All @@ -18,5 +21,5 @@ export const createShiftTracker = (): Accessor<boolean> => {
});
});

return isShiftHeld;
return isHeld;
};
16 changes: 11 additions & 5 deletions packages/react-grab/src/components/edit-panel/step-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, shiftHeld: boolean, altHeld: boolean, isRepeat: boolean) => void;
isShiftHeld: Accessor<boolean>;
isAltHeld: Accessor<boolean>;
}

export interface StepController {
readonly heldDirection: Accessor<-1 | 0 | 1>;
pressArrow: (key: ArrowKey, isRepeat: boolean, shiftKey: boolean) => void;
pressArrow: (key: ArrowKey, isRepeat: boolean, shiftHeld: boolean, altHeld: boolean) => void;
releaseKey: (key: string) => void;
cancelRepeat: () => void;
}
Expand Down Expand Up @@ -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,
shiftHeld: boolean,
altHeld: boolean,
): void => {
if (isRepeat) return;
const direction = getDirectionForKey(key);
startRepeat(key);
setHeldDirection(direction);
options.step(direction, shiftKey, false);
options.step(direction, shiftHeld, altHeld, false);
};

const releaseKey = (key: string) => {
Expand Down
32 changes: 24 additions & 8 deletions packages/react-grab/src/components/edit-panel/step-property.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,24 +9,40 @@ import { pickNextOption } from "../../utils/pick-next-option.js";
export const stepProperty = (
property: EditableProperty,
direction: 1 | -1,
shift: boolean,
shiftHeld: boolean,
altHeld: 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. 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 (!shiftHeld && !altHeld && 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 = shiftHeld ? 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;
};
Loading
Loading