@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,13 +5,24 @@ import {
5
5
  saveProjectFilesWithHistory,
6
6
  type DomEditCommitBaseParams,
7
7
  } from "../utils/studioFileHistory";
8
- import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
8
+ import {
9
+ buildDomEditPatchTarget,
10
+ readHfId,
11
+ type DomEditSelection,
12
+ } from "../components/editor/domEditing";
9
13
  import { studioWriteHeaders } from "../utils/studioFileVersion";
14
+ import {
15
+ findMatchingTimelineElementId,
16
+ resolveElementTrack,
17
+ type ElementMatchSelection,
18
+ } from "../utils/studioHelpers";
19
+ import type { TimelineElement } from "../player";
10
20
 
11
21
  interface UseGroupCommitsParams extends DomEditCommitBaseParams {
12
22
  /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
13
23
  * structure the in-memory doc doesn't know about). */
14
24
  forceReloadSdkSession?: () => void;
25
+ timelineElements: TimelineElement[];
15
26
  }
16
27
 
17
28
  interface PatchTarget {
@@ -24,7 +35,21 @@ interface PatchTarget {
24
35
  interface GroupGeometry {
25
36
  bbox: { left: number; top: number; width: number; height: number };
26
37
  targets: PatchTarget[];
27
- rebases: Array<{ target: PatchTarget; left: number; top: number }>;
38
+ rebases: Array<{ target: PatchTarget; left: number; top: number; track?: number }>;
39
+ }
40
+
41
+ // The member's current resolved track (authored, or the runtime's positional-
42
+ // index fallback). Threaded through so the server can stamp it explicitly —
43
+ // same hazard and fix as the razor split.
44
+ function resolveAuthoredTrack(
45
+ selection: ElementMatchSelection,
46
+ timelineElements: TimelineElement[],
47
+ ): number | undefined {
48
+ const id = findMatchingTimelineElementId(selection, timelineElements);
49
+ if (!id) return undefined;
50
+ const match = timelineElements.find((el) => (el.key ?? el.id) === id);
51
+ if (!match) return undefined;
52
+ return resolveElementTrack(match);
28
53
  }
29
54
 
30
55
  // Wrapper sits at the members' bounding box top-left; each member is rebased so
@@ -32,13 +57,17 @@ interface GroupGeometry {
32
57
  // composition space (transforms excluded), exactly the space the rebase formula
33
58
  // `left_new = left_old - W.left` operates in — GSAP x/y and offset vars are
34
59
  // transform deltas and stay correct without adjustment.
35
- function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
60
+ export function computeGroupGeometry(
61
+ members: DomEditSelection[],
62
+ timelineElements: TimelineElement[],
63
+ ): GroupGeometry {
36
64
  const boxes = members.map((m) => ({
37
65
  target: buildDomEditPatchTarget(m),
38
66
  left: m.element.offsetLeft,
39
67
  top: m.element.offsetTop,
40
68
  right: m.element.offsetLeft + m.element.offsetWidth,
41
69
  bottom: m.element.offsetTop + m.element.offsetHeight,
70
+ track: resolveAuthoredTrack(m, timelineElements),
42
71
  }));
43
72
  const left = Math.min(...boxes.map((b) => b.left));
44
73
  const top = Math.min(...boxes.map((b) => b.top));
@@ -47,10 +76,37 @@ function computeGroupGeometry(members: DomEditSelection[]): GroupGeometry {
47
76
  return {
48
77
  bbox: { left, top, width, height },
49
78
  targets: boxes.map((b) => b.target),
50
- rebases: boxes.map((b) => ({ target: b.target, left: b.left - left, top: b.top - top })),
79
+ rebases: boxes.map((b) => ({
80
+ target: b.target,
81
+ left: b.left - left,
82
+ top: b.top - top,
83
+ track: b.track,
84
+ })),
51
85
  };
52
86
  }
53
87
 
88
+ // Ungroup re-derives each child's track from the raw DOM (no DomEditSelection
89
+ // exists per child); matches by id, falling back to hfId for a child with no
90
+ // authored id. A child with neither keeps its current track, unstamped.
91
+ export function resolveGroupChildTracks(
92
+ group: DomEditSelection,
93
+ timelineElements: TimelineElement[],
94
+ ): Array<{ target: PatchTarget; track?: number }> {
95
+ const sourceFile = group.sourceFile || "index.html";
96
+ const result: Array<{ target: PatchTarget; track?: number }> = [];
97
+ for (const child of Array.from(group.element.children)) {
98
+ const id = (child as HTMLElement).id || undefined;
99
+ const hfId = readHfId(child);
100
+ if (!id && !hfId) continue;
101
+ const track = resolveAuthoredTrack(
102
+ { id, hfId, sourceFile, isCompositionHost: false },
103
+ timelineElements,
104
+ );
105
+ result.push({ target: buildDomEditPatchTarget({ id, hfId }), track });
106
+ }
107
+ return result;
108
+ }
109
+
54
110
  // Shared read → mutate-route → save-with-history → reload pipeline for both
55
111
  // wrap (group) and unwrap (ungroup). Mirrors the structural-mutation pattern in
56
112
  // useElementLifecycleOps (delete). Returns the route's JSON, or throws.
@@ -103,7 +159,7 @@ async function commitStructuralMutation(
103
159
  }
104
160
 
105
161
  export function useGroupCommits(params: UseGroupCommitsParams) {
106
- const { activeCompPath, showToast, projectIdRef } = params;
162
+ const { activeCompPath, showToast, projectIdRef, timelineElements } = params;
107
163
 
108
164
  const groupSelection = useCallback(
109
165
  async (members: DomEditSelection[]): Promise<string | null> => {
@@ -121,7 +177,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
121
177
  // Auto-name "Group N" by the count of existing groups in the document.
122
178
  const doc = members[0].element.ownerDocument;
123
179
  const groupId = `Group ${doc.querySelectorAll("[data-hf-group]").length + 1}`;
124
- const { bbox, targets, rebases } = computeGroupGeometry(members);
180
+ const { bbox, targets, rebases } = computeGroupGeometry(members, timelineElements);
125
181
 
126
182
  try {
127
183
  const data = await commitStructuralMutation(
@@ -138,7 +194,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
138
194
  return null;
139
195
  }
140
196
  },
141
- [activeCompPath, projectIdRef, showToast, params],
197
+ [activeCompPath, projectIdRef, showToast, params, timelineElements],
142
198
  );
143
199
 
144
200
  const ungroupSelection = useCallback(
@@ -146,13 +202,14 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
146
202
  const pid = projectIdRef.current;
147
203
  if (!pid) return;
148
204
  const targetPath = group.sourceFile || activeCompPath || "index.html";
205
+ const childTracks = resolveGroupChildTracks(group, timelineElements);
149
206
 
150
207
  try {
151
208
  await commitStructuralMutation(
152
209
  pid,
153
210
  targetPath,
154
211
  "unwrap-elements",
155
- { target: buildDomEditPatchTarget(group) },
212
+ { target: buildDomEditPatchTarget(group), childTracks },
156
213
  "Ungroup elements",
157
214
  params,
158
215
  );
@@ -160,7 +217,7 @@ export function useGroupCommits(params: UseGroupCommitsParams) {
160
217
  showToast(error instanceof Error ? error.message : "Failed to ungroup elements", "error");
161
218
  }
162
219
  },
163
- [activeCompPath, projectIdRef, showToast, params],
220
+ [activeCompPath, projectIdRef, showToast, params, timelineElements],
164
221
  );
165
222
 
166
223
  return { groupSelection, ungroupSelection };
@@ -0,0 +1,234 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, useMemo, useRef, useState } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useResetSelectionOnProjectSwitch } from "./useResetSelectionOnProjectSwitch";
7
+ import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
8
+ import { useActiveComposition } from "./useActiveComposition";
9
+ import { useFileTree } from "./useFileTree";
10
+ import {
11
+ isHydratedFromUrlState,
12
+ readStudioUrlStateFromWindow,
13
+ resolveMasterCompositionPath,
14
+ } from "../utils/studioUrlState";
15
+
16
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ let root: Root | null = null;
19
+
20
+ afterEach(() => {
21
+ if (root) act(() => root?.unmount());
22
+ root = null;
23
+ document.body.innerHTML = "";
24
+ window.location.hash = "";
25
+ });
26
+
27
+ describe("useResetSelectionOnProjectSwitch", () => {
28
+ function ResetHarness({ projectId }: { projectId: string | null }) {
29
+ const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
30
+ const [activeCompPath, setActiveCompPath] = useState<string | null>("stale-comp.html");
31
+ const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(true);
32
+ useResetSelectionOnProjectSwitch({
33
+ projectId,
34
+ initialUrlStateRef,
35
+ setActiveCompPath,
36
+ setActiveCompPathHydrated,
37
+ });
38
+ return (
39
+ <div
40
+ data-active-comp-path={activeCompPath ?? ""}
41
+ data-hydrated={String(activeCompPathHydrated)}
42
+ />
43
+ );
44
+ }
45
+
46
+ it("does not reset on the initial resolution from null", () => {
47
+ const host = document.createElement("div");
48
+ document.body.append(host);
49
+ root = createRoot(host);
50
+ act(() => {
51
+ root?.render(<ResetHarness projectId={null} />);
52
+ });
53
+ act(() => {
54
+ root?.render(<ResetHarness projectId="project-a" />);
55
+ });
56
+ const el = host.firstElementChild as HTMLElement;
57
+ expect(el.dataset.activeCompPath).toBe("stale-comp.html");
58
+ });
59
+
60
+ it("resets activeCompPath and re-derives hydrated on an actual project switch", () => {
61
+ const host = document.createElement("div");
62
+ document.body.append(host);
63
+ root = createRoot(host);
64
+ act(() => {
65
+ root?.render(<ResetHarness projectId="project-a" />);
66
+ });
67
+ act(() => {
68
+ root?.render(<ResetHarness projectId="project-b" />);
69
+ });
70
+ const el = host.firstElementChild as HTMLElement;
71
+ expect(el.dataset.activeCompPath).toBe("");
72
+ expect(el.dataset.hydrated).toBe("true");
73
+ });
74
+ });
75
+
76
+ describe("project switch integration (mirrors App.tsx's wiring)", () => {
77
+ function AppLikeHarness({
78
+ projectId,
79
+ masterCompPath,
80
+ onSelectComposition,
81
+ }: {
82
+ projectId: string | null;
83
+ masterCompPath: string | null;
84
+ onSelectComposition: (comp: string) => void;
85
+ }) {
86
+ const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
87
+ const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
88
+ const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(() =>
89
+ isHydratedFromUrlState(initialUrlStateRef.current),
90
+ );
91
+ // Mirrors handleSelectComposition (useCompositionContentLoader): selecting a composition
92
+ // also updates activeCompPath, which is exactly the state that must be reset on switch.
93
+ const handleSelect = (comp: string) => {
94
+ setActiveCompPath(comp);
95
+ onSelectComposition(comp);
96
+ };
97
+ // Mirrors useActiveComposition's wiring: reset as a sibling call, before auto-open.
98
+ useResetSelectionOnProjectSwitch({
99
+ projectId,
100
+ initialUrlStateRef,
101
+ setActiveCompPath,
102
+ setActiveCompPathHydrated,
103
+ });
104
+ useAutoOpenRootComposition({
105
+ projectId,
106
+ activeCompPath,
107
+ activeCompPathHydrated,
108
+ masterCompPath,
109
+ onSelectComposition: handleSelect,
110
+ });
111
+ return null;
112
+ }
113
+
114
+ it("opens the new project's root after switching projects with a composition already open", () => {
115
+ const onSelectComposition = vi.fn();
116
+ const host = document.createElement("div");
117
+ document.body.append(host);
118
+ root = createRoot(host);
119
+
120
+ // Project A resolves and auto-opens its root, which also sets activeCompPath — the
121
+ // "something already open" state a real user would be in before switching projects.
122
+ act(() => {
123
+ root?.render(
124
+ <AppLikeHarness
125
+ projectId="project-a"
126
+ masterCompPath="index.html"
127
+ onSelectComposition={onSelectComposition}
128
+ />,
129
+ );
130
+ });
131
+ expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
132
+ onSelectComposition.mockClear();
133
+
134
+ // Switch to project B in-session (hash change, no remount) — this is the exact scenario
135
+ // the adversarial review found broken: activeCompPath was never reset, so project B's
136
+ // auto-open silently no-op'd because activeCompPath still read as "already set".
137
+ act(() => {
138
+ root?.render(
139
+ <AppLikeHarness
140
+ projectId="project-b"
141
+ masterCompPath="hero.html"
142
+ onSelectComposition={onSelectComposition}
143
+ />,
144
+ );
145
+ });
146
+
147
+ expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("hero.html");
148
+ });
149
+ });
150
+
151
+ describe("full stack: useFileTree -> useActiveComposition (masterCompPath staleness)", () => {
152
+ const PROJECT_DATA: Record<string, { files: string[]; compositions: string[] }> = {
153
+ "project-a": { files: ["index.html"], compositions: ["index.html"] },
154
+ "project-b": { files: ["hero.html"], compositions: ["hero.html"] },
155
+ // A preset/vendor .html can be in `files` without being a real composition —
156
+ // this is the exact shape a review found auto-open mis-targeting.
157
+ "project-c": { files: ["preset.html", "hero.html"], compositions: ["hero.html"] },
158
+ };
159
+
160
+ function mockFetch(url: string): Promise<Response> {
161
+ const match = /^\/api\/projects\/([^/]+)$/.exec(url);
162
+ if (match) {
163
+ const data = PROJECT_DATA[decodeURIComponent(match[1])] ?? { files: [], compositions: [] };
164
+ return Promise.resolve(new Response(JSON.stringify(data), { status: 200 }));
165
+ }
166
+ return Promise.resolve(new Response(null, { status: 404 }));
167
+ }
168
+
169
+ function FullStackHarness({ projectId }: { projectId: string | null }) {
170
+ const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
171
+ const projectIdRef = useRef(projectId);
172
+ projectIdRef.current = projectId;
173
+ const { fileTree, fileTreeLoaded, compositions } = useFileTree({ projectId, projectIdRef });
174
+ const masterCompPath = useMemo(
175
+ () => resolveMasterCompositionPath(compositions),
176
+ [compositions],
177
+ );
178
+ const { activeCompPath } = useActiveComposition({
179
+ projectId,
180
+ initialUrlStateRef,
181
+ fileTree,
182
+ fileTreeLoaded,
183
+ masterCompPath,
184
+ setEditingFile: vi.fn(),
185
+ showToast: vi.fn(),
186
+ });
187
+ return <div data-active-comp-path={activeCompPath ?? ""} />;
188
+ }
189
+
190
+ it("opens project B's real root, not project A's stale master path, on an in-session switch", async () => {
191
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
192
+ const host = document.createElement("div");
193
+ document.body.append(host);
194
+ root = createRoot(host);
195
+
196
+ await act(async () => {
197
+ root?.render(<FullStackHarness projectId="project-a" />);
198
+ await Promise.resolve();
199
+ await Promise.resolve();
200
+ });
201
+ let el = host.firstElementChild as HTMLElement;
202
+ expect(el.dataset.activeCompPath).toBe("index.html");
203
+
204
+ // Switch in-session (no remount) before asserting — this is the exact race the
205
+ // adversarial review found: project A's fileTree/masterCompPath are still in state
206
+ // for at least one render after projectId flips, until B's own fetch resolves.
207
+ await act(async () => {
208
+ root?.render(<FullStackHarness projectId="project-b" />);
209
+ await Promise.resolve();
210
+ await Promise.resolve();
211
+ });
212
+ el = host.firstElementChild as HTMLElement;
213
+ expect(el.dataset.activeCompPath).toBe("hero.html");
214
+
215
+ fetchSpy.mockRestore();
216
+ });
217
+
218
+ it("auto-opens the real composition, not a non-composition .html earlier in the file list", async () => {
219
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
220
+ const host = document.createElement("div");
221
+ document.body.append(host);
222
+ root = createRoot(host);
223
+
224
+ await act(async () => {
225
+ root?.render(<FullStackHarness projectId="project-c" />);
226
+ await Promise.resolve();
227
+ await Promise.resolve();
228
+ });
229
+ const el = host.firstElementChild as HTMLElement;
230
+ expect(el.dataset.activeCompPath).toBe("hero.html");
231
+
232
+ fetchSpy.mockRestore();
233
+ });
234
+ });
@@ -0,0 +1,34 @@
1
+ import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
2
+ import {
3
+ isHydratedFromUrlState,
4
+ readStudioUrlStateFromWindow,
5
+ type StudioUrlState,
6
+ } from "../utils/studioUrlState";
7
+
8
+ /**
9
+ * Resets selection when `projectId` switches in-session. Runs during render, not an
10
+ * effect, so the caller never sees a stale activeCompPath in the same commit.
11
+ */
12
+ export function useResetSelectionOnProjectSwitch({
13
+ projectId,
14
+ initialUrlStateRef,
15
+ setActiveCompPath,
16
+ setActiveCompPathHydrated,
17
+ }: {
18
+ projectId: string | null;
19
+ initialUrlStateRef: MutableRefObject<StudioUrlState>;
20
+ setActiveCompPath: Dispatch<SetStateAction<string | null>>;
21
+ setActiveCompPathHydrated: Dispatch<SetStateAction<boolean>>;
22
+ }): void {
23
+ const previousProjectIdRef = useRef(projectId);
24
+
25
+ if (previousProjectIdRef.current !== projectId) {
26
+ const isSwitch = previousProjectIdRef.current !== null;
27
+ previousProjectIdRef.current = projectId;
28
+ if (isSwitch) {
29
+ initialUrlStateRef.current = readStudioUrlStateFromWindow();
30
+ setActiveCompPath(null);
31
+ setActiveCompPathHydrated(isHydratedFromUrlState(initialUrlStateRef.current));
32
+ }
33
+ }
34
+ }
@@ -12,6 +12,17 @@ vi.mock("@hyperframes/sdk", () => ({
12
12
  import type { Composition } from "@hyperframes/sdk";
13
13
  import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
14
14
 
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
+
17
+ import { trackStudioEvent } from "../utils/studioTelemetry";
18
+
19
+ const trackMock = vi.mocked(trackStudioEvent);
20
+
21
+ function Probe({ projectId }: { projectId: string }) {
22
+ useSdkSession(projectId, "index.html");
23
+ return null;
24
+ }
25
+
15
26
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
27
 
17
28
  function fakeSession(): Composition {
@@ -135,3 +146,68 @@ describe("useSdkSession ownership", () => {
135
146
  expect(published.dispose).toHaveBeenCalledOnce();
136
147
  });
137
148
  });
149
+
150
+ describe("useSdkSession unavailable telemetry", () => {
151
+ beforeEach(() => {
152
+ openComposition.mockReset();
153
+ trackMock.mockClear();
154
+ class FakeEventSource {
155
+ addEventListener(): void {}
156
+ close(): void {}
157
+ }
158
+ vi.stubGlobal("EventSource", FakeEventSource);
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.unstubAllGlobals();
163
+ });
164
+
165
+ // Every cutover chokepoint silently takes the server path when there is no
166
+ // session, and the shadow never runs either — so a missing session is a total,
167
+ // otherwise-invisible SDK bypass. These three exits are its only origin.
168
+ it("reports an unreadable composition", async () => {
169
+ vi.stubGlobal(
170
+ "fetch",
171
+ vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
172
+ );
173
+ const root = createRoot(document.createElement("div"));
174
+ await act(async () => root.render(<Probe projectId="project-a" />));
175
+ await flushAsyncEffects();
176
+
177
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
178
+ await act(async () => root.unmount());
179
+ });
180
+
181
+ it("reports a parse failure with its message", async () => {
182
+ vi.stubGlobal(
183
+ "fetch",
184
+ vi.fn(async () => response("PROJECT_A")),
185
+ );
186
+ openComposition.mockRejectedValue(new Error("unparseable composition"));
187
+
188
+ const root = createRoot(document.createElement("div"));
189
+ await act(async () => root.render(<Probe projectId="project-a" />));
190
+ await flushAsyncEffects();
191
+
192
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
193
+ stage: "open",
194
+ error: "unparseable composition",
195
+ });
196
+ await act(async () => root.unmount());
197
+ });
198
+
199
+ it("stays silent on the happy path", async () => {
200
+ vi.stubGlobal(
201
+ "fetch",
202
+ vi.fn(async () => response("PROJECT_A")),
203
+ );
204
+ openComposition.mockResolvedValue(fakeSession());
205
+
206
+ const root = createRoot(document.createElement("div"));
207
+ await act(async () => root.render(<Probe projectId="project-a" />));
208
+ await flushAsyncEffects();
209
+
210
+ expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything());
211
+ await act(async () => root.unmount());
212
+ });
213
+ });
@@ -181,7 +181,15 @@ export function useSdkSession(
181
181
 
182
182
  readProjectFileOptional(projectId, activeCompPath)
183
183
  .then(async (content) => {
184
- if (cancelled || typeof content !== "string") return;
184
+ if (cancelled) return;
185
+ if (typeof content !== "string") {
186
+ // No SDK session follows, so EVERY cutover chokepoint below takes the
187
+ // server path and emits nothing — the shadow never runs either. This
188
+ // is the only place a missing session can originate, so a broken read
189
+ // would otherwise be a silent, total SDK bypass.
190
+ trackStudioEvent("sdk_session_unavailable", { stage: "read" });
191
+ return;
192
+ }
185
193
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
186
194
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
187
195
  // queue too would double-write the file (queue auto-writes on every
@@ -206,6 +214,7 @@ export function useSdkSession(
206
214
  )
207
215
  ) {
208
216
  disposeSdkSession(comp);
217
+ trackStudioEvent("sdk_session_unavailable", { stage: "ownership" });
209
218
  return;
210
219
  }
211
220
  const displaced = ownedSessionRef.current;
@@ -215,8 +224,17 @@ export function useSdkSession(
215
224
  setOwnedSession(installed);
216
225
  if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
217
226
  })
218
- .catch(() => {
219
- if (!cancelled && generationRef.current === generation) setOwnedSession(null);
227
+ .catch((error: unknown) => {
228
+ if (!cancelled && generationRef.current === generation) {
229
+ setOwnedSession(null);
230
+ // openComposition threw (unparseable composition, OOM) — same total
231
+ // bypass as the read failure above, but this one is a real defect
232
+ // rather than a missing file. Carry the message; it is the only clue.
233
+ trackStudioEvent("sdk_session_unavailable", {
234
+ stage: "open",
235
+ error: error instanceof Error ? error.message : String(error),
236
+ });
237
+ }
220
238
  });
221
239
 
222
240
  return () => {
@@ -0,0 +1,72 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.unstubAllGlobals();
13
+ });
14
+
15
+ type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
16
+
17
+ function renderDropHook(
18
+ sourceContent: string,
19
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
20
+ ) {
21
+ vi.stubGlobal(
22
+ "fetch",
23
+ vi.fn().mockResolvedValue({ ok: true, json: async () => ({ content: sourceContent }) }),
24
+ );
25
+ let drop: DropFn | null = null;
26
+ function Harness() {
27
+ const { handleTimelineAssetDrop } = useTimelineAssetDropOps({
28
+ projectIdRef: { current: "project" },
29
+ activeCompPath: "index.html",
30
+ timelineElements: [],
31
+ showToast: vi.fn(),
32
+ writeProjectFile,
33
+ recordEdit: vi.fn().mockResolvedValue(undefined),
34
+ reloadPreview: vi.fn(),
35
+ uploadProjectFiles: vi.fn().mockResolvedValue([]),
36
+ });
37
+ drop = handleTimelineAssetDrop;
38
+ return null;
39
+ }
40
+ mountReactHarness(<Harness />);
41
+ return () => drop!;
42
+ }
43
+
44
+ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
45
+ it("grows the root duration when the drop lands past the current end", async () => {
46
+ const source =
47
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
48
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
49
+ const getDrop = renderDropHook(source, writeProjectFile);
50
+
51
+ await act(async () => {
52
+ await getDrop()("clip.mp4", { start: 8, track: 0 }, 5);
53
+ });
54
+
55
+ const [, written] = writeProjectFile.mock.calls[0] as [string, string];
56
+ expect(written).toContain('data-duration="13"');
57
+ });
58
+
59
+ it("leaves the root duration alone when the drop lands inside the current end", async () => {
60
+ const source =
61
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
62
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
63
+ const getDrop = renderDropHook(source, writeProjectFile);
64
+
65
+ await act(async () => {
66
+ await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
67
+ });
68
+
69
+ const [, written] = writeProjectFile.mock.calls[0] as [string, string];
70
+ expect(written).toContain('data-duration="10"');
71
+ });
72
+ });
@@ -20,6 +20,7 @@ import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelp
20
20
  import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
21
21
  import { readFileContent } from "./timelineTimingSync";
22
22
  import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
23
+ import { extendRootDurationInSource } from "../utils/rootDuration";
23
24
  import { usePlayerStore } from "../player";
24
25
 
25
26
  interface UseTimelineAssetDropOpsOptions {
@@ -89,22 +90,25 @@ export function useTimelineAssetDropOps({
89
90
  );
90
91
  const newElementZIndex = Math.max(1, relevantElements.length + 1);
91
92
 
92
- const patchedContent = insertTimelineAssetIntoSource(
93
- originalContent,
94
- buildTimelineAssetInsertHtml({
95
- id: newId,
96
- hfId: `hf-${generateId()}`,
97
- assetPath: resolvedAssetSrc,
98
- kind,
99
- start: normalizedStart,
100
- duration: normalizedDuration,
101
- track: placement.track,
102
- zIndex: newElementZIndex,
103
- geometry: fitTimelineAssetGeometry(
104
- null,
105
- resolveTimelineAssetCompositionSize(originalContent),
106
- ),
107
- }),
93
+ const patchedContent = extendRootDurationInSource(
94
+ insertTimelineAssetIntoSource(
95
+ originalContent,
96
+ buildTimelineAssetInsertHtml({
97
+ id: newId,
98
+ hfId: `hf-${generateId()}`,
99
+ assetPath: resolvedAssetSrc,
100
+ kind,
101
+ start: normalizedStart,
102
+ duration: normalizedDuration,
103
+ track: placement.track,
104
+ zIndex: newElementZIndex,
105
+ geometry: fitTimelineAssetGeometry(
106
+ null,
107
+ resolveTimelineAssetCompositionSize(originalContent),
108
+ ),
109
+ }),
110
+ ),
111
+ normalizedStart + normalizedDuration,
108
112
  );
109
113
 
110
114
  await saveProjectFilesWithHistory({