@hyperframes/studio 0.6.28 → 0.6.30

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 (45) hide show
  1. package/dist/assets/index-BWBj8I6Q.css +1 -0
  2. package/dist/assets/index-D790O3az.js +115 -0
  3. package/dist/assets/index-DSLrl2tB.js +531 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +13 -0
  7. package/src/components/StudioErrorBoundary.tsx +68 -0
  8. package/src/components/StudioHeader.tsx +15 -3
  9. package/src/components/editor/PropertyPanel.tsx +4 -1
  10. package/src/components/editor/domEditingLayers.ts +15 -4
  11. package/src/components/editor/domEditingTypes.ts +1 -0
  12. package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
  13. package/src/components/renders/RenderQueue.tsx +2 -0
  14. package/src/components/renders/useRenderQueue.ts +9 -0
  15. package/src/components/sidebar/LeftSidebar.tsx +2 -0
  16. package/src/contexts/FileManagerContext.tsx +3 -3
  17. package/src/hooks/useDomEditCommits.ts +52 -24
  18. package/src/hooks/useFileManager.ts +15 -13
  19. package/src/hooks/usePanelLayout.ts +11 -1
  20. package/src/hooks/useRenderClipContent.test.ts +50 -0
  21. package/src/hooks/useRenderClipContent.ts +23 -4
  22. package/src/hooks/useServerConnection.ts +11 -1
  23. package/src/main.tsx +22 -1
  24. package/src/player/components/CompositionThumbnail.tsx +10 -44
  25. package/src/player/components/PlayerControls.tsx +16 -3
  26. package/src/player/components/TimelineCanvas.tsx +9 -23
  27. package/src/player/components/TimelineClip.tsx +63 -67
  28. package/src/player/components/timelineEditing.test.ts +16 -0
  29. package/src/player/components/timelineEditing.ts +2 -1
  30. package/src/player/components/timelineTheme.ts +18 -48
  31. package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
  32. package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
  33. package/src/player/lib/mediaProbe.ts +20 -5
  34. package/src/player/lib/timelineDOM.ts +4 -0
  35. package/src/player/store/playerStore.ts +2 -0
  36. package/src/styles/studio.css +9 -0
  37. package/src/telemetry/client.test.ts +100 -0
  38. package/src/telemetry/client.ts +145 -0
  39. package/src/telemetry/config.ts +78 -0
  40. package/src/telemetry/events.test.ts +57 -0
  41. package/src/telemetry/events.ts +27 -0
  42. package/src/telemetry/system.ts +48 -0
  43. package/src/utils/studioTelemetry.ts +128 -0
  44. package/dist/assets/index-DVpLGNHi.css +0 -1
  45. package/dist/assets/index-EdfhuQ5T.js +0 -362
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-EdfhuQ5T.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DVpLGNHi.css">
8
+ <script type="module" crossorigin src="/assets/index-D790O3az.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BWBj8I6Q.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.28",
35
- "@hyperframes/player": "0.6.28"
34
+ "@hyperframes/core": "0.6.30",
35
+ "@hyperframes/player": "0.6.30"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.28"
49
+ "@hyperframes/producer": "0.6.30"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
package/src/App.tsx CHANGED
@@ -47,11 +47,24 @@ import {
47
47
  normalizeStudioCompositionPath,
48
48
  readStudioUrlStateFromWindow,
49
49
  } from "./utils/studioUrlState";
50
+ import { trackStudioSessionStart } from "./telemetry/events";
51
+ import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
50
52
 
51
53
  export function StudioApp() {
52
54
  const { projectId, resolving, waitingForServer } = useServerConnection();
53
55
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
54
56
 
57
+ // Fire once per browser tab session — sessionStorage-backed so HMR
58
+ // remounts, route changes, and any future StudioApp remount within the
59
+ // same tab don't refire `studio_session_start`. `has_project` lets us
60
+ // tell scratch-open from project-context-open.
61
+ useEffect(() => {
62
+ if (resolving || waitingForServer) return;
63
+ if (hasFiredSessionStart()) return;
64
+ markSessionStartFired();
65
+ trackStudioSessionStart({ has_project: projectId != null });
66
+ }, [projectId, resolving, waitingForServer]);
67
+
55
68
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
56
69
  const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
57
70
  () => initialUrlStateRef.current.activeCompPath == null,
@@ -0,0 +1,68 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+ import { trackStudioEvent } from "../utils/studioTelemetry";
3
+
4
+ interface Props {
5
+ children: ReactNode;
6
+ }
7
+
8
+ interface State {
9
+ error: Error | null;
10
+ }
11
+
12
+ export class StudioErrorBoundary extends Component<Props, State> {
13
+ state: State = { error: null };
14
+
15
+ static getDerivedStateFromError(error: Error): State {
16
+ return { error };
17
+ }
18
+
19
+ componentDidCatch(error: Error, info: ErrorInfo) {
20
+ console.error("[Studio] Uncaught error:", error, info.componentStack);
21
+ trackStudioEvent("crash", {
22
+ error_message: error.message,
23
+ error_name: error.name,
24
+ component_stack: info.componentStack?.slice(0, 500) ?? null,
25
+ });
26
+ }
27
+
28
+ render() {
29
+ if (!this.state.error) return this.props.children;
30
+
31
+ return (
32
+ <div
33
+ style={{
34
+ position: "fixed",
35
+ inset: 0,
36
+ display: "flex",
37
+ flexDirection: "column",
38
+ alignItems: "center",
39
+ justifyContent: "center",
40
+ background: "#0a0a0a",
41
+ color: "#e5e5e5",
42
+ fontFamily: "system-ui, -apple-system, sans-serif",
43
+ gap: 16,
44
+ }}
45
+ >
46
+ <div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
47
+ <div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
48
+ {this.state.error.message}
49
+ </div>
50
+ <button
51
+ onClick={() => this.setState({ error: null })}
52
+ style={{
53
+ marginTop: 8,
54
+ padding: "8px 20px",
55
+ background: "#2563eb",
56
+ color: "#fff",
57
+ border: "none",
58
+ borderRadius: 6,
59
+ fontSize: 14,
60
+ cursor: "pointer",
61
+ }}
62
+ >
63
+ Try again
64
+ </button>
65
+ </div>
66
+ );
67
+ }
68
+ }
@@ -8,6 +8,7 @@ import { getHistoryShortcutLabel } from "../utils/studioHelpers";
8
8
  import { useStudioContext } from "../contexts/StudioContext";
9
9
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
10
10
  import { useDomEditContext } from "../contexts/DomEditContext";
11
+ import { trackStudioEvent } from "../utils/studioTelemetry";
11
12
 
12
13
  export interface StudioHeaderProps {
13
14
  captureFrameHref: string;
@@ -165,7 +166,10 @@ export function StudioHeader({
165
166
  <div className="flex items-center gap-1.5">
166
167
  <button
167
168
  type="button"
168
- onClick={() => void handleUndo()}
169
+ onClick={() => {
170
+ trackStudioEvent("toolbar_action", { action: "undo" });
171
+ void handleUndo();
172
+ }}
169
173
  disabled={!editHistory.canUndo}
170
174
  className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
171
175
  editHistory.canUndo
@@ -183,7 +187,10 @@ export function StudioHeader({
183
187
  </button>
184
188
  <button
185
189
  type="button"
186
- onClick={() => void handleRedo()}
190
+ onClick={() => {
191
+ trackStudioEvent("toolbar_action", { action: "redo" });
192
+ void handleRedo();
193
+ }}
187
194
  disabled={!editHistory.canRedo}
188
195
  className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
189
196
  editHistory.canRedo
@@ -202,7 +209,10 @@ export function StudioHeader({
202
209
  <a
203
210
  href={captureFrameHref}
204
211
  download={captureFrameFilename}
205
- onClick={handleCaptureFrameClick}
212
+ onClick={(e) => {
213
+ trackStudioEvent("toolbar_action", { action: "capture_frame" });
214
+ handleCaptureFrameClick(e);
215
+ }}
206
216
  onFocus={refreshCaptureFrameTime}
207
217
  onPointerDown={refreshCaptureFrameTime}
208
218
  className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
@@ -217,10 +227,12 @@ export function StudioHeader({
217
227
  onClick={() => {
218
228
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
219
229
  if (rightCollapsed || !inspectorPanelActive) {
230
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
220
231
  setRightPanelTab("design");
221
232
  setRightCollapsed(false);
222
233
  return;
223
234
  }
235
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
224
236
  clearDomSelection();
225
237
  setRightCollapsed(true);
226
238
  }}
@@ -74,7 +74,10 @@ function TimingSection({
74
74
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
75
75
  }) {
76
76
  const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
77
- const duration = Number.parseFloat(element.dataAttributes.duration ?? "0") || 0;
77
+ const duration =
78
+ Number.parseFloat(
79
+ element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
80
+ ) || 0;
78
81
  const end = start + duration;
79
82
 
80
83
  const commitStart = (nextValue: string) => {
@@ -12,6 +12,7 @@ import type {
12
12
  } from "./domEditingTypes";
13
13
  import {
14
14
  buildStableSelector,
15
+ findClosestByAttribute,
15
16
  getCuratedComputedStyles,
16
17
  getDataAttributes,
17
18
  getInlineStyles,
@@ -175,18 +176,21 @@ export function resolveDomEditCapabilities(args: {
175
176
  inlineStyles: Record<string, string>;
176
177
  computedStyles: Record<string, string>;
177
178
  isCompositionHost: boolean;
179
+ isInsideLockedComposition: boolean;
178
180
  isMasterView: boolean;
179
181
  }): DomEditCapabilities {
180
- if (!args.selector) {
182
+ if (!args.selector || args.isInsideLockedComposition) {
181
183
  return {
182
- canSelect: false,
184
+ canSelect: !args.isInsideLockedComposition,
183
185
  canEditStyles: false,
184
186
  canMove: false,
185
187
  canResize: false,
186
188
  canApplyManualOffset: false,
187
189
  canApplyManualSize: false,
188
190
  canApplyManualRotation: false,
189
- reasonIfDisabled: "Studio could not resolve a stable patch target for this element.",
191
+ reasonIfDisabled: args.isInsideLockedComposition
192
+ ? "This element belongs to a locked composition."
193
+ : "Studio could not resolve a stable patch target for this element.",
190
194
  };
191
195
  }
192
196
 
@@ -298,6 +302,7 @@ export function resolveDomEditSelection(
298
302
  const inlineStyles = getInlineStyles(current);
299
303
  const computedStyles = getCuratedComputedStyles(current);
300
304
  const textFields = collectDomEditTextFields(current);
305
+ const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
301
306
  const capabilities = resolveDomEditCapabilities({
302
307
  selector,
303
308
  tagName: current.tagName.toLowerCase(),
@@ -305,6 +310,7 @@ export function resolveDomEditSelection(
305
310
  inlineStyles,
306
311
  computedStyles,
307
312
  isCompositionHost: Boolean(compositionSrc),
313
+ isInsideLockedComposition: isInsideLocked,
308
314
  isMasterView: options.isMasterView,
309
315
  });
310
316
  const rect = current.getBoundingClientRect();
@@ -318,6 +324,7 @@ export function resolveDomEditSelection(
318
324
  compositionPath,
319
325
  compositionSrc,
320
326
  isCompositionHost: Boolean(compositionSrc),
327
+ isInsideLockedComposition: isInsideLocked,
321
328
  label: buildElementLabel(current),
322
329
  tagName: current.tagName.toLowerCase(),
323
330
  boundingBox: {
@@ -488,7 +495,11 @@ export function getDomEditTargetKey(
488
495
  }
489
496
 
490
497
  export function isTextEditableSelection(selection: DomEditSelection): boolean {
491
- return selection.textFields.length > 0 && !selection.isCompositionHost;
498
+ return (
499
+ selection.textFields.length > 0 &&
500
+ !selection.isCompositionHost &&
501
+ !selection.isInsideLockedComposition
502
+ );
492
503
  }
493
504
 
494
505
  // buildElementAgentPrompt is in domEditingAgentPrompt.ts
@@ -78,6 +78,7 @@ export interface DomEditSelection extends PatchTarget {
78
78
  compositionPath: string;
79
79
  compositionSrc?: string;
80
80
  isCompositionHost: boolean;
81
+ isInsideLockedComposition: boolean;
81
82
  boundingBox: { x: number; y: number; width: number; height: number };
82
83
  textContent: string | null;
83
84
  dataAttributes: Record<string, string>;
@@ -1,4 +1,5 @@
1
1
  import { ArrowLeft, CaretRight } from "@phosphor-icons/react";
2
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
2
3
 
3
4
  export interface CompositionLevel {
4
5
  /** Unique id — "master" or composition file path */
@@ -25,7 +26,13 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
25
26
  {/* Back button — always goes to parent */}
26
27
  <button
27
28
  type="button"
28
- onClick={() => onNavigate(stack.length - 2)}
29
+ onClick={() => {
30
+ trackStudioEvent("navigation", {
31
+ action: "back",
32
+ target: stack[stack.length - 2]?.label,
33
+ });
34
+ onNavigate(stack.length - 2);
35
+ }}
29
36
  className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
30
37
  title="Back (Esc)"
31
38
  >
@@ -43,7 +50,10 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
43
50
  ) : (
44
51
  <button
45
52
  type="button"
46
- onClick={() => onNavigate(i)}
53
+ onClick={() => {
54
+ trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
55
+ onNavigate(i);
56
+ }}
47
57
  className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
48
58
  >
49
59
  {level.label}
@@ -2,6 +2,7 @@ import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
3
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
4
4
  import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
5
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
5
6
 
6
7
  export interface CompositionDimensions {
7
8
  width: number;
@@ -277,6 +278,7 @@ function FormatExportButton({
277
278
  </select>
278
279
  <button
279
280
  onClick={() => {
281
+ trackStudioEvent("render_start", { format, quality, resolution, fps });
280
282
  void onStartRender(format, quality, resolution, fps);
281
283
  }}
282
284
  disabled={isRendering}
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { trackStudioRenderStart } from "../../telemetry/events";
2
3
 
3
4
  export interface RenderJob {
4
5
  id: string;
@@ -90,6 +91,14 @@ export function useRenderQueue(projectId: string | null) {
90
91
  const resolution = opts.resolution;
91
92
  const composition = opts.composition;
92
93
 
94
+ trackStudioRenderStart({
95
+ fps,
96
+ quality,
97
+ format,
98
+ resolution,
99
+ composition,
100
+ });
101
+
93
102
  const startTime = Date.now();
94
103
  // "auto" / undefined means "render at the composition's authored size".
95
104
  // Omit the field entirely — sending "auto" would trip the route's
@@ -8,6 +8,7 @@ import {
8
8
  } from "react";
9
9
  import { CompositionsTab } from "./CompositionsTab";
10
10
  import { AssetsTab } from "./AssetsTab";
11
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
11
12
  import { BlocksTab } from "./BlocksTab";
12
13
  import { FileTree } from "../editor/FileTree";
13
14
  import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
@@ -90,6 +91,7 @@ export const LeftSidebar = memo(
90
91
  const selectTab = useCallback((t: SidebarTab) => {
91
92
  setTab(t);
92
93
  localStorage.setItem(STORAGE_KEY, t);
94
+ trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
93
95
  }, []);
94
96
 
95
97
  useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
@@ -21,7 +21,7 @@ export function FileManagerProvider({
21
21
  setFileTree,
22
22
  editingPathRef,
23
23
  projectIdRef,
24
- saveTimerRef,
24
+ saveRafRef,
25
25
  importedFontAssetsRef,
26
26
  readProjectFile,
27
27
  writeProjectFile,
@@ -59,7 +59,7 @@ export function FileManagerProvider({
59
59
  setFileTree,
60
60
  editingPathRef,
61
61
  projectIdRef,
62
- saveTimerRef,
62
+ saveRafRef,
63
63
  importedFontAssetsRef,
64
64
  readProjectFile,
65
65
  writeProjectFile,
@@ -91,7 +91,7 @@ export function FileManagerProvider({
91
91
  setFileTree,
92
92
  editingPathRef,
93
93
  projectIdRef,
94
- saveTimerRef,
94
+ saveRafRef,
95
95
  importedFontAssetsRef,
96
96
  readProjectFile,
97
97
  writeProjectFile,
@@ -1,7 +1,8 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import { FONT_EXT } from "../utils/mediaTypes";
4
- import { applyPatchByTarget } from "../utils/sourcePatcher";
4
+ import type { PatchOperation } from "../utils/sourcePatcher";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
5
6
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
6
7
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
7
8
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
@@ -45,7 +46,7 @@ interface RecordEditInput {
45
46
 
46
47
  export type PersistDomEditOperations = (
47
48
  selection: DomEditSelection,
48
- operations: Parameters<typeof applyPatchByTarget>[2][],
49
+ operations: PatchOperation[],
49
50
  options?: {
50
51
  label?: string;
51
52
  coalesceKey?: string;
@@ -134,39 +135,61 @@ export function useDomEditCommits({
134
135
  if (options?.shouldSave && !options.shouldSave()) return;
135
136
 
136
137
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
137
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
138
- if (!response.ok) {
139
- throw new Error(`Failed to read ${targetPath}`);
140
- }
141
138
 
142
- const data = (await response.json()) as { content?: string };
143
- const originalContent = data.content;
139
+ const readResponse = await fetch(
140
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
141
+ );
142
+ if (!readResponse.ok) throw new Error(`Failed to read ${targetPath}`);
143
+ const readData = (await readResponse.json()) as { content?: string };
144
+ const originalContent = readData.content;
144
145
  if (typeof originalContent !== "string") {
145
146
  throw new Error(`Missing file contents for ${targetPath}`);
146
147
  }
147
148
 
148
- let patchedContent = originalContent;
149
- for (const operation of operations) {
150
- patchedContent = applyPatchByTarget(patchedContent, selection, operation);
151
- }
152
- if (options?.prepareContent) {
153
- patchedContent = options.prepareContent(patchedContent, targetPath);
154
- }
155
149
  if (options?.shouldSave && !options.shouldSave()) return;
156
150
 
157
- if (patchedContent === originalContent) {
151
+ const patchTarget: { id?: string | null; selector?: string; selectorIndex?: number } = {
152
+ id: selection.id,
153
+ selector: selection.selector,
154
+ selectorIndex: selection.selectorIndex,
155
+ };
156
+
157
+ const patchResponse = await fetch(
158
+ `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
159
+ {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({ target: patchTarget, operations }),
163
+ },
164
+ );
165
+ if (!patchResponse.ok) throw new Error(`Failed to patch ${targetPath}`);
166
+
167
+ const patchData = (await patchResponse.json()) as {
168
+ ok?: boolean;
169
+ changed?: boolean;
170
+ content?: string;
171
+ };
172
+
173
+ if (!patchData.changed) {
158
174
  throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
159
175
  }
160
176
 
161
- await saveProjectFilesWithHistory({
162
- projectId: pid,
177
+ const patchedContent =
178
+ typeof patchData.content === "string" ? patchData.content : originalContent;
179
+
180
+ let finalContent = patchedContent;
181
+ if (options?.prepareContent) {
182
+ finalContent = options.prepareContent(patchedContent, targetPath);
183
+ if (finalContent !== patchedContent) {
184
+ await writeProjectFile(targetPath, finalContent);
185
+ }
186
+ }
187
+
188
+ await editHistory.recordEdit({
163
189
  label: options?.label ?? "Edit layer",
164
190
  kind: "manual",
165
191
  coalesceKey: options?.coalesceKey,
166
- files: { [targetPath]: patchedContent },
167
- readFile: async () => originalContent,
168
- writeFile: writeProjectFile,
169
- recordEdit: editHistory.recordEdit,
192
+ files: { [targetPath]: { before: originalContent, after: finalContent } },
170
193
  });
171
194
 
172
195
  if (options?.skipRefresh) {
@@ -177,7 +200,7 @@ export function useDomEditCommits({
177
200
  },
178
201
  [
179
202
  activeCompPath,
180
- editHistory.recordEdit,
203
+ editHistory,
181
204
  writeProjectFile,
182
205
  projectIdRef,
183
206
  domEditSaveTimestampRef,
@@ -212,7 +235,7 @@ export function useDomEditCommits({
212
235
  const commitPositionPatchToHtml = useCallback(
213
236
  (
214
237
  selection: DomEditSelection,
215
- patches: Parameters<typeof applyPatchByTarget>[2][],
238
+ patches: PatchOperation[],
216
239
  options: { label: string; coalesceKey: string; skipRefresh?: boolean },
217
240
  ) => {
218
241
  void queueDomEditSave(async () => {
@@ -224,6 +247,11 @@ export function useDomEditCommits({
224
247
  }).catch((error) => {
225
248
  const message = error instanceof Error ? error.message : "Failed to save position";
226
249
  showToast(message);
250
+ trackStudioEvent("save_failure", {
251
+ source: "dom_edit",
252
+ label: options.label,
253
+ error_message: message,
254
+ });
227
255
  });
228
256
  },
229
257
  [persistDomEditOperations, queueDomEditSave, showToast],
@@ -5,6 +5,7 @@ import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/e
5
5
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
6
6
  import type { EditHistoryKind } from "../utils/editHistory";
7
7
  import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
8
+ import { trackStudioEvent } from "../utils/studioTelemetry";
8
9
 
9
10
  // ── Types ──
10
11
 
@@ -48,8 +49,8 @@ export function useFileManager({
48
49
  const projectIdRef = useRef(projectId);
49
50
  projectIdRef.current = projectId;
50
51
 
51
- const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
52
- const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
52
+ const saveRafRef = useRef<number | null>(null);
53
+ const refreshRafRef = useRef<number | null>(null);
53
54
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
54
55
 
55
56
  // ── Load file tree when projectId changes ──
@@ -145,12 +146,8 @@ export function useFileManager({
145
146
  const path = editingPathRef.current;
146
147
  if (!path) return;
147
148
 
148
- // Debounce the server write (600ms)
149
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
150
- saveTimerRef.current = setTimeout(() => {
151
- // Suppress the file-change watcher echo — the save callback triggers
152
- // its own refresh, so a second one from the watcher causes a double-reload
153
- // race that can leave the player in a non-playable state.
149
+ if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
150
+ saveRafRef.current = requestAnimationFrame(() => {
154
151
  domEditSaveTimestampRef.current = Date.now();
155
152
  saveProjectFilesWithHistory({
156
153
  projectId: pid,
@@ -163,11 +160,16 @@ export function useFileManager({
163
160
  recordEdit,
164
161
  })
165
162
  .then(() => {
166
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
167
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
163
+ if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
164
+ refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
168
165
  })
169
- .catch(() => {});
170
- }, 600);
166
+ .catch((error) => {
167
+ trackStudioEvent("save_failure", {
168
+ source: "code_editor",
169
+ error_message: error instanceof Error ? error.message : "unknown",
170
+ });
171
+ });
172
+ });
171
173
  },
172
174
  [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
173
175
  );
@@ -449,7 +451,7 @@ export function useFileManager({
449
451
  // Refs
450
452
  editingPathRef,
451
453
  projectIdRef,
452
- saveTimerRef,
454
+ saveRafRef,
453
455
  importedFontAssetsRef,
454
456
 
455
457
  // Core I/O
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import type { RightPanelTab } from "../utils/studioHelpers";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
4
+ import { trackStudioEvent } from "../utils/studioTelemetry";
4
5
 
5
6
  export interface InitialPanelLayoutState {
6
7
  rightCollapsed?: boolean | null;
@@ -26,6 +27,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
26
27
  const toggleLeftSidebar = useCallback(() => {
27
28
  setLeftCollapsed((collapsed) => {
28
29
  writeStudioUiPreferences({ leftCollapsed: !collapsed });
30
+ trackStudioEvent("panel_toggle", { panel: "left_sidebar", collapsed: !collapsed });
29
31
  return !collapsed;
30
32
  });
31
33
  }, []);
@@ -63,6 +65,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
63
65
  panelDragRef.current = null;
64
66
  }, []);
65
67
 
68
+ const trackedSetRightPanelTab = useCallback(
69
+ (tab: RightPanelTab) => {
70
+ setRightPanelTab(tab);
71
+ trackStudioEvent("tab_switch", { panel: "right_panel", tab });
72
+ },
73
+ [setRightPanelTab],
74
+ );
75
+
66
76
  return {
67
77
  leftWidth,
68
78
  setLeftWidth,
@@ -72,7 +82,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
72
82
  rightCollapsed,
73
83
  setRightCollapsed,
74
84
  rightPanelTab,
75
- setRightPanelTab,
85
+ setRightPanelTab: trackedSetRightPanelTab,
76
86
  toggleLeftSidebar,
77
87
  handlePanelResizeStart,
78
88
  handlePanelResizeMove,