@hyperframes/studio 0.8.79 → 0.8.81

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 (86) hide show
  1. package/dist/assets/hyperframes-player-C68UawR3.js +464 -0
  2. package/dist/assets/{index-ORZXYB0k.js → index-CMzPGv9P.js} +1 -1
  3. package/dist/assets/{index-CJP92F4-.js → index-CS5SImRT.js} +1 -1
  4. package/dist/assets/{index-DBKySIo7.js → index-hNVM9Pd2.js} +200 -197
  5. package/dist/index.d.ts +18 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1673 -1348
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +13 -10
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  13. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  14. package/src/components/editor/manualEdits.ts +1 -0
  15. package/src/components/editor/manualEditsParsing.ts +4 -0
  16. package/src/components/editor/useFxAudition.ts +3 -3
  17. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  18. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  19. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  20. package/src/components/panels/VariablesPanel.tsx +3 -3
  21. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  22. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  23. package/src/components/studioColorGradingScope.ts +5 -7
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/contexts/TimelineEditContext.tsx +2 -0
  26. package/src/hooks/liveLanes.ts +147 -0
  27. package/src/hooks/timelineAudioGroupVolume.ts +81 -47
  28. package/src/hooks/timelineEditPermission.ts +9 -1
  29. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  30. package/src/hooks/timelineEditingHelpers.ts +69 -43
  31. package/src/hooks/timelineElementFxAttribute.ts +82 -36
  32. package/src/hooks/useAppHotkeys.ts +1 -1
  33. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  34. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  35. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  36. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  37. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -4
  39. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  40. package/src/hooks/useDomEditSession.ts +1 -0
  41. package/src/hooks/useEditHistoryActions.ts +4 -3
  42. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  43. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  44. package/src/hooks/useLintModal.test.tsx +15 -0
  45. package/src/hooks/useLintModal.ts +5 -2
  46. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  47. package/src/hooks/useTimelineEditing.ts +19 -16
  48. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  49. package/src/hooks/useToast.ts +2 -1
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +23 -0
  52. package/src/player/components/Player.tsx +4 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/groupAutomationElement.ts +9 -1
  64. package/src/player/components/timelineCallbacks.ts +2 -0
  65. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  66. package/src/player/components/timelineClipDragPreview.ts +15 -24
  67. package/src/player/components/timelineEditing.test.ts +99 -1
  68. package/src/player/components/timelineEditing.ts +5 -8
  69. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  70. package/src/player/components/timelineGroupEditing.ts +18 -15
  71. package/src/player/components/timelineStackingSync.ts +5 -5
  72. package/src/player/components/timelineZones.ts +2 -3
  73. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  74. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  75. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  76. package/src/player/components/useAutomationLanes.ts +58 -74
  77. package/src/player/lib/automationStoreSync.ts +30 -3
  78. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  79. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -8
  81. package/src/player/lib/timelineElementHelpers.ts +3 -4
  82. package/src/player/lib/timelineGroupInfo.ts +23 -23
  83. package/src/utils/studioFileHistory.ts +36 -30
  84. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
  85. package/src/player/components/automationGestureKeys.test.ts +0 -49
  86. package/src/player/components/automationGestureKeys.ts +0 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.79",
3
+ "version": "0.8.81",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,12 +52,12 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.79",
56
- "@hyperframes/parsers": "0.8.79",
57
- "@hyperframes/sdk": "0.8.79",
58
- "@hyperframes/player": "0.8.79",
59
- "@hyperframes/shader-transitions": "0.8.79",
60
- "@hyperframes/studio-server": "0.8.79"
55
+ "@hyperframes/core": "0.8.81",
56
+ "@hyperframes/parsers": "0.8.81",
57
+ "@hyperframes/player": "0.8.81",
58
+ "@hyperframes/sdk": "0.8.81",
59
+ "@hyperframes/shader-transitions": "0.8.81",
60
+ "@hyperframes/studio-server": "0.8.81"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@tailwindcss/vite": "^4.3.3",
@@ -73,7 +73,7 @@
73
73
  "vite": "^6.4.2",
74
74
  "vitest": "^4.1.11",
75
75
  "zustand": "^5.0.0",
76
- "@hyperframes/producer": "0.8.79"
76
+ "@hyperframes/producer": "0.8.81"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "react": "19",
package/src/App.tsx CHANGED
@@ -209,6 +209,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
209
209
  refreshFileTree: fileManager.refreshFileTree,
210
210
  reloadPreview,
211
211
  showToast,
212
+ dismissToast,
212
213
  },
213
214
  previewIframeRef,
214
215
  setRightCollapsed: panelLayout.setRightCollapsed,
@@ -260,7 +261,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
260
261
  handleDuplicate,
261
262
  onResetKeyframes: () => resetKeyframesRef.current(),
262
263
  onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
263
- onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
264
+ onAfterUndoRedo: (restore) => {
265
+ invalidateGsapCacheRef.current();
266
+ timelineEditing.restoreLiveLanes(restore);
267
+ },
264
268
  onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(),
265
269
  onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(),
266
270
  activeCompPath,
@@ -340,8 +344,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
340
344
  effectiveTimelineDuration,
341
345
  });
342
346
  const compositionDimensions = useCompositionDimensions(previewIframeRef);
343
- const { lintModal, linting, handleLint, closeLintModal, findingsByFile, hasLintError } =
344
- useLintModal(projectId, refreshKey);
347
+ const lint = useLintModal(projectId, refreshKey);
345
348
  const frameCapture = useFrameCapture({
346
349
  projectId,
347
350
  activeCompPath,
@@ -496,11 +499,11 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
496
499
  onSelectComposition={handleSelectComposition}
497
500
  onAddBlock={handleAddBlock}
498
501
  onPreviewBlock={setBlockPreview}
499
- onLint={handleLint}
500
- linting={linting}
501
- lintFindingCount={lintModal?.length ?? findingsByFile.size}
502
- lintFindingsByFile={findingsByFile}
503
- lintHasError={hasLintError}
502
+ onLint={lint.handleLint}
503
+ linting={lint.linting}
504
+ lintFindingCount={lint.lintFindingCount}
505
+ lintFindingsByFile={lint.findingsByFile}
506
+ lintHasError={lint.hasLintError}
504
507
  onAddAssetToTimeline={handleAddAssetAtPlayhead}
505
508
  onAddCompositionToTimeline={handleAddCompositionAtPlayhead}
506
509
  />
@@ -573,8 +576,8 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
573
576
  <StudioOverlays
574
577
  projectId={projectId}
575
578
  projectDir={fileManager.projectDir}
576
- lintModal={lintModal}
577
- closeLintModal={closeLintModal}
579
+ lintModal={lint.lintModal}
580
+ closeLintModal={lint.closeLintModal}
578
581
  consoleErrors={consoleErrors}
579
582
  clearConsoleErrors={() => setConsoleErrors(null)}
580
583
  domEditSession={domEditSession}
@@ -23,14 +23,10 @@ import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/Stu
23
23
  import { useFileManagerContext } from "../contexts/FileManagerContext";
24
24
  import { useDomEditContext } from "../contexts/DomEditContext";
25
25
  import { usePlayerStore } from "../player";
26
- import {
27
- applyColorGradingScopeUpdate,
28
- EMPTY_COLOR_GRADING_SCOPE_RESULT,
29
- type ColorGradingScope,
30
- } from "./studioColorGradingScope";
31
26
  import { timelineKeysForSelections } from "../utils/studioHelpers";
32
27
  import { canHideSelections } from "../utils/timelineInspector";
33
28
  import { useRemoveBackground } from "../hooks/useRemoveBackground";
29
+ import { useApplyColorGradingScope } from "../hooks/useApplyColorGradingScope";
34
30
 
35
31
  // fallow-ignore-next-line complexity
36
32
  export function StudioRightPanels({
@@ -49,14 +45,8 @@ export function StudioRightPanels({
49
45
  onAutoGroupCarveSources,
50
46
  onAddMediaOverlay,
51
47
  }: StudioRightPanelsProps) {
52
- const {
53
- previewIframeRef,
54
- projectId,
55
- activeCompPath,
56
- showToast,
57
- waitForPendingDomEditSaves,
58
- renderQueue,
59
- } = useStudioShellContext();
48
+ const { previewIframeRef, projectId, activeCompPath, showToast, renderQueue } =
49
+ useStudioShellContext();
60
50
  const { captionEditMode, refreshKey } = useStudioPlaybackContext();
61
51
 
62
52
  const {
@@ -114,7 +104,6 @@ export function StudioRightPanels({
114
104
  refreshFileTree,
115
105
  readProjectFile,
116
106
  writeProjectFile,
117
- fileTree,
118
107
  editingFile,
119
108
  } = useFileManagerContext();
120
109
 
@@ -158,40 +147,7 @@ export function StudioRightPanels({
158
147
  });
159
148
  useCaptionDesignFocus(captionEditMode);
160
149
 
161
- const handleApplyColorGradingScope = useCallback(
162
- async (scope: ColorGradingScope, value: string | null) =>
163
- applyColorGradingScopeUpdate({
164
- scope,
165
- value,
166
- selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
167
- fileTree,
168
- projectId,
169
- waitForPendingDomEditSaves,
170
- readProjectFile,
171
- writeProjectFile,
172
- recordEdit,
173
- reloadPreview,
174
- showToast,
175
- }).catch((error) => {
176
- showToast(
177
- `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
178
- "error",
179
- );
180
- return EMPTY_COLOR_GRADING_SCOPE_RESULT;
181
- }),
182
- [
183
- activeCompPath,
184
- domEditSelection?.sourceFile,
185
- fileTree,
186
- projectId,
187
- readProjectFile,
188
- recordEdit,
189
- reloadPreview,
190
- showToast,
191
- waitForPendingDomEditSaves,
192
- writeProjectFile,
193
- ],
194
- );
150
+ const handleApplyColorGradingScope = useApplyColorGradingScope(recordEdit, reloadPreview);
195
151
 
196
152
  const handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);
197
153
 
@@ -87,7 +87,7 @@ describe("TimelineFxPopover", () => {
87
87
  act(() => {
88
88
  button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
89
89
  });
90
- expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN);
90
+ expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN, true);
91
91
  });
92
92
 
93
93
  it("Escape closes without letting the keystroke propagate past the popover", () => {
@@ -75,7 +75,7 @@ export interface TimelineFxPopoverProps {
75
75
  * selected clip's attribute — the caller resolves which). */
76
76
  onChainChange: (next: HfAudioFxChain) => void;
77
77
  /** Preview a hypothetical chain on the running graph without persisting. */
78
- onChainPreview?: (next: HfAudioFxChain) => void;
78
+ onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
79
79
  onAuditionTransport?: (on: boolean) => void;
80
80
  /** Select the target the way clicking it in the timeline does, and ensure
81
81
  * the property panel's Audio FX group is expanded. */
@@ -11,6 +11,7 @@ export {
11
11
 
12
12
  export {
13
13
  readFileChangeAffectedCompositions,
14
+ readFileChangeAffectsPreview,
14
15
  readFileChangeField,
15
16
  readStudioFileChangePath,
16
17
  } from "./manualEditsParsing";
@@ -34,6 +34,10 @@ export function readStudioFileChangePath(payload: unknown): string | null {
34
34
  return path === null ? null : normalizeStudioFileChangePath(path);
35
35
  }
36
36
 
37
+ export function readFileChangeAffectsPreview(payload: unknown): boolean {
38
+ return asPayloadRecord(payload)?.affectsPreview !== false;
39
+ }
40
+
37
41
  /**
38
42
  * The compositions whose thumbnails a change can alter, or `null` for all of them. Anything
39
43
  * but a list of paths (an older server, the Vite dev host) reads as "all".
@@ -11,7 +11,7 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
11
11
 
12
12
  export function useFxAudition(
13
13
  chain: HfAudioFxChain,
14
- onChainPreview: ((chain: HfAudioFxChain) => void) | undefined,
14
+ onChainPreview: ((chain: HfAudioFxChain, ended?: boolean) => void) | undefined,
15
15
  onAuditionTransport: ((on: boolean) => void) | undefined,
16
16
  ) {
17
17
  /**
@@ -45,7 +45,7 @@ export function useFxAudition(
45
45
  // Stop before reverting, for the mirror of that reason — the last thing
46
46
  // heard should be the preset, not a frame of the chain coming back.
47
47
  onAuditionTransport?.(false);
48
- onChainPreview(auditionBase.current);
48
+ onChainPreview(auditionBase.current, true);
49
49
  auditionBase.current = null;
50
50
  }
51
51
  },
@@ -96,7 +96,7 @@ export function useFxAudition(
96
96
  () => () => {
97
97
  if (auditionBase.current) {
98
98
  transportRef.current?.(false);
99
- previewRef.current?.(auditionBase.current);
99
+ previewRef.current?.(auditionBase.current, true);
100
100
  }
101
101
  },
102
102
  [],
@@ -41,6 +41,7 @@ export interface TimelineEditCallbackDeps {
41
41
  handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
42
42
  setAudioGroupAttribute: {
43
43
  setLive: (groupId: string, attr: string, value: string | null) => void;
44
+ revertLive?: (groupId: string, attr: string) => void;
44
45
  setQuiet: (
45
46
  groupId: string,
46
47
  attr: string,
@@ -61,6 +62,7 @@ export interface TimelineEditCallbackDeps {
61
62
  /** C1's single-clip FX write, addressed by the clip itself. */
62
63
  setElementFxAttribute?: {
63
64
  setLive: (element: TimelineElement, attr: string, value: string | null) => void;
65
+ revertLive?: (element: TimelineElement, attr: string) => void;
64
66
  setQuiet: (
65
67
  element: TimelineElement,
66
68
  attr: string,
@@ -216,9 +218,11 @@ export function useTimelineEditCallbacks({
216
218
  onToggleTrackHidden: handleToggleTrackHidden,
217
219
  onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
218
220
  onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
221
+ onRevertAudioGroupAttributeLive: setAudioGroupAttribute.revertLive,
219
222
  onGroupClips: handleGroupClips,
220
223
  onSetElementAttributeLive: setElementFxAttribute?.setLive,
221
224
  onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
225
+ onRevertElementAttributeLive: setElementFxAttribute?.revertLive,
222
226
  onBlockedEditAttempt: handleBlockedTimelineEdit,
223
227
  onSplitElement: handleTimelineElementSplit,
224
228
  onRazorSplit: handleRazorSplit,
@@ -85,7 +85,7 @@ function CompositionSection({
85
85
  }
86
86
 
87
87
  export function VariablesOtherCompositions({
88
- fileTree,
88
+ compositionPaths,
89
89
  excludePath,
90
90
  refreshKey,
91
91
  readProjectFile,
@@ -93,8 +93,8 @@ export function VariablesOtherCompositions({
93
93
  recordEdit,
94
94
  reloadPreview,
95
95
  }: {
96
- fileTree: string[];
97
- excludePath: string;
96
+ compositionPaths: string[];
97
+ excludePath: string | null;
98
98
  refreshKey: unknown;
99
99
  readProjectFile: (path: string) => Promise<string>;
100
100
  writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -103,7 +103,7 @@ export function VariablesOtherCompositions({
103
103
  }) {
104
104
  const [selfRefresh, setSelfRefresh] = useState(0);
105
105
  const groups = useProjectCompositionVariables(
106
- fileTree,
106
+ compositionPaths,
107
107
  excludePath,
108
108
  readProjectFile,
109
109
  `${refreshKey}:${selfRefresh}`,
@@ -0,0 +1,86 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { openComposition } from "@hyperframes/sdk";
7
+ import { VariablesPanel } from "./VariablesPanel";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const withTitle = (id: string, label: string) => `<!DOCTYPE html>
12
+ <html data-composition-variables='[{"id":"title","type":"string","label":"${label}","default":"Hello"}]'>
13
+ <body><div data-composition-id="${id}" data-hf-id="hf-stage" data-hf-root data-duration="5"></div></body>
14
+ </html>`;
15
+
16
+ const files: Record<string, string> = {
17
+ "index.html":
18
+ '<html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"></div></body></html>',
19
+ "main.html": withTitle("main", "Main title"),
20
+ "scene.html": withTitle("scene", "Scene title"),
21
+ ".hyperframes/preview/4884df6e.html": withTitle("root", "Generated title"),
22
+ };
23
+ const shell = {
24
+ activeCompPath: "index.html" as string | null,
25
+ compositions: ["index.html", "scene.html"],
26
+ };
27
+
28
+ vi.mock("../../contexts/StudioContext", () => ({
29
+ useStudioShellContext: () => ({ activeCompPath: shell.activeCompPath, showToast: vi.fn() }),
30
+ useStudioPlaybackContext: () => ({ refreshKey: 0 }),
31
+ }));
32
+ vi.mock("../../contexts/DomEditContext", () => ({
33
+ useDomEditContext: () => ({ domEditSelection: null }),
34
+ }));
35
+ vi.mock("../../contexts/FileManagerContext", () => ({
36
+ useFileManagerContext: () => ({
37
+ readProjectFile: async (path: string) => files[path] ?? "",
38
+ writeProjectFile: vi.fn(),
39
+ fileTree: Object.keys(files),
40
+ compositions: shell.compositions,
41
+ }),
42
+ }));
43
+
44
+ let root: Root | null = null;
45
+
46
+ afterEach(() => {
47
+ act(() => root?.unmount());
48
+ root = null;
49
+ document.body.innerHTML = "";
50
+ });
51
+
52
+ async function renderOtherCompositions(sessionPath: string): Promise<string> {
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ root = createRoot(host);
56
+ const session = await openComposition(files[sessionPath]!, { history: false });
57
+ await act(async () => {
58
+ root?.render(
59
+ <VariablesPanel
60
+ sdkSession={session}
61
+ publishSdkSession={vi.fn()}
62
+ reloadPreview={vi.fn()}
63
+ recordEdit={vi.fn(async () => {})}
64
+ />,
65
+ );
66
+ });
67
+ const other = () =>
68
+ [...host.querySelectorAll("p")].find((p) => p.textContent === "Other compositions")
69
+ ?.parentElement?.textContent ?? "";
70
+ await vi.waitFor(() => expect(other()).toContain("scene.html"));
71
+ return other();
72
+ }
73
+
74
+ describe("VariablesPanel other compositions", () => {
75
+ it("lists the project's compositions, not Studio's generated preview documents", async () => {
76
+ shell.activeCompPath = "index.html";
77
+ shell.compositions = ["index.html", "scene.html"];
78
+ expect(await renderOtherCompositions("index.html")).not.toContain(".hyperframes/preview");
79
+ });
80
+
81
+ it("leaves out the main composition in the master view when it is not index.html", async () => {
82
+ shell.activeCompPath = null;
83
+ shell.compositions = ["main.html", "scene.html"];
84
+ expect(await renderOtherCompositions("main.html")).not.toContain("main.html");
85
+ });
86
+ });
@@ -254,7 +254,7 @@ export const VariablesPanel = memo(function VariablesPanel({
254
254
  }: VariablesPanelProps) {
255
255
  const { activeCompPath, showToast } = useStudioShellContext();
256
256
  const { refreshKey } = useStudioPlaybackContext();
257
- const { readProjectFile, writeProjectFile, fileTree, compositions } = useFileManagerContext();
257
+ const { readProjectFile, writeProjectFile, compositions } = useFileManagerContext();
258
258
  const { domEditSelection } = useDomEditContext();
259
259
  // Master view (no activeCompPath) targets the real main composition, not a
260
260
  // hardcoded index.html — used for both the persist write target and the
@@ -537,8 +537,8 @@ export const VariablesPanel = memo(function VariablesPanel({
537
537
  </button>
538
538
  )}
539
539
  <VariablesOtherCompositions
540
- fileTree={fileTree}
541
- excludePath={activeCompPath ?? "index.html"}
540
+ compositionPaths={compositions}
541
+ excludePath={effectiveCompPath}
542
542
  refreshKey={`${refreshKey}:${revision}`}
543
543
  readProjectFile={readProjectFile}
544
544
  writeProjectFile={writeProjectFile}
@@ -44,6 +44,35 @@ describe("SidebarLintButton badge", () => {
44
44
  expect(badge?.textContent).toContain("including errors");
45
45
  });
46
46
 
47
+ it("pulses a few times and then holds still, so an idle Studio paints nothing", () => {
48
+ const badge = renderBadge(true);
49
+ expect(badge?.style.animationIterationCount).toBe("3");
50
+ });
51
+
52
+ it("pulses again when the finding count changes", () => {
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ root = createRoot(host);
56
+ const render = (findingCount: number) =>
57
+ act(() => {
58
+ root?.render(
59
+ <SidebarLintButton
60
+ onLint={vi.fn()}
61
+ linting={false}
62
+ findingCount={findingCount}
63
+ hasError
64
+ />,
65
+ );
66
+ });
67
+ render(2);
68
+ const first = host.querySelector("[data-lint-badge]");
69
+ render(2);
70
+ expect(host.querySelector("[data-lint-badge]")).toBe(first);
71
+ render(3);
72
+ // A new element restarts its CSS animation; a text change on the old one would not.
73
+ expect(host.querySelector("[data-lint-badge]")).not.toBe(first);
74
+ });
75
+
47
76
  it("stays still when the findings are warnings only", () => {
48
77
  const badge = renderBadge(false);
49
78
  expect(badge?.dataset.lintBadge).toBe("warning");
@@ -1,3 +1,5 @@
1
+ const ERROR_PULSES_BEFORE_IDLE = 3;
2
+
1
3
  export function SidebarLintButton({
2
4
  onLint,
3
5
  linting,
@@ -31,7 +33,9 @@ export function SidebarLintButton({
31
33
  {linting ? "Linting…" : "Lint"}
32
34
  {!linting && findingCount != null && findingCount > 0 && (
33
35
  <span
36
+ key={findingCount}
34
37
  data-lint-badge={hasError ? "error" : "warning"}
38
+ style={hasError ? { animationIterationCount: ERROR_PULSES_BEFORE_IDLE } : undefined}
35
39
  className={
36
40
  hasError
37
41
  ? "ml-1 min-w-[16px] rounded-full bg-panel-danger/25 px-1 text-[9px] font-bold text-panel-danger animate-pulse motion-reduce:animate-none"
@@ -22,7 +22,7 @@ interface ApplyColorGradingScopeOptions {
22
22
  scope: ColorGradingScope;
23
23
  value: string | null;
24
24
  selectedSourceFile: string;
25
- fileTree: string[];
25
+ compositionPaths: string[];
26
26
  projectId: string;
27
27
  waitForPendingDomEditSaves: () => Promise<void>;
28
28
  readProjectFile: ProjectFileReader;
@@ -35,11 +35,9 @@ interface ApplyColorGradingScopeOptions {
35
35
  function colorGradingScopePaths(
36
36
  scope: ColorGradingScope,
37
37
  selectedSourceFile: string,
38
- fileTree: string[],
38
+ compositionPaths: string[],
39
39
  ): string[] {
40
- return scope === "source-file"
41
- ? [selectedSourceFile]
42
- : fileTree.filter((path) => /\.html?$/i.test(path));
40
+ return scope === "source-file" ? [selectedSourceFile] : compositionPaths;
43
41
  }
44
42
 
45
43
  async function patchColorGradingScopeFiles(
@@ -72,7 +70,7 @@ export async function applyColorGradingScopeUpdate({
72
70
  scope,
73
71
  value,
74
72
  selectedSourceFile,
75
- fileTree,
73
+ compositionPaths,
76
74
  projectId,
77
75
  waitForPendingDomEditSaves,
78
76
  readProjectFile,
@@ -91,7 +89,7 @@ export async function applyColorGradingScopeUpdate({
91
89
  }
92
90
 
93
91
  const { files, changedElements } = await patchColorGradingScopeFiles(
94
- colorGradingScopePaths(scope, selectedSourceFile, fileTree),
92
+ colorGradingScopePaths(scope, selectedSourceFile, compositionPaths),
95
93
  value,
96
94
  readProjectFile,
97
95
  );
@@ -13,6 +13,7 @@ export interface DomEditActionsValue extends Pick<
13
13
  | "handlePreviewCanvasPointerLeave"
14
14
  | "applyDomSelection"
15
15
  | "clearDomSelection"
16
+ | "refreshDomEditSelectionFromPreview"
16
17
  | "handleDomStyleCommit"
17
18
  | "handleDomStyleCommitForSelection"
18
19
  | "handleDomAttributeCommit"
@@ -155,6 +156,7 @@ export function DomEditProvider({
155
156
  handlePreviewCanvasPointerLeave,
156
157
  applyDomSelection,
157
158
  clearDomSelection,
159
+ refreshDomEditSelectionFromPreview,
158
160
  handleDomStyleCommit,
159
161
  handleDomStyleCommitForSelection,
160
162
  handleDomAttributeCommit,
@@ -248,6 +250,7 @@ export function DomEditProvider({
248
250
  handlePreviewCanvasPointerLeave,
249
251
  applyDomSelection,
250
252
  clearDomSelection,
253
+ refreshDomEditSelectionFromPreview,
251
254
  handleDomStyleCommit,
252
255
  handleDomStyleCommitForSelection,
253
256
  handleDomAttributeCommit,
@@ -322,6 +325,7 @@ export function DomEditProvider({
322
325
  handlePreviewCanvasPointerLeave,
323
326
  applyDomSelection,
324
327
  clearDomSelection,
328
+ refreshDomEditSelectionFromPreview,
325
329
  handleDomStyleCommit,
326
330
  handleDomStyleCommitForSelection,
327
331
  handleDomAttributeCommit,
@@ -44,9 +44,11 @@ export function TimelineEditProvider({
44
44
  value.onToggleTrackHidden,
45
45
  value.onSetAudioGroupAttributeLive,
46
46
  value.onSetAudioGroupAttributeQuiet,
47
+ value.onRevertAudioGroupAttributeLive,
47
48
  value.onGroupClips,
48
49
  value.onSetElementAttributeLive,
49
50
  value.onSetElementAttributeQuiet,
51
+ value.onRevertElementAttributeLive,
50
52
  value.onBlockedEditAttempt,
51
53
  value.onSplitElement,
52
54
  value.onRazorSplit,