diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx
index bf128ca0a..9899dcd9d 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx
@@ -238,6 +238,10 @@ describe("FlatColorGradingSection — Preset + LUT", () => {
);
if (!presetSelect) throw new Error("expected a preset select");
expect(presetSelect.value).toBe("neutral");
+ // The visible "Preset" label is a sibling span outside FlatSelectRow
+ // (label="" there, to avoid rendering it twice) — the select still
+ // needs its own accessible name via the dedicated ariaLabel prop.
+ expect(presetSelect.getAttribute("aria-label")).toBe("Preset");
act(() => {
presetSelect.value = "fresh-pop";
presetSelect.dispatchEvent(new Event("change", { bubbles: true }));
diff --git a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx
index 3018bcf1f..80c654759 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatColorGradingSection.tsx
@@ -312,6 +312,7 @@ export function FlatColorGradingSection({
Preset {
expect(track.getAttribute("aria-valuenow")).toBe("99");
act(() => root.unmount());
});
+
+ it("resyncs immediately from the latest value on lostpointercapture, even when the value changed WHILE still dragging", () => {
+ 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: 100, top: 0, height: 20, right: 100, bottom: 20 }),
+ });
+ act(() => {
+ track.dispatchEvent(
+ new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
+ );
+ });
+ expect(track.getAttribute("aria-valuenow")).toBe("30");
+ // Value changes to 99 WHILE still dragging — the [value] sync effect
+ // must skip it (draggingRef is still true), so draft stays at 30.
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ expect(track.getAttribute("aria-valuenow")).toBe("30");
+ act(() => {
+ // Capture lost with NO further render afterward — if the resync
+ // depended on a subsequent [value] effect run rather than reading
+ // latestValueRef directly, this would leave the knob stuck at 30.
+ track.dispatchEvent(new Event("lostpointercapture", { bubbles: true }));
+ });
+ expect(track.getAttribute("aria-valuenow")).toBe("99");
+ act(() => root.unmount());
+ });
});
describe("FlatSelectRow", () => {
@@ -967,44 +1015,3 @@ describe("FlatSelectRow — label/value options", () => {
act(() => root.unmount());
});
});
-
-describe("FlatToggle", () => {
- it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
- const onChange = vi.fn();
- const { host, root } = renderInto(
- ,
- );
- const label = host.querySelector('[data-flat-toggle-label="true"]');
- expect(label?.className).toContain("text-panel-text-3");
- const pill = host.querySelector('[data-flat-toggle="true"]');
- expect(pill).not.toBeNull();
- act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
- expect(onChange).toHaveBeenCalledWith(true);
- act(() => root.unmount());
- });
-
- it("renders the on state with an emphasized label and mint knob, and fires onChange(false) on click", () => {
- const onChange = vi.fn();
- const { host, root } = renderInto();
- const label = host.querySelector('[data-flat-toggle-label="true"]');
- expect(label?.className).toContain("text-panel-text-2");
- const knob = host.querySelector('[data-flat-toggle-knob="true"]');
- expect(knob?.className).toContain("bg-panel-accent");
- const pill = host.querySelector('[data-flat-toggle="true"]');
- act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
- expect(onChange).toHaveBeenCalledWith(false);
- act(() => root.unmount());
- });
-
- it("does not fire onChange when disabled", () => {
- const onChange = vi.fn();
- const { host, root } = renderInto(
- ,
- );
- const pill = host.querySelector('[data-flat-toggle="true"]');
- expect(pill?.disabled).toBe(true);
- act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
- expect(onChange).not.toHaveBeenCalled();
- act(() => root.unmount());
- });
-});
diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx
index 7e5afca27..3b463002d 100644
--- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx
+++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx
@@ -300,6 +300,11 @@ export function FlatSlider({
// different control in between.
const onCommitRef = useRef(onCommit);
onCommitRef.current = onCommit;
+ // Always this render's committed value — read directly (not via the
+ // effect below) by onLostPointerCapture, so the resync there doesn't
+ // depend on ordering between the native event and the [value] effect.
+ const latestValueRef = useRef(value);
+ latestValueRef.current = value;
useEffect(() => {
if (draggingRef.current) return;
@@ -407,10 +412,16 @@ export function FlatSlider({
onLostPointerCapture={() => {
// Capture can be lost without either pointerup or pointercancel
// firing first (e.g. another element steals it, or the browser
- // reclaims it for a scroll/touch gesture) — without this,
- // draggingRef stays stuck true and the knob permanently stops
- // syncing to the committed value prop.
+ // reclaims it for a scroll/touch gesture). Resync immediately and
+ // directly from latestValueRef, rather than only clearing
+ // draggingRef and waiting for the [value] effect to notice —
+ // that effect depends on `value` actually changing again to
+ // re-run, so if this event and any concurrent value update are
+ // ordered unfavorably, the knob could otherwise stay stuck at
+ // its mid-drag position indefinitely.
draggingRef.current = false;
+ setDraft(latestValueRef.current);
+ lastCommittedRef.current = latestValueRef.current;
}}
onKeyDown={(e) => {
if (disabled) return;
@@ -478,6 +489,7 @@ export function FlatSlider({
export function FlatSelectRow({
label,
+ ariaLabel,
value,
options,
tier,
@@ -486,6 +498,11 @@ export function FlatSelectRow({
onReset,
}: {
label: string;
+ /** Accessible name when a caller renders the visible label OUTSIDE this
+ * row (label="" to avoid a duplicate) — e.g. Grade's "Preset" row, which
+ * shows its own label span and would otherwise leave the