From 5dd9efe5558667c8d230c9ed37bc3013c544e7fa Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 14 Jul 2026 01:06:52 -0700 Subject: [PATCH 1/8] fix(studio): resolve 8 confirmed adversarial-review findings across the flat-inspector stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes issues raised in the Deepwork re-review of #2120-#2190 that weren't covered by #2225's earlier fix pass: - useColorGradingController: reset grading/compare/mediaMetadata state (and cancel pending persist/status timers) when selection changes to a different element — this hook is called unconditionally on every render (unlike legacy ColorGradingSection, remounted via a selectionIdentityKey React key), so switching selection reused the previous element's state. - useColorGradingController: stop permanently caching a non-OK /media/metadata response as null — a transient server error poisoned the HDR banner for that asset for the whole page lifetime. - FlatSelectRow: preserve a valid authored value outside the preset list (e.g. mix-blend-mode: difference, an arbitrary object-position) instead of silently misrepresenting it as the first preset — touching the control would overwrite real persisted state. - FlatSlider: the throttled trailing commit now reads onCommit through a ref updated every render instead of closing over it at schedule time — a caller whose onCommit spreads other current state (Grade's per-detail commits) could otherwise have a delayed commit revert whatever the user changed on a different control in the same 40ms window. - FlatSlider: flush a still-queued trailing commit on unmount instead of dropping it, and disable the reset button when the slider itself is disabled. - FlatSlider: add touch-action: none to the track so touch drags don't compete with page scroll. - FlatColorGradingAccessory: clean up the compare-hold's window listeners on unmount, not only on release — switching selection mid-hold used to leak them. - Align (flat Text): re-clicking the option already visually active for a logical start/end value no longer rewrites it to the physical left/right, preserving RTL semantics. - FlatSegmentedRow: give every option an accessible name and aria-pressed state — two visually-identical glyph buttons (upright/italic "A") had no way to be told apart by assistive tech. - PropertyPanelFlat: the panel body falls back to its own scroll when the collapsed group headers alone exceed the available height, so groups can't become permanently unreachable in a short pane. New regression tests for all of the above; full studio suite at the known pre-existing baseline (55 failures unrelated to this stack). --- .../components/editor/PropertyPanelFlat.tsx | 2 +- .../propertyPanelFlatColorGradingSection.tsx | 16 +- .../editor/propertyPanelFlatLayoutSection.tsx | 4 +- .../editor/propertyPanelFlatMaskInsetRows.tsx | 102 +++++++++++ .../propertyPanelFlatPrimitives.test.tsx | 172 +++++++++++++++++- .../editor/propertyPanelFlatPrimitives.tsx | 42 ++++- .../editor/propertyPanelFlatStyleSections.tsx | 102 +---------- .../propertyPanelFlatTextSection.test.tsx | 28 ++- .../editor/propertyPanelFlatTextSection.tsx | 26 ++- .../editor/useColorGradingController.test.ts | 135 ++++++++++++-- .../editor/useColorGradingController.ts | 63 ++++++- 11 files changed, 550 insertions(+), 142 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 90d7ed8698..fe914267d9 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -516,7 +516,7 @@ export function PropertyPanelFlat({ onUngroup={onUngroup} showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)} /> -
+
{beforeOpen.map((g) => ( void) | null>(null); + useEffect( + () => () => { + releaseRef.current?.(); + releaseRef.current = null; + }, + [], + ); return ( @@ -50,7 +62,9 @@ export function FlatColorGradingAccessory({ window.removeEventListener("pointerup", release); window.removeEventListener("pointercancel", release); window.removeEventListener("blur", release); + releaseRef.current = null; }; + releaseRef.current = release; window.addEventListener("pointerup", release); window.addEventListener("pointercancel", release); window.addEventListener("blur", release); diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index 0c7b0a2e4d..60988350e4 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -203,8 +203,8 @@ export function LayoutFlexBlock({ void onSetStyle("flex-direction", next)} diff --git a/packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx b/packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx new file mode 100644 index 0000000000..1dfb77cb71 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatMaskInsetRows.tsx @@ -0,0 +1,102 @@ +import { + buildInsetClipPathSides, + buildInsetClipPathValue, + formatNumericValue, + formatPxMetricValue, + getClipPathInsetPx, + inferClipPathPreset, + parseInsetClipPathSides, + parsePxMetricValue, + type ClipPathInsetSides, +} from "./propertyPanelHelpers"; +import { FlatSlider } from "./propertyPanelFlatPrimitives"; +import { MetricField } from "./propertyPanelPrimitives"; + +/* ------------------------------------------------------------------ */ +/* Flat Mask inset — uniform slider + per-side fields */ +/* (split out of propertyPanelFlatStyleSections.tsx to stay under the */ +/* 600-line file-size gate) */ +/* ------------------------------------------------------------------ */ + +export 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 ?? { + top: clipInsetValue, + right: clipInsetValue, + bottom: clipInsetValue, + left: clipInsetValue, + radius: radiusValue, + }; + const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null; + + const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => { + const next = parsePxMetricValue(nextValue); + if (next == null) return; + const sides: ClipPathInsetSides = { + top: clipInsetSides.top, + right: clipInsetSides.right, + bottom: clipInsetSides.bottom, + left: clipInsetSides.left, + }; + sides[side] = next; + void onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius)); + }; + + return ( + <> + 0 ? "explicitCustom" : "default"} + displayValue={`${formatNumericValue(clipInsetValue)}px`} + disabled={disabled} + onCommit={(next) => + void onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue)) + } + /> + {showClipInsetSides && ( +
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+ )} + + ); +} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 6fb1dad09d..09b1e1038d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -89,8 +89,8 @@ describe("FlatSegmentedRow", () => { , @@ -105,6 +105,25 @@ describe("FlatSegmentedRow", () => { expect(onChange).toHaveBeenCalledWith("left"); act(() => root.unmount()); }); + + it("gives each option an accessible name and pressed state — glyphs alone (e.g. two 'A' buttons) aren't a valid accessible name", () => { + const { host, root } = renderInto( + , + ); + const options = host.querySelectorAll('[data-flat-segment="true"]'); + expect(options[0]?.getAttribute("aria-label")).toBe("upright"); + expect(options[0]?.getAttribute("aria-pressed")).toBe("true"); + expect(options[1]?.getAttribute("aria-label")).toBe("italic"); + expect(options[1]?.getAttribute("aria-pressed")).toBe("false"); + act(() => root.unmount()); + }); }); describe("FlatGroupHeader", () => { @@ -554,6 +573,130 @@ describe("FlatSlider — Grade extensions", () => { act(() => root.unmount()); }); + it("disables the reset button when the slider itself is disabled", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const resetButton = host.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton?.disabled).toBe(true); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("a trailing throttled commit uses the current render's onCommit, not the one captured when it was scheduled", () => { + vi.useFakeTimers(); + const onCommitA = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + // Leading-edge commit fires synchronously with onCommitA (clientX 150 + // on a -100..100 track maps to 50, distinct from the initial value 0). + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 150, pointerId: 1 }), + ); + }); + expect(onCommitA).toHaveBeenCalledTimes(1); + act(() => { + // Within the 40ms throttle window — queues a trailing commit (to 80, + // distinct from the just-committed 50) instead of firing immediately. + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 180, pointerId: 1 }), + ); + }); + expect(onCommitA).toHaveBeenCalledTimes(1); + // Simulate the real-world race: something else causes this slider to + // re-render with a NEW onCommit closure before the queued timer fires + // (e.g. Grade's per-detail onCommit spreads the render-time whole + // grading object, so a different control committing in between produces + // a fresh closure). The stale closure must not win. + const onCommitB = vi.fn(); + act(() => { + root.render( + , + ); + }); + act(() => { + vi.advanceTimersByTime(45); + }); + expect(onCommitB).toHaveBeenCalledTimes(1); + expect(onCommitB).toHaveBeenCalledWith(80); + expect(onCommitA).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + vi.useRealTimers(); + }); + + it("flushes a still-queued trailing commit on unmount instead of dropping it", () => { + vi.useFakeTimers(); + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenCalledTimes(1); + act(() => { + // Queues a trailing commit that never gets to fire before unmount. + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + expect(onCommit).toHaveBeenCalledTimes(2); + expect(onCommit).toHaveBeenNthCalledWith(2, 80); + vi.useRealTimers(); + }); + it("supports keyboard operation: focusable, arrow keys step, Home/End clamp to range", () => { const onCommit = vi.fn(); const { host, root } = renderInto( @@ -728,6 +871,31 @@ describe("FlatSelectRow — label/value options", () => { expect(options).toEqual(["normal", "multiply", "screen"]); act(() => root.unmount()); }); + + it("preserves a valid authored value outside the preset list instead of misrepresenting it as the first option", () => { + const onChange = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + // A native falls + // back to selectedIndex 0 when `value` matches no