@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.
- package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +18029 -11563
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -6
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +55 -1
- package/src/hooks/timelineTrackVisibility.ts +31 -13
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +81 -162
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +182 -84
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +306 -155
- package/src/hooks/useTimelineEditing.ts +111 -101
- package/src/hooks/useTimelineEditingTypes.ts +4 -1
- package/src/hooks/useTimelineGroupEditing.ts +51 -5
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-DfmYkU44.js +0 -423
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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) =>
|
|
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<
|
|
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
|
+
}
|