Skip to content
Merged
15 changes: 10 additions & 5 deletions packages/studio/src/components/StudioLeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ export function StudioLeftSidebar({
onAddAssetToTimeline={onAddAssetToTimeline}
onAddCompositionToTimeline={onAddCompositionToTimeline}
/>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
{/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
the absolutely-positioned inner hit area. The outer element is w-[3px] so
it contributes only 3px of gap in the flex row; the inner -left-[2.5px]
element widens the hit area to 8px without affecting layout. */}
it contributes only 3px of gap in the flex row; the inner -left-[2px]
element widens the hit area without affecting layout. */}
<div
role="separator"
aria-label="Resize sidebar"
Expand All @@ -176,8 +176,13 @@ export function StudioLeftSidebar({
adjustPanelWidth("left", delta);
}}
>
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
{/* Expanded hit zone, deliberately asymmetric: 2px into the sidebar card,
the 3px seam, then 8px into the preview pane's p-2 stage gutter — the
only dead space adjacent to this seam. It stops at 13px rather than the
24px WCAG 2.2 (2.5.8) target because the next pixel on either side is
live: the sidebar's scrolling tab content on the left, the preview
stage on the right. Silently stealing their clicks is the worse bug. */}
<div className="absolute inset-y-0 -left-[2px] w-[13px]" />
{/* Visible hairline */}
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
Expand Down
9 changes: 5 additions & 4 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,7 @@ export function StudioRightPanel({

return (
<>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
the absolutely-positioned inner hit area. */}
{/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
<div
role="separator"
aria-label="Resize inspector panel"
Expand All @@ -467,8 +466,10 @@ export function StudioRightPanel({
adjustPanelWidth("right", delta);
}}
>
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
{/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
2.5.8 target because the next pixel each way is live. */}
<div className="absolute inset-y-0 -left-[8px] w-[13px]" />
{/* Visible hairline */}
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
setZoomMode("manual");
setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
}}
className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
// track stays 2px and the thumb 10px, only the pointer box grows.
className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
/>
<Tooltip label="Zoom in">
<button
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/src/components/editor/KeyframeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
};

return (
// The two 12x20 steppers sit gap-0.5 apart with a 9px diamond between them,
// so they stay below the 24px WCAG 2.2 (2.5.8) minimum under that criterion's
// own spacing/inline exception: centred 24px targets here would overlap each
// other AND the diamond, and one control swallowing its neighbour's clicks is
// a worse 2.5.8 failure than a small target. Do not "fix" these to 24.
<div className="flex h-5 items-center gap-0.5">
<button
type="button"
Expand Down
42 changes: 42 additions & 0 deletions packages/studio/src/components/editor/keyframeRetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,45 @@ describe("resolveKeyframeRetime — guards", () => {
expect(r.pctRemap).toEqual([]);
});
});

describe("resolveKeyframeRetime — move percentages are rounded like the resize path", () => {
// The move branch used to return the raw quotient, so `74.81203007518799%`
// landed in the user's source and churned the diff on every drag.
const decimals = (n: number): number => String(n).split(".")[1]?.length ?? 0;

it("rounds a repeating quotient to 3dp", () => {
const r = resolveKeyframeRetime({
tweenStart: 2,
tweenDuration: 3,
keyframes: KEYFRAMES,
draggedTweenPct: 0,
dropAbsTime: 3, // (3-2)/3 = 33.333333333333336%
});
expect(r.kind).toBe("move");
expect(r.toTweenPct).toBe(33.333);
});

it("never emits more than 3 decimal places", () => {
for (const tweenDuration of [3, 7, 9, 11, 133]) {
const r = resolveKeyframeRetime({
tweenStart: 2,
tweenDuration,
keyframes: KEYFRAMES,
draggedTweenPct: 0,
dropAbsTime: 3,
});
expect(r.kind).toBe("move");
expect(decimals(r.toTweenPct ?? 0)).toBeLessThanOrEqual(3);
}
});

it("leaves an already-short percentage untouched", () => {
const r = resolveKeyframeRetime({
...WINDOW,
keyframes: KEYFRAMES,
draggedTweenPct: 0,
dropAbsTime: 3, // (3-2)/4 = exactly 25%
});
expect(r.toTweenPct).toBe(25);
});
});
5 changes: 4 additions & 1 deletion packages/studio/src/components/editor/keyframeRetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ export function resolveKeyframeRetime(opts: {

// Within the tween window → plain move (re-key the tween-%).
if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
// Round here, not at the return: the no-op test below and the value written
// to source must be the same number. The resize branch already rounds, so
// this keeps both write paths at the authored 3dp precision.
const toTweenPct = round3(clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100));
if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
return { kind: "move", toTweenPct };
}
Expand Down
193 changes: 181 additions & 12 deletions packages/studio/src/components/editor/propertyPanelColor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,85 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
import { ColorField } from "./propertyPanelColor";

const trackStudioEvent = vi.hoisted(() => vi.fn());

vi.mock("../../utils/studioTelemetry", () => ({
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
}));

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const roots: Root[] = [];

beforeEach(() => {
trackStudioEvent.mockReset();
__resetDesignInputThrottle();
});

afterEach(() => {
for (const root of roots) act(() => root.unmount());
roots.length = 0;
document.body.innerHTML = "";
});

function renderColorField(onCommit: (value: string) => void): void {
function renderColorField({
value = "#333333",
onPreview,
onCommit = vi.fn(),
}: {
value?: string;
onPreview?: (value: string) => void;
onCommit?: (value: string) => void;
} = {}): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
roots.push(root);
act(() => {
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
root.render(
<ColorField flat label="Color" value={value} onPreview={onPreview} onCommit={onCommit} />,
);
});
return host;
}

function changeInput(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!setter) throw new Error("expected native input value setter");
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}

function openHexInput(host: HTMLElement): HTMLInputElement {
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
if (!trigger) throw new Error("Color trigger was not rendered");
act(() => trigger.click());
const input = document.querySelector<HTMLInputElement>('input[spellcheck="false"]');
if (!input) throw new Error("Hex input was not rendered");
return input;
}

function clickOutside(): void {
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
}

describe("ColorField flat trigger", () => {
it("renders label and value inline with a small swatch, no boxed border", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={vi.fn()} />);
});
const host = renderColorField({ value: "rgb(255, 176, 32)" });
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
expect(trigger).not.toBeNull();
expect(trigger?.className).not.toContain("border-neutral-800");
expect(host.textContent).toContain("Color");
act(() => root.unmount());
});

it("persists one keyboard slider gesture on keyup", () => {
const onCommit = vi.fn();
renderColorField(onCommit);
renderColorField({ value: "rgb(255, 176, 32)", onCommit });
const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
if (!trigger) throw new Error("Color trigger was not rendered");
act(() => {
Expand All @@ -57,3 +99,130 @@ describe("ColorField flat trigger", () => {
expect(onCommit).toHaveBeenCalledOnce();
});
});

describe("ColorField hex editing", () => {
it("allows #333333 to be backspaced to #3 without snapping", () => {
const input = openHexInput(renderColorField());

for (const value of ["#33333", "#3333", "#333", "#33", "#3"]) {
act(() => changeInput(input, value));
expect(input.value).toBe(value);
}
});

it("does not silently change #22CC66 to #2222CC while backspacing", () => {
const input = openHexInput(renderColorField({ value: "#22CC66" }));

for (const value of ["#22CC6", "#22CC", "#22C"]) {
act(() => changeInput(input, value));
expect(input.value).toBe(value);
expect(input.value).not.toBe("#2222CC");
}
});

it("commits a full replacement after selecting the existing value", () => {
const onCommit = vi.fn();
const input = openHexInput(renderColorField({ onCommit }));
input.focus();
input.select();

act(() => changeInput(input, "#12AB34"));
act(() => input.blur());

expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
});

it("commits a complete pending hex on outside-click", () => {
const onCommit = vi.fn();
const input = openHexInput(renderColorField({ onCommit }));

act(() => changeInput(input, "#12AB34"));
act(clickOutside);

expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
});

it("commits a 3-digit hex shorthand on outside-click", () => {
// parseCssColor accepts shorthand, so the gesture resolver has to as well;
// #F00 is ordinary designer input and used to be dropped in silence.
const onCommit = vi.fn();
const input = openHexInput(renderColorField({ onCommit }));

act(() => changeInput(input, "#F00"));
act(clickOutside);

expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith("rgb(255, 0, 0)");
});

it("does not commit an incomplete pending hex on outside-click", () => {
const onCommit = vi.fn();
const host = renderColorField({ value: "#224466", onCommit });
const input = openHexInput(host);

act(() => changeInput(input, "#12AB3"));
act(clickOutside);

expect(onCommit).not.toHaveBeenCalled();
// The settle also has to put the field back, or the panel re-opens showing
// a value the composition never took.
expect(openHexInput(host).value).toBe("#224466");
});

it("cancels a pending hex edit on Escape and restores the previous value", () => {
const onPreview = vi.fn();
const onCommit = vi.fn();
const host = renderColorField({ value: "#224466", onPreview, onCommit });
const input = openHexInput(host);

act(() => changeInput(input, "#12AB34"));
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));

expect(onCommit).not.toHaveBeenCalled();
expect(onPreview).toHaveBeenLastCalledWith("rgb(34, 68, 102)");
expect(openHexInput(host).value).toBe("#224466");
});

it("still commits a complete hex on Tab-blur", () => {
const onCommit = vi.fn();
const input = openHexInput(renderColorField({ onCommit }));
input.focus();

act(() => changeInput(input, "#12AB34"));
act(() => input.blur());

expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
});

it("live-previews only a hex length that parses, 3 or 6 digits", () => {
const onPreview = vi.fn();
const input = openHexInput(renderColorField({ value: "#112233", onPreview }));

// 4 and 5 digits are mid-typing, so they must stay silent.
act(() => changeInput(input, "#3333"));
act(() => changeInput(input, "#33333"));
expect(onPreview).not.toHaveBeenCalled();

act(() => changeInput(input, "#333333"));
expect(onPreview).toHaveBeenCalledOnce();
expect(onPreview).toHaveBeenCalledWith("rgb(51, 51, 51)");

act(() => changeInput(input, "#333"));
expect(onPreview).toHaveBeenCalledTimes(2);
expect(onPreview).toHaveBeenLastCalledWith("rgb(51, 51, 51)");
});

it("tracks exactly once per completed edit, not once per keystroke", () => {
const input = openHexInput(renderColorField());

for (const value of ["#", "#1", "#12", "#12A", "#12AB", "#12AB3", "#12AB34"]) {
act(() => changeInput(input, value));
}
act(clickOutside);

expect(trackStudioEvent).toHaveBeenCalledOnce();
});
});
Loading
Loading