@hyperframes/studio 0.7.57 → 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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.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-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip } from "./playbackTypes";
13
+ import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
13
14
  import {
14
15
  resolveMediaElement,
15
16
  applyMediaMetadataFromElement,
@@ -228,6 +229,7 @@ export function createImplicitTimelineLayersFromDOM(
228
229
  selector,
229
230
  selectorIndex,
230
231
  sourceFile,
232
+ stackingContextId: resolveCssStackingContextId(child),
231
233
  start: 0,
232
234
  tag: child.tagName.toLowerCase(),
233
235
  timingSource: "implicit",
@@ -303,6 +305,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
303
305
  selector,
304
306
  selectorIndex,
305
307
  sourceFile,
308
+ stackingContextId: resolveCssStackingContextId(el),
306
309
  timingSource: "authored",
307
310
  zIndex: readTimelineElementZIndex(el),
308
311
  };
@@ -10,6 +10,31 @@
10
10
  import type { TimelineElement } from "../store/playerStore";
11
11
  import type { ClipManifestClip } from "./playbackTypes";
12
12
  import { isFinitePositive } from "./playbackAdapter";
13
+ import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Layer-reveal lift transparency
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Attributes carrying the pre-lift state of a Layers-panel selection reveal
21
+ * (useLayerRevealOverride): while a layer is selected it PAINTS on top via a
22
+ * temporary inline z-index, but the lift is a purely visual, ephemeral studio
23
+ * affordance — every z reader must keep reporting the element's TRUE z
24
+ * (stored here) so menus, badges, the lane mirror, and the panel sort never
25
+ * reason on the lifted value. A z-reorder commit removes the attributes (the
26
+ * commit is the new truth).
27
+ */
28
+ export const LAYER_REVEAL_PRIOR_Z_ATTR = "data-hf-reveal-prior-z";
29
+ export const LAYER_REVEAL_PRIOR_POSITION_ATTR = "data-hf-reveal-prior-pos";
30
+
31
+ /** The lifted element's true (pre-lift) z, or null when no lift is active. */
32
+ export function readLayerRevealPriorZ(el: Element): number | null {
33
+ const raw = el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
34
+ if (raw == null) return null;
35
+ const n = Number.parseInt(raw, 10);
36
+ return Number.isFinite(n) ? n : null;
37
+ }
13
38
 
14
39
  // ---------------------------------------------------------------------------
15
40
  // Duration attribute helpers
@@ -22,8 +47,11 @@ import { isFinitePositive } from "./playbackAdapter";
22
47
  * back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
23
48
  * detached parse Document (no defaultView) as well as a live iframe. Mirrors
24
49
  * canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
50
+ * Reveal-lift transparent: an active lift reports the stored TRUE z.
25
51
  */
26
52
  export function readTimelineElementZIndex(el: Element): number {
53
+ const prior = readLayerRevealPriorZ(el);
54
+ if (prior != null) return prior;
27
55
  const html = el as HTMLElement;
28
56
  const parseZ = (value: string | null | undefined): number | null => {
29
57
  if (value == null || value === "" || value === "auto") return null;
@@ -237,17 +265,13 @@ export function getTimelineElementSelectorIndex(
237
265
  el: Element,
238
266
  selector: string | undefined,
239
267
  ): number | undefined {
240
- if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
241
- return undefined;
242
- }
243
-
244
- try {
245
- const matches = Array.from(doc.querySelectorAll(selector));
246
- const matchIndex = matches.indexOf(el);
247
- return matchIndex >= 0 ? matchIndex : undefined;
248
- } catch {
249
- return undefined;
250
- }
268
+ return getSourceScopedSelectorIndex(
269
+ doc,
270
+ el,
271
+ selector,
272
+ getTimelineElementSourceFile(el),
273
+ getTimelineElementSourceFile,
274
+ );
251
275
  }
252
276
 
253
277
  export function buildTimelineElementKey(params: {
@@ -44,7 +44,7 @@ export interface TimelineElement {
44
44
  zIndex?: number;
45
45
  /** True when the effective z-index was authored inline or through CSS, not auto. */
46
46
  hasExplicitZIndex?: boolean;
47
- /** Stacking context this element belongs to; root clips use the root composition id. */
47
+ /** Canonical CSS stacking context this element's z-index participates in. */
48
48
  stackingContextId?: string | null;
49
49
  /** Nearest parent composition context, matching RuntimeTimelineClip. */
50
50
  parentCompositionId?: string | null;
@@ -124,6 +124,13 @@ interface PlayerState {
124
124
  zoomMode: ZoomMode;
125
125
  /** Timeline zoom percent relative to the fit width when in manual mode */
126
126
  manualZoomPercent: number;
127
+ /**
128
+ * Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
129
+ * rollback). Flashless z commits (skipReload) never reload the iframe or
130
+ * bump refreshKey, so DOM-derived views (the Layers panel's z-sorted tree)
131
+ * subscribe to this to re-read the live DOM while playback is paused.
132
+ */
133
+ zEditVersion: number;
127
134
  /** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
128
135
  inPoint: number | null;
129
136
  /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
@@ -212,6 +219,7 @@ interface PlayerState {
212
219
  ) => void;
213
220
  setZoomMode: (mode: ZoomMode) => void;
214
221
  setManualZoomPercent: (percent: number) => void;
222
+ bumpZEditVersion: () => void;
215
223
  setInPoint: (time: number | null) => void;
216
224
  setOutPoint: (time: number | null) => void;
217
225
  reset: () => void;
@@ -274,6 +282,7 @@ export interface DomClipChild {
274
282
  /** The manifest sub-comp host clip id this descendant ultimately lives under. */
275
283
  hostId: string;
276
284
  label: string;
285
+ stackingContextId: string;
277
286
  }
278
287
 
279
288
  interface BeatHistoryEntry {
@@ -308,6 +317,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
308
317
  loopEnabled: false,
309
318
  zoomMode: "fit",
310
319
  manualZoomPercent: 100,
320
+ zEditVersion: 0,
311
321
  timelinePps: 100,
312
322
  timelineFitPps: 100,
313
323
  inPoint: null,
@@ -494,6 +504,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
494
504
  }),
495
505
  setManualZoomPercent: (percent) =>
496
506
  set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
507
+ bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
497
508
  setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
498
509
  setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
499
510
  setTimelineReady: (ready) => set({ timelineReady: ready }),
@@ -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
  },
@@ -12,6 +12,7 @@ const {
12
12
  trackStudioRenderStart,
13
13
  trackStudioRazorSplit,
14
14
  trackStudioExpandedClipEdit,
15
+ trackStudioFeedback,
15
16
  } = await import("./events");
16
17
 
17
18
  describe("studio telemetry events", () => {
@@ -69,4 +70,13 @@ describe("studio telemetry events", () => {
69
70
  trackStudioExpandedClipEdit({ action: "resize" });
70
71
  expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
71
72
  });
73
+
74
+ it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => {
75
+ trackStudioFeedback({ rating });
76
+
77
+ expect(trackEvent).toHaveBeenCalledWith(
78
+ "survey sent",
79
+ expect.objectContaining({ $survey_response: rating, rating_scale: 10 }),
80
+ );
81
+ });
72
82
  });
@@ -67,6 +67,7 @@ export function trackStudioFeedback(props: { rating: number; comment?: string })
67
67
  trackEvent("survey sent", {
68
68
  $survey_id: "studio_experience",
69
69
  $survey_response: props.rating,
70
+ rating_scale: 10,
70
71
  ...(props.comment ? { $survey_response_2: props.comment } : {}),
71
72
  doctor_summary: getBrowserDoctorSummary(),
72
73
  source: "studio",
@@ -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
+ }
@@ -13,7 +13,7 @@ interface DomEditSaveQueueOptions {
13
13
  }
14
14
 
15
15
  export interface DomEditSaveQueue {
16
- enqueue: (save: () => Promise<void>) => Promise<void>;
16
+ enqueue: <T>(save: () => Promise<T>) => Promise<T>;
17
17
  waitForIdle: () => Promise<void>;
18
18
  reset: () => void;
19
19
  destroy: () => void;
@@ -52,10 +52,11 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
52
52
  });
53
53
  };
54
54
 
55
- const run = async (save: () => Promise<void>) => {
55
+ const run = async <T>(save: () => Promise<T>): Promise<T> => {
56
56
  try {
57
- await save();
57
+ const result = await save();
58
58
  if (!breakerOpen) consecutiveFailures = 0;
59
+ return result;
59
60
  } catch (error) {
60
61
  consecutiveFailures += 1;
61
62
  if (consecutiveFailures >= failureThreshold) open(error);
@@ -277,6 +277,88 @@ describe("edit history", () => {
277
277
  expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
278
278
  });
279
279
 
280
+ it("keeps timing-only files when a multi-file GSAP follow-up touches one path", () => {
281
+ const timing = buildEditHistoryEntry({
282
+ projectId: "project-1",
283
+ label: "Move timeline clips",
284
+ kind: "timeline",
285
+ coalesceKey: "timeline-group-move:a,b",
286
+ files: {
287
+ "index.html": { before: "index-before", after: "index-timing" },
288
+ "scene.html": { before: "scene-before", after: "scene-timing" },
289
+ },
290
+ now: 0,
291
+ id: "timing",
292
+ });
293
+ const gsap = buildEditHistoryEntry({
294
+ projectId: "project-1",
295
+ label: "Move timeline clips",
296
+ kind: "timeline",
297
+ coalesceKey: "timeline-group-move:a,b",
298
+ coalesceMs: 10_000,
299
+ files: {
300
+ "index.html": { before: "index-timing", after: "index-timing+gsap" },
301
+ },
302
+ now: 2000,
303
+ id: "gsap",
304
+ });
305
+
306
+ const state = pushEditHistoryEntry(
307
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
308
+ gsap,
309
+ );
310
+
311
+ expect(state.undo).toHaveLength(1);
312
+ expect(Object.keys(state.undo[0].files)).toEqual(["index.html", "scene.html"]);
313
+ expect(state.undo[0].files).toMatchObject({
314
+ "index.html": { before: "index-before", after: "index-timing+gsap" },
315
+ "scene.html": { before: "scene-before", after: "scene-timing" },
316
+ });
317
+ });
318
+
319
+ it("keeps a discontinuous GSAP follow-up separate and preserves foreign bytes on undo", () => {
320
+ const timing = buildEditHistoryEntry({
321
+ projectId: "project-1",
322
+ label: "Move timeline clip",
323
+ kind: "timeline",
324
+ coalesceKey: "timeline-move:clip",
325
+ files: { "index.html": { before: "A", after: "B" } },
326
+ now: 0,
327
+ id: "timing",
328
+ });
329
+ const gsapAfterForeignWrite = buildEditHistoryEntry({
330
+ projectId: "project-1",
331
+ label: "Move timeline clip",
332
+ kind: "timeline",
333
+ coalesceKey: "timeline-move:clip",
334
+ coalesceMs: 10_000,
335
+ files: { "index.html": { before: "F", after: "G" } },
336
+ now: 2000,
337
+ id: "gsap",
338
+ });
339
+
340
+ const state = pushEditHistoryEntry(
341
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
342
+ gsapAfterForeignWrite,
343
+ );
344
+
345
+ expect(state.undo.map((entry) => entry.id)).toEqual(["timing", "gsap"]);
346
+ const undoGsap = undoEditHistory(state, { "index.html": hashEditHistoryContent("G") }, 3000);
347
+ expect(undoGsap.ok).toBe(true);
348
+ expect(undoGsap.filesToWrite).toEqual({ "index.html": "F" });
349
+
350
+ const undoTiming = undoEditHistory(
351
+ undoGsap.state,
352
+ { "index.html": hashEditHistoryContent("F") },
353
+ 4000,
354
+ );
355
+ expect(undoTiming).toMatchObject({
356
+ ok: false,
357
+ reason: "content-mismatch",
358
+ filesToWrite: {},
359
+ });
360
+ });
361
+
280
362
  it("does not merge a slow follow-up without the coalesceMs override", () => {
281
363
  const timing = buildEditHistoryEntry({
282
364
  projectId: "project-1",
@@ -126,9 +126,21 @@ export function pushEditHistoryEntry(
126
126
  previous &&
127
127
  previous.coalesceKey &&
128
128
  previous.coalesceKey === entry.coalesceKey &&
129
- entry.createdAt - previous.createdAt <= coalesceMs
129
+ entry.createdAt - previous.createdAt <= coalesceMs &&
130
+ // A shared key/window is not enough: bytes from an external writer may
131
+ // have landed between the two Studio-owned edits. Only fold a path when
132
+ // the follow-up starts from the exact output the previous entry recorded.
133
+ // Otherwise keep both entries so undo restores the foreign bytes first and
134
+ // the older entry's content check safely blocks at that ownership boundary.
135
+ Object.entries(entry.files).every(([path, snapshot]) => {
136
+ const previousSnapshot = previous.files[path];
137
+ return !previousSnapshot || previousSnapshot.after === snapshot.before;
138
+ })
130
139
  ) {
131
- const files: Record<string, EditHistoryFileSnapshot> = {};
140
+ // A follow-up may touch only a subset of the files in the original edit
141
+ // (for example, only one file in a multi-file timing move has GSAP). Keep
142
+ // previous-only paths in the single coalesced undo entry.
143
+ const files: Record<string, EditHistoryFileSnapshot> = { ...previous.files };
132
144
  for (const [path, snapshot] of Object.entries(entry.files)) {
133
145
  const previousSnapshot = previous.files[path];
134
146
  files[path] = previousSnapshot