From 3cfb221a6517fb50e29fc00b752614f76f4f44b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 00:09:11 +0000 Subject: [PATCH 1/6] Fix style mode bugs: prompt/preview desync, oklch chroma, clamping, Tailwind fixes Co-authored-by: Aiden Bai --- packages/react-grab/e2e/edit-panel-helpers.ts | 17 +++ packages/react-grab/e2e/edit-panel.spec.ts | 134 ++++++++++++++++++ .../edit-panel/active-property-control.tsx | 2 +- .../components/edit-panel/cycle-control.tsx | 16 +-- .../src/components/edit-panel/index.tsx | 3 +- .../components/edit-panel/step-property.ts | 10 +- .../edit-panel/tailwind-autoapply.ts | 49 ++++--- .../src/components/edit-panel/tweak-store.ts | 45 +++++- packages/react-grab/src/constants.ts | 6 + .../src/utils/css-baseline-measurement.ts | 10 ++ .../src/utils/css-property-builders.ts | 14 +- .../src/utils/find-tailwind-class.ts | 34 +++-- .../react-grab/src/utils/parse-any-color.ts | 3 +- .../sort-properties-by-recommendation.ts | 3 + 14 files changed, 298 insertions(+), 48 deletions(-) diff --git a/packages/react-grab/e2e/edit-panel-helpers.ts b/packages/react-grab/e2e/edit-panel-helpers.ts index df036860a..277d4f86a 100644 --- a/packages/react-grab/e2e/edit-panel-helpers.ts +++ b/packages/react-grab/e2e/edit-panel-helpers.ts @@ -204,6 +204,23 @@ export const getActiveTailwindLabelOrder = async ( { attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR }, ); +export const getActiveTailwindLabelText = async (page: Page): Promise => + page.evaluate( + ({ attrName, tailwindLabelAttr }) => { + const host = document.querySelector(`[${attrName}]`); + const shadowRoot = host?.shadowRoot; + const tailwindLabelElements = Array.from( + shadowRoot?.querySelectorAll( + `[data-react-grab-edit-panel] [${tailwindLabelAttr}]`, + ) ?? [], + ); + const tailwindLabel = + tailwindLabelElements.find((element) => element.getBoundingClientRect().width > 0) ?? null; + return tailwindLabel?.textContent ?? null; + }, + { attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR }, + ); + export interface SearchInputFocusVisualState { isFocusVisible: boolean; outlineStyle: string; diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index 46b40428a..ef3ec2961 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -18,6 +18,7 @@ import { getActivePropertyValue, getActiveSliderVisualState, getActiveTailwindLabelOrder, + getActiveTailwindLabelText, getEditPanelCompactAttr, getInlineStyleAttribute, getInlineStyleProperty, @@ -39,6 +40,11 @@ import { typeInSearchInput, } from "./edit-panel-helpers.js"; +// Leaf element (text-only table cell with uniform `p-2`) whose center +// point hits the element itself — nested containers like the card +// select whichever child sits at their center. +const UNIFORM_PADDING_SELECTOR = "[data-testid='th-1']"; + test.describe("Style Panel", () => { test.beforeEach(async ({ reactGrab }) => { await clearEditStorage(reactGrab.page); @@ -631,6 +637,56 @@ test.describe("Style Panel", () => { } }); + test("Tailwind label names the aggregate class on a uniform padding row", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding"); + await reactGrab.page.keyboard.down("Shift"); + try { + await expect.poll(() => getActiveTailwindLabelText(reactGrab.page)).toBe("p-2"); + } finally { + await reactGrab.page.keyboard.up("Shift"); + } + }); + + test("stepping an out-of-range value never jumps against the arrow direction", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate((buttonSelector) => { + const button = document.querySelector(buttonSelector); + if (button instanceof HTMLElement) button.style.fontSize = "128px"; + }, BUTTON_SELECTOR); + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "font size"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("font-size"); + + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size")).toBe( + "128px", + ); + + await reactGrab.page.keyboard.press("ArrowLeft"); + await reactGrab.page.waitForTimeout(80); + expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size")).toBe( + "127px", + ); + }); + + test("rounded-full's infinite radius displays as a finite clamped value", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate((buttonSelector) => { + const button = document.querySelector(buttonSelector); + if (button instanceof HTMLElement) button.style.borderRadius = "calc(infinity * 1px)"; + }, BUTTON_SELECTOR); + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await setSearchInputValue(reactGrab.page, "rounded"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius"); + expect(await getActivePropertyValue(reactGrab.page)).toBe("96px"); + }); + test("ArrowUp / ArrowDown navigate the list, not the value", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); const initialActivePropertyKey = await getActivePropertyKey(reactGrab.page); @@ -971,6 +1027,29 @@ test.describe("Style Panel", () => { expect(marginTopAfterTyping).toContain("20"); }); + test("typing -m-4 applies a negative margin", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.type("-m-4"); + await reactGrab.page.waitForTimeout(80); + + expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")).toBe( + "-16px", + ); + expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-left")).toBe( + "-16px", + ); + }); + + test("typing -mt-[8px] applies a negative arbitrary margin", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.type("-mt-[8px]"); + await reactGrab.page.waitForTimeout(80); + + expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")).toBe( + "-8px", + ); + }); + test("typing font-mono applies font family + compact", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); await reactGrab.page.keyboard.type("font-mono"); @@ -1148,6 +1227,61 @@ test.describe("Style Panel", () => { expect(afterCommit).toBe(inlineStyleAfterTweak); }); + test("copied prompt matches the preview when aggregate and longhand tweaks overlap", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "pt-8"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("32px"); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + await setSearchInputValue(reactGrab.page, "pt-1"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("4px"); + expect( + await getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-right"), + ).toBe("24px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 4px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-right: 24px;"); + expect(clipboardContent).not.toContain("padding-top: 24px;"); + }); + + test("copied prompt emits a longhand stepped back to its original under a changed aggregate", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "pt-8"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("32px"); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("24px"); + // Back to the original 8px: the padding-top override must still + // be emitted or the prompt claims the p-6 fan-out covers the top. + await setSearchInputValue(reactGrab.page, "pt-2"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("8px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 8px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-right: 24px;"); + expect(clipboardContent).not.toContain("padding-top: 24px;"); + }); + 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/components/edit-panel/active-property-control.tsx b/packages/react-grab/src/components/edit-panel/active-property-control.tsx index 65b6a67f1..5c261beb4 100644 --- a/packages/react-grab/src/components/edit-panel/active-property-control.tsx +++ b/packages/react-grab/src/components/edit-panel/active-property-control.tsx @@ -61,7 +61,7 @@ export const ActivePropertyControl: Component = (pro value={enumProp().value} options={enumProp().options} activeKey={props.activeKey} - onCommit={props.onCommit} + onStep={props.onStep} /> )} diff --git a/packages/react-grab/src/components/edit-panel/cycle-control.tsx b/packages/react-grab/src/components/edit-panel/cycle-control.tsx index 0a5991a43..ec91ee66c 100644 --- a/packages/react-grab/src/components/edit-panel/cycle-control.tsx +++ b/packages/react-grab/src/components/edit-panel/cycle-control.tsx @@ -1,6 +1,5 @@ import { Show, type Component } from "solid-js"; import type { EnumEditableOption } from "../../types.js"; -import { pickNextOption } from "../../utils/pick-next-option.js"; import { EDIT_LABEL_CLASS } from "./constants.js"; import { StepArrow } from "./step-arrow.js"; @@ -9,7 +8,7 @@ interface CycleControlProps { value: string; options: ReadonlyArray; activeKey: "left" | "right" | null; - onCommit: (value: string) => void; + onStep: (direction: 1 | -1) => void; } export const CycleControl: Component = (props) => { @@ -18,11 +17,6 @@ export const CycleControl: Component = (props) => { return match?.label ?? props.value; }; - const advance = (direction: 1 | -1) => { - const nextOption = pickNextOption(props.options, props.value, direction); - if (nextOption) props.onCommit(nextOption.value); - }; - return (
@@ -34,7 +28,7 @@ export const CycleControl: Component = (props) => { advance(-1)} + onPointerDown={() => props.onStep(-1)} /> = (props) => { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - advance(1); + props.onStep(1); }} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); - advance(-1); + props.onStep(-1); }} > {currentLabel()} @@ -61,7 +55,7 @@ export const CycleControl: Component = (props) => { advance(1)} + onPointerDown={() => props.onStep(1)} />
diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index 61625517d..9c14dd9b4 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -303,8 +303,7 @@ const EditPanelBody: Component = (props) => { if (!isShiftHeld()) return null; const property = activeProperty(); if (!property || property.kind !== "numeric") return null; - const cssKey = property.cssProperties[0]; - return cssKey ? findTailwindClass(cssKey, property.value) : null; + return findTailwindClass(property.key, property.value); }); const autoApply = createTailwindAutoApply({ 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 365d336f6..43821cddd 100644 --- a/packages/react-grab/src/components/edit-panel/step-property.ts +++ b/packages/react-grab/src/components/edit-panel/step-property.ts @@ -17,8 +17,16 @@ export const stepProperty = ( } 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 candidate = roundEditableNumericValue( - clampToRange(property.value + direction * multiplier, property.min, property.max), + clampToRange( + property.value + direction * multiplier, + Math.min(property.min, property.value), + Math.max(property.max, property.value), + ), ); return candidate === property.value ? null : candidate; }; diff --git a/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts b/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts index 855ffe2da..d59262504 100644 --- a/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts +++ b/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts @@ -244,40 +244,52 @@ export const createTailwindAutoApply = ( const lengthPx = parseArbitraryLengthPx(value, ARBITRARY_LENGTH_HINT.test(rawValue.trim())); if (lengthPx === null) return false; - const cssKey = tailwindPrefixToProperty(arbitraryPrefix(arbitraryMatch[1])); - return cssKey ? commitLengthPx(cssKey, lengthPx) : false; + const rawPrefix = arbitraryMatch[1]; + const isNegativePrefixed = rawPrefix.startsWith("-"); + const cssKey = tailwindPrefixToProperty( + arbitraryPrefix(isNegativePrefixed ? rawPrefix.slice(1) : rawPrefix), + ); + return cssKey ? commitLengthPx(cssKey, isNegativePrefixed ? -lengthPx : lengthPx) : false; }; const applySingleClass = (rawQuery: string) => { if (applyArbitraryClass(rawQuery)) return; const query = normalizeTailwindClassInput(rawQuery); - const intentPrefix = query.replace(/-\d*$/, "").replace(/-$/, ""); + // Canonical negative spelling: `-m-4` is margin -16px. Only the + // numeric path below understands the sign; colors and enums have + // no negative forms. + const isNegativePrefixed = query.startsWith("-"); + const baseQuery = isNegativePrefixed ? query.slice(1) : query; + const intentPrefix = baseQuery.replace(/-\d*$/, "").replace(/-$/, ""); const intentCssKey = intentPrefix ? tailwindPrefixToProperty(intentPrefix) : null; if (intentCssKey && hasTrackableTarget(intentCssKey)) setIsCompact(true); - if (applyColorClass(query, null)) return; - - const enumMapping = tailwindClassToEnumValue(query); - if (enumMapping) { - const enumTarget = findEnum(initialProperties, enumMapping.property); - if (!enumTarget) return; - const option = enumTarget.options.find((entry) => entry.value === enumMapping.value); - if (option) { - setIsCompact(true); - commit(enumTarget, option.value, { shouldCompact: true }); + if (!isNegativePrefixed) { + if (applyColorClass(query, null)) return; + + const enumMapping = tailwindClassToEnumValue(query); + if (enumMapping) { + const enumTarget = findEnum(initialProperties, enumMapping.property); + if (!enumTarget) return; + const option = enumTarget.options.find((entry) => entry.value === enumMapping.value); + if (option) { + setIsCompact(true); + commit(enumTarget, option.value, { shouldCompact: true }); + } + return; } - return; } - const tailwindClassMatch = query.match(TAILWIND_CLASS_PATTERN); + const tailwindClassMatch = baseQuery.match(TAILWIND_CLASS_PATTERN); if (!tailwindClassMatch) return; const cssKey = tailwindPrefixToProperty(tailwindClassMatch[1]); if (!cssKey) return; const rawNumber = Number.parseFloat(tailwindClassMatch[2]); if (!Number.isFinite(rawNumber)) return; + const signedNumber = isNegativePrefixed ? -rawNumber : rawNumber; const candidate = LITERAL_NUMBER_KEYS.has(cssKey) - ? rawNumber - : rawNumber * TAILWIND_SPACING_UNIT_PX; + ? signedNumber + : signedNumber * TAILWIND_SPACING_UNIT_PX; const numericTarget = findNumeric(initialProperties, cssKey); if (numericTarget) { @@ -306,7 +318,8 @@ export const createTailwindAutoApply = ( const isApplicableSingleClass = (normalizedQuery: string): boolean => { if (tailwindClassToEnumValue(normalizedQuery)) return true; - const tailwindClassMatch = normalizedQuery.match(TAILWIND_CLASS_PATTERN); + const baseQuery = normalizedQuery.startsWith("-") ? normalizedQuery.slice(1) : normalizedQuery; + const tailwindClassMatch = baseQuery.match(TAILWIND_CLASS_PATTERN); if (!tailwindClassMatch) return false; const cssKey = tailwindPrefixToProperty(tailwindClassMatch[1]); if (!cssKey) return false; diff --git a/packages/react-grab/src/components/edit-panel/tweak-store.ts b/packages/react-grab/src/components/edit-panel/tweak-store.ts index 67871255e..e561c3416 100644 --- a/packages/react-grab/src/components/edit-panel/tweak-store.ts +++ b/packages/react-grab/src/components/edit-panel/tweak-store.ts @@ -91,6 +91,14 @@ export const createTweakStore = (options: CreateTweakStoreOptions): TweakStore = return property; }; + // Preview writes longhands last-write-wins, so every consumer that + // resolves overlapping tweaks (row display sync, prompt building) + // must see them in commit order. Key insertion order carries that: + // each commit re-inserts its key at the end, and tweaks whose + // longhands are fully covered by the new commit are dropped — their + // preview effect was just overwritten wholesale. + let lastCommittedTweakKey: string | null = null; + const applyTweak = (property: EditableProperty, nextValue: number | string) => { let tweak: PropertyTweak; if (property.kind === "numeric" && typeof nextValue === "number") { @@ -102,16 +110,49 @@ export const createTweakStore = (options: CreateTweakStoreOptions): TweakStore = } else { return; } - setTweaksByKey((current) => ({ ...current, [property.key]: tweak })); + if (lastCommittedTweakKey === property.key) { + setTweaksByKey((current) => ({ ...current, [property.key]: tweak })); + return; + } + lastCommittedTweakKey = property.key; + setTweaksByKey((current) => { + const next: Record = {}; + for (const existingKey of Object.keys(current)) { + if (existingKey === property.key) continue; + const existingProperty = propertyByKey.get(existingKey); + const isCoveredByNewTweak = + existingProperty !== undefined && + existingProperty.cssProperties.every((longhand) => + property.cssProperties.includes(longhand), + ); + if (isCoveredByNewTweak) continue; + next[existingKey] = current[existingKey]; + } + next[property.key] = tweak; + return next; + }); }; const buildPendingEdits = (): PendingEdit[] => { const currentTweaks = tweaksByKey(); const pendingEdits: PendingEdit[] = []; + const changedCssProperties = new Set(); for (const tweakedKey of Object.keys(currentTweaks)) { const tweak = currentTweaks[tweakedKey]; const property = propertyByKey.get(tweakedKey); - if (!property || !hasChangedFromOriginal(property, tweak)) continue; + if (!property || property.kind !== tweak.kind) continue; + const isChanged = hasChangedFromOriginal(property, tweak); + // A back-to-original tweak still matters when an earlier (wider) + // tweak changed one of its longhands: `pt-4`, `p-6`, then top + // back to its original must emit `padding-top` or the prompt + // claims the `padding: 24px` fan-out applies to the top side too. + const isOverridingChangedTweak = + !isChanged && + property.cssProperties.some((cssProperty) => changedCssProperties.has(cssProperty)); + if (!isChanged && !isOverridingChangedTweak) continue; + if (isChanged) { + for (const cssProperty of property.cssProperties) changedCssProperties.add(cssProperty); + } if (property.kind === "numeric" && tweak.kind === "numeric") { pendingEdits.push({ kind: "numeric", diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index efaf6384d..48fe6436b 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -237,6 +237,12 @@ export const EDIT_SEARCH_LENGTH_PENALTY = 1; export const EDIT_COMPACT_SLIDER_MIN_WIDTH_PX = 96; export const CSS_VALUE_DECIMAL_PLACES = 2; +// Tailwind v4's `rounded-full` is calc(infinity * 1px). Browsers clamp +// it to their internal length maximum (~3.36e7px in Chromium, ~1.79e7px +// in Firefox, float max in WebKit) — all far past this threshold and +// none a real length. Cap to the row's max bound instead of showing +// (and re-emitting) 33554400px. +export const CSS_LENGTH_INFINITY_THRESHOLD_PX = 1e7; export const OPACITY_PERCENT_MAX = 100; export const FONT_SIZE_LINE_HEIGHT_RATIO = 1.2; // Tailwind's spacing scale: `p-1` = 0.25rem = 4px, so each "unit" diff --git a/packages/react-grab/src/utils/css-baseline-measurement.ts b/packages/react-grab/src/utils/css-baseline-measurement.ts index 47c4b54d1..64ba9cb7b 100644 --- a/packages/react-grab/src/utils/css-baseline-measurement.ts +++ b/packages/react-grab/src/utils/css-baseline-measurement.ts @@ -36,12 +36,21 @@ export const measureBaseline = (target: Element): ComputedSnapshot | null => { } }; +// The baseline clone is forced to display:none, so its computed +// `display` is always "none" and its width/height never resolve to +// used layout values — comparing those keys against the target would +// mark them "modified" on every element. Fall back to the heuristic. +const LAYOUT_DEPENDENT_BASELINE_KEYS: ReadonlySet = new Set(["display", "width", "height"]); + export const isDefaultByBaseline = ( property: EditableProperty, currentSnapshot: ComputedSnapshot, baselineSnapshot: ComputedSnapshot, ): boolean => { if (property.kind === "color") return false; + if (property.cssProperties.some((key) => LAYOUT_DEPENDENT_BASELINE_KEYS.has(key))) { + return isDefaultByHeuristic(property); + } return property.cssProperties.every((key) => { const currentValue = currentSnapshot[key]; const baselineValue = baselineSnapshot[key]; @@ -72,6 +81,7 @@ export const isDefaultByHeuristic = (property: EditableProperty): boolean => { if ( propertyKey === "width" || propertyKey === "height" || + propertyKey === "width,height" || propertyKey.startsWith("max-") || propertyKey.startsWith("min-") ) { diff --git a/packages/react-grab/src/utils/css-property-builders.ts b/packages/react-grab/src/utils/css-property-builders.ts index 6430bf5ce..030efa862 100644 --- a/packages/react-grab/src/utils/css-property-builders.ts +++ b/packages/react-grab/src/utils/css-property-builders.ts @@ -4,7 +4,7 @@ import type { EnumEditableProperty, NumericEditableProperty, } from "../types.js"; -import { EDIT_TRANSPARENT_COLOR_HEX } from "../constants.js"; +import { CSS_LENGTH_INFINITY_THRESHOLD_PX, EDIT_TRANSPARENT_COLOR_HEX } from "../constants.js"; import { propertyBounds } from "./css-property-bounds.js"; import { normalizeForEdit } from "./css-value-resolution.js"; import { parseAnyColor } from "./parse-any-color.js"; @@ -19,7 +19,13 @@ export const buildNumericProperty = ( isCanonical: boolean, ): NumericEditableProperty => { const normalized = normalizeForEdit(definition.key, rawValue); - const bounds = propertyBounds(definition.key, normalized.value, normalized.unit); + const isInfiniteLength = normalized.value > CSS_LENGTH_INFINITY_THRESHOLD_PX; + const bounds = propertyBounds( + definition.key, + isInfiniteLength ? 0 : normalized.value, + normalized.unit, + ); + const value = isInfiniteLength ? bounds.max : normalized.value; return { kind: "numeric", key: definition.key, @@ -27,8 +33,8 @@ export const buildNumericProperty = ( cssProperties: definition.longhands, min: bounds.min, max: bounds.max, - value: normalized.value, - original: normalized.value, + value, + original: value, unit: normalized.unit, tailwindAliases: tailwindAliasesForProperty(definition.key), isPrioritized: false, diff --git a/packages/react-grab/src/utils/find-tailwind-class.ts b/packages/react-grab/src/utils/find-tailwind-class.ts index 3d9c1cdf5..854d97632 100644 --- a/packages/react-grab/src/utils/find-tailwind-class.ts +++ b/packages/react-grab/src/utils/find-tailwind-class.ts @@ -8,11 +8,15 @@ import { const SPACING_PROPERTIES: ReadonlySet = new Set([ "padding", + "padding-top,padding-bottom", + "padding-left,padding-right", "padding-top", "padding-right", "padding-bottom", "padding-left", "margin", + "margin-top,margin-bottom", + "margin-left,margin-right", "margin-top", "margin-right", "margin-bottom", @@ -20,27 +24,37 @@ const SPACING_PROPERTIES: ReadonlySet = new Set([ "gap", "column-gap", "row-gap", + "width,height", "width", "height", "min-width", "max-width", "min-height", "max-height", + "top,right,bottom,left", + "top,bottom", + "left,right", "top", "right", "bottom", "left", ]); -// Most-specific Tailwind prefix per CSS key — `pt` over `p` for -// padding-top so the chip names the longhand the user would type. +// Most-specific Tailwind prefix per row key — `pt` over `p` for +// padding-top so the chip names the class the user would type. Keys +// are EditableProperty.key spellings, including the comma-joined +// aggregate rows ("padding-top,padding-bottom" → `py`). const PROPERTY_TO_BEST_PREFIX: Record = { padding: "p", + "padding-top,padding-bottom": "py", + "padding-left,padding-right": "px", "padding-top": "pt", "padding-right": "pr", "padding-bottom": "pb", "padding-left": "pl", margin: "m", + "margin-top,margin-bottom": "my", + "margin-left,margin-right": "mx", "margin-top": "mt", "margin-right": "mr", "margin-bottom": "mb", @@ -48,12 +62,16 @@ const PROPERTY_TO_BEST_PREFIX: Record = { gap: "gap", "column-gap": "gap-x", "row-gap": "gap-y", + "width,height": "size", width: "w", height: "h", "min-width": "min-w", "max-width": "max-w", "min-height": "min-h", "max-height": "max-h", + "top,right,bottom,left": "inset", + "top,bottom": "inset-y", + "left,right": "inset-x", top: "top", right: "right", bottom: "bottom", @@ -67,11 +85,11 @@ const PROPERTY_TO_BEST_PREFIX: Record = { opacity: "opacity", }; -export const findTailwindClass = (cssKey: string, value: number): string | null => { - const prefix = PROPERTY_TO_BEST_PREFIX[cssKey]; +export const findTailwindClass = (propertyKey: string, value: number): string | null => { + const prefix = PROPERTY_TO_BEST_PREFIX[propertyKey]; if (!prefix) return null; - if (SPACING_PROPERTIES.has(cssKey)) { + if (SPACING_PROPERTIES.has(propertyKey)) { if (value < 0) return null; const units = value / TAILWIND_SPACING_UNIT_PX; if (!Number.isInteger(units)) return null; @@ -79,7 +97,7 @@ export const findTailwindClass = (cssKey: string, value: number): string | null return `${prefix}-${units}`; } - if (cssKey === "border-width" || cssKey.endsWith("-width")) { + if (propertyKey === "border-width" || propertyKey.endsWith("-width")) { if (!Number.isInteger(value) || value < 0 || value > TAILWIND_BORDER_MAX_PX) return null; // 1px is the suffix-less form: `border` / `border-t`. Others get // numeric suffix: `border-2`, `border-t-4`, … @@ -87,12 +105,12 @@ export const findTailwindClass = (cssKey: string, value: number): string | null return `${prefix}-${value}`; } - if (cssKey === "z-index") { + if (propertyKey === "z-index") { if (!Number.isInteger(value) || value < 0 || value > TAILWIND_Z_INDEX_MAX) return null; return `${prefix}-${value}`; } - if (cssKey === "opacity") { + if (propertyKey === "opacity") { // Opacity is stored as percent [0, 100] (build-editable-properties // normalizes), so `value` already matches the chip number. if (!Number.isFinite(value) || value < 0 || value > 100) 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 a2457b48c..0d34ddcb8 100644 --- a/packages/react-grab/src/utils/parse-any-color.ts +++ b/packages/react-grab/src/utils/parse-any-color.ts @@ -23,7 +23,8 @@ const REJECTED_FILL_STYLE_SENTINEL = "rgba(0, 0, 0, 0)"; const OPAQUE_HEX_LENGTH = 7; const OKLCH_PATTERN = /^oklch\(\s*([+-]?\d*\.?\d+%?)\s+([+-]?\d*\.?\d+%?)\s+([+-]?\d*\.?\d+)(deg|grad|rad|turn)?(?:\s*\/\s*([+-]?\d*\.?\d+%?))?\s*\)$/i; -const OKLCH_PERCENT_CHROMA_SCALE = 0.004; +// Per CSS Color 4, 100% chroma in oklch() corresponds to 0.4. +const OKLCH_PERCENT_CHROMA_SCALE = 0.4; const DEGREES_PER_GRAD = 0.9; const DEGREES_PER_RADIAN = 180 / Math.PI; const DEGREES_PER_TURN = 360; diff --git a/packages/react-grab/src/utils/sort-properties-by-recommendation.ts b/packages/react-grab/src/utils/sort-properties-by-recommendation.ts index 431e97bff..ea2b87c0e 100644 --- a/packages/react-grab/src/utils/sort-properties-by-recommendation.ts +++ b/packages/react-grab/src/utils/sort-properties-by-recommendation.ts @@ -62,6 +62,9 @@ const RECOMMENDED_KEY_ORDER: readonly string[] = [ "border-color", "align-items", "justify-content", + "top,right,bottom,left", + "left,right", + "top,bottom", "top", "right", "bottom", From 6f9c8c74c653b20d5d05c2c1e0f1b35f12e44104 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 12 Jun 2026 20:37:28 -0700 Subject: [PATCH 2/6] Fix review findings: z-index clamp, negative-class enum leak, map dedup Addresses the two cubic P2 reports plus thermo review findings: - Unit-gate the infinity clamp so a real large z-index keeps its value instead of being rewritten to the bound (css-property-builders.ts). - Skip the numeric->enum fallback for negative-prefixed classes so `-font-700` is a no-op instead of applying font-weight 700. - Extract splitNegativePrefix util to de-duplicate the sign handling. - Derive the tailwind chip table (single source of truth) and POSITION_KEYS from the registry so parallel lists can't drift. - Drop the redundant lastCommittedTweakKey shadow state (derive the last-committed key from insertion order). - Finish the cssKey->propertyKey rename for aggregate-capable keys. - Make the new e2e tests deterministic (poll + setSearchInputValue; non-reflowing border-radius for the out-of-range stepping case). --- packages/react-grab/e2e/edit-panel-helpers.ts | 28 ++-- packages/react-grab/e2e/edit-panel.spec.ts | 40 ++--- .../edit-panel/tailwind-autoapply.ts | 87 +++++----- .../src/components/edit-panel/tweak-store.ts | 26 ++- .../src/utils/css-property-builders.ts | 11 +- .../src/utils/expand-aggregate-longhands.ts | 8 +- .../src/utils/find-tailwind-class.ts | 154 ++++++++---------- .../src/utils/property-definitions.ts | 16 +- .../src/utils/split-negative-prefix.ts | 9 + 9 files changed, 181 insertions(+), 198 deletions(-) create mode 100644 packages/react-grab/src/utils/split-negative-prefix.ts diff --git a/packages/react-grab/e2e/edit-panel-helpers.ts b/packages/react-grab/e2e/edit-panel-helpers.ts index 277d4f86a..69504494d 100644 --- a/packages/react-grab/e2e/edit-panel-helpers.ts +++ b/packages/react-grab/e2e/edit-panel-helpers.ts @@ -178,9 +178,9 @@ export const hoverVisibleSlider = async (page: Page): Promise => { ); }; -export const getActiveTailwindLabelOrder = async ( +const getActiveTailwindLabelInfo = async ( page: Page, -): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> => +): Promise<{ text: string | null; tailwindLeft: number | null; valueLeft: number | null }> => page.evaluate( ({ attrName, tailwindLabelAttr }) => { const host = document.querySelector(`[${attrName}]`); @@ -197,6 +197,7 @@ export const getActiveTailwindLabelOrder = async ( ?.closest("[data-react-grab-value]") ?.querySelector("[data-react-grab-value-text]") ?? null; return { + text: tailwindLabel?.textContent ?? null, tailwindLeft: tailwindLabel?.getBoundingClientRect().left ?? null, valueLeft: valueText?.getBoundingClientRect().left ?? null, }; @@ -204,22 +205,15 @@ export const getActiveTailwindLabelOrder = async ( { attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR }, ); +export const getActiveTailwindLabelOrder = async ( + page: Page, +): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> => { + const { tailwindLeft, valueLeft } = await getActiveTailwindLabelInfo(page); + return { tailwindLeft, valueLeft }; +}; + export const getActiveTailwindLabelText = async (page: Page): Promise => - page.evaluate( - ({ attrName, tailwindLabelAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const tailwindLabelElements = Array.from( - shadowRoot?.querySelectorAll( - `[data-react-grab-edit-panel] [${tailwindLabelAttr}]`, - ) ?? [], - ); - const tailwindLabel = - tailwindLabelElements.find((element) => element.getBoundingClientRect().width > 0) ?? null; - return tailwindLabel?.textContent ?? null; - }, - { attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR }, - ); + (await getActiveTailwindLabelInfo(page)).text; export interface SearchInputFocusVisualState { isFocusVisible: boolean; diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index ef3ec2961..88c6c25f6 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -653,25 +653,27 @@ test.describe("Style Panel", () => { test("stepping an out-of-range value never jumps against the arrow direction", async ({ reactGrab, }) => { + // 200px border-radius is above the 96px row max but applies no + // layout (unlike a 128px font, whose reflow makes the hover-based + // panel open flaky) so the regression is isolated cleanly. await reactGrab.page.evaluate((buttonSelector) => { const button = document.querySelector(buttonSelector); - if (button instanceof HTMLElement) button.style.fontSize = "128px"; + if (button instanceof HTMLElement) button.style.borderRadius = "200px"; }, BUTTON_SELECTOR); await openEditPanel(reactGrab, BUTTON_SELECTOR); - await setSearchInputValue(reactGrab.page, "font size"); - await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("font-size"); + await setSearchInputValue(reactGrab.page, "rounded"); + await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius"); + // Stepping up from an above-max value is a no-op; settle then + // assert it held (can't poll for the absence of a change). await reactGrab.page.keyboard.press("ArrowRight"); await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size")).toBe( - "128px", - ); + 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). await reactGrab.page.keyboard.press("ArrowLeft"); - await reactGrab.page.waitForTimeout(80); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "font-size")).toBe( - "127px", - ); + await expect.poll(() => getActivePropertyValue(reactGrab.page)).toBe("199px"); }); test("rounded-full's infinite radius displays as a finite clamped value", async ({ @@ -1029,12 +1031,11 @@ test.describe("Style Panel", () => { test("typing -m-4 applies a negative margin", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.type("-m-4"); - await reactGrab.page.waitForTimeout(80); + await setSearchInputValue(reactGrab.page, "-m-4"); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")).toBe( - "-16px", - ); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) + .toBe("-16px"); expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-left")).toBe( "-16px", ); @@ -1042,12 +1043,11 @@ test.describe("Style Panel", () => { test("typing -mt-[8px] applies a negative arbitrary margin", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); - await reactGrab.page.keyboard.type("-mt-[8px]"); - await reactGrab.page.waitForTimeout(80); + await setSearchInputValue(reactGrab.page, "-mt-[8px]"); - expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")).toBe( - "-8px", - ); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top")) + .toBe("-8px"); }); test("typing font-mono applies font family + compact", async ({ reactGrab }) => { diff --git a/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts b/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts index d59262504..9f53b65fa 100644 --- a/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts +++ b/packages/react-grab/src/components/edit-panel/tailwind-autoapply.ts @@ -11,6 +11,7 @@ import { expandAggregateLonghands } from "../../utils/expand-aggregate-longhands import { roundEditableNumericValue } from "../../utils/format-css-value.js"; import { isNumericDraftQuery } from "../../utils/is-numeric-draft-query.js"; import { parseAnyColor } from "../../utils/parse-any-color.js"; +import { splitNegativePrefix } from "../../utils/split-negative-prefix.js"; import { normalizeTailwindClassInput, tailwindClassToEnumValue, @@ -41,30 +42,30 @@ const LITERAL_NUMBER_KEYS = new Set([ const findNumeric = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): NumericEditableProperty | null => { for (const property of properties) { - if (property.key === cssKey && property.kind === "numeric") return property; + if (property.key === propertyKey && property.kind === "numeric") return property; } return null; }; const findEnum = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): EnumEditableProperty | null => { for (const property of properties) { - if (property.key === cssKey && property.kind === "enum") return property; + if (property.key === propertyKey && property.kind === "enum") return property; } return null; }; const findColor = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): ColorEditableProperty | null => { for (const property of properties) { - if (property.key === cssKey && property.kind === "color") return property; + if (property.key === propertyKey && property.kind === "color") return property; } return null; }; @@ -98,9 +99,9 @@ const parseArbitraryLengthPx = (value: string, allowUnitless: boolean): number | const findNumericLonghands = ( properties: readonly EditableProperty[], - cssKey: string, + propertyKey: string, ): NumericEditableProperty[] => { - const longhands = expandAggregateLonghands(cssKey); + const longhands = expandAggregateLonghands(propertyKey); const matchedByRowKey = new Map(); for (const longhand of longhands) { for (const property of properties) { @@ -146,13 +147,16 @@ export const createTailwindAutoApply = ( const { initialProperties, searchQuery, isCompact, activeProperty, commit, setIsCompact } = options; - const hasTrackableTarget = (cssKey: string): boolean => { + const hasTrackableTarget = (propertyKey: string): boolean => { for (const property of initialProperties) { - if (property.key === cssKey && (property.kind === "numeric" || property.kind === "enum")) { + if ( + property.key === propertyKey && + (property.kind === "numeric" || property.kind === "enum") + ) { return true; } } - return findNumericLonghands(initialProperties, cssKey).length > 0; + return findNumericLonghands(initialProperties, propertyKey).length > 0; }; const parseInlineNumericValue = (query: string): InlineNumericValue | null => { @@ -197,8 +201,8 @@ export const createTailwindAutoApply = ( return true; }; - const commitLengthPx = (cssKey: string, value: number): boolean => { - const numericTarget = findNumeric(initialProperties, cssKey); + const commitLengthPx = (propertyKey: string, value: number): boolean => { + const numericTarget = findNumeric(initialProperties, propertyKey); if (numericTarget) { // px lengths only apply to px-measured props, not opacity/z-index. if (numericTarget.unit !== "px") return false; @@ -206,7 +210,7 @@ export const createTailwindAutoApply = ( commit(numericTarget, clampedFor(numericTarget, value), { shouldCompact: true }); return true; } - const sideProperties = findNumericLonghands(initialProperties, cssKey); + const sideProperties = findNumericLonghands(initialProperties, propertyKey); if (sideProperties.length === 0) return false; setIsCompact(true); batch(() => { @@ -244,12 +248,9 @@ export const createTailwindAutoApply = ( const lengthPx = parseArbitraryLengthPx(value, ARBITRARY_LENGTH_HINT.test(rawValue.trim())); if (lengthPx === null) return false; - const rawPrefix = arbitraryMatch[1]; - const isNegativePrefixed = rawPrefix.startsWith("-"); - const cssKey = tailwindPrefixToProperty( - arbitraryPrefix(isNegativePrefixed ? rawPrefix.slice(1) : rawPrefix), - ); - return cssKey ? commitLengthPx(cssKey, isNegativePrefixed ? -lengthPx : lengthPx) : false; + const { isNegative, base: basePrefix } = splitNegativePrefix(arbitraryMatch[1]); + const propertyKey = tailwindPrefixToProperty(arbitraryPrefix(basePrefix)); + return propertyKey ? commitLengthPx(propertyKey, isNegative ? -lengthPx : lengthPx) : false; }; const applySingleClass = (rawQuery: string) => { @@ -258,11 +259,10 @@ export const createTailwindAutoApply = ( // Canonical negative spelling: `-m-4` is margin -16px. Only the // numeric path below understands the sign; colors and enums have // no negative forms. - const isNegativePrefixed = query.startsWith("-"); - const baseQuery = isNegativePrefixed ? query.slice(1) : query; + const { isNegative: isNegativePrefixed, base: baseQuery } = splitNegativePrefix(query); const intentPrefix = baseQuery.replace(/-\d*$/, "").replace(/-$/, ""); - const intentCssKey = intentPrefix ? tailwindPrefixToProperty(intentPrefix) : null; - if (intentCssKey && hasTrackableTarget(intentCssKey)) setIsCompact(true); + const intentPropertyKey = intentPrefix ? tailwindPrefixToProperty(intentPrefix) : null; + if (intentPropertyKey && hasTrackableTarget(intentPropertyKey)) setIsCompact(true); if (!isNegativePrefixed) { if (applyColorClass(query, null)) return; @@ -282,32 +282,37 @@ export const createTailwindAutoApply = ( const tailwindClassMatch = baseQuery.match(TAILWIND_CLASS_PATTERN); if (!tailwindClassMatch) return; - const cssKey = tailwindPrefixToProperty(tailwindClassMatch[1]); - if (!cssKey) return; + const propertyKey = tailwindPrefixToProperty(tailwindClassMatch[1]); + if (!propertyKey) return; const rawNumber = Number.parseFloat(tailwindClassMatch[2]); if (!Number.isFinite(rawNumber)) return; const signedNumber = isNegativePrefixed ? -rawNumber : rawNumber; - const candidate = LITERAL_NUMBER_KEYS.has(cssKey) + const candidate = LITERAL_NUMBER_KEYS.has(propertyKey) ? signedNumber : signedNumber * TAILWIND_SPACING_UNIT_PX; - const numericTarget = findNumeric(initialProperties, cssKey); + const numericTarget = findNumeric(initialProperties, propertyKey); if (numericTarget) { commit(numericTarget, clampedFor(numericTarget, candidate), { shouldCompact: true }); return; } - const enumTarget = findEnum(initialProperties, cssKey); - if (enumTarget) { - const optionValue = String(rawNumber); - const option = enumTarget.options.find((entry) => entry.value === optionValue); - if (option) { - commit(enumTarget, option.value, { shouldCompact: true }); - return; + // Enum spellings like `font-700` are unsigned-only; a negative prefix + // (`-font-700`) has no meaning, so don't let the unsigned rawNumber + // resolve an enum option behind the sign's back. + if (!isNegativePrefixed) { + const enumTarget = findEnum(initialProperties, propertyKey); + if (enumTarget) { + const optionValue = String(rawNumber); + const option = enumTarget.options.find((entry) => entry.value === optionValue); + if (option) { + commit(enumTarget, option.value, { shouldCompact: true }); + return; + } } } - const sideProperties = findNumericLonghands(initialProperties, cssKey); + const sideProperties = findNumericLonghands(initialProperties, propertyKey); if (sideProperties.length === 0) return; batch(() => { for (const sideProperty of sideProperties) { @@ -318,13 +323,13 @@ export const createTailwindAutoApply = ( const isApplicableSingleClass = (normalizedQuery: string): boolean => { if (tailwindClassToEnumValue(normalizedQuery)) return true; - const baseQuery = normalizedQuery.startsWith("-") ? normalizedQuery.slice(1) : normalizedQuery; + const { base: baseQuery } = splitNegativePrefix(normalizedQuery); const tailwindClassMatch = baseQuery.match(TAILWIND_CLASS_PATTERN); if (!tailwindClassMatch) return false; - const cssKey = tailwindPrefixToProperty(tailwindClassMatch[1]); - if (!cssKey) return false; - if (hasTrackableTarget(cssKey)) return true; - return findEnum(initialProperties, cssKey) !== null; + const propertyKey = tailwindPrefixToProperty(tailwindClassMatch[1]); + if (!propertyKey) return false; + if (hasTrackableTarget(propertyKey)) return true; + return findEnum(initialProperties, propertyKey) !== null; }; const applyTailwindClass = (rawQuery: string) => { diff --git a/packages/react-grab/src/components/edit-panel/tweak-store.ts b/packages/react-grab/src/components/edit-panel/tweak-store.ts index e561c3416..695a28a0c 100644 --- a/packages/react-grab/src/components/edit-panel/tweak-store.ts +++ b/packages/react-grab/src/components/edit-panel/tweak-store.ts @@ -91,14 +91,13 @@ export const createTweakStore = (options: CreateTweakStoreOptions): TweakStore = return property; }; - // Preview writes longhands last-write-wins, so every consumer that - // resolves overlapping tweaks (row display sync, prompt building) - // must see them in commit order. Key insertion order carries that: - // each commit re-inserts its key at the end, and tweaks whose - // longhands are fully covered by the new commit are dropped — their - // preview effect was just overwritten wholesale. - let lastCommittedTweakKey: string | null = null; - + // Overlapping tweaks resolve last-write-wins, so every consumer that + // reads them (filteredProperties' fan-out, buildPendingEdits' forward + // scan) must see them in commit order. A Record preserves insertion + // order, so the just-committed key is kept last: re-committing the + // current last key spreads it in place; committing any other key + // rebuilds the map, appends that key, and drops tweaks whose longhands + // are now fully covered (their preview was overwritten wholesale). const applyTweak = (property: EditableProperty, nextValue: number | string) => { let tweak: PropertyTweak; if (property.kind === "numeric" && typeof nextValue === "number") { @@ -110,14 +109,13 @@ export const createTweakStore = (options: CreateTweakStoreOptions): TweakStore = } else { return; } - if (lastCommittedTweakKey === property.key) { - setTweaksByKey((current) => ({ ...current, [property.key]: tweak })); - return; - } - lastCommittedTweakKey = property.key; setTweaksByKey((current) => { + const existingKeys = Object.keys(current); + if (existingKeys[existingKeys.length - 1] === property.key) { + return { ...current, [property.key]: tweak }; + } const next: Record = {}; - for (const existingKey of Object.keys(current)) { + for (const existingKey of existingKeys) { if (existingKey === property.key) continue; const existingProperty = propertyByKey.get(existingKey); const isCoveredByNewTweak = diff --git a/packages/react-grab/src/utils/css-property-builders.ts b/packages/react-grab/src/utils/css-property-builders.ts index 030efa862..8d7d4cfe8 100644 --- a/packages/react-grab/src/utils/css-property-builders.ts +++ b/packages/react-grab/src/utils/css-property-builders.ts @@ -19,13 +19,20 @@ export const buildNumericProperty = ( isCanonical: boolean, ): NumericEditableProperty => { const normalized = normalizeForEdit(definition.key, rawValue); - const isInfiniteLength = normalized.value > CSS_LENGTH_INFINITY_THRESHOLD_PX; + // `rounded-full` resolves to calc(infinity * 1px), which browsers clamp + // to a huge px length. Only px-measured rows can hit it — unitless rows + // (z-index) keep their real value even when it legitimately exceeds the + // threshold. Pass 0 into propertyBounds so a value-derived max (size = + // value * 2) isn't itself inflated before we clamp to the bound. + const isInfiniteLength = + normalized.unit === "px" && Math.abs(normalized.value) > CSS_LENGTH_INFINITY_THRESHOLD_PX; const bounds = propertyBounds( definition.key, isInfiniteLength ? 0 : normalized.value, normalized.unit, ); - const value = isInfiniteLength ? bounds.max : normalized.value; + const clampedInfiniteValue = normalized.value < 0 ? bounds.min : bounds.max; + const value = isInfiniteLength ? clampedInfiniteValue : normalized.value; return { kind: "numeric", key: definition.key, diff --git a/packages/react-grab/src/utils/expand-aggregate-longhands.ts b/packages/react-grab/src/utils/expand-aggregate-longhands.ts index 595156d53..5b8a77a6a 100644 --- a/packages/react-grab/src/utils/expand-aggregate-longhands.ts +++ b/packages/react-grab/src/utils/expand-aggregate-longhands.ts @@ -7,8 +7,8 @@ for (const group of AGGREGATE_GROUPS) { } } -export const expandAggregateLonghands = (cssKey: string): string[] => { - if (cssKey.includes(",")) return cssKey.split(","); - const expansion = AGGREGATE_LONGHANDS.get(cssKey); - return expansion ? [...expansion] : [cssKey]; +export const expandAggregateLonghands = (propertyKey: string): string[] => { + if (propertyKey.includes(",")) return propertyKey.split(","); + const expansion = AGGREGATE_LONGHANDS.get(propertyKey); + return expansion ? [...expansion] : [propertyKey]; }; diff --git a/packages/react-grab/src/utils/find-tailwind-class.ts b/packages/react-grab/src/utils/find-tailwind-class.ts index 854d97632..444985630 100644 --- a/packages/react-grab/src/utils/find-tailwind-class.ts +++ b/packages/react-grab/src/utils/find-tailwind-class.ts @@ -6,90 +6,68 @@ import { TAILWIND_Z_INDEX_MAX, } from "../constants.js"; -const SPACING_PROPERTIES: ReadonlySet = new Set([ - "padding", - "padding-top,padding-bottom", - "padding-left,padding-right", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "margin", - "margin-top,margin-bottom", - "margin-left,margin-right", - "margin-top", - "margin-right", - "margin-bottom", - "margin-left", - "gap", - "column-gap", - "row-gap", - "width,height", - "width", - "height", - "min-width", - "max-width", - "min-height", - "max-height", - "top,right,bottom,left", - "top,bottom", - "left,right", - "top", - "right", - "bottom", - "left", -]); +// Tailwind suffix scales the chip number differently per family: +// spacing/size/inset use the 4px unit, border-width and z-index are +// literal px/integers, opacity is a percent step. +type TailwindChipScale = "spacing" | "border-width" | "z-index" | "opacity"; -// Most-specific Tailwind prefix per row key — `pt` over `p` for -// padding-top so the chip names the class the user would type. Keys -// are EditableProperty.key spellings, including the comma-joined -// aggregate rows ("padding-top,padding-bottom" → `py`). -const PROPERTY_TO_BEST_PREFIX: Record = { - padding: "p", - "padding-top,padding-bottom": "py", - "padding-left,padding-right": "px", - "padding-top": "pt", - "padding-right": "pr", - "padding-bottom": "pb", - "padding-left": "pl", - margin: "m", - "margin-top,margin-bottom": "my", - "margin-left,margin-right": "mx", - "margin-top": "mt", - "margin-right": "mr", - "margin-bottom": "mb", - "margin-left": "ml", - gap: "gap", - "column-gap": "gap-x", - "row-gap": "gap-y", - "width,height": "size", - width: "w", - height: "h", - "min-width": "min-w", - "max-width": "max-w", - "min-height": "min-h", - "max-height": "max-h", - "top,right,bottom,left": "inset", - "top,bottom": "inset-y", - "left,right": "inset-x", - top: "top", - right: "right", - bottom: "bottom", - left: "left", - "border-width": "border", - "border-top-width": "border-t", - "border-right-width": "border-r", - "border-bottom-width": "border-b", - "border-left-width": "border-l", - "z-index": "z", - opacity: "opacity", +interface TailwindChipRule { + prefix: string; + scale: TailwindChipScale; +} + +// Single source of truth for the chip: the most-specific Tailwind prefix +// per row key (`pt` over `p` for padding-top, so the chip names the class +// the user would type) plus the suffix scale that prefix uses. Keys are +// EditableProperty.key spellings, including the comma-joined aggregate +// rows ("padding-top,padding-bottom" → `py`). +const PROPERTY_CHIP_RULES: Record = { + padding: { prefix: "p", scale: "spacing" }, + "padding-top,padding-bottom": { prefix: "py", scale: "spacing" }, + "padding-left,padding-right": { prefix: "px", scale: "spacing" }, + "padding-top": { prefix: "pt", scale: "spacing" }, + "padding-right": { prefix: "pr", scale: "spacing" }, + "padding-bottom": { prefix: "pb", scale: "spacing" }, + "padding-left": { prefix: "pl", scale: "spacing" }, + margin: { prefix: "m", scale: "spacing" }, + "margin-top,margin-bottom": { prefix: "my", scale: "spacing" }, + "margin-left,margin-right": { prefix: "mx", scale: "spacing" }, + "margin-top": { prefix: "mt", scale: "spacing" }, + "margin-right": { prefix: "mr", scale: "spacing" }, + "margin-bottom": { prefix: "mb", scale: "spacing" }, + "margin-left": { prefix: "ml", scale: "spacing" }, + gap: { prefix: "gap", scale: "spacing" }, + "column-gap": { prefix: "gap-x", scale: "spacing" }, + "row-gap": { prefix: "gap-y", scale: "spacing" }, + "width,height": { prefix: "size", scale: "spacing" }, + width: { prefix: "w", scale: "spacing" }, + height: { prefix: "h", scale: "spacing" }, + "min-width": { prefix: "min-w", scale: "spacing" }, + "max-width": { prefix: "max-w", scale: "spacing" }, + "min-height": { prefix: "min-h", scale: "spacing" }, + "max-height": { prefix: "max-h", scale: "spacing" }, + "top,right,bottom,left": { prefix: "inset", scale: "spacing" }, + "top,bottom": { prefix: "inset-y", scale: "spacing" }, + "left,right": { prefix: "inset-x", scale: "spacing" }, + top: { prefix: "top", scale: "spacing" }, + right: { prefix: "right", scale: "spacing" }, + bottom: { prefix: "bottom", scale: "spacing" }, + left: { prefix: "left", scale: "spacing" }, + "border-width": { prefix: "border", scale: "border-width" }, + "border-top-width": { prefix: "border-t", scale: "border-width" }, + "border-right-width": { prefix: "border-r", scale: "border-width" }, + "border-bottom-width": { prefix: "border-b", scale: "border-width" }, + "border-left-width": { prefix: "border-l", scale: "border-width" }, + "z-index": { prefix: "z", scale: "z-index" }, + opacity: { prefix: "opacity", scale: "opacity" }, }; export const findTailwindClass = (propertyKey: string, value: number): string | null => { - const prefix = PROPERTY_TO_BEST_PREFIX[propertyKey]; - if (!prefix) return null; + const rule = PROPERTY_CHIP_RULES[propertyKey]; + if (!rule) return null; + const { prefix, scale } = rule; - if (SPACING_PROPERTIES.has(propertyKey)) { + if (scale === "spacing") { if (value < 0) return null; const units = value / TAILWIND_SPACING_UNIT_PX; if (!Number.isInteger(units)) return null; @@ -97,7 +75,7 @@ export const findTailwindClass = (propertyKey: string, value: number): string | return `${prefix}-${units}`; } - if (propertyKey === "border-width" || propertyKey.endsWith("-width")) { + if (scale === "border-width") { if (!Number.isInteger(value) || value < 0 || value > TAILWIND_BORDER_MAX_PX) return null; // 1px is the suffix-less form: `border` / `border-t`. Others get // numeric suffix: `border-2`, `border-t-4`, … @@ -105,18 +83,14 @@ export const findTailwindClass = (propertyKey: string, value: number): string | return `${prefix}-${value}`; } - if (propertyKey === "z-index") { + if (scale === "z-index") { if (!Number.isInteger(value) || value < 0 || value > TAILWIND_Z_INDEX_MAX) return null; return `${prefix}-${value}`; } - if (propertyKey === "opacity") { - // Opacity is stored as percent [0, 100] (build-editable-properties - // normalizes), so `value` already matches the chip number. - if (!Number.isFinite(value) || value < 0 || value > 100) return null; - if (value % TAILWIND_OPACITY_GRANULARITY !== 0) return null; - return `${prefix}-${value}`; - } - - return null; + // Opacity is stored as percent [0, 100] (build-editable-properties + // normalizes), so `value` already matches the chip number. + if (!Number.isFinite(value) || value < 0 || value > 100) return null; + if (value % TAILWIND_OPACITY_GRANULARITY !== 0) return null; + return `${prefix}-${value}`; }; diff --git a/packages/react-grab/src/utils/property-definitions.ts b/packages/react-grab/src/utils/property-definitions.ts index 2c9b9ab29..44123d873 100644 --- a/packages/react-grab/src/utils/property-definitions.ts +++ b/packages/react-grab/src/utils/property-definitions.ts @@ -72,16 +72,6 @@ export const FALLBACK_ZERO_PX: ReadonlySet = new Set([ "column-gap", ]); -export const POSITION_KEYS: ReadonlySet = new Set([ - "top", - "right", - "bottom", - "left", - "top,right,bottom,left", - "top,bottom", - "left,right", -]); - // Keys whose CSS value is a pure number (no unit). Writing `10px` here // would be invalid CSS. export const UNITLESS_KEYS: ReadonlySet = new Set(["z-index"]); @@ -218,6 +208,12 @@ const INSET_AGGREGATES: readonly AggregateDefinition[] = [ { key: "left", label: "left", longhands: ["left"] }, ]; +// Position rows allow negative values (overlays at `top: -8px`). The set +// is exactly the inset aggregate keys — derived so the two can't drift. +export const POSITION_KEYS: ReadonlySet = new Set( + INSET_AGGREGATES.map((definition) => definition.key), +); + const BORDER_WIDTH_AGGREGATES: readonly AggregateDefinition[] = [ { key: "border-width", diff --git a/packages/react-grab/src/utils/split-negative-prefix.ts b/packages/react-grab/src/utils/split-negative-prefix.ts new file mode 100644 index 000000000..b0e4650cd --- /dev/null +++ b/packages/react-grab/src/utils/split-negative-prefix.ts @@ -0,0 +1,9 @@ +// Canonical Tailwind negatives are written with a leading dash (`-m-4`, +// `-mt-[8px]`). Only numeric/length utilities carry a sign — colors and +// enums have no negative form — so the parser strips a single leading +// `-`, works on the unsigned spelling, and the caller re-applies the +// sign to the magnitude. +export const splitNegativePrefix = (query: string): { isNegative: boolean; base: string } => { + const isNegative = query.startsWith("-"); + return { isNegative, base: isNegative ? query.slice(1) : query }; +}; From 4c7bda48bde26f7ca3cf5e8a3dd5f60dbd5a7445 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 12 Jun 2026 23:12:34 -0700 Subject: [PATCH 3/6] Rename tweak store to style store; harden overlap tests - Rename the edit-panel "tweak store" to "style store" to match the product's style mode: tweak-store.ts -> style-store.ts and all symbols (createStyleStore, applyStyle, hasPendingStyles, hasChangedStyleFor, stylesByKey, PropertyStyle). Behavior-preserving. - Rename commitTweak -> stepActiveProperty (it steps the active property and delegates to commit) and isUntweakedColor -> isUnchangedColor. - Add an e2e regression for a 3-way overlap (p-6 -> pt-8 -> px-2) that locks the prompt-matches-preview ordering across a full aggregate, a longhand, and an axis aggregate. - De-flake panel opening: share a hoverUntilSelected retry primitive (re-hover, moving the pointer away first) between openEditPanel and enterPromptMode, replacing their divergent ad-hoc hover waits. --- packages/react-grab/e2e/edit-panel-helpers.ts | 3 +- packages/react-grab/e2e/edit-panel.spec.ts | 34 ++++ packages/react-grab/e2e/fixtures.ts | 53 +++---- .../src/components/edit-panel/index.tsx | 34 ++-- .../{tweak-store.ts => style-store.ts} | 147 +++++++++--------- packages/react-grab/src/core/edit-mode.ts | 2 +- 6 files changed, 151 insertions(+), 122 deletions(-) rename packages/react-grab/src/components/edit-panel/{tweak-store.ts => style-store.ts} (54%) diff --git a/packages/react-grab/e2e/edit-panel-helpers.ts b/packages/react-grab/e2e/edit-panel-helpers.ts index 69504494d..376170ceb 100644 --- a/packages/react-grab/e2e/edit-panel-helpers.ts +++ b/packages/react-grab/e2e/edit-panel-helpers.ts @@ -547,8 +547,7 @@ export const openEditPanel = async ( selector: string, ): Promise => { await reactGrab.activate(); - await reactGrab.hoverElement(selector); - await reactGrab.waitForSelectionBox(); + await reactGrab.hoverUntilSelected(selector); await reactGrab.rightClickElement(selector); await reactGrab.clickContextMenuItem("Style"); await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index 88c6c25f6..fd7807b9b 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -1282,6 +1282,40 @@ test.describe("Style Panel", () => { expect(clipboardContent).not.toContain("padding-top: 24px;"); }); + test("copied prompt matches the preview across overlapping full, longhand, and axis aggregates", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR); + await setSearchInputValue(reactGrab.page, "p-6"); + await expect + .poll(() => + getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-left"), + ) + .toBe("24px"); + await setSearchInputValue(reactGrab.page, "pt-8"); + await expect + .poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top")) + .toBe("32px"); + // px-2 sends left/right back to their 8px original on top of the + // p-6 fan-out; it must still be emitted so the prompt doesn't claim + // padding: 24px covers the horizontal sides. + await setSearchInputValue(reactGrab.page, "px-2"); + await expect + .poll(() => + getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-left"), + ) + .toBe("8px"); + + await reactGrab.page.keyboard.press("Enter"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-left: 8px;"); + const clipboardContent = await reactGrab.getClipboardContent(); + expect(clipboardContent).toContain("padding-top: 32px;"); + expect(clipboardContent).toContain("padding-right: 8px;"); + expect(clipboardContent).toContain("padding-bottom: 24px;"); + expect(clipboardContent).not.toContain("padding-right: 24px;"); + }); + 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/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 46284ac5b..fffd6ffc4 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -11,6 +11,8 @@ const TOOLBAR_SNAP_ANIMATION_WAIT_MS = 300; const TOOLBAR_SNAP_EDGE_THRESHOLD_PX = 30; const TOUCH_DRAG_STEPS = 10; const PAGE_SETUP_RETRY_BACKOFF_MS = 250; +const SELECTION_HOVER_ATTEMPTS = 3; +const SELECTION_HOVER_TIMEOUT_MS = 4_000; interface ContextMenuInfo { isVisible: boolean; @@ -96,13 +98,14 @@ export interface ReactGrabPageObject { getOverlayHost: () => Locator; getShadowRoot: () => Promise; hoverElement: (selector: string) => Promise; + hoverUntilSelected: (selector: string) => Promise; clickElement: (selector: string) => Promise; rightClickElement: (selector: string) => Promise; rightClickAtPosition: (x: number, y: number) => Promise; dragSelect: (startSelector: string, endSelector: string) => Promise; getClipboardContent: () => Promise; captureNextClipboardWrites: () => Promise>; - waitForSelectionBox: () => Promise; + waitForSelectionBox: (timeout?: number) => Promise; waitForSelectionSource: () => Promise; isContextMenuVisible: () => Promise; getContextMenuInfo: () => Promise; @@ -356,7 +359,7 @@ const createReactGrabPageObject = ( }); }; - const waitForSelectionBox = async () => { + const waitForSelectionBox = async (timeout = 10_000) => { await page.waitForFunction( () => { const api = ( @@ -373,10 +376,26 @@ const createReactGrabPageObject = ( return state?.isSelectionBoxVisible || state?.targetElement !== null; }, undefined, - { timeout: 10_000 }, + { timeout }, ); }; + // A single synthetic hover can race scroll-into-view for elements low + // in the page, leaving react-grab without a selection. Re-hover (moving + // the pointer away first so the move re-fires) until it registers. + const hoverUntilSelected = async (selector: string) => { + for (let attempt = 1; attempt <= SELECTION_HOVER_ATTEMPTS; attempt++) { + if (attempt > 1) await page.mouse.move(0, 0); + await hoverElement(selector); + try { + await waitForSelectionBox(SELECTION_HOVER_TIMEOUT_MS); + return; + } catch (error) { + if (attempt === SELECTION_HOVER_ATTEMPTS) throw error; + } + } + }; + const waitForSelectionSource = async () => { await page.waitForFunction( () => { @@ -608,32 +627,7 @@ const createReactGrabPageObject = ( const enterPromptMode = async (selector: string) => { await activate(); - await hoverElement(selector); - const isSelected = await page - .waitForFunction( - () => { - const api = ( - window as { - __REACT_GRAB__?: { - getState: () => { - isSelectionBoxVisible: boolean; - targetElement: unknown; - }; - }; - } - ).__REACT_GRAB__; - const state = api?.getState(); - return state?.isSelectionBoxVisible || state?.targetElement !== null; - }, - undefined, - { timeout: 5000 }, - ) - .then(() => true) - .catch(() => false); - if (!isSelected) { - await hoverElement(selector); - await waitForSelectionBox(); - } + await hoverUntilSelected(selector); await rightClickElement(selector); await clickContextMenuItem("Comment"); await waitForPromptMode(true); @@ -1727,6 +1721,7 @@ const createReactGrabPageObject = ( getOverlayHost, getShadowRoot, hoverElement, + hoverUntilSelected, clickElement, rightClickElement, rightClickAtPosition, diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index 9c14dd9b4..f60d8ac81 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -47,8 +47,8 @@ import { arePropertyValuesEqual } from "./property-values-equal.js"; import { createShiftTracker } from "./shift-tracker.js"; import { createStepController } from "./step-controller.js"; import { stepProperty } from "./step-property.js"; +import { createStyleStore } from "./style-store.js"; import { createTailwindAutoApply } from "./tailwind-autoapply.js"; -import { createTweakStore } from "./tweak-store.js"; interface EditPanelProps { state: EditPanelState | null; @@ -93,20 +93,20 @@ const EditPanelBody: Component = (props) => { const [searchQuery, setSearchQuery] = createSignal(props.state.initialSearchQuery ?? ""); const [inlineNumericSearchQuery, setInlineNumericSearchQuery] = createSignal(null); const [activeKey, setActiveKey] = createSignal<"left" | "right" | null>(null); - const tweakStore = createTweakStore({ + const styleStore = createStyleStore({ initialProperties, searchQuery: () => inlineNumericSearchQuery() ?? searchQuery(), }); // Colors are pinned on top but aren't slider-steppable, so the arrow-key // cursor lands on the first numeric row instead. const firstNumericActiveIndex = (): number => { - const numericIndex = tweakStore + const numericIndex = styleStore .filteredProperties() .findIndex((property) => property.kind === "numeric"); return numericIndex > 0 ? numericIndex : 0; }; const [activeIndex, setActiveIndex] = createSignal(firstNumericActiveIndex()); - const hasPendingTweaks = createMemo(() => tweakStore.hasPendingTweaks()); + const hasPendingStyles = createMemo(() => styleStore.hasPendingStyles()); const [isCompact, setIsCompact] = createSignal(false); let activeKeyTimerId: ReturnType | undefined; @@ -114,7 +114,7 @@ const EditPanelBody: Component = (props) => { let inlineNumericReplaceTimerId: ReturnType | undefined; let shouldReplaceInlineNumericInput = false; const [isTransientInteraction, setIsTransientInteraction] = createSignal(false); - const isInteracting = createMemo(() => isTransientInteraction() || hasPendingTweaks()); + const isInteracting = createMemo(() => isTransientInteraction() || hasPendingStyles()); const [isHeaderHovered, setIsHeaderHovered] = createSignal(false); const tagDisplay = createMemo(() => @@ -124,7 +124,7 @@ const EditPanelBody: Component = (props) => { }), ); - const filteredProperties = tweakStore.filteredProperties; + const filteredProperties = styleStore.filteredProperties; const activeProperty = createMemo(() => { const properties = filteredProperties(); @@ -246,7 +246,7 @@ const EditPanelBody: Component = (props) => { nextValue: number | string, options: CommitOptions = {}, ) => { - tweakStore.applyTweak(property, nextValue); + styleStore.applyStyle(property, nextValue); preview.apply(property.cssProperties, formatEditableValue(property, nextValue)); markAsInteracting(); if (!options.isFromKeyRepeat) discardConfirmation.hide(); @@ -257,7 +257,7 @@ const EditPanelBody: Component = (props) => { const isShiftHeld = createShiftTracker(); - const commitTweak = ( + const stepActiveProperty = ( direction: 1 | -1, shift: boolean, fromRepeat: boolean, @@ -278,11 +278,11 @@ const EditPanelBody: Component = (props) => { }; const stepFromKeyboard = (direction: 1 | -1, shift: boolean, fromRepeat: boolean) => { - if (commitTweak(direction, shift, fromRepeat)) setIsCompact(true); + if (stepActiveProperty(direction, shift, fromRepeat)) setIsCompact(true); }; const stepFromPointer = (direction: 1 | -1) => { - commitTweak(direction, false, false); + stepActiveProperty(direction, false, false); }; const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld }); @@ -320,7 +320,7 @@ const EditPanelBody: Component = (props) => { ); const handleSubmit = () => { - const pendingEdits = tweakStore.buildPendingEdits(); + const pendingEdits = styleStore.buildPendingEdits(); const entry = { filePath: props.state.filePath ?? "", lineNumber: props.state.lineNumber ?? 0, @@ -353,7 +353,7 @@ const EditPanelBody: Component = (props) => { closePanel("discard"); return; } - if (!hasPendingTweaks()) { + if (!hasPendingStyles()) { closePanel(preview.hasAppliedStyles() ? "discard" : "preserve"); return; } @@ -418,9 +418,9 @@ const EditPanelBody: Component = (props) => { // row with nothing else staged. If any edit is pending, Enter must // submit it — otherwise the picker interaction dismisses the panel // and the pending change is discarded instead of copied. - const isUntweakedColor = - property?.kind === "color" && !tweakStore.hasChangedTweakFor(property.key); - if (isUntweakedColor && colorPickerTrigger && !hasPendingTweaks()) { + const isUnchangedColor = + property?.kind === "color" && !styleStore.hasChangedStyleFor(property.key); + if (isUnchangedColor && colorPickerTrigger && !hasPendingStyles()) { colorPickerTrigger(); return; } @@ -562,7 +562,7 @@ const EditPanelBody: Component = (props) => {
setIsHeaderHovered(true)} onMouseLeave={() => setIsHeaderHovered(false)} @@ -574,7 +574,7 @@ const EditPanelBody: Component = (props) => { onClick={() => {}} shrink /> - +