@hyperframes/studio 0.6.109 → 0.6.111

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 (66) hide show
  1. package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
  2. package/dist/assets/index-B7S86vK1.js +370 -0
  3. package/dist/assets/index-DP8pPIk2.css +1 -0
  4. package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
  5. package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +8 -5
  8. package/src/App.tsx +133 -141
  9. package/src/components/StudioHeader.tsx +44 -0
  10. package/src/components/StudioOverlays.tsx +70 -0
  11. package/src/components/editor/SourceEditor.tsx +19 -16
  12. package/src/components/editor/gsapAnimationConstants.ts +24 -0
  13. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  14. package/src/components/editor/manualEditingAvailability.ts +25 -7
  15. package/src/components/storyboard/FramePoster.tsx +56 -0
  16. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  17. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  18. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  19. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  20. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  21. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  22. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  23. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  24. package/src/components/storyboard/StoryboardView.tsx +78 -0
  25. package/src/components/storyboard/frameStatus.ts +36 -0
  26. package/src/contexts/ViewModeContext.tsx +98 -0
  27. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  28. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  29. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  30. package/src/hooks/timelineEditingHelpers.ts +2 -0
  31. package/src/hooks/useAppHotkeys.ts +20 -0
  32. package/src/hooks/useDomEditCommits.ts +43 -14
  33. package/src/hooks/useDomEditSession.test.ts +41 -0
  34. package/src/hooks/useDomEditSession.ts +38 -6
  35. package/src/hooks/useDomGeometryCommits.ts +5 -9
  36. package/src/hooks/useElementLifecycleOps.ts +30 -0
  37. package/src/hooks/useGsapAnimationOps.ts +78 -51
  38. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  39. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  40. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  41. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  42. package/src/hooks/useGsapScriptCommits.ts +95 -40
  43. package/src/hooks/useGsapTweenCache.ts +0 -27
  44. package/src/hooks/useRazorSplit.ts +4 -10
  45. package/src/hooks/useSdkSession.test.ts +12 -0
  46. package/src/hooks/useSdkSession.ts +91 -25
  47. package/src/hooks/useStoryboard.ts +80 -0
  48. package/src/hooks/useTimelineEditing.ts +163 -68
  49. package/src/utils/gsapSoftReload.ts +14 -0
  50. package/src/utils/sdkCutover.gate.test.ts +61 -0
  51. package/src/utils/sdkCutover.test.ts +839 -0
  52. package/src/utils/sdkCutover.ts +465 -0
  53. package/src/utils/sdkOpMapping.ts +43 -0
  54. package/src/utils/sdkResolverShadow.test.ts +366 -0
  55. package/src/utils/sdkResolverShadow.ts +313 -0
  56. package/src/utils/timelineElementSplit.test.ts +61 -1
  57. package/src/utils/timelineElementSplit.ts +18 -0
  58. package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
  59. package/dist/assets/index-BVqybwMG.css +0 -1
  60. package/dist/assets/index-ho_f4axK.js +0 -296
  61. package/src/utils/sdkShadow.test.ts +0 -606
  62. package/src/utils/sdkShadow.ts +0 -517
  63. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  64. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  65. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  66. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -0,0 +1,231 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { marked } from "marked";
3
+ import DOMPurify from "dompurify";
4
+ import { SourceEditor } from "../editor/SourceEditor";
5
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
6
+
7
+ export interface SourceFile {
8
+ path: string;
9
+ label: string;
10
+ }
11
+
12
+ export interface StoryboardSourceEditorProps {
13
+ files: SourceFile[];
14
+ /** Called after a successful save so the Board can re-parse the updated file. */
15
+ onSaved: () => void;
16
+ /** Surfaces unsaved-edit state so the parent can guard the Board↔Source toggle. */
17
+ onDirtyChange?: (dirty: boolean) => void;
18
+ }
19
+
20
+ const DISCARD_PROMPT = "Discard unsaved markdown changes?";
21
+
22
+ interface EditableFile {
23
+ content: string;
24
+ setContent: (next: string) => void;
25
+ dirty: boolean;
26
+ loading: boolean;
27
+ saving: boolean;
28
+ error: string | null;
29
+ save: () => void;
30
+ }
31
+
32
+ /** Load a project file's raw text and track edits + save state via the shared file manager. */
33
+ function useEditableFile(path: string, onSaved: () => void): EditableFile {
34
+ const { readProjectFile, writeProjectFile } = useFileManagerContext();
35
+ const [content, setContent] = useState("");
36
+ const [saved, setSaved] = useState("");
37
+ const [loading, setLoading] = useState(true);
38
+ const [saving, setSaving] = useState(false);
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ useEffect(() => {
42
+ if (!path) return;
43
+ let cancelled = false;
44
+ setLoading(true);
45
+ setError(null);
46
+ readProjectFile(path)
47
+ .then((text) => {
48
+ if (cancelled) return;
49
+ setContent(text);
50
+ setSaved(text);
51
+ })
52
+ .catch((err: unknown) => {
53
+ if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
54
+ })
55
+ .finally(() => {
56
+ if (!cancelled) setLoading(false);
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [path, readProjectFile]);
62
+
63
+ const save = useCallback(() => {
64
+ if (saving) return; // coalesce a fast double Cmd+S into one PUT
65
+ setSaving(true);
66
+ setError(null);
67
+ writeProjectFile(path, content)
68
+ .then(() => {
69
+ setSaved(content);
70
+ onSaved();
71
+ })
72
+ .catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
73
+ .finally(() => setSaving(false));
74
+ }, [writeProjectFile, path, content, onSaved, saving]);
75
+
76
+ return { content, setContent, dirty: content !== saved, loading, saving, error, save };
77
+ }
78
+
79
+ /** Preview links open in a new tab with the `window.opener` back-channel severed. */
80
+ function hardenLinks(node: Element): void {
81
+ if (node.tagName === "A" && node.hasAttribute("href")) {
82
+ node.setAttribute("target", "_blank");
83
+ node.setAttribute("rel", "noopener noreferrer");
84
+ }
85
+ }
86
+
87
+ /** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
88
+ function useMarkdownPreview(source: string): string {
89
+ const [debounced, setDebounced] = useState(source);
90
+ const primed = useRef(false);
91
+ useEffect(() => {
92
+ // Paint the first non-empty content immediately (no 200ms blank window after a file
93
+ // loads), exactly once, then debounce all subsequent keystrokes.
94
+ if (!primed.current && source !== "") {
95
+ primed.current = true;
96
+ setDebounced(source);
97
+ return;
98
+ }
99
+ const id = window.setTimeout(() => setDebounced(source), 200);
100
+ return () => window.clearTimeout(id);
101
+ }, [source]);
102
+ return useMemo(() => {
103
+ // `{ async: false }` pins the synchronous string return (no Promise union to narrow).
104
+ const html = marked.parse(debounced, { async: false });
105
+ // Scope the link-hardening hook to this call; `finally` guarantees removal even if
106
+ // `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
107
+ DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
108
+ try {
109
+ return DOMPurify.sanitize(html);
110
+ } finally {
111
+ DOMPurify.removeHook("afterSanitizeAttributes");
112
+ }
113
+ }, [debounced]);
114
+ }
115
+
116
+ function isSaveShortcut(event: React.KeyboardEvent): boolean {
117
+ return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
118
+ }
119
+
120
+ // Minimal prose styling for the rendered preview (Tailwind doesn't style raw HTML).
121
+ const PREVIEW_PROSE =
122
+ "text-sm leading-relaxed text-neutral-300 " +
123
+ "[&_h1]:mb-2 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-neutral-100 " +
124
+ "[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-neutral-100 " +
125
+ "[&_h3]:mt-4 [&_h3]:font-semibold [&_h3]:text-neutral-200 " +
126
+ "[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 " +
127
+ "[&_code]:rounded [&_code]:bg-neutral-800 [&_code]:px-1 [&_code]:text-[0.85em] [&_code]:text-neutral-200 " +
128
+ "[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
129
+ "[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
130
+ "[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
131
+ "[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
132
+ "[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
133
+ "[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
134
+
135
+ /**
136
+ * Raw markdown editor + live preview for the storyboard's canonical files
137
+ * (STORYBOARD.md / SCRIPT.md). Markdown stays the source of truth: saving writes
138
+ * the file and re-parses the Board. Deliberately raw (not WYSIWYG) so the
139
+ * structured frame fields can't be mangled; the preview is sanitized before render.
140
+ */
141
+ // fallow-ignore-next-line complexity
142
+ export function StoryboardSourceEditor({
143
+ files,
144
+ onSaved,
145
+ onDirtyChange,
146
+ }: StoryboardSourceEditorProps) {
147
+ const [selected, setSelected] = useState(files[0]?.path ?? "");
148
+ // Reconcile against the current file list so a removed/renamed file can't strand the tab.
149
+ const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
150
+ const file = useEditableFile(activePath, onSaved);
151
+ const previewHtml = useMarkdownPreview(file.content);
152
+
153
+ // Surface dirty state to the parent (guards the Board↔Source toggle) and warn
154
+ // on browser-level navigation while there are unsaved edits.
155
+ useEffect(() => onDirtyChange?.(file.dirty), [onDirtyChange, file.dirty]);
156
+ useEffect(() => {
157
+ if (!file.dirty) return;
158
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
159
+ event.preventDefault();
160
+ event.returnValue = "";
161
+ };
162
+ window.addEventListener("beforeunload", onBeforeUnload);
163
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
164
+ }, [file.dirty]);
165
+
166
+ // Switching files discards the in-memory buffer; confirm when there are unsaved edits.
167
+ const selectFile = (path: string) => {
168
+ if (path === activePath) return;
169
+ if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
170
+ setSelected(path);
171
+ };
172
+
173
+ return (
174
+ <div
175
+ className="flex flex-1 min-h-0 flex-col"
176
+ onKeyDown={(e) => {
177
+ if (!isSaveShortcut(e)) return;
178
+ e.preventDefault();
179
+ if (file.dirty && !file.saving) file.save();
180
+ }}
181
+ >
182
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-4 py-2">
183
+ {files.map((f) => (
184
+ <button
185
+ key={f.path}
186
+ type="button"
187
+ onClick={() => selectFile(f.path)}
188
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
189
+ activePath === f.path
190
+ ? "bg-neutral-800 text-neutral-100"
191
+ : "text-neutral-400 hover:text-neutral-200"
192
+ }`}
193
+ >
194
+ {f.label}
195
+ </button>
196
+ ))}
197
+ <div className="ml-auto flex items-center gap-3">
198
+ {file.error && <span className="text-xs text-red-400">{file.error}</span>}
199
+ <span className="text-xs text-neutral-500">
200
+ {file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
201
+ </span>
202
+ <button
203
+ type="button"
204
+ onClick={file.save}
205
+ disabled={!file.dirty || file.saving}
206
+ className="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
207
+ >
208
+ Save
209
+ </button>
210
+ </div>
211
+ </div>
212
+ <div className="flex flex-1 min-h-0">
213
+ <div className="w-1/2 min-w-0 border-r border-neutral-800">
214
+ {file.loading ? (
215
+ <div className="p-4 text-sm text-neutral-500">Loading {activePath}…</div>
216
+ ) : (
217
+ <SourceEditor
218
+ content={file.content}
219
+ language="markdown"
220
+ filePath={activePath}
221
+ onChange={file.setContent}
222
+ />
223
+ )}
224
+ </div>
225
+ <div className="w-1/2 min-w-0 overflow-auto bg-neutral-950 px-6 py-4">
226
+ <div className={PREVIEW_PROSE} dangerouslySetInnerHTML={{ __html: previewHtml }} />
227
+ </div>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,21 @@
1
+ import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
2
+
3
+ /**
4
+ * Explains the frame lifecycle: a frame advances outline → built → animated.
5
+ * Mirrors the status chips on each tile (shares FRAME_STATUS_META).
6
+ */
7
+ export function StoryboardStatusLegend() {
8
+ return (
9
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
10
+ <span className="uppercase tracking-wider text-neutral-600">Status</span>
11
+ {FRAME_STATUS_ORDER.map((status, i) => (
12
+ <span key={status} className="flex items-center gap-1.5">
13
+ {i > 0 && <span className="text-neutral-700">→</span>}
14
+ <span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
15
+ <span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
16
+ <span className="text-neutral-500">— {FRAME_STATUS_META[status].description}</span>
17
+ </span>
18
+ ))}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from "react";
2
+ import { useStoryboard } from "../../hooks/useStoryboard";
3
+ import { StoryboardLoaded } from "./StoryboardLoaded";
4
+
5
+ export interface StoryboardViewProps {
6
+ projectId: string;
7
+ /** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
8
+ onSelectComposition: (path: string) => void;
9
+ }
10
+
11
+ /**
12
+ * Top-level storyboard stage. Replaces the timeline/preview when the view mode
13
+ * is `storyboard`. Handles the load states here; once a storyboard exists,
14
+ * {@link StoryboardLoaded} owns the Board ↔ Source experience.
15
+ */
16
+ // fallow-ignore-next-line complexity
17
+ export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
18
+ const { data, loading, error, reload } = useStoryboard(projectId);
19
+
20
+ if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
21
+ if (error) {
22
+ return (
23
+ <StoryboardFrame>
24
+ <Message tone="error">Couldn’t load the storyboard: {error}</Message>
25
+ </StoryboardFrame>
26
+ );
27
+ }
28
+ if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
29
+ if (!data.exists) {
30
+ return (
31
+ <StoryboardFrame>
32
+ <EmptyState path={data.path} />
33
+ </StoryboardFrame>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <StoryboardLoaded
39
+ projectId={projectId}
40
+ data={data}
41
+ reload={reload}
42
+ onSelectComposition={onSelectComposition}
43
+ />
44
+ );
45
+ }
46
+
47
+ function StoryboardFrame({ children }: { children: ReactNode }) {
48
+ return (
49
+ <div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
50
+ <div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
56
+ return (
57
+ <div
58
+ className={`px-6 py-12 text-center text-sm ${
59
+ tone === "error" ? "text-red-400" : "text-neutral-500"
60
+ }`}
61
+ >
62
+ {children}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function EmptyState({ path }: { path: string }) {
68
+ return (
69
+ <div className="rounded-lg border border-dashed border-neutral-800 px-6 py-16 text-center">
70
+ <h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
71
+ <p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
72
+ Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code> at
73
+ the project root to plan this video frame by frame. Your agent can create and iterate on it
74
+ for you.
75
+ </p>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,36 @@
1
+ import type { FrameStatus } from "@hyperframes/core/storyboard";
2
+
3
+ /**
4
+ * Single source of truth for how each frame lifecycle status is presented —
5
+ * label, tooltip, description, and the chip/dot color classes — so the tile
6
+ * chip and the legend dot can't drift apart.
7
+ */
8
+ export const FRAME_STATUS_META: Record<
9
+ FrameStatus,
10
+ { label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
11
+ > = {
12
+ outline: {
13
+ label: "Outline",
14
+ tooltip: "Planned in text — no HTML frame built yet.",
15
+ description: "Planned in text. Story and intent exist; the visual isn’t built.",
16
+ chipClass: "bg-neutral-800 text-neutral-300",
17
+ dotClass: "bg-neutral-500",
18
+ },
19
+ built: {
20
+ label: "Built",
21
+ tooltip: "Static HTML frame built — not animated yet.",
22
+ description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
23
+ chipClass: "bg-sky-500/20 text-sky-300",
24
+ dotClass: "bg-sky-400",
25
+ },
26
+ animated: {
27
+ label: "Animated",
28
+ tooltip: "Keyframed and animated — plays in the final cut.",
29
+ description: "Keyframed into motion. This is the file stitched into the final video.",
30
+ chipClass: "bg-emerald-500/20 text-emerald-300",
31
+ dotClass: "bg-emerald-400",
32
+ },
33
+ };
34
+
35
+ /** The lifecycle order an agent advances each frame through. */
36
+ export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];
@@ -0,0 +1,98 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ /**
12
+ * Top-level Studio view mode.
13
+ *
14
+ * `timeline` is the existing NLE/preview stage. `storyboard` replaces that stage
15
+ * with the storyboard contact sheet. The mode is mirrored to the `?view=` query
16
+ * param so it survives reloads and — importantly — so an agent can deep-link the
17
+ * user straight into the storyboard by navigating the tab to `?view=storyboard`.
18
+ */
19
+ export type StudioViewMode = "timeline" | "storyboard";
20
+
21
+ const VIEW_QUERY_PARAM = "view";
22
+
23
+ function readViewModeFromUrl(): StudioViewMode {
24
+ if (typeof window === "undefined") return "timeline";
25
+ return new URLSearchParams(window.location.search).get(VIEW_QUERY_PARAM) === "storyboard"
26
+ ? "storyboard"
27
+ : "timeline";
28
+ }
29
+
30
+ function writeViewModeToUrl(mode: StudioViewMode): void {
31
+ if (typeof window === "undefined") return;
32
+ const url = new URL(window.location.href);
33
+ if (mode === "storyboard") {
34
+ url.searchParams.set(VIEW_QUERY_PARAM, "storyboard");
35
+ } else {
36
+ url.searchParams.delete(VIEW_QUERY_PARAM);
37
+ }
38
+ window.history.replaceState(window.history.state, "", url);
39
+ }
40
+
41
+ export interface ViewModeValue {
42
+ viewMode: StudioViewMode;
43
+ setViewMode: (mode: StudioViewMode) => void;
44
+ }
45
+
46
+ /**
47
+ * Owns the view-mode state. When `enabled` is false (storyboard flag off) the
48
+ * mode is pinned to `timeline` and the URL is left untouched, so the feature is
49
+ * fully inert until the flag is on.
50
+ */
51
+ export function useViewModeState(enabled: boolean): ViewModeValue {
52
+ const [viewMode, setMode] = useState<StudioViewMode>(() =>
53
+ enabled ? readViewModeFromUrl() : "timeline",
54
+ );
55
+
56
+ // Reflect genuine browser back/forward between history entries with a different
57
+ // `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
58
+ // `popstate`, so this listener never sees them — `setViewMode` updates state directly.
59
+ // An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
60
+ // the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
61
+ // reflected here, by design.
62
+ useEffect(() => {
63
+ if (!enabled) return;
64
+ const onPopState = () => setMode(readViewModeFromUrl());
65
+ window.addEventListener("popstate", onPopState);
66
+ return () => window.removeEventListener("popstate", onPopState);
67
+ }, [enabled]);
68
+
69
+ const setViewMode = useCallback(
70
+ (mode: StudioViewMode) => {
71
+ if (!enabled) return;
72
+ setMode(mode);
73
+ writeViewModeToUrl(mode);
74
+ },
75
+ [enabled],
76
+ );
77
+
78
+ const effectiveMode = enabled ? viewMode : "timeline";
79
+ return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
80
+ }
81
+
82
+ const ViewModeContext = createContext<ViewModeValue | null>(null);
83
+
84
+ export function useViewMode(): ViewModeValue {
85
+ const ctx = useContext(ViewModeContext);
86
+ if (!ctx) throw new Error("useViewMode must be used within ViewModeProvider");
87
+ return ctx;
88
+ }
89
+
90
+ export function ViewModeProvider({
91
+ value,
92
+ children,
93
+ }: {
94
+ value: ViewModeValue;
95
+ children: ReactNode;
96
+ }) {
97
+ return <ViewModeContext value={value}>{children}</ViewModeContext>;
98
+ }
@@ -2,8 +2,6 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
- import type { ShadowGsapOp } from "../utils/sdkShadow";
6
- import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
7
5
 
8
6
  export interface MutationResult {
9
7
  ok: boolean;
@@ -28,10 +26,6 @@ export interface CommitMutationOptions {
28
26
  * (and under distinct keys) run concurrently as before.
29
27
  */
30
28
  serializeKey?: string;
31
- /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
32
- shadowGsapOp?: ShadowGsapOp;
33
- /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
34
- shadowKeyframeOp?: ShadowKeyframeOp;
35
29
  }
36
30
 
37
31
  export type CommitMutation = (
@@ -70,6 +64,9 @@ export interface GsapScriptCommitsParams {
70
64
  onCacheInvalidate: () => void;
71
65
  onFileContentChanged?: (path: string, content: string) => void;
72
66
  showToast: (message: string, tone?: "error" | "info") => void;
73
- /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
67
+ /** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
74
68
  sdkSession?: Composition | null;
69
+ writeProjectFile?: (path: string, content: string) => Promise<void>;
70
+ /** Resync the in-memory SDK session after a server-authoritative write. */
71
+ forceReloadSdkSession?: () => void;
75
72
  }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ hashContent,
4
+ markSelfWrite,
5
+ isSelfWriteEcho,
6
+ resetSelfWriteRegistry,
7
+ } from "./sdkSelfWriteRegistry";
8
+ import { shouldReloadOnFileChange } from "./useSdkSession";
9
+
10
+ describe("sdkSelfWriteRegistry (finding #14)", () => {
11
+ beforeEach(() => resetSelfWriteRegistry());
12
+
13
+ it("recognizes the echo of bytes we just wrote", () => {
14
+ markSelfWrite("/comp.html", "<html>A</html>");
15
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
16
+ });
17
+
18
+ it("does NOT match different content on the same path (an undo's reverted bytes)", () => {
19
+ markSelfWrite("/comp.html", "<html>A</html>");
20
+ expect(isSelfWriteEcho("/comp.html", "<html>REVERTED</html>")).toBe(false);
21
+ });
22
+
23
+ it("is keyed per file — a self-write to one file can't mask a change to another", () => {
24
+ markSelfWrite("/a.html", "<html>A</html>");
25
+ expect(isSelfWriteEcho("/b.html", "<html>A</html>")).toBe(false);
26
+ });
27
+
28
+ it("consumes a matched entry so a later genuine external write isn't suppressed", () => {
29
+ markSelfWrite("/comp.html", "<html>A</html>");
30
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
31
+ // A second arrival of identical bytes is NOT our echo — must reload.
32
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(false);
33
+ });
34
+
35
+ it("expires entries past the TTL so a stale self-write can't suppress forever", () => {
36
+ const t0 = 1_000_000;
37
+ markSelfWrite("/comp.html", "<html>A</html>", t0);
38
+ // 3 s later (> 2 s TTL) the entry is gone.
39
+ expect(isSelfWriteEcho("/comp.html", "<html>A</html>", t0 + 3000)).toBe(false);
40
+ });
41
+
42
+ it("hashes are stable and distinguish different content", () => {
43
+ expect(hashContent("x")).toBe(hashContent("x"));
44
+ expect(hashContent("x")).not.toBe(hashContent("y"));
45
+ });
46
+ });
47
+
48
+ describe("shouldReloadOnFileChange (finding #14)", () => {
49
+ beforeEach(() => resetSelfWriteRegistry());
50
+
51
+ it("suppresses the reload when content matches a registered self-write (cutover echo)", () => {
52
+ markSelfWrite("/comp.html", "<html>SELF</html>");
53
+ expect(shouldReloadOnFileChange("/comp.html", "<html>SELF</html>", true)).toBe(false);
54
+ });
55
+
56
+ it("reloads on an undo write even inside the suppress window (content differs)", () => {
57
+ // The cutover registered SELF; the undo writes REVERTED bytes within the
58
+ // same 2 s window. Time-only suppression dropped this; identity reloads it.
59
+ markSelfWrite("/comp.html", "<html>SELF</html>");
60
+ expect(shouldReloadOnFileChange("/comp.html", "<html>REVERTED</html>", true)).toBe(true);
61
+ });
62
+
63
+ it("falls back to the time window only when content is unavailable", () => {
64
+ expect(shouldReloadOnFileChange("/comp.html", null, true)).toBe(false);
65
+ expect(shouldReloadOnFileChange("/comp.html", null, false)).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Self-write identity registry — discriminates an SDK cutover ECHO from a genuine
3
+ * external write (notably undo/redo) in the file-change reload-suppression path.
4
+ *
5
+ * The old suppression was purely time-based: any file-change within 2 s of the
6
+ * shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover
7
+ * self-write AND an undo write set that same timestamp, so the window could not
8
+ * tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes
9
+ * an undo just wrote" (must reload). An undo that landed inside the window was
10
+ * silently dropped, leaving the in-memory SDK doc on stale pre-undo content.
11
+ *
12
+ * Fix: tag each cutover self-write with the CONTENT it wrote (by hash). A
13
+ * file-change reload is suppressed only when the new on-disk content matches a
14
+ * recently-registered self-write hash — i.e. it is provably our own echo. Undo
15
+ * writes are never registered (they don't flow through persistSdkSerialize), so
16
+ * their content won't match and the reload always fires. Identity, not a clock.
17
+ */
18
+
19
+ const SELF_WRITE_TTL_MS = 2000;
20
+
21
+ interface SelfWriteEntry {
22
+ hash: string;
23
+ at: number;
24
+ }
25
+
26
+ // Module-scoped: the studio process has a single SDK session lifecycle at a time
27
+ // and persists are funnelled through one persistSdkSerialize. Keyed by file path
28
+ // so a self-write to one file can't mask a real external change to another.
29
+ const registry = new Map<string, SelfWriteEntry[]>();
30
+
31
+ /**
32
+ * Stable 32-bit FNV-1a hash of content. Collisions only risk SUPPRESSING a real
33
+ * reload, and only within the 2 s TTL for the exact same file — negligible, and
34
+ * strictly safer than the prior time-only window it replaces.
35
+ */
36
+ export function hashContent(content: string): string {
37
+ let h = 0x811c9dc5;
38
+ for (let i = 0; i < content.length; i++) {
39
+ h ^= content.charCodeAt(i);
40
+ h = Math.imul(h, 0x01000193);
41
+ }
42
+ return (h >>> 0).toString(16);
43
+ }
44
+
45
+ function prune(entries: SelfWriteEntry[], now: number): SelfWriteEntry[] {
46
+ return entries.filter((e) => now - e.at < SELF_WRITE_TTL_MS);
47
+ }
48
+
49
+ /** Record that WE wrote `content` to `path` (an SDK cutover self-write). */
50
+ export function markSelfWrite(path: string, content: string, now: number = Date.now()): void {
51
+ const next = prune(registry.get(path) ?? [], now);
52
+ next.push({ hash: hashContent(content), at: now });
53
+ registry.set(path, next);
54
+ }
55
+
56
+ /**
57
+ * True when `content` matches a self-write registered for `path` within the TTL.
58
+ * Consumes the matched entry so a later genuinely-external write of identical
59
+ * bytes isn't suppressed forever.
60
+ */
61
+ export function isSelfWriteEcho(path: string, content: string, now: number = Date.now()): boolean {
62
+ const entries = prune(registry.get(path) ?? [], now);
63
+ const hash = hashContent(content);
64
+ const idx = entries.findIndex((e) => e.hash === hash);
65
+ if (idx === -1) {
66
+ registry.set(path, entries);
67
+ return false;
68
+ }
69
+ entries.splice(idx, 1);
70
+ registry.set(path, entries);
71
+ return true;
72
+ }
73
+
74
+ /** Test-only: drop all registered self-writes. */
75
+ export function resetSelfWriteRegistry(): void {
76
+ registry.clear();
77
+ }
@@ -97,6 +97,7 @@ export interface PersistTimelineEditInput {
97
97
  recordEdit: (input: RecordEditInput) => Promise<void>;
98
98
  domEditSaveTimestampRef: React.MutableRefObject<number>;
99
99
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
100
+ coalesceKey?: string;
100
101
  }
101
102
 
102
103
  export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
@@ -119,6 +120,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
119
120
  projectId: input.projectId,
120
121
  label: input.label,
121
122
  kind: "timeline",
123
+ coalesceKey: input.coalesceKey,
122
124
  files: { [targetPath]: patchedContent },
123
125
  readFile: async () => originalContent,
124
126
  writeFile: input.writeProjectFile,