@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
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
- import { idFromSelector, toClipKeyframes } from "./gsapShared";
7
+ import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
8
8
  import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
9
9
 
10
10
  export function updateKeyframeCacheFromParsed(
@@ -12,58 +12,65 @@ export function updateKeyframeCacheFromParsed(
12
12
  targetPath: string,
13
13
  selectionId: string | undefined,
14
14
  mutation: Record<string, unknown>,
15
+ doc?: Document | null,
15
16
  ): void {
16
- const { setKeyframeCache, elements } = usePlayerStore.getState();
17
+ const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
17
18
  const idsWithKeyframes = new Set<string>();
18
19
  const merged = new Map<string, KeyframeCacheEntry>();
19
20
  const sourceAnimations = new Map<string, GsapAnimation[]>();
20
21
  for (const anim of animations) {
21
- const id = idFromSelector(anim.targetSelector);
22
22
  const kfSource =
23
23
  anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
24
- if (!id || kfSource.length === 0) continue;
25
- idsWithKeyframes.add(id);
26
- // Every tween that fed keyframeCache also lands in gsapAnimations, group or
27
- // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
28
- // cache diamonds with no source animation behind them, so the collapsed row
29
- // drew keyframes the expanded lanes couldn't render. Lane consumers do the
30
- // group filtering themselves (animationContributesLane).
31
- sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
24
+ if (kfSource.length === 0) continue;
25
+ // Attribute the tween to every element it actually animates. A leading-id
26
+ // match filed `#stat3 .block` under `#stat3`: the child's diamonds landed on
27
+ // its ancestor AND collided with the ancestor's own tween at the shared
28
+ // percentage, which the same-% merge then resolved by dropping the ease.
29
+ for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
30
+ idsWithKeyframes.add(id);
31
+ // Every tween that fed keyframeCache also lands in gsapAnimations, group or
32
+ // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
33
+ // cache diamonds with no source animation behind them, so the collapsed row
34
+ // drew keyframes the expanded lanes couldn't render. Lane consumers do the
35
+ // group filtering themselves (animationContributesLane).
36
+ sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
32
37
 
33
- // Convert tween-relative percentages to clip-relative so diamonds
34
- // render at the correct position within the timeline clip.
35
- const timelineEl = elements.find(
36
- (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
37
- );
38
- const clipKeyframes = toClipKeyframes(
39
- kfSource,
40
- anim,
41
- timelineEl?.start ?? 0,
42
- timelineEl?.duration ?? 1,
43
- );
38
+ // Convert tween-relative percentages to clip-relative so diamonds
39
+ // render at the correct position within the timeline clip. The basis comes
40
+ // from the shared resolver, so this writer agrees with the AST load on both
41
+ // the sub-comp host fallback and the tween's own time frame.
42
+ const { elStart, elDuration } = resolveClipTimingBasis(
43
+ id,
44
+ targetPath,
45
+ elements,
46
+ domClipChildren,
47
+ );
48
+ const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
44
49
 
45
- const existing = merged.get(id);
46
- if (existing) {
47
- // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
48
- // flag downstream lanes read); a second copy of that rule here is how the
49
- // two writers drift.
50
- existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
51
- } else {
52
- merged.set(id, {
53
- ...anim.keyframes,
54
- format: anim.keyframes?.format ?? "percentage",
55
- keyframes: clipKeyframes,
56
- });
50
+ const existing = merged.get(id);
51
+ if (existing) {
52
+ // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
53
+ // flag downstream lanes read); a second copy of that rule here is how the
54
+ // two writers drift.
55
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
56
+ } else {
57
+ merged.set(id, {
58
+ ...anim.keyframes,
59
+ format: anim.keyframes?.format ?? "percentage",
60
+ keyframes: clipKeyframes,
61
+ });
62
+ }
57
63
  }
58
64
  }
59
65
  for (const [id, entry] of merged) {
60
66
  for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
61
67
  writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
62
68
  }
63
- const targetId =
64
- idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
65
- if (targetId && !idsWithKeyframes.has(targetId)) {
66
- clearKeyframeCacheForElement(targetPath, targetId);
69
+ const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
70
+ const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
71
+ const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
72
+ for (const targetId of targetIds) {
73
+ if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId);
67
74
  }
68
75
  }
69
76
 
@@ -1,7 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
4
- import { PROPERTY_DEFAULTS, selectorFromSelection } from "./gsapShared";
4
+ import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
5
5
  import { roundToCenti } from "../utils/rounding";
6
6
 
7
7
  type CommitFn = (
@@ -22,6 +22,9 @@ export async function commitKeyframeAtTimeImpl(
22
22
  properties: Record<string, number | string>,
23
23
  commitMutation: CommitFn,
24
24
  ): Promise<void> {
25
+ // Matching an authored tween is a string compare against what the author
26
+ // wrote, so it keeps using the selection's own selector; the NEW tween below
27
+ // is authored with the one-element form instead.
25
28
  const selector = selectorFromSelection(selection);
26
29
  if (!selector) return;
27
30
 
@@ -60,12 +63,18 @@ export async function commitKeyframeAtTimeImpl(
60
63
  },
61
64
  );
62
65
  } else {
66
+ // Null means the live DOM could not prove any one-element form. Falling
67
+ // back to the author's own selector would write the group-collapsing
68
+ // target this narrowing exists to prevent, so the keyframe is dropped
69
+ // instead (see writeTargetSelector).
70
+ const target = writeTargetSelector(selection);
71
+ if (!target) return;
63
72
  const defaultDuration = 0.5;
64
73
  await commitMutation(
65
74
  selection,
66
75
  {
67
76
  type: "add-with-keyframes" as const,
68
- targetSelector: selector,
77
+ targetSelector: target,
69
78
  position: absoluteTime,
70
79
  duration: defaultDuration,
71
80
  keyframes: [
@@ -79,7 +79,7 @@ export async function tryGsapResizeIntercept(
79
79
  if (!anim || isInstantHold(anim)) {
80
80
  const sel = selectorFromSelection(selection);
81
81
  if (!sel) return false;
82
- const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
82
+ const sizeSet = anim ?? findSizeSetAnimation(animations, sel, selection.element);
83
83
 
84
84
  // If the element is animated (has a real tween, not just a static size
85
85
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
@@ -242,7 +242,7 @@ export async function tryGsapResizeIntercept(
242
242
  const currentAnimations = fetchFallbackAnimations
243
243
  ? await fetchFallbackAnimations()
244
244
  : (resolved?.animations ?? animations);
245
- const existingSet = findExistingPositionWrite(currentAnimations, selector);
245
+ const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
246
246
  // Delta chosen so the drag-path math composes back to exactly `corrected`
247
247
  // (no drag scratch attrs exist during a resize, so base = gsapPos).
248
248
  await commitStaticGsapPosition(
@@ -213,7 +213,7 @@ export async function tryGsapDragIntercept(
213
213
  const existingSet =
214
214
  posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
215
215
  ? posAnim
216
- : findExistingPositionWrite(resolvedAnimations, selector);
216
+ : findExistingPositionWrite(resolvedAnimations, selector, selection.element);
217
217
  await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
218
218
  commitMutation,
219
219
  fetchAnimations: fetchFallbackAnimations,
@@ -301,7 +301,8 @@ export async function tryGsapRotationIntercept(
301
301
  // rotation set in place, else add a new one. This replaces the old
302
302
  // `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
303
303
  if (!anim || isInstantHold(anim)) {
304
- const existingSet = anim ?? findRotationSetAnimation(resolvedAnimations, selector);
304
+ const existingSet =
305
+ anim ?? findRotationSetAnimation(resolvedAnimations, selector, selection.element);
305
306
  await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
306
307
  commitMutation,
307
308
  fetchAnimations: fetchFallbackAnimations,
@@ -2,17 +2,32 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
 
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
- import { idSelector } from "./gsapShared";
5
+ import { idSelector, matchesExactlyOne } from "./gsapShared";
6
6
 
7
+ /**
8
+ * The selector to author a NEW tween against, minting an id on the element when
9
+ * it has no address of its own.
10
+ *
11
+ * `selection.selector` is only usable when it addresses ONE element:
12
+ * `buildStableSelector` hands back a bare class for an id-less element, so
13
+ * returning it unconditionally aimed "add animation" at every sibling sharing
14
+ * the class (the attribution blow-up that collapsed the timeline to one row,
15
+ * see writeTargetSelector). A non-unique selector falls through to the id mint
16
+ * below, which is the stronger fix here than a structural path: the id it writes
17
+ * back to the source also makes every later lookup for this element exact.
18
+ */
7
19
  export function ensureElementAddressable(selection: DomEditSelection): {
8
20
  selector: string;
9
21
  autoId?: string;
10
22
  } {
11
23
  if (selection.id) return { selector: idSelector(selection.id) };
12
- if (selection.selector) return { selector: selection.selector };
13
24
 
14
25
  const el = selection.element;
15
26
  const doc = el.ownerDocument;
27
+ if (selection.selector && matchesExactlyOne(doc, selection.selector, el)) {
28
+ return { selector: selection.selector };
29
+ }
30
+
16
31
  const tag = el.tagName.toLowerCase();
17
32
  let id = tag;
18
33
  let n = 1;
@@ -6,6 +6,7 @@ import {
6
6
  idSelector,
7
7
  isInstantHold,
8
8
  parsePercentageKeyframes,
9
+ resolveClipTimingBasis,
9
10
  resolveEditableTweenDuration,
10
11
  toClipKeyframes,
11
12
  toClipPercentage,
@@ -169,6 +170,149 @@ describe("toClipKeyframes", () => {
169
170
  });
170
171
  });
171
172
 
173
+ describe("resolveClipTimingBasis", () => {
174
+ // Measured on v-product-promo: `captions-comp` mounts at 1.5s for 12.5s, and the
175
+ // six tweens inside it resolve to 0..12.5 — composition-local, not main-timeline
176
+ // absolute. Subtracting the host's 1.5 mount from a 0s tween cached pct -12.
177
+ const host = { id: "captions-comp", domId: "captions-comp", start: 1.5, duration: 12.5 };
178
+ const children = [{ id: "line", hostId: "captions-comp" }];
179
+
180
+ it("gives a sub-composition inner element the host window in the tween's own frame", () => {
181
+ expect(resolveClipTimingBasis("line", "captions.html", [host], children)).toEqual({
182
+ elStart: 0,
183
+ elDuration: 12.5,
184
+ });
185
+ });
186
+
187
+ it("leaves a root-composition element on the main timeline", () => {
188
+ const box = { id: "box", domId: "box", start: 3, duration: 2 };
189
+ expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({
190
+ elStart: 3,
191
+ elDuration: 2,
192
+ });
193
+ });
194
+
195
+ it("rebases an expanded sub-comp child by its host mount", () => {
196
+ // Expanded children carry host-ABSOLUTE display starts; the tweens they own are
197
+ // still composition-local, so the basis is the child's local start.
198
+ const pill = { id: "pill", domId: "pill", start: 8, duration: 4, expandedParentStart: 6 };
199
+ expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
200
+ elStart: 2,
201
+ elDuration: 4,
202
+ });
203
+ });
204
+
205
+ it("rebases by the parent composition clip when the child is not expanded", () => {
206
+ const parent = { id: "scene-comp", domId: "scene-comp", start: 5, duration: 10 };
207
+ const pill = {
208
+ id: "pill",
209
+ domId: "pill",
210
+ start: 7,
211
+ duration: 3,
212
+ parentCompositionId: "scene-comp",
213
+ };
214
+ expect(resolveClipTimingBasis("pill", "scene.html", [parent, pill], [])).toEqual({
215
+ elStart: 2,
216
+ elDuration: 3,
217
+ });
218
+ });
219
+
220
+ it("treats a child whose parent composition is missing as starting at 0", () => {
221
+ // The mount is unknowable, so the only safe frame is the child's own. The
222
+ // old `?? 0` handed back `start` unchanged, which is a main-timeline value
223
+ // masquerading as a composition-local one and caches negative percentages.
224
+ const pill = {
225
+ id: "pill",
226
+ domId: "pill",
227
+ start: 7,
228
+ duration: 3,
229
+ parentCompositionId: "not-in-elements",
230
+ };
231
+ expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
232
+ elStart: 0,
233
+ elDuration: 3,
234
+ });
235
+ });
236
+
237
+ it("keeps a main-timeline clip's own start when it names no parent", () => {
238
+ const box = { id: "box", domId: "box", start: 4, duration: 2 };
239
+ expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({
240
+ elStart: 4,
241
+ elDuration: 2,
242
+ });
243
+ });
244
+
245
+ it("falls back to a unit window when neither the element nor a host resolves", () => {
246
+ expect(resolveClipTimingBasis("ghost", "index.html", [], [])).toEqual({
247
+ elStart: 0,
248
+ elDuration: 1,
249
+ });
250
+ });
251
+ });
252
+
253
+ describe("sub-composition keyframe percentages", () => {
254
+ const host = { id: "captions-comp", domId: "captions-comp", start: 1.5, duration: 12.5 };
255
+ const children = [{ id: "line", hostId: "captions-comp" }];
256
+ const basis = () => resolveClipTimingBasis("line", "captions.html", [host], children);
257
+ const inner = (resolvedStart: number, duration?: number) =>
258
+ ({
259
+ id: `t-${resolvedStart}`,
260
+ method: "to",
261
+ targetSelector: "#line",
262
+ vars: {},
263
+ resolvedStart,
264
+ duration,
265
+ }) as unknown as GsapAnimation;
266
+ const percentages = (animation: GsapAnimation) => {
267
+ const { elStart, elDuration } = basis();
268
+ return toClipKeyframes(
269
+ [{ percentage: 0 }, { percentage: 100 }],
270
+ animation,
271
+ elStart,
272
+ elDuration,
273
+ ).map((row) => row.percentage);
274
+ };
275
+
276
+ it("puts a tween on the host's first frame at 0%, never below zero", () => {
277
+ // A clip-relative percentage can never be negative; this one cached -12.
278
+ expect(percentages(inner(0))).toEqual([0, 100]);
279
+ });
280
+
281
+ it("puts the last tween's end keyframe at 100%", () => {
282
+ expect(percentages(inner(12.1, 0.4))).toEqual([96.8, 100]);
283
+ });
284
+
285
+ it("keeps every measured tween of the fixture inside 0..100", () => {
286
+ for (const start of [0, 3.2, 3.5, 7.7, 8, 12.1]) {
287
+ for (const percentage of percentages(inner(start, 0.4))) {
288
+ expect(percentage).toBeGreaterThanOrEqual(0);
289
+ expect(percentage).toBeLessThanOrEqual(100);
290
+ }
291
+ }
292
+ });
293
+
294
+ it("keeps a root-composition tween at the head of its own clip", () => {
295
+ const box = { id: "box", domId: "box", start: 3, duration: 2 };
296
+ const { elStart, elDuration } = resolveClipTimingBasis("box", "index.html", [box], []);
297
+ const rows = toClipKeyframes([{ percentage: 0 }], inner(3, 2), elStart, elDuration);
298
+ expect(rows[0]!.percentage).toBe(0);
299
+ });
300
+
301
+ it("passes tween percentages through for a zero-length clip", () => {
302
+ expect(toClipKeyframes([{ percentage: 40 }], inner(0, 0.4), 0, 0)[0]!.percentage).toBe(40);
303
+ });
304
+
305
+ it("round-trips a clip percentage through the basis it was written with", () => {
306
+ // The drag commit converts a dropped clip-% back to a time with this basis
307
+ // (useTimelineEditCallbacks) and compares it against the tween's own
308
+ // resolvedStart, so the basis has to be in the tween's frame on both sides.
309
+ const { elStart, elDuration } = basis();
310
+ const absTime = elStart + (40 / 100) * elDuration;
311
+ expect(absTime).toBe(5);
312
+ expect(toClipPercentage(absTime, elStart, elDuration, 0)).toBe(40);
313
+ });
314
+ });
315
+
172
316
  describe("idFromSelector", () => {
173
317
  it("round-trips every shape idSelector emits", () => {
174
318
  for (const id of ["hero-word", "el_1", "01-hook-hero-word", "my.class", "1box", '1"x']) {
@@ -110,12 +110,215 @@ export function idFromSelector(selector: string | undefined | null): string | nu
110
110
  return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1");
111
111
  }
112
112
 
113
+ /** Either shape {@link idSelector} emits, anchored to the WHOLE selector. */
114
+ const WHOLE_SELECTOR_ID = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/;
115
+
116
+ /**
117
+ * The id a selector addresses **as a whole**, or null. `"#stat3 .block"` animates
118
+ * the `.block` INSIDE `#stat3`, not `#stat3`, so the unanchored leading-id match
119
+ * of {@link idFromSelector} is wrong for attribution: it files the child's
120
+ * keyframes under its ancestor. `idFromSelector` stays unanchored on purpose
121
+ * (two non-attribution callers want the leading id); attribution goes through
122
+ * here, or through the DOM (see resolveSelectorElementIds).
123
+ */
124
+ function wholeSelectorElementId(selector: string): string | null {
125
+ const trimmed = selector.trim();
126
+ return WHOLE_SELECTOR_ID.test(trimmed) ? idFromSelector(trimmed) : null;
127
+ }
128
+
129
+ /**
130
+ * Resolve a tween's target selector to the ids of the element(s) it animates.
131
+ * A whole-selector `#id` resolves directly; anything else (a class like `.dot`,
132
+ * a group `.a, .b`, or a descendant selector) is matched against the live
133
+ * preview DOM so class/selector tweens (e.g. `gsap.from(".dot", {stagger})`)
134
+ * attribute to every element they animate — not just one parsed from the string.
135
+ * With no DOM, only whole-selector ids resolve: a descendant selector has no
136
+ * answer that isn't a guess at its ancestor.
137
+ */
138
+ export function resolveSelectorElementIds(
139
+ selector: string,
140
+ doc: Document | null | undefined,
141
+ ): string[] {
142
+ const bareId = wholeSelectorElementId(selector);
143
+ if (bareId) return [bareId];
144
+ const ids = new Set<string>();
145
+ for (const part of selector.split(",")) {
146
+ const sel = part.trim();
147
+ if (!sel) continue;
148
+ if (!doc) {
149
+ const whole = wholeSelectorElementId(sel);
150
+ if (whole) ids.add(whole);
151
+ continue;
152
+ }
153
+ try {
154
+ for (const el of Array.from(doc.querySelectorAll(sel))) {
155
+ if (el.id) ids.add(el.id);
156
+ }
157
+ } catch {
158
+ // An unsupported/invalid selector never reached the DOM, so the leading id
159
+ // is the best available answer (`[id="01-hook"]:has(>*)` still names it).
160
+ const lead = idFromSelector(sel);
161
+ if (lead) ids.add(lead);
162
+ }
163
+ }
164
+ return Array.from(ids);
165
+ }
166
+
167
+ /**
168
+ * The clip start in the frame the element's OWN tweens are measured in. An
169
+ * expanded sub-composition child sits on the master timeline at a host-absolute
170
+ * `start`, but its tweens are parsed from its own source file and are local to
171
+ * it, so the two must be brought into one frame before any clip-% math — or
172
+ * every keyframe rebases to a percentage far outside the clip.
173
+ */
174
+ export function clipTimingStart(element: { start: number; expandedParentStart?: number }): number {
175
+ return element.start - (element.expandedParentStart ?? 0);
176
+ }
177
+
113
178
  export function selectorFromSelection(selection: DomEditSelection): string | null {
114
179
  if (selection.id) return idSelector(selection.id);
115
180
  if (selection.selector) return selection.selector;
116
181
  return null;
117
182
  }
118
183
 
184
+ /** `[name="value"]`, with the quote/backslash escaping a CSS string needs. */
185
+ function attributeSelector(name: string, value: string): string {
186
+ return `[${name}="${value.replace(/(["\\])/g, "\\$1")}"]`;
187
+ }
188
+
189
+ /**
190
+ * Whether `selector` addresses `element` AND NOTHING ELSE. The single test for
191
+ * "this string is safe to author a new tween against": a selector that also
192
+ * hits siblings writes a tween that animates all of them.
193
+ */
194
+ export function matchesExactlyOne(doc: Document, selector: string, element: Element): boolean {
195
+ try {
196
+ const matches = doc.querySelectorAll(selector);
197
+ return matches.length === 1 && matches[0] === element;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * A structural address for an element that carries no identity of its own:
205
+ * `:nth-child` steps up to the nearest ancestor that IS uniquely addressable
206
+ * (an id or a data-hf-id). This is the `selector` + `selectorIndex` pair the
207
+ * selection already carries, resolved through the live DOM the index was
208
+ * counted in — an index can't be spelled in CSS, but the element's position can.
209
+ */
210
+ function structuralSelector(element: Element): string | null {
211
+ const doc = element.ownerDocument;
212
+ if (!doc) return null;
213
+ const parts: string[] = [];
214
+ for (let node: Element | null = element; node; node = node.parentElement) {
215
+ if (node !== element) {
216
+ const id = node instanceof HTMLElement ? node.id : "";
217
+ const hfId = node.getAttribute("data-hf-id");
218
+ if (id) {
219
+ parts.unshift(idSelector(id));
220
+ break;
221
+ }
222
+ if (hfId) {
223
+ parts.unshift(attributeSelector("data-hf-id", hfId));
224
+ break;
225
+ }
226
+ }
227
+ const parent = node.parentElement;
228
+ if (!parent) break;
229
+ const index = [...parent.children].indexOf(node) + 1;
230
+ if (index < 1) return null;
231
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
232
+ }
233
+ if (parts.length === 0) return null;
234
+ const selector = parts.join(" > ");
235
+ return matchesExactlyOne(doc, selector, element) ? selector : null;
236
+ }
237
+
238
+ /**
239
+ * The selector to author a NEW tween with. Distinct from
240
+ * {@link selectorFromSelection}, which must keep returning the exact string an
241
+ * already-authored tween is string-matched against (findTweenAtTime): this one
242
+ * has to ADDRESS ONE ELEMENT.
243
+ *
244
+ * `buildStableSelector` hands back a bare class for any element without an id,
245
+ * so "add keyframe at playhead" on one of five `.group` siblings wrote
246
+ * `tl.set(".group", …)` — a tween that animates all five and that
247
+ * {@link resolveSelectorElementIds} reads back as all five, collapsing their
248
+ * timeline rows into one. Every rung below resolves to exactly one element.
249
+ *
250
+ * Null means "no string here addresses one element". With a live DOM to check
251
+ * against, a failed structural walk (detached between select and commit, a
252
+ * shadow-root boundary, a chain that no longer re-resolves) IS that evidence,
253
+ * so returning the bare selector anyway would hand back the exact input this
254
+ * function exists to replace. Every caller treats the null as "do not author
255
+ * this tween": falling back to the selection's own selector would write the
256
+ * group-collapsing target this function exists to prevent, and a gesture that
257
+ * does not persist reverts visibly on the next reload, where a tween silently
258
+ * aimed at five elements does not. The bare selector comes back only with no DOM
259
+ * to disambiguate against, where refusing would be guessing rather than knowing.
260
+ */
261
+ export function writeTargetSelector(selection: DomEditSelection): string | null {
262
+ if (selection.id) return idSelector(selection.id);
263
+ if (selection.hfId) return attributeSelector("data-hf-id", selection.hfId);
264
+ const element = selection.element;
265
+ const doc = element?.ownerDocument;
266
+ if (element && doc) {
267
+ if (selection.selector && matchesExactlyOne(doc, selection.selector, element)) {
268
+ return selection.selector;
269
+ }
270
+ return structuralSelector(element);
271
+ }
272
+ return selection.selector ?? null;
273
+ }
274
+
275
+ /**
276
+ * The selector a `replace-with-keyframes` mutation must re-author an EXISTING
277
+ * tween against. The server deletes the tween and adds it back, so this string
278
+ * REWRITES its target: deriving it from the selection instead discards whatever
279
+ * the author aimed at, and silently widens a tween {@link writeTargetSelector}
280
+ * had already narrowed to one element back onto every class sibling.
281
+ *
282
+ * The selection is the fallback only for a target the parser could not resolve
283
+ * statically, where there is no authored string to preserve.
284
+ */
285
+ export function existingTweenTargetSelector(
286
+ animation: Pick<GsapAnimation, "targetSelector" | "hasUnresolvedSelector">,
287
+ selection: DomEditSelection,
288
+ ): string | null {
289
+ if (animation.targetSelector && !animation.hasUnresolvedSelector) {
290
+ return animation.targetSelector;
291
+ }
292
+ return selectorFromSelection(selection);
293
+ }
294
+
295
+ /**
296
+ * The read half of {@link writeTargetSelector}: does an already-authored tween
297
+ * write THIS element?
298
+ *
299
+ * String equality against `selectorFromSelection` alone is not enough once new
300
+ * tweens are authored with a narrowed one-element selector: the next edit would
301
+ * miss the write it just made and append a second, conflicting one. Falling back
302
+ * to the live DOM keeps the pair consistent.
303
+ *
304
+ * That fallback is `matchesExactlyOne`, not a bare `element.matches`. A target
305
+ * the element merely shares with its siblings is a GROUP tween, and the callers
306
+ * here MUTATE what they find: an individual nudge on one of five `.group`
307
+ * siblings would rewrite the group's own tween and move all five. Only the
308
+ * selection that IS the group (string equality above, where the author selected
309
+ * `.group` itself) may edit it; every other element authors its own write.
310
+ */
311
+ export function tweenTargetsElement(
312
+ targetSelector: string,
313
+ selector: string,
314
+ element: Element | null | undefined,
315
+ ): boolean {
316
+ if (targetSelector === selector) return true;
317
+ const doc = element?.ownerDocument;
318
+ if (!element || !doc) return false;
319
+ return matchesExactlyOne(doc, targetSelector, element);
320
+ }
321
+
119
322
  // ── Percentage computation ────────────────────────────────────────────────────
120
323
 
121
324
  /**
@@ -259,6 +462,67 @@ export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: n
259
462
  return tweenPos + (percentage / 100) * tweenDur;
260
463
  }
261
464
 
465
+ /**
466
+ * Timing basis for an element's keyframes, expressed in the TWEEN's own time
467
+ * frame. Sub-composition internals (e.g. pills inside a scene) aren't timeline
468
+ * clips themselves — they're derived at expand time — so they're absent from
469
+ * `elements`. Without a basis, elDuration defaulted to 1 and clip-relative
470
+ * keyframe percentages blew past 100% (rendering off the clip). Fall back to the
471
+ * sub-comp HOST's bounds, resolved via domClipChildren (the host's
472
+ * data-composition-src is stripped in the rendered DOM, so we can't query it).
473
+ *
474
+ * `elStart` is the clip's start in the frame the tween's own times are measured
475
+ * in. A sub-composition tween's resolvedStart is composition-local while a
476
+ * timeline element's start is main-timeline absolute, so passing the raw element
477
+ * start subtracted two different frames from each other: a host mounted at 1.5s
478
+ * cached its 0s tween at -12%, and a clip-relative percentage can never be
479
+ * negative. The composition's mount is `expandedParentStart` for an expanded
480
+ * child, the parent composition clip's start otherwise, and 0 for a
481
+ * root-composition element, whose start already IS the tween frame.
482
+ */
483
+ export function resolveClipTimingBasis(
484
+ elementId: string,
485
+ sourceFile: string,
486
+ elements: ReadonlyArray<{
487
+ domId?: string;
488
+ key?: string;
489
+ id: string;
490
+ start: number;
491
+ duration: number;
492
+ expandedParentStart?: number;
493
+ parentCompositionId?: string | null;
494
+ }>,
495
+ domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
496
+ ): { elStart: number; elDuration: number } {
497
+ const direct = elements.find(
498
+ (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
499
+ );
500
+ if (direct) {
501
+ const parentId = direct.parentCompositionId;
502
+ const parent = parentId
503
+ ? elements.find((el) => el.domId === parentId || el.id === parentId)
504
+ : undefined;
505
+ const mount = direct.expandedParentStart ?? parent?.start;
506
+ if (mount !== undefined) return { elStart: direct.start - mount, elDuration: direct.duration };
507
+ // No parent composition named, so this IS a main-timeline clip and its own
508
+ // start is already the basis.
509
+ if (!parentId) return { elStart: direct.start, elDuration: direct.duration };
510
+ // It named a parent we cannot find, so the mount is unknowable. Its tweens
511
+ // are still composition-local, so treat its own window as the frame rather
512
+ // than subtracting nothing and handing back a main-timeline start, which is
513
+ // exactly the mixed-frame subtraction this function exists to prevent.
514
+ return { elStart: 0, elDuration: direct.duration };
515
+ }
516
+ const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
517
+ const host = hostId
518
+ ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
519
+ : undefined;
520
+ // The inner element is not a clip of its own: the host's window IS the frame
521
+ // its tweens are timed in, so the start in that frame is 0, not the host's
522
+ // main-timeline mount.
523
+ return { elStart: 0, elDuration: host?.duration ?? 1 };
524
+ }
525
+
262
526
  /**
263
527
  * An absolute time as a percentage of a timeline clip, at the one precision every
264
528
  * keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered