@hyperframes/studio 0.8.104 → 0.8.106

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 (225) hide show
  1. package/dist/assets/{hyperframes-player-DAoaajuM.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/index-CMiYVGv5.js +399 -0
  3. package/dist/assets/{index-WV9LroAD.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-BDK4rTMb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-HIGHVWYD.js → chunk-ICTZJPQQ.js} +3 -2
  9. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  10. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  11. package/dist/chunk-NCUBAVGK.js.map +1 -0
  12. package/dist/index.css +6 -1
  13. package/dist/index.css.map +1 -1
  14. package/dist/index.d.ts +15 -4
  15. package/dist/index.html +11 -2
  16. package/dist/index.js +10391 -9836
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +2 -2
  19. package/dist/styles/tailwind-preset.js.map +1 -1
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +2 -2
  22. package/package.json +9 -9
  23. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  24. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  25. package/src/captions/components/CaptionTimeline.tsx +3 -3
  26. package/src/captions/keyboard.test.ts +6 -0
  27. package/src/captions/keyboard.ts +2 -1
  28. package/src/components/AskAgentModal.tsx +1 -1
  29. package/src/components/LintModal.tsx +35 -35
  30. package/src/components/StudioErrorBoundary.tsx +1 -1
  31. package/src/components/StudioHeader.dom.test.tsx +35 -13
  32. package/src/components/StudioHeader.tsx +6 -2
  33. package/src/components/StudioToast.tsx +7 -16
  34. package/src/components/ThemeToggle.tsx +99 -0
  35. package/src/components/TimelineToolPicker.tsx +1 -1
  36. package/src/components/TimelineToolbar.tsx +11 -11
  37. package/src/components/dock/Dock.tsx +1 -1
  38. package/src/components/dock/dock.css +9 -1
  39. package/src/components/editor/AnimationCard.tsx +5 -5
  40. package/src/components/editor/AnimationCardParts.tsx +1 -1
  41. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  42. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  43. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  44. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  45. package/src/components/editor/DomEditOverlay.tsx +37 -5
  46. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  47. package/src/components/editor/EaseCurveSection.tsx +14 -19
  48. package/src/components/editor/EaseModeControls.tsx +2 -2
  49. package/src/components/editor/EaseParamFields.tsx +8 -8
  50. package/src/components/editor/FileTree.tsx +1 -1
  51. package/src/components/editor/FileTreeIcons.tsx +47 -26
  52. package/src/components/editor/FileTreeNodes.tsx +11 -8
  53. package/src/components/editor/GestureRecordControl.tsx +3 -3
  54. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  55. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  56. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  57. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  58. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  59. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  60. package/src/components/editor/LayersPanel.tsx +3 -3
  61. package/src/components/editor/PromotableControl.tsx +2 -2
  62. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  63. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  64. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  65. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  66. package/src/components/editor/SnapToolbar.tsx +5 -5
  67. package/src/components/editor/SourceEditor.tsx +2 -6
  68. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  69. package/src/components/editor/Transform3DCube.tsx +1 -1
  70. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  71. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  72. package/src/components/editor/easeCurveSvg.tsx +1 -1
  73. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  74. package/src/components/editor/manualEditsDom.ts +36 -3
  75. package/src/components/editor/manualEditsDomPatches.test.ts +2 -86
  76. package/src/components/editor/manualEditsDomPatches.ts +0 -30
  77. package/src/components/editor/manualEditsSnapshot.ts +1 -0
  78. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  79. package/src/components/editor/marqueeCommit.ts +6 -1
  80. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  81. package/src/components/editor/overlayFrameLoop.ts +21 -0
  82. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  83. package/src/components/editor/plainTranslate.ts +34 -3
  84. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  85. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  86. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  87. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  88. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  89. package/src/components/editor/propertyPanelFill.tsx +6 -6
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  92. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  93. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  94. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  97. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  98. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  99. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  101. package/src/components/editor/propertyPanelFont.tsx +71 -48
  102. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  106. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  107. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  108. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  109. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  110. package/src/components/editor/propertyPanelSections.tsx +2 -2
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  114. package/src/components/editor/studioCodeTheme.ts +99 -0
  115. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  116. package/src/components/editor/useDomEditNudge.ts +2 -2
  117. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  118. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  119. package/src/components/editor/useMotionPathData.ts +5 -1
  120. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  121. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  122. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  123. package/src/components/nle/NLEPreview.test.ts +8 -4
  124. package/src/components/nle/PreviewOverlays.tsx +1 -1
  125. package/src/components/nle/PreviewPane.tsx +3 -3
  126. package/src/components/nle/PreviewPoster.tsx +29 -1
  127. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  128. package/src/components/panels/SlideshowPanel.tsx +4 -4
  129. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  130. package/src/components/panels/VariablesBindElement.tsx +1 -1
  131. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  132. package/src/components/panels/VariablesPanel.tsx +4 -6
  133. package/src/components/panels/VariablesRowAction.tsx +1 -1
  134. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  135. package/src/components/renders/RenderQueue.tsx +2 -2
  136. package/src/components/renders/RenderQueueItem.tsx +3 -3
  137. package/src/components/sidebar/AssetCard.tsx +3 -3
  138. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  139. package/src/components/sidebar/AssetsTab.tsx +6 -6
  140. package/src/components/sidebar/AudioRow.tsx +5 -3
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  143. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  144. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  145. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  148. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  149. package/src/components/timelineToolbarStyles.ts +3 -3
  150. package/src/components/ui/Button.tsx +4 -4
  151. package/src/components/ui/HyperframesLogo.tsx +19 -18
  152. package/src/components/ui/Input.tsx +1 -1
  153. package/src/components/ui/Menu.tsx +4 -2
  154. package/src/hooks/appHotkeysDispatch.ts +2 -1
  155. package/src/hooks/elementOffsetStager.ts +8 -7
  156. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  157. package/src/hooks/useAppHotkeys.test.ts +32 -0
  158. package/src/hooks/useAppHotkeys.ts +1 -2
  159. package/src/hooks/useClipboard.source.test.ts +141 -23
  160. package/src/hooks/useClipboard.ts +154 -123
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.test.tsx +76 -72
  163. package/src/hooks/useDomEditSession.ts +7 -43
  164. package/src/hooks/useDomGeometryCommit.test.tsx +4 -1
  165. package/src/hooks/useDomGeometryCommit.ts +0 -1
  166. package/src/hooks/useDomGeometryCommits.test.tsx +177 -32
  167. package/src/hooks/useDomGeometryCommits.ts +4 -49
  168. package/src/hooks/useGsapTweenCache.ts +1 -2
  169. package/src/hooks/useInlineTextEdit.ts +16 -2
  170. package/src/hooks/useLivePreviewIframe.ts +18 -0
  171. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  172. package/src/hooks/usePreviewPersistence.ts +2 -5
  173. package/src/hooks/useRenderClipContent.ts +3 -3
  174. package/src/index.ts +2 -0
  175. package/src/livePreviewIframeExport.test.tsx +33 -0
  176. package/src/main.tsx +4 -1
  177. package/src/player/components/AudioWaveform.test.tsx +36 -15
  178. package/src/player/components/AudioWaveform.tsx +24 -7
  179. package/src/player/components/ClipContextMenu.tsx +1 -1
  180. package/src/player/components/CompositionThumbnail.tsx +23 -11
  181. package/src/player/components/EditModal.tsx +6 -6
  182. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  183. package/src/player/components/PlayerControls.tsx +3 -3
  184. package/src/player/components/ShortcutsPanel.tsx +8 -20
  185. package/src/player/components/TimelineCanvas.tsx +1 -1
  186. package/src/player/components/TimelineClip.test.tsx +38 -0
  187. package/src/player/components/TimelineClip.tsx +16 -8
  188. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  189. package/src/player/components/TimelineClipFades.tsx +368 -137
  190. package/src/player/components/TimelineEmptyState.tsx +2 -2
  191. package/src/player/components/TimelineLanes.tsx +0 -1
  192. package/src/player/components/TrackClipCount.tsx +1 -1
  193. package/src/player/components/VolumeControl.tsx +1 -1
  194. package/src/player/components/timelineClipChildren.tsx +1 -1
  195. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  196. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  197. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  198. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  199. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  200. package/src/player/lib/automationStoreSync.test.ts +18 -1
  201. package/src/player/lib/automationStoreSync.ts +29 -17
  202. package/src/player/lib/thumbnailScheduler.ts +4 -5
  203. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  204. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  205. package/src/player/sceneSwap.ts +18 -0
  206. package/src/styles/components.css +26 -0
  207. package/src/styles/hex-baseline.json +7 -19
  208. package/src/styles/studio.css +4 -7
  209. package/src/styles/tailwind-preset.shared.js +2 -1
  210. package/src/styles/theme.css +214 -147
  211. package/src/styles/theme.test.ts +13 -9
  212. package/src/utils/blockCategories.ts +41 -9
  213. package/src/utils/clipboardPayload.ts +12 -1
  214. package/src/utils/studioPendingEdits.ts +21 -0
  215. package/src/utils/studioTheme.test.tsx +141 -0
  216. package/src/utils/studioTheme.ts +34 -0
  217. package/src/utils/studioUiPreferences.ts +3 -0
  218. package/src/utils/typingTarget.test.ts +8 -1
  219. package/src/utils/typingTarget.ts +5 -0
  220. package/dist/assets/index-CQN3M56N.css +0 -1
  221. package/dist/assets/index-De2aO4XM.js +0 -399
  222. package/dist/chunk-62DDWOSH.js.map +0 -1
  223. package/dist/chunk-6GAO75IU.js.map +0 -1
  224. package/dist/chunk-HIGHVWYD.js.map +0 -1
  225. package/src/hooks/gsapTargetCache.ts +0 -62
@@ -8,15 +8,47 @@ export type { BlockCategory };
8
8
  export { BLOCK_CATEGORIES, resolveBlockCategory };
9
9
 
10
10
  const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }> = {
11
- transitions: { bg: "bg-blue-500/15", text: "text-blue-400", dot: "bg-blue-400" },
12
- vfx: { bg: "bg-purple-500/15", text: "text-purple-400", dot: "bg-purple-400" },
13
- social: { bg: "bg-pink-500/15", text: "text-pink-400", dot: "bg-pink-400" },
14
- data: { bg: "bg-green-500/15", text: "text-green-400", dot: "bg-green-400" },
15
- scenes: { bg: "bg-amber-500/15", text: "text-amber-400", dot: "bg-amber-400" },
16
- captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
17
- effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
18
- "text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
19
- "code-animation": { bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" },
11
+ transitions: {
12
+ bg: "bg-blue-500/15",
13
+ text: "text-[light-dark(oklch(0.48_0.12_255),var(--color-blue-400))]",
14
+ dot: "bg-[light-dark(oklch(0.48_0.12_255),var(--color-blue-400))]",
15
+ },
16
+ vfx: {
17
+ bg: "bg-purple-500/15",
18
+ text: "text-[light-dark(oklch(0.48_0.12_305),var(--color-purple-400))]",
19
+ dot: "bg-[light-dark(oklch(0.48_0.12_305),var(--color-purple-400))]",
20
+ },
21
+ social: {
22
+ bg: "bg-pink-500/15",
23
+ text: "text-[light-dark(oklch(0.48_0.12_350),var(--color-pink-400))]",
24
+ dot: "bg-[light-dark(oklch(0.48_0.12_350),var(--color-pink-400))]",
25
+ },
26
+ data: {
27
+ bg: "bg-green-500/15",
28
+ text: "text-[light-dark(oklch(0.48_0.12_152),var(--color-green-400))]",
29
+ dot: "bg-[light-dark(oklch(0.48_0.12_152),var(--color-green-400))]",
30
+ },
31
+ scenes: { bg: "bg-amber-500/15", text: "text-warning-ink", dot: "bg-warning-ink" },
32
+ captions: {
33
+ bg: "bg-cyan-500/15",
34
+ text: "text-[light-dark(oklch(0.48_0.12_212),var(--color-cyan-400))]",
35
+ dot: "bg-[light-dark(oklch(0.48_0.12_212),var(--color-cyan-400))]",
36
+ },
37
+ effects: {
38
+ bg: "bg-rose-500/15",
39
+ text: "text-[light-dark(oklch(0.48_0.12_13),var(--color-rose-400))]",
40
+ dot: "bg-[light-dark(oklch(0.48_0.12_13),var(--color-rose-400))]",
41
+ },
42
+ "text-effects": {
43
+ bg: "bg-violet-500/15",
44
+ text: "text-[light-dark(oklch(0.48_0.12_294),var(--color-violet-400))]",
45
+ dot: "bg-[light-dark(oklch(0.48_0.12_294),var(--color-violet-400))]",
46
+ },
47
+ "code-animation": {
48
+ bg: "bg-emerald-500/15",
49
+ text: "text-[light-dark(oklch(0.48_0.12_163),var(--color-emerald-400))]",
50
+ dot: "bg-[light-dark(oklch(0.48_0.12_163),var(--color-emerald-400))]",
51
+ },
20
52
  };
21
53
 
22
54
  export function getCategoryColors(category: BlockCategory) {
@@ -17,11 +17,13 @@ export type ClipboardPayload =
17
17
  clips: TimelineClipboardClip[];
18
18
  sourceFile: string;
19
19
  projectId?: string;
20
+ copiedInComposition?: string;
20
21
  }
21
22
  | {
22
23
  kind: "dom-element";
23
24
  html: string;
24
25
  sourceFile: string;
26
+ projectId?: string;
25
27
  originSelector?: string;
26
28
  originSelectorIndex?: number;
27
29
  };
@@ -62,7 +64,15 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
62
64
  });
63
65
  if (clips.length === 0) return null;
64
66
  const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined;
65
- return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile, projectId };
67
+ const copiedInComposition =
68
+ typeof obj.copiedInComposition === "string" ? obj.copiedInComposition : undefined;
69
+ return {
70
+ kind: "timeline-clip",
71
+ clips,
72
+ sourceFile: obj.sourceFile,
73
+ projectId,
74
+ copiedInComposition,
75
+ };
66
76
  }
67
77
  if (obj.kind === "dom-element") {
68
78
  if (typeof obj.html !== "string") return null;
@@ -70,6 +80,7 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
70
80
  kind: "dom-element",
71
81
  html: obj.html,
72
82
  sourceFile: obj.sourceFile,
83
+ projectId: typeof obj.projectId === "string" ? obj.projectId : undefined,
73
84
  originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
74
85
  originSelectorIndex:
75
86
  typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
@@ -39,6 +39,27 @@ export function hasStudioPendingEdits(): boolean {
39
39
  return pendingEditPromises.size > 0 || focusedField() !== null;
40
40
  }
41
41
 
42
+ export function isStudioEditSaving(): boolean {
43
+ return pendingEditPromises.size > 0;
44
+ }
45
+
46
+ export function afterStudioPendingEdits(run: () => void): () => void {
47
+ let waiting = true;
48
+ const check = () => {
49
+ if (!waiting) return;
50
+ if (isStudioEditSaving()) {
51
+ void Promise.allSettled([...pendingEditPromises]).then(check);
52
+ return;
53
+ }
54
+ waiting = false;
55
+ run();
56
+ };
57
+ check();
58
+ return () => {
59
+ waiting = false;
60
+ };
61
+ }
62
+
42
63
  export function trackStudioPendingEdit(
43
64
  result: Promise<unknown> | unknown,
44
65
  ): Promise<unknown> | undefined {
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { act } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { ThemeToggle } from "../components/ThemeToggle";
7
+ import { cleanupMounted, mountHost } from "../components/ui/mountHost.testHelpers";
8
+ import { savedStudioTheme, shownStudioTheme } from "./studioTheme";
9
+
10
+ const KEY = "hf-studio-ui-preferences";
11
+ const page = new DOMParser().parseFromString(
12
+ readFileSync(path.join(__dirname, "../../index.html"), "utf8"),
13
+ "text/html",
14
+ );
15
+ const scripts = [...page.querySelectorAll("script")];
16
+ const boot = scripts.find((script) => !script.type)?.textContent ?? "";
17
+
18
+ function store(theme: unknown) {
19
+ localStorage.setItem(KEY, JSON.stringify({ theme }));
20
+ }
21
+
22
+ function boots(): "light" | "dark" {
23
+ new Function(boot)();
24
+ return shownStudioTheme();
25
+ }
26
+
27
+ afterEach(() => {
28
+ cleanupMounted();
29
+ localStorage.clear();
30
+ delete document.documentElement.dataset.theme;
31
+ });
32
+
33
+ describe("Studio's theme", () => {
34
+ it.each([["light"], ["dark"], [undefined], ["neon"]])(
35
+ "boots to the theme the owner picks (saved %s)",
36
+ (saved) => {
37
+ store(saved);
38
+ expect(boots()).toBe(savedStudioTheme());
39
+ },
40
+ );
41
+
42
+ it.each([["null"], ["{not json"], ['"dark"']])("boots to the owner's pick from %s", (raw) => {
43
+ localStorage.setItem(KEY, raw);
44
+ expect(boots()).toBe(savedStudioTheme());
45
+ });
46
+
47
+ it("opens light the first time", () => {
48
+ expect(boots()).toBe("light");
49
+ expect(savedStudioTheme()).toBe("light");
50
+ });
51
+
52
+ it("runs the boot script before the app's module", () => {
53
+ expect(boot).toContain(`"${KEY}"`);
54
+ expect(scripts.findIndex((script) => !script.type)).toBeLessThan(
55
+ scripts.findIndex((script) => script.type === "module"),
56
+ );
57
+ });
58
+
59
+ it("flips the document between light and dark and keeps the choice", async () => {
60
+ store("dark");
61
+ const host = mountHost(<ThemeToggle />);
62
+ const button = () => host.querySelector("button")!;
63
+ expect(button().getAttribute("aria-label")).toBe("Switch to light theme");
64
+
65
+ await act(async () => button().click());
66
+ expect(document.documentElement.dataset.theme).toBe("paper");
67
+ expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("light");
68
+ expect(button().getAttribute("aria-label")).toBe("Switch to dark theme");
69
+
70
+ await act(async () => button().click());
71
+ expect(document.documentElement.dataset.theme).toBeUndefined();
72
+ expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("dark");
73
+ });
74
+
75
+ it("follows the theme on screen, not only its own clicks", async () => {
76
+ store("light");
77
+ const host = mountHost(<ThemeToggle />);
78
+ expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to light theme");
79
+ await act(async () => {
80
+ document.documentElement.dataset.theme = "paper";
81
+ });
82
+ expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to dark theme");
83
+ });
84
+ });
85
+
86
+ describe("the reveal", () => {
87
+ function stubTransition() {
88
+ const animate = vi.fn();
89
+ Object.assign(document.documentElement, { animate });
90
+ const markers: (string | undefined)[] = [];
91
+ const finish: (() => void)[] = [];
92
+ const start = vi.fn((update: () => void) => {
93
+ markers.push(document.documentElement.dataset.themeReveal);
94
+ update();
95
+ return { ready: Promise.resolve(), finished: new Promise<void>((done) => finish.push(done)) };
96
+ });
97
+ Object.assign(document, { startViewTransition: start });
98
+ return { animate, start, markers, finish };
99
+ }
100
+
101
+ afterEach(() => {
102
+ Reflect.deleteProperty(document, "startViewTransition");
103
+ Reflect.deleteProperty(document.documentElement, "animate");
104
+ vi.unstubAllGlobals();
105
+ });
106
+
107
+ it("grows from the button's centre when the keyboard presses it, marked while it runs", async () => {
108
+ const { animate, markers, finish } = stubTransition();
109
+ vi.stubGlobal("innerWidth", 1000);
110
+ vi.stubGlobal("innerHeight", 500);
111
+ const button = mountHost(<ThemeToggle />).querySelector("button")!;
112
+ button.getBoundingClientRect = () => ({ left: 890, top: 10, width: 20, height: 20 }) as DOMRect;
113
+ await act(async () => {
114
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
115
+ });
116
+ expect(markers).toEqual([""]);
117
+ expect(animate.mock.calls[0]![0].clipPath[1]).toMatch(/^circle\(129\.0\d*% at 90% 4%\)$/);
118
+ await act(async () => finish[0]!());
119
+ expect(document.documentElement.dataset.themeReveal).toBeUndefined();
120
+ });
121
+
122
+ it("keeps the marker for a second reveal that starts before the first ends", async () => {
123
+ const { finish } = stubTransition();
124
+ const button = mountHost(<ThemeToggle />).querySelector("button")!;
125
+ await act(async () => button.click());
126
+ await act(async () => button.click());
127
+ await act(async () => finish[0]!());
128
+ expect(document.documentElement.dataset.themeReveal).toBe("");
129
+ await act(async () => finish[1]!());
130
+ expect(document.documentElement.dataset.themeReveal).toBeUndefined();
131
+ });
132
+
133
+ it("switches without the reveal under reduced motion", async () => {
134
+ const { start } = stubTransition();
135
+ vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduce") }));
136
+ const button = mountHost(<ThemeToggle />).querySelector("button")!;
137
+ await act(async () => button.click());
138
+ expect(start).not.toHaveBeenCalled();
139
+ expect(document.documentElement.dataset.theme).toBe("paper");
140
+ });
141
+ });
@@ -0,0 +1,34 @@
1
+ // Studio's own app opens on the toggle's saved choice, else light. Light is `data-theme="paper"`
2
+ // on the document element, the hook theme.css keys on; dark is no attribute. index.html applies
3
+ // `savedStudioTheme` before the first paint; studioTheme.test.tsx holds the two together.
4
+ import { useSyncExternalStore } from "react";
5
+ import {
6
+ readStudioUiPreferences,
7
+ writeStudioUiPreferences,
8
+ type StudioTheme,
9
+ } from "./studioUiPreferences";
10
+
11
+ export function savedStudioTheme(): StudioTheme {
12
+ return readStudioUiPreferences().theme ?? "light";
13
+ }
14
+
15
+ export function shownStudioTheme(): StudioTheme {
16
+ return document.documentElement.dataset.theme === "paper" ? "light" : "dark";
17
+ }
18
+
19
+ export function setStudioTheme(theme: StudioTheme): void {
20
+ writeStudioUiPreferences({ theme });
21
+ const root = document.documentElement;
22
+ if (theme === "light") root.dataset.theme = "paper";
23
+ else delete root.dataset.theme;
24
+ }
25
+
26
+ function subscribe(listener: () => void): () => void {
27
+ const observer = new MutationObserver(listener);
28
+ observer.observe(document.documentElement, { attributeFilter: ["data-theme"] });
29
+ return () => observer.disconnect();
30
+ }
31
+
32
+ export function useShownStudioTheme(): StudioTheme {
33
+ return useSyncExternalStore(subscribe, shownStudioTheme);
34
+ }
@@ -2,6 +2,7 @@ import type { SerializedDockview } from "dockview-react";
2
2
  import { parseDockLayout } from "../components/dock/dockLayoutSchema";
3
3
 
4
4
  export type TimelineTimeDisplayMode = "time" | "frame";
5
+ export type StudioTheme = "light" | "dark";
5
6
 
6
7
  export interface StudioUiPreferences {
7
8
  timelineVisible?: boolean;
@@ -41,6 +42,7 @@ export interface StudioUiPreferences {
41
42
  * intentionally scoped to one mount.
42
43
  */
43
44
  agentToolsEnabled?: boolean;
45
+ theme?: StudioTheme;
44
46
  /** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
45
47
  dockLayout?: SerializedDockview;
46
48
  }
@@ -128,6 +130,7 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
128
130
  if (typeof parsed.rippleEditEnabled === "boolean") {
129
131
  preferences.rippleEditEnabled = parsed.rippleEditEnabled;
130
132
  }
133
+ if (parsed.theme === "light" || parsed.theme === "dark") preferences.theme = parsed.theme;
131
134
  if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
132
135
  preferences.timeDisplayMode = parsed.timeDisplayMode;
133
136
  }
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
- import { isTypingTarget } from "./typingTarget";
4
+ import { isTypingTarget, ownsPlainKeys } from "./typingTarget";
5
5
  import { isEditableTarget } from "./timelineDiscovery";
6
6
 
7
7
  afterEach(() => {
@@ -41,6 +41,13 @@ describe("isTypingTarget", () => {
41
41
  expect(isTypingTarget(mount('<div role="combobox"></div>'))).toBe(true);
42
42
  });
43
43
 
44
+ it("gives a focused slider its plain keys but leaves Cmd shortcuts to the app", () => {
45
+ const slider = mount('<div role="slider" tabindex="0"></div>');
46
+ expect(ownsPlainKeys(slider)).toBe(true);
47
+ // Undo, copy and group still reach the app while a slider has focus.
48
+ expect(isTypingTarget(slider)).toBe(false);
49
+ });
50
+
44
51
  it("leaves the keys alone for anything that is not being typed into", () => {
45
52
  expect(isTypingTarget(mount("<div>plain</div>"))).toBe(false);
46
53
  expect(isTypingTarget(mount("<button>press</button>"))).toBe(false);
@@ -21,6 +21,11 @@ export function isTypingTarget(target: EventTarget | null): boolean {
21
21
  return element.closest(TYPING_SELECTOR) !== null;
22
22
  }
23
23
 
24
+ /** Plain keys (arrows, Delete) also belong to a focused slider; Cmd shortcuts still do not. */
25
+ export function ownsPlainKeys(target: EventTarget | null): boolean {
26
+ return isTypingTarget(target) || asElement(target)?.closest("[role='slider']") != null;
27
+ }
28
+
24
29
  /**
25
30
  * Things a keystroke belongs to rather than to a shortcut. `contenteditable` is
26
31
  * matched by value as well, for a host whose own property is not yet true.