@hyperframes/studio 0.7.59 → 0.7.61

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 (78) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
  3. package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1441 -770
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +4 -9
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +47 -28
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  20. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
  23. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  24. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  25. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  26. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  27. package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  29. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  30. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  31. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  32. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  33. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  34. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  35. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  36. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  37. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  38. package/src/components/storyboard/useFrameComments.ts +22 -6
  39. package/src/contexts/FileManagerContext.tsx +3 -0
  40. package/src/contexts/PanelLayoutContext.tsx +3 -0
  41. package/src/contexts/ViewModeContext.tsx +60 -7
  42. package/src/hooks/timelineEditingHelpers.ts +2 -2
  43. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  44. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  46. package/src/hooks/useDomEditCommits.ts +2 -2
  47. package/src/hooks/useDomEditSession.ts +1 -1
  48. package/src/hooks/useEditorSave.ts +1 -1
  49. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  50. package/src/hooks/useFileManager.ts +76 -12
  51. package/src/hooks/usePanelLayout.test.ts +118 -0
  52. package/src/hooks/usePanelLayout.ts +24 -1
  53. package/src/hooks/usePreviewPersistence.ts +4 -1
  54. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  55. package/src/hooks/useRazorSplit.test.tsx +88 -0
  56. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  57. package/src/hooks/useRazorSplit.ts +50 -16
  58. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  59. package/src/hooks/useSlideshowTabState.ts +61 -0
  60. package/src/hooks/useTimelineEditing.ts +2 -0
  61. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  62. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  63. package/src/player/lib/playbackTypes.ts +7 -0
  64. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  65. package/src/player/lib/timelineDOM.ts +8 -11
  66. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  67. package/src/utils/domEditSaveQueue.test.ts +19 -0
  68. package/src/utils/domEditSaveQueue.ts +2 -1
  69. package/src/utils/sdkCutover.test.ts +21 -5
  70. package/src/utils/sdkEditTransaction.ts +5 -4
  71. package/src/utils/studioFileHistory.ts +6 -4
  72. package/src/utils/studioFileVersion.test.ts +36 -0
  73. package/src/utils/studioFileVersion.ts +23 -0
  74. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  75. package/src/utils/studioSaveDiagnostics.ts +21 -0
  76. package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
  77. package/dist/assets/index-Dj5p8U_A.css +0 -1
  78. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { TimelineElement } from "../store/playerStore";
14
14
  import type { IframeWindow } from "./playbackTypes";
15
+ import { readClipTiming } from "@hyperframes/core/composition-contract";
15
16
  import {
16
17
  getTimelineElementSelector,
17
18
  getTimelineElementSourceFile,
@@ -38,6 +39,9 @@ export function normalizePreviewViewport(doc: Document, win: Window): void {
38
39
  win.scrollTo({ top: 0, left: 0, behavior: "auto" });
39
40
  }
40
41
 
42
+ // Legacy recovery retained until versioned composition manifests complete
43
+ // their compatibility soak across published CDN runtimes.
44
+ // fallow-ignore-next-line complexity
41
45
  export function autoHealMissingCompositionIds(doc: Document): void {
42
46
  const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
43
47
  const referencedIds = new Set<string>();
@@ -258,6 +262,141 @@ export function stopScrubPreviewAudio(): void {
258
262
  // Enrich missing compositions from DOM
259
263
  // ---------------------------------------------------------------------------
260
264
 
265
+ function timelineDuration(iframeWin: IframeWindow, compositionId: string): number {
266
+ return (
267
+ (
268
+ iframeWin.__timelines?.[compositionId] as { duration?: () => number } | undefined
269
+ )?.duration?.() ?? 0
270
+ );
271
+ }
272
+
273
+ function createTimedElementLookup(doc: Document): Map<string, Element> {
274
+ const timedById = new Map<string, Element>();
275
+ for (const timed of doc.querySelectorAll("[data-start]")) {
276
+ for (const id of [
277
+ timed.id,
278
+ timed.getAttribute("data-hf-id"),
279
+ timed.getAttribute("data-composition-id"),
280
+ ]) {
281
+ if (id) timedById.set(id, timed);
282
+ }
283
+ }
284
+ return timedById;
285
+ }
286
+
287
+ function createReferenceEndResolver(
288
+ timedById: ReadonlyMap<string, Element>,
289
+ iframeWin: IframeWindow,
290
+ ): (refId: string, visiting: ReadonlySet<string>) => number | null {
291
+ const resolveEnd = (refId: string, visiting: ReadonlySet<string>): number | null => {
292
+ if (visiting.has(refId)) return null;
293
+ const referenced = timedById.get(refId);
294
+ if (!referenced) return null;
295
+ const next = new Set(visiting).add(refId);
296
+ const timing = readClipTiming(referenced, {
297
+ resolveReferenceEnd: (nestedId) => resolveEnd(nestedId, next),
298
+ });
299
+ if (timing.end != null) return timing.end;
300
+ const compositionId = referenced.getAttribute("data-composition-id");
301
+ const duration = compositionId ? timelineDuration(iframeWin, compositionId) : 0;
302
+ return timing.start == null || duration <= 0 ? null : timing.start + duration;
303
+ };
304
+ return resolveEnd;
305
+ }
306
+
307
+ function clampCompositionWindow(
308
+ start: number,
309
+ duration: number,
310
+ rootDuration: number,
311
+ ): { start: number; duration: number } | null {
312
+ if (!Number.isFinite(duration) || duration <= 0) return null;
313
+ const safeStart = Number.isFinite(start) ? start : 0;
314
+ if (!Number.isFinite(rootDuration) || rootDuration <= 0) {
315
+ return { start: safeStart, duration };
316
+ }
317
+ if (safeStart >= rootDuration) return null;
318
+ const clamped = Math.min(duration, Math.max(0, rootDuration - safeStart));
319
+ return clamped > 0 ? { start: safeStart, duration: clamped } : null;
320
+ }
321
+
322
+ function nonEmpty(value: string, fallback: string): string {
323
+ return value || fallback;
324
+ }
325
+
326
+ function optionalNonEmpty(value: string | null): string | undefined {
327
+ return value || undefined;
328
+ }
329
+
330
+ function attachCompositionSource(
331
+ entry: TimelineElement,
332
+ element: HTMLElement,
333
+ compositionSrc: string | null,
334
+ ): TimelineElement {
335
+ if (compositionSrc) return { ...entry, compositionSrc };
336
+ const innerVideo = element.querySelector("video[src]");
337
+ if (!innerVideo) return entry;
338
+ return { ...entry, src: optionalNonEmpty(innerVideo.getAttribute("src")), tag: "video" };
339
+ }
340
+
341
+ function buildMissingCompositionEntry(params: {
342
+ doc: Document;
343
+ iframeWin: IframeWindow;
344
+ element: HTMLElement;
345
+ compositionId: string;
346
+ rootDuration: number;
347
+ fallbackIndex: number;
348
+ resolveEnd: (refId: string, visiting: ReadonlySet<string>) => number | null;
349
+ }): TimelineElement | null {
350
+ const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
351
+ params;
352
+ const timing = readClipTiming(element, {
353
+ resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
354
+ });
355
+ const window = clampCompositionWindow(
356
+ timing.start ?? 0,
357
+ timing.duration ?? timelineDuration(iframeWin, compositionId),
358
+ rootDuration,
359
+ );
360
+ if (!window) return null;
361
+
362
+ const preferredId = nonEmpty(element.id, compositionId);
363
+ const compositionSrc =
364
+ element.getAttribute("data-composition-src") ?? element.getAttribute("data-composition-file");
365
+ const selector = getTimelineElementSelector(element);
366
+ const sourceFile = getTimelineElementSourceFile(element);
367
+ const selectorIndex = getTimelineElementSelectorIndex(doc, element, selector);
368
+ const label = getTimelineElementDisplayLabel({
369
+ id: preferredId,
370
+ label: element.getAttribute("data-timeline-label") ?? element.getAttribute("data-label"),
371
+ tag: element.tagName,
372
+ });
373
+ const identity = buildTimelineElementIdentity({
374
+ preferredId,
375
+ label,
376
+ fallbackIndex,
377
+ domId: optionalNonEmpty(element.id),
378
+ selector,
379
+ selectorIndex,
380
+ sourceFile,
381
+ });
382
+ const entry: TimelineElement = {
383
+ id: identity.id,
384
+ label,
385
+ key: identity.key,
386
+ tag: element.tagName.toLowerCase(),
387
+ start: window.start,
388
+ duration: window.duration,
389
+ track: timing.trackIndex,
390
+ domId: optionalNonEmpty(element.id),
391
+ hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
392
+ selector,
393
+ selectorIndex,
394
+ sourceFile,
395
+ zIndex: readTimelineElementZIndex(element),
396
+ };
397
+ return attachCompositionSource(entry, element, compositionSrc);
398
+ }
399
+
261
400
  /**
262
401
  * Scan the iframe DOM for composition hosts missing from the current
263
402
  * timeline elements and add them. The CDN runtime often fails to resolve
@@ -279,117 +418,24 @@ export function buildMissingCompositionElements(
279
418
  const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
280
419
  const missing: TimelineElement[] = [];
281
420
 
282
- hosts.forEach((host) => {
421
+ const resolveEnd = createReferenceEndResolver(createTimedElementLookup(doc), iframeWin);
422
+
423
+ for (const host of hosts) {
283
424
  const el = host as HTMLElement;
284
425
  const compId = el.getAttribute("data-composition-id");
285
- if (!compId || compId === rootCompId) return;
286
- if (existingIds.has(el.id) || existingIds.has(compId)) return;
287
-
288
- // Resolve start: numeric or element-reference
289
- const startAttr = el.getAttribute("data-start") ?? "0";
290
- let start = parseFloat(startAttr);
291
- if (isNaN(start)) {
292
- const ref =
293
- doc.getElementById(startAttr) ||
294
- doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
295
- if (ref) {
296
- const refStartAttr = ref.getAttribute("data-start") ?? "0";
297
- let refStart = parseFloat(refStartAttr);
298
- // Recursively resolve one level of reference for the ref's own start
299
- if (isNaN(refStart)) {
300
- const refRef =
301
- doc.getElementById(refStartAttr) ||
302
- doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
303
- const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
304
- const rrCompId = refRef?.getAttribute("data-composition-id");
305
- const rrDur =
306
- parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
307
- (rrCompId
308
- ? ((
309
- iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
310
- )?.duration?.() ?? 0)
311
- : 0);
312
- refStart = rrStart + rrDur;
313
- }
314
- const refCompId = ref.getAttribute("data-composition-id");
315
- const refDur =
316
- parseFloat(ref.getAttribute("data-duration") ?? "") ||
317
- (refCompId
318
- ? ((
319
- iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
320
- )?.duration?.() ?? 0)
321
- : 0);
322
- start = refStart + refDur;
323
- } else {
324
- start = 0;
325
- }
326
- }
327
-
328
- // Resolve duration from data-duration or GSAP timeline
329
- let dur = parseFloat(el.getAttribute("data-duration") ?? "");
330
- if (isNaN(dur) || dur <= 0) {
331
- dur =
332
- (
333
- iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
334
- )?.duration?.() ?? 0;
335
- }
336
- if (!Number.isFinite(dur) || dur <= 0) return;
337
- if (!Number.isFinite(start)) start = 0;
338
- if (Number.isFinite(rootDuration) && rootDuration > 0) {
339
- if (start >= rootDuration) return;
340
- dur = Math.min(dur, Math.max(0, rootDuration - start));
341
- if (dur <= 0) return;
342
- }
343
-
344
- const trackStr = el.getAttribute("data-track-index");
345
- const track = trackStr != null ? parseInt(trackStr, 10) : 0;
346
- // fallow-ignore-next-line code-duplication
347
- const compSrc =
348
- el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
349
- const selector = getTimelineElementSelector(el);
350
- const sourceFile = getTimelineElementSourceFile(el);
351
- const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
352
- const label = getTimelineElementDisplayLabel({
353
- id: el.id || compId || null,
354
- label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
355
- tag: el.tagName,
356
- });
357
- const identity = buildTimelineElementIdentity({
358
- preferredId: el.id || compId || null,
359
- label,
426
+ if (!compId || compId === rootCompId) continue;
427
+ if (existingIds.has(el.id) || existingIds.has(compId)) continue;
428
+ const entry = buildMissingCompositionEntry({
429
+ doc,
430
+ iframeWin,
431
+ element: el,
432
+ compositionId: compId,
433
+ rootDuration,
360
434
  fallbackIndex: missing.length,
361
- domId: el.id || undefined,
362
- selector,
363
- selectorIndex,
364
- sourceFile,
435
+ resolveEnd,
365
436
  });
366
- const entry: TimelineElement = {
367
- id: identity.id,
368
- label,
369
- key: identity.key,
370
- tag: el.tagName.toLowerCase(),
371
- start,
372
- duration: dur,
373
- track: isNaN(track) ? 0 : track,
374
- domId: el.id || undefined,
375
- hfId: el.getAttribute("data-hf-id") || undefined,
376
- selector,
377
- selectorIndex,
378
- sourceFile,
379
- zIndex: readTimelineElementZIndex(el),
380
- };
381
- if (compSrc) {
382
- entry.compositionSrc = compSrc;
383
- } else {
384
- // Inline composition — expose inner video for thumbnails
385
- const innerVideo = el.querySelector("video[src]");
386
- if (innerVideo) {
387
- entry.src = innerVideo.getAttribute("src") || undefined;
388
- entry.tag = "video";
389
- }
390
- }
391
- missing.push(entry);
392
- });
437
+ if (entry) missing.push(entry);
438
+ }
393
439
 
394
440
  // Patch existing elements that are missing compositionSrc
395
441
  let patched = false;
@@ -114,4 +114,23 @@ describe("dom edit save queue", () => {
114
114
  expect(onOpen).not.toHaveBeenCalled();
115
115
  queue.destroy();
116
116
  });
117
+
118
+ it("pauses immediately on a file conflict instead of retrying stale work", async () => {
119
+ const onOpen = vi.fn();
120
+ const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
121
+
122
+ await expect(
123
+ queue.enqueue(async () => {
124
+ throw new StudioSaveHttpError("File changed elsewhere", 409);
125
+ }),
126
+ ).rejects.toThrow("File changed elsewhere");
127
+
128
+ expect(onOpen).toHaveBeenCalledWith({
129
+ consecutiveFailures: 1,
130
+ errorMessage: "File changed elsewhere",
131
+ statusCode: 409,
132
+ });
133
+ await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
134
+ queue.destroy();
135
+ });
117
136
  });
@@ -59,7 +59,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
59
59
  return result;
60
60
  } catch (error) {
61
61
  consecutiveFailures += 1;
62
- if (consecutiveFailures >= failureThreshold) open(error);
62
+ if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
63
+ open(error);
63
64
  throw error;
64
65
  }
65
66
  };
@@ -223,7 +223,7 @@ describe("sdkCutoverPersist", () => {
223
223
  target: "hf-abc",
224
224
  styles: { color: "red", opacity: "0.5" },
225
225
  });
226
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
226
+ expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>", "before");
227
227
  expect(deps.reloadPreview).toHaveBeenCalled();
228
228
  });
229
229
 
@@ -852,7 +852,11 @@ describe("sdkDeletePersist", () => {
852
852
  const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
853
853
  expect(result.status).toBe("committed");
854
854
  expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
855
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
855
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
856
+ "/comp.html",
857
+ "<html>after</html>",
858
+ "before",
859
+ );
856
860
  });
857
861
 
858
862
  it("records edit history with before/after diff", async () => {
@@ -937,7 +941,11 @@ describe("sdkTimingPersist", () => {
937
941
  duration: 5,
938
942
  trackIndex: 1,
939
943
  });
940
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
944
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
945
+ "/comp.html",
946
+ "<html>after</html>",
947
+ "<html>before</html>",
948
+ );
941
949
  });
942
950
 
943
951
  it("captures before-state before setTiming dispatch", async () => {
@@ -1158,7 +1166,11 @@ describe("sdkGsapTweenPersist", () => {
1158
1166
  "hf-box",
1159
1167
  expect.objectContaining({ method: "to" }),
1160
1168
  );
1161
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
1169
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
1170
+ "/comp.html",
1171
+ "<html>after</html>",
1172
+ "<html>before</html>",
1173
+ );
1162
1174
  });
1163
1175
 
1164
1176
  it("returns false for kind=add when element not found", async () => {
@@ -1262,7 +1274,11 @@ describe("sdkGsapKeyframePersist", () => {
1262
1274
  position: 50,
1263
1275
  value: { opacity: 0.5 },
1264
1276
  });
1265
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
1277
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
1278
+ "/comp.html",
1279
+ "<html>after</html>",
1280
+ "<html>before</html>",
1281
+ );
1266
1282
  expect(deps.reloadPreview).toHaveBeenCalled();
1267
1283
  });
1268
1284
 
@@ -37,7 +37,7 @@ export interface CutoverDeps {
37
37
  * Must be bound to one project. Its identity plus path scopes the shared
38
38
  * mutation queue used by every whole-file writer in that project.
39
39
  */
40
- writeProjectFile: (path: string, content: string) => Promise<void>;
40
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
41
41
  reloadPreview: () => void;
42
42
  domEditSaveTimestampRef: MutableRefObject<number>;
43
43
  refresh?: (after: string) => void;
@@ -148,13 +148,14 @@ function isCutoverResult(value: CandidateEdit | CutoverResult): value is Cutover
148
148
  async function rollbackWrite(
149
149
  targetPath: string,
150
150
  originalContent: string,
151
+ expectedCurrentContent: string,
151
152
  deps: CutoverDeps,
152
153
  cause: Error,
153
154
  ): Promise<Error> {
154
155
  try {
155
156
  deps.domEditSaveTimestampRef.current = Date.now();
156
157
  markSelfWrite(targetPath, originalContent);
157
- await deps.writeProjectFile(targetPath, originalContent);
158
+ await deps.writeProjectFile(targetPath, originalContent, expectedCurrentContent);
158
159
  return cause;
159
160
  } catch (rollbackError) {
160
161
  return new AggregateError(
@@ -174,7 +175,7 @@ async function writeAndRecord(
174
175
  deps.domEditSaveTimestampRef.current = Date.now();
175
176
  markSelfWrite(targetPath, after);
176
177
  try {
177
- await deps.writeProjectFile(targetPath, after);
178
+ await deps.writeProjectFile(targetPath, after, originalContent);
178
179
  } catch (error) {
179
180
  return asCutoverError(error);
180
181
  }
@@ -188,7 +189,7 @@ async function writeAndRecord(
188
189
  });
189
190
  return null;
190
191
  } catch (error) {
191
- return rollbackWrite(targetPath, originalContent, deps, asCutoverError(error));
192
+ return rollbackWrite(targetPath, originalContent, after, deps, asCutoverError(error));
192
193
  }
193
194
  }
194
195
 
@@ -14,7 +14,7 @@ export interface RecordEditInput {
14
14
  export interface DomEditCommitBaseParams {
15
15
  activeCompPath: string | null;
16
16
  showToast: (message: string, tone?: "error" | "info") => void;
17
- writeProjectFile: (path: string, content: string) => Promise<void>;
17
+ writeProjectFile: ProjectFileWriter;
18
18
  domEditSaveTimestampRef: MutableRefObject<number>;
19
19
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
20
20
  projectIdRef: MutableRefObject<string | null>;
@@ -22,6 +22,8 @@ export interface DomEditCommitBaseParams {
22
22
  clearDomSelection: () => void;
23
23
  }
24
24
 
25
+ type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
26
+
25
27
  interface SaveProjectFilesWithHistoryInput {
26
28
  projectId: string;
27
29
  label: string;
@@ -30,7 +32,7 @@ interface SaveProjectFilesWithHistoryInput {
30
32
  coalesceMs?: number;
31
33
  files: Record<string, string>;
32
34
  readFile: (path: string) => Promise<string>;
33
- writeFile: (path: string, content: string) => Promise<void>;
35
+ writeFile: ProjectFileWriter;
34
36
  recordEdit: (entry: RecordEditInput) => Promise<void>;
35
37
  }
36
38
 
@@ -71,7 +73,7 @@ export async function saveProjectFilesWithHistory({
71
73
  const writtenPaths: string[] = [];
72
74
  try {
73
75
  for (const path of changedPaths) {
74
- await writeFile(path, snapshots[path].after);
76
+ await writeFile(path, snapshots[path].after, snapshots[path].before);
75
77
  writtenPaths.push(path);
76
78
  }
77
79
 
@@ -79,7 +81,7 @@ export async function saveProjectFilesWithHistory({
79
81
  } catch (error) {
80
82
  try {
81
83
  for (const path of writtenPaths.reverse()) {
82
- await writeFile(path, snapshots[path].before);
84
+ await writeFile(path, snapshots[path].before, snapshots[path].after);
83
85
  }
84
86
  } catch (rollbackError) {
85
87
  throw new AggregateError(
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { studioExpectedFileVersion, studioFileContentVersion } from "./studioFileVersion";
3
+
4
+ describe("studioFileContentVersion", () => {
5
+ it("matches the strong SHA-256 ETag format used by studio-server", async () => {
6
+ await expect(studioFileContentVersion("abc")).resolves.toBe(
7
+ '"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"',
8
+ );
9
+ });
10
+
11
+ it("keeps an explicit content precondition authoritative over cached state", async () => {
12
+ const versions = new Map<string, string | null>([
13
+ ["stale.html", await studioFileContentVersion("stale")],
14
+ ["newer.html", await studioFileContentVersion("newer")],
15
+ ["missing.html", null],
16
+ ]);
17
+ const expectedVersion = await studioFileContentVersion("expected");
18
+
19
+ expect(await studioExpectedFileVersion(versions, "stale.html", "expected")).toBe(
20
+ expectedVersion,
21
+ );
22
+ expect(await studioExpectedFileVersion(versions, "newer.html", "expected")).toBe(
23
+ expectedVersion,
24
+ );
25
+ expect(await studioExpectedFileVersion(versions, "missing.html", "expected")).toBe(
26
+ expectedVersion,
27
+ );
28
+ });
29
+
30
+ it("keeps known-missing and untracked files distinct without explicit content", async () => {
31
+ const versions = new Map<string, string | null>([["missing.html", null]]);
32
+
33
+ expect(await studioExpectedFileVersion(versions, "missing.html")).toBeNull();
34
+ expect(await studioExpectedFileVersion(versions, "untracked.html")).toBeUndefined();
35
+ });
36
+ });
@@ -0,0 +1,23 @@
1
+ /** Browser-safe SHA-256 version matching studio-server's strong ETag format. */
2
+ export async function studioFileContentVersion(content: string): Promise<string> {
3
+ const bytes = new TextEncoder().encode(content);
4
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
5
+ const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
6
+ "",
7
+ );
8
+ return `"sha256:${hex}"`;
9
+ }
10
+
11
+ /** Prefer an explicit content precondition, then the version observed during the read. */
12
+ export async function studioExpectedFileVersion(
13
+ versions: ReadonlyMap<string, string | null>,
14
+ path: string,
15
+ expectedContent?: string,
16
+ ): Promise<string | null | undefined> {
17
+ if (expectedContent !== undefined) return studioFileContentVersion(expectedContent);
18
+ return versions.get(path);
19
+ }
20
+
21
+ export function createStudioWriteToken(): string {
22
+ return globalThis.crypto.randomUUID();
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
+ StudioFileConflictError,
3
4
  StudioSaveHttpError,
4
5
  StudioSaveNetworkError,
5
6
  buildStudioSaveFailureProperties,
@@ -8,6 +9,23 @@ import {
8
9
  } from "./studioSaveDiagnostics";
9
10
 
10
11
  describe("studio save diagnostics", () => {
12
+ it("preserves conflict versions and both sides for explicit recovery UI", () => {
13
+ const error = new StudioFileConflictError({
14
+ filePath: "index.html",
15
+ currentVersion: '"sha256:new"',
16
+ currentContent: "external",
17
+ attemptedContent: "local",
18
+ });
19
+
20
+ expect(getStudioSaveStatusCode(error)).toBe(409);
21
+ expect(error).toMatchObject({
22
+ filePath: "index.html",
23
+ currentVersion: '"sha256:new"',
24
+ currentContent: "external",
25
+ attemptedContent: "local",
26
+ });
27
+ });
28
+
11
29
  it("builds save_failure properties with stable diagnostics", () => {
12
30
  const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
13
31
 
@@ -35,6 +35,27 @@ export class StudioSaveNetworkError extends Error {
35
35
  }
36
36
  }
37
37
 
38
+ export class StudioFileConflictError extends StudioSaveHttpError {
39
+ readonly filePath: string;
40
+ readonly currentVersion: string | null;
41
+ readonly currentContent: string | null;
42
+ readonly attemptedContent: string;
43
+
44
+ constructor(input: {
45
+ filePath: string;
46
+ currentVersion: string | null;
47
+ currentContent: string | null;
48
+ attemptedContent: string;
49
+ }) {
50
+ super(`Save conflict: ${input.filePath} changed outside this Studio session`, 409);
51
+ this.name = "StudioFileConflictError";
52
+ this.filePath = input.filePath;
53
+ this.currentVersion = input.currentVersion;
54
+ this.currentContent = input.currentContent;
55
+ this.attemptedContent = input.attemptedContent;
56
+ }
57
+ }
58
+
38
59
  function readNumericProperty(value: object, key: string): number | undefined {
39
60
  const record = value as Record<string, unknown>;
40
61
  const property = record[key];