@hyperframes/studio 0.7.78 → 0.7.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -5,26 +5,21 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid
5
5
  import {
6
6
  clearKeyframeCacheForElement,
7
7
  pruneKeyframeCacheToFiles,
8
+ publishKeyframeCache,
8
9
  writeGsapAnimationsForElement,
9
10
  } from "./gsapKeyframeCacheHelpers";
10
- import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
11
+ import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
11
12
  import {
12
13
  deduplicateKeyframes,
13
14
  isStaticPositionHold,
14
15
  synthesizeFlatTweenKeyframes,
16
+ type MergeableKeyframe,
15
17
  } from "./gsapTweenSynth";
16
- import {
17
- fetchParsedAnimations,
18
- populateKeyframeCacheFromAst,
19
- resolveClipTimingBasis,
20
- } from "./keyframeCacheAstLoad";
18
+ import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
21
19
 
22
20
  // Re-exported so callers keep importing the GSAP cache surface from one module.
23
- export {
24
- fetchParsedAnimations,
25
- resolveClipTimingBasis,
26
- resolveSelectorElementIds,
27
- } from "./keyframeCacheAstLoad";
21
+ export { resolveClipTimingBasis } from "./gsapShared";
22
+ export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
28
23
 
29
24
  /** The selected element's identity for matching tweens to it. */
30
25
  export interface GsapElementTarget {
@@ -273,13 +268,7 @@ export function useGsapAnimationsForElement(
273
268
  domClipChildren,
274
269
  );
275
270
 
276
- const allKeyframes: Array<
277
- GsapKeyframesData["keyframes"][0] & {
278
- tweenPercentage?: number;
279
- propertyGroup?: string;
280
- animationId?: string;
281
- }
282
- > = [];
271
+ const allKeyframes: MergeableKeyframe[] = [];
283
272
  let format: GsapKeyframesData["format"] = "percentage";
284
273
  let ease: string | undefined;
285
274
  let easeEach: string | undefined;
@@ -325,11 +314,15 @@ export function useGsapAnimationsForElement(
325
314
  ...(ease ? { ease } : {}),
326
315
  ...(easeEach ? { easeEach } : {}),
327
316
  };
328
- const { setKeyframeCache } = usePlayerStore.getState();
329
- setKeyframeCache(`${sourceFile}#${elementId}`, merged);
330
- // PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
331
- // so write a duplicate entry under the bare key for cross-component lookups.
332
- setKeyframeCache(elementId, merged);
317
+ // PropertyPanel reads the cache by bare elementId (without sourceFile
318
+ // prefix), so the same entry is written under the bare key for
319
+ // cross-component lookups. Both keys land in one publish: a reader that woke
320
+ // between two separate writes saw the prefixed key updated and the bare one
321
+ // still stale.
322
+ publishKeyframeCache((draft) => {
323
+ draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
324
+ draft.keyframeCache.set(elementId, merged);
325
+ });
333
326
  // eslint-disable-next-line react-hooks/exhaustive-deps
334
327
  }, [elementId, sourceFile, animations, domClipChildrenKey]);
335
328
 
@@ -428,29 +421,35 @@ export function usePopulateKeyframeCacheForFile(
428
421
  }
429
422
  const scanned = scanAllRuntimeKeyframes(iframe, clipById);
430
423
  if (scanned.size === 0) return false;
431
- const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
432
- for (const [id, data] of scanned) {
433
- const cacheKey = `${sf}#${id}`;
434
- const fallbackKey = `index.html#${id}`;
435
- const alreadyCached =
436
- keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
437
- if (alreadyCached) continue;
438
- // Skip position-only set tweens from runtime too — same filter as AST path
439
- const isPosOnly =
440
- data.keyframes.length === 1 &&
441
- Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
442
- if (isPosOnly) {
443
- continue;
424
+ // One publish for the whole scan: a scan of a 120-clip composition used to
425
+ // emit up to three store notifications per element, and every subscriber
426
+ // in between re-rendered against a cache only partly filled in.
427
+ publishKeyframeCache((draft) => {
428
+ for (const [id, data] of scanned) {
429
+ const cacheKey = `${sf}#${id}`;
430
+ const fallbackKey = `index.html#${id}`;
431
+ const alreadyCached =
432
+ draft.keyframeCache.has(cacheKey) ||
433
+ draft.keyframeCache.has(fallbackKey) ||
434
+ draft.keyframeCache.has(id);
435
+ if (alreadyCached) continue;
436
+ // Skip position-only set tweens from runtime too, same filter as AST path
437
+ const isPosOnly =
438
+ data.keyframes.length === 1 &&
439
+ Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
440
+ if (isPosOnly) {
441
+ continue;
442
+ }
443
+ const entry = {
444
+ format: "percentage" as const,
445
+ keyframes: data.keyframes,
446
+ ...(data.easeEach ? { easeEach: data.easeEach } : {}),
447
+ };
448
+ draft.keyframeCache.set(cacheKey, entry);
449
+ if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
450
+ draft.keyframeCache.set(id, entry);
444
451
  }
445
- const entry = {
446
- format: "percentage" as const,
447
- keyframes: data.keyframes,
448
- ...(data.easeEach ? { easeEach: data.easeEach } : {}),
449
- };
450
- setKeyframeCache(cacheKey, entry);
451
- if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
452
- setKeyframeCache(id, entry);
453
- }
452
+ });
454
453
  runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
455
454
  return true;
456
455
  };
@@ -0,0 +1,78 @@
1
+ import { useCallback, type RefObject } from "react";
2
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
3
+ import type { AnimationKeyframeTarget } from "./gsapTweenSynth";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+
6
+ interface KeyframeEaseCommitsInput {
7
+ gsapCommitMutation: CommitMutation;
8
+ domEditSelectionRef: RefObject<DomEditSelection | null>;
9
+ }
10
+
11
+ export function useKeyframeEaseCommits({
12
+ gsapCommitMutation,
13
+ domEditSelectionRef,
14
+ }: KeyframeEaseCommitsInput) {
15
+ const handleUpdateSegmentEase = useCallback(
16
+ (targets: AnimationKeyframeTarget[], ease: string) => {
17
+ const selection = domEditSelectionRef.current;
18
+ if (!selection || targets.length === 0) return;
19
+ const options = {
20
+ label: targets.length === 1 ? "Update keyframe ease" : "Update segment ease",
21
+ softReload: true,
22
+ };
23
+ const calls = targets.map(({ animationId, tweenPercentage }) => ({
24
+ selection,
25
+ mutation: {
26
+ type: "update-keyframe",
27
+ animationId,
28
+ percentage: tweenPercentage,
29
+ properties: {},
30
+ ease,
31
+ },
32
+ options,
33
+ }));
34
+ if (calls.length === 1) {
35
+ const call = calls[0];
36
+ if (call) void gsapCommitMutation(call.selection, call.mutation, call.options);
37
+ return;
38
+ }
39
+ const batch = gsapCommitMutation.batch;
40
+ if (batch) {
41
+ void batch(calls, options);
42
+ return;
43
+ }
44
+ // A wrapped commit (a gesture transaction, say) carries no batch
45
+ // transport. Serial keeps the edit correct at the cost of one round trip
46
+ // per tween and one undo entry per tween; an optional-chained call here
47
+ // would silently drop the whole edit instead.
48
+ for (const call of calls)
49
+ void gsapCommitMutation(call.selection, call.mutation, call.options);
50
+ },
51
+ [gsapCommitMutation, domEditSelectionRef],
52
+ );
53
+
54
+ const handleUpdateKeyframeEase = useCallback(
55
+ (animationId: string, percentage: number, ease: string) =>
56
+ handleUpdateSegmentEase([{ animationId, tweenPercentage: percentage }], ease),
57
+ [handleUpdateSegmentEase],
58
+ );
59
+
60
+ const handleSetAllKeyframeEases = useCallback(
61
+ (animationId: string, ease: string) => {
62
+ const sel = domEditSelectionRef.current;
63
+ if (!sel) return;
64
+ gsapCommitMutation(
65
+ sel,
66
+ {
67
+ type: "update-meta",
68
+ animationId,
69
+ updates: { easeEach: ease, resetKeyframeEases: true },
70
+ },
71
+ { label: "Apply ease to all segments", softReload: true },
72
+ );
73
+ },
74
+ [gsapCommitMutation, domEditSelectionRef],
75
+ );
76
+
77
+ return { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases };
78
+ }
@@ -66,4 +66,25 @@ describe("KeyframeDiamondContextMenu", () => {
66
66
  expect(onDelete).toHaveBeenCalledWith("box", target);
67
67
  expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target);
68
68
  });
69
+
70
+ // An arc waypoint on a two-anchor path cannot be dropped on its own, so the
71
+ // caller withholds onDelete rather than offering an entry that does nothing.
72
+ it("hides the single-node delete when no handler is given", () => {
73
+ const host = document.createElement("div");
74
+ document.body.appendChild(host);
75
+ const root = createRoot(host);
76
+ act(() =>
77
+ root.render(
78
+ <KeyframeDiamondContextMenu state={state} onClose={() => {}} onDeleteAll={vi.fn()} />,
79
+ ),
80
+ );
81
+
82
+ const labels = Array.from(document.body.querySelectorAll("button")).map(
83
+ (button) => button.textContent,
84
+ );
85
+ expect(labels).toEqual(["Delete All Keyframes"]);
86
+
87
+ act(() => root.unmount());
88
+ host.remove();
89
+ });
69
90
  });
@@ -19,7 +19,10 @@ export interface KeyframeDiamondContextMenuState {
19
19
  interface KeyframeDiamondContextMenuProps {
20
20
  state: KeyframeDiamondContextMenuState;
21
21
  onClose: () => void;
22
- onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
22
+ /** Omitted where this node cannot be deleted on its own (see the arc-waypoint
23
+ * floor in removeMotionPathPointInScript): an entry that silently no-ops is
24
+ * worse than no entry. */
25
+ onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
23
26
  onDeleteAll: (element: TimelineElement) => void;
24
27
  /** Retime the keyframe to the current playhead, preserving its value + ease. */
25
28
  onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
@@ -43,7 +46,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
43
46
  };
44
47
 
45
48
  const menuWidth = 200;
46
- const menuHeight = onMoveToPlayhead ? 100 : 70;
49
+ // Measured off the rendered rows, so the flip-up test below stays right as
50
+ // optional entries drop out.
51
+ const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
47
52
  const overflowY = state.y + menuHeight - window.innerHeight;
48
53
  const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
49
54
  const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
@@ -71,16 +76,18 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
71
76
  )}
72
77
 
73
78
  {/* Delete */}
74
- <button
75
- type="button"
76
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
77
- onClick={() => {
78
- onDelete(state.elementId, keyframe);
79
- onClose();
80
- }}
81
- >
82
- Delete Keyframe
83
- </button>
79
+ {onDelete && (
80
+ <button
81
+ type="button"
82
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
83
+ onClick={() => {
84
+ onDelete(state.elementId, keyframe);
85
+ onClose();
86
+ }}
87
+ >
88
+ Delete Keyframe
89
+ </button>
90
+ )}
84
91
 
85
92
  <button
86
93
  type="button"
@@ -22,7 +22,10 @@ export function LayerDisclosureRow({
22
22
  /** Same adaptive width the lane rows use: a narrowed header column must not
23
23
  * leave this row hanging over the clips it labels. */
24
24
  columnWidth: number;
25
- /** Id of the element holding the lanes this row's caret expands. */
25
+ /** Id of the CANVAS-side element holding the diamond lanes this row's caret
26
+ * expands (see TimelinePropertyLanes). The caret also reveals the per-lane
27
+ * control rows in this column, but the diamonds are what following the
28
+ * reference should land on. */
26
29
  lanesId: string;
27
30
  onToggleClipExpanded: () => void;
28
31
  /** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
@@ -0,0 +1,158 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, Profiler } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { ShortcutsPanel } from "./ShortcutsPanel";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ const roots: Root[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const root of roots.splice(0)) act(() => root.unmount());
14
+ document.body.innerHTML = "";
15
+ });
16
+
17
+ function renderPanel(onRender = vi.fn()) {
18
+ const host = document.createElement("div");
19
+ document.body.append(host);
20
+ const root = createRoot(host);
21
+ roots.push(root);
22
+ act(() => {
23
+ root.render(
24
+ <Profiler id="shortcuts-panel" onRender={onRender}>
25
+ <ShortcutsPanel
26
+ disabled={false}
27
+ duration={10}
28
+ inPoint={null}
29
+ outPoint={null}
30
+ setInPoint={vi.fn()}
31
+ setOutPoint={vi.fn()}
32
+ onSeek={vi.fn()}
33
+ />
34
+ </Profiler>,
35
+ );
36
+ });
37
+
38
+ const trigger = host.querySelector<HTMLButtonElement>(
39
+ 'button[aria-label="Shortcuts and tools"]',
40
+ )!;
41
+ return { host, trigger, onRender };
42
+ }
43
+
44
+ function pressAndClick(target: HTMLElement): void {
45
+ target.dispatchEvent(
46
+ new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
47
+ );
48
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
49
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
50
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
51
+ }
52
+
53
+ function openPanel(trigger: HTMLButtonElement): void {
54
+ act(() => pressAndClick(trigger));
55
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
56
+ }
57
+
58
+ describe("ShortcutsPanel", () => {
59
+ it.each(["page", "button", "panel"] as const)(
60
+ "closes with Escape from the %s-focused position",
61
+ (focusPosition) => {
62
+ const { host, trigger } = renderPanel();
63
+ openPanel(trigger);
64
+
65
+ let eventTarget: Document | HTMLElement;
66
+ if (focusPosition === "page") {
67
+ document.body.tabIndex = -1;
68
+ document.body.focus();
69
+ eventTarget = document;
70
+ expect(document.activeElement).toBe(document.body);
71
+ } else if (focusPosition === "button") {
72
+ trigger.focus();
73
+ eventTarget = trigger;
74
+ expect(document.activeElement).toBe(trigger);
75
+ } else {
76
+ const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
77
+ panelInput.focus();
78
+ eventTarget = panelInput;
79
+ expect(document.activeElement).toBe(panelInput);
80
+ }
81
+
82
+ act(() => {
83
+ eventTarget.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
84
+ });
85
+
86
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
87
+ expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
88
+ },
89
+ );
90
+
91
+ it("closes on capture-phase pointerdown even when its default is prevented", () => {
92
+ const preventDefault = (event: Event) => event.preventDefault();
93
+ document.addEventListener("pointerdown", preventDefault, true);
94
+ const { host, trigger } = renderPanel();
95
+ openPanel(trigger);
96
+ const outside = document.createElement("div");
97
+ document.body.append(outside);
98
+ const pointerDown = new PointerEvent("pointerdown", {
99
+ bubbles: true,
100
+ cancelable: true,
101
+ button: 0,
102
+ });
103
+
104
+ act(() => outside.dispatchEvent(pointerDown));
105
+
106
+ document.removeEventListener("pointerdown", preventDefault, true);
107
+ expect(pointerDown.defaultPrevented).toBe(true);
108
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
109
+ expect(host.querySelector('[aria-label="Jump to frame"]')).toBeNull();
110
+ });
111
+
112
+ it("does not close on a click inside the panel", () => {
113
+ const { host, trigger } = renderPanel();
114
+ openPanel(trigger);
115
+ const panelInput = host.querySelector<HTMLInputElement>('[aria-label="Jump to frame"]')!;
116
+
117
+ act(() => pressAndClick(panelInput));
118
+
119
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
120
+ expect(host.querySelector('[aria-label="Jump to frame"]')).not.toBeNull();
121
+ });
122
+
123
+ it("toggles once per trigger click", () => {
124
+ const { trigger } = renderPanel();
125
+
126
+ act(() => pressAndClick(trigger));
127
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
128
+
129
+ act(() => pressAndClick(trigger));
130
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
131
+ });
132
+
133
+ it("does not re-render when Escape is pressed while closed", () => {
134
+ const { trigger, onRender } = renderPanel();
135
+ expect(onRender).toHaveBeenCalledTimes(1);
136
+
137
+ act(() => {
138
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
139
+ });
140
+
141
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
142
+ expect(onRender).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it("connects the trigger to the dialog with aria-controls", () => {
146
+ const { host, trigger } = renderPanel();
147
+ openPanel(trigger);
148
+ const panelId = trigger.getAttribute("aria-controls");
149
+ const panel = panelId ? host.querySelector<HTMLElement>(`#${CSS.escape(panelId)}`) : null;
150
+
151
+ expect(panelId).not.toBeNull();
152
+ expect(panel?.getAttribute("role")).toBe("dialog");
153
+ // Non-modal on purpose: focus is not trapped and the editor behind stays
154
+ // operable, so aria-modal would lie to assistive tech about inertness.
155
+ expect(panel?.getAttribute("aria-modal")).toBeNull();
156
+ expect(panel?.id).toBe(panelId);
157
+ });
158
+ });
@@ -1,6 +1,7 @@
1
- import { useState, useCallback, useRef, useEffect, memo } from "react";
1
+ import { useState, useCallback, useId, memo } from "react";
2
2
  import { formatTime, frameToSeconds } from "../lib/time";
3
3
  import { Tooltip } from "../../components/ui";
4
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
5
 
5
6
  const SHORTCUT_SECTIONS = [
6
7
  {
@@ -108,20 +109,9 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
108
109
  }: ShortcutsPanelProps) {
109
110
  const [showShortcuts, setShowShortcuts] = useState(false);
110
111
  const [jumpFrame, setJumpFrame] = useState("");
111
- const shortcutsPanelRef = useRef<HTMLDivElement>(null);
112
-
113
- useEffect(() => {
114
- if (!showShortcuts) return;
115
- const handleMouseDown = (e: MouseEvent) => {
116
- if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
117
- setShowShortcuts(false);
118
- }
119
- };
120
- document.addEventListener("mousedown", handleMouseDown);
121
- return () => {
122
- document.removeEventListener("mousedown", handleMouseDown);
123
- };
124
- }, [showShortcuts]);
112
+ const shortcutsPanelId = useId();
113
+ const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
114
+ const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
125
115
 
126
116
  const commitJumpFrame = useCallback(() => {
127
117
  if (disabled) return;
@@ -158,6 +148,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
158
148
  }`}
159
149
  aria-label="Shortcuts and tools"
160
150
  aria-expanded={showShortcuts}
151
+ aria-controls={shortcutsPanelId}
161
152
  >
162
153
  <svg
163
154
  width="11"
@@ -177,6 +168,11 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
177
168
  </Tooltip>
178
169
  {showShortcuts && (
179
170
  <div
171
+ id={shortcutsPanelId}
172
+ role="dialog"
173
+ // Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
174
+ // not trapped and the rest of the editor stays operable, so claiming
175
+ // modality would make assistive tech treat the whole app as inert.
180
176
  className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
181
177
  style={{
182
178
  background: "#161618",
@@ -293,7 +293,9 @@ describe("Timeline provider boundary", () => {
293
293
  // mounted before we query it.
294
294
  act(() => {});
295
295
 
296
- const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
296
+ // "1", not "0": the label carries the 1-based display row, while the
297
+ // callback below still routes by the track's own key.
298
+ const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 1"]');
297
299
  expect(button).not.toBeNull();
298
300
  if (!button) throw new Error("Expected a track visibility toggle");
299
301
 
@@ -20,7 +20,8 @@ import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
20
20
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
21
21
  import type { Rect } from "../../utils/marqueeGeometry";
22
22
  import { TimelineClip } from "./TimelineClip";
23
- import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
23
+ import { TimelineLanes } from "./TimelineLanes";
24
+ import type { TimelineLaneBaseProps } from "./timelineLaneProps";
24
25
  import { renderClipChildren } from "./timelineClipChildren";
25
26
  import { useTimelineRevealClip } from "./useTimelineRevealClip";
26
27
  import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
@@ -641,7 +641,7 @@ describe("TimelineClipDiamonds", () => {
641
641
  act(() => root.unmount());
642
642
  });
643
643
 
644
- const renderSegmentLane = (lastAmbiguous: boolean) => {
644
+ const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
645
645
  const host = document.createElement("div");
646
646
  document.body.append(host);
647
647
  const root = createRoot(host);
@@ -658,9 +658,20 @@ describe("TimelineClipDiamonds", () => {
658
658
  <TimelineDiamondLane
659
659
  keyframesData={{
660
660
  format: "percentage",
661
- keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
661
+ keyframes: [
662
+ kf(0),
663
+ kf(50),
664
+ kf(100, {
665
+ collidingAnimationTargets: lastAmbiguous
666
+ ? [
667
+ { animationId: "anim-1", tweenPercentage: 100 },
668
+ { animationId: "anim-2", tweenPercentage: 75 },
669
+ ]
670
+ : undefined,
671
+ }),
672
+ ],
662
673
  }}
663
- clipWidthPx={200}
674
+ clipWidthPx={clipWidthPx}
664
675
  clipHeightPx={48}
665
676
  accentColor="#4ba3d2"
666
677
  isSelected
@@ -675,15 +686,16 @@ describe("TimelineClipDiamonds", () => {
675
686
  return { host, root };
676
687
  };
677
688
 
678
- it("hides the inline ease button on an ambiguous merged segment", () => {
679
- // Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
680
- // keyframe, so its hover/ease-button area is not rendered.
689
+ it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
690
+ // Both segments (0->50, 50->100) render their ease button; the 50->100
691
+ // segment ends on a keyframe shared by two animations and the button now
692
+ // bulk-edits both rather than being hidden.
681
693
  const { host, root } = renderSegmentLane(true);
682
- expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
694
+ expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
683
695
  act(() => root.unmount());
684
696
  });
685
697
 
686
- it("keeps the inline ease button on unambiguous merged segments", () => {
698
+ it("shows the inline ease button on single-animation merged segments", () => {
687
699
  const { host, root } = renderSegmentLane(false);
688
700
  expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
689
701
  act(() => root.unmount());
@@ -698,6 +710,21 @@ describe("TimelineClipDiamonds", () => {
698
710
  expect(segment?.style.pointerEvents).toBe("none");
699
711
  expect(ease?.style.pointerEvents).toBe("auto");
700
712
  expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
713
+ // Room to spare here, so the button carries the 24x24 WCAG 2.5.8 overlay.
714
+ expect(ease?.className).toContain("before:h-6");
715
+ act(() => root.unmount());
716
+ });
717
+
718
+ it("drops the 24x24 ease overlay when the segment is too narrow to hold it", () => {
719
+ // The segment wrapper outranks the diamonds, so an overlay wider than the
720
+ // clear span between them would steal their clicks at fit zoom. 40px of clip
721
+ // across three keyframes leaves well under 24px of clear span per segment.
722
+ const { host, root } = renderSegmentLane(false, 40);
723
+ const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
724
+
725
+ expect(ease).not.toBeNull();
726
+ expect(ease?.className).not.toContain("before:h-6");
727
+ expect(ease?.style.width).toBe("16px");
701
728
  act(() => root.unmount());
702
729
  });
703
730
 
@@ -27,6 +27,12 @@ export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
27
27
  // stops there: at the zoom floor the gap alone left a ~7px target, which is
28
28
  // neither hittable nor selectable with any accuracy. Boxes may overlap slightly
29
29
  // below this width; each diamond still owns the half-gap around its own centre.
30
+ //
31
+ // Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
32
+ // own spacing exception: at normal keyframe density the neighbour gap is under
33
+ // 24px, so a 24px floor would make adjacent diamonds' hit boxes OVERLAP — one
34
+ // diamond swallowing its neighbour's clicks is a worse 2.5.8 failure than a
35
+ // small target. Do not "fix" this to 24.
30
36
  const KF_MIN_HIT_W = 12;
31
37
 
32
38
  /** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
@@ -141,6 +147,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
141
147
  ? Math.round(clipHeightPx * 0.45)
142
148
  : Math.round(LANE_H * DIAMOND_RATIO);
143
149
  const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
150
+ // Hit-box height only — the glyph keeps `marker.visualSize`. 24 is the WCAG 2.2
151
+ // (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
152
+ // shrunken box: 24px there would reach up into the beat strip.
153
+ const hitHeight = beatsActive ? diamondSize : 24;
144
154
  const sorted = keyframesData.keyframes
145
155
  .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
146
156
  .sort((a, b) => a.percentage - b.percentage);
@@ -412,7 +422,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
412
422
  top: centerY,
413
423
  transform: "translateY(-50%)",
414
424
  width: marker.hitWidth,
415
- height: diamondSize,
425
+ height: hitHeight,
416
426
  zIndex: isHighlighted ? 2 : 1,
417
427
  pointerEvents: "auto",
418
428
  background: "none",