@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.90",
3
+ "version": "0.7.93",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,17 +47,18 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.90",
51
- "@hyperframes/parsers": "0.7.90",
52
- "@hyperframes/player": "0.7.90",
53
- "@hyperframes/studio-server": "0.7.90",
54
- "@hyperframes/sdk": "0.7.90"
50
+ "@hyperframes/core": "0.7.93",
51
+ "@hyperframes/parsers": "0.7.93",
52
+ "@hyperframes/studio-server": "0.7.93",
53
+ "@hyperframes/sdk": "0.7.93",
54
+ "@hyperframes/player": "0.7.93"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
58
58
  "@types/react-dom": "19",
59
59
  "@vitejs/plugin-react": "^4.0.0",
60
60
  "autoprefixer": "^10.4.0",
61
+ "fake-indexeddb": "^6.2.5",
61
62
  "postcss": "^8.4.0",
62
63
  "puppeteer-core": "^25.2.1",
63
64
  "tailwindcss": "^3.4.0",
@@ -66,7 +67,7 @@
66
67
  "vite": "^6.4.2",
67
68
  "vitest": "^3.2.4",
68
69
  "zustand": "^5.0.0",
69
- "@hyperframes/producer": "0.7.90"
70
+ "@hyperframes/producer": "0.7.93"
70
71
  },
71
72
  "peerDependencies": {
72
73
  "react": "19",
package/src/App.tsx CHANGED
@@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
4
4
  import { usePlayerStore } from "./player";
5
5
  import { StudioOverlays } from "./components/StudioOverlays";
6
6
  import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
7
+ import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
7
8
  import { useCaptionStore } from "./captions/store";
8
9
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
9
10
  import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
22
23
  import { useDomEditSession } from "./hooks/useDomEditSession";
23
24
  import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
24
25
  import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
26
+ import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
25
27
  import { useBlockHandlers } from "./hooks/useBlockHandlers";
26
28
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
27
29
  import { useClipboard } from "./hooks/useClipboard";
@@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
56
58
  import { DomEditProvider } from "./contexts/DomEditContext";
57
59
  import { StudioSplash } from "./components/StudioSplash";
58
60
  import { useServerConnection } from "./hooks/useServerConnection";
61
+ import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
59
62
  import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
60
63
  import {
61
64
  normalizeStudioCompositionPath,
62
65
  readStudioUrlStateFromWindow,
63
66
  resolveMasterCompositionPath,
64
67
  } from "./utils/studioUrlState";
65
- import { trackStudioSessionStart } from "./telemetry/events";
66
- import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
67
68
  // fallow-ignore-next-line complexity
68
69
  export function StudioApp() {
69
70
  const { projectId, resolving, waitingForServer } = useServerConnection();
70
71
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
71
72
  const viewModeValue = useViewModeState();
72
- useEffect(() => {
73
- if (resolving || waitingForServer) return;
74
- if (hasFiredSessionStart()) return;
75
- markSessionStartFired();
76
- trackStudioSessionStart({ has_project: projectId != null });
77
- }, [projectId, resolving, waitingForServer]);
73
+ useStudioSessionStart(projectId, resolving, waitingForServer);
78
74
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
79
75
  const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
80
76
  () => initialUrlStateRef.current.activeCompPath == null,
@@ -130,7 +126,6 @@ export function StudioApp() {
130
126
  const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
131
127
  projectId,
132
128
  activeCompPath,
133
- domEditSaveTimestampRef,
134
129
  masterCompPath,
135
130
  );
136
131
  useEffect(() => {
@@ -144,20 +139,24 @@ export function StudioApp() {
144
139
  setActiveCompPathHydrated(true);
145
140
  }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
146
141
  const previewPersistence = usePreviewPersistence({
147
- projectId,
148
142
  showToast,
149
143
  readOptionalProjectFile: fileManager.readOptionalProjectFile,
150
144
  writeProjectFile: fileManager.writeProjectFile,
151
145
  recordEdit: editHistory.recordEdit,
152
146
  previewIframeRef,
153
147
  activeCompPathRef,
154
- domEditSaveTimestampRef,
155
148
  reloadPreview: () => setRefreshKey((k) => k + 1),
149
+ });
150
+ const externalFileChanges = useStudioExternalFileChanges({
151
+ projectId,
152
+ activeCompPath,
153
+ masterCompPath,
154
+ fileManager,
155
+ previewPersistence,
156
156
  pendingTimelineEditPathRef,
157
+ reloadPreview,
157
158
  });
158
159
  const invalidateGsapCacheRef = useRef<() => void>(() => {});
159
- // Stable identity — what the ref indirection is for. An inline arrow re-created
160
- // the memoized timeline handlers (it is in their deps) on every render.
161
160
  const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
162
161
  const timelineEditing = useTimelineEditing({
163
162
  projectId,
@@ -483,12 +482,13 @@ export function StudioApp() {
483
482
  })();
484
483
  }}
485
484
  />
486
- {previewPersistence.domEditSaveQueuePaused && (
485
+ {previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
487
486
  <SaveQueuePausedBanner
488
487
  message={previewPersistence.domEditSaveQueuePaused}
489
488
  onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
490
489
  />
491
490
  )}
491
+ <ExternalFileConflictBanner coordinator={externalFileChanges} />
492
492
  {viewModeValue.viewMode === "storyboard" && (
493
493
  <StoryboardView
494
494
  projectId={projectId}
@@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
52
52
  children: ReactNode;
53
53
  }) {
54
54
  const targetPath = selection?.sourceFile || activeCompPath || "index.html";
55
- const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
55
+ const handle = useSdkSession(projectId, targetPath);
56
56
  const rawPersist = useVariablesPersist({
57
57
  ...persistDeps,
58
58
  sdkSession: handle.session,
@@ -0,0 +1,142 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
6
+ import type { ExternalFileChangeCoordinatorHandle } from "../hooks/useExternalFileChangeCoordinator";
7
+ import { ExternalFileConflictBanner } from "./ExternalFileConflictBanner";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ describe("ExternalFileConflictBanner", () => {
12
+ afterEach(() => {
13
+ document.body.replaceChildren();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("keeps destructive choices explicit and exposes both full versions for review", async () => {
18
+ const container = document.createElement("div");
19
+ document.body.append(container);
20
+ const root = createRoot(container);
21
+ const conflict = new StudioFileConflictError({
22
+ filePath: "index.html",
23
+ currentVersion: "v2",
24
+ currentContent: "<html>external</html>",
25
+ attemptedContent: "<html>studio</html>",
26
+ });
27
+ const coordinator: ExternalFileChangeCoordinatorHandle = {
28
+ blocked: { status: "conflict", generation: 1, error: conflict, payload: {} },
29
+ retry: vi.fn(async () => undefined),
30
+ useExternalFile: vi.fn(async () => undefined),
31
+ keepStudioFile: vi.fn(async () => undefined),
32
+ };
33
+
34
+ await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
35
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain(
36
+ "Preview is paused so neither version is lost",
37
+ );
38
+ expect(document.body.textContent).toContain("Discard Studio edits and reload file");
39
+ expect(document.body.textContent).toContain("Overwrite file with Studio version");
40
+
41
+ const review = Array.from(document.querySelectorAll("button")).find((button) =>
42
+ button.textContent?.includes("Review or export both"),
43
+ );
44
+ await act(async () => review?.click());
45
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull();
46
+ expect(Array.from(document.querySelectorAll("textarea"), (field) => field.value)).toEqual([
47
+ "<html>external</html>",
48
+ "<html>studio</html>",
49
+ ]);
50
+
51
+ await act(async () => root.unmount());
52
+ });
53
+
54
+ it("lets authors review and export the local candidate after a drain failure", async () => {
55
+ const container = document.createElement("div");
56
+ document.body.append(container);
57
+ const root = createRoot(container);
58
+ const coordinator: ExternalFileChangeCoordinatorHandle = {
59
+ blocked: {
60
+ status: "failed",
61
+ generation: 1,
62
+ path: "index.html",
63
+ error: new Error("offline"),
64
+ payload: {},
65
+ studioContent: "<html>recover me</html>",
66
+ recovered: false,
67
+ },
68
+ retry: vi.fn(async () => undefined),
69
+ useExternalFile: vi.fn(async () => undefined),
70
+ keepStudioFile: vi.fn(async () => undefined),
71
+ };
72
+
73
+ await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
74
+ const review = Array.from(document.querySelectorAll("button")).find((button) =>
75
+ button.textContent?.includes("Review or export Studio draft"),
76
+ );
77
+ await act(async () => review?.click());
78
+ expect(document.querySelector("textarea")?.value).toBe("<html>recover me</html>");
79
+ expect(document.body.textContent).toContain("Copy");
80
+ expect(document.body.textContent).toContain("Download");
81
+ await act(async () => root.unmount());
82
+ });
83
+
84
+ it("does not offer a fake retry after remount and instead offers explicit overwrite", async () => {
85
+ const container = document.createElement("div");
86
+ document.body.append(container);
87
+ const root = createRoot(container);
88
+ const keepStudioFile = vi.fn(async () => undefined);
89
+ const coordinator: ExternalFileChangeCoordinatorHandle = {
90
+ blocked: {
91
+ status: "failed",
92
+ generation: 1,
93
+ path: "index.html",
94
+ error: new Error("offline"),
95
+ payload: { path: "index.html", version: "v2", content: "external" },
96
+ studioContent: "recovered draft",
97
+ recovered: true,
98
+ },
99
+ retry: vi.fn(async () => undefined),
100
+ useExternalFile: vi.fn(async () => undefined),
101
+ keepStudioFile,
102
+ };
103
+
104
+ await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
105
+ expect(document.body.textContent).not.toContain("Retry save");
106
+ const overwrite = Array.from(document.querySelectorAll("button")).find((button) =>
107
+ button.textContent?.includes("Overwrite file with recovered Studio draft"),
108
+ );
109
+ expect(overwrite).toBeTruthy();
110
+ vi.spyOn(window, "confirm").mockReturnValue(true);
111
+ await act(async () => overwrite?.click());
112
+ expect(keepStudioFile).toHaveBeenCalledOnce();
113
+
114
+ await act(async () => root.unmount());
115
+ });
116
+
117
+ it("does not offer retry when a failed DOM edit has no recoverable source candidate", async () => {
118
+ const container = document.createElement("div");
119
+ document.body.append(container);
120
+ const root = createRoot(container);
121
+ const coordinator: ExternalFileChangeCoordinatorHandle = {
122
+ blocked: {
123
+ status: "failed",
124
+ generation: 1,
125
+ path: "index.html",
126
+ error: new Error("offline"),
127
+ payload: { path: "index.html", version: "v2", content: "external" },
128
+ studioContent: null,
129
+ recovered: false,
130
+ },
131
+ retry: vi.fn(async () => undefined),
132
+ useExternalFile: vi.fn(async () => undefined),
133
+ keepStudioFile: vi.fn(async () => undefined),
134
+ };
135
+
136
+ await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
137
+ expect(document.body.textContent).not.toContain("Retry save");
138
+ expect(document.body.textContent).toContain("Discard Studio edits and reload file");
139
+
140
+ await act(async () => root.unmount());
141
+ });
142
+ });
@@ -0,0 +1,255 @@
1
+ import { useRef, useState } from "react";
2
+ import type {
3
+ ExternalFileChangeBlockedState,
4
+ ExternalFileChangeCoordinatorHandle,
5
+ } from "../hooks/useExternalFileChangeCoordinator";
6
+ import { useDialogBehavior } from "./ui/useDialogBehavior";
7
+
8
+ function errorMessage(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ function downloadText(filename: string, content: string): void {
13
+ const url = URL.createObjectURL(new Blob([content], { type: "text/html;charset=utf-8" }));
14
+ const anchor = document.createElement("a");
15
+ anchor.href = url;
16
+ anchor.download = filename;
17
+ anchor.click();
18
+ URL.revokeObjectURL(url);
19
+ }
20
+
21
+ function ConflictReview({
22
+ conflict,
23
+ onClose,
24
+ }: {
25
+ conflict: Extract<ExternalFileChangeBlockedState, { status: "conflict" }>;
26
+ onClose: () => void;
27
+ }) {
28
+ const containerRef = useRef<HTMLDivElement>(null);
29
+ useDialogBehavior({ open: true, onClose, containerRef });
30
+ const external = conflict.error.currentContent ?? "(The server did not return file contents.)";
31
+ const studio = conflict.error.attemptedContent;
32
+
33
+ return (
34
+ <div
35
+ className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
36
+ onClick={onClose}
37
+ >
38
+ <div
39
+ ref={containerRef}
40
+ role="dialog"
41
+ aria-modal="true"
42
+ aria-labelledby="external-conflict-title"
43
+ tabIndex={-1}
44
+ className="max-h-[88vh] w-full max-w-5xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
45
+ onClick={(event) => event.stopPropagation()}
46
+ >
47
+ <div className="flex items-start justify-between gap-4">
48
+ <div>
49
+ <h2 id="external-conflict-title" className="text-base font-semibold">
50
+ Review both versions of {conflict.error.filePath}
51
+ </h2>
52
+ <p className="mt-1 text-xs text-neutral-400">
53
+ Reviewing or exporting does not change either version.
54
+ </p>
55
+ </div>
56
+ <button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
57
+ Close
58
+ </button>
59
+ </div>
60
+ <div className="mt-4 grid gap-4 md:grid-cols-2">
61
+ {[
62
+ { title: "File on disk", content: external, suffix: "external" },
63
+ { title: "Unsaved Studio version", content: studio, suffix: "studio" },
64
+ ].map((side) => (
65
+ <section key={side.suffix} aria-label={side.title}>
66
+ <div className="mb-2 flex items-center justify-between gap-2">
67
+ <h3 className="text-sm font-medium">{side.title}</h3>
68
+ <div className="flex gap-2">
69
+ <button
70
+ type="button"
71
+ className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
72
+ onClick={() => void navigator.clipboard.writeText(side.content)}
73
+ >
74
+ Copy
75
+ </button>
76
+ <button
77
+ type="button"
78
+ className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
79
+ onClick={() =>
80
+ downloadText(`${conflict.error.filePath}.${side.suffix}.html`, side.content)
81
+ }
82
+ >
83
+ Download
84
+ </button>
85
+ </div>
86
+ </div>
87
+ <textarea
88
+ readOnly
89
+ value={side.content}
90
+ className="h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
91
+ />
92
+ </section>
93
+ ))}
94
+ </div>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function FailedDraftReview({
101
+ path,
102
+ content,
103
+ onClose,
104
+ }: {
105
+ path: string;
106
+ content: string;
107
+ onClose: () => void;
108
+ }) {
109
+ const containerRef = useRef<HTMLDivElement>(null);
110
+ useDialogBehavior({ open: true, onClose, containerRef });
111
+ return (
112
+ <div
113
+ className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
114
+ onClick={onClose}
115
+ >
116
+ <div
117
+ ref={containerRef}
118
+ role="dialog"
119
+ aria-modal="true"
120
+ aria-labelledby="failed-draft-title"
121
+ tabIndex={-1}
122
+ className="max-h-[88vh] w-full max-w-3xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
123
+ onClick={(event) => event.stopPropagation()}
124
+ >
125
+ <div className="flex items-start justify-between gap-4">
126
+ <div>
127
+ <h2 id="failed-draft-title" className="text-base font-semibold">
128
+ Recover unsaved Studio draft for {path}
129
+ </h2>
130
+ <p className="mt-1 text-xs text-neutral-400">
131
+ Copy or download this draft before choosing to discard it.
132
+ </p>
133
+ </div>
134
+ <button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
135
+ Close
136
+ </button>
137
+ </div>
138
+ <div className="mt-4 flex justify-end gap-2">
139
+ <button
140
+ type="button"
141
+ className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
142
+ onClick={() => void navigator.clipboard.writeText(content)}
143
+ >
144
+ Copy
145
+ </button>
146
+ <button
147
+ type="button"
148
+ className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
149
+ onClick={() => downloadText(`${path}.studio.html`, content)}
150
+ >
151
+ Download
152
+ </button>
153
+ </div>
154
+ <textarea
155
+ readOnly
156
+ value={content}
157
+ className="mt-2 h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
158
+ />
159
+ </div>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ export function ExternalFileConflictBanner({
165
+ coordinator,
166
+ }: {
167
+ coordinator: ExternalFileChangeCoordinatorHandle;
168
+ }) {
169
+ const [reviewing, setReviewing] = useState(false);
170
+ const blocked = coordinator.blocked;
171
+ if (!blocked) return null;
172
+
173
+ const conflict = blocked.status === "conflict" ? blocked : null;
174
+ const failure = blocked.status === "failed" ? blocked : null;
175
+ return (
176
+ <>
177
+ <div
178
+ role="alert"
179
+ className="absolute left-1/2 top-14 z-[94] flex max-w-[calc(100vw-32px)] -translate-x-1/2 flex-wrap items-center gap-3 rounded-md border border-amber-400/30 bg-amber-950/95 px-4 py-2 text-[12px] font-medium text-amber-50 shadow-lg"
180
+ >
181
+ <span>
182
+ {conflict
183
+ ? `${conflict.error.filePath} changed outside Studio. Preview is paused so neither version is lost.`
184
+ : `Studio could not safely finish local saves: ${errorMessage(blocked.error)}. Preview is paused.`}
185
+ </span>
186
+ {conflict && (
187
+ <button type="button" onClick={() => setReviewing(true)} className="underline">
188
+ Review or export both
189
+ </button>
190
+ )}
191
+ {failure?.studioContent != null && (
192
+ <button type="button" onClick={() => setReviewing(true)} className="underline">
193
+ Review or export Studio draft
194
+ </button>
195
+ )}
196
+ {failure && !failure.recovered && failure.studioContent != null && (
197
+ <button type="button" onClick={() => void coordinator.retry()} className="underline">
198
+ Retry save
199
+ </button>
200
+ )}
201
+ <button
202
+ type="button"
203
+ onClick={() => void coordinator.useExternalFile()}
204
+ className="rounded border border-amber-200/30 px-2 py-1"
205
+ >
206
+ Discard Studio edits and reload file
207
+ </button>
208
+ {conflict && (
209
+ <button
210
+ type="button"
211
+ onClick={() => {
212
+ if (
213
+ window.confirm(
214
+ "Overwrite the externally changed file with the Studio version? The server will preserve its normal backup before writing.",
215
+ )
216
+ ) {
217
+ void coordinator.keepStudioFile();
218
+ }
219
+ }}
220
+ className="rounded border border-red-300/40 px-2 py-1 text-red-100"
221
+ >
222
+ Overwrite file with Studio version
223
+ </button>
224
+ )}
225
+ {failure?.recovered && failure.studioContent != null && (
226
+ <button
227
+ type="button"
228
+ onClick={() => {
229
+ if (
230
+ window.confirm(
231
+ "Overwrite the file with the recovered Studio draft? The current file will be preserved by the server's normal backup before writing.",
232
+ )
233
+ ) {
234
+ void coordinator.keepStudioFile();
235
+ }
236
+ }}
237
+ className="rounded border border-red-300/40 px-2 py-1 text-red-100"
238
+ >
239
+ Overwrite file with recovered Studio draft
240
+ </button>
241
+ )}
242
+ </div>
243
+ {reviewing && conflict && (
244
+ <ConflictReview conflict={conflict} onClose={() => setReviewing(false)} />
245
+ )}
246
+ {reviewing && failure?.studioContent != null && (
247
+ <FailedDraftReview
248
+ path={failure.path}
249
+ content={failure.studioContent}
250
+ onClose={() => setReviewing(false)}
251
+ />
252
+ )}
253
+ </>
254
+ );
255
+ }
@@ -401,8 +401,8 @@ export function StudioRightPanel({
401
401
  onUpdateArcSegment={handleUpdateArcSegment}
402
402
  onUnroll={handleUnroll}
403
403
  onUpdateKeyframeEase={handleUpdateKeyframeEase}
404
- onSetAllKeyframeEases={handleSetAllKeyframeEases}
405
404
  onUpdateSegmentEase={handleUpdateSegmentEase}
405
+ onSetAllKeyframeEases={handleSetAllKeyframeEases}
406
406
  recordingState={recordingState}
407
407
  recordingDuration={recordingDuration}
408
408
  onToggleRecording={onToggleRecording}
@@ -0,0 +1,84 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { useShallow } from "zustand/react/shallow";
4
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
5
+ import { usePlayerStore } from "../../player";
6
+ import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
7
+ import { AnimationCard } from "./AnimationCard";
8
+ import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
9
+ import {
10
+ type GsapAnimationEditCallbacks,
11
+ withTrackedGsapAnimationCallbacks,
12
+ } from "./gsapAnimationCallbacks";
13
+
14
+ interface GsapAnimationListProps extends GsapAnimationEditCallbacks {
15
+ elementId: string;
16
+ animations: GsapAnimation[];
17
+ onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
18
+ variant: "classic" | "flat";
19
+ }
20
+
21
+ /** Shared animation cards, telemetry, and timeline-ease focus ownership for both inspectors. */
22
+ export function GsapAnimationList({
23
+ elementId,
24
+ animations,
25
+ onAddAnimation,
26
+ variant,
27
+ ...callbacks
28
+ }: GsapAnimationListProps) {
29
+ const track = useTrackDesignInput();
30
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
31
+ const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
32
+ const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch, selectedElementId } =
33
+ usePlayerStore(
34
+ useShallow((state) => ({
35
+ focusedEaseSegment: state.focusedEaseSegment,
36
+ timelineProjectId: state.timelineProjectId,
37
+ timelineSessionEpoch: state.timelineSessionEpoch,
38
+ selectedElementId: state.selectedElementId,
39
+ })),
40
+ );
41
+ const focusedHere =
42
+ focusedEaseSegment &&
43
+ focusedEaseSegment.elementId === elementId &&
44
+ isFocusedEaseRequestCurrent(focusedEaseSegment, {
45
+ timelineProjectId,
46
+ timelineSessionEpoch,
47
+ selectedElementId,
48
+ }) &&
49
+ animations.some((animation) => animation.id === focusedEaseSegment.animationId)
50
+ ? focusedEaseSegment
51
+ : null;
52
+ // Stable while the request is unchanged: AnimationCard includes this callback
53
+ // in its focus-effect deps, and a fresh closure would replay that effect on
54
+ // unrelated inspector renders. Reading the action lazily also avoids a store
55
+ // subscription for a function whose identity never changes.
56
+ const consumeFocusedEaseSegment = useCallback(() => {
57
+ if (focusedHere) {
58
+ usePlayerStore.getState().clearFocusedEaseSegment(focusedHere.nonce);
59
+ }
60
+ }, [focusedHere]);
61
+
62
+ return (
63
+ <div className="space-y-2">
64
+ {animations.map((animation, index) => (
65
+ <AnimationCard
66
+ {...trackedCallbacks}
67
+ key={animation.id}
68
+ animation={animation}
69
+ defaultExpanded={index === 0}
70
+ flat={variant === "flat"}
71
+ focusedSegment={focusedHere?.animationId === animation.id ? focusedHere : null}
72
+ onFocusSegmentConsumed={consumeFocusedEaseSegment}
73
+ />
74
+ ))}
75
+ <GsapAddAnimationControl
76
+ open={addMenuOpen}
77
+ setOpen={setAddMenuOpen}
78
+ onAddAnimation={onAddAnimation}
79
+ track={track}
80
+ variant={variant}
81
+ />
82
+ </div>
83
+ );
84
+ }