Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6902069
fix(studio): seek ruler clicks to the pressed position and round reti…
miguel-heygen Jul 28, 2026
4e74eef
fix(studio): meet the 24x24 pointer target minimum on toolbar and lan…
miguel-heygen Jul 28, 2026
bb24d00
style(studio): keep StudioRightPanel under the 600 line cap
miguel-heygen Jul 28, 2026
b825063
fix(studio): grow the ease target only where the segment has room
miguel-heygen Jul 28, 2026
5561403
fix(studio): let the colour hex field be edited and commit it on outs…
miguel-heygen Jul 28, 2026
aa28116
fix(studio): dismiss the shortcuts panel on escape and outside press
miguel-heygen Jul 28, 2026
7f0cadc
fix(studio): drop aria-modal from the non-modal shortcuts popup
miguel-heygen Jul 28, 2026
86f633f
fix(studio): accept 3-digit hex shorthand in the colour field
miguel-heygen Jul 28, 2026
3f0c20f
fix(studio): compute keyframe percentages in the tween's own time frame
miguel-heygen Jul 28, 2026
1254698
fix(studio): expand sub-comp children against their resolved parent host
miguel-heygen Jul 28, 2026
d48440a
fix(studio): keep the host row when drilling into a sub-composition
miguel-heygen Jul 28, 2026
acad7b2
fix(studio): keep every host row on the drill path, not just the top
miguel-heygen Jul 28, 2026
59a818e
fix(studio): lane every tween and attribute tweens to their real target
miguel-heygen Jul 28, 2026
be3451a
fix(studio): resolve a panel edit through the lane groups it can see
miguel-heygen Jul 28, 2026
fd5555b
fix(studio): target one element when adding a keyframe at the playhead
miguel-heygen Jul 28, 2026
f04cdb7
fix(studio): never re-author a target the DOM proved is not unique
miguel-heygen Jul 28, 2026
1faa0cb
fix(studio): announce real track numbers and point aria-controls at t…
miguel-heygen Jul 28, 2026
477916c
fix(studio): label undo history with the track display row
miguel-heygen Jul 28, 2026
ba0d640
fix(studio): scope lane ids per timeline and stop inventing a track row
miguel-heygen Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
10 changes: 3 additions & 7 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes";
import { resolveEditingSections } from "@hyperframes/core/editing";
import { MediaSection } from "./propertyPanelMediaSection";
import { ColorGradingSection } from "./propertyPanelColorGradingSection";
Expand Down Expand Up @@ -241,12 +241,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);

const animIdForProp = (prop: string): string => {
const group = classifyPropertyGroup(prop);
const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group);
if (groupAnim) return groupAnim.id;
return gsapAnimId ?? "";
};
const animIdForProp = (prop: string): string =>
resolveAnimIdForProperty(prop, gsapAnimations, gsapAnimId);

const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
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