@hyperframes/studio 0.7.78 → 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 (74) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1303 -1121
  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.tsx +4 -1
  26. package/src/hooks/gsapDragCommit.ts +43 -10
  27. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  28. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  30. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  31. package/src/hooks/gsapResizeIntercept.ts +2 -2
  32. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  33. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  34. package/src/hooks/gsapShared.test.ts +144 -0
  35. package/src/hooks/gsapShared.ts +264 -0
  36. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  37. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  38. package/src/hooks/newTweenTarget.test.ts +364 -0
  39. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  40. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  41. package/src/hooks/timelineTrackVisibility.ts +11 -1
  42. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  43. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  44. package/src/hooks/useEnableKeyframes.ts +18 -5
  45. package/src/hooks/useGestureCommit.ts +21 -4
  46. package/src/hooks/useGsapScriptCommits.ts +3 -0
  47. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  48. package/src/hooks/useGsapTweenCache.ts +4 -11
  49. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  50. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  51. package/src/player/components/ShortcutsPanel.tsx +11 -15
  52. package/src/player/components/Timeline.test.ts +3 -1
  53. package/src/player/components/TimelineCanvas.tsx +2 -1
  54. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  55. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  56. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  57. package/src/player/components/TimelineLanes.test.tsx +281 -0
  58. package/src/player/components/TimelineLanes.tsx +43 -97
  59. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  60. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  61. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  62. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  63. package/src/player/components/timelineLaneProps.ts +86 -0
  64. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  65. package/src/player/components/timelineTrackDisplay.ts +38 -0
  66. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  67. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  68. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  69. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  70. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  71. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  72. package/src/telemetry/client.test.ts +12 -3
  73. package/src/telemetry/client.ts +3 -2
  74. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -1,6 +1,11 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../player";
3
3
  import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
4
+ import {
5
+ timelineTrackOrder,
6
+ trackDisplayNumber,
7
+ trackDisplaySuffix,
8
+ } from "../player/components/timelineTrackDisplay";
4
9
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
10
  import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
6
11
  import {
@@ -208,12 +213,17 @@ export async function toggleTimelineTrackHidden({
208
213
  domEditSaveTimestampRef,
209
214
  pendingTimelineEditPathRef,
210
215
  }: ToggleTimelineTrackHiddenInput): Promise<string[]> {
216
+ // `track` is the fractional sort key the callback needs; the history entry is
217
+ // read by a human, so it gets the display row instead.
218
+ const suffix = trackDisplaySuffix(
219
+ trackDisplayNumber(timelineTrackOrder(timelineElements), track),
220
+ );
211
221
  return setElementsHidden({
212
222
  projectId,
213
223
  activeCompPath,
214
224
  elements: timelineElements.filter((element) => element.track === track),
215
225
  hidden,
216
- label: hidden ? `Hide track ${track}` : `Show track ${track}`,
226
+ label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
217
227
  previewIframe,
218
228
  writeProjectFile,
219
229
  recordEdit,
@@ -14,7 +14,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
16
16
  import type { SetPatchProps } from "./gsapRuntimePatch";
17
- import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
17
+ import {
18
+ selectorFromSelection,
19
+ computeElementPercentage,
20
+ isInstantHold,
21
+ writeTargetSelector,
22
+ tweenTargetsElement,
23
+ } from "./gsapShared";
18
24
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
25
  import { roundTo3 } from "../utils/rounding";
20
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
@@ -185,7 +191,9 @@ async function commitStaticSet(
185
191
  batch.push(entry);
186
192
  byGroup.set(group, batch);
187
193
  }
188
- const staticWrites = animations.filter((a) => isInstantHold(a) && a.targetSelector === selector);
194
+ const staticWrites = animations.filter(
195
+ (a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
196
+ );
189
197
  // Resolve every group's target BEFORE committing anything, and coalesce
190
198
  // groups that land on the SAME write into one commit: the snapshot is captured
191
199
  // once, so if two groups resolved to one legacy mixed write, a first
@@ -206,7 +214,7 @@ async function commitStaticSet(
206
214
  }
207
215
  // Fresh adds don't reshape existing sets, so their ids can't go stale.
208
216
  for (const batch of newSetBatches) {
209
- await addGlobalStaticSet(selection, batch, selector, commit);
217
+ await addGlobalStaticSet(selection, batch, commit);
210
218
  }
211
219
  }
212
220
 
@@ -236,18 +244,22 @@ function findGroupOwningStaticWrite(
236
244
  async function addGlobalStaticSet(
237
245
  selection: DomEditSelection,
238
246
  batch: [string, number | string][],
239
- selector: string,
240
247
  commit: Commit,
241
248
  ): Promise<void> {
242
249
  const numericProps: SetPatchProps = {};
243
250
  for (const [k, v] of batch) {
244
251
  if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
245
252
  }
253
+ // A brand-new write, so it must address ONE element: the selection's own
254
+ // selector is the bare class an id-less element yields, which would hold every
255
+ // sibling. No one-element form means no write at all (see writeTargetSelector).
256
+ const target = writeTargetSelector(selection);
257
+ if (!target) return;
246
258
  await commit(
247
259
  selection,
248
260
  {
249
261
  type: "add",
250
- targetSelector: selector,
262
+ targetSelector: target,
251
263
  method: "set",
252
264
  position: 0,
253
265
  properties: Object.fromEntries(batch),
@@ -259,7 +271,7 @@ async function addGlobalStaticSet(
259
271
  ...(Object.keys(numericProps).length > 0
260
272
  ? {
261
273
  instantPatch: {
262
- selector,
274
+ selector: target,
263
275
  change: { kind: "global-set" as const, props: numericProps },
264
276
  },
265
277
  }
@@ -482,7 +494,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
482
494
  // contaminating a foreign-group tween. Mirror an existing keyframed tween's
483
495
  // time range so the new group animates over the same span. The 0% baseline is
484
496
  // an `_auto` endpoint so it tracks the nearest keyframe as you add more.
485
- if (selector) {
497
+ // A fresh tween, so its target must address ONE element; with no
498
+ // one-element form the edit is dropped rather than written onto every
499
+ // class sibling (see writeTargetSelector).
500
+ const newTweenTarget = writeTargetSelector(selection);
501
+ if (selector && newTweenTarget) {
486
502
  const template = selectedGsapAnimations.find((a) => !!a.keyframes);
487
503
  const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
488
504
  const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -503,7 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
503
519
  selection,
504
520
  {
505
521
  type: "add-with-keyframes",
506
- targetSelector: selector,
522
+ targetSelector: newTweenTarget,
507
523
  position: roundTo3(tStart),
508
524
  duration: roundTo3(tDur),
509
525
  keyframes,
@@ -466,3 +466,47 @@ describe("useEnableKeyframes — flat tween transaction", () => {
466
466
  });
467
467
  });
468
468
  });
469
+
470
+ describe("useEnableKeyframes — new tween on a class-only element", () => {
471
+ it("targets the selected sibling alone, not every element sharing its class", async () => {
472
+ window.location.hash = "#/project/test-project";
473
+ usePlayerStore.setState({ currentTime: 1 });
474
+ vi.stubGlobal(
475
+ "fetch",
476
+ vi.fn(async () => ({ ok: true, json: async () => ({ animations: [] }) })),
477
+ );
478
+ const scene = document.body.appendChild(document.createElement("div"));
479
+ scene.id = "scene";
480
+ scene.innerHTML = '<i class="group"></i>'.repeat(5);
481
+ const groups = Array.from(scene.querySelectorAll<HTMLElement>(".group"));
482
+ const commitMutation = vi.fn(async () => undefined);
483
+ const enable = renderEnableKeyframes({
484
+ // A bare `.group` here writes `tl.to(".group", …)`, which animates all five
485
+ // siblings and reads back as all five — one add wiped the timeline.
486
+ domEditSelection: {
487
+ selector: ".group",
488
+ selectorIndex: 3,
489
+ sourceFile: "index.html",
490
+ element: groups[3],
491
+ dataAttributes: { start: "0", duration: "2" },
492
+ } as unknown as DomEditSelection,
493
+ selectedGsapAnimations: [],
494
+ previewIframeRef: {
495
+ current: {
496
+ contentWindow: { gsap: { getProperty: () => 7 } },
497
+ } as unknown as HTMLIFrameElement,
498
+ },
499
+ handleGsapAddAnimation: vi.fn(),
500
+ handleGsapConvertToKeyframes: vi.fn(),
501
+ handleGsapRemoveKeyframe: vi.fn(),
502
+ commitMutation,
503
+ });
504
+
505
+ await act(async () => enable());
506
+
507
+ const mutation = commitMutation.mock.calls[0]?.[0] as { targetSelector: string };
508
+ expect(mutation?.targetSelector).toBeTruthy();
509
+ expect(document.querySelectorAll(mutation.targetSelector)).toHaveLength(1);
510
+ expect(document.querySelector(mutation.targetSelector)).toBe(groups[3]);
511
+ });
512
+ });
@@ -13,11 +13,12 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
15
15
  import {
16
- selectorFromSelection,
16
+ existingTweenTargetSelector,
17
17
  computeElementPercentage,
18
18
  KEYFRAME_PCT_MATCH,
19
19
  isInstantHold,
20
20
  resolveEditableTweenDuration,
21
+ writeTargetSelector,
21
22
  } from "./gsapShared";
22
23
  import {
23
24
  absoluteToPercentage,
@@ -250,7 +251,10 @@ async function extendKeyframedTweenToPlayhead(
250
251
  iframe: HTMLIFrameElement | null,
251
252
  commitOverrides?: Partial<CommitMutationOptions>,
252
253
  ): Promise<void> {
253
- const selector = selectorFromSelection(sel);
254
+ // Re-authoring an EXISTING tween: keep the target it already writes rather
255
+ // than re-deriving one from the selection, which would widen a tween already
256
+ // narrowed to one element back onto its class siblings (existingTweenTargetSelector).
257
+ const selector = existingTweenTargetSelector(anim, sel);
254
258
  const position = readElementPosition(iframe, sel, anim);
255
259
  if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
256
260
  const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
@@ -335,7 +339,10 @@ export async function promoteSetToKeyframes(
335
339
  t: number,
336
340
  iframe: HTMLIFrameElement | null,
337
341
  ): Promise<void> {
338
- const selector = selectorFromSelection(sel);
342
+ // Re-authoring an EXISTING tween: keep the target it already writes rather
343
+ // than re-deriving one from the selection, which would widen a tween already
344
+ // narrowed to one element back onto its class siblings (existingTweenTargetSelector).
345
+ const selector = existingTweenTargetSelector(setAnim, sel);
339
346
  const setStart = resolveTweenStart(setAnim) ?? 0;
340
347
  if (!selector || !session.commitMutation) return;
341
348
  // Playhead at or before the set → there's no forward range to promote into.
@@ -389,7 +396,10 @@ export async function applyArcKeyframeAtPlayhead(
389
396
  iframe: HTMLIFrameElement | null,
390
397
  ): Promise<void> {
391
398
  if (!session.commitMutation) return;
392
- const targetSelector = selectorFromSelection(sel);
399
+ // Re-authoring an EXISTING tween: keep the target it already writes rather
400
+ // than re-deriving one from the selection, which would widen a tween already
401
+ // narrowed to one element back onto its class siblings (existingTweenTargetSelector).
402
+ const targetSelector = existingTweenTargetSelector(arcAnim, sel);
393
403
  if (!targetSelector) return;
394
404
  const start = resolveTweenStart(arcAnim) ?? 0;
395
405
  const duration = resolveEditableTweenDuration(arcAnim, sel);
@@ -523,7 +533,10 @@ export function useEnableKeyframes(
523
533
  sel.dataAttributes?.duration,
524
534
  t,
525
535
  );
526
- const selector = selectorFromSelection(sel);
536
+ // A brand-new tween: author it against the one element the user selected.
537
+ // The bare class selectorFromSelection hands back for an id-less element
538
+ // animates every sibling sharing the class (see writeTargetSelector).
539
+ const selector = writeTargetSelector(sel);
527
540
 
528
541
  if (!selector) {
529
542
  session.handleGsapAddAnimation("to");
@@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
13
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
14
14
  import { roundTo3 } from "../utils/rounding";
15
15
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
16
- import { isInstantHold, idSelector } from "./gsapShared";
16
+ import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
17
17
 
18
18
  type RecordedKeyframe = {
19
19
  percentage: number;
@@ -168,11 +168,24 @@ export function useGestureCommit({
168
168
  if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
169
169
  }
170
170
 
171
+ // Two different jobs, two different selectors. `selector` is the string an
172
+ // ALREADY-AUTHORED tween is matched against (and retargeted with, so a
173
+ // tween aimed at a whole group stays aimed at it). `writeSelector` is what
174
+ // a NEW tween is authored with: the bare class the id-less case yields here
175
+ // would record the gesture onto every sibling sharing it.
171
176
  const selector = sel.id ? idSelector(sel.id) : sel.selector;
172
177
  if (!selector) {
173
178
  showToast("Cannot save — element has no selector", "error");
174
179
  return;
175
180
  }
181
+ // A recorded gesture becomes a NEW tween, so its target must address one
182
+ // element; the selection's own selector would record the motion onto
183
+ // every sibling sharing its class (see writeTargetSelector).
184
+ const writeSelector = writeTargetSelector(sel);
185
+ if (!writeSelector) {
186
+ showToast("Cannot save: element has no unique selector", "error");
187
+ return;
188
+ }
176
189
  if (liveSession.commitMutation) {
177
190
  const recStart = recordingStartTimeRef.current;
178
191
  const rawKeyframes = sortedPcts.map((pct) => ({
@@ -186,7 +199,11 @@ export function useGestureCommit({
186
199
  );
187
200
  const allAnims = liveSession.selectedGsapAnimations ?? [];
188
201
  const existingPositionTween = hasPositionProps
189
- ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
202
+ ? allAnims.find(
203
+ (a) =>
204
+ a.propertyGroup === "position" &&
205
+ tweenTargetsElement(a.targetSelector, selector, sel.element),
206
+ )
190
207
  : undefined;
191
208
  if (existingPositionTween) {
192
209
  if (isInstantHold(existingPositionTween)) {
@@ -261,7 +278,7 @@ export function useGestureCommit({
261
278
  await liveSession.commitMutation(
262
279
  {
263
280
  type: "add-with-keyframes",
264
- targetSelector: selector,
281
+ targetSelector: writeSelector,
265
282
  position: roundTo3(recStart),
266
283
  duration: roundTo3(duration),
267
284
  keyframes: groupKfs,
@@ -287,7 +304,7 @@ export function useGestureCommit({
287
304
  await liveSession.commitMutation(
288
305
  {
289
306
  type: "add-with-keyframes",
290
- targetSelector: selector,
307
+ targetSelector: writeSelector,
291
308
  position: roundTo3(recStart),
292
309
  duration: roundTo3(duration),
293
310
  keyframes: groupKfs,
@@ -186,6 +186,9 @@ function syncCommittedGsapMutation({
186
186
  targetPath,
187
187
  selection.id ?? undefined,
188
188
  mutation,
189
+ // The live preview document is what resolves a class / descendant tween to
190
+ // the elements it really animates; without it only whole-id selectors do.
191
+ iframe?.contentDocument,
189
192
  );
190
193
  }
191
194
  refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
@@ -86,16 +86,24 @@ describe("resolveSelectorElementIds", () => {
86
86
  expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
87
87
  });
88
88
 
89
- it("falls back to a leading #id when there is no DOM", () => {
90
- expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
89
+ // A DOM-less LEADING-id fallback attributed `#card .label` to `#card`, the
90
+ // ancestor it merely scopes to. Without a DOM there is nothing to resolve the
91
+ // descendant against, so the honest answer is no element at all.
92
+ it("resolves nothing for a compound selector when there is no DOM", () => {
93
+ expect(resolveSelectorElementIds("#card .label", null)).toEqual([]);
91
94
  expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
92
95
  });
93
96
 
97
+ it("still resolves every whole-id part of a group selector without a DOM", () => {
98
+ expect(resolveSelectorElementIds("#a, #b", null)).toEqual(["a", "b"]);
99
+ });
100
+
94
101
  // The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading,
95
102
  // dotted). The old local `#id`-only regex read no id at all for those, so they
96
103
  // silently dropped out of both DOM-less paths.
97
104
  it("falls back to a bracketed id when there is no DOM", () => {
98
- expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual(["01-hook"]);
105
+ expect(resolveSelectorElementIds('[id="01-hook"]', null)).toEqual(["01-hook"]);
106
+ expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual([]);
99
107
  });
100
108
 
101
109
  it("falls back to a bracketed id when querySelectorAll rejects the selector", () => {
@@ -7,24 +7,17 @@ import {
7
7
  pruneKeyframeCacheToFiles,
8
8
  writeGsapAnimationsForElement,
9
9
  } from "./gsapKeyframeCacheHelpers";
10
- import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
10
+ import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
11
11
  import {
12
12
  deduplicateKeyframes,
13
13
  isStaticPositionHold,
14
14
  synthesizeFlatTweenKeyframes,
15
15
  } from "./gsapTweenSynth";
16
- import {
17
- fetchParsedAnimations,
18
- populateKeyframeCacheFromAst,
19
- resolveClipTimingBasis,
20
- } from "./keyframeCacheAstLoad";
16
+ import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
21
17
 
22
18
  // Re-exported so callers keep importing the GSAP cache surface from one module.
23
- export {
24
- fetchParsedAnimations,
25
- resolveClipTimingBasis,
26
- resolveSelectorElementIds,
27
- } from "./keyframeCacheAstLoad";
19
+ export { resolveClipTimingBasis } from "./gsapShared";
20
+ export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
28
21
 
29
22
  /** The selected element's identity for matching tweens to it. */
30
23
  export interface GsapElementTarget {
@@ -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);
@@ -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