@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,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCommentsFile, draftEntries, parseCommentsFile, passForFrames } from "./frameComments";
3
+
4
+ const frames = [
5
+ { index: 1, status: "built" as const, src: "compositions/frames/01-hook.html", title: "Hook" },
6
+ {
7
+ index: 2,
8
+ status: "built" as const,
9
+ src: "compositions/frames/02-thesis.html",
10
+ title: "Thesis",
11
+ },
12
+ { index: 3, status: "built" as const, src: "compositions/frames/03-proof.html", title: "Proof" },
13
+ ];
14
+
15
+ describe("passForFrames", () => {
16
+ it("maps the furthest status to the review pass", () => {
17
+ expect(passForFrames([{ status: "outline" }, { status: "outline" }])).toBe("storyboard");
18
+ expect(passForFrames([{ status: "outline" }, { status: "built" }])).toBe("sketch");
19
+ expect(passForFrames([{ status: "built" }, { status: "animated" }])).toBe("final");
20
+ });
21
+ });
22
+
23
+ describe("draftEntries", () => {
24
+ it("keeps only non-empty drafts, in board order, with src/title echoed", () => {
25
+ const entries = draftEntries(frames, { 3: "swap the chart", 1: " ", 2: "tighter kerning" });
26
+ expect(entries).toEqual([
27
+ { frame: 2, src: frames[1]?.src, title: "Thesis", text: "tighter kerning" },
28
+ { frame: 3, src: frames[2]?.src, title: "Proof", text: "swap the chart" },
29
+ ]);
30
+ });
31
+ });
32
+
33
+ describe("parseCommentsFile", () => {
34
+ it("reads a well-formed file", () => {
35
+ const parsed = parseCommentsFile(
36
+ JSON.stringify({
37
+ version: 1,
38
+ pass: "sketch",
39
+ submitted_at: "2026-07-10T00:00:00Z",
40
+ comments: [{ frame: 2, text: "hi" }],
41
+ }),
42
+ );
43
+ expect(parsed?.pass).toBe("sketch");
44
+ expect(parsed?.comments).toEqual([{ frame: 2, src: undefined, title: undefined, text: "hi" }]);
45
+ });
46
+
47
+ it("treats empty or malformed input as no file", () => {
48
+ expect(parseCommentsFile("")).toBeNull();
49
+ expect(parseCommentsFile("not json")).toBeNull();
50
+ expect(parseCommentsFile(JSON.stringify({ comments: "nope" }))).toBeNull();
51
+ });
52
+
53
+ it("drops malformed entries but keeps the valid ones", () => {
54
+ const parsed = parseCommentsFile(
55
+ JSON.stringify({ comments: [{ frame: 1, text: "ok" }, { frame: "x" }, null] }),
56
+ );
57
+ expect(parsed?.comments).toEqual([{ frame: 1, src: undefined, title: undefined, text: "ok" }]);
58
+ });
59
+ });
60
+
61
+ describe("buildCommentsFile", () => {
62
+ it("merges an unconsumed previous batch: new frames win, others stay", () => {
63
+ const previous = parseCommentsFile(
64
+ JSON.stringify({
65
+ version: 1,
66
+ pass: "sketch",
67
+ submitted_at: "2026-07-10T00:00:00Z",
68
+ comments: [
69
+ { frame: 1, text: "old note on hook" },
70
+ { frame: 3, text: "old note on proof" },
71
+ ],
72
+ }),
73
+ );
74
+ const file = buildCommentsFile(
75
+ frames,
76
+ { 3: "new note on proof" },
77
+ previous,
78
+ "2026-07-10T01:00:00Z",
79
+ );
80
+ expect(file.pass).toBe("sketch");
81
+ expect(file.submitted_at).toBe("2026-07-10T01:00:00Z");
82
+ expect(file.comments.map((c) => [c.frame, c.text])).toEqual([
83
+ [1, "old note on hook"],
84
+ [3, "new note on proof"],
85
+ ]);
86
+ });
87
+
88
+ it("writes a fresh batch when no previous file exists", () => {
89
+ const file = buildCommentsFile(frames, { 2: "tighter" }, null, "2026-07-10T01:00:00Z");
90
+ expect(file.version).toBe(1);
91
+ expect(file.comments).toEqual([
92
+ { frame: 2, src: frames[1]?.src, title: "Thesis", text: "tighter" },
93
+ ]);
94
+ });
95
+ });
@@ -0,0 +1,115 @@
1
+ import type { FrameStatus } from "@hyperframes/core/storyboard";
2
+
3
+ /**
4
+ * Frame comments — the storyboard review's structured feedback channel.
5
+ *
6
+ * The board's per-frame comment boxes write one batch file on submit; the
7
+ * consuming agent revises exactly the frames named, deletes the file, and
8
+ * re-presents. The file shape is a cross-repo contract — keep it in sync with
9
+ * skills/hyperframes-core/references/storyboard-format.md § Frame comments.
10
+ */
11
+ export const FRAME_COMMENTS_PATH = ".hyperframes/frame-comments.json";
12
+
13
+ export type FrameCommentsPass = "storyboard" | "sketch" | "final";
14
+
15
+ export interface FrameCommentEntry {
16
+ /** The frame's 1-based `index` in the manifest — the key. */
17
+ frame: number;
18
+ /** Copied from the frame at submit time so a post-submit reorder is detectable. */
19
+ src?: string;
20
+ title?: string;
21
+ text: string;
22
+ }
23
+
24
+ export interface FrameCommentsFile {
25
+ version: 1;
26
+ pass: FrameCommentsPass;
27
+ submitted_at: string;
28
+ comments: FrameCommentEntry[];
29
+ }
30
+
31
+ interface CommentableFrame {
32
+ index: number;
33
+ status: FrameStatus;
34
+ src?: string;
35
+ title?: string;
36
+ }
37
+
38
+ /** Which review the batch belongs to — the furthest status present on the board. */
39
+ export function passForFrames(frames: ReadonlyArray<{ status: FrameStatus }>): FrameCommentsPass {
40
+ if (frames.some((f) => f.status === "animated")) return "final";
41
+ if (frames.some((f) => f.status === "built")) return "sketch";
42
+ return "storyboard";
43
+ }
44
+
45
+ /** The non-empty drafts as file entries, in board order. */
46
+ export function draftEntries(
47
+ frames: readonly CommentableFrame[],
48
+ drafts: Readonly<Record<number, string>>,
49
+ ): FrameCommentEntry[] {
50
+ const entries: FrameCommentEntry[] = [];
51
+ for (const frame of frames) {
52
+ const text = drafts[frame.index]?.trim();
53
+ if (!text) continue;
54
+ entries.push({ frame: frame.index, src: frame.src, title: frame.title, text });
55
+ }
56
+ return entries;
57
+ }
58
+
59
+ function isRecord(value: unknown): value is Record<string, unknown> {
60
+ return typeof value === "object" && value !== null;
61
+ }
62
+
63
+ function parseCommentEntry(item: unknown): FrameCommentEntry | null {
64
+ if (!isRecord(item)) return null;
65
+ if (typeof item.frame !== "number" || typeof item.text !== "string") return null;
66
+ return {
67
+ frame: item.frame,
68
+ src: typeof item.src === "string" ? item.src : undefined,
69
+ title: typeof item.title === "string" ? item.title : undefined,
70
+ text: item.text,
71
+ };
72
+ }
73
+
74
+ function parsePass(value: unknown): FrameCommentsPass {
75
+ return value === "sketch" || value === "final" ? value : "storyboard";
76
+ }
77
+
78
+ /** Tolerant read of an existing comments file — anything malformed counts as "no file". */
79
+ export function parseCommentsFile(raw: string): FrameCommentsFile | null {
80
+ if (!raw.trim()) return null;
81
+ let data: unknown;
82
+ try {
83
+ data = JSON.parse(raw);
84
+ } catch {
85
+ return null;
86
+ }
87
+ if (!isRecord(data) || !Array.isArray(data.comments)) return null;
88
+ const comments = data.comments
89
+ .map(parseCommentEntry)
90
+ .filter((entry): entry is FrameCommentEntry => entry !== null);
91
+ return {
92
+ version: 1,
93
+ pass: parsePass(data.pass),
94
+ submitted_at: typeof data.submitted_at === "string" ? data.submitted_at : "",
95
+ comments,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * One file = the currently pending feedback. A resubmit before the agent
101
+ * consumed the previous batch keeps the pending entries for frames the new
102
+ * batch doesn't mention and overwrites the ones it does.
103
+ */
104
+ export function buildCommentsFile(
105
+ frames: readonly CommentableFrame[],
106
+ drafts: Readonly<Record<number, string>>,
107
+ previous: FrameCommentsFile | null,
108
+ submittedAt: string,
109
+ ): FrameCommentsFile {
110
+ const fresh = draftEntries(frames, drafts);
111
+ const freshIndexes = new Set(fresh.map((entry) => entry.frame));
112
+ const kept = previous?.comments.filter((entry) => !freshIndexes.has(entry.frame)) ?? [];
113
+ const comments = [...kept, ...fresh].sort((a, b) => a.frame - b.frame);
114
+ return { version: 1, pass: passForFrames(frames), submitted_at: submittedAt, comments };
115
+ }
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
3
+ import type { StoryboardFrameView } from "../../hooks/useStoryboard";
4
+ import {
5
+ FRAME_COMMENTS_PATH,
6
+ buildCommentsFile,
7
+ parseCommentsFile,
8
+ type FrameCommentEntry,
9
+ } from "./frameComments";
10
+
11
+ export type CommentsSubmitState = "idle" | "saving";
12
+
13
+ export interface FrameCommentsValue {
14
+ /** Draft text per frame index — "" / absent means no comment. */
15
+ drafts: Record<number, string>;
16
+ setDraft: (index: number, text: string) => void;
17
+ /** How many frames currently carry a non-empty draft. */
18
+ draftCount: number;
19
+ submitState: CommentsSubmitState;
20
+ /** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
21
+ submit: () => Promise<void>;
22
+ /**
23
+ * Comments already submitted but not yet consumed by the agent (the file
24
+ * still exists on disk). Refreshed on mount, after submit, and on window
25
+ * focus — the agent deletes the file once it has applied the feedback.
26
+ */
27
+ pending: FrameCommentEntry[] | null;
28
+ /** Re-read the comments file — callers hook this to board data refreshes. */
29
+ refreshPending: () => Promise<void>;
30
+ }
31
+
32
+ /** Per-frame comment drafts + the batch submit that writes the comments file. */
33
+ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsValue {
34
+ const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
35
+ const [drafts, setDrafts] = useState<Record<number, string>>({});
36
+ const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
37
+ const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
38
+
39
+ const refreshPending = useCallback(async () => {
40
+ try {
41
+ const parsed = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
42
+ setPending(parsed && parsed.comments.length > 0 ? parsed.comments : null);
43
+ } catch {
44
+ // Transient read failure — keep whatever is currently shown.
45
+ }
46
+ }, [readOptionalProjectFile]);
47
+
48
+ useEffect(() => {
49
+ void refreshPending();
50
+ const onFocus = () => void refreshPending();
51
+ window.addEventListener("focus", onFocus);
52
+ return () => window.removeEventListener("focus", onFocus);
53
+ }, [refreshPending]);
54
+
55
+ const setDraft = useCallback((index: number, text: string) => {
56
+ setDrafts((prev) => ({ ...prev, [index]: text }));
57
+ }, []);
58
+
59
+ const draftCount = useMemo(
60
+ () => Object.values(drafts).filter((text) => text.trim().length > 0).length,
61
+ [drafts],
62
+ );
63
+
64
+ const submit = useCallback(async () => {
65
+ if (draftCount === 0 || submitState === "saving") return;
66
+ setSubmitState("saving");
67
+ try {
68
+ const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
69
+ const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
70
+ await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
71
+ setDrafts({});
72
+ setPending(file.comments);
73
+ } catch {
74
+ // writeProjectFile surfaces save failures through the studio save banner;
75
+ // just re-arm the button so the user can retry.
76
+ } finally {
77
+ setSubmitState("idle");
78
+ }
79
+ }, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
80
+
81
+ return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
82
+ }
@@ -0,0 +1,81 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const trackDesignInput = vi.fn();
8
+ vi.mock("../utils/designInputTracking", () => ({
9
+ trackDesignInput: (...args: unknown[]) => trackDesignInput(...args),
10
+ }));
11
+
12
+ import { DesignPanelInputProvider, useTrackDesignInput } from "./DesignPanelInputContext";
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ beforeEach(() => trackDesignInput.mockReset());
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ function FireButton({ control, name }: { control: string; name: string }) {
22
+ const track = useTrackDesignInput();
23
+ return (
24
+ <button type="button" onClick={() => track(control, name)}>
25
+ fire
26
+ </button>
27
+ );
28
+ }
29
+
30
+ function renderAndClick(tree: React.ReactElement) {
31
+ const host = document.createElement("div");
32
+ document.body.appendChild(host);
33
+ const root = createRoot(host);
34
+ act(() => root.render(tree));
35
+ act(() => {
36
+ host.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
37
+ });
38
+ act(() => root.unmount());
39
+ }
40
+
41
+ describe("DesignPanelInputContext", () => {
42
+ it("binds the tracker to the enclosing ui + section", () => {
43
+ renderAndClick(
44
+ <DesignPanelInputProvider ui="flat" section="style">
45
+ <FireButton control="metric" name="Opacity" />
46
+ </DesignPanelInputProvider>,
47
+ );
48
+ expect(trackDesignInput).toHaveBeenCalledWith({
49
+ ui: "flat",
50
+ section: "style",
51
+ control: "metric",
52
+ name: "Opacity",
53
+ });
54
+ });
55
+
56
+ it("nested provider overrides section but inherits ui from parent", () => {
57
+ renderAndClick(
58
+ <DesignPanelInputProvider ui="flat" section="outer">
59
+ <DesignPanelInputProvider section="color-grading">
60
+ <FireButton control="slider" name="Exposure" />
61
+ </DesignPanelInputProvider>
62
+ </DesignPanelInputProvider>,
63
+ );
64
+ expect(trackDesignInput).toHaveBeenCalledWith({
65
+ ui: "flat",
66
+ section: "color-grading",
67
+ control: "slider",
68
+ name: "Exposure",
69
+ });
70
+ });
71
+
72
+ it("defaults to classic/unknown with no provider", () => {
73
+ renderAndClick(<FireButton control="button" name="Reset" />);
74
+ expect(trackDesignInput).toHaveBeenCalledWith({
75
+ ui: "classic",
76
+ section: "unknown",
77
+ control: "button",
78
+ name: "Reset",
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,48 @@
1
+ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
2
+ import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking";
3
+
4
+ // Carries which inspector UI and which section the currently-rendered design-panel
5
+ // inputs belong to, so commit sites only pass { control, name } and never thread
6
+ // section/ui through every call. Providers nest: PropertyPanel sets `ui` once at the
7
+ // top; each Section sets `section` and inherits `ui` from the parent.
8
+
9
+ interface DesignPanelInputContextValue {
10
+ ui: DesignInputUi;
11
+ section: string;
12
+ }
13
+
14
+ const DesignPanelInputContext = createContext<DesignPanelInputContextValue>({
15
+ ui: "classic",
16
+ section: "unknown",
17
+ });
18
+
19
+ export function DesignPanelInputProvider({
20
+ ui,
21
+ section,
22
+ children,
23
+ }: {
24
+ ui?: DesignInputUi;
25
+ section?: string;
26
+ children: ReactNode;
27
+ }) {
28
+ const parent = useContext(DesignPanelInputContext);
29
+ const value = useMemo(
30
+ () => ({ ui: ui ?? parent.ui, section: section ?? parent.section }),
31
+ [ui, section, parent.ui, parent.section],
32
+ );
33
+ return (
34
+ <DesignPanelInputContext.Provider value={value}>{children}</DesignPanelInputContext.Provider>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Returns a stable `track(control, name)` fn bound to the current UI + section.
40
+ * Call it from any input's commit handler.
41
+ */
42
+ export function useTrackDesignInput(): (control: string, name: string) => void {
43
+ const { ui, section } = useContext(DesignPanelInputContext);
44
+ return useCallback(
45
+ (control: string, name: string) => trackDesignInput({ ui, section, control, name }),
46
+ [ui, section],
47
+ );
48
+ }
@@ -16,6 +16,7 @@ export interface DomEditActionsValue extends Pick<
16
16
  | "handleDomAttributeCommit"
17
17
  | "handleDomAttributeLiveCommit"
18
18
  | "handleDomHtmlAttributeCommit"
19
+ | "handleDomAttributesCommit"
19
20
  | "handleDomPathOffsetCommit"
20
21
  | "handleDomGroupPathOffsetCommit"
21
22
  | "handleDomZIndexReorderCommit"
@@ -138,6 +139,7 @@ export function DomEditProvider({
138
139
  handleDomAttributeCommit,
139
140
  handleDomAttributeLiveCommit,
140
141
  handleDomHtmlAttributeCommit,
142
+ handleDomAttributesCommit,
141
143
  handleDomPathOffsetCommit,
142
144
  handleDomGroupPathOffsetCommit,
143
145
  handleDomZIndexReorderCommit,
@@ -224,6 +226,7 @@ export function DomEditProvider({
224
226
  handleDomAttributeCommit,
225
227
  handleDomAttributeLiveCommit,
226
228
  handleDomHtmlAttributeCommit,
229
+ handleDomAttributesCommit,
227
230
  handleDomPathOffsetCommit,
228
231
  handleDomGroupPathOffsetCommit,
229
232
  handleDomZIndexReorderCommit,
@@ -291,6 +294,7 @@ export function DomEditProvider({
291
294
  handleDomAttributeCommit,
292
295
  handleDomAttributeLiveCommit,
293
296
  handleDomHtmlAttributeCommit,
297
+ handleDomAttributesCommit,
294
298
  handleDomPathOffsetCommit,
295
299
  handleDomGroupPathOffsetCommit,
296
300
  handleDomZIndexReorderCommit,
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { Composition } from "@hyperframes/sdk";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import {
8
+ VariablePromoteProvider,
9
+ useVariablePromoteChannel,
10
+ type ChannelPromote,
11
+ } from "./VariablePromoteContext";
12
+
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ describe("VariablePromoteProvider save failures", () => {
16
+ it("reports a rejected promote instead of leaving an unhandled promise", async () => {
17
+ const snapshot = {
18
+ attributes: { id: "box" },
19
+ inlineStyles: {},
20
+ text: "",
21
+ children: [],
22
+ };
23
+ const session = {
24
+ getElement: vi.fn().mockReturnValue(snapshot),
25
+ getVariableDeclarations: vi.fn().mockReturnValue([]),
26
+ on: vi.fn().mockReturnValue(() => {}),
27
+ } as unknown as Composition;
28
+ const selection = {
29
+ hfId: "hf-box",
30
+ tagName: "div",
31
+ label: "Box",
32
+ capabilities: { canEditStyles: true },
33
+ computedStyles: { color: "rgb(255, 0, 0)" },
34
+ } as unknown as DomEditSelection;
35
+ const error = new Error("write failed");
36
+ const persist = vi.fn().mockRejectedValue(error);
37
+ const onPersistError = vi.fn();
38
+ let channel: ChannelPromote | null = null;
39
+
40
+ function Consumer() {
41
+ channel = useVariablePromoteChannel({ kind: "style", prop: "color" });
42
+ return null;
43
+ }
44
+
45
+ const root = createRoot(document.createElement("div"));
46
+ act(() => {
47
+ root.render(
48
+ <VariablePromoteProvider
49
+ session={session}
50
+ selection={selection}
51
+ persist={persist}
52
+ onPersistError={onPersistError}
53
+ >
54
+ <Consumer />
55
+ </VariablePromoteProvider>,
56
+ );
57
+ });
58
+ act(() => channel?.promote());
59
+ await act(async () => {
60
+ await Promise.resolve();
61
+ });
62
+
63
+ expect(persist).toHaveBeenCalledOnce();
64
+ expect(onPersistError).toHaveBeenCalledWith(error);
65
+ act(() => root.unmount());
66
+ });
67
+ });
@@ -44,6 +44,7 @@ interface VariablePromoteContextValue {
44
44
  actions: BindAction[];
45
45
  declarations: CompositionVariable[];
46
46
  persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
47
+ onPersistError: (error: unknown) => void;
47
48
  }
48
49
 
49
50
  const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
@@ -58,11 +59,13 @@ export function VariablePromoteProvider({
58
59
  session,
59
60
  selection,
60
61
  persist,
62
+ onPersistError,
61
63
  children,
62
64
  }: {
63
65
  session: Composition | null;
64
66
  selection: DomEditSelection | null;
65
67
  persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
68
+ onPersistError: (error: unknown) => void;
66
69
  children: React.ReactNode;
67
70
  }) {
68
71
  // Re-derive actions/bindings after each persisted schema edit.
@@ -85,8 +88,8 @@ export function VariablePromoteProvider({
85
88
  }, [session, revision]);
86
89
 
87
90
  const value = useMemo<VariablePromoteContextValue>(
88
- () => ({ session, selection, actions, declarations, persist }),
89
- [session, selection, actions, declarations, persist],
91
+ () => ({ session, selection, actions, declarations, persist, onPersistError }),
92
+ [session, selection, actions, declarations, persist, onPersistError],
90
93
  );
91
94
 
92
95
  return (
@@ -105,7 +108,7 @@ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromo
105
108
 
106
109
  return useMemo(() => {
107
110
  if (!ctx || !ctx.session || !ctx.selection?.hfId) return null;
108
- const { session, selection, actions, declarations, persist } = ctx;
111
+ const { session, selection, actions, declarations, persist, onPersistError } = ctx;
109
112
  const hfId = selection.hfId!;
110
113
  const action = matchAction(actions, channel);
111
114
  const boundId = readBinding(session, hfId, channel);
@@ -125,12 +128,14 @@ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromo
125
128
  const id = uniqueId(action.suggestedId, declarations);
126
129
  void persist(`Bind ${action.label.toLowerCase()} to variable "${id}"`, (s) =>
127
130
  applyBind(s, hfId, action, id),
128
- );
131
+ ).catch(onPersistError);
129
132
  },
130
133
  setDefault: (raw: string) => {
131
134
  if (!boundId || !declaration) return;
132
135
  const next = declaration.type === "color" ? rgbToHex(raw) : raw;
133
- void persist(`Set default for "${boundId}"`, (s) => s.setVariableValue(boundId, next));
136
+ void persist(`Set default for "${boundId}"`, (s) =>
137
+ s.setVariableValue(boundId, next),
138
+ ).catch(onPersistError);
134
139
  },
135
140
  };
136
141
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -7,6 +7,15 @@ interface DomEditCommitRunnerConfig {
7
7
  onError: (error: unknown) => void;
8
8
  shouldResync: () => boolean;
9
9
  resync: () => void | Promise<void>;
10
+ /**
11
+ * Reports success/failure without changing this function's own resolve-
12
+ * always contract — `persist` failures are handled here (revert + onError)
13
+ * and never rethrown, so callers awaiting `runDomEditCommit` can't observe
14
+ * failure via rejection. A caller that needs to react to a specific
15
+ * commit's outcome (e.g. reverting its OWN optimistic state) can pass this
16
+ * instead of relying on a rejection that will never come.
17
+ */
18
+ onSettled?: (ok: boolean) => void;
10
19
  }
11
20
 
12
21
  interface CommitVersionRef {
@@ -34,11 +43,13 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
34
43
 
35
44
  try {
36
45
  await config.persist();
46
+ config.onSettled?.(true);
37
47
  } catch (error) {
38
48
  if (config.shouldRevert(error)) {
39
49
  config.revert();
40
50
  }
41
51
  config.onError(error);
52
+ config.onSettled?.(false);
42
53
  }
43
54
 
44
55
  if (!config.shouldResync()) return;
@@ -11,6 +11,8 @@ export type CommitDomEditPatchBatches = (
11
11
  options: {
12
12
  label: string;
13
13
  coalesceKey: string;
14
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
15
+ coalesceMs?: number;
14
16
  /**
15
17
  * Request skipping the preview iframe reload after a successful persist.
16
18
  * Only honored when the persist is provably in sync with the live DOM:
@@ -20,7 +22,21 @@ export type CommitDomEditPatchBatches = (
20
22
  */
21
23
  skipReload?: boolean;
22
24
  },
23
- ) => Promise<void>;
25
+ ) => Promise<DomEditPatchBatchesResult>;
26
+
27
+ /**
28
+ * Durability report for a patch-batches commit. `durable === false` means the
29
+ * server could not locate at least one patch target on disk — the preview was
30
+ * reloaded to reconverge, and dependent follow-up writes (the z→lane timeline
31
+ * mirror) must be skipped. `allMatched` retains the underlying match detail.
32
+ * `changed === false` means no source file was written: every patch was a
33
+ * byte-identical no-op, or the atomic gesture was refused before any write.
34
+ */
35
+ export interface DomEditPatchBatchesResult {
36
+ durable: boolean;
37
+ allMatched: boolean;
38
+ changed: boolean;
39
+ }
24
40
 
25
41
  export type PersistDomEditOperations = (
26
42
  selection: DomEditSelection,
@@ -0,0 +1,25 @@
1
+ import { vi } from "vitest";
2
+ import type { useElementLifecycleOps } from "./useElementLifecycleOps";
3
+
4
+ type LifecycleOpsParams = Parameters<typeof useElementLifecycleOps>[0];
5
+
6
+ /**
7
+ * Baseline `useElementLifecycleOps` params for tests: inert stubs for every
8
+ * dependency, overridden per test (typically just `commitDomEditPatchBatches`).
9
+ * Shared by the z-reorder commit tests and the timeline-mirror harness.
10
+ */
11
+ export function makeLifecycleOpsParams(
12
+ overrides: Partial<LifecycleOpsParams> & Pick<LifecycleOpsParams, "commitDomEditPatchBatches">,
13
+ ): LifecycleOpsParams {
14
+ return {
15
+ activeCompPath: "index.html",
16
+ showToast: vi.fn(),
17
+ writeProjectFile: vi.fn(async () => {}),
18
+ domEditSaveTimestampRef: { current: 0 },
19
+ editHistory: { recordEdit: vi.fn(async () => {}) },
20
+ projectIdRef: { current: null },
21
+ reloadPreview: vi.fn(),
22
+ clearDomSelection: vi.fn(),
23
+ ...overrides,
24
+ };
25
+ }
@@ -0,0 +1,16 @@
1
+ // Shared helpers for test-side `fetch` stubs (timelineTimingSync.test.ts,
2
+ // useTimelineEditing.test.tsx): a JSON Response factory and a Request → URL
3
+ // normalizer. Test-only module — imported exclusively from *.test.* files.
4
+
5
+ export function jsonResponse(body: unknown): Response {
6
+ return new Response(JSON.stringify(body), {
7
+ status: 200,
8
+ headers: { "content-type": "application/json" },
9
+ });
10
+ }
11
+
12
+ export function requestUrl(input: Parameters<typeof fetch>[0]): string {
13
+ if (typeof input === "string") return input;
14
+ if (input instanceof URL) return input.toString();
15
+ return input.url;
16
+ }