@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -10,8 +10,10 @@ import {
10
10
  } from "../../utils/studioUiPreferences";
11
11
  import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
12
12
  import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
13
+ import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
13
14
 
14
15
  export type { KeyframeCacheEntry } from "./keyframeSlice";
16
+ export { liveTime } from "./liveTime";
15
17
 
16
18
  export interface TimelineElement {
17
19
  id: string;
@@ -220,15 +222,10 @@ interface PlayerState extends KeyframeSlice {
220
222
  requestSeek: (time: number) => void;
221
223
  clearSeekRequest: () => void;
222
224
 
223
- /**
224
- * Request the timeline to scroll a clip into view (e.g. clicking an
225
- * already-added asset card in the sidebar). Consumed and cleared by
226
- * useTimelineRevealClip. The nonce makes repeat requests for the same
227
- * clip observable so a second click re-reveals after the user scrolls away.
228
- */
229
- clipRevealRequest: { elementId: string; nonce: number } | null;
230
- requestClipReveal: (elementId: string) => void;
231
- clearClipRevealRequest: () => void;
225
+ timelineFocus: TimelineFocusRequest | null;
226
+ timelineFocusNonce: number;
227
+ requestTimelineFocus: (id: string) => void;
228
+ clearTimelineFocus: (nonce: number) => void;
232
229
 
233
230
  lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
234
231
  setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
@@ -279,19 +276,6 @@ interface BeatHistoryEntry {
279
276
  label: string;
280
277
  }
281
278
 
282
- // Lightweight pub-sub for current time during playback.
283
- // Bypasses React state so the RAF loop can update the playhead/time display
284
- // without triggering re-renders on every frame.
285
- type TimeListener = (time: number) => void;
286
- const _timeListeners = new Set<TimeListener>();
287
- export const liveTime = {
288
- notify: (t: number) => _timeListeners.forEach((cb) => cb(t)),
289
- subscribe: (cb: TimeListener) => {
290
- _timeListeners.add(cb);
291
- return () => _timeListeners.delete(cb);
292
- },
293
- };
294
-
295
279
  export function createTimelineResetState() {
296
280
  return {
297
281
  isPlaying: false,
@@ -313,8 +297,8 @@ export function createTimelineResetState() {
313
297
  focusedEaseSegment: null,
314
298
  selectedElementIds: new Set<string>(),
315
299
  requestedSeekTime: null,
316
- clipRevealRequest: null,
317
300
  lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
301
+ timelineFocus: null,
318
302
  keyframeCache: new Map<string, KeyframeCacheEntry>(),
319
303
  gsapAnimations: new Map<string, GsapAnimation[]>(),
320
304
  beatAnalysis: null,
@@ -352,7 +336,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
352
336
  activeTool: "select",
353
337
  setActiveTool: (tool) => set({ activeTool: tool }),
354
338
 
355
- ...createKeyframeSlice(set),
339
+ ...createKeyframeSlice(set, () => ({
340
+ timelineProjectId: get().timelineProjectId,
341
+ timelineSessionEpoch: get().timelineSessionEpoch,
342
+ })),
356
343
 
357
344
  activeKeyframePct: null,
358
345
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
@@ -384,12 +371,23 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
384
371
  requestSeek: (time) => set({ requestedSeekTime: time }),
385
372
  clearSeekRequest: () => set({ requestedSeekTime: null }),
386
373
 
387
- clipRevealRequest: null,
388
- requestClipReveal: (elementId) =>
389
- set((s) => ({
390
- clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
391
- })),
392
- clearClipRevealRequest: () => set({ clipRevealRequest: null }),
374
+ timelineFocus: null,
375
+ timelineFocusNonce: 0,
376
+ requestTimelineFocus: (id) =>
377
+ set((s) => {
378
+ const nonce = s.timelineFocusNonce + 1;
379
+ return {
380
+ timelineFocusNonce: nonce,
381
+ timelineFocus: createTimelineFocusRequest(
382
+ id,
383
+ s.timelineProjectId,
384
+ s.timelineSessionEpoch,
385
+ nonce,
386
+ ),
387
+ };
388
+ }),
389
+ clearTimelineFocus: (nonce) =>
390
+ set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)),
393
391
 
394
392
  lintFindingsByElement: new Map(),
395
393
  setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
@@ -541,6 +539,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
541
539
  selectedElementIds,
542
540
  activeKeyframePct: null,
543
541
  motionPathArmed: false,
542
+ focusedEaseSegment: null,
544
543
  }
545
544
  : { selectedElementId: id, selectedElementIds };
546
545
  }),
@@ -550,9 +549,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
550
549
  setSelectionAnchor: (id) =>
551
550
  set((s) => {
552
551
  if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
553
- return { selectedElementId: id };
552
+ return {
553
+ selectedElementId: id,
554
+ focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
555
+ };
554
556
  }
555
- return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set<string>() };
557
+ return {
558
+ selectedElementId: id,
559
+ selectedElementIds: id ? new Set([id]) : new Set<string>(),
560
+ focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
561
+ };
556
562
  }),
557
563
  updateElement: (elementId, updates) =>
558
564
  set((state) => ({
@@ -560,9 +566,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
560
566
  (el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
561
567
  ),
562
568
  })),
563
- // playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are
564
- // intentionally absent from createTimelineResetState because they are user
565
- // preferences that survive both source refreshes and project switches.
569
+ // UI preferences intentionally survive reset. So do timelineSessionEpoch and
570
+ // focusedEaseRequestNonce: the epoch advances only when project identity
571
+ // changes, while a monotonic nonce prevents collisions with stale consumers.
566
572
  beginTimelineSession: (projectId) =>
567
573
  set((state) => {
568
574
  if (state.timelineProjectId === projectId) return state;
@@ -575,18 +581,15 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
575
581
  reset: () => set(createTimelineResetState()),
576
582
  }));
577
583
 
578
- // Bug-bash aid: expose the store so a reproduction can dump live state from the
579
- // console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
580
- // handle; no behavioural effect.
581
- // Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
582
- // Turbopack), so guard the access like the telemetry client does.
583
584
  function isDevBuild(): boolean {
584
585
  try {
585
586
  return import.meta.env.DEV === true;
586
587
  } catch {
588
+ // Turbopack and other non-Vite bundlers may not provide import.meta.env.
587
589
  return false;
588
590
  }
589
591
  }
590
592
  if (isDevBuild() && typeof window !== "undefined") {
593
+ // Console handle for dumping live Studio state during bug-bash reproduction.
591
594
  (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
592
595
  }
@@ -0,0 +1,15 @@
1
+ export interface TimelineFocusRequest {
2
+ id: string;
3
+ projectId: string | null;
4
+ sessionEpoch: number;
5
+ nonce: number;
6
+ }
7
+
8
+ export function createTimelineFocusRequest(
9
+ id: string,
10
+ projectId: string | null,
11
+ sessionEpoch: number,
12
+ nonce: number,
13
+ ): TimelineFocusRequest {
14
+ return { id, projectId, sessionEpoch, nonce };
15
+ }
@@ -0,0 +1,325 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
4
+ import { evaluateCanary } from "@hyperframes/core/canary";
5
+
6
+ // Pin the registry: real entries move as rollouts ramp, and these tests are
7
+ // about the BINDING (does the browser supply the right three inputs?), not
8
+ // about whichever canaries happen to be live today.
9
+ // The policy reads import.meta.env.DEV, which vitest sets true — without
10
+ // this every case would resolve to telemetry_opt_out. Controlled explicitly
11
+ // so each test states the privacy posture it is exercising.
12
+ const policyState = { allowed: true };
13
+ vi.mock("./policy", () => ({
14
+ browserTelemetryAllowed: () => policyState.allowed,
15
+ }));
16
+
17
+ vi.mock("@hyperframes/core/canary-registry", async () => {
18
+ const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
19
+ "@hyperframes/core/canary-registry",
20
+ );
21
+ const defs = [
22
+ {
23
+ name: "on-everywhere",
24
+ percentage: 100,
25
+ description: "",
26
+ owner: "t",
27
+ sunsetAfter: "2099-01-01",
28
+ },
29
+ {
30
+ name: "off-everywhere",
31
+ percentage: 0,
32
+ description: "",
33
+ owner: "t",
34
+ sunsetAfter: "2099-01-01",
35
+ },
36
+ ];
37
+ return { ...actual, CANARIES: defs, findCanary: (n: string) => defs.find((d) => d.name === n) };
38
+ });
39
+
40
+ const {
41
+ isCanaryEnabled,
42
+ resolveCanary,
43
+ canaryEventProperties,
44
+ canaryParamName,
45
+ __resetStudioCanaryCacheForTests,
46
+ } = await import("./canary");
47
+ const { resolveStudioDistinctId, __resetStudioDistinctIdForTests } = await import("./distinctId");
48
+
49
+ function setSearch(search: string): void {
50
+ window.history.replaceState({}, "", `/${search}`);
51
+ }
52
+
53
+ beforeEach(() => {
54
+ policyState.allowed = true;
55
+ localStorage.clear();
56
+ sessionStorage.clear();
57
+ setSearch("");
58
+ delete window.__HF_CLI_DISTINCT_ID;
59
+ delete window.__HF_CLI_BUCKET_SEED;
60
+ Object.defineProperty(navigator, "webdriver", { value: false, configurable: true });
61
+ __resetStudioCanaryCacheForTests();
62
+ __resetStudioDistinctIdForTests();
63
+ });
64
+
65
+ afterEach(() => {
66
+ setSearch("");
67
+ __resetStudioCanaryCacheForTests();
68
+ __resetStudioDistinctIdForTests();
69
+ });
70
+
71
+ describe("studio canary binding", () => {
72
+ it("reads the percentage from the shared registry", () => {
73
+ expect(isCanaryEnabled("on-everywhere")).toBe(true);
74
+ expect(isCanaryEnabled("off-everywhere")).toBe(false);
75
+ });
76
+
77
+ it("an unregistered name is off, not a throw — a typo must not break the editor", () => {
78
+ expect(isCanaryEnabled("nope")).toBe(false);
79
+ expect(resolveCanary("nope").reason).toBe("out_of_cohort");
80
+ });
81
+
82
+ it("derives the query param from the canary name", () => {
83
+ expect(canaryParamName("de-parallel-router")).toBe("hf_canary_de_parallel_router");
84
+ });
85
+ });
86
+
87
+ describe("URL override", () => {
88
+ it("turns a canary on and off from the query string", () => {
89
+ setSearch("?hf_canary_off_everywhere=on");
90
+ expect(resolveCanary("off-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
91
+
92
+ __resetStudioCanaryCacheForTests();
93
+ setSearch("?hf_canary_on_everywhere=off");
94
+ expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "forced_off" });
95
+ });
96
+
97
+ it("survives losing the query string, so in-app navigation keeps the override", () => {
98
+ setSearch("?hf_canary_off_everywhere=on");
99
+ expect(isCanaryEnabled("off-everywhere")).toBe(true);
100
+
101
+ // Navigate away from the param — a real SPA drops it constantly.
102
+ __resetStudioCanaryCacheForTests();
103
+ setSearch("");
104
+ expect(isCanaryEnabled("off-everywhere")).toBe(true);
105
+ });
106
+
107
+ it("is session-scoped, not persisted to localStorage", () => {
108
+ // A URL-borne override must not silently pin a browser into a cohort
109
+ // forever; closing the tab is the reset.
110
+ setSearch("?hf_canary_off_everywhere=on");
111
+ expect(isCanaryEnabled("off-everywhere")).toBe(true);
112
+ expect(JSON.stringify(localStorage).includes("canary")).toBe(false);
113
+ expect(sessionStorage.length).toBeGreaterThan(0);
114
+ });
115
+
116
+ it("=reset clears a stored override", () => {
117
+ setSearch("?hf_canary_off_everywhere=on");
118
+ expect(isCanaryEnabled("off-everywhere")).toBe(true);
119
+
120
+ __resetStudioCanaryCacheForTests();
121
+ setSearch("?hf_canary_off_everywhere=reset");
122
+ expect(isCanaryEnabled("off-everywhere")).toBe(false);
123
+
124
+ __resetStudioCanaryCacheForTests();
125
+ setSearch("");
126
+ expect(isCanaryEnabled("off-everywhere")).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe("automated browsers", () => {
131
+ it("are excluded from percentage enrolment", () => {
132
+ Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
133
+ expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "excluded" });
134
+ });
135
+
136
+ it("still honour an explicit override, so a canary can be tested under automation", () => {
137
+ Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
138
+ setSearch("?hf_canary_on_everywhere=on");
139
+ expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
140
+ });
141
+ });
142
+
143
+ describe("cohort identity", () => {
144
+ it("buckets on the CLI's bucket seed when injected — the unit that survives config wipes", () => {
145
+ // The CLI buckets on its bucketSeed (inherited across config wipes via
146
+ // the install-state file), so a CLI-launched Studio must bucket on the
147
+ // SAME seed or the two surfaces would split one machine across cohorts.
148
+ const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
149
+ const cliSeed = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
150
+ window.__HF_CLI_DISTINCT_ID = cliId;
151
+ window.__HF_CLI_BUCKET_SEED = cliSeed;
152
+ __resetStudioDistinctIdForTests();
153
+ __resetStudioCanaryCacheForTests();
154
+
155
+ // Telemetry identity still adopts the DISTINCT id — the seed only buckets.
156
+ expect(resolveStudioDistinctId()).toBe(cliId);
157
+ const viaBinding = resolveCanary("on-everywhere").bucket;
158
+ const bySeed = evaluateCanary({
159
+ feature: "on-everywhere",
160
+ unitId: cliSeed,
161
+ percentage: 100,
162
+ }).bucket;
163
+ expect(viaBinding).toBe(bySeed);
164
+ });
165
+
166
+ it("buckets on the Studio distinct id when no seed is injected (standalone Studio)", () => {
167
+ const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
168
+ window.__HF_CLI_DISTINCT_ID = cliId;
169
+ __resetStudioDistinctIdForTests();
170
+ __resetStudioCanaryCacheForTests();
171
+
172
+ expect(resolveStudioDistinctId()).toBe(cliId);
173
+ const viaBinding = resolveCanary("on-everywhere").bucket;
174
+ const direct = evaluateCanary({
175
+ feature: "on-everywhere",
176
+ unitId: cliId,
177
+ percentage: 100,
178
+ }).bucket;
179
+ expect(viaBinding).toBe(direct);
180
+ });
181
+
182
+ it("memoizes so a decision cannot change mid-session", () => {
183
+ expect(isCanaryEnabled("off-everywhere")).toBe(false);
184
+ // A late override must NOT flip a component that already rendered.
185
+ setSearch("?hf_canary_off_everywhere=on");
186
+ expect(isCanaryEnabled("off-everywhere")).toBe(false);
187
+ __resetStudioCanaryCacheForTests();
188
+ expect(isCanaryEnabled("off-everywhere")).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe("telemetry", () => {
193
+ it("emits the same PostHog flag-shaped properties as the CLI", () => {
194
+ expect(canaryEventProperties()).toEqual({
195
+ "$feature/canary-on-everywhere": "true",
196
+ "$feature/canary-off-everywhere": "false",
197
+ });
198
+
199
+ __resetStudioCanaryCacheForTests();
200
+ setSearch("?hf_canary_on_everywhere=off");
201
+ expect(canaryEventProperties()["$feature/canary-on-everywhere"]).toBe("false");
202
+ });
203
+ });
204
+
205
+ describe("telemetry opt-out is canary opt-out", () => {
206
+ // The studio opt-out lever, per telemetry/config.ts.
207
+ const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
208
+
209
+ it("does not enrol an opted-out browser profile", () => {
210
+ policyState.allowed = false;
211
+ localStorage.setItem(OPT_OUT_KEY, "1");
212
+ // on-everywhere is at 100% — it would be on for everyone otherwise.
213
+ expect(resolveCanary("on-everywhere")).toEqual({
214
+ enabled: false,
215
+ reason: "telemetry_opt_out",
216
+ });
217
+ });
218
+
219
+ it("never buckets an opted-out profile — no cohort is assigned at all", () => {
220
+ policyState.allowed = false;
221
+ localStorage.setItem(OPT_OUT_KEY, "1");
222
+ expect(resolveCanary("on-everywhere").bucket).toBeUndefined();
223
+ });
224
+
225
+ it("still honours an explicit URL override", () => {
226
+ policyState.allowed = false;
227
+ localStorage.setItem(OPT_OUT_KEY, "1");
228
+ setSearch("?hf_canary_off_everywhere=on");
229
+ expect(resolveCanary("off-everywhere")).toEqual({ enabled: true, reason: "forced_on" });
230
+ });
231
+
232
+ it("reports every canary as false when opted out", () => {
233
+ policyState.allowed = false;
234
+ localStorage.setItem(OPT_OUT_KEY, "1");
235
+ expect(canaryEventProperties()).toEqual({
236
+ "$feature/canary-on-everywhere": "false",
237
+ "$feature/canary-off-everywhere": "false",
238
+ });
239
+ });
240
+ });
241
+
242
+ describe("CLI-launched Studio adopts the CLI's decisions", () => {
243
+ const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
244
+ const cohort = (enabled: boolean) => ({ enabled, forced: false });
245
+ const forced = (enabled: boolean) => ({ enabled, forced: true });
246
+
247
+ afterEach(() => {
248
+ delete window.__HF_CLI_CANARY_DECISIONS;
249
+ });
250
+
251
+ // The divergence this exists for: CLI telemetry off resolves every canary
252
+ // to telemetry_opt_out, but Studio's opt-out is a SEPARATE localStorage
253
+ // flag it cannot see — left to itself it would evaluate and could enrol.
254
+ it("stays off when the CLI opted out, even though Studio's own flag is unset", () => {
255
+ expect(localStorage.getItem(OPT_OUT_KEY)).toBeNull();
256
+ window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": cohort(false) };
257
+ expect(resolveCanary("on-everywhere").enabled).toBe(false);
258
+ });
259
+
260
+ // HF_CANARY_* never crosses into the browser, so before this the CLI was
261
+ // forced on and Studio silently guessed from the percentage.
262
+ it("turns on when the CLI forced it on, with no URL param present", () => {
263
+ window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": forced(true) };
264
+ expect(resolveCanary("off-everywhere").enabled).toBe(true);
265
+ });
266
+
267
+ it("beats a contradicting URL override — one render must not run half-enrolled", () => {
268
+ window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": forced(false) };
269
+ setSearch("?hf_canary_on_everywhere=on");
270
+ expect(resolveCanary("on-everywhere").enabled).toBe(false);
271
+ });
272
+
273
+ it("beats the seed-derived bucket", () => {
274
+ window.__HF_CLI_BUCKET_SEED = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
275
+ window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": cohort(false) };
276
+ expect(resolveCanary("on-everywhere").enabled).toBe(false);
277
+ });
278
+
279
+ it("falls back to local evaluation for a canary the CLI did not publish", () => {
280
+ window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": cohort(true) };
281
+ expect(resolveCanary("on-everywhere").enabled).toBe(true);
282
+ });
283
+
284
+ it("ignores a malformed entry rather than trusting it", () => {
285
+ window.__HF_CLI_CANARY_DECISIONS = {
286
+ "on-everywhere": { enabled: "false" },
287
+ } as unknown as Record<string, { enabled?: boolean; forced?: boolean }>;
288
+ // Falls through to local evaluation: on-everywhere is at 100%.
289
+ expect(resolveCanary("on-everywhere").enabled).toBe(true);
290
+ });
291
+
292
+ // Miguel's P1: a percentage roll from the CLI must NOT be able to enrol a
293
+ // browser profile that opted out. The two surfaces have independent
294
+ // opt-outs, and CLI telemetry being on says nothing about this profile.
295
+ describe("precedence against Studio's own opt-out", () => {
296
+ beforeEach(() => {
297
+ policyState.allowed = false;
298
+ localStorage.setItem(OPT_OUT_KEY, "1");
299
+ });
300
+
301
+ it("refuses a CLI COHORT enrolment when this profile opted out", () => {
302
+ window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": cohort(true) };
303
+ expect(resolveCanary("off-everywhere")).toEqual({
304
+ enabled: false,
305
+ reason: "telemetry_opt_out",
306
+ });
307
+ });
308
+
309
+ it("honours a CLI FORCED enrolment even when this profile opted out", () => {
310
+ // An explicit HF_CANARY_* override is a deliberate operator choice —
311
+ // the documented escalation channel, same as a local URL override.
312
+ window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": forced(true) };
313
+ expect(resolveCanary("off-everywhere")).toEqual({ enabled: true, reason: "forced_on" });
314
+ });
315
+
316
+ it("honours a CLI forced-OFF when this profile opted out", () => {
317
+ window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": forced(false) };
318
+ expect(resolveCanary("on-everywhere")).toEqual({ enabled: false, reason: "forced_off" });
319
+ });
320
+
321
+ it("still refuses cohort enrolment with no CLI decision at all", () => {
322
+ expect(resolveCanary("on-everywhere").reason).toBe("telemetry_opt_out");
323
+ });
324
+ });
325
+ });