From 58bb05c31d571386a95df34ad2263c349be5c873 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 21:54:27 -0700 Subject: [PATCH] fix(studio): restore stroke color, radius unlink, and mask inset in flat Style --- .fallowrc.jsonc | 10 + .../editor/propertyPanelFlatStyleHelpers.ts | 15 ++ .../propertyPanelFlatStyleSections.test.tsx | 116 +++++++-- .../editor/propertyPanelFlatStyleSections.tsx | 222 +++++++++++------- 4 files changed, 257 insertions(+), 106 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index cf2376394..d0ccbb4ab 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -337,6 +337,16 @@ "file": "packages/studio/src/components/editor/propertyPanelSections.tsx", "exports": ["TextAreaField"], }, + // Link: its only consumer was FlatRadiusRow's uniform-only fallback row in + // propertyPanelFlatStyleSections.tsx, deleted by the Style parity fix + // (p8-task-style-parity) — that row was unreachable from a uniform radius + // and is now replaced by BorderRadiusEditor's own unlink toggle. Kept in + // the icon set for future reuse rather than deleted from a file outside + // this fix's scope. + { + "file": "packages/studio/src/icons/SystemIcons.tsx", + "exports": ["Link"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts index 3f1db7ef7..56939a48d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleHelpers.ts @@ -1,3 +1,18 @@ +// Mirrors legacy `propertyPanelStyleSections.tsx`'s `SelectField` "Style" options — +// the single source of truth for which border-style tokens are valid. +export const STROKE_STYLE_OPTIONS: string[] = [ + "none", + "solid", + "dashed", + "dotted", + "double", + "hidden", + "groove", + "ridge", + "inset", + "outset", +]; + export function formatStrokeSummary(widthPx: number, style: string): string { return `${widthPx}px ${style}`; } diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx index 90767fec4..e5c27540f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx @@ -156,6 +156,24 @@ const STROKE_STYLES = { "border-color": "rgba(255,255,255,.12)", }; +function getMetricFieldInput(host: HTMLElement, label: string): HTMLInputElement { + const spans = Array.from(host.querySelectorAll("span")).filter((el) => el.textContent === label); + for (const span of spans) { + const input = span.parentElement?.querySelector("input"); + if (input) return input; + } + throw new Error(`expected a metric field input for "${label}"`); +} + +function setInputValue(input: HTMLInputElement, nextValue: string) { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + describe("FlatStyleSection — Stroke and Radius", () => { it("renders the combined stroke row and commits width+style together on blur", () => { const { host, root } = renderSection(STROKE_STYLES); @@ -172,21 +190,77 @@ describe("FlatStyleSection — Stroke and Radius", () => { act(() => root.unmount()); }); - it("renders a single Radius value with a Linked indicator when corners are uniform", () => { - const { host, root } = renderSection({ "border-radius": "12px" }); - expect(host.textContent).toContain("Radius"); - expect(getFlatRowInput(host, "Radius").value).toBe("12px"); - expect(host.textContent).toContain("Linked"); + it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + await commitFlatRowInput(host, "Stroke", "9999px solid"); + expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px"); act(() => root.unmount()); }); - it("commits the radius row's new value to border-radius on blur when corners are uniform", async () => { + it("rejects a stroke commit whose style token is not a valid border-style (fix 2)", async () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + await commitFlatRowInput(host, "Stroke", "12px bogus"); + expect(onSetStyle).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("commits a stroke style change through the discoverable Stroke style select (fix 2)", () => { + const { host, root, onSetStyle } = renderSection(STROKE_STYLES); + changeFlatSelectRow(host, "Stroke style", "dashed"); + expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed"); + act(() => root.unmount()); + }); + + it("commits a new stroke color through the flat ColorField (fix 1)", () => { + const { host, root, onSetStyle } = renderSection({ + "border-width": "1px", + "border-style": "solid", + "border-color": "rgb(10, 20, 30)", + }); + const trigger = Array.from( + host.querySelectorAll('[data-flat-color-trigger="true"]'), + ).find((btn) => btn.getAttribute("aria-label") === "Pick stroke color color"); + if (!trigger) throw new Error("expected the stroke color trigger"); + act(() => trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const hexInput = Array.from(document.querySelectorAll("input")).find( + (input) => !host.contains(input), + ); + if (!hexInput) throw new Error("expected the color picker's hex input"); + act(() => setInputValue(hexInput, "#112233")); + expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)"); + act(() => root.unmount()); + }); + + it("uses BorderRadiusEditor for radius, linked by default, even when corners are uniform (fix 3)", () => { + const { host, root } = renderSection({ "border-radius": "12px" }); + const unlinkButton = host.querySelector('button[title="Unlink corners"]'); + expect(unlinkButton).not.toBeNull(); + expect(getMetricFieldInput(host, "All").value).toBe("12"); + act(() => root.unmount()); + }); + + it("commits a uniform radius value through BorderRadiusEditor's linked All field", () => { const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" }); - await commitFlatRowInput(host, "Radius", "20px"); + const allInput = getMetricFieldInput(host, "All"); + act(() => setInputValue(allInput, "20")); + act(() => allInput.dispatchEvent(new Event("focusout", { bubbles: true }))); expect(onSetStyle).toHaveBeenCalledWith("border-radius", "20px"); act(() => root.unmount()); }); + it("commits a single-corner radius update after unlinking a uniform radius (fix 3)", () => { + const { host, root, onSetStyle } = renderSection({ "border-radius": "12px" }); + const unlinkButton = host.querySelector('button[title="Unlink corners"]'); + if (!unlinkButton) throw new Error("expected the unlink toggle button"); + act(() => unlinkButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const trInput = getMetricFieldInput(host, "TR"); + act(() => setInputValue(trInput, "18")); + act(() => trInput.dispatchEvent(new Event("focusout", { bubbles: true }))); + expect(onSetStyle).toHaveBeenCalledWith("border-top-right-radius", "18px"); + expect(onSetStyle).not.toHaveBeenCalledWith("border-radius", expect.anything()); + act(() => root.unmount()); + }); + it("falls back to the legacy BorderRadiusEditor when corners are not uniform", () => { const { host, root } = renderSection({}, {}, { tl: 4, tr: 12, br: 4, bl: 4 }); expect(host.textContent).not.toContain("Linked"); @@ -199,14 +273,7 @@ describe("FlatStyleSection — Stroke and Radius", () => { (el) => el.value === "12", ); if (!trInput) throw new Error("expected the TR corner input"); - act(() => { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - nativeInputValueSetter?.call(trInput, "18"); - trInput.dispatchEvent(new Event("input", { bubbles: true })); - }); + act(() => setInputValue(trInput, "18")); act(() => { trInput.dispatchEvent(new Event("focusout", { bubbles: true })); }); @@ -472,6 +539,25 @@ describe("FlatStyleSection — Overflow and Mask", () => { expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(8px 8px 5px 8px round 4px)"); act(() => root.unmount()); }); + + it("renders a uniform Mask inset slider and commits clip-path via buildInsetClipPathValue (fix 4)", () => { + const { host, root, onSetStyle } = renderSection({ "clip-path": "inset(8px round 4px)" }); + expect(host.textContent).toContain("Mask inset"); + const tracks = host.querySelectorAll('[data-flat-slider-track="true"]'); + // Track order: Layer blur, Backdrop, Mask inset, Opacity. + const maskInsetTrack = tracks[2]; + Object.defineProperty(maskInsetTrack, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 2, right: 100, bottom: 2 }), + }); + act(() => { + maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 })); + }); + // clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px. + // border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue + // (read from the `border-radius` style, matching legacy) is 0. + expect(onSetStyle).toHaveBeenCalledWith("clip-path", "inset(60px round 0px)"); + act(() => root.unmount()); + }); }); describe("FlatStyleSection — Opacity", () => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index 0819ae660..c8f10e482 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -2,13 +2,17 @@ import { useEffect, useState } from "react"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; -import { Link as LinkIcon } from "../../icons/SystemIcons"; import { BorderRadiusEditor } from "./BorderRadiusEditor"; -import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers"; +import { + formatStrokeSummary, + parseStrokeSummary, + STROKE_STYLE_OPTIONS, +} from "./propertyPanelFlatStyleHelpers"; import { buildBoxShadowPresetValue, buildClipPathValue, buildInsetClipPathSides, + buildInsetClipPathValue, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, extractBackgroundImageUrl, @@ -170,37 +174,65 @@ function FlatStrokeRow({ ); return ( - { - const parsed = parseStrokeSummary(next); - if (!parsed) return; - for (const [property, value] of buildStrokeWidthStyleUpdates( - formatPxMetricValue(parsed.widthPx), - parsed.style, - )) { - await onSetStyle(property, value); + <> + { + const parsed = parseStrokeSummary(next); + if (!parsed) return; + if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return; + const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, { + min: 0, + max: 200, + fallback: borderWidthValue, + }); + if (!normalizedWidth) return; + for (const [property, value] of buildStrokeWidthStyleUpdates( + normalizedWidth, + parsed.style, + )) { + await onSetStyle(property, value); + } + for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) { + await onSetStyle(property, value); + } + }} + suffix={ + <> + + {borderColorValue} + } - for (const [property, value] of buildStrokeStyleUpdates( - parsed.style, - formatPxMetricValue(parsed.widthPx), - )) { - await onSetStyle(property, value); - } - }} - suffix={ - <> - - {borderColorValue} - - } - /> + /> + { + for (const [property, value] of buildStrokeStyleUpdates( + next, + formatPxMetricValue(borderWidthValue), + )) { + await onSetStyle(property, value); + } + }} + /> + onSetStyle("border-color", next)} + /> + ); } @@ -229,7 +261,6 @@ function FlatRadiusRow({ gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue; const radiusBL = gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue; - const uniform = radiusTL === radiusTR && radiusTR === radiusBR && radiusBR === radiusBL; const commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => { const px = `${formatNumericValue(value)}px`; @@ -246,41 +277,14 @@ function FlatRadiusRow({ void onSetStyle(prop, px); }; - if (!uniform) { - return ( - - ); - } - return ( - { - const parsed = parsePxMetricValue(next.endsWith("px") ? next : `${next}px`); - if (parsed == null) return; - const normalized = normalizePanelPxValue(`${parsed}px`, { - min: 0, - max: 400, - fallback: radiusTL, - }); - commit("all", normalized != null ? (parsePxMetricValue(normalized) ?? radiusTL) : radiusTL); - }} - suffix={ - - - Linked - - } + onCommit={commit} /> ); } @@ -380,10 +384,7 @@ function FlatBlurSliders({ ); } -/* ------------------------------------------------------------------ */ -/* Flat Overflow + Mask rows (+ inset sides) */ -/* ------------------------------------------------------------------ */ - +// Flat Overflow + Mask rows (+ inset sides). function FlatOverflowMaskRows({ styles, disabled, @@ -396,6 +397,55 @@ function FlatOverflowMaskRows({ const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0; const clipPathValue = styles["clip-path"] || "none"; const clipPathPreset = inferClipPathPreset(clipPathValue); + + return ( + <> + void onSetStyle("overflow", next)} + onReset={() => void onSetStyle("overflow", "visible")} + /> + { + void onSetStyle( + "clip-path", + buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue), + ); + }} + onReset={() => void onSetStyle("clip-path", "none")} + /> + + + ); +} + +// Flat Mask inset — uniform slider + per-side fields. +function FlatMaskInsetRows({ + clipPathValue, + radiusValue, + disabled, + onSetStyle, +}: { + clipPathValue: string; + radiusValue: number; + disabled: boolean; + onSetStyle: (prop: string, value: string) => void | Promise; +}) { + const clipPathPreset = inferClipPathPreset(clipPathValue); const parsedClipInsets = parseInsetClipPathSides(clipPathValue); const clipInsetValue = getClipPathInsetPx(clipPathValue); const clipInsetSides = parsedClipInsets ?? { @@ -422,28 +472,18 @@ function FlatOverflowMaskRows({ return ( <> - 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(clipInsetValue)}px`} disabled={disabled} - onChange={(next) => void onSetStyle("overflow", next)} - onReset={() => void onSetStyle("overflow", "visible")} - /> - { - void onSetStyle( - "clip-path", - buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue), - ); - }} - onReset={() => void onSetStyle("clip-path", "none")} + onCommit={(next) => + void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue)) + } /> {showClipInsetSides && (