@hyperframes/studio 0.8.56 → 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 (82) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
  6. package/dist/index.d.ts +95 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15573 -14835
  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 +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +40 -4
  33. package/src/components/nle/NLEPreview.test.ts +66 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  39. package/src/hooks/useCompositionDimensions.ts +17 -1
  40. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  41. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  42. package/src/hooks/useFileTree.test.tsx +56 -0
  43. package/src/hooks/useFileTree.ts +8 -1
  44. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  45. package/src/hooks/useSdkSession.ts +46 -10
  46. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  47. package/src/hooks/useServerConnection.ts +81 -8
  48. package/src/index.ts +12 -0
  49. package/src/player/components/Player.test.ts +141 -13
  50. package/src/player/components/Player.tsx +58 -2
  51. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  52. package/src/player/components/Timeline.tsx +23 -572
  53. package/src/player/components/TimelineCanvas.tsx +9 -30
  54. package/src/player/components/TimelineOverlays.test.ts +67 -32
  55. package/src/player/components/TimelineOverlays.tsx +46 -107
  56. package/src/player/components/TimelineProvider.tsx +191 -0
  57. package/src/player/components/testRootHarness.ts +18 -0
  58. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  59. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  60. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  61. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  62. package/src/player/components/useTimelineProviderState.tsx +583 -0
  63. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  64. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  65. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  66. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  67. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  70. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  71. package/src/telemetry/events.test.ts +18 -0
  72. package/src/telemetry/events.ts +10 -0
  73. package/src/telemetry/system.test.ts +49 -0
  74. package/src/telemetry/system.ts +6 -0
  75. package/src/telemetry/tabId.test.ts +43 -0
  76. package/src/telemetry/tabId.ts +44 -0
  77. package/src/utils/previewCompositionSize.ts +26 -0
  78. package/src/utils/reloadDebug.ts +2 -4
  79. package/src/utils/sourcePatcher.ts +1 -1
  80. package/src/utils/studioTelemetry.test.ts +14 -0
  81. package/src/utils/studioTelemetry.ts +4 -0
  82. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -8,7 +8,7 @@
8
8
  * - onIframeLoad — orchestrates initializeAdapter with a message-based fallback
9
9
  */
10
10
 
11
- import { useCallback } from "react";
11
+ import { useCallback, useRef } from "react";
12
12
  import { liveTime, usePlayerStore } from "../store/playerStore";
13
13
  import type { TimelineElement } from "../store/playerStore";
14
14
  import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
@@ -33,7 +33,7 @@ import {
33
33
  // Re-exported for the tests and callers that have always imported it from here.
34
34
  export { resolveReloadSeekTime } from "./timelineSyncHydration";
35
35
 
36
- interface UseTimelineSyncCallbacksParams {
36
+ export interface UseTimelineSyncCallbacksParams {
37
37
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
38
38
  probeIntervalRef: React.MutableRefObject<ReturnType<typeof setInterval> | undefined>;
39
39
  pendingSeekRef: React.MutableRefObject<number | null>;
@@ -46,6 +46,19 @@ interface UseTimelineSyncCallbacksParams {
46
46
  setIsPlaying: (v: boolean) => void;
47
47
  attachIframeShortcutListeners: () => void;
48
48
  applyPreviewAudioState: () => void;
49
+ /**
50
+ * Fires once the restore-seek is issued and owns when `commit` (the store hydration) runs.
51
+ * The default reveals the iframe and commits at once; a shadow reload defers it to promotion.
52
+ */
53
+ onAdapterReady?: (
54
+ iframe: HTMLIFrameElement | null,
55
+ context: number | undefined,
56
+ commit: () => void,
57
+ ) => void;
58
+ /** False when the load for `context` was superseded: it must then cause no side effects. */
59
+ isCurrent?: (context?: number) => boolean;
60
+ /** Fires when no adapter appeared within the wait window. */
61
+ onLoadGiveUp?: (context?: number) => void;
49
62
  }
50
63
 
51
64
  /**
@@ -62,18 +75,54 @@ interface UseTimelineSyncCallbacksParams {
62
75
  * resets currentTime on project switch. Invariant: an edit NEVER moves the
63
76
  * playhead (the clamp below is the one sanctioned move — content shrank past it).
64
77
  */
65
- /**
66
- * Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
67
- * Safe to call when the iframe was never hidden (idempotent no-op). Every reload
68
- * completion + failure path funnels through here so the preview can never get
69
- * stuck invisible.
70
- */
78
+ /** Undo a hidden `visibility` on an iframe; idempotent no-op otherwise. */
71
79
  export function revealIframe(iframe: HTMLIFrameElement | null): void {
72
80
  if (iframe && iframe.style.visibility === "hidden") {
73
81
  iframe.style.visibility = "";
74
82
  }
75
83
  }
76
84
 
85
+ function revealAndCommit(
86
+ iframe: HTMLIFrameElement | null,
87
+ _context: number | undefined,
88
+ commit: () => void,
89
+ ): void {
90
+ revealIframe(iframe);
91
+ commit();
92
+ }
93
+
94
+ export type PreviewIframeRole = "live" | "shadow";
95
+
96
+ export interface PreviewIframeSlot {
97
+ gen: number;
98
+ role: PreviewIframeRole;
99
+ url?: string;
100
+ }
101
+
102
+ /** Queue a hidden shadow slot for the reload URL, replacing any earlier unpromoted shadow. */
103
+ export function planShadowReload(
104
+ slots: PreviewIframeSlot[],
105
+ nextGen: number,
106
+ url: string,
107
+ ): PreviewIframeSlot[] {
108
+ const live = slots.find((slot) => slot.role === "live");
109
+ return live ? [live, { gen: nextGen, role: "shadow", url }] : [{ gen: nextGen, role: "live" }];
110
+ }
111
+
112
+ /** Drop any pending shadow; the live slot (and its key) is untouched. */
113
+ export function planShadowDiscard(slots: PreviewIframeSlot[]): PreviewIframeSlot[] {
114
+ return slots.filter((slot) => slot.role === "live");
115
+ }
116
+
117
+ /** Atomically make the ready shadow the only (live) slot; a stale readyGen is a no-op. */
118
+ export function planShadowPromotion(
119
+ slots: PreviewIframeSlot[],
120
+ readyGen: number,
121
+ ): PreviewIframeSlot[] {
122
+ const ready = slots.find((slot) => slot.gen === readyGen && slot.role === "shadow");
123
+ return ready ? [{ ...ready, role: "live" }] : slots;
124
+ }
125
+
77
126
  /**
78
127
  * The transport TOTAL a clip-manifest message should write to the store.
79
128
  *
@@ -110,6 +159,9 @@ export function useTimelineSyncCallbacks({
110
159
  setIsPlaying,
111
160
  attachIframeShortcutListeners,
112
161
  applyPreviewAudioState,
162
+ onAdapterReady = revealAndCommit,
163
+ isCurrent,
164
+ onLoadGiveUp,
113
165
  }: UseTimelineSyncCallbacksParams) {
114
166
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
115
167
  const processTimelineMessage = useCallback(
@@ -183,94 +235,112 @@ export function useTimelineSyncCallbacks({
183
235
  } catch {}
184
236
  }, [iframeRef, syncTimelineElements]);
185
237
 
186
- const initializeAdapter = useCallback(() => {
187
- const adapter = getAdapter();
188
- if (!adapter || adapter.getDuration() <= 0) return false;
189
-
190
- adapter.pause();
191
- const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
192
- // The correct frame is now rendered — reveal the iframe that refreshPlayer hid
193
- // for the reload, so the user sees the restored frame directly (never the raw
194
- // all-clips DOM). Cleared unconditionally: any later failure path must not leave
195
- // the preview stuck invisible.
196
- revealIframe(iframeRef.current);
197
- // Keep non-React listeners such as the capture link and time display in sync
198
- // with the initial adapter seek on iframe load.
199
- liveTime.notify(startTime);
200
- syncAdapterDuration(adapter, setDuration);
201
- setCurrentTime(startTime);
202
- if (!isRefreshingRef.current) {
203
- // Enables Play from actual play-readiness, not just a known duration —
204
- // a click before this resolves used to start the timeline with media,
205
- // images or fonts still loading and never recover.
206
- requestTimelineReady(safeContentDocument(iframeRef.current));
207
- }
208
- isRefreshingRef.current = false;
209
- setIsPlaying(false);
210
-
211
- hydrateTimelineFromPreview({
212
- iframe: iframeRef.current,
213
- adapter,
238
+ const initializeAdapter = useCallback(
239
+ (context?: number) => {
240
+ if (isCurrent && !isCurrent(context)) return true;
241
+ const adapter = getAdapter();
242
+ if (!adapter || adapter.getDuration() <= 0) return false;
243
+
244
+ adapter.pause();
245
+ const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
246
+ const commit = () => {
247
+ // Keep non-React listeners such as the capture link and time display in sync
248
+ // with the initial adapter seek on iframe load.
249
+ liveTime.notify(startTime);
250
+ syncAdapterDuration(adapter, setDuration);
251
+ setCurrentTime(startTime);
252
+ if (!isRefreshingRef.current) {
253
+ // Enables Play from actual play-readiness, not just a known duration —
254
+ // a click before this resolves used to start the timeline with media,
255
+ // images or fonts still loading and never recover.
256
+ requestTimelineReady(safeContentDocument(iframeRef.current));
257
+ }
258
+ isRefreshingRef.current = false;
259
+ setIsPlaying(false);
260
+
261
+ hydrateTimelineFromPreview({
262
+ iframe: iframeRef.current,
263
+ adapter,
264
+ processTimelineMessage,
265
+ enrichMissingCompositions,
266
+ applyPreviewAudioState,
267
+ attachIframeShortcutListeners,
268
+ syncTimelineElements,
269
+ });
270
+ };
271
+ onAdapterReady(iframeRef.current, context, commit);
272
+ return true;
273
+ },
274
+ [
275
+ getAdapter,
276
+ setDuration,
277
+ setCurrentTime,
278
+ requestTimelineReady,
279
+ setIsPlaying,
214
280
  processTimelineMessage,
215
281
  enrichMissingCompositions,
216
- applyPreviewAudioState,
217
- attachIframeShortcutListeners,
218
282
  syncTimelineElements,
219
- });
220
- return true;
221
- }, [
222
- getAdapter,
223
- setDuration,
224
- setCurrentTime,
225
- requestTimelineReady,
226
- setIsPlaying,
227
- processTimelineMessage,
228
- enrichMissingCompositions,
229
- syncTimelineElements,
230
- attachIframeShortcutListeners,
231
- applyPreviewAudioState,
232
- iframeRef,
233
- isRefreshingRef,
234
- pendingSeekRef,
235
- ]);
236
-
237
- const onIframeLoad = useCallback(() => {
238
- applyPreviewAudioState();
239
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
283
+ attachIframeShortcutListeners,
284
+ applyPreviewAudioState,
285
+ onAdapterReady,
286
+ isCurrent,
287
+ iframeRef,
288
+ isRefreshingRef,
289
+ pendingSeekRef,
290
+ ],
291
+ );
240
292
 
241
- // Fast path: adapter already available (in-place reloads, cached compositions)
242
- if (initializeAdapter()) return;
293
+ // Drops the previous load's readiness listener so a superseded load cannot settle later.
294
+ const stopWaitingRef = useRef<(() => void) | null>(null);
243
295
 
244
- // The runtime posts "state" or "timeline" messages once ready.
245
- // Listen for those instead of polling.
246
- const iframe = iframeRef.current;
247
- let settled = false;
296
+ const onIframeLoad = useCallback(
297
+ (context?: number) => {
298
+ applyPreviewAudioState();
299
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
300
+ stopWaitingRef.current?.();
301
+ stopWaitingRef.current = null;
248
302
 
249
- const trySettle = () => {
250
- if (settled) return;
251
- if (initializeAdapter()) {
252
- settled = true;
253
- window.removeEventListener("message", onMessage);
254
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
255
- }
256
- };
303
+ // Fast path: adapter already available (in-place reloads, cached compositions)
304
+ if (initializeAdapter(context)) return;
257
305
 
258
- const onMessage = (e: MessageEvent) => {
259
- if (isPreviewReadinessMessage(e, iframe)) trySettle();
260
- };
261
- window.addEventListener("message", onMessage);
306
+ // The runtime posts "state" or "timeline" messages once ready.
307
+ // Listen for those instead of polling.
308
+ const iframe = iframeRef.current;
309
+ let settled = false;
262
310
 
263
- // Safety net: if no message arrives within 5s, try one last time then give up.
264
- probeIntervalRef.current = setTimeout(() => {
265
- if (!settled) {
266
- trySettle();
267
- }
268
- window.removeEventListener("message", onMessage);
269
- // Never leave the preview stuck invisible if the runtime never settled
270
- // (initializeAdapter reveals on success; this covers the give-up case).
271
- revealIframe(iframeRef.current);
272
- }, 5000) as unknown as ReturnType<typeof setInterval>;
273
- }, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
311
+ const trySettle = () => {
312
+ if (settled) return;
313
+ if (initializeAdapter(context)) {
314
+ settled = true;
315
+ window.removeEventListener("message", onMessage);
316
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
317
+ }
318
+ };
319
+
320
+ const onMessage = (e: MessageEvent) => {
321
+ if (isPreviewReadinessMessage(e, iframe)) trySettle();
322
+ };
323
+ window.addEventListener("message", onMessage);
324
+ stopWaitingRef.current = () => window.removeEventListener("message", onMessage);
325
+
326
+ // Safety net: if no message arrives within 5s, try one last time then give up.
327
+ probeIntervalRef.current = setTimeout(() => {
328
+ if (!settled) {
329
+ trySettle();
330
+ }
331
+ window.removeEventListener("message", onMessage);
332
+ if (!settled) onLoadGiveUp?.(context);
333
+ revealIframe(iframeRef.current);
334
+ }, 5000) as unknown as ReturnType<typeof setInterval>;
335
+ },
336
+ [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp],
337
+ );
338
+
339
+ const cancelPendingLoad = useCallback(() => {
340
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
341
+ stopWaitingRef.current?.();
342
+ stopWaitingRef.current = null;
343
+ }, [probeIntervalRef]);
274
344
 
275
345
  // Stable refs so mount-effect closures always call the latest version
276
346
  const processTimelineMessageRef = { current: processTimelineMessage };
@@ -283,5 +353,6 @@ export function useTimelineSyncCallbacks({
283
353
  enrichMissingCompositionsRef,
284
354
  initializeAdapter,
285
355
  onIframeLoad,
356
+ cancelPendingLoad,
286
357
  };
287
358
  }
@@ -9,6 +9,7 @@ vi.mock("./client", () => ({
9
9
 
10
10
  const {
11
11
  trackStudioSessionStart,
12
+ trackPreviewFirstFrame,
12
13
  trackStudioRenderStart,
13
14
  trackStudioRazorSplit,
14
15
  trackStudioExpandedClipEdit,
@@ -34,6 +35,23 @@ describe("studio telemetry events", () => {
34
35
  expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: false });
35
36
  });
36
37
 
38
+ it("trackPreviewFirstFrame emits timing, composition duration, and version", () => {
39
+ trackPreviewFirstFrame({
40
+ duration_ms: 842,
41
+ composition_seconds: 12.5,
42
+ clip_count: 8,
43
+ media_clip_count: 5,
44
+ studio_version: "0.8.56",
45
+ });
46
+ expect(trackEvent).toHaveBeenCalledWith("preview_first_frame", {
47
+ duration_ms: 842,
48
+ composition_seconds: 12.5,
49
+ clip_count: 8,
50
+ media_clip_count: 5,
51
+ studio_version: "0.8.56",
52
+ });
53
+ });
54
+
37
55
  it("trackStudioRenderStart emits 'studio_render_start' with all render opts", () => {
38
56
  trackStudioRenderStart({
39
57
  fps: 30,
@@ -11,6 +11,16 @@ export function trackStudioSessionStart(props: { has_project: boolean }): void {
11
11
  });
12
12
  }
13
13
 
14
+ export function trackPreviewFirstFrame(props: {
15
+ duration_ms: number;
16
+ composition_seconds: number;
17
+ clip_count: number;
18
+ media_clip_count: number;
19
+ studio_version: string;
20
+ }): void {
21
+ trackEvent("preview_first_frame", props);
22
+ }
23
+
14
24
  export function trackStudioRenderStart(props: {
15
25
  fps: number;
16
26
  quality: string;
@@ -0,0 +1,49 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // The `studio_*` transport carries browser metadata built here, and this is its
5
+ // only test. tab_id has a module test of its own, but nothing there fails if
6
+ // this file stops attaching it — the two are separate facts.
7
+ describe("browser system meta", () => {
8
+ // Both modules are imported AFTER resetModules so they share one tabId
9
+ // instance; a top-level import would be a different copy of the memo.
10
+ beforeEach(() => {
11
+ vi.resetModules();
12
+ });
13
+
14
+ async function load() {
15
+ const [{ getBrowserSystemMeta }, tab] = await Promise.all([
16
+ import("./system"),
17
+ import("./tabId"),
18
+ ]);
19
+ return { getBrowserSystemMeta, resolveTabId: tab.resolveTabId };
20
+ }
21
+
22
+ it("carries the tab id, from the same source as the studio:* transport", async () => {
23
+ const { getBrowserSystemMeta, resolveTabId } = await load();
24
+ expect(getBrowserSystemMeta().tab_id).toBe(resolveTabId());
25
+ });
26
+
27
+ it("keeps the meta memoized, so one page reports one tab id", async () => {
28
+ const { getBrowserSystemMeta } = await load();
29
+ const first = getBrowserSystemMeta();
30
+ expect(getBrowserSystemMeta()).toBe(first);
31
+ expect(getBrowserSystemMeta().tab_id).toBe(first.tab_id);
32
+ });
33
+
34
+ it("has a tab id in the no-DOM fallback, not an empty string", async () => {
35
+ // The empty-meta branch is taken on SSR. An empty or missing tab_id would
36
+ // read in PostHog as "this event predates the property" rather than "this
37
+ // event has no page", which is the encoding mistake this property exists
38
+ // to avoid. Stubbing the globals is what actually reaches that branch —
39
+ // happy-dom otherwise satisfies the `typeof window` check every time.
40
+ vi.stubGlobal("navigator", undefined);
41
+ vi.stubGlobal("window", undefined);
42
+ const { getBrowserSystemMeta } = await load();
43
+ const meta = getBrowserSystemMeta();
44
+ expect(meta.user_agent).toBe("");
45
+ expect(typeof meta.tab_id).toBe("string");
46
+ expect(meta.tab_id.length).toBeGreaterThan(0);
47
+ vi.unstubAllGlobals();
48
+ });
49
+ });
@@ -5,6 +5,7 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  import { agentRuntimeProperty } from "./agentRuntime";
8
+ import { tabIdProperty } from "./tabId";
8
9
 
9
10
  export interface BrowserSystemMeta {
10
11
  user_agent: string;
@@ -17,6 +18,8 @@ export interface BrowserSystemMeta {
17
18
  studio_version: string;
18
19
  /** Which coding agent is driving this Studio, or null when a person is. */
19
20
  agent_runtime: string;
21
+ /** One id per page load. distinct_id identifies a browser, not a page. */
22
+ tab_id: string;
20
23
  }
21
24
 
22
25
  const EMPTY_META: BrowserSystemMeta = {
@@ -29,6 +32,7 @@ const EMPTY_META: BrowserSystemMeta = {
29
32
  is_mobile: false,
30
33
  studio_version: "dev",
31
34
  agent_runtime: "none",
35
+ tab_id: "unknown",
32
36
  };
33
37
 
34
38
  let cached: BrowserSystemMeta | null = null;
@@ -54,6 +58,8 @@ export function getBrowserSystemMeta(): BrowserSystemMeta {
54
58
  // Same value the `studio:*` transport attaches, from the same accessor —
55
59
  // two families that disagreed here would split every agent breakdown.
56
60
  agent_runtime: agentRuntimeProperty(),
61
+ // Same shared module as the `studio:*` transport, for the same reason.
62
+ tab_id: tabIdProperty(),
57
63
  };
58
64
  return cached;
59
65
  }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { resetTabIdForTests, resolveTabId, tabIdProperty } from "./tabId";
3
+
4
+ afterEach(() => {
5
+ resetTabIdForTests();
6
+ });
7
+
8
+ describe("tab id", () => {
9
+ it("is stable for the life of the page", () => {
10
+ // The whole value of the property: every event from one page load must
11
+ // carry the SAME id, or a burst still reads as several sources.
12
+ const first = resolveTabId();
13
+ expect(resolveTabId()).toBe(first);
14
+ expect(tabIdProperty()).toBe(first);
15
+ });
16
+
17
+ it("is not empty, since an empty value would read as an absent one", () => {
18
+ expect(resolveTabId().length).toBeGreaterThan(0);
19
+ });
20
+
21
+ it("mints a new id after a fresh page load", () => {
22
+ // resetTabIdForTests stands in for a reload: the memo is all that survives
23
+ // a page, so clearing it is exactly what a reload does.
24
+ const first = resolveTabId();
25
+ resetTabIdForTests();
26
+ expect(resolveTabId()).not.toBe(first);
27
+ });
28
+
29
+ it("never touches storage", () => {
30
+ // Deliberate: a stored id would drift into a tab-session id, silently
31
+ // changing what every query means. This asserts the intent directly rather
32
+ // than inferring it, so adding a read OR a write fails here.
33
+ const seen: string[] = [];
34
+ vi.stubGlobal("localStorage", {
35
+ getItem: (k: string) => (seen.push(`get:${k}`), null),
36
+ setItem: (k: string) => void seen.push(`set:${k}`),
37
+ removeItem: (k: string) => void seen.push(`remove:${k}`),
38
+ });
39
+ resolveTabId();
40
+ expect(seen).toEqual([]);
41
+ vi.unstubAllGlobals();
42
+ });
43
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A per-page-load identifier, attached to every Studio event from BOTH
3
+ * transports (`studio:*` via utils/studioTelemetry.ts, `studio_*` via
4
+ * telemetry/system.ts).
5
+ *
6
+ * Why it exists: `distinct_id` identifies a browser, not a page. Several
7
+ * investigations have stalled on "two tabs or one tab switching?" — a burst of
8
+ * events alternating between two project hashes is indistinguishable from a
9
+ * user moving between projects, and `session_start` fires once per page LOAD
10
+ * (main.tsx), so counting it cannot tell them apart either. Nothing in the
11
+ * payload named the page. This does.
12
+ *
13
+ * Semantics, stated plainly because the name invites the wrong reading: one id
14
+ * per PAGE LOAD. A hard reload mints a new one. It is not "the same tab across
15
+ * reloads" — that is a different, harder question, and nobody has needed it yet.
16
+ *
17
+ * Deliberately not persisted. Storage would survive a reload and turn this into
18
+ * a tab-session id, silently changing what every query means; and a blocked or
19
+ * cleared storage would make the property intermittently absent, which reads as
20
+ * "old client" rather than "storage unavailable".
21
+ *
22
+ * Memoized like agentRuntime.ts and distinctId.ts: fixed for the life of the
23
+ * page, so a per-event caller does not pay for generation on every emit.
24
+ */
25
+
26
+ import { generateId } from "../utils/generateId";
27
+
28
+ let cached: string | null = null;
29
+
30
+ /** The page's id. Generated once, on first read. */
31
+ export function resolveTabId(): string {
32
+ if (cached === null) cached = generateId();
33
+ return cached;
34
+ }
35
+
36
+ /** The same value as a telemetry property, for the `studio_*` transport's meta. */
37
+ export function tabIdProperty(): string {
38
+ return resolveTabId();
39
+ }
40
+
41
+ /** Test seam: the memo would otherwise leak between cases in one module load. */
42
+ export function resetTabIdForTests(): void {
43
+ cached = null;
44
+ }
@@ -0,0 +1,26 @@
1
+ export interface PreviewCompositionSize {
2
+ width: number;
3
+ height: number;
4
+ }
5
+
6
+ /** The root composition's authored size, read from the preview document itself. */
7
+ // fallow-ignore-next-line complexity
8
+ export function readPreviewCompositionSize(
9
+ iframe: HTMLIFrameElement | null,
10
+ ): PreviewCompositionSize | null {
11
+ try {
12
+ const doc = iframe?.contentDocument;
13
+ const root =
14
+ doc?.querySelector("[data-composition-id][data-width][data-height]") ??
15
+ doc?.querySelector("[data-width][data-height]");
16
+ if (!root) return null;
17
+ const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
18
+ const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
19
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
20
+ return null;
21
+ }
22
+ return { width, height };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
@@ -1,10 +1,8 @@
1
1
  // Preview full-reload diagnostics — grep [hf-reload]. Off by default; opt in per
2
2
  // session with `localStorage.setItem("hf-reload-debug", "1")` (then reload).
3
3
  //
4
- // A full reload blanks the stage for ~100-300ms, so any reload the user did not
5
- // ask for reads as a flash. These lines answer the only question that matters
6
- // when one appears: who asked for it, and why the write that triggered it was
7
- // not recognised as Studio's own.
4
+ // A full reload no longer blanks the stage (see useShadowPreviewReload.ts). These lines answer
5
+ // who asked for a reload and why the write was not recognised as Studio's own.
8
6
  import { makeStudioDebugLogger } from "./studioDebug";
9
7
 
10
8
  export const logReload = makeStudioDebugLogger("reload");
@@ -420,7 +420,7 @@ function findMatchingClosingTagIndex(html: string, tagName: string, contentStart
420
420
  return -1;
421
421
  }
422
422
 
423
- const HTML_BOOLEAN_ATTRIBUTES = new Set([
423
+ export const HTML_BOOLEAN_ATTRIBUTES = new Set([
424
424
  "loop",
425
425
  "muted",
426
426
  "autoplay",
@@ -66,6 +66,20 @@ describe("studioTelemetry — shared opt-out and canary properties", () => {
66
66
  });
67
67
  });
68
68
 
69
+ // The property is useless if the transport does not attach it: the module
70
+ // has its own test, but nothing there fails if this line is deleted.
71
+ it("stamps every event with a tab id that is stable within the page", async () => {
72
+ trackStudioEvent("first");
73
+ trackStudioEvent("second");
74
+ const events = await sentEvents();
75
+ expect(events).toHaveLength(2);
76
+ const tabIds = events.map((e) => (e["properties"] as Record<string, unknown>)["tab_id"]);
77
+ expect(typeof tabIds[0]).toBe("string");
78
+ expect((tabIds[0] as string).length).toBeGreaterThan(0);
79
+ // Same page, same id — otherwise a burst still reads as several sources.
80
+ expect(tabIds[1]).toBe(tabIds[0]);
81
+ });
82
+
69
83
  it("lets an explicit property win over the canary mixin", async () => {
70
84
  trackStudioEvent("thing_happened", { "$feature/canary-test-one": "false" });
71
85
  const events = await sentEvents();
@@ -2,6 +2,7 @@ import { resolveStudioDistinctId } from "../telemetry/distinctId";
2
2
  import { browserTelemetryAllowed } from "../telemetry/policy";
3
3
  import { canaryEventProperties } from "../telemetry/canary";
4
4
  import { agentRuntimeProperty } from "../telemetry/agentRuntime";
5
+ import { tabIdProperty } from "../telemetry/tabId";
5
6
 
6
7
  // PostHog public ingest key — write-only, safe to ship in the client bundle
7
8
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -48,6 +49,9 @@ function getSessionProperties(): EventProperties {
48
49
  // and a property that only some events carry cannot answer it — the
49
50
  // breakdown silently reads as though the agent never used the rest.
50
51
  agent_runtime: agentRuntimeProperty(),
52
+ // Which page load this came from — distinct_id identifies the browser, not
53
+ // the page, so two tabs are otherwise one indistinguishable stream.
54
+ tab_id: tabIdProperty(),
51
55
  screen_width: window.screen?.width,
52
56
  screen_height: window.screen?.height,
53
57
  viewport_width: window.innerWidth,