@hyperframes/studio 0.8.60 → 0.8.62

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 (53) hide show
  1. package/dist/assets/{hyperframes-player-Di7GyCOP.js → hyperframes-player-BMHU9-3w.js} +1 -1
  2. package/dist/assets/{index-Dx_pt9m4.js → index-Ce1SRT2i.js} +1 -1
  3. package/dist/assets/{index-Dpq2lkOm.js → index-DLYk_vu_.js} +186 -186
  4. package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
  5. package/dist/assets/{index-jAjhQTrB.js → index-Dg0Vjj_i.js} +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3308 -2895
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +2 -0
  13. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
  14. package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
  15. package/src/hooks/useRenderClipContent.test.ts +33 -4
  16. package/src/hooks/useRenderClipContent.ts +7 -3
  17. package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
  18. package/src/hooks/useSdkSession.ts +84 -14
  19. package/src/hooks/useStudioSdkSessions.ts +8 -1
  20. package/src/player/components/AudioWaveform.test.tsx +53 -1
  21. package/src/player/components/AudioWaveform.tsx +113 -63
  22. package/src/player/components/TimelineClipFades.tsx +64 -31
  23. package/src/player/components/TimelineLanes.tsx +15 -8
  24. package/src/player/components/TimelineTransitionBadge.tsx +59 -0
  25. package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
  26. package/src/player/components/VideoThumbnail.test.tsx +23 -12
  27. package/src/player/components/VideoThumbnail.tsx +83 -27
  28. package/src/player/components/audioClipLink.test.ts +34 -0
  29. package/src/player/components/audioClipLink.ts +33 -0
  30. package/src/player/components/audioWaveformPeaks.test.ts +32 -0
  31. package/src/player/components/audioWaveformPeaks.ts +46 -0
  32. package/src/player/components/thumbnailUtils.test.ts +17 -0
  33. package/src/player/components/thumbnailUtils.ts +7 -0
  34. package/src/player/components/timelineTheme.ts +6 -0
  35. package/src/player/components/timelineTransitionSeams.test.ts +48 -0
  36. package/src/player/components/timelineTransitionSeams.ts +36 -0
  37. package/src/player/components/timelineZones.test.ts +18 -0
  38. package/src/player/components/timelineZones.ts +4 -1
  39. package/src/player/hooks/useShadowPreviewReload.ts +41 -6
  40. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +53 -0
  41. package/src/player/lib/playbackTypes.ts +2 -0
  42. package/src/player/lib/timelineDOM.test.ts +28 -0
  43. package/src/player/lib/timelineDOM.ts +24 -2
  44. package/src/player/lib/timelineIframeHelpers.ts +7 -0
  45. package/src/player/lib/timelineTransitionMetadata.ts +52 -0
  46. package/src/player/lib/timelineViewportBudgets.ts +2 -1
  47. package/src/player/store/timelineElement.ts +1 -0
  48. package/src/styles/hex-baseline.json +4 -4
  49. package/src/styles/studio.css +84 -0
  50. package/src/styles/theme.css +10 -2
  51. package/src/utils/sdkCutover.test.ts +102 -1
  52. package/src/utils/sdkCutover.ts +16 -21
  53. package/src/utils/sdkEditTransaction.ts +50 -1
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from "vitest";
3
+ import { gsap } from "gsap";
3
4
  import {
4
5
  createTimelineElementFromManifestClip,
5
6
  parseTimelineFromDOM,
@@ -20,6 +21,33 @@ function makeDoc(html: string): Document {
20
21
  }
21
22
 
22
23
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
24
+ it("bridges a real GSAP transition marker onto both named clips", () => {
25
+ document.body.innerHTML = `
26
+ <div data-composition-id="root">
27
+ <img data-hf-id="outgoing" data-start="0" data-duration="2.5" data-track-index="0" />
28
+ <img data-hf-id="incoming" data-start="2" data-duration="2.5" data-track-index="0" />
29
+ </div>
30
+ `;
31
+ const timeline = gsap.timeline({ paused: true });
32
+ timeline.addLabel("hf:transition:outgoing:incoming:crossfade", 2);
33
+ const runtimeWindow = window as Window & {
34
+ __timelines?: Record<string, typeof timeline>;
35
+ };
36
+ runtimeWindow.__timelines = { main: timeline };
37
+
38
+ try {
39
+ const elements = parseTimelineFromDOM(document, 5);
40
+ expect(elements.map((element) => element.transitionLabel)).toEqual([
41
+ "hf:transition:outgoing:incoming:crossfade",
42
+ "hf:transition:outgoing:incoming:crossfade",
43
+ ]);
44
+ } finally {
45
+ delete runtimeWindow.__timelines;
46
+ timeline.kill();
47
+ document.body.innerHTML = "";
48
+ }
49
+ });
50
+
23
51
  it("harvests hfId from a data-start element that has data-hf-id", () => {
24
52
  const doc = makeDoc(`
25
53
  <div data-composition-id="root">
@@ -9,10 +9,11 @@
9
9
  */
10
10
 
11
11
  import type { TimelineElement } from "../store/playerStore";
12
- import type { ClipManifestClip } from "./playbackTypes";
12
+ import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
14
  import { readClipTiming } from "@hyperframes/core/composition-contract";
15
15
  import { groupInfoFor } from "./timelineGroupInfo";
16
+ import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
16
17
  import {
17
18
  resolveMediaElement,
18
19
  applyMediaMetadataFromElement,
@@ -75,6 +76,9 @@ export function createTimelineElementFromManifestClip(params: {
75
76
  }): TimelineElement {
76
77
  const { clip, fallbackIndex, doc } = params;
77
78
  let hostEl = params.hostEl ?? null;
79
+ const transitionLabels = doc
80
+ ? transitionLabelsForDocument(doc, (doc.defaultView as IframeWindow | null)?.__timelines)
81
+ : new Map<Element, string>();
78
82
  const label = getTimelineElementDisplayLabel({
79
83
  id: clip.id,
80
84
  label: clip.label,
@@ -108,6 +112,10 @@ export function createTimelineElementFromManifestClip(params: {
108
112
  const entry: TimelineElement = {
109
113
  id: identity.id,
110
114
  label,
115
+ transitionLabel:
116
+ (hostEl && transitionLabels.get(hostEl)) ||
117
+ hostEl?.getAttribute("data-transition-label") ||
118
+ undefined,
111
119
  key: identity.key,
112
120
  kind: clip.kind,
113
121
  tag: resolveClipTag(clip),
@@ -134,6 +142,10 @@ export function createTimelineElementFromManifestClip(params: {
134
142
 
135
143
  if (hostEl) {
136
144
  applyMediaMetadataFromElement(entry, hostEl);
145
+ if (!entry.src) {
146
+ const rawSrc = hostEl.getAttribute("src");
147
+ if (rawSrc) entry.src = new URL(rawSrc, hostEl.baseURI).href;
148
+ }
137
149
  if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
138
150
  const timelineRole = hostEl.getAttribute("data-timeline-role");
139
151
  if (timelineRole) entry.timelineRole = timelineRole;
@@ -205,11 +217,19 @@ export function createTimelineElementFromManifestClip(params: {
205
217
  * Parse [data-start] elements from a Document into TimelineElement[].
206
218
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
207
219
  */
208
- export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
220
+ export function parseTimelineFromDOM(
221
+ doc: Document,
222
+ rootDuration: number,
223
+ timelines?: Readonly<Record<string, TimelineLike>>,
224
+ ): TimelineElement[] {
209
225
  const rootComp = doc.querySelector("[data-composition-id]");
210
226
  const nodes = doc.querySelectorAll("[data-start]");
211
227
  const els: TimelineElement[] = [];
212
228
  let trackCounter = 0;
229
+ const transitionLabels = transitionLabelsForDocument(
230
+ doc,
231
+ timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines,
232
+ );
213
233
 
214
234
  // fallow-ignore-next-line complexity
215
235
  nodes.forEach((node) => {
@@ -252,6 +272,8 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
252
272
  const entry: TimelineElement = {
253
273
  id: identity.id,
254
274
  label,
275
+ transitionLabel:
276
+ transitionLabels.get(el) ?? el.getAttribute("data-transition-label") ?? undefined,
255
277
  key: identity.key,
256
278
  kind:
257
279
  compId && compId !== rootComp?.getAttribute("data-composition-id")
@@ -22,6 +22,7 @@ import {
22
22
  readTimelineElementZIndex,
23
23
  } from "./timelineElementHelpers";
24
24
  import { postRuntimeControlMessage } from "./runtimeProtocol";
25
+ import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Viewport / DOM normalisation
@@ -414,6 +415,7 @@ function buildMissingCompositionEntry(params: {
414
415
  }): TimelineElement | null {
415
416
  const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
416
417
  params;
418
+ const transitionLabels = transitionLabelsForDocument(doc, iframeWin.__timelines);
417
419
  const timing = readClipTiming(element, {
418
420
  resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
419
421
  });
@@ -447,6 +449,8 @@ function buildMissingCompositionEntry(params: {
447
449
  const entry: TimelineElement = {
448
450
  id: identity.id,
449
451
  label,
452
+ transitionLabel:
453
+ transitionLabels.get(element) ?? element.getAttribute("data-transition-label") ?? undefined,
450
454
  key: identity.key,
451
455
  tag: element.tagName.toLowerCase(),
452
456
  start: window.start,
@@ -459,6 +463,9 @@ function buildMissingCompositionEntry(params: {
459
463
  selectorIndex,
460
464
  sourceFile,
461
465
  zIndex: readTimelineElementZIndex(element),
466
+ src: optionalNonEmpty(element.getAttribute("src"))
467
+ ? new URL(element.getAttribute("src")!, element.baseURI).href
468
+ : undefined,
462
469
  };
463
470
  return attachCompositionSource(entry, element, compositionSrc);
464
471
  }
@@ -0,0 +1,52 @@
1
+ import type { TimelineLike } from "./playbackTypes";
2
+
3
+ const TRANSITION_LABEL_PREFIX = "hf:transition:";
4
+
5
+ type TimelineWithLabels = TimelineLike & {
6
+ labels?: Record<string, number>;
7
+ getLabels?: () => Record<string, number>;
8
+ };
9
+
10
+ function transitionParts(label: string): [string, string] | null {
11
+ if (!label.startsWith(TRANSITION_LABEL_PREFIX)) return null;
12
+ const parts = label.slice(TRANSITION_LABEL_PREFIX.length).split(":");
13
+ if (parts.length < 3 || !parts[0] || !parts[1]) return null;
14
+ return [parts[0], parts[1]];
15
+ }
16
+
17
+ function findTimedElement(doc: Document, markerId: string): Element | null {
18
+ for (const candidate of [markerId, `el-${markerId}`]) {
19
+ const byId = doc.getElementById(candidate);
20
+ if (byId?.hasAttribute("data-start")) return byId;
21
+ }
22
+ return (
23
+ Array.from(doc.querySelectorAll("[data-start]")).find(
24
+ (element) =>
25
+ element.getAttribute("data-hf-id") === markerId ||
26
+ element.getAttribute("data-composition-id") === markerId,
27
+ ) ?? null
28
+ );
29
+ }
30
+
31
+ export function transitionLabelsForDocument(
32
+ doc: Document,
33
+ timelines: Readonly<Record<string, TimelineLike>> | undefined,
34
+ ): ReadonlyMap<Element, string> {
35
+ const labels = new Map<Element, string>();
36
+ if (!timelines) return labels;
37
+
38
+ for (const timeline of Object.values(timelines) as TimelineWithLabels[]) {
39
+ const timelineLabels = timeline.getLabels?.() ?? timeline.labels ?? {};
40
+ for (const label of Object.keys(timelineLabels)) {
41
+ const pair = transitionParts(label);
42
+ if (!pair) continue;
43
+ const outgoing = findTimedElement(doc, pair[0]);
44
+ const incoming = findTimedElement(doc, pair[1]);
45
+ if (outgoing && incoming) {
46
+ labels.set(outgoing, label);
47
+ labels.set(incoming, label);
48
+ }
49
+ }
50
+ }
51
+ return labels;
52
+ }
@@ -49,6 +49,7 @@ export interface TimelineViewportBudgets {
49
49
 
50
50
  const MEBIBYTE = 1024 * 1024;
51
51
  const DAY_MS = 24 * 60 * 60 * 1000;
52
+ export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // 4K timeline width / tallest 16:9 tile.
52
53
 
53
54
  /**
54
55
  * The sole default budget owner for timeline viewport and media virtualization.
@@ -66,7 +67,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
66
67
  posterMaxPhysicalWidth: 240,
67
68
  posterMaxPhysicalHeight: 135,
68
69
  posterDprCap: 1.5,
69
- richPreviewFrameCount: 6,
70
+ richPreviewFrameCount: MAX_VISIBLE_THUMBNAIL_FRAMES,
70
71
  concurrentVideoDecodes: 2,
71
72
  concurrentMetadataJobs: 4,
72
73
  concurrentCompositionFetches: 2,
@@ -12,6 +12,7 @@ import type { ClipManifestClip } from "../lib/playbackTypes";
12
12
  export interface TimelineElement {
13
13
  id: string;
14
14
  label?: string;
15
+ transitionLabel?: string;
15
16
  key?: string;
16
17
  kind?: ClipManifestClip["kind"];
17
18
  tag: string;
@@ -1,5 +1,5 @@
1
1
  {
2
- "total": 302,
2
+ "total": 297,
3
3
  "files": {
4
4
  "packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
5
5
  "packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
@@ -49,7 +49,7 @@
49
49
  "packages/studio/src/components/sidebar/AudioRow.tsx": 2,
50
50
  "packages/studio/src/components/ui/HyperframesMark.tsx": 4,
51
51
  "packages/studio/src/hooks/useInlineTextEdit.ts": 1,
52
- "packages/studio/src/player/components/AudioWaveform.tsx": 1,
52
+ "packages/studio/src/player/components/AudioWaveform.tsx": 2,
53
53
  "packages/studio/src/player/components/BeatStrip.tsx": 6,
54
54
  "packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
55
55
  "packages/studio/src/player/components/Player.tsx": 2,
@@ -58,7 +58,7 @@
58
58
  "packages/studio/src/player/components/SpeedMenu.tsx": 2,
59
59
  "packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
60
60
  "packages/studio/src/telemetry/client.ts": 1,
61
- "packages/studio/src/webmcp/webmcpTestUtils.ts": 1,
62
- "packages/studio/src/styles/studio.css": 48
61
+ "packages/studio/src/styles/studio.css": 48,
62
+ "packages/studio/src/webmcp/webmcpTestUtils.ts": 1
63
63
  }
64
64
  }
@@ -208,6 +208,72 @@ body {
208
208
  var(--timeline-clip-shadow);
209
209
  }
210
210
 
211
+ .timeline-transition {
212
+ position: absolute;
213
+ width: 0;
214
+ height: 0;
215
+ pointer-events: none;
216
+ z-index: 30;
217
+ }
218
+
219
+ .timeline-transition__zone {
220
+ position: absolute;
221
+ left: calc(var(--timeline-transition-zone-width) / -2);
222
+ top: -21px;
223
+ width: var(--timeline-transition-zone-width);
224
+ height: 42px;
225
+ border-radius: 8px;
226
+ overflow: hidden;
227
+ background: var(--timeline-transition-zone);
228
+ filter: blur(2.5px);
229
+ opacity: 0.95;
230
+ }
231
+
232
+ .timeline-transition__image {
233
+ position: absolute;
234
+ inset: -4px;
235
+ background-position: center;
236
+ background-size: cover;
237
+ }
238
+
239
+ .timeline-transition__image--out {
240
+ mask-image: linear-gradient(90deg, black 0%, transparent 100%);
241
+ }
242
+
243
+ .timeline-transition__image--in {
244
+ mask-image: linear-gradient(90deg, transparent 0%, black 100%);
245
+ }
246
+
247
+ .timeline-transition__badge {
248
+ position: absolute;
249
+ left: -14px;
250
+ top: -14px;
251
+ display: grid;
252
+ width: 28px;
253
+ height: 28px;
254
+ place-items: center;
255
+ border: 1px solid color-mix(in srgb, var(--timeline-transition-badge-ink) 22%, transparent);
256
+ border-radius: 999px;
257
+ background: var(--timeline-transition-badge);
258
+ box-shadow: 0 2px 5px var(--timeline-playhead-shadow);
259
+ }
260
+
261
+ .timeline-transition__badge svg {
262
+ width: 17px;
263
+ height: 17px;
264
+ }
265
+ .timeline-transition__frame,
266
+ .timeline-transition__frame--out,
267
+ .timeline-transition__frame--in {
268
+ fill: none;
269
+ stroke: var(--timeline-transition-badge-ink);
270
+ stroke-width: 1.5;
271
+ }
272
+ .timeline-transition__intersection {
273
+ fill: var(--timeline-transition-badge-ink);
274
+ opacity: 0.7;
275
+ }
276
+
211
277
  .timeline-clip::before {
212
278
  content: "";
213
279
  position: absolute;
@@ -245,6 +311,24 @@ body {
245
311
  0 8px 24px var(--timeline-clip-shadow-dragging);
246
312
  }
247
313
 
314
+ /* Mute greys the pill in place. Hot path: no transition. */
315
+ .timeline-clip.is-audio[data-clip-hidden="true"],
316
+ .timeline-clip.is-audio[data-audio-muted="true"] {
317
+ background-color: var(--timeline-clip-audio-muted-bg);
318
+ transition: none;
319
+ }
320
+
321
+ .timeline-audio-link {
322
+ position: absolute;
323
+ z-index: 4;
324
+ top: -9px;
325
+ left: 50%;
326
+ width: 1px;
327
+ height: 10px;
328
+ background: var(--timeline-audio-link);
329
+ pointer-events: none;
330
+ }
331
+
248
332
  .timeline-clip.is-hovered {
249
333
  background-color: var(--clip-bg-hover);
250
334
  }
@@ -68,6 +68,8 @@
68
68
  --timeline-clip-audio-border: rgba(167, 139, 250, 0.4);
69
69
  --timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
70
70
  --timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
71
+ --timeline-clip-audio-muted-bg: rgb(72, 74, 78);
72
+ --timeline-audio-link: #a0aaa4;
71
73
  --timeline-clip-selection: rgba(255, 255, 255, 0.85);
72
74
  --timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
73
75
  --timeline-clip-radius: 8px;
@@ -91,10 +93,12 @@
91
93
  transparent 100%
92
94
  );
93
95
  --timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
94
- --timeline-waveform-bar-rgb: 75, 163, 210;
96
+ --timeline-waveform-bar-rgb: 245, 237, 255;
97
+ --timeline-waveform-muted-rgb: 236, 238, 242;
98
+ --timeline-waveform-baseline-rgb: 226, 216, 238;
95
99
  --timeline-waveform-error: repeating-linear-gradient(
96
100
  90deg,
97
- rgba(75, 163, 210, 0.35) 0 2px,
101
+ rgba(245, 237, 255, 0.35) 0 2px,
98
102
  transparent 2px 5px
99
103
  );
100
104
  --timeline-waveform-label-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
@@ -148,6 +152,9 @@
148
152
  --timeline-clip-shadow-dragging: rgba(0, 0, 0, 0.4);
149
153
  --timeline-playhead-glow: rgba(60, 230, 172, 0.14);
150
154
  --timeline-playhead-shadow: rgba(0, 0, 0, 0.55);
155
+ --timeline-transition-zone: rgba(255, 255, 255, 0.2);
156
+ --timeline-transition-badge: rgba(237, 244, 239, 0.98);
157
+ --timeline-transition-badge-ink: rgba(36, 56, 45, 0.96);
151
158
 
152
159
  /* -------------------------------------------------------------- geometry */
153
160
 
@@ -361,6 +368,7 @@
361
368
  --timeline-audio-link: #626d66;
362
369
  --timeline-waveform-bar-rgb: 20, 12, 32;
363
370
  --timeline-waveform-muted-rgb: 24, 28, 26;
371
+ --timeline-waveform-baseline-rgb: 76, 62, 88;
364
372
  --timeline-waveform-error: repeating-linear-gradient(
365
373
  90deg,
366
374
  rgba(20, 12, 32, 0.35) 0 2px,
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  shouldUseSdkCutover,
4
4
  sdkCutoverPersist,
@@ -14,6 +14,7 @@ import {
14
14
  import { openComposition } from "@hyperframes/sdk";
15
15
  import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
16
16
  import type { PatchOperation } from "./sourcePatcher";
17
+ import { StudioSaveNetworkError } from "./studioSaveDiagnostics";
17
18
 
18
19
  vi.mock("../components/editor/manualEditingAvailability", () => ({
19
20
  STUDIO_SDK_CUTOVER_ENABLED: true,
@@ -1404,3 +1405,103 @@ gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
1404
1405
  expect(written).toContain("gsap.timeline()");
1405
1406
  });
1406
1407
  });
1408
+
1409
+ describe("sdk_cutover_failed classification (error_kind)", () => {
1410
+ const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[5]> = {}) => ({
1411
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
1412
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
1413
+ reloadPreview: vi.fn(),
1414
+ ...candidateTestDeps(),
1415
+ ...overrides,
1416
+ });
1417
+
1418
+ const makeSession = () =>
1419
+ ({
1420
+ getElement: vi.fn().mockReturnValue({ inlineStyles: {} }),
1421
+ dispatch: vi.fn(),
1422
+ serialize: vi
1423
+ .fn()
1424
+ .mockReturnValueOnce("<html>before</html>")
1425
+ .mockReturnValue("<html></html>"),
1426
+ batch: vi.fn((fn: () => void) => fn()),
1427
+ }) as unknown as Parameters<typeof sdkCutoverPersist>[4];
1428
+
1429
+ beforeEach(() => {
1430
+ vi.mocked(trackStudioEvent).mockClear();
1431
+ });
1432
+
1433
+ // The production gate event this fix exists for: a bare `TypeError` from an
1434
+ // UNWRAPPED raw fetch (`readProjectFile`'s implementation in
1435
+ // useProjectFileWriter.ts, or writeProjectFile's own preflight — neither
1436
+ // wraps as StudioSaveNetworkError). Without the message-based fallback in
1437
+ // `cutoverErrorKind`, this files as `error_kind: "sdk"` and trips the
1438
+ // cutover-failure rollback gate on a network blip the SDK never owned.
1439
+ it("classifies a bare fetch TypeError from readProjectFile as network", async () => {
1440
+ const deps = makeDeps({
1441
+ readProjectFile: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
1442
+ });
1443
+ const session = makeSession();
1444
+ const sel = { hfId: "hf-abc" } as never;
1445
+
1446
+ const result = await sdkCutoverPersist(
1447
+ sel,
1448
+ [styleOp("color", "red")],
1449
+ "before",
1450
+ "/path.html",
1451
+ session,
1452
+ deps,
1453
+ );
1454
+
1455
+ expect(result.status).toBe("failed");
1456
+ expect(trackStudioEvent).toHaveBeenCalledWith(
1457
+ "sdk_cutover_failed",
1458
+ expect.objectContaining({ family: "dom", error_kind: "network" }),
1459
+ );
1460
+ });
1461
+
1462
+ it("classifies StudioSaveNetworkError from writeProjectFile as network", async () => {
1463
+ const deps = makeDeps({
1464
+ writeProjectFile: vi.fn().mockRejectedValue(new StudioSaveNetworkError("Failed to save")),
1465
+ });
1466
+ const session = makeSession();
1467
+ const sel = { hfId: "hf-abc" } as never;
1468
+
1469
+ const result = await sdkCutoverPersist(
1470
+ sel,
1471
+ [styleOp("color", "red")],
1472
+ "before",
1473
+ "/path.html",
1474
+ session,
1475
+ deps,
1476
+ );
1477
+
1478
+ expect(result.status).toBe("failed");
1479
+ expect(trackStudioEvent).toHaveBeenCalledWith(
1480
+ "sdk_cutover_failed",
1481
+ expect.objectContaining({ family: "dom", error_kind: "network" }),
1482
+ );
1483
+ });
1484
+
1485
+ it("classifies a plain error as an sdk defect, not network", async () => {
1486
+ const deps = makeDeps({
1487
+ writeProjectFile: vi.fn().mockRejectedValue(new Error("disk full")),
1488
+ });
1489
+ const session = makeSession();
1490
+ const sel = { hfId: "hf-abc" } as never;
1491
+
1492
+ const result = await sdkCutoverPersist(
1493
+ sel,
1494
+ [styleOp("color", "red")],
1495
+ "before",
1496
+ "/path.html",
1497
+ session,
1498
+ deps,
1499
+ );
1500
+
1501
+ expect(result.status).toBe("failed");
1502
+ expect(trackStudioEvent).toHaveBeenCalledWith(
1503
+ "sdk_cutover_failed",
1504
+ expect.objectContaining({ family: "dom", error_kind: "sdk" }),
1505
+ );
1506
+ });
1507
+ });
@@ -11,8 +11,8 @@ import {
11
11
  shouldDeclineTextCutoverForTarget,
12
12
  } from "./sdkCutoverEligibility";
13
13
  import {
14
- asCutoverError,
15
14
  declinedCutover,
15
+ failedCutover,
16
16
  persistSdkCandidateMutation,
17
17
  type CutoverDeps,
18
18
  type CutoverOptions,
@@ -45,12 +45,13 @@ function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean {
45
45
 
46
46
  function trackCutoverResult(
47
47
  result: CutoverResult,
48
+ family: StudioSdkOperationFamily,
48
49
  context: { hfId?: string | null; opCount: number },
49
50
  ): void {
50
51
  if (result.status === "committed") {
51
- trackStudioEvent("sdk_cutover_success", context);
52
+ trackStudioEvent("sdk_cutover_success", { ...context, family });
52
53
  } else if (result.status === "failed") {
53
- trackStudioEvent("sdk_cutover_failed", { ...context, error: result.error.message });
54
+ failedCutover(result.error, family, context);
54
55
  }
55
56
  }
56
57
 
@@ -106,7 +107,7 @@ export async function sdkCutoverPersist(
106
107
  },
107
108
  options,
108
109
  );
109
- trackCutoverResult(result, { hfId, opCount: ops.length });
110
+ trackCutoverResult(result, "dom", { hfId, opCount: ops.length });
110
111
  return result;
111
112
  }
112
113
 
@@ -148,12 +149,10 @@ export async function sdkTimingPersist(
148
149
  options,
149
150
  serializedBefore,
150
151
  );
151
- trackCutoverResult(result, { hfId, opCount: 1 });
152
+ trackCutoverResult(result, "timing", { hfId, opCount: 1 });
152
153
  return result;
153
154
  } catch (error) {
154
- const failed = { status: "failed", error: asCutoverError(error) } as const;
155
- trackStudioEvent("sdk_cutover_failed", { hfId, error: failed.error.message });
156
- return failed;
155
+ return failedCutover(error, "timing", { hfId, opCount: 1 });
157
156
  }
158
157
  }
159
158
 
@@ -202,24 +201,22 @@ export async function sdkTimingBatchPersist(
202
201
  serializedBefore,
203
202
  );
204
203
  if (result.status === "failed") {
205
- trackStudioEvent("sdk_cutover_failed", {
204
+ return failedCutover(result.error, "timing", {
206
205
  hfId: changes[0]?.hfId ?? null,
207
- error: result.error.message,
206
+ opCount: changes.length,
208
207
  });
209
- return result;
210
208
  }
211
209
  trackStudioEvent("sdk_cutover_success", {
212
210
  hfId: changes[0]?.hfId ?? null,
213
211
  opCount: changes.length,
212
+ family: "timing",
214
213
  });
215
214
  return result;
216
215
  } catch (error) {
217
- const failed = { status: "failed", error: asCutoverError(error) } as const;
218
- trackStudioEvent("sdk_cutover_failed", {
216
+ return failedCutover(error, "timing", {
219
217
  hfId: changes[0]?.hfId ?? null,
220
- error: failed.error.message,
218
+ opCount: changes.length,
221
219
  });
222
- return failed;
223
220
  }
224
221
  }
225
222
 
@@ -321,15 +318,13 @@ async function dispatchGsapOpAndPersist(
321
318
  serializedBefore,
322
319
  );
323
320
  if (result.status === "committed") {
324
- trackStudioEvent("sdk_cutover_success", { opCount: 1 });
321
+ trackStudioEvent("sdk_cutover_success", { opCount: 1, family });
325
322
  } else if (result.status === "failed") {
326
- trackStudioEvent("sdk_cutover_failed", { error: result.error.message });
323
+ return failedCutover(result.error, family, { opCount: 1 });
327
324
  }
328
325
  return result;
329
326
  } catch (error) {
330
- const failed = { status: "failed", error: asCutoverError(error) } as const;
331
- trackStudioEvent("sdk_cutover_failed", { error: failed.error.message });
332
- return failed;
327
+ return failedCutover(error, family, { opCount: 1 });
333
328
  }
334
329
  }
335
330
 
@@ -595,6 +590,6 @@ export async function sdkDeletePersist(
595
590
  (session) => session.removeElement(hfId),
596
591
  { label: "Delete element" },
597
592
  );
598
- trackCutoverResult(result, { hfId, opCount: 1 });
593
+ trackCutoverResult(result, "lifecycle", { hfId, opCount: 1 });
599
594
  return result;
600
595
  }
@@ -4,6 +4,11 @@ import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
4
4
  import { trackStudioEvent } from "./studioTelemetry";
5
5
  import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
6
6
  import type { StudioSdkOperationFamily } from "./sdkCutoverPolicy";
7
+ import {
8
+ StudioFileConflictError,
9
+ StudioSaveHttpError,
10
+ StudioSaveNetworkError,
11
+ } from "./studioSaveDiagnostics";
7
12
 
8
13
  export type CutoverResult =
9
14
  | { status: "declined"; reason: string }
@@ -91,10 +96,54 @@ export function declinedCutover(
91
96
  return { status: "declined", reason };
92
97
  }
93
98
 
94
- export function asCutoverError(error: unknown): Error {
99
+ function asCutoverError(error: unknown): Error {
95
100
  return error instanceof Error ? error : new Error(String(error));
96
101
  }
97
102
 
103
+ // Browser rejection strings for a fetch that never produced a response (offline,
104
+ // the dev server gone, a CSP/PNA block, an extension rewriting fetch). See
105
+ // `useSdkSession.ts`'s `readProjectFileOptional` catch for the same list.
106
+ const FETCH_REJECTION_MESSAGE = /Failed to fetch|Load failed|NetworkError when attempting to fetch/;
107
+
108
+ /**
109
+ * Classify a cutover failure for the gate dashboard. `writeProjectFile`'s own
110
+ * fetch wraps a rejection as `StudioSaveNetworkError`, but two raw fetches in
111
+ * this same transaction — `readProjectFile` (via `captureOnDiskBefore`) and
112
+ * `writeProjectFile`'s own read-before-write preflight — throw an unwrapped
113
+ * `TypeError` that reaches here undecorated. Without the message fallback,
114
+ * those file as `"sdk"` and trip the cutover-failure rollback gate on a
115
+ * network blip the SDK never had a chance to own.
116
+ */
117
+ function cutoverErrorKind(error: Error): "network" | "conflict" | "http" | "sdk" {
118
+ if (error instanceof StudioSaveNetworkError) return "network";
119
+ if (error instanceof StudioFileConflictError) return "conflict";
120
+ if (error instanceof StudioSaveHttpError) return "http";
121
+ if (error.name === "TypeError" && FETCH_REJECTION_MESSAGE.test(error.message)) return "network";
122
+ return "sdk";
123
+ }
124
+
125
+ /**
126
+ * Record a cutover that could not commit. Replaces ad hoc `trackStudioEvent("sdk_cutover_failed", ...)`
127
+ * calls so every gate event carries `family` and `error_kind` — without them the
128
+ * dashboard's rollback gate (any `sdk_cutover_failed` on a flipped version) cannot
129
+ * distinguish a real SDK defect from a rejected fetch it doesn't own.
130
+ */
131
+ export function failedCutover(
132
+ error: unknown,
133
+ family: StudioSdkOperationFamily,
134
+ context: { hfId?: string | null; opCount?: number } = {},
135
+ ): CutoverResult {
136
+ const err = asCutoverError(error);
137
+ trackStudioEvent("sdk_cutover_failed", {
138
+ ...context,
139
+ family,
140
+ error_kind: cutoverErrorKind(err),
141
+ ...(err instanceof StudioSaveHttpError ? { status: err.statusCode } : {}),
142
+ error: err.message,
143
+ });
144
+ return { status: "failed", error: err };
145
+ }
146
+
98
147
  /** Only an explicit decline may enter the legacy mutation backend. */
99
148
  export function cutoverCommittedOrThrow(result: CutoverResult): boolean {
100
149
  if (result.status === "failed") throw result.error;