From 5dfee38e971a93ca088d86488062cb8aaecfe29f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 21:54:27 -0700 Subject: [PATCH 1/4] fix(studio): restore stroke color, radius unlink, and mask inset in flat Style --- .fallowrc.jsonc | 10 + .../editor/propertyPanelFlatStyleHelpers.ts | 15 ++ .../propertyPanelFlatStyleSections.test.tsx | 114 +++++++-- .../editor/propertyPanelFlatStyleSections.tsx | 222 +++++++++++------- 4 files changed, 256 insertions(+), 105 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f497696dba..5960d782d8 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -272,6 +272,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 3f1db7ef73..56939a48d0 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 90767fec4d..e5c27540fb 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", () => { + 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("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" }); - expect(host.textContent).toContain("Radius"); - expect(getFlatRowInput(host, "Radius").value).toBe("12px"); - expect(host.textContent).toContain("Linked"); + const unlinkButton = host.querySelector('button[title="Unlink corners"]'); + expect(unlinkButton).not.toBeNull(); + expect(getMetricFieldInput(host, "All").value).toBe("12"); act(() => root.unmount()); }); - it("commits the radius row's new value to border-radius on blur when corners are uniform", async () => { + 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 0819ae6603..c8f10e482e 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); - } - for (const [property, value] of buildStrokeStyleUpdates( - parsed.style, - formatPxMetricValue(parsed.widthPx), - )) { - 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} + } - }} - 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 ( <> - void onSetStyle("overflow", next)} - onReset={() => void onSetStyle("overflow", "visible")} - /> - 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(clipInsetValue)}px`} disabled={disabled} - onChange={(next) => { - 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 && (
From e0dd00b9297194a27e59b74f2b679ffa8e5ff47d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 22:00:20 -0700 Subject: [PATCH 2/4] docs(studio): fix stale radius-row comment after unlink fix --- .../src/components/editor/propertyPanelFlatStyleSections.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx index c8f10e482e..2f689b0f07 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx @@ -237,7 +237,7 @@ function FlatStrokeRow({ } /* ------------------------------------------------------------------ */ -/* Flat Radius row — uniform case; legacy fallback otherwise */ +/* Flat Radius row — always delegates to BorderRadiusEditor */ /* ------------------------------------------------------------------ */ // fallow-ignore-next-line complexity From b3cef4a75ec3a2ae0d7592434e29dbfd2cb2ddec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 22:06:14 -0700 Subject: [PATCH 3/4] fix(studio): restore zero-strength revive, keyboard hold, and HDR detail in flat Grade --- ...pertyPanelFlatColorGradingSection.test.tsx | 181 ++++++++++++++++++ .../propertyPanelFlatColorGradingSection.tsx | 72 ++++++- 2 files changed, 246 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx index a1ee58b829..73ef2a06fb 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx @@ -103,6 +103,110 @@ describe("FlatColorGradingAccessory", () => { expect(resetGrading).toHaveBeenCalledTimes(1); act(() => root.unmount()); }); + + it("shows the runtime status message as visible text next to the dot, not only as a title", () => { + const { host, root } = renderInto( + , + ); + const messageEl = host.querySelector('[data-flat-grade-status-message="true"]'); + expect(messageEl).not.toBeNull(); + expect(messageEl?.textContent).toBe("Waiting for shader"); + expect(host.textContent).toContain("Waiting for shader"); + act(() => root.unmount()); + }); + + function activeGrading() { + const grading = neutralGrading(); + return { ...grading, adjust: { ...grading.adjust, contrast: 0.2 } }; + } + + it("activates hold-to-compare on pointerdown and releases on window pointerup", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + expect(commitCompare).toHaveBeenNthCalledWith(1, true); + act(() => window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }))); + expect(commitCompare).toHaveBeenNthCalledWith(2, false); + act(() => root.unmount()); + }); + + it("activates hold-to-compare via keyboard Space and releases on keyup", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => + compareButton.dispatchEvent( + new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }), + ), + ); + expect(commitCompare).toHaveBeenNthCalledWith(1, true); + act(() => + compareButton.dispatchEvent( + new KeyboardEvent("keyup", { key: " ", bubbles: true, cancelable: true }), + ), + ); + expect(commitCompare).toHaveBeenNthCalledWith(2, false); + act(() => root.unmount()); + }); + + it("releases an active hold when the window loses focus mid-hold", () => { + const commitCompare = vi.fn(); + const { host, root } = renderInto( + , + ); + const compareButton = host.querySelector( + '[aria-label="Hold to show original"]', + ); + if (!compareButton) throw new Error("expected a compare button"); + act(() => compareButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + expect(commitCompare).toHaveBeenNthCalledWith(1, true); + act(() => window.dispatchEvent(new Event("blur"))); + expect(commitCompare).toHaveBeenNthCalledWith(2, false); + act(() => root.unmount()); + }); }); function neutralPropsBase() { @@ -280,6 +384,43 @@ describe("FlatColorGradingSection — Adjust sliders", () => { expect(onCommitColorGrading.mock.calls[0][0].adjust.saturation).toBe(0.2); act(() => root.unmount()); }); + + it("revives a grade parked at 0% strength back to 100% when an Adjust slider is committed", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), intensity: 0 }; + const { host, root } = renderInto( + , + ); + const contrastRow = findRowByText(host, '[data-flat-grade-adjust="true"]', "Contrast"); + // min=-100, max=100, step=1, ratio=0.75 -> raw=50 -> commit(50) -> adjust.contrast = 0.5 + dragSliderTrack(contrastRow, 75, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(1); + expect(onCommitColorGrading.mock.calls[0][0].adjust.contrast).toBe(0.5); + act(() => root.unmount()); + }); + + it("does NOT force intensity to revive when the Strength slider itself is dragged — it writes the value directly", () => { + const onCommitColorGrading = vi.fn(); + const grading = { ...neutralGrading(), intensity: 0 }; + const { host, root } = renderInto( + , + ); + const strengthRow = findRowByText(host, "div", "Strength", "startsWith"); + // min=0, max=100, step=1, ratio=0.4 -> raw=40 -> commit(40) -> intensity = 40/100 = 0.4 + dragSliderTrack(strengthRow, 40, 100); + expect(onCommitColorGrading).toHaveBeenCalledTimes(1); + expect(onCommitColorGrading.mock.calls[0][0].intensity).toBe(0.4); + act(() => root.unmount()); + }); }); describe("FlatColorGradingSection — Vignette and Grain", () => { @@ -386,6 +527,46 @@ describe("FlatColorGradingSection — HDR banner and Apply scope", () => { act(() => root.unmount()); }); + it("shows a codec/profile/pixel-format/color detail line in the HDR banner when metadata provides it", () => { + const { host, root } = renderInto( + , + ); + const detail = host.querySelector('[data-flat-grade-hdr-detail="true"]'); + expect(detail).not.toBeNull(); + expect(detail?.textContent).toBe("hevc · Main10 · yuv420p10le · bt2020 · smpte2084"); + act(() => root.unmount()); + }); + + it("omits the HDR detail line entirely when no detail fields are populated", () => { + const { host, root } = renderInto( + , + ); + expect(host.querySelector('[data-flat-grade-hdr-detail="true"]')).toBeNull(); + act(() => root.unmount()); + }); + it("omits the HDR banner for SDR media", () => { const { host, root } = renderInto( { + if (compareEnabled) commitCompare(false); + }} + onKeyDown={(e) => { + if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return; + e.preventDefault(); + if (!compareEnabled) commitCompare(true); + }} + onKeyUp={(e) => { + if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return; + e.preventDefault(); + commitCompare(false); }} title="Hold to show original" className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40" > - + + + + {runtimeStatus.message} + +
- {fields.map((field) => { + {fields.map((field, index) => { const active = field.key === activeFieldKey; return (
- {formatTextFieldPreview(field.value) || "Text"} + {formatTextFieldPreview(field.value) || `Text ${index + 1}`} {field.tagName}