@hyperframes/studio 0.8.55 → 0.8.57

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 (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -25,6 +25,34 @@ type ProjectFileReadFailure =
25
25
 
26
26
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
27
 
28
+ /**
29
+ * Record a read that produced no usable content, and answer which project — if
30
+ * any — the failure identifies as unreachable.
31
+ *
32
+ * No SDK session follows a failed read, so EVERY cutover chokepoint takes the
33
+ * server path and emits nothing — the shadow never runs either. A broken read
34
+ * would otherwise be a silent, total SDK bypass, which is why this is recorded
35
+ * at all.
36
+ *
37
+ * Only a 404 identifies the *project*: the server answered, and its answer was
38
+ * that it does not serve this id. A 5xx, a dropped request, an unexpected body
39
+ * and an empty file all say nothing about which project the server serves, so
40
+ * none of them claim it. See `unreachableProject` on the handle.
41
+ *
42
+ * A function rather than three more conditions inline: the read callback it is
43
+ * called from is a long pre-existing async body already near the complexity
44
+ * threshold, and this branch is one coherent unit.
45
+ */
46
+ function reportReadFailure(read: ProjectFileReadFailure, projectId: string): string | null {
47
+ trackStudioEvent("sdk_session_unavailable", {
48
+ stage: "read",
49
+ reason: read.reason,
50
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
51
+ });
52
+ if (read.reason !== "http_error") return null;
53
+ return read.status === 404 ? projectId : null;
54
+ }
55
+
28
56
  /**
29
57
  * Read a project file's content (optional read — a missing file is not an
30
58
  * error). Replaces the removed SDK http adapter's `read()` — the only thing
@@ -96,6 +124,19 @@ export interface SdkSessionHandle {
96
124
  * side of that path is covered by usePersistentEditHistory.test.ts.
97
125
  */
98
126
  forceReload: () => void;
127
+ /**
128
+ * Set when this server answered the composition read with a 404 for the
129
+ * project it was asked to open. In the CLI-embedded host — the only host
130
+ * that reports telemetry at all (`telemetry/policy.ts` suppresses Vite dev)
131
+ * — that does not mean the project is gone. It means this Studio is serving
132
+ * a *different* one; the project is untouched on disk. Either way every edit
133
+ * in this tab fails, and until now it failed silently.
134
+ *
135
+ * `null` for every other failure: a 500 or a dropped request says nothing
136
+ * about which project the server serves, and `absent_or_empty` /
137
+ * `missing_content` say nothing about the project at all.
138
+ */
139
+ unreachableProject: string | null;
99
140
  }
100
141
 
101
142
  interface SdkSessionOwner {
@@ -173,6 +214,7 @@ export function useSdkSession(
173
214
  const [reloadToken, setReloadToken] = useState(0);
174
215
  const reloadTokenRef = useRef(reloadToken);
175
216
  reloadTokenRef.current = reloadToken;
217
+ const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
176
218
 
177
219
  useEffect(
178
220
  () =>
@@ -195,6 +237,7 @@ export function useSdkSession(
195
237
  if (previous) disposeSdkSession(previous.session);
196
238
 
197
239
  if (!projectId || !activeCompPath) {
240
+ setUnreachableProject(null);
198
241
  return () => {
199
242
  cancelled = true;
200
243
  };
@@ -211,17 +254,10 @@ export function useSdkSession(
211
254
  .then(async (read) => {
212
255
  if (cancelled) return;
213
256
  if (!read.ok) {
214
- // No SDK session follows, so EVERY cutover chokepoint below takes the
215
- // server path and emits nothing — the shadow never runs either. This
216
- // is the only place a missing session can originate, so a broken read
217
- // would otherwise be a silent, total SDK bypass.
218
- trackStudioEvent("sdk_session_unavailable", {
219
- stage: "read",
220
- reason: read.reason,
221
- ...(read.reason === "http_error" ? { status: read.status } : {}),
222
- });
257
+ setUnreachableProject(reportReadFailure(read, projectId));
223
258
  return;
224
259
  }
260
+ setUnreachableProject(null);
225
261
  const content = read.content;
226
262
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
227
263
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
@@ -316,5 +352,5 @@ export function useSdkSession(
316
352
  ownedSession.reloadToken === reloadToken
317
353
  ? ownedSession.session
318
354
  : null;
319
- return { session, publish, forceReload };
355
+ return { session, publish, forceReload, unreachableProject };
320
356
  }
@@ -2,8 +2,11 @@
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
5
6
  import { useServerConnection } from "./useServerConnection";
6
7
 
8
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
9
+
7
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
11
 
9
12
  const captured: { projectId: string | null } = { projectId: null };
@@ -55,6 +58,7 @@ describe("useServerConnection hash project id", () => {
55
58
 
56
59
  afterEach(() => {
57
60
  vi.unstubAllGlobals();
61
+ vi.mocked(trackStudioEvent).mockClear();
58
62
  });
59
63
 
60
64
  it("keeps a hash id the server can resolve", async () => {
@@ -92,3 +96,302 @@ describe("useServerConnection hash project id", () => {
92
96
  await act(async () => root.unmount());
93
97
  });
94
98
  });
99
+
100
+ /** Per-project status, so one test can hold two projects in different states. */
101
+ function stubFetchByProject(statuses: Record<string, number | "reject">) {
102
+ vi.stubGlobal(
103
+ "fetch",
104
+ vi.fn(async (url: string) => {
105
+ if (url === "/api/projects") {
106
+ return {
107
+ ok: true,
108
+ json: async () => ({ projects: [{ id: "real-project" }] }),
109
+ } as Response;
110
+ }
111
+ const id = decodeURIComponent(url.replace("/api/projects/", ""));
112
+ const status = statuses[id];
113
+ if (status === "reject") throw new TypeError("Failed to fetch");
114
+ if (status === undefined) return { ok: false, status: 404 } as Response;
115
+ return { ok: status === 200, status } as Response;
116
+ }),
117
+ );
118
+ }
119
+
120
+ async function changeHash(hash: string) {
121
+ await act(async () => {
122
+ window.location.hash = hash;
123
+ });
124
+ await flush();
125
+ }
126
+
127
+ /**
128
+ * Change the hash WITHOUT the test itself pushing or firing anything.
129
+ *
130
+ * `location.hash =` does both, which makes "did the CODE push?" unanswerable —
131
+ * the test's own navigation moves history.length either way. replaceState does
132
+ * neither, so the dispatch is explicit and any growth after it is the hook's.
133
+ */
134
+ async function replaceHashAndDispatch(hash: string) {
135
+ await act(async () => {
136
+ window.history.replaceState(null, "", hash);
137
+ window.dispatchEvent(new Event("hashchange"));
138
+ });
139
+ await flush();
140
+ }
141
+
142
+ /**
143
+ * The hashchange path took any id the URL offered, with no validation — so the
144
+ * mount fix from v0.8.52 could be walked straight around by changing projects.
145
+ * These pin the second door shut.
146
+ */
147
+ describe("useServerConnection hashchange validation", () => {
148
+ beforeEach(() => {
149
+ captured.projectId = null;
150
+ window.location.hash = "";
151
+ vi.mocked(trackStudioEvent).mockClear();
152
+ });
153
+
154
+ afterEach(() => {
155
+ vi.unstubAllGlobals();
156
+ });
157
+
158
+ it("adopts a hash id the server can resolve", async () => {
159
+ stubFetchByProject({ first: 200, second: 200 });
160
+ const root = await renderWithHash("#project/first");
161
+ expect(captured.projectId).toBe("first");
162
+
163
+ await changeHash("#project/second");
164
+ expect(captured.projectId).toBe("second");
165
+ await act(async () => root.unmount());
166
+ });
167
+
168
+ it("falls back to the first project when the new hash id is gone", async () => {
169
+ stubFetchByProject({ first: 200, deleted: 404 });
170
+ const root = await renderWithHash("#project/first");
171
+ expect(captured.projectId).toBe("first");
172
+
173
+ await changeHash("#project/deleted");
174
+ expect(captured.projectId).toBe("real-project");
175
+ expect(window.location.hash).toContain("real-project");
176
+ await act(async () => root.unmount());
177
+ });
178
+
179
+ it("keeps the new hash id when the check itself fails", async () => {
180
+ // Same transient-tolerance as the mount path: a blip must not rewrite the
181
+ // user's hash out from under a project that is actually fine.
182
+ stubFetchByProject({ first: 200, flaky: "reject" });
183
+ const root = await renderWithHash("#project/first");
184
+
185
+ await changeHash("#project/flaky");
186
+ expect(captured.projectId).toBe("flaky");
187
+ await act(async () => root.unmount());
188
+ });
189
+
190
+ it("reports the hashchange verdict with its stage", async () => {
191
+ stubFetchByProject({ first: 200, deleted: 404 });
192
+ const root = await renderWithHash("#project/first");
193
+ vi.mocked(trackStudioEvent).mockClear();
194
+
195
+ await changeHash("#project/deleted");
196
+ const verdicts = vi
197
+ .mocked(trackStudioEvent)
198
+ .mock.calls.filter(([name]) => name === "project_hash_validated");
199
+ expect(verdicts).toEqual([
200
+ ["project_hash_validated", { stage: "hashchange", outcome: "missing", status: 404 }],
201
+ ]);
202
+ await act(async () => root.unmount());
203
+ });
204
+
205
+ it("ignores a verdict that lands after the user moved on", async () => {
206
+ // Two changes in quick succession. The first check resolves late; if it were
207
+ // trusted, it would adopt a project the user already navigated away from.
208
+ let releaseFirst: (() => void) | null = null;
209
+ vi.stubGlobal(
210
+ "fetch",
211
+ vi.fn(async (url: string) => {
212
+ if (url === "/api/projects") {
213
+ return {
214
+ ok: true,
215
+ json: async () => ({ projects: [{ id: "real-project" }] }),
216
+ } as Response;
217
+ }
218
+ const id = decodeURIComponent(url.replace("/api/projects/", ""));
219
+ if (id === "slow") {
220
+ await new Promise<void>((resolve) => {
221
+ releaseFirst = resolve;
222
+ });
223
+ return { ok: true, status: 200 } as Response;
224
+ }
225
+ return { ok: true, status: 200 } as Response;
226
+ }),
227
+ );
228
+
229
+ const root = await renderWithHash("#project/slow");
230
+ await flush();
231
+ // Move on before the slow validation answers.
232
+ await act(async () => {
233
+ window.location.hash = "#project/fast";
234
+ });
235
+ await flush();
236
+ expect(captured.projectId).toBe("fast");
237
+
238
+ // Now let the stale check resolve. It must not win.
239
+ await act(async () => {
240
+ releaseFirst?.();
241
+ });
242
+ await flush();
243
+ expect(captured.projectId).toBe("fast");
244
+ await act(async () => root.unmount());
245
+ });
246
+
247
+ it("falls back to the CURRENT project list, not the one from mount", async () => {
248
+ // The mount fetch is a snapshot. A long-lived tab is exactly the case this
249
+ // path exists for, so re-reading the list is the difference between landing
250
+ // on a project that exists and landing on the one that was first an hour ago.
251
+ let list = [{ id: "old-first" }];
252
+ vi.stubGlobal(
253
+ "fetch",
254
+ vi.fn(async (url: string) => {
255
+ if (url === "/api/projects") {
256
+ return { ok: true, json: async () => ({ projects: list }) } as Response;
257
+ }
258
+ const id = decodeURIComponent(url.replace("/api/projects/", ""));
259
+ return { ok: id !== "deleted", status: id === "deleted" ? 404 : 200 } as Response;
260
+ }),
261
+ );
262
+
263
+ const root = await renderWithHash("#project/old-first");
264
+ expect(captured.projectId).toBe("old-first");
265
+
266
+ list = [{ id: "new-first" }];
267
+ await changeHash("#project/deleted");
268
+ expect(captured.projectId).toBe("new-first");
269
+ await act(async () => root.unmount());
270
+ });
271
+
272
+ it("re-reads the list on a second dead hash rather than reusing the first answer", async () => {
273
+ // The caching version is invisible with one dead hash — firstProjectId runs
274
+ // once, so a memo and a fresh fetch look identical. Two dead hashes with the
275
+ // list changing in between is what separates them.
276
+ let list = [{ id: "first-a" }];
277
+ vi.stubGlobal(
278
+ "fetch",
279
+ vi.fn(async (url: string) => {
280
+ if (url === "/api/projects") {
281
+ return { ok: true, json: async () => ({ projects: list }) } as Response;
282
+ }
283
+ const id = decodeURIComponent(url.replace("/api/projects/", ""));
284
+ return { ok: id === "alive", status: id === "alive" ? 200 : 404 } as Response;
285
+ }),
286
+ );
287
+
288
+ const root = await renderWithHash("#project/alive");
289
+ await changeHash("#project/dead-one");
290
+ expect(captured.projectId).toBe("first-a");
291
+
292
+ list = [{ id: "first-b" }];
293
+ await changeHash("#project/dead-two");
294
+ expect(captured.projectId).toBe("first-b");
295
+ await act(async () => root.unmount());
296
+ });
297
+
298
+ it("rewrites a dead hash without adding a history entry", async () => {
299
+ // `location.hash =` pushes, which would leave the dead id one Back away —
300
+ // and Back would then re-validate, fall back and push again, forever.
301
+ stubFetchByProject({ first: 200, deleted: 404 });
302
+ const root = await renderWithHash("#project/first");
303
+
304
+ const before = window.history.length;
305
+ await replaceHashAndDispatch("#project/deleted");
306
+ expect(captured.projectId).toBe("real-project");
307
+ expect(window.history.length).toBe(before);
308
+ await act(async () => root.unmount());
309
+ });
310
+
311
+ it("rewrites the mount fallback without adding a history entry", async () => {
312
+ // The mount path pushed until now — the same bug from the other end: Back
313
+ // returned to the dead id, which re-validates and pushes again.
314
+ stubFetchByProject({ deleted: 404 });
315
+ window.history.replaceState(null, "", "#project/deleted");
316
+ const before = window.history.length;
317
+
318
+ const root = await renderWithHash("#project/deleted");
319
+ expect(captured.projectId).toBe("real-project");
320
+ expect(window.history.length).toBe(before);
321
+ await act(async () => root.unmount());
322
+ });
323
+ });
324
+
325
+ /**
326
+ * The verdict event. Until it existed, a tab that kept its hash because the
327
+ * check itself failed was indistinguishable in production from one that never
328
+ * ran the check — different causes, different fixes, one silent bucket.
329
+ */
330
+ describe("project_hash_validated", () => {
331
+ // Mount only: assigning window.location.hash is what happy-dom turns into a
332
+ // hashchange, so the listener reports too. This describe is about the mount
333
+ // path; the hashchange verdicts have their own.
334
+ const verdictCalls = () =>
335
+ vi
336
+ .mocked(trackStudioEvent)
337
+ .mock.calls.filter(
338
+ ([name, props]) =>
339
+ name === "project_hash_validated" &&
340
+ (props as { stage?: string } | undefined)?.stage === "mount",
341
+ );
342
+
343
+ beforeEach(() => {
344
+ captured.projectId = null;
345
+ window.location.hash = "";
346
+ vi.mocked(trackStudioEvent).mockClear();
347
+ });
348
+
349
+ afterEach(() => {
350
+ vi.unstubAllGlobals();
351
+ });
352
+
353
+ it("reports an ok verdict with its status", async () => {
354
+ stubFetch({ status: 200 });
355
+ const root = await renderWithHash("#project/stale-or-not");
356
+ expect(verdictCalls()).toEqual([
357
+ ["project_hash_validated", { stage: "mount", outcome: "ok", status: 200 }],
358
+ ]);
359
+ await act(async () => root.unmount());
360
+ });
361
+
362
+ it("reports a missing verdict with the 404", async () => {
363
+ stubFetch({ status: 404 });
364
+ const root = await renderWithHash("#project/deleted-project");
365
+ expect(verdictCalls()).toEqual([
366
+ ["project_hash_validated", { stage: "mount", outcome: "missing", status: 404 }],
367
+ ]);
368
+ await act(async () => root.unmount());
369
+ });
370
+
371
+ it("reports unknown with no status when the check is rejected", async () => {
372
+ // The point of the event: this case LOOKS like success from outside (the
373
+ // hash is kept) but is a failed check. Only the verdict says so.
374
+ stubFetch("reject");
375
+ const root = await renderWithHash("#project/unreachable-check");
376
+ expect(verdictCalls()).toEqual([
377
+ ["project_hash_validated", { stage: "mount", outcome: "unknown" }],
378
+ ]);
379
+ await act(async () => root.unmount());
380
+ });
381
+
382
+ it("reports unknown — not missing — for a 5xx", async () => {
383
+ stubFetch({ status: 500 });
384
+ const root = await renderWithHash("#project/server-erroring");
385
+ expect(verdictCalls()).toEqual([
386
+ ["project_hash_validated", { stage: "mount", outcome: "unknown", status: 500 }],
387
+ ]);
388
+ await act(async () => root.unmount());
389
+ });
390
+
391
+ it("emits nothing when there is no hash id to validate", async () => {
392
+ stubFetch({ status: 200 });
393
+ const root = await renderWithHash("");
394
+ expect(verdictCalls()).toEqual([]);
395
+ await act(async () => root.unmount());
396
+ });
397
+ });
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
3
3
  import { useMountEffect } from "./useMountEffect";
4
+ import { trackStudioEvent } from "../utils/studioTelemetry";
4
5
 
5
6
  interface ServerConnectionState {
6
7
  projectId: string | null;
@@ -28,16 +29,65 @@ interface ServerConnectionState {
28
29
  * `/api/projects` list would be wrong twice over: that list omits session ids
29
30
  * (which resolve fine) and skips project dirs without an `index.html`.
30
31
  */
31
- async function resolveHashProject(id: string): Promise<"ok" | "missing" | "unknown"> {
32
+ type ProjectHashVerdict = {
33
+ outcome: "ok" | "missing" | "unknown";
34
+ /** HTTP status when the server answered; absent on a rejected request. */
35
+ status?: number;
36
+ };
37
+
38
+ async function resolveHashProject(id: string): Promise<ProjectHashVerdict> {
32
39
  try {
33
40
  const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
34
- if (res.ok) return "ok";
35
- return res.status === 404 ? "missing" : "unknown";
41
+ if (res.ok) return { outcome: "ok", status: res.status };
42
+ return { outcome: res.status === 404 ? "missing" : "unknown", status: res.status };
36
43
  } catch {
37
- return "unknown";
44
+ return { outcome: "unknown" };
38
45
  }
39
46
  }
40
47
 
48
+ /**
49
+ * What the hash validation decided. Without this, a tab that kept a hash
50
+ * because the check itself failed ("unknown") is indistinguishable from one
51
+ * that never ran the check — the two have different fixes and, until this
52
+ * event, no way to tell them apart in production.
53
+ */
54
+ /**
55
+ * Rewrite the project hash WITHOUT pushing a history entry.
56
+ *
57
+ * `location.hash =` pushes. That leaves the dead id as the previous entry, so
58
+ * Back returns to it — and now that hashchange validates, Back would re-run the
59
+ * whole fallback and push again, leaving a trail of dead ids the user cannot
60
+ * escape with the Back button.
61
+ */
62
+ function replaceProjectHash(projectId: string): void {
63
+ window.history.replaceState(null, "", buildProjectHash(projectId));
64
+ }
65
+
66
+ /**
67
+ * The first project the server offers, for a hash that resolves to nothing.
68
+ *
69
+ * Fetched here rather than cached from mount: the mount fetch is a snapshot,
70
+ * and the scenario this exists for is exactly the one where the project list
71
+ * changed underneath a long-lived tab.
72
+ */
73
+ async function firstProjectId(): Promise<string | null> {
74
+ try {
75
+ const res = await fetch("/api/projects");
76
+ const data = (await res.json()) as { projects?: Array<{ id?: string }> };
77
+ return data.projects?.[0]?.id ?? null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function reportHashVerdict(stage: "mount" | "hashchange", verdict: ProjectHashVerdict): void {
84
+ trackStudioEvent("project_hash_validated", {
85
+ stage,
86
+ outcome: verdict.outcome,
87
+ ...(verdict.status === undefined ? {} : { status: verdict.status }),
88
+ });
89
+ }
90
+
41
91
  export function useServerConnection(): ServerConnectionState {
42
92
  const [projectId, setProjectId] = useState<string | null>(null);
43
93
  const [resolving, setResolving] = useState(true);
@@ -69,11 +119,12 @@ export function useServerConnection(): ServerConnectionState {
69
119
  // Telemetry after the read-reason split: 120 http_error/404 reads
70
120
  // across 5 users in 24h, ~24 each, never recovering.
71
121
  if (hashProjectId) {
72
- const state = await resolveHashProject(hashProjectId);
122
+ const verdict = await resolveHashProject(hashProjectId);
73
123
  if (cancelled) return;
124
+ reportHashVerdict("mount", verdict);
74
125
  // "unknown" keeps the old behaviour: a transient failure must not
75
126
  // rewrite the user's hash out from under a valid project.
76
- if (state !== "missing") {
127
+ if (verdict.outcome !== "missing") {
77
128
  setProjectId(hashProjectId);
78
129
  setWaitingForServer(false);
79
130
  return;
@@ -83,7 +134,7 @@ export function useServerConnection(): ServerConnectionState {
83
134
  if (first) {
84
135
  setProjectId(first.id);
85
136
  setWaitingForServer(false);
86
- window.location.hash = buildProjectHash(first.id);
137
+ replaceProjectHash(first.id);
87
138
  } else {
88
139
  scheduleRetry();
89
140
  }
@@ -107,7 +158,29 @@ export function useServerConnection(): ServerConnectionState {
107
158
  useEffect(() => {
108
159
  const onHashChange = () => {
109
160
  const next = parseProjectIdFromHash(window.location.hash);
110
- if (next && next !== projectId) setProjectId(next);
161
+ if (!next || next === projectId) return;
162
+ // Validated, not adopted. The mount path has checked its hash since
163
+ // v0.8.52; this path took any id the URL offered, so changing projects
164
+ // re-armed the same dead-tab failure the mount check exists to prevent.
165
+ void (async () => {
166
+ const verdict = await resolveHashProject(next);
167
+ // Ground truth, not a captured flag. This effect re-registers on every
168
+ // projectId change, so a generation counter in this closure would reset
169
+ // and an in-flight verdict from the previous registration would read as
170
+ // current — adopting a project the user has already navigated away from.
171
+ // The hash itself cannot go stale.
172
+ if (parseProjectIdFromHash(window.location.hash) !== next) return;
173
+ reportHashVerdict("hashchange", verdict);
174
+ if (verdict.outcome !== "missing") {
175
+ setProjectId(next);
176
+ return;
177
+ }
178
+ const fallbackId = await firstProjectId();
179
+ if (parseProjectIdFromHash(window.location.hash) !== next) return;
180
+ if (!fallbackId) return;
181
+ setProjectId(fallbackId);
182
+ replaceProjectHash(fallbackId);
183
+ })();
111
184
  };
112
185
  window.addEventListener("hashchange", onHashChange);
113
186
  return () => window.removeEventListener("hashchange", onHashChange);
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
103
103
  expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
104
104
  });
105
105
 
106
- it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
106
+ it("measures the insert row against every manifest timeline row", async () => {
107
107
  const clip = (id: string, track: number): TimelineElement => ({
108
108
  id,
109
109
  key: id,
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
123
123
  }),
124
124
  "</main>",
125
125
  ].join("\n");
126
- usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
127
126
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
128
127
  const getDrop = renderDropHook(source, writeProjectFile, [
129
128
  clip("a", 0),
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
136
135
  });
137
136
 
138
137
  const [, written] = writeProjectFile.mock.calls[0] as [string, string];
139
- expect(written).toContain('id="b" data-start="0" data-track-index="2"');
140
- expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
138
+ expect(written).toContain('id="b" data-start="0" data-track-index="3"');
139
+ expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
141
140
  expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
142
141
  });
143
142
 
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
25
25
  import { extendRootDurationInSource } from "../utils/rootDuration";
26
26
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
27
27
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
28
- import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
- import { usePlayerStore } from "../player/store/playerStore";
30
28
 
31
29
  /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
32
30
  function fileDropPlacement(
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
127
125
  const { source: sourceWithRoom, track } = resolveDropTrack({
128
126
  source: originalContent,
129
127
  // insertRow counts the rows the timeline shows, so plan against those.
130
- elements: selectTimelineRowElements(
131
- relevantElements,
132
- usePlayerStore.getState().topLevelIds,
133
- ),
128
+ elements: relevantElements,
134
129
  placement,
135
130
  dropped: {
136
131
  id: newId,
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
1561
1561
  unmount();
1562
1562
  });
1563
1563
 
1564
- it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
1565
- // Regression: canEdit must resolve against the expanded rows
1566
- // (timelineTrackVisibility.ts's own invariant), not the raw
1567
- // timelineElements prop — proven by making the two disagree below.
1564
+ it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
1565
+ // The visibility hook reads the single store-owned row source. The editing
1566
+ // guard must receive that same source so a blocked row cannot fall through
1567
+ // to the write path.
1568
1568
  const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1569
1569
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1570
1570
  usePlayerStore.getState().setElements([clip]);
1571
1571
  const showToast = vi.fn();
1572
1572
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1573
+ const canEdit = vi.fn((element: TimelineElement) =>
1574
+ element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
1575
+ );
1573
1576
  let hook: ReturnType<typeof useTimelineEditing> | null = null;
1574
1577
  function Harness() {
1575
1578
  hook = useTimelineEditing({
1576
1579
  projectId: "p1",
1577
1580
  activeCompPath: "index.html",
1578
- timelineElements: [], // deliberately does not contain `clip`
1581
+ timelineElements: [clip],
1579
1582
  showToast,
1580
1583
  writeProjectFile,
1581
1584
  recordEdit: vi.fn(),
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
1583
1586
  previewIframeRef: { current: iframe },
1584
1587
  pendingTimelineEditPathRef: { current: new Set<string>() },
1585
1588
  uploadProjectFiles: vi.fn(),
1586
- canEdit: (element) =>
1587
- element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
1589
+ canEdit,
1588
1590
  });
1589
1591
  return null;
1590
1592
  }
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
1596
1598
  await flushAsyncWork();
1597
1599
  });
1598
1600
 
1601
+ expect(canEdit).toHaveBeenCalledWith(clip);
1599
1602
  expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1600
1603
  expect(writeProjectFile).not.toHaveBeenCalled();
1601
1604
  unmount();