@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -0,0 +1,88 @@
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 { PreviewGuides } from "./PreviewGuides";
7
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const compRect = vi.hoisted(() => ({
12
+ current: { left: 20, top: 20, width: 400, height: 225, scaleX: 1, scaleY: 1 },
13
+ }));
14
+ vi.mock("./useDomEditCompositionRect", () => ({
15
+ useDomEditCompositionRect: () => compRect.current,
16
+ }));
17
+
18
+ afterEach(() => {
19
+ compRect.current = { left: 20, top: 20, width: 400, height: 225, scaleX: 1, scaleY: 1 };
20
+ document.body.innerHTML = "";
21
+ window.localStorage.clear();
22
+ });
23
+
24
+ function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
25
+ usePreviewGuidesStore.setState({ rulerVisible, safeMarginsVisible });
26
+ const host = document.createElement("div");
27
+ document.body.append(host);
28
+ act(() => {
29
+ createRoot(host).render(<PreviewGuides iframeRef={{ current: null }} />);
30
+ });
31
+ return host;
32
+ }
33
+
34
+ describe("PreviewGuides", () => {
35
+ it("draws nothing when both toggles are off", () => {
36
+ const host = render(false, false);
37
+ expect(host.querySelector("[data-testid]")).toBeNull();
38
+ });
39
+
40
+ it("draws the ruler with ticks 0 to 100 every 10 on both edges", () => {
41
+ const host = render(true, false);
42
+ const top = host.querySelector('[data-testid="preview-ruler-top"]');
43
+ expect(Array.from(top?.children ?? []).map((t) => t.textContent)).toEqual(
44
+ Array.from({ length: 11 }, (_, i) => String(i * 10)),
45
+ );
46
+ expect(host.querySelector('[data-testid="preview-ruler-left"]')).not.toBeNull();
47
+ expect(host.querySelector('[data-testid="preview-safe-90"]')).toBeNull();
48
+ expect(host.querySelector('[data-testid="preview-safe-80"]')).toBeNull();
49
+ });
50
+
51
+ it("draws the 90% and 80% boxes with edge-midpoint ticks and no labels or caption band", () => {
52
+ const host = render(false, true);
53
+ const box = (pct: number) =>
54
+ host.querySelector<HTMLElement>(`[data-testid="preview-safe-${pct}"]`);
55
+ expect([box(90)?.style.left, box(90)?.style.bottom]).toEqual(["5%", "5%"]);
56
+ expect([box(80)?.style.top, box(80)?.style.right]).toEqual(["10%", "10%"]);
57
+ expect(box(90)?.children.length).toBe(4);
58
+ expect(box(80)?.children.length).toBe(4);
59
+ expect(host.textContent).toBe("");
60
+ expect(host.querySelector('[data-testid="preview-ruler-top"]')).toBeNull();
61
+ });
62
+
63
+ it("places the ruler in the gutter outside the frame", () => {
64
+ const host = render(true, false);
65
+ const top = host.querySelector<HTMLElement>('[data-testid="preview-ruler-top"]');
66
+ const left = host.querySelector<HTMLElement>('[data-testid="preview-ruler-left"]');
67
+ expect([top?.style.left, top?.style.top, top?.style.width]).toEqual(["20px", "4px", "400px"]);
68
+ expect([left?.style.left, left?.style.top, left?.style.height]).toEqual([
69
+ "4px",
70
+ "20px",
71
+ "225px",
72
+ ]);
73
+ });
74
+
75
+ it("draws nothing until the composition has a size", () => {
76
+ compRect.current = { left: 0, top: 0, width: 0, height: 0, scaleX: 1, scaleY: 1 };
77
+ const host = render(true, true);
78
+ expect(host.querySelector("[data-testid]")).toBeNull();
79
+ });
80
+
81
+ it("draws the same boxes on a portrait composition", () => {
82
+ compRect.current = { left: 20, top: 20, width: 225, height: 400, scaleX: 1, scaleY: 1 };
83
+ const host = render(false, true);
84
+ const box = host.querySelector<HTMLElement>('[data-testid="preview-safe-90"]');
85
+ expect([box?.style.left, box?.style.top]).toEqual(["5%", "5%"]);
86
+ expect(host.querySelector('[data-testid="preview-safe-80"]')).not.toBeNull();
87
+ });
88
+ });
@@ -0,0 +1,125 @@
1
+ import { memo, useRef, type RefObject } from "react";
2
+ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
3
+ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "./previewGuidesStore";
4
+ import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins";
5
+
6
+ interface PreviewGuidesProps {
7
+ iframeRef: RefObject<HTMLIFrameElement | null>;
8
+ }
9
+
10
+ const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
11
+ const INK = "rgba(255,255,255,0.7)";
12
+
13
+ /** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
14
+ export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
15
+ const rulerVisible = usePreviewGuidesStore((s) => s.rulerVisible);
16
+ const safeMarginsVisible = usePreviewGuidesStore((s) => s.safeMarginsVisible);
17
+ if (!rulerVisible && !safeMarginsVisible) return null;
18
+ return (
19
+ <ActiveGuides
20
+ iframeRef={iframeRef}
21
+ rulerVisible={rulerVisible}
22
+ safeMarginsVisible={safeMarginsVisible}
23
+ />
24
+ );
25
+ });
26
+
27
+ interface ActiveGuidesProps extends PreviewGuidesProps {
28
+ rulerVisible: boolean;
29
+ safeMarginsVisible: boolean;
30
+ }
31
+
32
+ /** Owns the composition-rect subscription, so it only runs while a guide is on. */
33
+ function ActiveGuides({ iframeRef, rulerVisible, safeMarginsVisible }: ActiveGuidesProps) {
34
+ const paneRef = useRef<HTMLDivElement>(null);
35
+ const rect = useDomEditCompositionRect({ iframeRef, overlayRef: paneRef });
36
+ const ready = rect.width > 0 && rect.height > 0;
37
+
38
+ return (
39
+ <div ref={paneRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20">
40
+ {ready && rulerVisible && (
41
+ <>
42
+ <div
43
+ data-testid="preview-ruler-top"
44
+ className="absolute bg-black/70"
45
+ style={{
46
+ left: rect.left,
47
+ top: rect.top - RULER_GUTTER_PX,
48
+ width: rect.width,
49
+ height: RULER_GUTTER_PX,
50
+ }}
51
+ >
52
+ {TICKS.map((pct) => (
53
+ <span
54
+ key={pct}
55
+ className="absolute bottom-0 border-l text-[9px] leading-none tabular-nums pl-0.5 h-2.5"
56
+ style={{ left: `${pct}%`, borderColor: INK, color: INK }}
57
+ >
58
+ {pct}
59
+ </span>
60
+ ))}
61
+ </div>
62
+ <div
63
+ data-testid="preview-ruler-left"
64
+ className="absolute bg-black/70"
65
+ style={{
66
+ left: rect.left - RULER_GUTTER_PX,
67
+ top: rect.top,
68
+ width: RULER_GUTTER_PX,
69
+ height: rect.height,
70
+ }}
71
+ >
72
+ {TICKS.map((pct) => (
73
+ <span
74
+ key={pct}
75
+ className="absolute right-0 border-t text-[9px] leading-none tabular-nums pt-0.5 w-2.5"
76
+ style={{ top: `${pct}%`, borderColor: INK, color: INK }}
77
+ >
78
+ {pct}
79
+ </span>
80
+ ))}
81
+ </div>
82
+ </>
83
+ )}
84
+ {ready && safeMarginsVisible && (
85
+ <div
86
+ className="absolute"
87
+ style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
88
+ >
89
+ {SAFE_BOX_PERCENTS.map((boxPercent) => (
90
+ <SafeBox key={boxPercent} boxPercent={boxPercent} />
91
+ ))}
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ const TICK_PX = 7;
99
+ const EDGE_MIDPOINTS = [
100
+ { edge: "top", style: { left: "50%", top: -(TICK_PX + 1) / 2, width: 1, height: TICK_PX } },
101
+ { edge: "bottom", style: { left: "50%", bottom: -(TICK_PX + 1) / 2, width: 1, height: TICK_PX } },
102
+ { edge: "left", style: { top: "50%", left: -(TICK_PX + 1) / 2, width: TICK_PX, height: 1 } },
103
+ { edge: "right", style: { top: "50%", right: -(TICK_PX + 1) / 2, width: TICK_PX, height: 1 } },
104
+ ] as const;
105
+
106
+ /** One thin white box inset from every edge, with a tick at the midpoint of each edge. */
107
+ function SafeBox({ boxPercent }: { boxPercent: number }) {
108
+ const inset = `${safeBoxInsetPercent(boxPercent)}%`;
109
+ return (
110
+ <div
111
+ data-testid={`preview-safe-${boxPercent}`}
112
+ className="absolute border border-white/90"
113
+ style={{
114
+ left: inset,
115
+ top: inset,
116
+ right: inset,
117
+ bottom: inset,
118
+ }}
119
+ >
120
+ {EDGE_MIDPOINTS.map(({ edge, style }) => (
121
+ <span key={edge} className="absolute bg-white/90" style={style} />
122
+ ))}
123
+ </div>
124
+ );
125
+ }
@@ -83,7 +83,7 @@ export function PromotableControl({
83
83
  <span
84
84
  // Sits above the row (not on top of top-0) so it clears a value that
85
85
  // renders flush to the row's right edge, e.g. flat Font/Color rows.
86
- className="pointer-events-none absolute -top-2 right-1.5 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
86
+ className="pointer-events-none absolute -top-2 right-1.5 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded-sm bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
87
87
  title={`Bound to variable "${promote.boundId}"`}
88
88
  >
89
89
  ◆ {promote.boundId}
@@ -97,7 +97,7 @@ export function PromotableControl({
97
97
  e.stopPropagation();
98
98
  promote.promote();
99
99
  }}
100
- className="absolute -top-2 right-1.5 z-10 inline-flex items-center gap-1 rounded bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
100
+ className="absolute -top-2 right-1.5 z-10 inline-flex items-center gap-1 rounded-sm bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
101
101
  >
102
102
  ◇ var
103
103
  </button>
@@ -18,7 +18,7 @@ function FlatEmptyState() {
18
18
  </svg>
19
19
  </span>
20
20
  <div className="text-[13px] font-semibold text-panel-text-0">Nothing selected</div>
21
- <div className="max-w-[250px] text-[11px] leading-[1.5] text-panel-text-3">
21
+ <div className="max-w-[250px] text-[11px] leading-normal text-panel-text-3">
22
22
  Click any element on the canvas to edit it, or drag to select several.
23
23
  </div>
24
24
  <div className="mt-2 flex w-full flex-col gap-1.5">
@@ -27,7 +27,7 @@ function FlatEmptyState() {
27
27
  <span className="text-panel-danger">●</span>
28
28
  Record a gesture
29
29
  </span>
30
- <span className="rounded border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
30
+ <span className="rounded-sm border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
31
31
  R
32
32
  </span>
33
33
  </span>
@@ -36,7 +36,7 @@ function FlatEmptyState() {
36
36
  <span className="text-panel-accent">✦</span>
37
37
  Describe a change to the agent
38
38
  </span>
39
- <span className="rounded border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
39
+ <span className="rounded-sm border border-panel-border-input px-[5px] py-px font-mono text-[9px] text-panel-text-5">
40
40
  ⌘K
41
41
  </span>
42
42
  </span>
@@ -74,7 +74,7 @@ function FlatMultiSelectState({
74
74
  return (
75
75
  <div className="flex flex-col gap-3 px-4 py-3">
76
76
  <div className="flex items-center gap-3 rounded-xl border border-panel-border bg-panel-surface p-3">
77
- <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-panel-accent/10 text-panel-accent">
77
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-panel-accent/10 text-panel-accent">
78
78
  <Layers size={16} />
79
79
  </span>
80
80
  <div className="min-w-0 flex-1">
@@ -90,7 +90,7 @@ function FlatMultiSelectState({
90
90
  data-flat-multiselect-clear="true"
91
91
  aria-label="Clear selection"
92
92
  onClick={onClearSelection}
93
- className="flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center text-panel-text-3"
93
+ className="flex h-[26px] w-[26px] shrink-0 items-center justify-center text-panel-text-3"
94
94
  >
95
95
  <svg
96
96
  width="13"
@@ -113,14 +113,14 @@ function FlatMultiSelectState({
113
113
  className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-2.5 py-[7px]"
114
114
  >
115
115
  <span
116
- className={`flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded text-[9px] font-bold ${className}`}
116
+ className={`flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-sm text-[9px] font-bold ${className}`}
117
117
  >
118
118
  {glyph}
119
119
  </span>
120
120
  <span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
121
121
  {element.label}
122
122
  </span>
123
- <span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
123
+ <span className="shrink-0 font-mono text-[9px] text-panel-text-4">
124
124
  {element.id ? `#${element.id}` : element.selector}
125
125
  </span>
126
126
  </span>
@@ -41,13 +41,13 @@ export function PropertyPanelFlatHeader({
41
41
  <Icon
42
42
  size={15}
43
43
  data-flat-header-icon="true"
44
- className={`flex-shrink-0 ${ICON_COLOR_BY_KIND[elementKind]}`}
44
+ className={`shrink-0 ${ICON_COLOR_BY_KIND[elementKind]}`}
45
45
  />
46
46
  <div className="flex min-w-0 flex-1 items-baseline gap-2">
47
47
  <span className="truncate text-[13px] font-semibold text-panel-text-0">{name}</span>
48
48
  <span className="truncate font-mono text-[10px] text-panel-text-4">{meta}</span>
49
49
  </div>
50
- <div className="flex flex-shrink-0 items-center gap-2.5 text-panel-text-3">
50
+ <div className="flex shrink-0 items-center gap-2.5 text-panel-text-3">
51
51
  {showUngroup && (
52
52
  <button
53
53
  type="button"
@@ -8,6 +8,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
9
9
  import type { DomEditSelection } from "./domEditing";
10
10
  import { SnapToolbar } from "./SnapToolbar";
11
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
@@ -53,6 +54,7 @@ function AppHotkeyHarness() {
53
54
  handleCopy: vi.fn(() => false),
54
55
  handlePaste: vi.fn(async () => undefined),
55
56
  handleCut: vi.fn(async () => false),
57
+ handleDuplicate: vi.fn(async () => false),
56
58
  onResetKeyframes: vi.fn(() => false),
57
59
  onDeleteSelectedKeyframes: vi.fn(),
58
60
  });
@@ -119,3 +121,22 @@ describe("SnapToolbar keyboard shortcuts", () => {
119
121
  act(() => root.unmount());
120
122
  });
121
123
  });
124
+
125
+ describe("SnapToolbar ruler and safe-margin toggles", () => {
126
+ it.each([
127
+ ["Toggle ruler", "rulerVisible"],
128
+ ["Toggle safe margins", "safeMarginsVisible"],
129
+ ])("%s flips %s and remembers it", (label, key) => {
130
+ usePreviewGuidesStore.setState({ rulerVisible: false, safeMarginsVisible: false });
131
+ const { root } = renderToolbar();
132
+ const button = () => document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`);
133
+ expect(button()?.getAttribute("aria-pressed")).toBe("false");
134
+
135
+ act(() => button()?.click());
136
+
137
+ expect(button()?.getAttribute("aria-pressed")).toBe("true");
138
+ const stored = window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}";
139
+ expect(JSON.parse(stored)[key]).toBe(true);
140
+ act(() => root.unmount());
141
+ });
142
+ });
@@ -1,7 +1,8 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
- import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
2
+ import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
4
  import { usePlayerStore } from "../../player/store/playerStore";
5
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
5
6
 
6
7
  const SNAP_DEFAULTS = {
7
8
  snapEnabled: true,
@@ -39,6 +40,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
39
40
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
40
41
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
41
42
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
43
+ const guides = usePreviewGuidesStore();
42
44
  const popoverRef = useRef<HTMLDivElement>(null);
43
45
  const gridButtonRef = useRef<HTMLButtonElement>(null);
44
46
 
@@ -116,6 +118,28 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
116
118
  <Path size={16} weight={motionPathArmed ? "fill" : "regular"} />
117
119
  </button>
118
120
  )}
121
+ {(
122
+ [
123
+ ["rulerVisible", "Ruler", Ruler],
124
+ ["safeMarginsVisible", "Safe margins", FrameCorners],
125
+ ] as const
126
+ ).map(([key, label, Icon]) => (
127
+ <button
128
+ key={key}
129
+ type="button"
130
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
131
+ guides[key]
132
+ ? "bg-studio-accent/20 text-studio-accent"
133
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
134
+ }`}
135
+ onClick={() => guides.toggle(key)}
136
+ title={`${label} ${guides[key] ? "on" : "off"}`}
137
+ aria-label={`Toggle ${label.toLowerCase()}`}
138
+ aria-pressed={guides[key]}
139
+ >
140
+ <Icon size={16} weight={guides[key] ? "fill" : "regular"} />
141
+ </button>
142
+ ))}
119
143
  <button
120
144
  type="button"
121
145
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
@@ -155,7 +179,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
155
179
  </button>
156
180
  <button
157
181
  type="button"
158
- className="absolute -right-0.5 -bottom-0.5 rounded p-0.5 text-white/50 hover:text-white/90 bg-black/50"
182
+ className="absolute -right-0.5 -bottom-0.5 rounded-sm p-0.5 text-white/50 hover:text-white/90 bg-black/50"
159
183
  onClick={() => setGridPopoverOpen((v) => !v)}
160
184
  title="Grid options"
161
185
  aria-label="Grid options"
@@ -185,7 +209,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
185
209
  updatePrefs({ gridSpacing: val });
186
210
  }
187
211
  }}
188
- className="w-16 rounded bg-neutral-900 border border-neutral-600 px-1.5 py-0.5 text-xs text-white text-right tabular-nums outline-none focus:border-studio-accent"
212
+ className="w-16 rounded-sm bg-neutral-900 border border-neutral-600 px-1.5 py-0.5 text-xs text-white text-right tabular-nums outline-hidden focus:border-studio-accent"
189
213
  />
190
214
  </label>
191
215
  <label className="flex items-center gap-2 text-xs text-white/80 cursor-pointer">
@@ -139,7 +139,7 @@ export function TimelineFxPopover({
139
139
  ref={rootRef}
140
140
  role="dialog"
141
141
  aria-label="Effects"
142
- className="z-[200] flex flex-col overflow-hidden rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
142
+ className="z-200 flex flex-col overflow-hidden rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
143
143
  style={clampedStyle(anchorRect)}
144
144
  onKeyDown={onKeyDown}
145
145
  onPointerDown={(event) => event.stopPropagation()}
@@ -143,7 +143,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
143
143
  ref={overlayRef}
144
144
  aria-hidden="true"
145
145
  data-topology-lens={visible ? state.phase : "hidden"}
146
- className="pointer-events-none absolute inset-0 z-[58] overflow-hidden"
146
+ className="pointer-events-none absolute inset-0 z-58 overflow-hidden"
147
147
  >
148
148
  {visible && (
149
149
  <>
@@ -171,7 +171,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
171
171
  key={index}
172
172
  data-topology-contour="true"
173
173
  data-topology-node={label}
174
- className="hf-topology-contour pointer-events-none absolute z-[1] rounded"
174
+ className="hf-topology-contour pointer-events-none absolute z-1 rounded-sm"
175
175
  style={{
176
176
  ...rectStyle(rect),
177
177
  animationDelay: `${Math.min(index, 5) * 28}ms`,
@@ -181,7 +181,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
181
181
  {!reducedMotion && (
182
182
  <div
183
183
  data-topology-scan="true"
184
- className="pointer-events-none absolute z-[2] overflow-hidden rounded"
184
+ className="pointer-events-none absolute z-2 overflow-hidden rounded-sm"
185
185
  style={rectStyle(geometry.field.rect)}
186
186
  >
187
187
  <div className="hf-topology-scan absolute inset-y-0 left-0 w-1/4" />
@@ -196,7 +196,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
196
196
  data-topology-terminal={
197
197
  state.phase === "localizing" ? (state.terminal ?? undefined) : undefined
198
198
  }
199
- className="hf-topology-target pointer-events-none absolute z-[3] rounded-md"
199
+ className="hf-topology-target pointer-events-none absolute z-3 rounded-md"
200
200
  style={rectStyle(geometry.target.rect)}
201
201
  />
202
202
  </>
@@ -183,7 +183,7 @@ export function Transform3DCube({
183
183
  };
184
184
 
185
185
  return (
186
- <div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
186
+ <div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-linear-to-b from-neutral-900 to-neutral-950">
187
187
  <svg
188
188
  ref={svgRef}
189
189
  viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
@@ -294,7 +294,7 @@ export function Transform3DCube({
294
294
  onClick={onRecenter}
295
295
  title="Reset 3D orientation"
296
296
  aria-label="Reset 3D orientation"
297
- className="absolute right-1.5 top-1.5 rounded p-0.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
297
+ className="absolute right-1.5 top-1.5 rounded-sm p-0.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
298
298
  >
299
299
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor">
300
300
  <circle cx="12" cy="12" r="9" strokeWidth="2" />
@@ -37,6 +37,7 @@ export function audioFxRevealTarget(
37
37
  const parsed = parseAutomationTarget(target);
38
38
  if (!parsed) return null;
39
39
  if (parsed.kind === "volume") return { kind: "volume" };
40
+ if (parsed.kind === "rate") return null;
40
41
  if (parsed.kind === "preset") {
41
42
  // A preset-level lane names the preset, not a node inside it: find its run
42
43
  // by the first node that belongs to it, which is how `runKey` is built.
@@ -1103,7 +1103,7 @@ describe("patch builders and prompt builder", () => {
1103
1103
  expect(prompt).toContain("Computed styles (browser-resolved):");
1104
1104
  expect(prompt).toContain("Target HTML:");
1105
1105
  expect(prompt).toContain("Guardrails:");
1106
- expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
1106
+ expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning");
1107
1107
  });
1108
1108
 
1109
1109
  it("uses an absolute source path in copied agent prompts when provided", () => {
@@ -78,9 +78,10 @@ describe("buildElementAgentPrompt", () => {
78
78
  "Text: Hello world",
79
79
  "",
80
80
  "Guardrails:",
81
- "- Make a targeted change to this element only.",
82
- "- Preserve the rest of the composition and its timing.",
83
- "- Do not modify other elements' data-* attributes or positioning.",
81
+ "- Make a targeted change to this element only, unless the request is a timeline edit.",
82
+ "- Preserve the rest of the composition and its timing, except what a timeline edit changes.",
83
+ "- Do not modify other elements' data-* attributes or positioning, except where the requested timeline edit requires it (split, retime, reorder, copy a group, swap media).",
84
+ "- For timeline edits (trim, split, speed, volume, copy, swap), follow the creator-editing-recipes reference of the hyperframes-core skill and use its exact attribute forms.",
84
85
  "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
85
86
  ].join("\n"),
86
87
  );
@@ -160,9 +161,10 @@ describe("buildPickerAgentPrompt", () => {
160
161
  // Both end on the identical guardrails block, byte for byte.
161
162
  const guardrails = [
162
163
  "Guardrails:",
163
- "- Make a targeted change to this element only.",
164
- "- Preserve the rest of the composition and its timing.",
165
- "- Do not modify other elements' data-* attributes or positioning.",
164
+ "- Make a targeted change to this element only, unless the request is a timeline edit.",
165
+ "- Preserve the rest of the composition and its timing, except what a timeline edit changes.",
166
+ "- Do not modify other elements' data-* attributes or positioning, except where the requested timeline edit requires it (split, retime, reorder, copy a group, swap media).",
167
+ "- For timeline edits (trim, split, speed, volume, copy, swap), follow the creator-editing-recipes reference of the hyperframes-core skill and use its exact attribute forms.",
166
168
  "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
167
169
  ].join("\n");
168
170
  expect(domPrompt.endsWith(guardrails)).toBe(true);
@@ -28,9 +28,10 @@ export interface AgentPromptElementInfo {
28
28
 
29
29
  const GUARDRAIL_LINES = [
30
30
  "Guardrails:",
31
- "- Make a targeted change to this element only.",
32
- "- Preserve the rest of the composition and its timing.",
33
- "- Do not modify other elements' data-* attributes or positioning.",
31
+ "- Make a targeted change to this element only, unless the request is a timeline edit.",
32
+ "- Preserve the rest of the composition and its timing, except what a timeline edit changes.",
33
+ "- Do not modify other elements' data-* attributes or positioning, except where the requested timeline edit requires it (split, retime, reorder, copy a group, swap media).",
34
+ "- For timeline edits (trim, split, speed, volume, copy, swap), follow the creator-editing-recipes reference of the hyperframes-core skill and use its exact attribute forms.",
34
35
  "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
35
36
  ];
36
37
 
@@ -96,6 +97,7 @@ export function buildElementAgentPrompt({
96
97
  selectionContext,
97
98
  userInstruction,
98
99
  sourceFilePath,
100
+ timeline,
99
101
  }: {
100
102
  selection: DomEditSelection;
101
103
  currentTime: number;
@@ -103,6 +105,8 @@ export function buildElementAgentPrompt({
103
105
  selectionContext?: string;
104
106
  userInstruction?: string;
105
107
  sourceFilePath?: string;
108
+ /** The `formatTimelineBlock` text; omitted or empty when the timeline has no clips. */
109
+ timeline?: string;
106
110
  }): string {
107
111
  const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
108
112
  const info: AgentPromptElementInfo = {
@@ -136,6 +140,8 @@ export function buildElementAgentPrompt({
136
140
 
137
141
  lines.push(...buildElementDetailLines(info));
138
142
 
143
+ if (timeline) lines.push("", timeline);
144
+
139
145
  if (tagSnippet) {
140
146
  lines.push("", "Target HTML:", tagSnippet);
141
147
  }
@@ -0,0 +1,26 @@
1
+ import { create } from "zustand";
2
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
3
+
4
+ /** Space the preview fit reserves above and left of the frame while the ruler is on. */
5
+ export const RULER_GUTTER_PX = 16;
6
+
7
+ type GuideKey = "rulerVisible" | "safeMarginsVisible";
8
+
9
+ interface PreviewGuidesState {
10
+ rulerVisible: boolean;
11
+ safeMarginsVisible: boolean;
12
+ toggle: (key: GuideKey) => void;
13
+ }
14
+
15
+ export const usePreviewGuidesStore = create<PreviewGuidesState>((set, get) => {
16
+ const stored = readStudioUiPreferences();
17
+ return {
18
+ rulerVisible: stored.rulerVisible ?? false,
19
+ safeMarginsVisible: stored.safeMarginsVisible ?? false,
20
+ toggle: (key) => {
21
+ const next = !get()[key];
22
+ writeStudioUiPreferences({ [key]: next });
23
+ set({ [key]: next });
24
+ },
25
+ };
26
+ });