@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
@@ -0,0 +1,268 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Studio (browser) binding for the shared canary registry.
3
+ //
4
+ // `@hyperframes/core` owns the decision and is deliberately pure — the caller
5
+ // supplies the unit id, the override and the exclusion. This file supplies
6
+ // those three from the browser, mirroring `packages/cli/src/telemetry/canary.ts`
7
+ // for the CLI. The public API is deliberately identical on both surfaces:
8
+ //
9
+ // import { isCanaryEnabled } from "../telemetry/canary";
10
+ // if (isCanaryEnabled("my-feature")) { ... }
11
+ //
12
+ // so a call site reads the same whether it runs in Node or the browser, and a
13
+ // canary can span both.
14
+ //
15
+ // Three things differ from the CLI, each for a reason:
16
+ //
17
+ // 1. UNIT ID — the CLI's bucket seed (`window.__HF_CLI_BUCKET_SEED`) when the
18
+ // CLI launched Studio, so both surfaces bucket on the SAME unit and a
19
+ // rollout spanning render and editor is coherent for that user. Standalone
20
+ // Studio falls back to `resolveStudioDistinctId()` — the browser has no
21
+ // second storage location, so its localStorage id doubles as the seed.
22
+ //
23
+ // 2. OVERRIDE — there is no `process.env` in a page, so the override is a URL
24
+ // query param mirrored into sessionStorage (see `readOverride`).
25
+ //
26
+ // 3. EXCLUSION — `navigator.webdriver` stands in for the CLI's `is_ci`.
27
+ // Automated browsers mint a fresh localStorage id per run, so their ids are
28
+ // ephemeral and they would hop cohorts between runs — noise in the rollout
29
+ // signal, and nothing learned about real users.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ // Deep subpath imports, NOT the "@hyperframes/core" barrel. Studio is a
33
+ // browser bundle, and the barrel re-exports the whole core surface (parsers,
34
+ // lint, studio-server); pulling that in here drags a Node-oriented dependency
35
+ // graph into the bundle. These two modules are pure and leaf.
36
+ import {
37
+ canaryFeatureProperties,
38
+ evaluateCanary,
39
+ parseCanaryOverride,
40
+ type CanaryDecision,
41
+ } from "@hyperframes/core/canary";
42
+ import { CANARIES, findCanary } from "@hyperframes/core/canary-registry";
43
+ import { resolveStudioDistinctId } from "./distinctId";
44
+ import { browserTelemetryAllowed } from "./policy";
45
+ import { safeSessionStorage } from "../utils/safeStorage";
46
+
47
+ /** `my-feature` → `hf_canary_my_feature`, the query param and storage key. */
48
+ export function canaryParamName(name: string): string {
49
+ return `hf_canary_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
50
+ }
51
+
52
+ // Injected by the CLI's studio server alongside __HF_CLI_DISTINCT_ID.
53
+ declare global {
54
+ interface Window {
55
+ __HF_CLI_BUCKET_SEED?: string;
56
+ /**
57
+ * Resolved per-canary outcome from the launching CLI. `forced` marks an
58
+ * explicit `HF_CANARY_*` override as opposed to a percentage roll — the
59
+ * two get different precedence against Studio's own opt-out.
60
+ */
61
+ __HF_CLI_CANARY_DECISIONS?: Record<string, { enabled?: boolean; forced?: boolean }>;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The launching CLI's decision for this canary, if it published one.
67
+ *
68
+ * When present this WINS over everything Studio could work out locally —
69
+ * seed, URL override, registry percentage. Studio re-deriving cannot agree
70
+ * with the CLI in the cases that matter most:
71
+ *
72
+ * - telemetry off: the CLI resolves `telemetry_opt_out`, but Studio's
73
+ * opt-out is a separate localStorage flag it cannot see from here;
74
+ * - `HF_CANARY_*` override: env vars never cross into the browser;
75
+ * - no seed injected: Studio falls back to a different unit, i.e. a
76
+ * different bucket.
77
+ *
78
+ * In all three the CLI has already decided, and one render spanning both
79
+ * surfaces must not run half-enrolled.
80
+ */
81
+ function cliDecision(name: string): { enabled: boolean; forced: boolean } | undefined {
82
+ try {
83
+ if (typeof window === "undefined") return undefined;
84
+ const decision = window.__HF_CLI_CANARY_DECISIONS?.[name];
85
+ if (typeof decision?.enabled !== "boolean") return undefined;
86
+ return { enabled: decision.enabled, forced: decision.forced === true };
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The bucketing unit. A CLI-launched Studio buckets on the CLI's SEED (which
94
+ * survives config wipes via the install-state file), not its distinct id —
95
+ * the CLI itself buckets on the seed, and the two surfaces must agree per
96
+ * machine. Standalone Studio falls back to its own distinct id: the browser
97
+ * has no second storage location, so localStorage IS both id and seed there.
98
+ */
99
+ function resolveBucketUnit(): string {
100
+ try {
101
+ const seed = typeof window === "undefined" ? undefined : window.__HF_CLI_BUCKET_SEED;
102
+ if (typeof seed === "string" && seed.length > 0) return seed;
103
+ } catch {
104
+ /* fall through */
105
+ }
106
+ return resolveStudioDistinctId();
107
+ }
108
+
109
+ const STORAGE_PREFIX = "hyperframes-studio:canary:";
110
+
111
+ /**
112
+ * Resolve a manual override for one canary.
113
+ *
114
+ * `?hf_canary_my_feature=on` (also off/true/false/1/0/yes/no), mirrored into
115
+ * sessionStorage so it survives in-app navigation and reloads within the tab.
116
+ *
117
+ * SESSION scope, not local, is the deliberate part. A URL is the right carrier
118
+ * — it is shareable, which is what "support: open this link" and "QA: repro
119
+ * with this on" actually need. But persisting a URL-borne override to
120
+ * localStorage would mean one click silently pins that browser into a cohort
121
+ * forever, long after anyone remembers why. Session scope keeps the link
122
+ * useful and lets closing the tab be the reset.
123
+ *
124
+ * `?hf_canary_my_feature=reset` clears it explicitly.
125
+ */
126
+ function readOverride(name: string): boolean | undefined {
127
+ if (typeof window === "undefined") return undefined;
128
+ const key = canaryParamName(name);
129
+ const storageKey = `${STORAGE_PREFIX}${name}`;
130
+ const store = safeSessionStorage();
131
+
132
+ let raw: string | null = null;
133
+ try {
134
+ raw = new URLSearchParams(window.location.search).get(key);
135
+ } catch {
136
+ raw = null;
137
+ }
138
+
139
+ if (raw !== null) {
140
+ if (raw.trim().toLowerCase() === "reset") {
141
+ store?.removeItem(storageKey);
142
+ return undefined;
143
+ }
144
+ // Persist for the tab session so the override outlives the query string.
145
+ try {
146
+ store?.setItem(storageKey, raw);
147
+ } catch {
148
+ /* storage full / blocked — the param still applies to this page load */
149
+ }
150
+ return parseCanaryOverride(raw);
151
+ }
152
+
153
+ return parseCanaryOverride(store?.getItem(storageKey) ?? undefined);
154
+ }
155
+
156
+ /**
157
+ * Automated browser? The browser analog of the CLI's CI exclusion.
158
+ * `navigator.webdriver` is set by Playwright, Puppeteer and Selenium.
159
+ */
160
+ function isAutomatedBrowser(): boolean {
161
+ if (typeof navigator === "undefined") return false;
162
+ return navigator.webdriver === true;
163
+ }
164
+
165
+ /**
166
+ * Memoized per page load, for the same reason the CLI memoizes per process: a
167
+ * canary must not change its mind mid-session. A component that mounted
168
+ * enrolled has to stay enrolled, and the telemetry has to agree with what the
169
+ * user actually saw.
170
+ */
171
+ const decisions = new Map<string, CanaryDecision>();
172
+
173
+ /** Test-only: drop memoized decisions so cases don't leak into each other. */
174
+ export function __resetStudioCanaryCacheForTests(): void {
175
+ decisions.clear();
176
+ }
177
+
178
+ /** The uncached decision. Split out so `resolveCanary` is purely the memo. */
179
+ const forcedOutcome = (enabled: boolean): CanaryDecision => ({
180
+ enabled,
181
+ reason: enabled ? "forced_on" : "forced_off",
182
+ });
183
+
184
+ const cohortOutcome = (enabled: boolean): CanaryDecision => ({
185
+ enabled,
186
+ reason: enabled ? "in_cohort" : "out_of_cohort",
187
+ });
188
+
189
+ /**
190
+ * Precedence, highest first:
191
+ *
192
+ * 1. An explicit `HF_CANARY_*` from the launching CLI. A deliberate operator
193
+ * choice — the documented escalation channel (dogfooding, bisect,
194
+ * panic-off) — so it wins outright, including over this profile's
195
+ * opt-out, exactly as a local URL override does.
196
+ * 2. A local URL / sessionStorage override, same reasoning.
197
+ * 3. This profile's telemetry opt-out. Checked BEFORE any percentage-derived
198
+ * CLI decision: the two surfaces have independent opt-outs, and CLI
199
+ * telemetry being on says nothing about whether THIS browser profile
200
+ * agreed to be measured. A cohort roll must never enrol an opted-out
201
+ * profile.
202
+ * 4. The CLI's percentage decision — authoritative, since it already applied
203
+ * the shared seed and re-deriving is what let the surfaces disagree.
204
+ * 5. Local evaluation (standalone Studio, or a canary the CLI didn't publish).
205
+ */
206
+ function decideStudioCanary(name: string): CanaryDecision {
207
+ const definition = findCanary(name);
208
+ if (!definition) return { enabled: false, reason: "out_of_cohort" };
209
+
210
+ const fromCli = cliDecision(definition.name);
211
+ if (fromCli?.forced) return forcedOutcome(fromCli.enabled);
212
+
213
+ const override = readOverride(definition.name);
214
+ if (override === undefined) {
215
+ if (!browserTelemetryAllowed()) return { enabled: false, reason: "telemetry_opt_out" };
216
+ // Studio's exclusion is its own to apply: the CLI cannot see
217
+ // navigator.webdriver, so adopting its cohort decision verbatim enrolled
218
+ // Playwright/Puppeteer sessions driving a local `hyperframes preview` —
219
+ // each minting a fresh localStorage id, precisely the ephemeral-id noise
220
+ // the exclusion exists to keep out of the rollout signal.
221
+ if (isAutomatedBrowser()) return { enabled: false, reason: "excluded" };
222
+ if (fromCli !== undefined) return cohortOutcome(fromCli.enabled);
223
+ }
224
+
225
+ // An explicit override decides on evaluateCanary's first line without ever
226
+ // reading unitId, so resolving the unit here would mint and PERSIST an
227
+ // anonymous id purely as an unused argument — for a profile that may have
228
+ // opted out. One click on a support link created a durable tracking id.
229
+ if (override !== undefined) return forcedOutcome(override);
230
+
231
+ return evaluateCanary({
232
+ feature: definition.name,
233
+ unitId: resolveBucketUnit(),
234
+ percentage: definition.percentage,
235
+ exclude: isAutomatedBrowser(),
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Full decision including the reason. An unregistered name resolves to off
241
+ * rather than throwing — a typo in a rollout control must never break the
242
+ * editor.
243
+ */
244
+ export function resolveCanary(name: string): CanaryDecision {
245
+ const cached = decisions.get(name);
246
+ if (cached) return cached;
247
+
248
+ const decision = decideStudioCanary(name);
249
+
250
+ decisions.set(name, decision);
251
+ return decision;
252
+ }
253
+
254
+ /** Is this canary on for this Studio install? The everyday call. */
255
+ export function isCanaryEnabled(name: string): boolean {
256
+ return resolveCanary(name).enabled;
257
+ }
258
+
259
+ /**
260
+ * Canary assignments as PostHog flag properties (`$feature/canary-<name>`),
261
+ * attached to every Studio event so any metric can be split by cohort —
262
+ * identical shape to the CLI, so a rollout spanning both reads as one flag.
263
+ */
264
+ export function canaryEventProperties(): Record<string, string> {
265
+ return canaryFeatureProperties(
266
+ CANARIES.map((c) => ({ name: c.name, enabled: resolveCanary(c.name).enabled })),
267
+ );
268
+ }
@@ -77,10 +77,15 @@ describe("studio client shouldTrack", () => {
77
77
  expect(shouldTrack()).toBe(false);
78
78
  });
79
79
 
80
- it("memoizes its decision after the first call", async () => {
80
+ // Previously asserted the opposite. That memoization WAS the bug: policy.ts
81
+ // is explicit that transports re-ask, and policy.test.ts asserts a
82
+ // mid-session opt-out takes effect at once — but this transport cached on
83
+ // first call, so a user who opted out in DevTools after one event kept
84
+ // sending `studio_*` and render events while `studio:*` correctly stopped.
85
+ it("re-reads the policy, so a mid-session opt-out takes effect immediately", async () => {
81
86
  const shouldTrack = await loadShouldTrack();
82
- const first = shouldTrack();
87
+ expect(shouldTrack()).toBe(true);
83
88
  localStorage.setItem(OPT_OUT_KEY, "1");
84
- expect(shouldTrack()).toBe(first);
89
+ expect(shouldTrack()).toBe(false);
85
90
  });
86
91
  });
@@ -4,8 +4,10 @@
4
4
  // All calls are fire-and-forget; telemetry must never break the studio UI.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
7
+ import { getAnonymousId, hasShownNotice, markNoticeShown } from "./config";
8
+ import { browserTelemetryAllowed } from "./policy";
8
9
  import { getBrowserSystemMeta } from "./system";
10
+ import { canaryEventProperties } from "./canary";
9
11
 
10
12
  // Write-only PostHog project key, safe to embed in client code.
11
13
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -22,49 +24,14 @@ interface QueuedEvent {
22
24
 
23
25
  let eventQueue: QueuedEvent[] = [];
24
26
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
25
- let telemetryEnabled: boolean | null = null;
26
-
27
- function isDoNotTrackOn(): boolean {
28
- return typeof navigator !== "undefined" && navigator.doNotTrack === "1";
29
- }
30
-
31
- function isApiKeyConfigured(): boolean {
32
- return POSTHOG_API_KEY.startsWith("phc_");
33
- }
34
-
35
- // VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
36
- // opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
37
- // bundle the same way. Vite injects it at build time. Match the CLI's
38
- // affirmative privacy-control spellings.
39
- // `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
40
- function isBuildTimeOptOut(): boolean {
41
- try {
42
- const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
43
- return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
44
- } catch {
45
- return false;
46
- }
47
- }
48
-
49
- // `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
50
- // so developers running `hyperframes preview` don't pollute production telemetry.
51
- function isViteDevMode(): boolean {
52
- try {
53
- return import.meta.env.DEV === true;
54
- } catch {
55
- return false;
56
- }
57
- }
58
27
 
59
28
  export function shouldTrack(): boolean {
60
- if (telemetryEnabled !== null) return telemetryEnabled;
61
- telemetryEnabled =
62
- isApiKeyConfigured() &&
63
- !isBuildTimeOptOut() &&
64
- !isViteDevMode() &&
65
- !isOptedOut() &&
66
- !isDoNotTrackOn();
67
- return telemetryEnabled;
29
+ // NOT memoized. policy.ts is explicit that the transports re-ask, and
30
+ // policy.test.ts asserts a mid-session opt-out takes effect at once — but
31
+ // this cached on first call, so a user who opted out in DevTools after one
32
+ // event kept sending `studio_*` and render events for the rest of the tab
33
+ // while `studio:*` correctly stopped. The check is two property reads.
34
+ return browserTelemetryAllowed();
68
35
  }
69
36
 
70
37
  export function trackEvent(event: string, properties: EventProperties = {}): void {
@@ -73,7 +40,10 @@ export function trackEvent(event: string, properties: EventProperties = {}): voi
73
40
  const sys = getBrowserSystemMeta();
74
41
  eventQueue.push({
75
42
  event,
76
- properties: { ...properties, ...sys },
43
+ // Canary assignments as `$feature/canary-<name>`, mirroring the CLI so a
44
+ // rollout spanning both surfaces reads as one flag in PostHog. Resolved
45
+ // after the shouldTrack guard, so opted-out users never pay for it.
46
+ properties: { ...properties, ...sys, ...canaryEventProperties() },
77
47
  timestamp: new Date().toISOString(),
78
48
  });
79
49
 
@@ -22,12 +22,26 @@ export function getAnonymousId(): string {
22
22
  return resolveStudioDistinctId();
23
23
  }
24
24
 
25
+ // safeLocalStorage() guards the REFERENCE, not the access: in a partitioned
26
+ // or sandboxed context the object resolves and `getItem` still throws (the
27
+ // case distinctId.ts already documents). These are read from the telemetry
28
+ // policy, which is called from event tracking that must never throw into a
29
+ // caller — `trackStudioEvent` sits in a post-commit catch block, so a throw
30
+ // there reported an already-committed edit as failed.
31
+ function readStoredFlag(key: string): boolean {
32
+ try {
33
+ return safeLocalStorage()?.getItem(key) === "1";
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
25
39
  export function isOptedOut(): boolean {
26
- return safeLocalStorage()?.getItem(OPT_OUT_KEY) === "1";
40
+ return readStoredFlag(OPT_OUT_KEY);
27
41
  }
28
42
 
29
43
  export function hasShownNotice(): boolean {
30
- return safeLocalStorage()?.getItem(NOTICE_KEY) === "1";
44
+ return readStoredFlag(NOTICE_KEY);
31
45
  }
32
46
 
33
47
  export function markNoticeShown(): void {
@@ -101,3 +101,38 @@ describe("getCliDistinctId", () => {
101
101
  expect(getCliDistinctId()).toBeNull();
102
102
  });
103
103
  });
104
+
105
+ describe("no-storage fallback must not collapse the population", () => {
106
+ const realLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
107
+
108
+ beforeEach(() => {
109
+ __resetStudioDistinctIdForTests();
110
+ // Simulate a hardened / partitioned context where safeLocalStorage()
111
+ // returns null.
112
+ Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
113
+ });
114
+
115
+ afterEach(() => {
116
+ if (realLocalStorage) Object.defineProperty(globalThis, "localStorage", realLocalStorage);
117
+ __resetStudioDistinctIdForTests();
118
+ });
119
+
120
+ it("is stable within a session", () => {
121
+ const first = resolveStudioDistinctId();
122
+ expect(resolveStudioDistinctId()).toBe(first);
123
+ expect(first).toBeTruthy();
124
+ });
125
+
126
+ // Regression: this used to return the shared literal "anonymous", so every
127
+ // storage-restricted profile was ONE bucketing unit. Against the shipped
128
+ // hash `calibration-50:anonymous` lands in bucket 44, so that whole
129
+ // population was enrolled at a nominal 50% and would flip together on a
130
+ // ramp — and they all merged into one PostHog person.
131
+ it("differs across sessions rather than sharing one constant", () => {
132
+ const first = resolveStudioDistinctId();
133
+ __resetStudioDistinctIdForTests();
134
+ const second = resolveStudioDistinctId();
135
+ expect(second).not.toBe(first);
136
+ expect(first).not.toBe("anonymous");
137
+ });
138
+ });
@@ -106,9 +106,18 @@ export function resolveStudioDistinctId(): string {
106
106
  return existing;
107
107
  }
108
108
  } else {
109
- // No storage at all (SSR / locked-down browser): stable within the session.
109
+ // No storage at all (SSR / locked-down browser): stable within the
110
+ // session, but RANDOM per session rather than a shared literal.
111
+ //
112
+ // It used to be the constant "anonymous", which made every
113
+ // storage-restricted profile one bucketing unit: computed against the
114
+ // shipped hash, `calibration-50:anonymous` lands in bucket 44, so 100% of
115
+ // that population was enrolled at a nominal 50% and would flip together
116
+ // on any ramp. It also merged unrelated users into a single PostHog
117
+ // person. A per-session id spreads them and keeps them distinct, while
118
+ // persisting nothing.
110
119
  // `cachedId` is guaranteed null here (early-returned at the top otherwise).
111
- cachedId = "anonymous";
120
+ cachedId = generateId();
112
121
  return cachedId;
113
122
  }
114
123
 
@@ -0,0 +1,81 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // Each control the browser telemetry policy enforces, asserted individually.
5
+ // This is the SSOT that `telemetry/client.ts`, `utils/studioTelemetry.ts` and
6
+ // canary enrolment all consult, so a gap here is a gap in all three — which is
7
+ // how `navigator.doNotTrack` and Vite dev mode came to suppress one transport
8
+ // but not the other, nor enrolment.
9
+
10
+ const DOCUMENTED_OPT_OUT = "hyperframes-studio:telemetryDisabled";
11
+ const LEGACY_OPT_OUT = "hf-studio-telemetry-opt-out";
12
+
13
+ describe("browserTelemetryAllowed", () => {
14
+ let browserTelemetryAllowed: typeof import("./policy").browserTelemetryAllowed;
15
+
16
+ beforeEach(async () => {
17
+ localStorage.clear();
18
+ vi.resetModules();
19
+ // vitest sets import.meta.env.DEV; the policy suppresses under it, so the
20
+ // baseline has to be an explicitly production-like env.
21
+ vi.stubEnv("DEV", false);
22
+ vi.stubEnv("VITE_HYPERFRAMES_NO_TELEMETRY", "");
23
+ Object.defineProperty(navigator, "doNotTrack", { value: null, configurable: true });
24
+ ({ browserTelemetryAllowed } = await import("./policy"));
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllEnvs();
29
+ });
30
+
31
+ it("allows telemetry with no control set", () => {
32
+ expect(browserTelemetryAllowed()).toBe(true);
33
+ });
34
+
35
+ it("refuses when the documented localStorage key is set", () => {
36
+ localStorage.setItem(DOCUMENTED_OPT_OUT, "1");
37
+ expect(browserTelemetryAllowed()).toBe(false);
38
+ });
39
+
40
+ // Anyone already opted out this way must never be quietly re-enabled by the
41
+ // move to the documented key.
42
+ it("refuses when the legacy localStorage key is set", () => {
43
+ localStorage.setItem(LEGACY_OPT_OUT, "1");
44
+ expect(browserTelemetryAllowed()).toBe(false);
45
+ });
46
+
47
+ it("refuses when navigator.doNotTrack is on", () => {
48
+ Object.defineProperty(navigator, "doNotTrack", { value: "1", configurable: true });
49
+ expect(browserTelemetryAllowed()).toBe(false);
50
+ });
51
+
52
+ it("refuses under Vite dev mode", async () => {
53
+ vi.stubEnv("DEV", true);
54
+ vi.resetModules();
55
+ ({ browserTelemetryAllowed } = await import("./policy"));
56
+ expect(browserTelemetryAllowed()).toBe(false);
57
+ });
58
+
59
+ it.each(["1", "true", "yes", "on", " ON "])(
60
+ "refuses when VITE_HYPERFRAMES_NO_TELEMETRY=%s",
61
+ async (value) => {
62
+ vi.stubEnv("VITE_HYPERFRAMES_NO_TELEMETRY", value);
63
+ vi.resetModules();
64
+ ({ browserTelemetryAllowed } = await import("./policy"));
65
+ expect(browserTelemetryAllowed()).toBe(false);
66
+ },
67
+ );
68
+
69
+ it("ignores an unset or unrelated VITE_HYPERFRAMES_NO_TELEMETRY value", async () => {
70
+ vi.stubEnv("VITE_HYPERFRAMES_NO_TELEMETRY", "0");
71
+ vi.resetModules();
72
+ ({ browserTelemetryAllowed } = await import("./policy"));
73
+ expect(browserTelemetryAllowed()).toBe(true);
74
+ });
75
+
76
+ it("is not memoized — a mid-session opt-out takes effect immediately", () => {
77
+ expect(browserTelemetryAllowed()).toBe(true);
78
+ localStorage.setItem(DOCUMENTED_OPT_OUT, "1");
79
+ expect(browserTelemetryAllowed()).toBe(false);
80
+ });
81
+ });
@@ -0,0 +1,104 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Browser telemetry policy — the single answer to "may this profile be
3
+ // measured?", shared by every transport and by canary enrolment.
4
+ //
5
+ // This exists because the answer was previously duplicated and the copies had
6
+ // drifted: `telemetry/client.ts` enforced five controls, the older
7
+ // `utils/studioTelemetry.ts` transport enforced one (its own localStorage
8
+ // key), and canary evaluation enforced a different one. So a profile with
9
+ // `navigator.doNotTrack` set, or a Vite dev build, still emitted `studio:*`
10
+ // events AND could be bucketed into a rollout — under controls the public
11
+ // docs say disable both.
12
+ //
13
+ // Deliberately imports only `./config` (localStorage helpers). Nothing here
14
+ // may import a transport or the canary module: both of those import this, and
15
+ // the whole point is one definition with no cycle.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import { isOptedOut } from "./config";
19
+
20
+ // Write-only PostHog project key, safe to embed in client code. Duplicated
21
+ // from client.ts intentionally — the eligibility check must not drag the
22
+ // transport (and its queue/timer state) into modules that only need the
23
+ // policy.
24
+ const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
25
+
26
+ /** Legacy opt-out key predating `telemetry/config.ts`. Still honoured so
27
+ * anyone already opted out is never quietly re-enabled. */
28
+ const LEGACY_OPT_OUT_KEY = "hf-studio-telemetry-opt-out";
29
+
30
+ function isLegacyOptedOut(): boolean {
31
+ try {
32
+ return localStorage.getItem(LEGACY_OPT_OUT_KEY) === "1";
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function isDoNotTrackOn(): boolean {
39
+ return typeof navigator !== "undefined" && navigator.doNotTrack === "1";
40
+ }
41
+
42
+ function isApiKeyConfigured(): boolean {
43
+ return POSTHOG_API_KEY.startsWith("phc_");
44
+ }
45
+
46
+ // VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
47
+ // opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
48
+ // bundle the same way. Vite injects it at build time. Match the CLI's
49
+ // affirmative privacy-control spellings.
50
+ // `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
51
+ function isBuildTimeOptOut(): boolean {
52
+ try {
53
+ const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
54
+ return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ // `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
61
+ // so developers running `hyperframes preview` don't pollute production telemetry.
62
+ function isViteDevMode(): boolean {
63
+ try {
64
+ return import.meta.env.DEV === true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * May this browser profile be measured at all?
72
+ *
73
+ * Governs BOTH sending events and canary enrolment. Enrolment is part of
74
+ * measurement, not separate from it: a profile that reports nothing cannot be
75
+ * compared against anyone, so bucketing it changes that user's code path for
76
+ * no signal. The one documented exception is an explicit `HF_CANARY_*` /
77
+ * `?hf_canary_*=` override, which callers apply before consulting this.
78
+ *
79
+ * Not memoized — `isOptedOut()` reads localStorage, which a user can flip in
80
+ * DevTools mid-session, and the per-call cost is a couple of property reads.
81
+ * Callers that must stay stable within a session memoize their own result
82
+ * (canary decisions do; the transports intentionally do not).
83
+ */
84
+ export function browserTelemetryAllowed(): boolean {
85
+ try {
86
+ return allowed();
87
+ } catch {
88
+ // Fail CLOSED. A storage read that throws must not enrol anyone, and must
89
+ // not propagate: callers include a post-commit catch block where a throw
90
+ // reports a committed edit as failed.
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function allowed(): boolean {
96
+ return (
97
+ isApiKeyConfigured() &&
98
+ !isBuildTimeOptOut() &&
99
+ !isViteDevMode() &&
100
+ !isOptedOut() &&
101
+ !isLegacyOptedOut() &&
102
+ !isDoNotTrackOn()
103
+ );
104
+ }