@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
@@ -0,0 +1,73 @@
1
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
2
+
3
+ export function PropertyPanelFlatFooter({
4
+ onAskAgent,
5
+ recordingState,
6
+ recordingDuration,
7
+ onToggleRecording,
8
+ }: {
9
+ onAskAgent?: () => void;
10
+ recordingState?: "idle" | "recording" | "preview";
11
+ recordingDuration?: number;
12
+ onToggleRecording?: () => void;
13
+ }) {
14
+ const track = useTrackDesignInput();
15
+ const recording = recordingState === "recording";
16
+ const recordTitle = recording
17
+ ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s`
18
+ : "Record gesture (R)";
19
+
20
+ return (
21
+ // No border-t here: every possible element immediately above this footer
22
+ // in the new fixed-headers + scrollable-open-section layout (a collapsed
23
+ // FlatGroupHeader, or the open group's scrollable body wrapper) already
24
+ // draws its own border-b in normal document flow — nothing here is
25
+ // `position: sticky` anymore, so there's no rounding seam to seal (see
26
+ // p11-scrollable-open-section-report.md).
27
+ <div className="flex items-center justify-between bg-panel-bg px-4 py-[11px]">
28
+ <button
29
+ type="button"
30
+ data-flat-footer-ask="true"
31
+ onClick={() => {
32
+ track("button", "Ask agent");
33
+ onAskAgent?.();
34
+ }}
35
+ disabled={!onAskAgent}
36
+ className="flex items-center gap-[7px] text-[11px] font-medium text-panel-text-2 disabled:cursor-not-allowed"
37
+ >
38
+ <svg
39
+ width="13"
40
+ height="13"
41
+ viewBox="0 0 16 16"
42
+ fill="currentColor"
43
+ className="text-panel-accent"
44
+ >
45
+ <path d="M8 1l1.4 4.6L14 7l-4.6 1.4L8 13l-1.4-4.6L2 7l4.6-1.4z" />
46
+ </svg>
47
+ Ask agent about this element
48
+ </button>
49
+ {onToggleRecording && (
50
+ <button
51
+ type="button"
52
+ data-flat-footer-record="true"
53
+ aria-label={recordTitle}
54
+ title={recordTitle}
55
+ onMouseDown={(e) => e.preventDefault()}
56
+ onClick={() => {
57
+ track("button", "Gesture recording");
58
+ onToggleRecording();
59
+ }}
60
+ className={recording ? "text-panel-danger animate-pulse" : "text-panel-danger"}
61
+ >
62
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
63
+ {recording ? (
64
+ <rect x="2" y="2" width="10" height="10" rx="1.5" />
65
+ ) : (
66
+ <circle cx="7" cy="7" r="6" />
67
+ )}
68
+ </svg>
69
+ </button>
70
+ )}
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,79 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function renderHeader(overrides: Partial<Parameters<typeof PropertyPanelFlatHeader>[0]> = {}) {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ const root = createRoot(host);
18
+ const props = {
19
+ name: "Mono Label",
20
+ meta: ".mono-label · div",
21
+ elementKind: "text" as const,
22
+ hidden: false,
23
+ copied: false,
24
+ onCopy: vi.fn(),
25
+ onClear: vi.fn(),
26
+ showUngroup: false,
27
+ ...overrides,
28
+ };
29
+ act(() => {
30
+ root.render(<PropertyPanelFlatHeader {...props} />);
31
+ });
32
+ return { host, root, props };
33
+ }
34
+
35
+ describe("PropertyPanelFlatHeader", () => {
36
+ it("renders name, meta, and the mint text-type icon", () => {
37
+ const { host, root } = renderHeader();
38
+ expect(host.textContent).toContain("Mono Label");
39
+ expect(host.textContent).toContain(".mono-label · div");
40
+ const icon = host.querySelector('[data-flat-header-icon="true"]');
41
+ expect(icon?.className).toContain("text-panel-accent");
42
+ act(() => root.unmount());
43
+ });
44
+
45
+ it("colors the media icon cyan and the other icon amber", () => {
46
+ const { host: mediaHost, root: mediaRoot } = renderHeader({ elementKind: "media" });
47
+ expect(mediaHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain(
48
+ "text-panel-media",
49
+ );
50
+ act(() => mediaRoot.unmount());
51
+
52
+ const { host: otherHost, root: otherRoot } = renderHeader({ elementKind: "other" });
53
+ expect(otherHost.querySelector('[data-flat-header-icon="true"]')?.className).toContain(
54
+ "text-panel-container",
55
+ );
56
+ act(() => otherRoot.unmount());
57
+ });
58
+
59
+ it("fires onCopy and onClear from their action buttons", () => {
60
+ const { host, root, props } = renderHeader();
61
+ const copy = host.querySelector<HTMLButtonElement>(
62
+ '[aria-label="Copy element info to clipboard"]',
63
+ );
64
+ const clear = host.querySelector<HTMLButtonElement>('[aria-label="Clear selection"]');
65
+ act(() => copy?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
66
+ act(() => clear?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
67
+ expect(props.onCopy).toHaveBeenCalledTimes(1);
68
+ expect(props.onClear).toHaveBeenCalledTimes(1);
69
+ act(() => root.unmount());
70
+ });
71
+
72
+ it("only renders Ungroup when showUngroup is true", () => {
73
+ const { host: without } = renderHeader({ showUngroup: false });
74
+ expect(without.querySelector('[aria-label="Ungroup"]')).toBeNull();
75
+
76
+ const { host: withUngroup } = renderHeader({ showUngroup: true, onUngroup: vi.fn() });
77
+ expect(withUngroup.querySelector('[aria-label="Ungroup"]')).not.toBeNull();
78
+ });
79
+ });
@@ -0,0 +1,112 @@
1
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
2
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
+ import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons";
4
+
5
+ const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const;
6
+ const ICON_COLOR_BY_KIND = {
7
+ text: "text-panel-accent",
8
+ media: "text-panel-media",
9
+ other: "text-panel-container",
10
+ } as const;
11
+
12
+ export function PropertyPanelFlatHeader({
13
+ name,
14
+ meta,
15
+ elementKind,
16
+ hidden,
17
+ onToggleHidden,
18
+ copied,
19
+ onCopy,
20
+ onClear,
21
+ onUngroup,
22
+ showUngroup,
23
+ }: {
24
+ name: string;
25
+ meta: string;
26
+ elementKind: "text" | "media" | "other";
27
+ hidden: boolean;
28
+ onToggleHidden?: () => void;
29
+ copied: boolean;
30
+ onCopy: () => void;
31
+ onClear: () => void;
32
+ onUngroup?: () => void;
33
+ showUngroup: boolean;
34
+ }) {
35
+ const track = useTrackDesignInput();
36
+ const Icon = ICON_BY_KIND[elementKind];
37
+ const visibilityLabel = hidden ? "Show element" : "Hide element";
38
+
39
+ return (
40
+ <div className="flex items-center gap-2.5 border-b border-panel-hairline px-4 py-3">
41
+ <Icon
42
+ size={15}
43
+ data-flat-header-icon="true"
44
+ className={`flex-shrink-0 ${ICON_COLOR_BY_KIND[elementKind]}`}
45
+ />
46
+ <div className="flex min-w-0 flex-1 items-baseline gap-2">
47
+ <span className="truncate text-[13px] font-semibold text-panel-text-0">{name}</span>
48
+ <span className="truncate font-mono text-[10px] text-panel-text-4">{meta}</span>
49
+ </div>
50
+ <div className="flex flex-shrink-0 items-center gap-2.5 text-panel-text-3">
51
+ {showUngroup && (
52
+ <button
53
+ type="button"
54
+ aria-label="Ungroup"
55
+ title="Ungroup (⌘⇧G)"
56
+ onClick={() => {
57
+ track("button", "Ungroup");
58
+ onUngroup?.();
59
+ }}
60
+ >
61
+ <svg
62
+ width="13"
63
+ height="13"
64
+ viewBox="0 0 16 16"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeWidth="1.5"
68
+ >
69
+ <rect x="1.5" y="1.5" width="7" height="7" rx="1" />
70
+ <rect x="7.5" y="7.5" width="7" height="7" rx="1" />
71
+ </svg>
72
+ </button>
73
+ )}
74
+ {onToggleHidden && (
75
+ <button
76
+ type="button"
77
+ aria-label={visibilityLabel}
78
+ title={visibilityLabel}
79
+ onClick={() => {
80
+ track("toggle", "Element visibility");
81
+ onToggleHidden();
82
+ }}
83
+ >
84
+ {hidden ? <EyeSlash size={13} weight="bold" /> : <Eye size={13} weight="bold" />}
85
+ </button>
86
+ )}
87
+ <button
88
+ type="button"
89
+ aria-label="Copy element info to clipboard"
90
+ title={copied ? "Copied!" : "Copy element info for any AI agent"}
91
+ onClick={() => {
92
+ track("button", "Copy element info");
93
+ onCopy();
94
+ }}
95
+ className={copied ? "text-panel-accent" : undefined}
96
+ >
97
+ <ClipboardList size={13} />
98
+ </button>
99
+ <button
100
+ type="button"
101
+ aria-label="Clear selection"
102
+ onClick={() => {
103
+ track("button", "Clear selection");
104
+ onClear();
105
+ }}
106
+ >
107
+ <X size={13} />
108
+ </button>
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -6,6 +6,7 @@ import {
6
6
  parseZIndex,
7
7
  resolveCrossedNeighbor,
8
8
  resolveZOrderChange,
9
+ resolveZOrderReposition,
9
10
  type ZOrderAction,
10
11
  type ZOrderPatch,
11
12
  } from "./canvasContextMenuZOrder";
@@ -617,3 +618,59 @@ describe("isZOrderActionEnabled", () => {
617
618
  }
618
619
  });
619
620
  });
621
+
622
+ describe("resolveZOrderReposition (Layers-panel arbitrary drop)", () => {
623
+ it("multi-position jump with distinct z resolves to ONE between-z write", () => {
624
+ // Render order bottom→top today: target(1), a(3), b(5). Drop target between
625
+ // a and b → single write: z strictly between 3 and 5.
626
+ const { target, byId } = makeFamily("1", [
627
+ ["a", "3"],
628
+ ["b", "5"],
629
+ ]);
630
+ const patches = resolveZOrderReposition(target, [byId.a, target, byId.b]);
631
+ expect(patches).toEqual([{ element: target, zIndex: 4 }]);
632
+ });
633
+
634
+ it("jump to the very top writes one z above the previous top", () => {
635
+ const { target, byId } = makeFamily("1", [
636
+ ["a", "3"],
637
+ ["b", "5"],
638
+ ]);
639
+ const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
640
+ expect(patches).toEqual([{ element: target, zIndex: 6 }]);
641
+ });
642
+
643
+ it("no-op drop (unchanged order) returns null", () => {
644
+ const { target, byId } = makeFamily("1", [
645
+ ["a", "3"],
646
+ ["b", "5"],
647
+ ]);
648
+ expect(resolveZOrderReposition(target, [target, byId.a, byId.b])).toBeNull();
649
+ });
650
+
651
+ it("tied z values renumber the scoped set minimally (band-safe)", () => {
652
+ const { target, byId } = makeFamily("2", [
653
+ ["a", "2"],
654
+ ["b", "2"],
655
+ ]);
656
+ // All tied at 2; DOM order target,a,b → render bottom→top target,a,b.
657
+ // Move target to the top: scoped renumber within the band.
658
+ const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
659
+ expect(patches).not.toBeNull();
660
+ const z = new Map(patches!.map((p) => [(p.element as HTMLElement).id, p.zIndex]));
661
+ const zOf = (id: string) => z.get(id) ?? 2;
662
+ expect(zOf("a")).toBeLessThan(zOf("b"));
663
+ expect(zOf("b")).toBeLessThan(zOf("target"));
664
+ });
665
+
666
+ it("rejects elements that are not painting siblings of the target", () => {
667
+ const { target, byId } = makeFamily("1", [["a", "3"]]);
668
+ const stranger = makeEl("stranger", "2");
669
+ expect(resolveZOrderReposition(target, [stranger, target, byId.a])).toBeNull();
670
+ });
671
+
672
+ it("returns null for sets too small to reorder", () => {
673
+ const { target } = makeFamily("1", []);
674
+ expect(resolveZOrderReposition(target, [target])).toBeNull();
675
+ });
676
+ });
@@ -42,6 +42,7 @@
42
42
  */
43
43
 
44
44
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
45
+ import { readLayerRevealPriorZ } from "../../player/lib/timelineElementHelpers";
45
46
 
46
47
  export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
47
48
 
@@ -107,8 +108,11 @@ export function parseZIndex(value: string | null | undefined): number {
107
108
  return Number.isFinite(n) ? n : 0;
108
109
  }
109
110
 
110
- /** Read the effective z-index for an element (inline style preferred). */
111
+ /** Read the effective z-index for an element (inline style preferred).
112
+ * Reveal-lift transparent: an active Layers-panel lift reports the TRUE z. */
111
113
  export function readEffectiveZIndex(el: HTMLElement): number {
114
+ const prior = readLayerRevealPriorZ(el);
115
+ if (prior != null) return prior;
112
116
  const inline = el.style.zIndex;
113
117
  if (inline && inline !== "auto") return parseZIndex(inline);
114
118
  try {
@@ -439,6 +443,41 @@ export function resolveZOrderChange(
439
443
  return realizeOrder(order, desired, target, entries);
440
444
  }
441
445
 
446
+ /**
447
+ * Realize an ARBITRARY repositioning of `target` within a scoped sibling set —
448
+ * the Layers-panel drag, which can jump several siblings in one drop, unlike
449
+ * the menu's four fixed actions. `desiredOrderBottomToTop` is the scoped set
450
+ * (target included at its new slot) in the intended render order. Reuses the
451
+ * menu's minimal-write realization (realizeOrder): one between-z write when a
452
+ * strict gap exists, band-safe scoped renumber otherwise — replacing the old
453
+ * LayersPanel computeReorderZValues path that stamped EVERY sibling.
454
+ *
455
+ * Null when nothing changes, the set is too small, or an element in the
456
+ * desired order is not actually a painting sibling of `target`.
457
+ */
458
+ export function resolveZOrderReposition(
459
+ target: HTMLElement,
460
+ desiredOrderBottomToTop: readonly HTMLElement[],
461
+ ): ZOrderPatch[] | null {
462
+ const { entries } = getFamily(target);
463
+ if (entries.length < 2) return null;
464
+ const byElement = new Map(entries.map((entry) => [entry.element, entry]));
465
+ const desired: RenderEntry[] = [];
466
+ for (const el of desiredOrderBottomToTop) {
467
+ const entry = byElement.get(el);
468
+ if (!entry) return null;
469
+ desired.push(entry);
470
+ }
471
+ if (desired.length < 2 || !desired.some((entry) => entry.element === target)) return null;
472
+ const currentOrder = toRenderOrder(desired);
473
+ // A drop back into the same slot is a no-op. The menu actions guard this via
474
+ // their position checks before realizeOrder; an arbitrary reposition must
475
+ // compare the orders itself — realizeOrder would otherwise "normalize" an
476
+ // end-of-set target to a fresh z value it doesn't need.
477
+ if (currentOrder.every((entry, i) => entry.element === desired[i].element)) return null;
478
+ return realizeOrder(currentOrder, desired, target, entries);
479
+ }
480
+
442
481
  /**
443
482
  * The sibling a forward/backward step crosses: the visible overlapping
444
483
  * neighbor directly above (bring-forward) or below (send-backward) the target
@@ -4,6 +4,7 @@
4
4
  * No imports from other domEditing* modules — safe to import from anywhere.
5
5
  */
6
6
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
7
+ import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
7
8
  import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
8
9
 
9
10
  // ─── Type guard ───────────────────────────────────────────────────────────────
@@ -296,11 +297,9 @@ export function getSelectorIndex(
296
297
  ): number | undefined {
297
298
  if (!selector?.startsWith(".")) return undefined;
298
299
 
299
- const candidates = querySelectorAllSafely(doc, selector).filter(
300
- (candidate): candidate is HTMLElement =>
301
- isHtmlElement(candidate) &&
302
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
300
+ return getSourceScopedSelectorIndex(doc, el, selector, sourceFile, (candidate) =>
301
+ isHtmlElement(candidate)
302
+ ? getSourceFileForElement(candidate, activeCompositionPath).sourceFile
303
+ : undefined,
303
304
  );
304
- const index = candidates.indexOf(el);
305
- return index >= 0 ? index : undefined;
306
305
  }
@@ -246,45 +246,28 @@ export function findElementForSelection(
246
246
  selection: FindElementSelection,
247
247
  activeCompositionPath: string | null = null,
248
248
  ): HTMLElement | null {
249
+ const sourceMatches = (candidate: Element): candidate is HTMLElement =>
250
+ isHtmlElement(candidate) &&
251
+ (!selection.sourceFile ||
252
+ getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
253
+ selection.sourceFile);
254
+ const findAll = (selector: string): HTMLElement[] =>
255
+ querySelectorAllSafely(doc, selector).filter(sourceMatches);
256
+
249
257
  if (selection.hfId) {
250
- const byHfId = doc.querySelector(`[data-hf-id="${CSS.escape(selection.hfId)}"]`);
251
- if (isHtmlElement(byHfId)) return byHfId;
258
+ const byHfId = findAll(`[data-hf-id="${escapeCssString(selection.hfId)}"]`)[0];
259
+ if (byHfId) return byHfId;
252
260
  }
253
261
 
254
262
  if (selection.id) {
255
- const byId = doc.getElementById(selection.id);
256
- if (
257
- isHtmlElement(byId) &&
258
- (!selection.sourceFile ||
259
- getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile)
260
- ) {
261
- return byId;
262
- }
263
+ // Flattened sub-compositions can repeat authored ids. getElementById returns
264
+ // only the first document match, so filter every id match by source first.
265
+ const byId = findAll(`[id="${escapeCssString(selection.id)}"]`)[0];
266
+ if (byId) return byId;
263
267
  }
264
268
 
265
269
  if (!selection.selector) return null;
266
-
267
- // fallow-ignore-next-line code-duplication
268
- if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
269
- const matches = querySelectorAllSafely(doc, selection.selector).filter(
270
- (candidate): candidate is HTMLElement =>
271
- isHtmlElement(candidate) &&
272
- (!selection.sourceFile ||
273
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
274
- selection.sourceFile),
275
- );
276
- return matches[selection.selectorIndex] ?? null;
277
- }
278
-
279
- // fallow-ignore-next-line code-duplication
280
- const matches = querySelectorAllSafely(doc, selection.selector).filter(
281
- (candidate): candidate is HTMLElement =>
282
- isHtmlElement(candidate) &&
283
- (!selection.sourceFile ||
284
- getSourceFileForElement(candidate, activeCompositionPath).sourceFile ===
285
- selection.sourceFile),
286
- );
287
- return matches[0] ?? null;
270
+ return findAll(selection.selector)[selection.selectorIndex ?? 0] ?? null;
288
271
  }
289
272
 
290
273
  // fallow-ignore-next-line complexity
@@ -34,3 +34,30 @@ export interface GsapAnimationEditCallbacks {
34
34
  /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
35
35
  onUnroll?: (animationId: string) => void;
36
36
  }
37
+
38
+ // User-facing control label for each animation-meta field. The ease control is
39
+ // labelled "Speed" in the card UI, so ease/easeEach map there.
40
+ const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
41
+ duration: { control: "metric", name: "Length" },
42
+ position: { control: "metric", name: "Starts at" },
43
+ ease: { control: "select", name: "Speed" },
44
+ easeEach: { control: "select", name: "Speed" },
45
+ };
46
+
47
+ /**
48
+ * Emit design-input telemetry for an `onUpdateMeta` payload, attributing each
49
+ * changed field to the control the user actually touched. Iterates the real keys
50
+ * present rather than falling through to a single placeholder — so a meta field
51
+ * added later is attributed honestly by its own key instead of poisoning another
52
+ * control's usage count.
53
+ */
54
+ export function trackAnimationMetaUpdate(
55
+ track: (control: string, name: string) => void,
56
+ updates: Record<string, unknown>,
57
+ ): void {
58
+ for (const key of Object.keys(updates)) {
59
+ const mapped = ANIMATION_META_LABELS[key];
60
+ if (mapped) track(mapped.control, mapped.name);
61
+ else track("select", key);
62
+ }
63
+ }
@@ -0,0 +1,34 @@
1
+ import type { DomEditSelection } from "./domEditingTypes";
2
+
3
+ /**
4
+ * Build the "live preview" callback the 3D-transform sub-view fires while a
5
+ * value is being dragged: apply a gsap.set() to the matching node inside the
6
+ * preview iframe so the edit is reflected immediately, before it's committed.
7
+ *
8
+ * Extracted so the identical closure exists once — shared by the legacy
9
+ * PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
10
+ */
11
+ // Resolve by id when unique, otherwise by selector + selectorIndex — a bare
12
+ // querySelector(selector) always hits the FIRST match, so dragging on the
13
+ // second of two same-selector siblings would animate the wrong element.
14
+ function resolvePreviewNode(
15
+ doc: Document | null | undefined,
16
+ el: DomEditSelection,
17
+ ): Element | null {
18
+ if (!doc) return null;
19
+ if (el.id) return doc.querySelector(`#${el.id}`);
20
+ if (!el.selector) return null;
21
+ return doc.querySelectorAll(el.selector)[el.selectorIndex ?? 0] ?? null;
22
+ }
23
+
24
+ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
25
+ return (el: DomEditSelection, props: Record<string, number>) => {
26
+ const iframe = iframeRef.current;
27
+ const win = iframe?.contentWindow as
28
+ | { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
29
+ | null
30
+ | undefined;
31
+ const node = resolvePreviewNode(iframe?.contentDocument, el);
32
+ if (win?.gsap && node) win.gsap.set(node, props);
33
+ };
34
+ }
@@ -0,0 +1,80 @@
1
+ import type { DomEditLayerItem } from "./domEditingTypes";
2
+ import { getElementZIndex } from "../../player/lib/layerOrdering";
3
+
4
+ interface CollapsedState {
5
+ [key: string]: boolean;
6
+ }
7
+
8
+ // ── Pure helpers ──────────────────────────────────────────────────────
9
+
10
+ // fallow-ignore-next-line complexity
11
+ export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
12
+ if (layers.length <= 1) return layers;
13
+
14
+ const minDepth = layers[0].depth;
15
+ for (let i = 1; i < layers.length; i++) {
16
+ if (layers[i].depth < minDepth) return layers;
17
+ }
18
+
19
+ const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
20
+ [];
21
+
22
+ for (let i = 0; i < layers.length; i++) {
23
+ if (layers[i].depth === minDepth) {
24
+ const children: DomEditLayerItem[] = [];
25
+ let j = i + 1;
26
+ while (j < layers.length && layers[j].depth > minDepth) {
27
+ children.push(layers[j]);
28
+ j++;
29
+ }
30
+ chunks.push({ root: layers[i], children, domIndex: chunks.length });
31
+ }
32
+ }
33
+
34
+ if (chunks.length <= 1) {
35
+ if (chunks.length === 1 && chunks[0].children.length > 0) {
36
+ const sorted = sortLayersByZIndex(chunks[0].children);
37
+ return [chunks[0].root, ...sorted];
38
+ }
39
+ return layers;
40
+ }
41
+
42
+ chunks.sort((a, b) => {
43
+ const zA = getElementZIndex(a.root.element);
44
+ const zB = getElementZIndex(b.root.element);
45
+ if (zA !== zB) return zB - zA;
46
+ return b.domIndex - a.domIndex;
47
+ });
48
+
49
+ const result: DomEditLayerItem[] = [];
50
+ for (const chunk of chunks) {
51
+ result.push(chunk.root);
52
+ if (chunk.children.length > 0) {
53
+ result.push(...sortLayersByZIndex(chunk.children));
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export function getVisibleLayers(
60
+ layers: DomEditLayerItem[],
61
+ collapsed: CollapsedState,
62
+ ): DomEditLayerItem[] {
63
+ if (Object.keys(collapsed).length === 0) return layers;
64
+
65
+ const result: DomEditLayerItem[] = [];
66
+ let skipDepth = -1;
67
+
68
+ for (const layer of layers) {
69
+ if (skipDepth >= 0 && layer.depth > skipDepth) continue;
70
+ skipDepth = -1;
71
+
72
+ result.push(layer);
73
+
74
+ if (collapsed[layer.key] && layer.childCount > 0) {
75
+ skipDepth = layer.depth;
76
+ }
77
+ }
78
+
79
+ return result;
80
+ }
@@ -105,4 +105,14 @@ describe("manual editing availability", () => {
105
105
  expect(resolveStudioBooleanEnvFlag({ EMPTY: "" }, ["EMPTY"], true)).toBe(true);
106
106
  expect(resolveStudioBooleanEnvFlag({ UNKNOWN: "maybe" }, ["UNKNOWN"], false)).toBe(false);
107
107
  });
108
+
109
+ it("defaults the flat inspector flag to false and honors an explicit override", async () => {
110
+ const off = await loadAvailabilityWithEnv({});
111
+ expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
112
+
113
+ const on = await loadAvailabilityWithEnv({
114
+ VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "true",
115
+ });
116
+ expect(on.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true);
117
+ });
108
118
  });
@@ -97,4 +97,13 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
97
97
  true,
98
98
  );
99
99
 
100
+ // Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector):
101
+ // flat identity header/footer/groups behind a flag for incremental review.
102
+ // Default false; enable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=true.
103
+ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
104
+ env,
105
+ ["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"],
106
+ false,
107
+ );
108
+
100
109
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";