@hyperframes/studio 0.7.6 → 0.7.7

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 (40) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/{index-ysftPins.js → index-BeRh2hMe.js} +195 -194
  4. package/dist/assets/{index-DsBhGFPe.js → index-BoASKOeE.js} +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1765 -1193
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +2 -0
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/EaseCurveSection.tsx +175 -99
  13. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  14. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  15. package/src/components/editor/PropertyPanel.tsx +11 -2
  16. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  17. package/src/components/editor/gsapAnimationConstants.ts +6 -35
  18. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  19. package/src/components/editor/propertyPanelHelpers.ts +6 -0
  20. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  21. package/src/components/editor/useMotionPathData.ts +2 -1
  22. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  23. package/src/components/sidebar/AssetsTab.tsx +542 -254
  24. package/src/components/sidebar/assetHelpers.ts +40 -0
  25. package/src/contexts/DomEditContext.tsx +4 -0
  26. package/src/hooks/gsapDragCommit.ts +101 -0
  27. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  28. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  29. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  30. package/src/hooks/useDomEditSession.ts +20 -0
  31. package/src/hooks/useDomEditWiring.ts +3 -0
  32. package/src/hooks/useGestureCommit.ts +0 -4
  33. package/src/hooks/useGsapAwareEditing.ts +0 -4
  34. package/src/hooks/useGsapScriptCommits.ts +0 -12
  35. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  36. package/src/hooks/useGsapTweenCache.ts +119 -38
  37. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  38. package/src/hooks/useRazorSplit.ts +0 -3
  39. package/dist/assets/index-wJZf6FK3.css +0 -1
  40. package/src/utils/editDebugLog.ts +0 -9
@@ -0,0 +1,40 @@
1
+ import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
2
+
3
+ export type MediaCategory = "audio" | "images" | "video" | "fonts";
4
+
5
+ export function getCategory(path: string): MediaCategory | null {
6
+ if (AUDIO_EXT.test(path)) return "audio";
7
+ if (IMAGE_EXT.test(path)) return "images";
8
+ if (VIDEO_EXT.test(path)) return "video";
9
+ if (FONT_EXT.test(path)) return "fonts";
10
+ return null;
11
+ }
12
+
13
+ export function getAudioSubtype(path: string): string {
14
+ const lower = path.toLowerCase();
15
+ if (lower.includes("/bgm/") || lower.includes("/music/")) return "BGM";
16
+ if (lower.includes("/sfx/") || lower.includes("/sound")) return "SFX";
17
+ if (lower.includes("/voice/") || lower.includes("/narrat")) return "Voice";
18
+ return "Audio";
19
+ }
20
+
21
+ export function basename(path: string): string {
22
+ const name = path.split("/").pop() ?? path;
23
+ const dot = name.lastIndexOf(".");
24
+ return dot > 0 ? name.slice(0, dot) : name;
25
+ }
26
+
27
+ export function ext(path: string): string {
28
+ const name = path.split("/").pop() ?? path;
29
+ const dot = name.lastIndexOf(".");
30
+ return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
31
+ }
32
+
33
+ export const CATEGORY_LABELS: Record<MediaCategory, string> = {
34
+ audio: "Audio",
35
+ images: "Images",
36
+ video: "Video",
37
+ fonts: "Fonts",
38
+ };
39
+
40
+ export const FILTER_ORDER: MediaCategory[] = ["audio", "images", "video", "fonts"];
@@ -63,6 +63,7 @@ export interface DomEditActionsValue extends Pick<
63
63
  | "commitMutation"
64
64
  | "applyMarqueeSelection"
65
65
  | "handleUpdateKeyframeEase"
66
+ | "handleSetAllKeyframeEases"
66
67
  > {}
67
68
 
68
69
  export interface DomEditSelectionValue extends Pick<
@@ -171,6 +172,7 @@ export function DomEditProvider({
171
172
  commitMutation,
172
173
  applyMarqueeSelection,
173
174
  handleUpdateKeyframeEase,
175
+ handleSetAllKeyframeEases,
174
176
  },
175
177
  children,
176
178
  }: {
@@ -244,6 +246,7 @@ export function DomEditProvider({
244
246
  commitMutation: stableCommitMutation,
245
247
  applyMarqueeSelection,
246
248
  handleUpdateKeyframeEase,
249
+ handleSetAllKeyframeEases,
247
250
  }),
248
251
  [
249
252
  handleTimelineElementSelect,
@@ -303,6 +306,7 @@ export function DomEditProvider({
303
306
  stableCommitMutation,
304
307
  applyMarqueeSelection,
305
308
  handleUpdateKeyframeEase,
309
+ handleSetAllKeyframeEases,
306
310
  ],
307
311
  );
308
312
 
@@ -4,6 +4,10 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import {
8
+ STUDIO_ORIGINAL_WIDTH_ATTR,
9
+ STUDIO_ORIGINAL_HEIGHT_ATTR,
10
+ } from "../components/editor/manualEditsTypes";
7
11
  import { usePlayerStore } from "../player/store/playerStore";
8
12
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
9
13
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
@@ -342,6 +346,103 @@ export async function commitStaticGsapSize(
342
346
  );
343
347
  }
344
348
 
349
+ /** Rounded `n` when it's a positive finite number, else `fallback`. */
350
+ function positiveOr(n: number, fallback: number): number {
351
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
352
+ }
353
+
354
+ /**
355
+ * Prior size for a keyframed resize: the existing global set's value, else the
356
+ * element's pre-resize size (the draft saved it on the element before mutating
357
+ * el.style.width/height). Falls back to the new size when neither is available.
358
+ */
359
+ function resolvePriorSize(
360
+ sizeSet: GsapAnimation | null,
361
+ el: Element | null | undefined,
362
+ fallbackW: number,
363
+ fallbackH: number,
364
+ ): { width: number; height: number } {
365
+ if (sizeSet) {
366
+ return {
367
+ width: positiveOr(Number(sizeSet.properties.width), fallbackW),
368
+ height: positiveOr(Number(sizeSet.properties.height), fallbackH),
369
+ };
370
+ }
371
+ const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
372
+ const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
373
+ return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
374
+ }
375
+
376
+ /**
377
+ * Resize an *animated* element by keyframing its size at the current playhead,
378
+ * instead of a global `gsap.set` hold. Builds a width/height keyframe tween
379
+ * aligned to the element's existing animation: every base keyframe keeps the
380
+ * prior size, only the keyframe nearest the playhead gets the new size — so
381
+ * resizing one keyframe leaves the others unchanged. Replaces any prior global
382
+ * size set. Returns false when there's no usable range (caller falls back to the
383
+ * static set).
384
+ */
385
+ export async function commitKeyframedSizeFromResize(
386
+ selection: DomEditSelection,
387
+ size: { width: number; height: number },
388
+ selector: string,
389
+ sizeSet: GsapAnimation | null,
390
+ animatedTween: GsapAnimation,
391
+ callbacks: GsapDragCommitCallbacks,
392
+ ): Promise<boolean> {
393
+ const ts = resolveTweenStart(animatedTween) ?? 0;
394
+ const td = resolveTweenDuration(animatedTween);
395
+ if (!(td > 0)) return false;
396
+
397
+ const newW = Math.round(size.width);
398
+ const newH = Math.round(size.height);
399
+ const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
400
+
401
+ const ct = usePlayerStore.getState().currentTime;
402
+ const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
403
+
404
+ // Base keyframe percentages from the animated tween (flat tween → 0 & 100),
405
+ // plus the endpoints and the playhead. Each keeps the prior size except the
406
+ // keyframe at the playhead, which gets the new size.
407
+ const pcts = new Set<number>(
408
+ animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
409
+ );
410
+ pcts.add(0);
411
+ pcts.add(100);
412
+ pcts.add(pct);
413
+ const keyframes = Array.from(pcts)
414
+ .sort((a, b) => a - b)
415
+ .map((p) => ({
416
+ percentage: p,
417
+ properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
418
+ }));
419
+
420
+ // Add the size keyframe tween FIRST, then delete the old global hold. The two
421
+ // commits aren't transactional, so ordering matters: if the delete fails the
422
+ // size is preserved (animated, recoverable) rather than lost. Only the last
423
+ // commit triggers the reload.
424
+ const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
425
+ await callbacks.commitMutation(
426
+ selection,
427
+ {
428
+ type: "add-with-keyframes",
429
+ targetSelector: selector,
430
+ position: roundTo3(ts),
431
+ duration: roundTo3(td),
432
+ keyframes,
433
+ },
434
+ sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
435
+ );
436
+ if (sizeSet) {
437
+ await callbacks.commitMutation(
438
+ selection,
439
+ { type: "delete", animationId: sizeSet.id },
440
+ { label: "Resize layer", softReload: true },
441
+ );
442
+ }
443
+ return true;
444
+ }
445
+
345
446
  export { findSizeSetAnimation };
346
447
 
347
448
  // ── Whole-path offset (plain drag on animated element) ──────────────────
@@ -18,6 +18,7 @@ import {
18
18
  commitStaticGsapPosition,
19
19
  commitStaticGsapRotation,
20
20
  commitStaticGsapSize,
21
+ commitKeyframedSizeFromResize,
21
22
  commitWholePathOffset,
22
23
  computeCurrentPercentage,
23
24
  findPositionSetAnimation,
@@ -329,6 +330,27 @@ export async function tryGsapResizeIntercept(
329
330
  const sel = selectorFromSelection(selection);
330
331
  if (!sel) return false;
331
332
  const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
333
+
334
+ // If the element is animated (has a real tween, not just a static size
335
+ // hold), keyframe the size at the playhead so other keyframes keep theirs —
336
+ // instead of a global set that resizes every frame.
337
+ if (resizeGroup === "size") {
338
+ const animatedTween = pickClosestToPlayhead(
339
+ animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
340
+ );
341
+ if (animatedTween) {
342
+ const handled = await commitKeyframedSizeFromResize(
343
+ selection,
344
+ size,
345
+ sel,
346
+ sizeSet,
347
+ animatedTween,
348
+ { commitMutation, fetchAnimations: fetchFallbackAnimations },
349
+ );
350
+ if (handled) return true;
351
+ }
352
+ }
353
+
332
354
  await commitStaticGsapSize(selection, size, sel, sizeSet, {
333
355
  commitMutation,
334
356
  fetchAnimations: fetchFallbackAnimations,
@@ -101,6 +101,53 @@ describe("readRuntimeKeyframes — multiple tweens pick the one under the playhe
101
101
  });
102
102
  });
103
103
 
104
+ describe("readRuntimeKeyframes — requireChannels keeps the motion path on the positional tween", () => {
105
+ // An animated element with a position tween AND a (longer) size tween, both at
106
+ // start 0 — the shape the per-keyframe size resize produces.
107
+ const el = { id: "dot-a" };
108
+ const positionTween = {
109
+ targets: () => [el],
110
+ vars: {
111
+ keyframes: [
112
+ { x: -1252, y: -394 },
113
+ { x: 244, y: -316 },
114
+ ],
115
+ duration: 2.4,
116
+ },
117
+ duration: () => 2.4,
118
+ startTime: () => 0, // range [0, 2.4]
119
+ };
120
+ const sizeTween = {
121
+ targets: () => [el],
122
+ vars: {
123
+ keyframes: [
124
+ { width: 120, height: 96 },
125
+ { width: 325, height: 300 },
126
+ ],
127
+ duration: 3.243,
128
+ },
129
+ duration: () => 3.243,
130
+ startTime: () => 0, // range [0, 3.243] — outlives the position tween
131
+ };
132
+
133
+ it("playhead past the position range (2.4–3.243s) still returns the position tween, not size", () => {
134
+ // Without the filter the size tween (the only one containing the playhead)
135
+ // would win and blank the path.
136
+ const read = readRuntimeKeyframes(
137
+ fakeIframe(el, [positionTween, sizeTween], 3.0),
138
+ "#dot-a",
139
+ undefined,
140
+ ["x", "y"],
141
+ );
142
+ expect(read?.keyframes[0]?.properties).toHaveProperty("x");
143
+ });
144
+
145
+ it("without requireChannels the size tween shadows the path past the position range (documents the bug)", () => {
146
+ const read = readRuntimeKeyframes(fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a");
147
+ expect(read?.keyframes[0]?.properties).toHaveProperty("width");
148
+ });
149
+ });
150
+
104
151
  describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => {
105
152
  const el = { id: "puck-b" };
106
153
  const holdSet = {
@@ -252,14 +252,26 @@ export function resolveRuntimeTween(
252
252
  return channelMatch ?? first;
253
253
  }
254
254
 
255
+ /** Whether a read carries at least one of `channels` as a keyframe property. */
256
+ function readCarriesChannel(read: ReadTween, channels: string[]): boolean {
257
+ return read.keyframes.some((kf) => channels.some((c) => kf.properties[c] != null));
258
+ }
259
+
255
260
  /**
256
261
  * Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
257
262
  * Returns tween-relative percentages; callers convert to clip-relative.
263
+ *
264
+ * `requireChannels` restricts the scan to tweens whose read carries one of those
265
+ * properties — e.g. the motion-path overlay passes `["x","y"]` so it never picks
266
+ * up a co-located size/scale tween (which has no x/y and would blank the path
267
+ * whenever the playhead sits in that tween's range but outside the position
268
+ * tween's). Omitted → any keyframed tween qualifies (back-compat).
258
269
  */
259
270
  export function readRuntimeKeyframes(
260
271
  iframe: HTMLIFrameElement | null,
261
272
  selector: string,
262
273
  compositionId?: string,
274
+ requireChannels?: string[],
263
275
  ): ReadTween | null {
264
276
  const timelines = timelinesOf(iframe);
265
277
  if (!timelines) return null;
@@ -299,6 +311,7 @@ export function readRuntimeKeyframes(
299
311
  if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
300
312
  const read = readTween(tween.vars);
301
313
  if (!read) continue;
314
+ if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
302
315
  if (firstRead === null) firstRead = read;
303
316
  // Prefer the tween whose [start, start+dur] contains the playhead.
304
317
  if (now != null) {
@@ -410,6 +410,25 @@ export function useDomEditSession({
410
410
  [gsapCommitMutation, domEditSelectionRef],
411
411
  );
412
412
 
413
+ // Apply one ease to every segment at once (AE select-all + F9): set easeEach
414
+ // and strip per-keyframe overrides in a single mutation.
415
+ const handleSetAllKeyframeEases = useCallback(
416
+ (animationId: string, ease: string) => {
417
+ const sel = domEditSelectionRef.current;
418
+ if (!sel) return;
419
+ gsapCommitMutation(
420
+ sel,
421
+ {
422
+ type: "update-meta",
423
+ animationId,
424
+ updates: { easeEach: ease, resetKeyframeEases: true },
425
+ },
426
+ { label: "Apply ease to all segments", softReload: true },
427
+ );
428
+ },
429
+ [gsapCommitMutation, domEditSelectionRef],
430
+ );
431
+
413
432
  return {
414
433
  // State
415
434
  domEditSelection,
@@ -477,6 +496,7 @@ export function useDomEditSession({
477
496
  handleGsapRemoveAllKeyframes,
478
497
  handleResetSelectedElementKeyframes,
479
498
  handleUpdateKeyframeEase,
499
+ handleSetAllKeyframeEases,
480
500
  commitAnimatedProperty,
481
501
  handleSetArcPath,
482
502
  handleUpdateArcSegment,
@@ -197,6 +197,9 @@ export function useDomEditWiring({
197
197
  ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
198
198
  : null,
199
199
  gsapCacheVersion,
200
+ // Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
201
+ // the live element and surface in the inspector — not just by #id match.
202
+ previewIframeRef,
200
203
  );
201
204
 
202
205
  // ── Telemetry & fallback ──
@@ -3,7 +3,6 @@
3
3
  * Extracted from App.tsx to keep file sizes under the 600-line limit.
4
4
  */
5
5
  import { useState, useCallback, useRef, useEffect } from "react";
6
- import { editLog } from "../utils/editDebugLog";
7
6
  import { useGestureRecording } from "./useGestureRecording";
8
7
  import { simplifyGestureSamples } from "../utils/rdpSimplify";
9
8
  import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
@@ -294,9 +293,6 @@ export function useGestureCommit({
294
293
 
295
294
  // fallow-ignore-next-line complexity
296
295
  const handleToggleRecording = useCallback(() => {
297
- editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
298
- id: domEditSessionRef.current.domEditSelection?.id,
299
- });
300
296
  if (gestureStateRef.current === "recording") {
301
297
  void stopAndCommitRecording();
302
298
  return;
@@ -8,7 +8,6 @@
8
8
  * from the rest of the editing orchestration.
9
9
  */
10
10
  import { useCallback } from "react";
11
- import { editLog } from "../utils/editDebugLog";
12
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
13
  import {
@@ -97,7 +96,6 @@ export function useGsapAwareEditing({
97
96
  next: { x: number; y: number },
98
97
  modifiers?: { altKey?: boolean },
99
98
  ) => {
100
- editLog("manual-drag:move", { id: selection.id, next, altKey: modifiers?.altKey });
101
99
  if (gsapCommitMutation) {
102
100
  try {
103
101
  await tryGsapDragIntercept(
@@ -126,7 +124,6 @@ export function useGsapAwareEditing({
126
124
 
127
125
  const handleGsapAwareBoxSizeCommit = useCallback(
128
126
  async (selection: DomEditSelection, next: { width: number; height: number }) => {
129
- editLog("manual-drag:resize", { id: selection.id, next });
130
127
  if (gsapCommitMutation) {
131
128
  try {
132
129
  const handled = await tryGsapResizeIntercept(
@@ -157,7 +154,6 @@ export function useGsapAwareEditing({
157
154
 
158
155
  const handleGsapAwareRotationCommit = useCallback(
159
156
  async (selection: DomEditSelection, next: { angle: number }) => {
160
- editLog("manual-drag:rotate", { id: selection.id, next });
161
157
  if (gsapCommitMutation) {
162
158
  try {
163
159
  // Single source of truth for rotation too: tryGsapRotationIntercept handles
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
- import { editLog } from "../utils/editDebugLog";
3
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
4
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
4
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
@@ -130,12 +129,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
130
129
  const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
131
130
  const pid = projectIdRef.current;
132
131
  if (!pid) return;
133
- editLog("gsap-commit", {
134
- type: mutation.type,
135
- id: selection.id,
136
- file: selection.sourceFile || activeCompPath,
137
- label: options.label,
138
- });
139
132
  const unsafeFields = findUnsafeMutationValues(mutation);
140
133
  if (unsafeFields.length > 0) {
141
134
  showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
@@ -165,11 +158,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
165
158
  options.beforeReload?.();
166
159
  applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
167
160
  onCacheInvalidate();
168
- editLog("gsap-commit:done", {
169
- type: mutation.type,
170
- changed: result.changed,
171
- instant: Boolean(options.instantPatch),
172
- });
173
161
  }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
174
162
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
175
163
  // commits to the SAME file (any op type, any animation) interleave server-side,
@@ -1,6 +1,13 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import { getAnimationsForElement } from "./useGsapTweenCache";
3
+ import { getAnimationsForElement, resolveSelectorElementIds } from "./useGsapTweenCache";
4
+
5
+ // Minimal Document stub: querySelectorAll returns the elements mapped per selector.
6
+ function fakeDoc(map: Record<string, { id: string }[]>): Document {
7
+ return {
8
+ querySelectorAll: (sel: string) => (map[sel] ?? []) as unknown as NodeListOf<Element>,
9
+ } as unknown as Document;
10
+ }
4
11
 
5
12
  function anim(targetSelector: string): GsapAnimation {
6
13
  return {
@@ -46,4 +53,41 @@ describe("getAnimationsForElement", () => {
46
53
  expect(getAnimationsForElement(grouped, { selector: ".clock-hand" })).toHaveLength(1);
47
54
  expect(getAnimationsForElement(grouped, { selector: ".unrelated" })).toHaveLength(0);
48
55
  });
56
+
57
+ it("attributes a class tween to an id-selected element via element.matches", () => {
58
+ // gsap.from(".dot", {stagger}) — the element is selected by id (#dot-a), so
59
+ // its selector string never equals ".dot", but the live element matches it.
60
+ const dots = [anim(".dot")];
61
+ const el = { matches: (s: string) => s === ".dot" || s === "#dot-a" } as unknown as Element;
62
+ expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" }, el)).toHaveLength(1);
63
+ // Without the live element the class tween is still missed (legacy behavior).
64
+ expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" })).toHaveLength(0);
65
+ });
66
+
67
+ it("element.matches gates attribution — no over-matching", () => {
68
+ const dots = [anim(".dot")];
69
+ const el = { matches: () => false } as unknown as Element;
70
+ expect(getAnimationsForElement(dots, { id: "other", selector: "#other" }, el)).toHaveLength(0);
71
+ });
72
+ });
73
+
74
+ describe("resolveSelectorElementIds", () => {
75
+ it("resolves a bare #id without touching the DOM", () => {
76
+ expect(resolveSelectorElementIds("#hero", null)).toEqual(["hero"]);
77
+ });
78
+
79
+ it("resolves a class selector to every matching element id (the .dot+stagger case)", () => {
80
+ const doc = fakeDoc({ ".dot": [{ id: "dot-a" }, { id: "dot-b" }] });
81
+ expect(resolveSelectorElementIds(".dot", doc)).toEqual(["dot-a", "dot-b"]);
82
+ });
83
+
84
+ it("resolves a group selector across its parts (deduped)", () => {
85
+ const doc = fakeDoc({ ".a": [{ id: "x" }], ".b": [{ id: "y" }, { id: "x" }] });
86
+ expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
87
+ });
88
+
89
+ it("falls back to a leading #id when there is no DOM", () => {
90
+ expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
91
+ expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
92
+ });
49
93
  });