@hyperframes/studio 0.6.29 → 0.6.31

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 (41) hide show
  1. package/dist/assets/index-BWBj8I6Q.css +1 -0
  2. package/dist/assets/index-DSLrl2tB.js +531 -0
  3. package/dist/assets/index-Do0kAMcy.js +115 -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 +69 -0
  8. package/src/components/StudioHeader.tsx +15 -3
  9. package/src/components/editor/PropertyPanel.tsx +4 -1
  10. package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
  11. package/src/components/nle/NLELayout.tsx +41 -6
  12. package/src/components/renders/RenderQueue.tsx +2 -0
  13. package/src/components/renders/useRenderQueue.ts +9 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +2 -0
  15. package/src/contexts/FileManagerContext.tsx +3 -3
  16. package/src/hooks/useAppHotkeys.ts +18 -0
  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 +36 -1
  24. package/src/player/components/CompositionThumbnail.tsx +10 -44
  25. package/src/player/components/PlayerControls.tsx +75 -3
  26. package/src/player/components/TimelineCanvas.tsx +9 -23
  27. package/src/player/components/TimelineClip.tsx +63 -67
  28. package/src/player/components/timelineTheme.ts +18 -48
  29. package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
  30. package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
  31. package/src/player/lib/mediaProbe.ts +20 -5
  32. package/src/styles/studio.css +9 -0
  33. package/src/telemetry/client.test.ts +100 -0
  34. package/src/telemetry/client.ts +145 -0
  35. package/src/telemetry/config.ts +78 -0
  36. package/src/telemetry/events.test.ts +57 -0
  37. package/src/telemetry/events.ts +27 -0
  38. package/src/telemetry/system.ts +48 -0
  39. package/src/utils/studioTelemetry.ts +128 -0
  40. package/dist/assets/index-C-kAqQVb.js +0 -362
  41. package/dist/assets/index-DVpLGNHi.css +0 -1
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-C-kAqQVb.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DVpLGNHi.css">
8
+ <script type="module" crossorigin src="/assets/index-Do0kAMcy.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.29",
3
+ "version": "0.6.31",
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.29",
35
- "@hyperframes/player": "0.6.29"
34
+ "@hyperframes/player": "0.6.31",
35
+ "@hyperframes/core": "0.6.31"
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.29"
49
+ "@hyperframes/producer": "0.6.31"
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,69 @@
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
+ stack_trace: error.stack?.slice(0, 4000) ?? null,
25
+ component_stack: info.componentStack?.slice(0, 2000) ?? null,
26
+ });
27
+ }
28
+
29
+ render() {
30
+ if (!this.state.error) return this.props.children;
31
+
32
+ return (
33
+ <div
34
+ style={{
35
+ position: "fixed",
36
+ inset: 0,
37
+ display: "flex",
38
+ flexDirection: "column",
39
+ alignItems: "center",
40
+ justifyContent: "center",
41
+ background: "#0a0a0a",
42
+ color: "#e5e5e5",
43
+ fontFamily: "system-ui, -apple-system, sans-serif",
44
+ gap: 16,
45
+ }}
46
+ >
47
+ <div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
48
+ <div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
49
+ {this.state.error.message}
50
+ </div>
51
+ <button
52
+ onClick={() => this.setState({ error: null })}
53
+ style={{
54
+ marginTop: 8,
55
+ padding: "8px 20px",
56
+ background: "#2563eb",
57
+ color: "#fff",
58
+ border: "none",
59
+ borderRadius: 6,
60
+ fontSize: 14,
61
+ cursor: "pointer",
62
+ }}
63
+ >
64
+ Try again
65
+ </button>
66
+ </div>
67
+ );
68
+ }
69
+ }
@@ -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) => {
@@ -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}
@@ -1,4 +1,12 @@
1
- import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react";
1
+ import {
2
+ useState,
3
+ useCallback,
4
+ useRef,
5
+ useEffect,
6
+ useSyncExternalStore,
7
+ memo,
8
+ type ReactNode,
9
+ } from "react";
2
10
  import { useMountEffect } from "../../hooks/useMountEffect";
3
11
  import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
4
12
  import type { TimelineElement } from "../../player";
@@ -71,6 +79,15 @@ const MIN_TIMELINE_H = 100;
71
79
  const DEFAULT_TIMELINE_H = 220;
72
80
  const MIN_PREVIEW_H = 120;
73
81
 
82
+ function subscribeFullscreen(cb: () => void) {
83
+ document.addEventListener("fullscreenchange", cb);
84
+ return () => document.removeEventListener("fullscreenchange", cb);
85
+ }
86
+
87
+ function getFullscreenElement() {
88
+ return document.fullscreenElement;
89
+ }
90
+
74
91
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
75
92
  return compositionLoading;
76
93
  }
@@ -248,9 +265,20 @@ export const NLELayout = memo(function NLELayout({
248
265
  onCompositionLoadingChangeParent?.(compositionLoading);
249
266
  }, [compositionLoading, onCompositionLoadingChangeParent]);
250
267
 
268
+ const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
251
269
  const isTimelineVisible = timelineVisible ?? true;
252
270
  const isDragging = useRef(false);
253
271
  const containerRef = useRef<HTMLDivElement>(null);
272
+ const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
273
+
274
+ const toggleFullscreen = useCallback(() => {
275
+ if (!containerRef.current) return;
276
+ if (document.fullscreenElement) {
277
+ void document.exitFullscreen();
278
+ } else {
279
+ void containerRef.current.requestFullscreen();
280
+ }
281
+ }, []);
254
282
 
255
283
  const currentLevel = compositionStack[compositionStack.length - 1];
256
284
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
@@ -312,6 +340,7 @@ export const NLELayout = memo(function NLELayout({
312
340
  className="flex flex-col h-full min-h-0 bg-neutral-950"
313
341
  onKeyDown={handleKeyDown}
314
342
  tabIndex={-1}
343
+ data-studio-fullscreen-target=""
315
344
  >
316
345
  {/* Preview + player controls */}
317
346
  <div className="flex-1 min-h-0 flex flex-col">
@@ -326,20 +355,26 @@ export const NLELayout = memo(function NLELayout({
326
355
  refreshKey={refreshKey}
327
356
  suppressLoadingOverlay={hasLoadedOnceRef.current}
328
357
  />
329
- {previewOverlay}
358
+ {!isFullscreen && previewOverlay}
330
359
  </div>
331
360
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
332
- {compositionStack.length > 1 && (
361
+ {!isFullscreen && compositionStack.length > 1 && (
333
362
  <CompositionBreadcrumb
334
363
  stack={compositionStack}
335
364
  onNavigate={handleNavigateComposition}
336
365
  />
337
366
  )}
338
- <PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
367
+ <PlayerControls
368
+ onTogglePlay={togglePlay}
369
+ onSeek={seek}
370
+ disabled={timelineDisabled}
371
+ isFullscreen={isFullscreen}
372
+ onToggleFullscreen={toggleFullscreen}
373
+ />
339
374
  </div>
340
375
  </div>
341
376
 
342
- {isTimelineVisible ? (
377
+ {!isFullscreen && isTimelineVisible ? (
343
378
  <>
344
379
  {/* Resize divider */}
345
380
  <div
@@ -396,7 +431,7 @@ export const NLELayout = memo(function NLELayout({
396
431
  )}
397
432
  </div>
398
433
  </>
399
- ) : onToggleTimeline ? (
434
+ ) : !isFullscreen && onToggleTimeline ? (
400
435
  <div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
401
436
  <div className="flex h-10 items-center justify-between px-3">
402
437
  <div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
@@ -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,
@@ -248,6 +248,24 @@ export function useAppHotkeys({
248
248
  }
249
249
  }
250
250
 
251
+ // F — toggle fullscreen preview
252
+ if (
253
+ event.key.toLowerCase() === "f" &&
254
+ !event.metaKey &&
255
+ !event.ctrlKey &&
256
+ !event.altKey &&
257
+ !event.shiftKey &&
258
+ !isEditableTarget(event.target)
259
+ ) {
260
+ event.preventDefault();
261
+ if (document.fullscreenElement) {
262
+ void document.exitFullscreen();
263
+ } else {
264
+ document.querySelector<HTMLElement>("[data-studio-fullscreen-target]")?.requestFullscreen();
265
+ }
266
+ return;
267
+ }
268
+
251
269
  // Delete / Backspace — remove selected element (timeline clip or preview selection)
252
270
  if (
253
271
  (event.key === "Delete" || event.key === "Backspace") &&
@@ -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