@hyperframes/studio 0.7.58 → 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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.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-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -10,6 +10,13 @@ export interface FramePosterProps {
10
10
  title: string;
11
11
  /** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
12
12
  fit?: "cover" | "contain";
13
+ /**
14
+ * Project content signature to key the poster URL on. The thumbnail route
15
+ * regenerates when the frame's source changes, but the browser only refetches
16
+ * when the URL does — so bake the signature in to pick up fresh posters after
17
+ * a background board refresh.
18
+ */
19
+ posterVersion?: string;
13
20
  }
14
21
 
15
22
  /**
@@ -18,11 +25,19 @@ export interface FramePosterProps {
18
25
  * no postMessage seek, and no client-side fps assumption. Shared by the
19
26
  * contact-sheet tile and the frame-focus view.
20
27
  */
21
- export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
28
+ export function FramePoster({
29
+ projectId,
30
+ src,
31
+ seconds,
32
+ title,
33
+ fit = "cover",
34
+ posterVersion,
35
+ }: FramePosterProps) {
22
36
  const [failed, setFailed] = useState(false);
23
37
  // The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
24
- // prior load error would stick. Reset when the poster target changes.
25
- useEffect(() => setFailed(false), [src, seconds]);
38
+ // prior load error would stick. Reset when the poster target changes — including
39
+ // a new posterVersion, so a frame that failed mid-write retries once it settles.
40
+ useEffect(() => setFailed(false), [src, seconds, posterVersion]);
26
41
  if (failed) {
27
42
  return (
28
43
  <div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
@@ -30,12 +45,17 @@ export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: F
30
45
  </div>
31
46
  );
32
47
  }
33
- const url = buildCompositionThumbnailUrl({
48
+ let url = buildCompositionThumbnailUrl({
34
49
  previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
35
50
  seekTime: seconds,
36
51
  duration: 0,
37
52
  origin: window.location.origin,
38
53
  });
54
+ if (posterVersion) {
55
+ const withVersion = new URL(url, window.location.origin);
56
+ withVersion.searchParams.set("sig", posterVersion);
57
+ url = withVersion.toString();
58
+ }
39
59
  return (
40
60
  <img
41
61
  src={url}
@@ -19,6 +19,8 @@ export interface StoryboardFrameFocusProps {
19
19
  onSaved: () => void;
20
20
  /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
21
21
  onSelectComposition: (path: string) => void;
22
+ /** Project signature the board was loaded with (busts the poster cache). */
23
+ posterVersion?: string;
22
24
  }
23
25
 
24
26
  /**
@@ -39,6 +41,7 @@ export function StoryboardFrameFocus({
39
41
  onNavigate,
40
42
  onSaved,
41
43
  onSelectComposition,
44
+ posterVersion,
42
45
  }: StoryboardFrameFocusProps) {
43
46
  const { readProjectFile, writeProjectFile } = useFileManagerContext();
44
47
  const { setViewMode } = useViewMode();
@@ -151,6 +154,7 @@ export function StoryboardFrameFocus({
151
154
  seconds={posterTime(frame)}
152
155
  title={title}
153
156
  fit="contain"
157
+ posterVersion={posterVersion}
154
158
  />
155
159
  ) : (
156
160
  <div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
@@ -7,6 +7,13 @@ export interface StoryboardFrameTileProps {
7
7
  frame: StoryboardFrameView;
8
8
  /** Open this frame in the full-area focus view. */
9
9
  onOpen: (index: number) => void;
10
+ /** This frame's pending comment draft ("" when none). */
11
+ commentDraft: string;
12
+ onCommentDraftChange: (index: number, text: string) => void;
13
+ /** A submitted comment the agent has not consumed yet (null when none). */
14
+ pendingComment: string | null;
15
+ /** Project signature the board was loaded with (busts the poster cache). */
16
+ posterVersion?: string;
10
17
  }
11
18
 
12
19
  function firstLine(text: string): string {
@@ -26,7 +33,15 @@ function placeholderMessage(frame: StoryboardFrameView): string {
26
33
 
27
34
  /** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
28
35
  // fallow-ignore-next-line complexity
29
- export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) {
36
+ export function StoryboardFrameTile({
37
+ projectId,
38
+ frame,
39
+ onOpen,
40
+ commentDraft,
41
+ onCommentDraftChange,
42
+ pendingComment,
43
+ posterVersion,
44
+ }: StoryboardFrameTileProps) {
30
45
  const meta = FRAME_STATUS_META[frame.status];
31
46
  const renderable = frame.srcExists && frame.status !== "outline";
32
47
  const title = frame.title ?? `Frame ${frame.index}`;
@@ -48,6 +63,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
48
63
  src={frame.src}
49
64
  seconds={posterTime(frame)}
50
65
  title={title}
66
+ posterVersion={posterVersion}
51
67
  />
52
68
  ) : (
53
69
  <FrameTilePlaceholder frame={frame} />
@@ -74,6 +90,19 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
74
90
  {frame.duration && <span>{frame.duration}</span>}
75
91
  {frame.transitionIn && <span>↘ {frame.transitionIn}</span>}
76
92
  </div>
93
+ <textarea
94
+ value={commentDraft}
95
+ onChange={(e) => onCommentDraftChange(frame.index, e.target.value)}
96
+ rows={2}
97
+ placeholder="Comment on this frame…"
98
+ aria-label={`Comment on ${title}`}
99
+ className="mt-2 w-full resize-none rounded-md border border-neutral-800 bg-neutral-900/60 px-2 py-1.5 text-xs text-neutral-200 placeholder:text-neutral-600 focus:border-sky-700 focus:outline-none"
100
+ />
101
+ {pendingComment && (
102
+ <p className="mt-1 text-[11px] text-sky-400/90">
103
+ <span className="font-medium">Pending:</span> “{pendingComment}”
104
+ </p>
105
+ )}
77
106
  </article>
78
107
  );
79
108
  }
@@ -1,4 +1,5 @@
1
1
  import type { StoryboardFrameView } from "../../hooks/useStoryboard";
2
+ import type { FrameCommentEntry } from "./frameComments";
2
3
  import { StoryboardFrameTile } from "./StoryboardFrameTile";
3
4
 
4
5
  export interface StoryboardGridProps {
@@ -6,10 +7,25 @@ export interface StoryboardGridProps {
6
7
  frames: StoryboardFrameView[];
7
8
  /** Open a frame in the full-area focus view. */
8
9
  onOpenFrame: (index: number) => void;
10
+ /** Per-frame comment drafts, keyed by frame index. */
11
+ commentDrafts: Record<number, string>;
12
+ onCommentDraftChange: (index: number, text: string) => void;
13
+ /** Submitted comments the agent has not consumed yet. */
14
+ pendingComments: FrameCommentEntry[] | null;
15
+ /** Project signature the board was loaded with (busts poster caches). */
16
+ posterVersion?: string;
9
17
  }
10
18
 
11
19
  /** The contact sheet: ordered frame tiles in a responsive grid. */
12
- export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGridProps) {
20
+ export function StoryboardGrid({
21
+ projectId,
22
+ frames,
23
+ onOpenFrame,
24
+ commentDrafts,
25
+ onCommentDraftChange,
26
+ pendingComments,
27
+ posterVersion,
28
+ }: StoryboardGridProps) {
13
29
  if (frames.length === 0) {
14
30
  return (
15
31
  <div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
@@ -26,6 +42,12 @@ export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGri
26
42
  projectId={projectId}
27
43
  frame={frame}
28
44
  onOpen={onOpenFrame}
45
+ commentDraft={commentDrafts[frame.index] ?? ""}
46
+ onCommentDraftChange={onCommentDraftChange}
47
+ pendingComment={
48
+ pendingComments?.find((entry) => entry.frame === frame.index)?.text ?? null
49
+ }
50
+ posterVersion={posterVersion}
29
51
  />
30
52
  ))}
31
53
  </div>
@@ -1,11 +1,13 @@
1
- import { useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import type { StoryboardResponse } from "../../hooks/useStoryboard";
3
+ import { Button } from "../ui/Button";
3
4
  import { StoryboardDirection } from "./StoryboardDirection";
4
5
  import { StoryboardGrid } from "./StoryboardGrid";
5
6
  import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
6
7
  import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
7
8
  import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
8
9
  import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
10
+ import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
9
11
 
10
12
  type SubView = "board" | "source";
11
13
 
@@ -33,6 +35,14 @@ export function StoryboardLoaded({
33
35
  const [subView, setSubView] = useState<SubView>("board");
34
36
  const [sourceDirty, setSourceDirty] = useState(false);
35
37
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
38
+ const comments = useFrameComments(data.frames);
39
+ // When the board refreshes off a project change (agent revised frames), the
40
+ // agent has likely consumed the comments file too — re-check so the pending
41
+ // banner clears the moment revisions land, not on the next window focus.
42
+ const { refreshPending } = comments;
43
+ useEffect(() => {
44
+ void refreshPending();
45
+ }, [data.signature, refreshPending]);
36
46
  const sourceFiles = useMemo<SourceFile[]>(() => {
37
47
  const files: SourceFile[] = [{ path: data.path, label: data.path }];
38
48
  if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
@@ -72,14 +82,23 @@ export function StoryboardLoaded({
72
82
  }
73
83
  onSaved={reload}
74
84
  onSelectComposition={onSelectComposition}
85
+ posterVersion={data.signature}
75
86
  />
76
87
  );
77
88
  }
78
89
 
79
90
  return (
80
91
  <div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
81
- <div className="flex items-center border-b border-neutral-800 px-4 py-2">
92
+ <div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
82
93
  <SubViewToggle value={subView} onChange={changeSubView} />
94
+ {subView === "board" && (
95
+ <CommentsSubmitBar
96
+ draftCount={comments.draftCount}
97
+ pendingCount={comments.pending?.length ?? 0}
98
+ submitState={comments.submitState}
99
+ onSubmit={() => void comments.submit()}
100
+ />
101
+ )}
83
102
  </div>
84
103
  {subView === "board" ? (
85
104
  <div className="flex-1 min-h-0 overflow-auto">
@@ -92,6 +111,10 @@ export function StoryboardLoaded({
92
111
  projectId={projectId}
93
112
  frames={data.frames}
94
113
  onOpenFrame={setFocusedIndex}
114
+ commentDrafts={comments.drafts}
115
+ onCommentDraftChange={comments.setDraft}
116
+ pendingComments={comments.pending}
117
+ posterVersion={data.signature}
95
118
  />
96
119
  {data.script && <StoryboardScriptPanel script={data.script} />}
97
120
  </div>
@@ -107,6 +130,39 @@ export function StoryboardLoaded({
107
130
  );
108
131
  }
109
132
 
133
+ /** Batch-submit the per-frame comment drafts to `.hyperframes/frame-comments.json`. */
134
+ function CommentsSubmitBar({
135
+ draftCount,
136
+ pendingCount,
137
+ submitState,
138
+ onSubmit,
139
+ }: {
140
+ draftCount: number;
141
+ pendingCount: number;
142
+ submitState: CommentsSubmitState;
143
+ onSubmit: () => void;
144
+ }) {
145
+ return (
146
+ <div className="ml-auto flex items-center gap-3">
147
+ {pendingCount > 0 && (
148
+ <span className="text-xs text-sky-300">
149
+ {pendingCount} comment{pendingCount > 1 ? "s" : ""} pending — reply anything in your agent
150
+ chat and it will apply them.
151
+ </span>
152
+ )}
153
+ <Button
154
+ variant="primary"
155
+ size="sm"
156
+ loading={submitState === "saving"}
157
+ disabled={draftCount === 0 || submitState === "saving"}
158
+ onClick={onSubmit}
159
+ >
160
+ {draftCount > 0 ? `Submit comments (${draftCount})` : "Submit comments"}
161
+ </Button>
162
+ </div>
163
+ );
164
+ }
165
+
110
166
  const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
111
167
  { value: "board", label: "Board" },
112
168
  { value: "source", label: "Source" },
@@ -1,6 +1,7 @@
1
1
  import { useState, type ReactNode } from "react";
2
2
  import { Copy, Check } from "@phosphor-icons/react";
3
3
  import { useStoryboard } from "../../hooks/useStoryboard";
4
+ import { useProjectSignaturePoll } from "../../hooks/useProjectSignaturePoll";
4
5
  import { copyTextToClipboard } from "../../utils/clipboard";
5
6
  import { Button } from "../ui/Button";
6
7
  import { StoryboardLoaded } from "./StoryboardLoaded";
@@ -19,6 +20,10 @@ export interface StoryboardViewProps {
19
20
  // fallow-ignore-next-line complexity
20
21
  export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
21
22
  const { data, loading, error, reload } = useStoryboard(projectId);
23
+ // Keep the board current while an agent writes to the project: when the
24
+ // project signature moves past the one `data` was loaded with, refetch. Also
25
+ // upgrades the empty state the moment STORYBOARD.md lands on disk.
26
+ useProjectSignaturePoll(projectId, data?.signature, reload);
22
27
 
23
28
  if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
24
29
  if (error) {
@@ -87,12 +92,14 @@ audience: <who it's for>
87
92
  ## Frame 1 — <title>
88
93
  - duration: 5s
89
94
  - transition_in: crossfade
90
- - status: planned
95
+ - status: outline
91
96
  - src: compositions/frames/01-<slug>.html
92
97
 
93
98
  <A sentence or two: what's on screen and what the narration says.>
94
99
 
95
- Add one \`## Frame N\` section per beat. Keep the arc tight.`;
100
+ Add one \`## Frame N\` section per beat. Keep the arc tight.
101
+
102
+ Then run the review loop from the hyperframes-core skill (references/review-loop.md): present the plan as a proposal, offer wireframe sketches on this board, and build on the confirmed layouts.`;
96
103
  }
97
104
 
98
105
  function EmptyState({ path }: { path: string }) {
@@ -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
+ }