@hyperframes/studio 0.8.46 → 0.8.48

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 (112) hide show
  1. package/dist/assets/hyperframes-player-CtVX6jL_.js +464 -0
  2. package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
  3. package/dist/assets/index-B9LfWNyM.css +1 -0
  4. package/dist/assets/index-C9gJKpqQ.js +441 -0
  5. package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +83 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1118 -661
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/manualEditingAvailability.test.ts +19 -0
  19. package/src/components/editor/manualEditingAvailability.ts +9 -5
  20. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  21. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  23. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  24. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  25. package/src/components/editor/useVolumeAutomation.ts +23 -2
  26. package/src/components/panels/VariablesPanel.tsx +5 -8
  27. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  29. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  30. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  31. package/src/contexts/FileManagerContext.tsx +0 -3
  32. package/src/hooks/appHotkeysDispatch.ts +256 -0
  33. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  34. package/src/hooks/domEditDeleteMembers.ts +19 -0
  35. package/src/hooks/useActiveComposition.ts +69 -0
  36. package/src/hooks/useAppHotkeys.test.ts +23 -1
  37. package/src/hooks/useAppHotkeys.ts +6 -239
  38. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  39. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  40. package/src/hooks/useDomEditCommits.ts +13 -15
  41. package/src/hooks/useDomEditSession.test.tsx +90 -1
  42. package/src/hooks/useDomEditSession.ts +31 -34
  43. package/src/hooks/useDomSelectionTypes.ts +7 -2
  44. package/src/hooks/useElementLifecycleOps.ts +0 -8
  45. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  46. package/src/hooks/useFileManager.ts +0 -2
  47. package/src/hooks/useFileTree.test.tsx +214 -0
  48. package/src/hooks/useFileTree.ts +66 -24
  49. package/src/hooks/useGroupCommits.test.ts +98 -0
  50. package/src/hooks/useGroupCommits.ts +66 -9
  51. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  52. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  53. package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
  54. package/src/hooks/useSdkSession.ts +21 -3
  55. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  56. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  57. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  58. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  59. package/src/hooks/useTimelineEditing.ts +1 -0
  60. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  61. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  62. package/src/index.ts +10 -0
  63. package/src/player/components/Timeline.test.ts +0 -4
  64. package/src/player/components/Timeline.tsx +0 -1
  65. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  66. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  67. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  68. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  69. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  70. package/src/player/components/automationLaneData.test.ts +11 -3
  71. package/src/player/components/automationLaneData.ts +5 -4
  72. package/src/player/components/automationLaneGeometry.ts +15 -0
  73. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  74. package/src/player/components/timelineDragDrop.ts +9 -25
  75. package/src/player/components/timelineGapCommit.test.ts +96 -0
  76. package/src/player/components/timelineGapCommit.ts +56 -11
  77. package/src/player/components/timelineGaps.ts +2 -2
  78. package/src/player/components/timelineLayout.test.ts +0 -1
  79. package/src/player/components/timelineLayout.ts +1 -6
  80. package/src/player/components/timelineZones.test.ts +20 -1
  81. package/src/player/components/timelineZones.ts +10 -3
  82. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  83. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  84. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  85. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  86. package/src/player/hooks/useTimelinePlayer.ts +9 -4
  87. package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
  88. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  89. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  90. package/src/player/store/playerStore.test.ts +42 -0
  91. package/src/player/store/playerStore.ts +25 -28
  92. package/src/player/store/readinessSlice.ts +42 -0
  93. package/src/utils/razorSplitTransaction.test.ts +10 -0
  94. package/src/utils/razorSplitTransaction.ts +5 -0
  95. package/src/utils/sdkCutover.gate.test.ts +5 -4
  96. package/src/utils/sdkCutover.test.ts +93 -0
  97. package/src/utils/sdkCutover.ts +46 -25
  98. package/src/utils/sdkCutoverEligibility.ts +21 -0
  99. package/src/utils/sdkCutoverPolicy.test.ts +11 -2
  100. package/src/utils/sdkCutoverPolicy.ts +5 -3
  101. package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
  102. package/src/utils/sdkEditTransaction.ts +22 -1
  103. package/src/utils/sdkResolverShadow.ts +10 -1
  104. package/src/utils/studioHelpers.test.ts +18 -0
  105. package/src/utils/studioHelpers.ts +41 -5
  106. package/src/utils/studioUiPreferences.test.ts +14 -0
  107. package/src/utils/studioUiPreferences.ts +6 -0
  108. package/src/utils/studioUrlState.ts +15 -7
  109. package/src/utils/timelineInspector.ts +10 -0
  110. package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
  111. package/dist/assets/index-DCblmTX3.js +0 -441
  112. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -5,7 +5,11 @@ import * as studioAvailability from "../components/editor/manualEditingAvailabil
5
5
  import { trackStudioEvent } from "./studioTelemetry";
6
6
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
7
7
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
8
- import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility";
8
+ import {
9
+ isResolverDisagreement,
10
+ shouldDeclineTextCutoverForTarget,
11
+ shouldUseSdkCutover,
12
+ } from "./sdkCutoverEligibility";
9
13
  import {
10
14
  asCutoverError,
11
15
  declinedCutover,
@@ -78,15 +82,17 @@ export async function sdkCutoverPersist(
78
82
  options?: CutoverOptions,
79
83
  ): Promise<CutoverResult> {
80
84
  if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
81
- return declinedCutover("ineligible_operation");
82
- if (!sdkSession) return declinedCutover("session_unavailable");
85
+ return declinedCutover("ineligible_operation", "dom");
86
+ if (!sdkSession) return declinedCutover("session_unavailable", "dom");
83
87
  const hfId = selection.hfId;
84
- if (!hfId) return declinedCutover("target_unaddressable");
88
+ if (!hfId) return declinedCutover("target_unaddressable", "dom");
85
89
  const target = sdkSession.getElement(hfId);
86
- if (!target) return declinedCutover("target_not_found");
90
+ if (!target)
91
+ return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
87
92
  if (shouldDeclineTextCutoverForTarget(target, ops))
88
- return declinedCutover("unsupported_text_target");
89
- if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
93
+ return declinedCutover("unsupported_text_target", "dom");
94
+ if (wrongCompositionFile(deps, targetPath))
95
+ return declinedCutover("wrong_composition_file", "dom");
90
96
  const result = await persistSdkCandidateMutation(
91
97
  sdkSession,
92
98
  targetPath,
@@ -122,10 +128,12 @@ export async function sdkTimingPersist(
122
128
  // Dark-launch gate: without this, timing cutover runs whenever an SDK session
123
129
  // exists (it always does, for shadow/selection) — flipping the flag OFF would
124
130
  // NOT disable it. Gate here so flag-off routes back to the legacy server path.
125
- if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
126
- if (!sdkSession) return declinedCutover("session_unavailable");
127
- if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
128
- if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
131
+ if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing");
132
+ if (!sdkSession) return declinedCutover("session_unavailable", "timing");
133
+ if (!sdkSession.getElement(hfId))
134
+ return declinedCutover("target_not_found", "timing", isResolverDisagreement(sdkSession, hfId));
135
+ if (wrongCompositionFile(deps, targetPath))
136
+ return declinedCutover("wrong_composition_file", "timing");
129
137
  try {
130
138
  const serializedBefore = sdkSession.serialize();
131
139
  const result = await persistSdkCandidateMutation(
@@ -166,11 +174,17 @@ export async function sdkTimingBatchPersist(
166
174
  { targetPath, compositionPath: deps.compositionPath },
167
175
  );
168
176
  }
169
- if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled");
170
- if (!sdkSession) return declinedCutover("session_unavailable");
171
- if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
172
- if (changes.some((change) => !sdkSession.getElement(change.hfId)))
173
- return declinedCutover("target_not_found");
177
+ if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing");
178
+ if (!sdkSession) return declinedCutover("session_unavailable", "timing");
179
+ if (wrongCompositionFile(deps, targetPath))
180
+ return declinedCutover("wrong_composition_file", "timing");
181
+ const unresolved = changes.find((change) => !sdkSession.getElement(change.hfId));
182
+ if (unresolved)
183
+ return declinedCutover(
184
+ "target_not_found",
185
+ "timing",
186
+ isResolverDisagreement(sdkSession, unresolved.hfId),
187
+ );
174
188
  try {
175
189
  const serializedBefore = sdkSession.serialize();
176
190
  const result = await persistSdkCandidateMutation(
@@ -244,9 +258,9 @@ export function sdkGsapTweenPersist(
244
258
  // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
245
259
  // matches the other three chokepoints' discipline.
246
260
  if (!sdkFamilyEnabled("gsap-animation"))
247
- return Promise.resolve(declinedCutover("feature_disabled"));
261
+ return Promise.resolve(declinedCutover("feature_disabled", "gsap-animation"));
248
262
  if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target))
249
- return Promise.resolve(declinedCutover("target_not_found"));
263
+ return Promise.resolve(declinedCutover("target_not_found", "gsap-animation"));
250
264
  // dispatchGsapOpAndPersist declines on before===after — that catches stale
251
265
  // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling
252
266
  // back to the server path. This subsumes explicit existence guards for set/remove.
@@ -285,9 +299,10 @@ async function dispatchGsapOpAndPersist(
285
299
  }
286
300
  // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
287
301
  // flag OFF → explicit decline → caller falls back to the legacy server path.
288
- if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled");
289
- if (!sdkSession) return declinedCutover("session_unavailable");
290
- if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
302
+ if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled", family);
303
+ if (!sdkSession) return declinedCutover("session_unavailable", family);
304
+ if (wrongCompositionFile(deps, targetPath))
305
+ return declinedCutover("wrong_composition_file", family);
291
306
  const session = sdkSession;
292
307
  // persistSdkCandidateMutation owns the shared per-project/file transaction
293
308
  // coordinator used by both SDK and legacy GSAP writes.
@@ -559,10 +574,16 @@ export async function sdkDeletePersist(
559
574
  { targetPath, compositionPath: deps.compositionPath },
560
575
  );
561
576
  // Dark-launch gate: flag OFF → legacy server delete path.
562
- if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled");
563
- if (!sdkSession) return declinedCutover("session_unavailable");
564
- if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found");
565
- if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file");
577
+ if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled", "lifecycle");
578
+ if (!sdkSession) return declinedCutover("session_unavailable", "lifecycle");
579
+ if (!sdkSession.getElement(hfId))
580
+ return declinedCutover(
581
+ "target_not_found",
582
+ "lifecycle",
583
+ isResolverDisagreement(sdkSession, hfId),
584
+ );
585
+ if (wrongCompositionFile(deps, targetPath))
586
+ return declinedCutover("wrong_composition_file", "lifecycle");
566
587
  const result = await persistSdkCandidateMutation(
567
588
  sdkSession,
568
589
  targetPath,
@@ -4,8 +4,10 @@
4
4
  * sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
5
5
  * block has no dependency on the persist/dispatch functions there.
6
6
  */
7
+ import type { Composition } from "@hyperframes/sdk";
7
8
  import type { PatchOperation } from "./sourcePatcher";
8
9
  import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
10
+ import { resolveSnapshot } from "./sdkResolverShadow";
9
11
 
10
12
  const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
11
13
  "inline-style",
@@ -102,6 +104,25 @@ export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOpe
102
104
  return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
103
105
  }
104
106
 
107
+ /**
108
+ * On a `target_not_found`, decide whether the miss is the known benign class
109
+ * (node genuinely absent from the session — nothing dispatch could have done)
110
+ * or a resolver disagreement: the shadow's broader resolver finds it, so
111
+ * dispatch could have made the edit and `getElement` refused it. The shadow
112
+ * event stays silent in that case, so this flag is the only signal for it.
113
+ *
114
+ * Diagnostic context only — it must never turn a clean decline into a throw, so
115
+ * a session shape without `getElements` reports "no disagreement" and the edit
116
+ * falls back exactly as it otherwise would have.
117
+ */
118
+ export function isResolverDisagreement(session: Composition, hfId: string): boolean {
119
+ try {
120
+ return resolveSnapshot(session, hfId) !== null;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
105
126
  export function shouldUseSdkCutover(
106
127
  flagEnabled: boolean,
107
128
  hasSession: boolean,
@@ -7,8 +7,17 @@ import {
7
7
  } from "./sdkCutoverPolicy";
8
8
 
9
9
  describe("Studio SDK operation-family policy", () => {
10
- it("enables no family from the master switch alone", () => {
11
- expect(resolveEnabledSdkFamilies({}, true).size).toBe(0);
10
+ it("enables every family when the master switch is on and no family list is set", () => {
11
+ const all = new Set(STUDIO_SDK_OPERATION_FAMILIES);
12
+ expect(resolveEnabledSdkFamilies({}, true)).toEqual(all);
13
+ expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "" }, true)).toEqual(all);
14
+ expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: " " }, true)).toEqual(
15
+ all,
16
+ );
17
+ });
18
+
19
+ it("enables no family when the master switch is off, regardless of the family list", () => {
20
+ expect(resolveEnabledSdkFamilies({}, false).size).toBe(0);
12
21
  expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom" }, false).size).toBe(
13
22
  0,
14
23
  );
@@ -54,7 +54,8 @@ export function resolveEnabledSdkFamilies(
54
54
  ): ReadonlySet<StudioSdkOperationFamily> {
55
55
  if (!masterEnabled) return new Set();
56
56
  const raw = env["VITE_STUDIO_SDK_CUTOVER_FAMILIES"];
57
- if (typeof raw !== "string" || raw.trim() === "") return new Set();
57
+ // Unset/blank = every family. The list exists to RESTRICT (kill switch per family).
58
+ if (typeof raw !== "string" || raw.trim() === "") return new Set(STUDIO_SDK_OPERATION_FAMILIES);
58
59
  const requested = raw
59
60
  .split(",")
60
61
  .map((family) => family.trim())
@@ -85,8 +86,9 @@ export function renderStudioSdkCutoverReport(): string {
85
86
  return [
86
87
  "# Studio SDK cutover report",
87
88
  "",
88
- "Master flag: `VITE_STUDIO_SDK_CUTOVER_ENABLED=true`",
89
- "Family flag: `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing,...`",
89
+ "Default: SDK persist ON for every family. Kill switches:",
90
+ " `VITE_STUDIO_SDK_CUTOVER_ENABLED=false` (all families → server path)",
91
+ " `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` (only listed families → SDK; rest → server)",
90
92
  "",
91
93
  "A family graduates only after zero unexplained resolver/serialization divergences over its agreed corpus and soak window.",
92
94
  "",
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
4
+
5
+ import { trackStudioEvent } from "./studioTelemetry";
6
+ import { declinedCutover } from "./sdkEditTransaction";
7
+
8
+ describe("declinedCutover telemetry", () => {
9
+ it("emits sdk_cutover_declined with reason and family, and returns the declined result", () => {
10
+ const result = declinedCutover("target_not_found", "timing");
11
+ expect(result).toEqual({ status: "declined", reason: "target_not_found" });
12
+ expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", {
13
+ reason: "target_not_found",
14
+ family: "timing",
15
+ });
16
+ });
17
+
18
+ it("emits family:null when the decline happens before a family is known", () => {
19
+ declinedCutover("no_change");
20
+ expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", {
21
+ reason: "no_change",
22
+ family: null,
23
+ });
24
+ });
25
+
26
+ it("omits resolverDisagreement when it is not set, and carries it when it is", () => {
27
+ declinedCutover("target_not_found", "timing");
28
+ expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
29
+ reason: "target_not_found",
30
+ family: "timing",
31
+ });
32
+
33
+ declinedCutover("target_not_found", "timing", true);
34
+ expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
35
+ reason: "target_not_found",
36
+ family: "timing",
37
+ resolverDisagreement: true,
38
+ });
39
+
40
+ declinedCutover("target_not_found", "timing", false);
41
+ expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", {
42
+ reason: "target_not_found",
43
+ family: "timing",
44
+ });
45
+ });
46
+ });
@@ -3,6 +3,7 @@ import type { EditHistoryKind } from "./editHistory";
3
3
  import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
4
4
  import { trackStudioEvent } from "./studioTelemetry";
5
5
  import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
6
+ import type { StudioSdkOperationFamily } from "./sdkCutoverPolicy";
6
7
 
7
8
  export type CutoverResult =
8
9
  | { status: "declined"; reason: string }
@@ -66,7 +67,27 @@ interface CandidateEdit {
66
67
  after: string;
67
68
  }
68
69
 
69
- export function declinedCutover(reason: string): CutoverResult {
70
+ /**
71
+ * Explicit fall-back to the legacy server path. Emits `sdk_cutover_declined` so
72
+ * post-flip we can distinguish "SDK took the edit" from "SDK bowed out"; payload
73
+ * is reason + family only (no hfId / path / content).
74
+ *
75
+ * `resolverDisagreement` is the one extra field with diagnostic value beyond the
76
+ * reason itself: on a `target_not_found`, the shadow's `resolveSnapshot` (what
77
+ * dispatch resolves) found the element that `getElement` could not. The shadow
78
+ * event stays silent in that case, so without this flag the case is invisible —
79
+ * and it is an adoption loss (a dispatchable edit refused), not a missing node.
80
+ */
81
+ export function declinedCutover(
82
+ reason: string,
83
+ family?: StudioSdkOperationFamily,
84
+ resolverDisagreement?: boolean,
85
+ ): CutoverResult {
86
+ trackStudioEvent("sdk_cutover_declined", {
87
+ reason,
88
+ family: family ?? null,
89
+ ...(resolverDisagreement ? { resolverDisagreement: true } : {}),
90
+ });
70
91
  return { status: "declined", reason };
71
92
  }
72
93
 
@@ -96,7 +96,16 @@ function countHfIdInSource(source: string, id: string): number {
96
96
  );
97
97
  }
98
98
 
99
- function resolveSnapshot(session: Composition, id: string): FlatEl | null {
99
+ /**
100
+ * Resolvability the SDK DISPATCH path actually has (mirrors `resolveScoped`):
101
+ * exact scoped-path match, then canonical bare match, then first bare match.
102
+ *
103
+ * Exported so the cutover path can distinguish its two `target_not_found` cases:
104
+ * `Composition.getElement` is narrower (canonical-only for a bare id) than what
105
+ * `dispatch` resolves, so an element can be genuinely dispatchable yet still
106
+ * fail `getElement` — a resolver disagreement worth fixing, not a missing node.
107
+ */
108
+ export function resolveSnapshot(session: Composition, id: string): FlatEl | null {
100
109
  const els = session.getElements();
101
110
  const exact = els.find((el) => el.scopedId === id);
102
111
  if (exact) return exact;
@@ -5,6 +5,7 @@ import {
5
5
  findMatchingTimelineElementId,
6
6
  findTimelineIdByAncestor,
7
7
  resolveDroppedAssetDimensions,
8
+ resolveElementTrack,
8
9
  resolveTimelineIdForSelection,
9
10
  resolveTimelineSelectionSeekTime,
10
11
  } from "./studioHelpers";
@@ -42,6 +43,13 @@ describe("findMatchingTimelineElementId", () => {
42
43
  expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
43
44
  });
44
45
 
46
+ it("matches by hfId when the selection has no domId, so an element with no authored id can still be found", () => {
47
+ const els = [el({ id: "hf-1", domId: undefined, hfId: "hf-1", sourceFile: "index.html" })];
48
+ expect(
49
+ findMatchingTimelineElementId({ id: undefined, hfId: "hf-1", sourceFile: "index.html" }, els),
50
+ ).toBe("hf-1");
51
+ });
52
+
45
53
  it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
46
54
  const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
47
55
  expect(
@@ -199,3 +207,13 @@ describe("resolveDroppedAssetDimensions", () => {
199
207
  expect(load).toHaveBeenCalledOnce();
200
208
  });
201
209
  });
210
+
211
+ describe("resolveElementTrack", () => {
212
+ it("rounds an authored track", () => {
213
+ expect(resolveElementTrack({ authoredTrack: 2.4, track: 0 })).toBe(2);
214
+ });
215
+
216
+ it("falls back to the resolved track, rounded, when nothing was authored", () => {
217
+ expect(resolveElementTrack({ authoredTrack: undefined, track: 3.6 })).toBe(4);
218
+ });
219
+ });
@@ -126,9 +126,15 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
126
126
  return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
127
127
  }
128
128
 
129
- type ElementMatchSelection = Pick<
129
+ export type ElementMatchSelection = Pick<
130
130
  DomEditSelection,
131
- "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
131
+ | "id"
132
+ | "hfId"
133
+ | "selector"
134
+ | "selectorIndex"
135
+ | "sourceFile"
136
+ | "compositionSrc"
137
+ | "isCompositionHost"
132
138
  >;
133
139
 
134
140
  function matchesByDomId(
@@ -142,6 +148,17 @@ function matchesByDomId(
142
148
  );
143
149
  }
144
150
 
151
+ function matchesByHfId(
152
+ selection: ElementMatchSelection,
153
+ element: TimelineElement,
154
+ selectionSourceFile: string,
155
+ ): boolean {
156
+ if (!selection.hfId) return false;
157
+ return (
158
+ element.hfId === selection.hfId && (element.sourceFile || "index.html") === selectionSourceFile
159
+ );
160
+ }
161
+
145
162
  function matchesByCompositionHost(
146
163
  selection: ElementMatchSelection,
147
164
  element: TimelineElement,
@@ -172,14 +189,22 @@ export function findMatchingTimelineElementId(
172
189
  // scan let `.find()` stop at an EARLIER, unrelated host that merely shares
173
190
  // the compositionSrc, before the scan ever reached the correct id/selector
174
191
  // match further down the list — collapsing every repeated host to the
175
- // first one. Try id, then selector, across the WHOLE list first; only fall
176
- // back to the coarser compositionSrc-only match when neither identifies a
177
- // specific element.
192
+ // first one. Try id, then hfId, then selector, across the WHOLE list
193
+ // first; only fall back to the coarser compositionSrc-only match when
194
+ // none of them identifies a specific element.
178
195
  const byId = selection.id
179
196
  ? elements.find((el) => matchesByDomId(selection, el, selectionSourceFile))
180
197
  : undefined;
181
198
  if (byId) return byId.key ?? byId.id;
182
199
 
200
+ // hfId is the stable content-hash id every element gets regardless of
201
+ // whether it has a real DOM id — the only correlator for an element like
202
+ // an ungroup child that has neither an authored id nor a selector.
203
+ const byHfId = selection.hfId
204
+ ? elements.find((el) => matchesByHfId(selection, el, selectionSourceFile))
205
+ : undefined;
206
+ if (byHfId) return byHfId.key ?? byHfId.id;
207
+
183
208
  const bySelector = selection.selector
184
209
  ? elements.find((el) => matchesBySelector(selection, el))
185
210
  : undefined;
@@ -198,6 +223,17 @@ export function findMatchingTimelineElementId(
198
223
  return null;
199
224
  }
200
225
 
226
+ // The element's track: authored if given, else the runtime's already-resolved
227
+ // fallback — always rounded to an integer index either way. Shared by the
228
+ // group/ungroup and razor-split flows so they can't drift out of sync again.
229
+ export function resolveElementTrack(
230
+ element: Pick<TimelineElement, "authoredTrack" | "track">,
231
+ ): number {
232
+ return Math.round(
233
+ Number.isFinite(element.authoredTrack) ? (element.authoredTrack as number) : element.track,
234
+ );
235
+ }
236
+
201
237
  /**
202
238
  * A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
203
239
  * inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
@@ -73,6 +73,20 @@ describe("timelineSnapEnabled preference", () => {
73
73
  });
74
74
  });
75
75
 
76
+ describe("rippleEditEnabled preference", () => {
77
+ it("round-trips through storage", () => {
78
+ const storage = createStorage();
79
+ writeStudioUiPreferences({ rippleEditEnabled: false }, storage);
80
+ expect(readStudioUiPreferences(storage).rippleEditEnabled).toBe(false);
81
+ });
82
+
83
+ it("ignores non-boolean values", () => {
84
+ const storage = createStorage();
85
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ rippleEditEnabled: "yes" }));
86
+ expect(readStudioUiPreferences(storage).rippleEditEnabled).toBeUndefined();
87
+ });
88
+ });
89
+
76
90
  describe("thumbnailMode preference", () => {
77
91
  it("round-trips the adaptive mode", () => {
78
92
  const storage = createStorage();
@@ -24,6 +24,9 @@ export interface StudioUiPreferences {
24
24
  snapToGrid?: boolean;
25
25
  /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
26
26
  timelineSnapEnabled?: boolean;
27
+ /** Keeps the main track gapless: deleting a clip closes the gap. Distinct
28
+ * from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
29
+ rippleEditEnabled?: boolean;
27
30
  /** Transport + ruler readout mode: timecode or frame number. */
28
31
  timeDisplayMode?: TimelineTimeDisplayMode;
29
32
  /**
@@ -136,6 +139,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
136
139
  if (typeof parsed.timelineSnapEnabled === "boolean") {
137
140
  preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
138
141
  }
142
+ if (typeof parsed.rippleEditEnabled === "boolean") {
143
+ preferences.rippleEditEnabled = parsed.rippleEditEnabled;
144
+ }
139
145
  if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
140
146
  preferences.timeDisplayMode = parsed.timeDisplayMode;
141
147
  }
@@ -31,16 +31,24 @@ export interface StudioUrlState {
31
31
  const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
32
32
 
33
33
  /**
34
- * The composition a schema-level panel (Variables / Slideshow) targets on the
35
- * master view, where there is no explicit `activeCompPath`. Prefer the
36
- * `index.html` convention, but fall back to the first `.html` in the file tree
34
+ * The composition auto-open and a schema-level panel (Variables / Slideshow)
35
+ * target on the master view, where there is no explicit `activeCompPath`.
36
+ * Prefer the `index.html` convention, but fall back to the first entry
37
37
  * (composition-browser order) so projects whose entry file is `card.html`,
38
38
  * `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
39
- * Returns null when the project carries no composition file at all.
39
+ * Takes the server's filtered composition list, never the raw file tree —
40
+ * a non-composition `.html` (a vendored preset, one with no
41
+ * `data-composition-id`) must never become the target. Returns null when the
42
+ * project carries no composition file at all.
40
43
  */
41
- export function resolveMasterCompositionPath(fileTree: string[]): string | null {
42
- if (fileTree.includes("index.html")) return "index.html";
43
- return fileTree.find((p) => p.endsWith(".html")) ?? null;
44
+ export function resolveMasterCompositionPath(compositions: string[]): string | null {
45
+ if (compositions.includes("index.html")) return "index.html";
46
+ return compositions.find((p) => p.endsWith(".html")) ?? null;
47
+ }
48
+
49
+ /** A URL with no `activeCompPath` needs no hydration step — there's nothing to apply. */
50
+ export function isHydratedFromUrlState(urlState: StudioUrlState): boolean {
51
+ return urlState.activeCompPath == null;
44
52
  }
45
53
 
46
54
  export function normalizeStudioUrlPanelTab(tab: RightPanelTab | null): RightPanelTab | null {
@@ -34,6 +34,16 @@ export function isAudioTimelineElement(
34
34
  return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
35
35
  }
36
36
 
37
+ /** The two tags the property panel lets you put a volume automation lane on.
38
+ * Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
39
+ * `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
40
+ export function isAudioOrVideoTimelineElement(
41
+ element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
42
+ ): boolean {
43
+ if (!element) return false;
44
+ return isAudioTimelineElement(element) || element.tag.trim().toLowerCase() === "video";
45
+ }
46
+
37
47
  /** True for the music track: an audio element with data-timeline-role="music",
38
48
  * or — when no role is set — an id matching the music regex. Voiceover/other
39
49
  * audio (explicit non-music role) is excluded. */