Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
3d92436
fix(studio): author every new tween against one element
miguel-heygen Jul 28, 2026
1f3fd28
fix(studio): give the motion path and the fallbacks a one-element target
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
5 changes: 4 additions & 1 deletion packages/studio/src/components/editor/MotionPathOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
const createSelector = createMode ? selectorFor(selection) : null;
const compW = compositionSize?.width ?? null;
const canCreate = createMode && hasMotionPathPlugin(iframeRef.current);
// No one-element selector means the path could only be authored onto the
// element's class siblings, so the toolbar toggle stays hidden instead of
// arming a press that the effect below would silently drop.
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);

// Publish whether the selected element can take a path so the preview toolbar
// shows its "set destination" toggle. Drops to false when this overlay unmounts
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
65 changes: 65 additions & 0 deletions packages/studio/src/components/editor/motionPathSelection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
import { selectorFor } from "./motionPathSelection";

afterEach(() => {
document.body.innerHTML = "";
});

function selectionFor(el: HTMLElement): DomEditSelection {
const selector = buildStableSelector(el);
return {
element: el,
id: el.id || undefined,
hfId: el.getAttribute("data-hf-id") || undefined,
selector,
selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
sourceFile: "index.html",
dataAttributes: { start: "0", duration: "2" },
} as unknown as DomEditSelection;
}

function mountGroupSiblings(): HTMLElement[] {
document.body.innerHTML = `
<div id="scene" class="clip" data-start="0" data-duration="2">
<div class="group"></div>
<div class="group"></div>
<div class="group"></div>
</div>
`;
return Array.from(document.querySelectorAll<HTMLElement>(".group"));
}

describe("selectorFor", () => {
it("addresses one element for a class-only sibling", () => {
const groups = mountGroupSiblings();
const selector = selectorFor(selectionFor(groups[2]!));

// The bare ".group" both measured home off the FIRST sibling and wrote the
// new motion path onto all three.
expect(selector).not.toBe(".group");
expect(document.querySelectorAll(selector!)).toHaveLength(1);
expect(document.querySelector(selector!)).toBe(groups[2]);
});

it("keeps a unique id target", () => {
document.body.innerHTML = `<div id="hero"></div>`;
const el = document.querySelector<HTMLElement>("#hero")!;

expect(selectorFor(selectionFor(el))).toBe("#hero");
});

it("returns null with no selection", () => {
expect(selectorFor(null)).toBeNull();
});

it("returns null when no rung addresses one element", () => {
const groups = mountGroupSiblings();
const selection = selectionFor(groups[1]!);
groups[1]!.remove();

expect(selectorFor(selection)).toBeNull();
});
});
16 changes: 13 additions & 3 deletions packages/studio/src/components/editor/motionPathSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@
*/
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import type { DomEditSelection } from "./domEditing";
import { writeTargetSelector } from "../../hooks/gsapShared";

/**
* The selector the overlay both MEASURES the element by and authors a new
* motion path against.
*
* Both halves need exactly one element. The selection's own selector is a bare
* class for an id-less element, so a `.group` sibling read its home position off
* the FIRST sibling (skewing the destination the click computes) and then wrote
* `add-motion-path` onto all five. `writeTargetSelector` is the same one-element
* narrowing every other new-tween writer goes through; null means no such form
* exists, and the overlay hides "set destination" rather than write a wrong one.
*/
export function selectorFor(sel: DomEditSelection | null): string | null {
if (!sel) return null;
if (sel.id) return `#${CSS.escape(sel.id)}`;
return sel.selector ?? null;
return sel ? writeTargetSelector(sel) : null;
}

/** The animation whose path is editable on-canvas: literal, statically resolved,
Expand Down
Loading
Loading