@hyperframes/studio 0.7.77 → 0.7.79

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 (77) hide show
  1. package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1432 -1181
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +70 -33
  27. package/src/hooks/gsapDragCommit.ts +43 -10
  28. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  29. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  31. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  32. package/src/hooks/gsapResizeIntercept.ts +2 -2
  33. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  34. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  35. package/src/hooks/gsapShared.test.ts +144 -0
  36. package/src/hooks/gsapShared.ts +264 -0
  37. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  38. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  39. package/src/hooks/newTweenTarget.test.ts +364 -0
  40. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  41. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  42. package/src/hooks/timelineTrackVisibility.ts +11 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  44. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  45. package/src/hooks/useEnableKeyframes.ts +18 -5
  46. package/src/hooks/useGestureCommit.ts +21 -4
  47. package/src/hooks/useGsapScriptCommits.ts +3 -0
  48. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  49. package/src/hooks/useGsapTweenCache.ts +4 -11
  50. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  51. package/src/player/components/Player.test.ts +143 -12
  52. package/src/player/components/Player.tsx +84 -43
  53. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  54. package/src/player/components/ShortcutsPanel.tsx +11 -15
  55. package/src/player/components/Timeline.test.ts +3 -1
  56. package/src/player/components/TimelineCanvas.tsx +2 -1
  57. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  58. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  59. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  60. package/src/player/components/TimelineLanes.test.tsx +281 -0
  61. package/src/player/components/TimelineLanes.tsx +43 -97
  62. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  63. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  64. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  65. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  66. package/src/player/components/timelineLaneProps.ts +86 -0
  67. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  68. package/src/player/components/timelineTrackDisplay.ts +38 -0
  69. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  70. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  72. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  73. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  74. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  75. package/src/telemetry/client.test.ts +12 -3
  76. package/src/telemetry/client.ts +3 -2
  77. package/dist/assets/index-CBAfprvx.css +0 -1
@@ -38,11 +38,19 @@ function getShaderTransitionLoading(event: Event): boolean | null {
38
38
  }
39
39
 
40
40
  const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
41
+ const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
41
42
 
42
43
  export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
43
44
  return compositionLoading;
44
45
  }
45
46
 
47
+ export function readPreviewErrorMessage(event: Event): string {
48
+ if (!(event instanceof CustomEvent) || !isRecord(event.detail)) return DEFAULT_PREVIEW_ERROR;
49
+ return typeof event.detail.message === "string" && event.detail.message.trim()
50
+ ? event.detail.message
51
+ : DEFAULT_PREVIEW_ERROR;
52
+ }
53
+
46
54
  function enableInteractiveIframe(player: HyperframesPlayerElement): void {
47
55
  const root = player.shadowRoot;
48
56
  if (!root) return;
@@ -122,12 +130,15 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
122
130
  const loadCountRef = useRef(0);
123
131
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
124
132
  const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+ const retryPreviewRef = useRef<(() => void) | null>(null);
134
+ const retryCountRef = useRef(0);
125
135
  const [assetsLoading, setAssetsLoading] = useState(false);
126
136
  const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
127
137
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
128
138
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
129
139
  const [compositionLoading, setCompositionLoading] = useState(true);
130
140
  const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
141
+ const [previewError, setPreviewError] = useState<string | null>(null);
131
142
 
132
143
  // eslint-disable-next-line no-restricted-syntax
133
144
  useEffect(() => {
@@ -161,62 +172,32 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
161
172
  );
162
173
  applyPreviewVariablesToUrl(srcUrl);
163
174
  const src = srcUrl.pathname + srcUrl.search;
164
- player.setAttribute("shader-capture-scale", "1");
165
- player.setAttribute("shader-loading", "player");
166
- player.setAttribute("src", src);
167
- player.setAttribute("width", String(portrait ? 1080 : 1920));
168
- player.setAttribute("height", String(portrait ? 1920 : 1080));
169
- player.style.width = "100%";
170
- player.style.height = "100%";
171
- player.style.display = "block";
172
- player.style.background = "transparent";
173
- container.appendChild(player);
174
-
175
- // Inject pasteboard shadow: let the shadow around the canvas bleed
176
- // into the surrounding pasteboard area (overflow: visible on the container)
177
- // and add a subtle outline + drop-shadow so the canvas boundary reads
178
- // against the gray pasteboard, consistent with professional editors.
179
- if (player.shadowRoot) {
180
- const pasteboardStyle = document.createElement("style");
181
- pasteboardStyle.textContent =
182
- ".hfp-container{overflow:visible}" +
183
- ".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
184
- player.shadowRoot.appendChild(pasteboardStyle);
185
- }
186
-
187
- enableInteractiveIframe(player);
188
-
189
- // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
175
+ const retryPreview = () => {
176
+ retryCountRef.current += 1;
177
+ const retryUrl = new URL(src, window.location.origin);
178
+ retryUrl.searchParams.set("_hfStudioRetry", String(retryCountRef.current));
179
+ setPreviewError(null);
180
+ setCompositionLoading(true);
181
+ player.setAttribute("src", retryUrl.pathname + retryUrl.search);
182
+ };
183
+ retryPreviewRef.current = retryPreview;
190
184
  const iframe = player.iframeElement;
191
- if (typeof ref === "function") {
192
- ref(iframe);
193
- } else if (ref) {
194
- (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
195
- }
196
-
197
- // Prevent the web component's built-in click-to-toggle behavior.
198
- // The studio manages playback exclusively via useTimelinePlayer.
199
185
  const preventToggle = (e: Event) => e.stopImmediatePropagation();
200
- player.addEventListener("click", preventToggle, { capture: true });
201
-
202
186
  const handleShaderTransitionState = (event: Event) => {
203
187
  const loading = getShaderTransitionLoading(event);
204
188
  if (loading !== null) setShaderTransitionLoading(loading);
205
189
  };
206
- player.addEventListener("shadertransitionstate", handleShaderTransitionState);
207
-
208
190
  const handleReady = () => {
191
+ setPreviewError(null);
209
192
  setCompositionLoading(false);
210
193
  };
211
- const handleError = () => {
194
+ const handleError = (event: Event) => {
195
+ setPreviewError(readPreviewErrorMessage(event));
212
196
  setCompositionLoading(false);
213
197
  };
214
- player.addEventListener("ready", handleReady);
215
- player.addEventListener("error", handleError);
216
-
217
- // Forward the iframe's native load event to the studio's onIframeLoad.
218
198
  const handleLoad = () => {
219
199
  loadCountRef.current++;
200
+ setPreviewError(null);
220
201
  setShaderTransitionLoading(false);
221
202
  setCompositionLoading(true);
222
203
  // Reveal animation on reload (hot-reload, composition switch)
@@ -264,7 +245,47 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
264
245
  setAssetsLoading(false);
265
246
  }
266
247
  };
248
+
249
+ // Attach lifecycle listeners before assigning src or connecting the
250
+ // custom element. A warm local iframe can otherwise finish before
251
+ // Studio observes its load and never initialize the timeline.
267
252
  iframe.addEventListener("load", handleLoad);
253
+ player.addEventListener("click", preventToggle, { capture: true });
254
+ player.addEventListener("shadertransitionstate", handleShaderTransitionState);
255
+ player.addEventListener("ready", handleReady);
256
+ player.addEventListener("error", handleError);
257
+
258
+ // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
259
+ if (typeof ref === "function") {
260
+ ref(iframe);
261
+ } else if (ref) {
262
+ (ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
263
+ }
264
+
265
+ player.setAttribute("shader-capture-scale", "1");
266
+ player.setAttribute("shader-loading", "player");
267
+ player.setAttribute("width", String(portrait ? 1080 : 1920));
268
+ player.setAttribute("height", String(portrait ? 1920 : 1080));
269
+ player.style.width = "100%";
270
+ player.style.height = "100%";
271
+ player.style.display = "block";
272
+ player.style.background = "transparent";
273
+ player.setAttribute("src", src);
274
+ container.appendChild(player);
275
+
276
+ // Inject pasteboard shadow: let the shadow around the canvas bleed
277
+ // into the surrounding pasteboard area (overflow: visible on the container)
278
+ // and add a subtle outline + drop-shadow so the canvas boundary reads
279
+ // against the gray pasteboard, consistent with professional editors.
280
+ if (player.shadowRoot) {
281
+ const pasteboardStyle = document.createElement("style");
282
+ pasteboardStyle.textContent =
283
+ ".hfp-container{overflow:visible}" +
284
+ ".hfp-iframe{box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 4px 32px rgba(0,0,0,.7)}";
285
+ player.shadowRoot.appendChild(pasteboardStyle);
286
+ }
287
+
288
+ enableInteractiveIframe(player);
268
289
 
269
290
  cleanup = () => {
270
291
  iframe.removeEventListener("load", handleLoad);
@@ -275,6 +296,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
275
296
  if (assetPollRef.current) clearInterval(assetPollRef.current);
276
297
  assetPollRef.current = null;
277
298
  container.removeChild(player);
299
+ if (retryPreviewRef.current === retryPreview) retryPreviewRef.current = null;
278
300
  // Clear the forwarded ref only if it still points to THIS iframe.
279
301
  // During crossfade refreshes the retiring Player unmounts after the
280
302
  // new Player has already assigned its iframe to the same ref — blindly
@@ -381,6 +403,25 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
381
403
  />
382
404
  </div>
383
405
  )}
406
+ {previewError && (
407
+ <div
408
+ className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 px-6 text-center"
409
+ data-hyperframes-ignore=""
410
+ data-testid="composition-preview-error"
411
+ >
412
+ <div className="max-w-sm">
413
+ <p className="text-sm font-semibold text-white">Preview failed to load</p>
414
+ <p className="mt-1 text-xs text-neutral-400">{previewError}</p>
415
+ <button
416
+ type="button"
417
+ className="mt-4 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black transition-colors hover:bg-neutral-200"
418
+ onClick={() => retryPreviewRef.current?.()}
419
+ >
420
+ Retry preview
421
+ </button>
422
+ </div>
423
+ </div>
424
+ )}
384
425
  </div>
385
426
  );
386
427
  },
@@ -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);
@@ -660,7 +660,7 @@ describe("TimelineClipDiamonds", () => {
660
660
  format: "percentage",
661
661
  keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
662
662
  }}
663
- clipWidthPx={200}
663
+ clipWidthPx={clipWidthPx}
664
664
  clipHeightPx={48}
665
665
  accentColor="#4ba3d2"
666
666
  isSelected
@@ -698,6 +698,21 @@ describe("TimelineClipDiamonds", () => {
698
698
  expect(segment?.style.pointerEvents).toBe("none");
699
699
  expect(ease?.style.pointerEvents).toBe("auto");
700
700
  expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
701
+ // Room to spare here, so the button carries the 24x24 WCAG 2.5.8 overlay.
702
+ expect(ease?.className).toContain("before:h-6");
703
+ act(() => root.unmount());
704
+ });
705
+
706
+ it("drops the 24x24 ease overlay when the segment is too narrow to hold it", () => {
707
+ // The segment wrapper outranks the diamonds, so an overlay wider than the
708
+ // clear span between them would steal their clicks at fit zoom. 40px of clip
709
+ // across three keyframes leaves well under 24px of clear span per segment.
710
+ const { host, root } = renderSegmentLane(false, 40);
711
+ const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
712
+
713
+ expect(ease).not.toBeNull();
714
+ expect(ease?.className).not.toContain("before:h-6");
715
+ expect(ease?.style.width).toBe("16px");
701
716
  act(() => root.unmount());
702
717
  });
703
718
 
@@ -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",
@@ -62,6 +62,12 @@ export function TimelineDiamondConnectors({
62
62
  // no source animation id (runtime-scanned) so there is no tween to target.
63
63
  const target = keyframeTarget(kf);
64
64
  const ease = kf.ease ?? globalEase;
65
+ // connectorWidth is the clear span between the two diamonds' edges, so a
66
+ // 24x24 target centred in it overhangs a diamond as soon as the span is
67
+ // narrower than 24. The segment wrapper sits at z-index 3, above the
68
+ // diamonds, so that overhang would win the hit test and steal their
69
+ // clicks at fit zoom. Grow the target only where the room exists.
70
+ const roomForFullTarget = connectorWidth >= 24;
65
71
  return (
66
72
  <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
67
73
  <div
@@ -103,7 +109,14 @@ export function TimelineDiamondConnectors({
103
109
  data-keyframe-ease-button=""
104
110
  aria-label={`Edit ${ease} easing`}
105
111
  title={`Edit ${ease} easing`}
106
- className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
112
+ // A visible 24x24 badge would collide with the diamonds either
113
+ // side, so the WCAG 2.2 (2.5.8) target is met with a centered
114
+ // transparent ::before overlay; the box stays 16x16. Where the
115
+ // segment is too narrow for that overlay the button keeps its
116
+ // 16x16 hit area, which is WCAG's target-spacing exception:
117
+ // the neighbouring diamonds are themselves the reason it
118
+ // cannot grow, and stealing their clicks is the worse failure.
119
+ className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
107
120
  style={{
108
121
  left: "50%",
109
122
  top: "50%",