@hyperframes/studio 0.7.99 → 0.7.101

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-BY5RLMBA.js → hyperframes-player-BxSY8bs4.js} +1 -1
  2. package/dist/assets/{index-CQ07_DLG.js → index-BlRPDw0A.js} +1 -1
  3. package/dist/assets/{index-DMSqmZM6.js → index-DF06yGPO.js} +200 -200
  4. package/dist/assets/{index-DeXLAktv.js → index-Dkz5RbFX.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +473 -359
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +2 -2
  10. package/src/components/StudioHeader.test.ts +28 -0
  11. package/src/components/StudioHeader.tsx +21 -2
  12. package/src/components/StudioLeftSidebar.tsx +2 -2
  13. package/src/components/nle/NLEContext.tsx +16 -7
  14. package/src/components/nle/TimelineResizeDivider.tsx +4 -11
  15. package/src/components/sidebar/LeftSidebar.storage.test.ts +28 -0
  16. package/src/components/sidebar/LeftSidebar.tsx +18 -3
  17. package/src/contexts/PanelLayoutContext.tsx +6 -3
  18. package/src/hooks/gsapEditOutcome.ts +19 -1
  19. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +47 -1
  20. package/src/hooks/gsapKeyframeCacheHelpers.ts +31 -3
  21. package/src/hooks/gsapResizeGeometrySweep.test.ts +289 -0
  22. package/src/hooks/gsapResizeIntercept.test.ts +2 -2
  23. package/src/hooks/gsapResizeIntercept.ts +37 -14
  24. package/src/hooks/gsapResizeMixedTween.test.ts +168 -0
  25. package/src/hooks/gsapResizeSweep.test.ts +274 -0
  26. package/src/hooks/useGsapAwareEditing.test.tsx +19 -2
  27. package/src/hooks/useGsapAwareEditing.ts +13 -5
  28. package/src/hooks/useGsapTweenCache.ts +16 -19
  29. package/src/hooks/usePanelLayout.test.ts +117 -0
  30. package/src/hooks/usePanelLayout.ts +121 -39
  31. package/src/player/components/Player.test.ts +13 -0
  32. package/src/player/components/Player.tsx +7 -1
  33. package/src/utils/clipboard.ts +1 -1
  34. package/src/utils/fitPanels.test.ts +164 -0
  35. package/src/utils/fitPanels.ts +151 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.99",
3
+ "version": "0.7.101",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/parsers": "0.7.99",
51
- "@hyperframes/core": "0.7.99",
52
- "@hyperframes/sdk": "0.7.99",
53
- "@hyperframes/player": "0.7.99",
54
- "@hyperframes/studio-server": "0.7.99"
50
+ "@hyperframes/core": "0.7.101",
51
+ "@hyperframes/player": "0.7.101",
52
+ "@hyperframes/parsers": "0.7.101",
53
+ "@hyperframes/sdk": "0.7.101",
54
+ "@hyperframes/studio-server": "0.7.101"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -67,7 +67,7 @@
67
67
  "vite": "^6.4.2",
68
68
  "vitest": "^3.2.4",
69
69
  "zustand": "^5.0.0",
70
- "@hyperframes/producer": "0.7.99"
70
+ "@hyperframes/producer": "0.7.101"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "19",
package/src/App.tsx CHANGED
@@ -399,7 +399,7 @@ export function StudioApp() {
399
399
  } = useInspectorState(
400
400
  panelLayout.rightPanelTab,
401
401
  panelLayout.rightInspectorPanes,
402
- panelLayout.rightCollapsed,
402
+ panelLayout.effectiveRightCollapsed,
403
403
  isPlaying,
404
404
  domEditSession.domEditSelection,
405
405
  gestureState === "recording",
@@ -512,7 +512,7 @@ export function StudioApp() {
512
512
  />
513
513
  }
514
514
  right={
515
- panelLayout.rightCollapsed ? null : (
515
+ panelLayout.effectiveRightCollapsed ? null : (
516
516
  <StudioRightPanel
517
517
  designPanelActive={designPanelActive}
518
518
  activeBlockParams={activeBlockParams}
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldOpenInspector } from "./StudioHeader";
3
+
4
+ describe("shouldOpenInspector", () => {
5
+ it("opens when the panel is hidden", () => {
6
+ expect(shouldOpenInspector(true, false)).toBe(true);
7
+ });
8
+
9
+ it("opens when a non-inspector tab is showing", () => {
10
+ expect(shouldOpenInspector(false, false)).toBe(true);
11
+ });
12
+
13
+ it("closes when the inspector is genuinely on screen", () => {
14
+ expect(shouldOpenInspector(false, true)).toBe(false);
15
+ });
16
+
17
+ it("opens when the window railed the panel away", () => {
18
+ // The regression this guards: the button used to branch on the raw
19
+ // rightCollapsed intent, which is still `false` while the window has the
20
+ // panel railed. That took the close branch, wrote rightCollapsed=true, and
21
+ // since that value is synced into the shareable Studio URL, a click that
22
+ // did nothing visible rewrote the link.
23
+ const userIntentIsOpen = false;
24
+ const windowRailedItAway = true;
25
+ expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
26
+ expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
27
+ });
28
+ });
@@ -196,6 +196,21 @@ export function ViewModeToggle() {
196
196
  );
197
197
  }
198
198
 
199
+ /**
200
+ * Does the header's Inspector button open the panel, or close it?
201
+ *
202
+ * Takes the EFFECTIVE collapse state, so a panel the window has railed away
203
+ * counts as closed even though the user's stored intent still says open. The
204
+ * argument name is the guard: passing raw intent here is the bug this exists
205
+ * to keep out.
206
+ */
207
+ export function shouldOpenInspector(
208
+ effectiveRightCollapsed: boolean,
209
+ inspectorPanelActive: boolean,
210
+ ): boolean {
211
+ return effectiveRightCollapsed || !inspectorPanelActive;
212
+ }
213
+
199
214
  // fallow-ignore-next-line complexity
200
215
  export function StudioHeader({
201
216
  captureFrameHref,
@@ -208,7 +223,11 @@ export function StudioHeader({
208
223
  onExport,
209
224
  }: StudioHeaderProps) {
210
225
  const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
211
- const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
226
+ // effectiveRightCollapsed, not the raw intent: in the auto-railed state the
227
+ // intent is still "open" while the panel is hidden, so branching on intent
228
+ // made this button write rightCollapsed=true — and that value is synced into
229
+ // the shareable Studio URL, so a dead click would rewrite a link.
230
+ const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
212
231
  const isRendering = renderQueue.isRendering;
213
232
 
214
233
  return (
@@ -328,7 +347,7 @@ export function StudioHeader({
328
347
  <button
329
348
  type="button"
330
349
  onClick={() => {
331
- if (rightCollapsed || !inspectorPanelActive) {
350
+ if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
332
351
  trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
333
352
  setRightPanelTab("design");
334
353
  setRightCollapsed(false);
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
36
36
  onAddCompositionToTimeline,
37
37
  }: StudioLeftSidebarProps) {
38
38
  const {
39
- leftCollapsed,
39
+ effectiveLeftCollapsed,
40
40
  leftWidth,
41
41
  adjustPanelWidth,
42
42
  toggleLeftSidebar,
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
71
71
  [renderQueue, waitForPendingDomEditSaves],
72
72
  );
73
73
 
74
- if (leftCollapsed) {
74
+ if (effectiveLeftCollapsed) {
75
75
  return (
76
76
  <div className="mr-0.5 flex w-10 flex-shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
77
77
  <button
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
11
11
  import type { TimelineElement } from "../../player";
12
12
  import type { CompositionLevel } from "./CompositionBreadcrumb";
13
13
  import { useCompositionStack } from "./useCompositionStack";
14
- import { MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
14
+ import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
15
15
  import { setCompositionSourceMap } from "../editor/domEditingDom";
16
16
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
17
17
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
@@ -273,13 +273,22 @@ export function NLEProvider({
273
273
  }, []);
274
274
  const containerRef = useRef<HTMLDivElement>(null);
275
275
  // A height persisted on a tall window can exceed this window's container and
276
- // collapse the flex-1 preview to 0px clamp once the container is measurable
277
- // (the drag/keyboard paths already clamp; the restore path must too).
276
+ // collapse the flex-1 preview to 0px. Observing the container rather than
277
+ // clamping once at mount is what makes a window RESIZED after load behave the
278
+ // same as one loaded at that size: dragging 760 -> 520 tall used to leave the
279
+ // timeline at its stored 429px and the preview at 47px.
278
280
  useEffect(() => {
279
- const containerH = containerRef.current?.getBoundingClientRect().height;
280
- if (!containerH) return;
281
- const max = containerH - MIN_PREVIEW_H;
282
- setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
281
+ const element = containerRef.current;
282
+ if (!element || typeof ResizeObserver === "undefined") return;
283
+ const reconcile = () => {
284
+ const containerH = element.getBoundingClientRect().height;
285
+ if (!containerH) return;
286
+ setTimelineH((prev) => fitTimelineHeight(containerH, prev));
287
+ };
288
+ reconcile();
289
+ const observer = new ResizeObserver(reconcile);
290
+ observer.observe(element);
291
+ return () => observer.disconnect();
283
292
  }, []);
284
293
 
285
294
  const hasLoadedOnceRef = useRef(false);
@@ -1,7 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
-
3
- export const MIN_TIMELINE_H = 100;
4
- export const MIN_PREVIEW_H = 120;
2
+ import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
5
3
 
6
4
  /**
7
5
  * Horizontal drag/keyboard-resizable divider between the preview and the
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
41
39
  if (!isDragging.current || !containerRef.current) return;
42
40
  const rect = containerRef.current.getBoundingClientRect();
43
41
  const mouseY = e.clientY - rect.top;
44
- const containerH = rect.height;
45
- const newTimelineH = Math.max(
46
- MIN_TIMELINE_H,
47
- Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
48
- );
49
- setTimelineH(newTimelineH);
42
+ setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
50
43
  },
51
44
  [disabled, containerRef, setTimelineH],
52
45
  );
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
61
54
  if (disabled) return;
62
55
  if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
63
56
  e.preventDefault();
64
- const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
57
+ const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
65
58
  const delta = e.key === "ArrowUp" ? 16 : -16;
66
59
  setTimelineH((prev) => {
67
- const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
60
+ const next = fitTimelineHeight(containerH, prev + delta);
68
61
  persistTimelineH(next);
69
62
  return next;
70
63
  });
@@ -0,0 +1,28 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, expect, it, vi } from "vitest";
4
+ import { getPersistedTab } from "./LeftSidebar";
5
+
6
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
7
+
8
+ const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
9
+
10
+ afterEach(() => {
11
+ if (originalDescriptor) Object.defineProperty(globalThis, "localStorage", originalDescriptor);
12
+ });
13
+
14
+ it("falls back to the default tab when localStorage is blocked", () => {
15
+ // Chrome throws on the property read itself when site data is blocked for
16
+ // the document. This runs as a useState initializer, so throwing here takes
17
+ // Studio to the crash boundary.
18
+ Object.defineProperty(globalThis, "localStorage", {
19
+ configurable: true,
20
+ get() {
21
+ throw new Error(
22
+ "Failed to read the 'localStorage' property from 'Window': Access is denied for this document.",
23
+ );
24
+ },
25
+ });
26
+
27
+ expect(getPersistedTab()).toBe("compositions");
28
+ });
@@ -10,6 +10,7 @@ import {
10
10
  import { CompositionsTab } from "./CompositionsTab";
11
11
  import { AssetsTab } from "./AssetsTab";
12
12
  import { trackStudioEvent } from "../../utils/studioTelemetry";
13
+ import { safeLocalStorage } from "../../utils/safeStorage";
13
14
  import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
14
15
  import { FileTree } from "../editor/FileTree";
15
16
  import { Tooltip } from "../ui";
@@ -23,8 +24,18 @@ export interface LeftSidebarHandle {
23
24
 
24
25
  const STORAGE_KEY = "hf-studio-sidebar-tab";
25
26
 
26
- function getPersistedTab(): SidebarTab {
27
- const stored = localStorage.getItem(STORAGE_KEY);
27
+ // Both the `localStorage` reference and `getItem` itself can throw when the
28
+ // browsing context is partitioned or site data is blocked — the same case
29
+ // telemetry/config.ts documents. This runs as a `useState` initializer, so an
30
+ // unguarded throw here takes the whole editor to the crash boundary rather
31
+ // than losing one remembered tab.
32
+ export function getPersistedTab(): SidebarTab {
33
+ let stored: string | null = null;
34
+ try {
35
+ stored = safeLocalStorage()?.getItem(STORAGE_KEY) ?? null;
36
+ } catch {
37
+ /* storage unavailable — fall back to the default tab */
38
+ }
28
39
  if (stored === "assets") return "assets";
29
40
  if (stored === "code") return "code";
30
41
  if (stored === "blocks") return "blocks";
@@ -104,7 +115,11 @@ export const LeftSidebar = memo(
104
115
 
105
116
  const selectTab = useCallback((t: SidebarTab) => {
106
117
  setTab(t);
107
- localStorage.setItem(STORAGE_KEY, t);
118
+ try {
119
+ safeLocalStorage()?.setItem(STORAGE_KEY, t);
120
+ } catch {
121
+ /* storage unavailable — the tab just won't be remembered */
122
+ }
108
123
  trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
109
124
  }, []);
110
125
 
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
17
17
  rightWidth,
18
18
  adjustPanelWidth,
19
19
  leftCollapsed,
20
- setLeftCollapsed,
21
20
  rightCollapsed,
22
21
  setRightCollapsed,
22
+ effectiveLeftCollapsed,
23
+ effectiveRightCollapsed,
23
24
  rightPanelTab,
24
25
  setRightPanelTab,
25
26
  rightInspectorPanes,
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
41
42
  rightWidth,
42
43
  adjustPanelWidth,
43
44
  leftCollapsed,
44
- setLeftCollapsed,
45
45
  rightCollapsed,
46
46
  setRightCollapsed,
47
+ effectiveLeftCollapsed,
48
+ effectiveRightCollapsed,
47
49
  rightPanelTab,
48
50
  setRightPanelTab,
49
51
  rightInspectorPanes,
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
59
61
  rightWidth,
60
62
  adjustPanelWidth,
61
63
  leftCollapsed,
62
- setLeftCollapsed,
63
64
  rightCollapsed,
64
65
  setRightCollapsed,
66
+ effectiveLeftCollapsed,
67
+ effectiveRightCollapsed,
65
68
  rightPanelTab,
66
69
  setRightPanelTab,
67
70
  rightInspectorPanes,
@@ -3,7 +3,25 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
3
3
  export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
4
4
 
5
5
  export type GsapEditOutcome =
6
- | { status: "persisted" }
6
+ | {
7
+ status: "persisted";
8
+ /**
9
+ * Whether this edit already accounted for where the gesture left the
10
+ * element, so the caller must not persist the drag offset on top.
11
+ *
12
+ * The scale route needs it: a committed scale renders around the element
13
+ * centre rather than the dragged corner, so it measures the difference
14
+ * and writes the position itself. Every other route moves nothing the
15
+ * caller has not already been told about, and the caller owns the offset.
16
+ *
17
+ * It has to be reported rather than inferred. The caller used to guess
18
+ * from "does this element have a scale-group tween", which is true for an
19
+ * element whose scale is an instant hold — but that resize commits
20
+ * width/height, not scale, so the guess withheld an offset nobody wrote
21
+ * and the element snapped back to its authored position on every drag.
22
+ */
23
+ ownsDragOffset?: boolean;
24
+ }
7
25
  | { status: "blocked"; reason: GsapEditBlockReason };
8
26
 
9
27
  const COPY: Record<GsapEditBlockReason, string> = {
@@ -1,12 +1,16 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
4
4
  import {
5
5
  clearKeyframeCacheForElement,
6
+ elementCacheKeys,
6
7
  pruneKeyframeCacheToFiles,
7
8
  replaceKeyframeCacheForFile,
8
9
  updateKeyframeCacheFromParsed,
9
10
  } from "./gsapKeyframeCacheHelpers";
11
+ import { trackStudioEvent } from "../utils/studioTelemetry";
12
+
13
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
10
14
 
11
15
  const entry = (): KeyframeCacheEntry => ({
12
16
  format: "percentage",
@@ -30,6 +34,48 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
30
34
 
31
35
  beforeEach(() => {
32
36
  usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
37
+ vi.mocked(trackStudioEvent).mockClear();
38
+ });
39
+
40
+ describe("non-string cache keys", () => {
41
+ // `s.indexOf is not a function` in pruneKeyframeCacheToFiles, decoded from the
42
+ // released 0.7.90 bundle. Some producer reaches elementCacheKeys with a
43
+ // non-string id; the bare-id key was written through raw, so both maps ended
44
+ // up holding a key that prune's `key.indexOf("#")` cannot handle.
45
+ const badId = 42 as unknown as string;
46
+
47
+ it("keeps every written key a string", () => {
48
+ expect(elementCacheKeys("comp.html", badId).every((k) => typeof k === "string")).toBe(true);
49
+ });
50
+
51
+ it("reports the offending value instead of swallowing it", () => {
52
+ elementCacheKeys("comp.html", badId);
53
+
54
+ expect(trackStudioEvent).toHaveBeenCalledWith(
55
+ "cache_key_non_string",
56
+ expect.objectContaining({
57
+ value_type: "number",
58
+ constructor_name: "Number",
59
+ source_file: "comp.html",
60
+ }),
61
+ );
62
+ });
63
+
64
+ it("stays silent on the normal string path", () => {
65
+ elementCacheKeys("comp.html", "box");
66
+
67
+ expect(trackStudioEvent).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("survives a prune after a write with a non-string id", () => {
71
+ replaceKeyframeCacheForFile(
72
+ "stale.html",
73
+ new Map([[badId, entry()]]),
74
+ new Map([[badId, [animWithKeyframes("box")]]]),
75
+ );
76
+
77
+ expect(() => pruneKeyframeCacheToFiles(["kept.html"])).not.toThrow();
78
+ });
33
79
  });
34
80
 
35
81
  describe("clearKeyframeCacheForElement", () => {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
+ import { trackStudioEvent } from "../utils/studioTelemetry";
7
8
  import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
8
9
  import {
9
10
  deduplicateKeyframes,
@@ -226,11 +227,38 @@ export function scopedElementKey(element: {
226
227
  return `${element.sourceFile || "index.html"}#${element.id}`;
227
228
  }
228
229
 
229
- /** Every cache key a write for this element sets, in read-preference order. */
230
+ /**
231
+ * The one gate every cache write passes through, so it is also the one place
232
+ * that can guarantee `keyframeCache` / `gsapAnimations` really are keyed by
233
+ * string the way their types claim.
234
+ *
235
+ * Two of the three keys are template literals, which coerce on their own. The
236
+ * bare-id key was passed through raw, so a non-string `elementId` reaching here
237
+ * put a non-string key in both maps — and `pruneKeyframeCacheToFiles` then threw
238
+ * `s.indexOf is not a function` on it, taking Studio to the crash boundary.
239
+ *
240
+ * Which caller supplies a non-string id is still unknown: every writer traced
241
+ * from here produces a string. So this coerces rather than guesses, and reports
242
+ * the offending value's shape instead of swallowing it — the next occurrence
243
+ * names its own producer.
244
+ */
230
245
  export function elementCacheKeys(sourceFile: string, elementId: string): string[] {
246
+ const id = typeof elementId === "string" ? elementId : coerceCacheKeyId(elementId, sourceFile);
231
247
  return sourceFile === "index.html"
232
- ? [`index.html#${elementId}`, elementId]
233
- : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
248
+ ? [`index.html#${id}`, id]
249
+ : [`${sourceFile}#${id}`, `index.html#${id}`, id];
250
+ }
251
+
252
+ function coerceCacheKeyId(elementId: unknown, sourceFile: string): string {
253
+ trackStudioEvent("cache_key_non_string", {
254
+ value_type: typeof elementId,
255
+ // An Element lands here as "HTMLDivElement", a boxed id as "Number" — enough
256
+ // to name the producer without shipping user content to telemetry.
257
+ constructor_name: (elementId as { constructor?: { name?: string } })?.constructor?.name ?? null,
258
+ is_array: Array.isArray(elementId),
259
+ source_file: sourceFile,
260
+ });
261
+ return String(elementId);
234
262
  }
235
263
 
236
264
  /** Replace one file's complete cache snapshot with one atomic store publish. */