@hyperframes/studio 0.7.89 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-ulQEdWKo.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-CddKwfQ1.js +0 -428
@@ -22,6 +22,34 @@ export interface KeyframeCacheEntry {
22
22
  easeEach?: string;
23
23
  }
24
24
 
25
+ export interface FocusedEaseSegment {
26
+ animationId: string;
27
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
28
+ tweenPercentage: number;
29
+ elementId: string;
30
+ projectId: string | null;
31
+ sessionEpoch: number;
32
+ nonce: number;
33
+ }
34
+
35
+ type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
36
+
37
+ interface TimelineSessionIdentity {
38
+ timelineProjectId: string | null;
39
+ timelineSessionEpoch: number;
40
+ }
41
+
42
+ export function isFocusedEaseRequestCurrent(
43
+ request: FocusedEaseSegment,
44
+ state: TimelineSessionIdentity & { selectedElementId: string | null },
45
+ ): boolean {
46
+ return (
47
+ request.projectId === state.timelineProjectId &&
48
+ request.sessionEpoch === state.timelineSessionEpoch &&
49
+ request.elementId === state.selectedElementId
50
+ );
51
+ }
52
+
25
53
  export interface KeyframeSlice {
26
54
  /** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
27
55
  selectedKeyframes: Set<string>;
@@ -35,22 +63,14 @@ export interface KeyframeSlice {
35
63
  /** Union-expand clips (keyframed clips are expanded by default on load). */
36
64
  expandClips: (ids: readonly string[]) => void;
37
65
 
38
- /** elementId scopes the request to one element so a shared (class-selector)
39
- * animation id can't open the ease editor on the wrong element. */
40
- focusedEaseSegment: {
41
- animationId: string;
42
- collidingAnimationTargets?: AnimationKeyframeTarget[];
43
- tweenPercentage: number;
44
- elementId: string;
45
- } | null;
46
- setFocusedEaseSegment: (
47
- target: {
48
- animationId: string;
49
- collidingAnimationTargets?: AnimationKeyframeTarget[];
50
- tweenPercentage: number;
51
- elementId: string;
52
- } | null,
53
- ) => void;
66
+ /**
67
+ * Project/session/element-scoped request. Its nonce is monotonic across store
68
+ * resets so a stale consumer can never collide with a later request.
69
+ */
70
+ focusedEaseSegment: FocusedEaseSegment | null;
71
+ focusedEaseRequestNonce: number;
72
+ setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
73
+ clearFocusedEaseSegment: (nonce: number) => void;
54
74
 
55
75
  /** Keyframe data per element id, populated from parsed GSAP animations. */
56
76
  keyframeCache: Map<string, KeyframeCacheEntry>;
@@ -60,7 +80,10 @@ export interface KeyframeSlice {
60
80
  setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
61
81
  }
62
82
 
63
- export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): KeyframeSlice {
83
+ export function createKeyframeSlice(
84
+ set: StoreApi<KeyframeSlice>["setState"],
85
+ getTimelineSessionIdentity: () => TimelineSessionIdentity,
86
+ ): KeyframeSlice {
64
87
  return {
65
88
  selectedKeyframes: new Set(),
66
89
  toggleSelectedKeyframe: (key) =>
@@ -97,7 +120,25 @@ export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): K
97
120
  }),
98
121
 
99
122
  focusedEaseSegment: null,
100
- setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }),
123
+ focusedEaseRequestNonce: 0,
124
+ setFocusedEaseSegment: (target) =>
125
+ set((state) => {
126
+ const nonce = state.focusedEaseRequestNonce + 1;
127
+ const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
128
+ return {
129
+ focusedEaseRequestNonce: nonce,
130
+ focusedEaseSegment: {
131
+ ...target,
132
+ projectId: timelineProjectId,
133
+ sessionEpoch: timelineSessionEpoch,
134
+ nonce,
135
+ },
136
+ };
137
+ }),
138
+ clearFocusedEaseSegment: (nonce) =>
139
+ set((state) =>
140
+ state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
141
+ ),
101
142
 
102
143
  keyframeCache: new Map(),
103
144
  setKeyframeCache: (elementId, data) =>
@@ -53,6 +53,82 @@ describe("usePlayerStore", () => {
53
53
  });
54
54
  });
55
55
 
56
+ describe("focused ease requests", () => {
57
+ it("stamps the current project session and only lets its nonce clear it", () => {
58
+ const store = usePlayerStore.getState();
59
+ store.beginTimelineSession("project-a");
60
+ store.setSelectedElementId("index.html#hero");
61
+ store.setFocusedEaseSegment({
62
+ elementId: "index.html#hero",
63
+ animationId: "animation-a",
64
+ tweenPercentage: 50,
65
+ });
66
+ const first = usePlayerStore.getState().focusedEaseSegment;
67
+ if (!first) throw new Error("expected focused ease request");
68
+ expect(first.projectId).toBe("project-a");
69
+ expect(first.sessionEpoch).toBeGreaterThan(0);
70
+ expect(first.nonce).toBeGreaterThan(0);
71
+
72
+ store.setFocusedEaseSegment({
73
+ elementId: "index.html#hero",
74
+ animationId: "animation-a",
75
+ tweenPercentage: 75,
76
+ });
77
+ const second = usePlayerStore.getState().focusedEaseSegment;
78
+ if (!second) throw new Error("expected replacement request");
79
+ expect(second.nonce).toBe(first.nonce + 1);
80
+
81
+ store.clearFocusedEaseSegment(first.nonce);
82
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(second);
83
+ store.clearFocusedEaseSegment(second.nonce);
84
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
85
+ });
86
+
87
+ it("clears a pending request when the project session changes", () => {
88
+ const store = usePlayerStore.getState();
89
+ store.beginTimelineSession("project-a");
90
+ store.setFocusedEaseSegment({
91
+ elementId: "index.html#hero",
92
+ animationId: "animation-a",
93
+ tweenPercentage: 50,
94
+ });
95
+
96
+ store.beginTimelineSession("project-b");
97
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
98
+ });
99
+
100
+ it("does not revive an old request after selecting away and back", () => {
101
+ const store = usePlayerStore.getState();
102
+ store.setSelectedElementId("index.html#a");
103
+ store.setFocusedEaseSegment({
104
+ elementId: "index.html#a",
105
+ animationId: "animation-a",
106
+ tweenPercentage: 50,
107
+ });
108
+
109
+ store.setSelectedElementId("index.html#b");
110
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
111
+ store.setSelectedElementId("index.html#a");
112
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
113
+ });
114
+
115
+ it("invalidates on a genuine selection-anchor change but not a same-anchor echo", () => {
116
+ const store = usePlayerStore.getState();
117
+ store.setSelection(new Set(["index.html#a", "index.html#b"]), "index.html#a");
118
+ store.setFocusedEaseSegment({
119
+ elementId: "index.html#a",
120
+ animationId: "animation-a",
121
+ tweenPercentage: 50,
122
+ });
123
+ const request = usePlayerStore.getState().focusedEaseSegment;
124
+
125
+ store.setSelectionAnchor("index.html#a");
126
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(request);
127
+ store.setSelectionAnchor("index.html#b");
128
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
129
+ });
130
+ });
131
+
56
132
  describe("setIsPlaying", () => {
57
133
  it("sets isPlaying to true", () => {
58
134
  usePlayerStore.getState().setIsPlaying(true);
@@ -352,7 +352,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
352
352
  activeTool: "select",
353
353
  setActiveTool: (tool) => set({ activeTool: tool }),
354
354
 
355
- ...createKeyframeSlice(set),
355
+ ...createKeyframeSlice(set, () => ({
356
+ timelineProjectId: get().timelineProjectId,
357
+ timelineSessionEpoch: get().timelineSessionEpoch,
358
+ })),
356
359
 
357
360
  activeKeyframePct: null,
358
361
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
@@ -541,6 +544,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
541
544
  selectedElementIds,
542
545
  activeKeyframePct: null,
543
546
  motionPathArmed: false,
547
+ focusedEaseSegment: null,
544
548
  }
545
549
  : { selectedElementId: id, selectedElementIds };
546
550
  }),
@@ -550,9 +554,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
550
554
  setSelectionAnchor: (id) =>
551
555
  set((s) => {
552
556
  if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
553
- return { selectedElementId: id };
557
+ return {
558
+ selectedElementId: id,
559
+ focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
560
+ };
554
561
  }
555
- return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set<string>() };
562
+ return {
563
+ selectedElementId: id,
564
+ selectedElementIds: id ? new Set([id]) : new Set<string>(),
565
+ focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
566
+ };
556
567
  }),
557
568
  updateElement: (elementId, updates) =>
558
569
  set((state) => ({
@@ -560,9 +571,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
560
571
  (el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
561
572
  ),
562
573
  })),
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.
574
+ // UI preferences intentionally survive reset. So do timelineSessionEpoch and
575
+ // focusedEaseRequestNonce: the epoch advances only when project identity
576
+ // changes, while a monotonic nonce prevents collisions with stale consumers.
566
577
  beginTimelineSession: (projectId) =>
567
578
  set((state) => {
568
579
  if (state.timelineProjectId === projectId) return state;
@@ -575,18 +586,15 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
575
586
  reset: () => set(createTimelineResetState()),
576
587
  }));
577
588
 
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
589
  function isDevBuild(): boolean {
584
590
  try {
585
591
  return import.meta.env.DEV === true;
586
592
  } catch {
593
+ // Turbopack and other non-Vite bundlers may not provide import.meta.env.
587
594
  return false;
588
595
  }
589
596
  }
590
597
  if (isDevBuild() && typeof window !== "undefined") {
598
+ // Console handle for dumping live Studio state during bug-bash reproduction.
591
599
  (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
592
600
  }
@@ -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
+ });