@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -1108,6 +1108,7 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1108
1108
  stubProjectFetch(ROLLBACK_SOURCE);
1109
1109
  usePlayerStore.getState().setDuration(4);
1110
1110
  vi.spyOn(console, "error").mockImplementation(() => {});
1111
+ const showToast = vi.fn();
1111
1112
  const hook = renderTimelineEditingHook({
1112
1113
  timelineElements: [clip],
1113
1114
  iframe,
@@ -1116,8 +1117,9 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1116
1117
  writeProjectFile,
1117
1118
  recordEdit: vi.fn(async () => {}),
1118
1119
  reloadPreview: vi.fn(),
1120
+ showToast,
1119
1121
  });
1120
- return { iframe, clip, hook, writeError };
1122
+ return { iframe, clip, hook, showToast, writeError };
1121
1123
  }
1122
1124
 
1123
1125
  /**
@@ -1137,6 +1139,7 @@ describe("useTimelineEditing duration rollback on failed persist", () => {
1137
1139
  await flushAsyncWork();
1138
1140
  });
1139
1141
  expect(rejection).toBe(ctx.writeError);
1142
+ expect(ctx.showToast).toHaveBeenCalledWith("write failed", "error");
1140
1143
  expect(usePlayerStore.getState().duration).toBe(4);
1141
1144
  expect(rootDurationAttr(ctx.iframe)).toBe("4");
1142
1145
  }
@@ -32,8 +32,9 @@ import {
32
32
  } from "./timelineTrackVisibility";
33
33
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
34
34
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
35
- import { sdkTimingPersist } from "../utils/sdkCutover";
35
+ import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
36
36
  import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
37
+ import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
37
38
 
38
39
  type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
39
40
  stackingReorder?: TimelineStackingReorderIntent | null;
@@ -53,6 +54,7 @@ export function useTimelineEditing({
53
54
  uploadProjectFiles,
54
55
  isRecordingRef,
55
56
  sdkSession,
57
+ publishSdkSession,
56
58
  forceReloadSdkSession,
57
59
  handleDomZIndexReorderCommitRef,
58
60
  }: UseTimelineEditingOptions) {
@@ -121,10 +123,10 @@ export function useTimelineEditing({
121
123
  recordEdit,
122
124
  reloadPreview,
123
125
  sdkSession,
126
+ publishSdkSession,
124
127
  showToast,
125
128
  writeProjectFile,
126
129
  });
127
-
128
130
  const handleTimelineElementMove = useCallback(
129
131
  // fallow-ignore-next-line complexity
130
132
  (element: TimelineElement, updates: TimelineMoveUpdates) => {
@@ -215,10 +217,11 @@ export function useTimelineEditing({
215
217
  // Capture on-disk bytes as the undo `before` so undoing a timing move
216
218
  // restores the file verbatim, not a normalized full-DOM re-emit.
217
219
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
220
+ publishSession: publishSdkSession,
218
221
  },
219
222
  { label: "Move timeline clip", coalesceKey },
220
- ).then((handled) => {
221
- if (!handled) return moveFallback();
223
+ ).then((result) => {
224
+ if (!cutoverCommittedOrThrow(result)) return moveFallback();
222
225
  });
223
226
  }
224
227
  return moveFallback();
@@ -226,6 +229,7 @@ export function useTimelineEditing({
226
229
  .catch((error) => {
227
230
  // Failed persist: revert the optimistic duration readout + live root.
228
231
  rollbackDuration();
232
+ showToast(getStudioSaveErrorMessage(error), "error");
229
233
  throw error;
230
234
  });
231
235
  };
@@ -236,12 +240,14 @@ export function useTimelineEditing({
236
240
  enqueueEdit,
237
241
  activeCompPath,
238
242
  sdkSession,
243
+ publishSdkSession,
239
244
  recordEdit,
240
245
  writeProjectFile,
241
246
  reloadPreview,
242
247
  domEditSaveTimestampRef,
243
248
  timelineElements,
244
249
  handleDomZIndexReorderCommitRef,
250
+ showToast,
245
251
  ],
246
252
  );
247
253
 
@@ -255,9 +261,6 @@ export function useTimelineEditing({
255
261
  ["data-start", formatTimelineAttributeNumber(updates.start)],
256
262
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
257
263
  ];
258
- // Patch the live playback-start/media-start attr too, or a resize that
259
- // trims the playback start leaves the preview showing the old in-point
260
- // until the next reload (the persisted patch handles it via pbs below).
261
264
  if (updates.playbackStart != null) {
262
265
  const liveAttr =
263
266
  element.playbackStartAttr === "playback-start"
@@ -319,15 +322,17 @@ export function useTimelineEditing({
319
322
  // Capture on-disk bytes as the undo `before` so undoing a timing
320
323
  // resize restores the file verbatim, not a normalized full-DOM re-emit.
321
324
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
325
+ publishSession: publishSdkSession,
322
326
  },
323
327
  { label: "Resize timeline clip", coalesceKey },
324
- ).then((handled) => {
325
- if (!handled) return resizeFallback();
328
+ ).then((result) => {
329
+ if (!cutoverCommittedOrThrow(result)) return resizeFallback();
326
330
  })
327
331
  : resizeFallback();
328
332
  return persistDone.catch((error) => {
329
333
  // Failed persist: revert the optimistic duration readout + live root.
330
334
  rollbackDuration();
335
+ showToast(getStudioSaveErrorMessage(error), "error");
331
336
  throw error;
332
337
  });
333
338
  },
@@ -336,10 +341,12 @@ export function useTimelineEditing({
336
341
  enqueueEdit,
337
342
  activeCompPath,
338
343
  sdkSession,
344
+ publishSdkSession,
339
345
  recordEdit,
340
346
  writeProjectFile,
341
347
  reloadPreview,
342
348
  domEditSaveTimestampRef,
349
+ showToast,
343
350
  ],
344
351
  );
345
352
 
@@ -360,7 +367,6 @@ export function useTimelineEditing({
360
367
  const handleToggleElementHidden = useTimelineElementVisibilityEditing({
361
368
  projectIdRef,
362
369
  activeCompPath,
363
- timelineElements,
364
370
  showToast,
365
371
  writeProjectFile,
366
372
  recordEdit,
@@ -2,6 +2,7 @@ import type { MutableRefObject, RefObject } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
+ import type { PublishSdkSession } from "../utils/sdkCutover";
5
6
 
6
7
  interface RecordEditInput {
7
8
  label: string;
@@ -40,6 +41,8 @@ export interface UseTimelineEditingOptions {
40
41
  isRecordingRef?: RefObject<boolean>;
41
42
  /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
42
43
  sdkSession?: Composition | null;
44
+ /** Publish a fully persisted candidate SDK session. */
45
+ publishSdkSession?: PublishSdkSession;
43
46
  /** Resync the SDK session after a server-authoritative timeline write. */
44
47
  forceReloadSdkSession?: () => void;
45
48
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
@@ -1,7 +1,11 @@
1
1
  import { useCallback, type MutableRefObject, type RefObject } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { TimelineElement } from "../player";
4
- import { sdkTimingBatchPersist } from "../utils/sdkCutover";
4
+ import {
5
+ cutoverCommittedOrThrow,
6
+ sdkTimingBatchPersist,
7
+ type PublishSdkSession,
8
+ } from "../utils/sdkCutover";
5
9
  import {
6
10
  buildTimelineMoveTimingPatch,
7
11
  buildTimelineResizeTimingPatch,
@@ -20,6 +24,7 @@ import {
20
24
  shiftGsapPositions,
21
25
  syncPreviewContentDuration,
22
26
  } from "./timelineTimingSync";
27
+ import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
23
28
 
24
29
  export interface TimelineGroupMoveChange {
25
30
  element: TimelineElement;
@@ -53,6 +58,7 @@ interface UseTimelineGroupEditingOptions {
53
58
  recordEdit: (input: RecordEditInput) => Promise<void>;
54
59
  reloadPreview: () => void;
55
60
  sdkSession?: Composition | null;
61
+ publishSdkSession?: PublishSdkSession;
56
62
  showToast: (message: string, tone?: "error" | "info") => void;
57
63
  writeProjectFile: (path: string, content: string) => Promise<void>;
58
64
  }
@@ -108,6 +114,7 @@ export function useTimelineGroupEditing({
108
114
  recordEdit,
109
115
  reloadPreview,
110
116
  sdkSession,
117
+ publishSdkSession,
111
118
  showToast,
112
119
  writeProjectFile,
113
120
  }: UseTimelineGroupEditingOptions) {
@@ -189,7 +196,7 @@ export function useTimelineGroupEditing({
189
196
  input.eligible &&
190
197
  input.sdkChanges.every((change) => change !== null);
191
198
  if (!canUseSdk) return false;
192
- return sdkTimingBatchPersist(
199
+ const result = await sdkTimingBatchPersist(
193
200
  input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
194
201
  sharedPath,
195
202
  sdkSession,
@@ -200,14 +207,17 @@ export function useTimelineGroupEditing({
200
207
  domEditSaveTimestampRef,
201
208
  compositionPath: activeCompPath,
202
209
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
210
+ publishSession: publishSdkSession,
203
211
  },
204
212
  { label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs },
205
213
  );
214
+ return cutoverCommittedOrThrow(result);
206
215
  },
207
216
  [
208
217
  activeCompPath,
209
218
  domEditSaveTimestampRef,
210
219
  projectIdRef,
220
+ publishSdkSession,
211
221
  recordEdit,
212
222
  reloadPreview,
213
223
  sdkSession,
@@ -313,6 +323,7 @@ export function useTimelineGroupEditing({
313
323
  // Failed persist: revert the optimistic duration readout + live root
314
324
  // alongside the gesture owner's store rollback.
315
325
  rollbackDuration();
326
+ showToast(getStudioSaveErrorMessage(error), "error");
316
327
  throw error;
317
328
  });
318
329
  },
@@ -324,6 +335,7 @@ export function useTimelineGroupEditing({
324
335
  recordEdit,
325
336
  reloadPreview,
326
337
  trySdkBatchPersist,
338
+ showToast,
327
339
  ],
328
340
  );
329
341
 
@@ -419,6 +431,7 @@ export function useTimelineGroupEditing({
419
431
  // Failed persist: revert the optimistic duration readout + live root
420
432
  // alongside the gesture owner's store rollback.
421
433
  rollbackDuration();
434
+ showToast(getStudioSaveErrorMessage(error), "error");
422
435
  throw error;
423
436
  });
424
437
  },
@@ -430,6 +443,7 @@ export function useTimelineGroupEditing({
430
443
  recordEdit,
431
444
  reloadPreview,
432
445
  trySdkBatchPersist,
446
+ showToast,
433
447
  ],
434
448
  );
435
449
 
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
- import { persistSdkSerialize } from "../utils/sdkCutover";
3
+ import { cutoverCommittedOrThrow, persistSdkCandidateMutation } from "../utils/sdkCutover";
4
4
  import type { UseSlideshowPersistParams } from "./useSlideshowPersist";
5
5
 
6
6
  /** Same single-writer dependency set the slideshow persist path uses. */
@@ -21,6 +21,7 @@ export function useVariablesPersist({
21
21
  recordEdit,
22
22
  reloadPreview,
23
23
  domEditSaveTimestampRef,
24
+ publishSdkSession,
24
25
  }: UseVariablesPersistParams): (
25
26
  label: string,
26
27
  mutate: (session: Composition) => void,
@@ -30,11 +31,8 @@ export function useVariablesPersist({
30
31
  if (!sdkSession) return false;
31
32
  const path = activeCompPath ?? "index.html";
32
33
  const originalContent = await readProjectFile(path);
33
- mutate(sdkSession);
34
- const after = sdkSession.serialize();
35
- if (after === originalContent) return false;
36
- await persistSdkSerialize(
37
- after,
34
+ const result = await persistSdkCandidateMutation(
35
+ sdkSession,
38
36
  path,
39
37
  originalContent,
40
38
  {
@@ -43,10 +41,13 @@ export function useVariablesPersist({
43
41
  reloadPreview,
44
42
  domEditSaveTimestampRef,
45
43
  compositionPath: activeCompPath,
44
+ readProjectFile,
45
+ publishSession: publishSdkSession,
46
46
  },
47
+ mutate,
47
48
  { label },
48
49
  );
49
- return true;
50
+ return cutoverCommittedOrThrow(result);
50
51
  },
51
52
  [
52
53
  sdkSession,
@@ -56,6 +57,7 @@ export function useVariablesPersist({
56
57
  recordEdit,
57
58
  reloadPreview,
58
59
  domEditSaveTimestampRef,
60
+ publishSdkSession,
59
61
  ],
60
62
  );
61
63
  }
@@ -21,6 +21,7 @@ import {
21
21
  ArrowClockwise,
22
22
  Gear,
23
23
  Scissors as PhScissors,
24
+ Link as PhLink,
24
25
  } from "@phosphor-icons/react";
25
26
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
26
27
 
@@ -69,3 +70,4 @@ export const Camera = makeIcon(PhCamera);
69
70
  export const RotateCw = makeIcon(ArrowClockwise);
70
71
  export const Settings = makeIcon(Gear);
71
72
  export const Scissors = makeIcon(PhScissors);
73
+ export const Link = makeIcon(PhLink);
@@ -447,3 +447,25 @@ body {
447
447
  animation: none;
448
448
  }
449
449
  }
450
+
451
+ /* Flat inspector group header/body entrance (FlatGroupHeader) */
452
+ @keyframes hf-flat-group-in {
453
+ from {
454
+ opacity: 0;
455
+ transform: translateY(-4px);
456
+ }
457
+ to {
458
+ opacity: 1;
459
+ transform: translateY(0);
460
+ }
461
+ }
462
+
463
+ .hf-flat-group-enter {
464
+ animation: hf-flat-group-in 120ms ease-out;
465
+ }
466
+
467
+ @media (prefers-reduced-motion: reduce) {
468
+ .hf-flat-group-enter {
469
+ animation: none;
470
+ }
471
+ }
@@ -12,11 +12,16 @@ const studioPreset = {
12
12
  },
13
13
  panel: {
14
14
  bg: "#0C0C0E",
15
+ // Open inspector-section body — slightly lighter than headers (bg)
16
+ // so the recessed scrollable region reads distinct.
17
+ "bg-inset": "#121214",
15
18
  input: "#161618",
16
19
  surface: "#18181B",
17
20
  hover: "#27272A",
18
21
  border: "#1E1E1E",
19
22
  "border-input": "#27272A",
23
+ hairline: "#1A1A1C",
24
+ "text-0": "#FAFAFA",
20
25
  "text-1": "#E4E4E7",
21
26
  "text-2": "#A1A1AA",
22
27
  "text-3": "#71717A",
@@ -24,6 +29,8 @@ const studioPreset = {
24
29
  "text-5": "#3F3F46",
25
30
  accent: "#3CE6AC",
26
31
  danger: "#EF4444",
32
+ media: "#00E3FF",
33
+ container: "#F5A623",
27
34
  },
28
35
  },
29
36
  },
@@ -0,0 +1,97 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const trackStudioEvent = vi.fn();
4
+ vi.mock("./studioTelemetry", () => ({
5
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
6
+ }));
7
+
8
+ import {
9
+ __resetDesignInputThrottle,
10
+ slugifyDesignInput,
11
+ trackDesignInput,
12
+ } from "./designInputTracking";
13
+
14
+ beforeEach(() => {
15
+ trackStudioEvent.mockReset();
16
+ __resetDesignInputThrottle();
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ describe("trackDesignInput", () => {
25
+ it("emits one design_input event with ui/section/control/name", () => {
26
+ trackDesignInput({ ui: "flat", section: "Style", control: "metric", name: "Opacity" });
27
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
28
+ expect(trackStudioEvent).toHaveBeenCalledWith("design_input", {
29
+ ui: "flat",
30
+ section: "style",
31
+ control: "metric",
32
+ name: "opacity",
33
+ });
34
+ });
35
+
36
+ it("slugifies compound names and sections", () => {
37
+ trackDesignInput({
38
+ ui: "classic",
39
+ section: "Color Grading",
40
+ control: "slider",
41
+ name: "Font Size",
42
+ });
43
+ expect(trackStudioEvent).toHaveBeenCalledWith("design_input", {
44
+ ui: "classic",
45
+ section: "color-grading",
46
+ control: "slider",
47
+ name: "font-size",
48
+ });
49
+ });
50
+
51
+ it("marks an unresolved name as 'unnamed' (R3 coverage signal)", () => {
52
+ trackDesignInput({ ui: "classic", section: "style", control: "button", name: "" });
53
+ expect(trackStudioEvent).toHaveBeenCalledWith(
54
+ "design_input",
55
+ expect.objectContaining({ name: "unnamed" }),
56
+ );
57
+ });
58
+
59
+ it("coalesces repeated fires of the same input within the window (R4)", () => {
60
+ const nowSpy = vi.spyOn(performance, "now");
61
+ // Same key, three quick fires -> 1 event.
62
+ nowSpy.mockReturnValue(1000);
63
+ trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
64
+ nowSpy.mockReturnValue(1100);
65
+ trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
66
+ nowSpy.mockReturnValue(1500);
67
+ trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
68
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
69
+
70
+ // A different input within the window is NOT collapsed.
71
+ nowSpy.mockReturnValue(1550);
72
+ trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "scale" });
73
+ expect(trackStudioEvent).toHaveBeenCalledTimes(2);
74
+
75
+ // Same input after the window fires again.
76
+ nowSpy.mockReturnValue(2200);
77
+ trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" });
78
+ expect(trackStudioEvent).toHaveBeenCalledTimes(3);
79
+ });
80
+
81
+ it("never throws even if the underlying tracker throws", () => {
82
+ trackStudioEvent.mockImplementation(() => {
83
+ throw new Error("ingest down");
84
+ });
85
+ expect(() =>
86
+ trackDesignInput({ ui: "flat", section: "style", control: "metric", name: "opacity" }),
87
+ ).not.toThrow();
88
+ });
89
+ });
90
+
91
+ describe("slugifyDesignInput", () => {
92
+ it("lowercases, collapses non-alphanumerics, and trims dashes", () => {
93
+ expect(slugifyDesignInput(" Border Radius (px) ")).toBe("border-radius-px");
94
+ expect(slugifyDesignInput("X")).toBe("x");
95
+ expect(slugifyDesignInput("---")).toBe("");
96
+ });
97
+ });
@@ -0,0 +1,80 @@
1
+ import { trackStudioEvent } from "./studioTelemetry";
2
+
3
+ // Per-input usage telemetry for the design (inspector) panel. Both inspector UIs
4
+ // (classic PropertyPanel, flat PropertyPanelFlat) funnel their inputs through this
5
+ // one helper so usage can be ranked by input to find removal candidates. Emits the
6
+ // batched `studio:design_input` event via trackStudioEvent (opt-out-aware, never-throw).
7
+ //
8
+ // Fire convention (kept consistent across both UIs):
9
+ // - Discrete controls (metric/text/select/segmented/toggle/color/button) fire only
10
+ // when a real committed value change happens (the commit site guards on
11
+ // next !== current).
12
+ // - Continuous controls (sliders, scrub) fire once per user interaction that
13
+ // produced commits, even if the net value ends unchanged — net-change-at-settle
14
+ // is unreliable for them because mid-drag commits have already advanced the value,
15
+ // so "did the user work this control" is the honest signal. The coalescing window
16
+ // below collapses the many mid-drag commits into that single event.
17
+
18
+ export type DesignInputUi = "flat" | "classic";
19
+
20
+ export interface DesignInputDescriptor {
21
+ ui: DesignInputUi;
22
+ /** Section slug the input lives under (e.g. "style", "color-grading"). */
23
+ section: string;
24
+ /** Control kind: "metric" | "slider" | "select" | "segmented" | "toggle" | "color" | "text" | "button" | … */
25
+ control: string;
26
+ /** Input identity — the field label or CSS/GSAP property. Slugified for stable ranking. */
27
+ name: string;
28
+ }
29
+
30
+ // Continuous controls (sliders, scrub, wheel-nudge, live-commit text) fire many
31
+ // commits per interaction. Collapse repeated fires of the same input within this
32
+ // window into one event so a single drag counts once (R4).
33
+ const COALESCE_WINDOW_MS = 600;
34
+
35
+ const lastFiredByKey = new Map<string, number>();
36
+
37
+ function now(): number {
38
+ // performance.now() is monotonic and available in the studio runtime and jsdom.
39
+ return typeof performance !== "undefined" && typeof performance.now === "function"
40
+ ? performance.now()
41
+ : 0;
42
+ }
43
+
44
+ export function slugifyDesignInput(value: string): string {
45
+ return value
46
+ .trim()
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, "-")
49
+ .replace(/(^-|-$)/g, "");
50
+ }
51
+
52
+ /** Test seam: clear the coalescing state between cases. */
53
+ export function __resetDesignInputThrottle(): void {
54
+ lastFiredByKey.clear();
55
+ }
56
+
57
+ export function trackDesignInput(descriptor: DesignInputDescriptor): void {
58
+ try {
59
+ const section = slugifyDesignInput(descriptor.section) || "unknown";
60
+ const name = slugifyDesignInput(descriptor.name);
61
+ // An input with no resolvable name is useless for the removal analysis (R3).
62
+ // Emit it anyway (so a coverage test can catch it) but under an explicit marker.
63
+ const control = descriptor.control || "unknown";
64
+ const key = `${descriptor.ui}:${section}:${control}:${name || "unnamed"}`;
65
+
66
+ const t = now();
67
+ const last = lastFiredByKey.get(key);
68
+ if (last !== undefined && t - last < COALESCE_WINDOW_MS) return;
69
+ lastFiredByKey.set(key, t);
70
+
71
+ trackStudioEvent("design_input", {
72
+ ui: descriptor.ui,
73
+ section,
74
+ control,
75
+ name: name || "unnamed",
76
+ });
77
+ } catch {
78
+ // Telemetry must never break the edit path.
79
+ }
80
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  // Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
4
- // persist chokepoint must return false so the caller takes the legacy server
4
+ // persist chokepoint must explicitly decline so the caller takes the legacy server
5
5
  // path — even when a valid SDK session exists (one always does, for
6
6
  // shadow/selection). This is the contract the prod flag-flip rests on; a future
7
7
  // refactor of the gate guards that silently re-enables cutover on flag-off
@@ -31,12 +31,12 @@ const makeDeps = () =>
31
31
  domEditSaveTimestampRef: { current: 0 },
32
32
  }) as never;
33
33
 
34
- describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist returns false", () => {
34
+ describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist declines", () => {
35
35
  it("sdkTimingPersist falls back without writing", async () => {
36
36
  const deps = makeDeps();
37
- expect(await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps)).toBe(
38
- false,
39
- );
37
+ expect(
38
+ await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps),
39
+ ).toMatchObject({ status: "declined", reason: "feature_disabled" });
40
40
  expect(
41
41
  (deps as unknown as { writeProjectFile: ReturnType<typeof vi.fn> }).writeProjectFile,
42
42
  ).not.toHaveBeenCalled();
@@ -50,12 +50,12 @@ describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist retu
50
50
  makeSession(),
51
51
  makeDeps(),
52
52
  ),
53
- ).toBe(false);
53
+ ).toMatchObject({ status: "declined", reason: "feature_disabled" });
54
54
  });
55
55
 
56
56
  it("sdkDeletePersist falls back", async () => {
57
57
  expect(
58
58
  await sdkDeletePersist("hf-a", "<html></html>", "/c.html", makeSession(), makeDeps()),
59
- ).toBe(false);
59
+ ).toMatchObject({ status: "declined", reason: "feature_disabled" });
60
60
  });
61
61
  });