@hyperframes/studio 0.8.105 → 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 (79) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
  3. package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
  7. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  8. package/dist/index.d.ts +15 -4
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +6601 -6022
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/captions/keyboard.ts +2 -1
  17. package/src/components/StudioHeader.dom.test.tsx +31 -9
  18. package/src/components/StudioHeader.tsx +4 -1
  19. package/src/components/ThemeToggle.tsx +99 -0
  20. package/src/components/editor/DomEditOverlay.tsx +37 -5
  21. package/src/components/editor/SourceEditor.tsx +2 -6
  22. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  23. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  24. package/src/components/editor/manualEditsDom.ts +33 -0
  25. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  26. package/src/components/editor/marqueeCommit.ts +6 -1
  27. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  28. package/src/components/editor/overlayFrameLoop.ts +21 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  30. package/src/components/editor/propertyPanelFont.tsx +69 -46
  31. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  32. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  33. package/src/components/editor/studioCodeTheme.ts +99 -0
  34. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  35. package/src/components/editor/useDomEditNudge.ts +2 -2
  36. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  37. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  38. package/src/components/editor/useMotionPathData.ts +5 -1
  39. package/src/hooks/appHotkeysDispatch.ts +2 -1
  40. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  41. package/src/hooks/useAppHotkeys.test.ts +32 -0
  42. package/src/hooks/useAppHotkeys.ts +1 -2
  43. package/src/hooks/useClipboard.source.test.ts +99 -19
  44. package/src/hooks/useClipboard.ts +145 -124
  45. package/src/hooks/useDomEditSession.test.tsx +76 -72
  46. package/src/hooks/useDomEditSession.ts +7 -43
  47. package/src/hooks/useGsapTweenCache.ts +1 -2
  48. package/src/hooks/useInlineTextEdit.ts +16 -2
  49. package/src/hooks/useLivePreviewIframe.ts +18 -0
  50. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  51. package/src/hooks/usePreviewPersistence.ts +2 -5
  52. package/src/hooks/useRenderClipContent.ts +3 -3
  53. package/src/index.ts +2 -0
  54. package/src/livePreviewIframeExport.test.tsx +33 -0
  55. package/src/main.tsx +4 -1
  56. package/src/player/components/AudioWaveform.test.tsx +36 -15
  57. package/src/player/components/AudioWaveform.tsx +24 -7
  58. package/src/player/components/TimelineClip.test.tsx +38 -0
  59. package/src/player/components/TimelineClip.tsx +14 -6
  60. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  61. package/src/player/components/TimelineClipFades.tsx +368 -137
  62. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  63. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  64. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  65. package/src/player/lib/automationStoreSync.test.ts +18 -1
  66. package/src/player/lib/automationStoreSync.ts +29 -17
  67. package/src/player/sceneSwap.ts +18 -0
  68. package/src/styles/components.css +26 -0
  69. package/src/styles/studio.css +6 -0
  70. package/src/styles/theme.css +10 -0
  71. package/src/utils/clipboardPayload.ts +10 -1
  72. package/src/utils/studioPendingEdits.ts +21 -0
  73. package/src/utils/studioTheme.test.tsx +141 -0
  74. package/src/utils/studioTheme.ts +34 -0
  75. package/src/utils/studioUiPreferences.ts +3 -0
  76. package/src/utils/typingTarget.test.ts +8 -1
  77. package/src/utils/typingTarget.ts +5 -0
  78. package/dist/assets/index-Cre9X4RS.css +0 -1
  79. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -22,7 +22,7 @@ function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
22
22
  // Header actions stay native Tab stops because they have no row-level shortcut.
23
23
  // The nearest interactive ancestor wins so their events never masquerade as row events.
24
24
  const target = event.target.closest<HTMLElement>(
25
- "button, input, select, textarea, a[href], [contenteditable], [data-timeline-focus-id]",
25
+ "button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
26
26
  );
27
27
  return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
28
28
  }
@@ -118,3 +118,30 @@ export function attachIframeAdapter(
118
118
  attachIframeWindow(api, win);
119
119
  return adapter;
120
120
  }
121
+
122
+ export function makePreview(body: string, query = ""): HTMLIFrameElement {
123
+ const iframe = makeFakeIframe(makeAdapterWindow().win);
124
+ iframe.src = `http://localhost/api/projects/demo/preview${query}`;
125
+ iframe.contentDocument!.body.innerHTML = body;
126
+ return iframe;
127
+ }
128
+
129
+ export function mountPlayerWithPreview(live: HTMLIFrameElement) {
130
+ const harness = renderTimelinePlayerHarness();
131
+ act(() => {
132
+ harness.getApi().iframeRef.current = live;
133
+ harness.getApi().onIframeLoad();
134
+ });
135
+ return harness;
136
+ }
137
+
138
+ export async function paintShadow(getApi: () => TimelinePlayerApi, shadow: HTMLIFrameElement) {
139
+ const slot = getApi().previewSlots.find((s) => s.role === "shadow");
140
+ if (!slot) throw new Error("no shadow reload is pending");
141
+ act(() => getApi().setShadowIframeNode(shadow));
142
+ await act(async () => {
143
+ getApi().onShadowIframeLoad(slot.gen);
144
+ getApi().onShadowReadyChange(slot.gen, true);
145
+ });
146
+ return slot.gen;
147
+ }
@@ -3,6 +3,11 @@
3
3
 
4
4
  import { useCallback, useRef, useState } from "react";
5
5
  import { useMountEffect } from "../../hooks/useMountEffect";
6
+ import {
7
+ afterStudioManualEditGestures,
8
+ isStudioManualEditGestureLiveIn,
9
+ studioManualEditSavesIn,
10
+ } from "../../components/editor/manualEditsDom";
6
11
  import { logReload } from "../../utils/reloadDebug";
7
12
  import {
8
13
  useTimelineSyncCallbacks,
@@ -14,6 +19,8 @@ import {
14
19
  } from "./useTimelineSyncCallbacks";
15
20
  import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
16
21
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
22
+ import { announcePreviewPromoted } from "../sceneSwap";
23
+ import { afterStudioPendingEdits, isStudioEditSaving } from "../../utils/studioPendingEdits";
17
24
  import { usePlayerStore } from "../store/playerStore";
18
25
 
19
26
  // One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
@@ -78,12 +85,18 @@ export function useShadowPreviewReload({
78
85
  // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
79
86
  const budgetGenRef = useRef<number | null>(null);
80
87
  const cancelPendingLoadRef = useRef<() => void>(() => {});
88
+ const stopHoldRef = useRef<() => void>(() => {});
89
+ const shadowUrlRef = useRef("");
90
+ const savesAtRequestRef = useRef(0);
91
+ const beginShadowReloadRef = useRef<(url: string) => void>(() => {});
92
+ const promoteWhenReadyRef = useRef<(gen: number) => void>(() => {});
81
93
  const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
82
94
 
83
95
  const stopPendingShadow = useCallback(() => {
84
96
  clearTimeout(readyTimerRef.current);
85
97
  budgetGenRef.current = null;
86
98
  cancelPendingLoadRef.current();
99
+ stopHoldRef.current();
87
100
  pendingCommitRef.current = null;
88
101
  visuallyReadyGenRef.current = null;
89
102
  }, []);
@@ -106,12 +119,58 @@ export function useShadowPreviewReload({
106
119
  [stopPendingShadow, isRefreshingRef, pendingSeekRef],
107
120
  );
108
121
 
122
+ const gestureSavesOnScreen = useCallback(() => {
123
+ const liveDoc = iframeRef.current?.contentDocument;
124
+ return liveDoc ? studioManualEditSavesIn(liveDoc) : 0;
125
+ }, [iframeRef]);
126
+
127
+ const waitThenRetry = useCallback(
128
+ (gen: number, why: string, wait: (retry: () => void) => () => void) => {
129
+ clearTimeout(readyTimerRef.current);
130
+ budgetGenRef.current = null;
131
+ stopHoldRef.current();
132
+ logReload("shadow-held", { gen, why });
133
+ stopHoldRef.current = wait(() => {
134
+ logReload("shadow-hold-ended", { gen, why });
135
+ promoteWhenReadyRef.current(gen);
136
+ });
137
+ return true;
138
+ },
139
+ [],
140
+ );
141
+
142
+ // Nothing replaces the preview under a gesture, and a shadow requested before a gesture's save
143
+ // shows the file before it: once that save has landed, a fresh one loads instead.
144
+ const heldOrStale = useCallback(
145
+ (gen: number) => {
146
+ const liveDoc = iframeRef.current?.contentDocument;
147
+ if (liveDoc && isStudioManualEditGestureLiveIn(liveDoc))
148
+ return waitThenRetry(gen, "gesture", (retry) =>
149
+ afterStudioManualEditGestures(liveDoc, retry),
150
+ );
151
+ if (gestureSavesOnScreen() === savesAtRequestRef.current) return false;
152
+ if (isStudioEditSaving()) return waitThenRetry(gen, "save", afterStudioPendingEdits);
153
+ logReload("shadow-reloaded-after-gesture-save", { gen });
154
+ const url = new URL(shadowUrlRef.current, window.location.origin);
155
+ url.searchParams.set("_t", String(Date.now()));
156
+ beginShadowReloadRef.current(url.toString());
157
+ return true;
158
+ },
159
+ [iframeRef, gestureSavesOnScreen, waitThenRetry],
160
+ );
161
+
162
+ const shadowReadyToPromote = useCallback((gen: number) => {
163
+ const shadow = shadowIframeRef.current;
164
+ const pending = pendingCommitRef.current;
165
+ const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
166
+ return shadow && pending && ready && gen === shadowGenRef.current ? { shadow, pending } : null;
167
+ }, []);
168
+
109
169
  const promoteWhenReady = useCallback(
110
170
  (gen: number) => {
111
- const shadow = shadowIframeRef.current;
112
- const pending = pendingCommitRef.current;
113
- const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
114
- if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
171
+ const promotable = shadowReadyToPromote(gen);
172
+ if (!promotable || heldOrStale(gen)) return;
173
+ const { shadow, pending } = promotable;
115
174
  stopPendingShadow();
116
175
  // The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
117
176
  const live = getAdapter();
@@ -119,6 +178,7 @@ export function useShadowPreviewReload({
119
178
  const playing = usePlayerStore.getState().isPlaying;
120
179
  live?.pause();
121
180
  // The store takes the new document's timeline only now that it is the one on screen, and reads it there.
181
+ const retired = iframeRef.current;
122
182
  iframeRef.current = shadow;
123
183
  pending.commit();
124
184
  shadowIframeRef.current = null;
@@ -126,17 +186,21 @@ export function useShadowPreviewReload({
126
186
  applyPreviewAudioState();
127
187
  setPreviewSlots((prev) => planShadowPromotion(prev, gen));
128
188
  if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
189
+ announcePreviewPromoted({ retired, live: shadow });
129
190
  onPromotedRef.current?.();
130
191
  thumbnailScheduler.setPreviewReloading(false);
131
192
  },
132
193
  [
133
194
  stopPendingShadow,
195
+ shadowReadyToPromote,
196
+ heldOrStale,
134
197
  getAdapter,
135
198
  iframeRef,
136
199
  attachIframeShortcutListeners,
137
200
  applyPreviewAudioState,
138
201
  ],
139
202
  );
203
+ promoteWhenReadyRef.current = promoteWhenReady;
140
204
 
141
205
  const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
142
206
  const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
@@ -216,13 +280,16 @@ export function useShadowPreviewReload({
216
280
  stopPendingShadow();
217
281
  budgetGenRef.current = gen;
218
282
  budgetsSpentRef.current = 0;
283
+ shadowUrlRef.current = url;
284
+ savesAtRequestRef.current = gestureSavesOnScreen();
219
285
  armReadyTimer(gen);
220
286
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
221
287
  // Thumbnails of the edit wait for the new preview instead of competing with it.
222
288
  thumbnailScheduler.setPreviewReloading(true);
223
289
  },
224
- [stopPendingShadow, armReadyTimer],
290
+ [stopPendingShadow, armReadyTimer, gestureSavesOnScreen],
225
291
  );
292
+ beginShadowReloadRef.current = beginShadowReload;
226
293
 
227
294
  // Composition switch (not an edit reload): drop any in-flight shadow.
228
295
  const resetPreviewSlots = useCallback(() => {
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
- import { syncStoredAutomationFromPreview } from "./automationStoreSync";
3
+ import { syncStoredAutomationFromPreview, syncStoredElementAttribute } from "./automationStoreSync";
4
4
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
5
 
6
6
  const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
@@ -32,7 +32,24 @@ beforeEach(() => {
32
32
  usePlayerStore.getState().reset();
33
33
  });
34
34
 
35
+ describe("syncStoredElementAttribute", () => {
36
+ it("records a saved fade on the stored clip, so the timeline draws what the file holds", () => {
37
+ usePlayerStore.setState({ elements: [el({ fadeIn: 1 })] });
38
+ syncStoredElementAttribute(el(), "data-fade-in", "2.5");
39
+ expect(usePlayerStore.getState().elements[0]?.fadeIn).toBe(2.5);
40
+ syncStoredElementAttribute(el(), "data-fade-in", null);
41
+ expect(usePlayerStore.getState().elements[0]?.fadeIn).toBeUndefined();
42
+ });
43
+ });
44
+
35
45
  describe("syncStoredAutomationFromPreview", () => {
46
+ it("reads back a fade an undo restored on the preview", () => {
47
+ usePlayerStore.setState({ elements: [el({ fadeOut: 2 })] });
48
+ syncStoredAutomationFromPreview(previewWith({ "data-fade-in": "0.6" }));
49
+ const stored = usePlayerStore.getState().elements[0];
50
+ expect([stored?.fadeIn, stored?.fadeOut]).toEqual([0.6, undefined]);
51
+ });
52
+
36
53
  it("reads the element the timeline bound, not an earlier same-id copy in a sub-composition", () => {
37
54
  usePlayerStore.setState({ elements: [el({ hfId: "hf-root" })] });
38
55
  const doc = document.implementation.createHTMLDocument("preview");
@@ -16,12 +16,18 @@
16
16
 
17
17
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
18
18
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
19
+ import {
20
+ HF_AUDIO_FADE_IN_ATTR,
21
+ HF_AUDIO_FADE_OUT_ATTR,
22
+ readElementFades,
23
+ readFadeSeconds,
24
+ } from "@hyperframes/core/audio-fade";
19
25
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
20
26
  import { groupInfoFor } from "./timelineGroupInfo";
21
27
  import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
22
28
 
23
29
  /**
24
- * Re-read every element's automation and FX-chain attributes from the preview
30
+ * Re-read every element's automation, FX-chain and fade attributes from the preview
25
31
  * document, for a change that reached the DOM without going through this store.
26
32
  *
27
33
  * That is undo and redo. A soft restore patches the reverted attributes onto the
@@ -34,21 +40,21 @@ import { getTimelineElementIdentity, previewElementFinder } from "./timelineElem
34
40
  * reverted is only known by looking.
35
41
  */
36
42
  /**
37
- * What an element's four synced fields SHOULD read, given the preview.
43
+ * What an element's six synced fields SHOULD read, given the preview.
38
44
  *
39
- * Its own two come off its node; the other two are its copy of what its group
45
+ * Its own four come off its node; the other two are its copy of what its group
40
46
  * carries. The timeline derives a group's lanes and chain from these mirrors,
41
- * never from the group element — and the FX rack is not group-aware: selecting
42
- * a group and automating one of its parameters writes `data-automation` on the
43
- * group node through the ordinary element path, which used to refresh an
44
- * element's own two fields and nothing else. So the group's row went on reading
45
- * the value it was born with, and its `∿` never appeared.
47
+ * never from the group element, and automating a group writes `data-automation`
48
+ * on the group node through the ordinary element path, so both halves are re-read.
46
49
  */
47
50
  function syncedFields(doc: Document, element: TimelineElement, node: Element) {
48
51
  const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
52
+ const fades = readElementFades(node);
49
53
  return {
50
54
  automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
51
55
  fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
56
+ fadeIn: storedFade(fades.fadeIn),
57
+ fadeOut: storedFade(fades.fadeOut),
52
58
  audioGroupAutomation: group?.automation,
53
59
  audioGroupFxChain: group?.fxChain,
54
60
  };
@@ -74,27 +80,33 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
74
80
  });
75
81
  }
76
82
 
77
- const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
78
- [HF_AUDIO_AUTOMATION_ATTR]: "automation",
79
- [HF_AUDIO_FX_ATTR]: "fxChain",
83
+ /** Discovery's rule (applyFadeMetadataFromElement): a fade of 0 is no fade. */
84
+ const storedFade = (seconds: number) => (seconds > 0 ? seconds : undefined);
85
+
86
+ const STORED_FIELD: Record<string, (value: string | null) => Partial<TimelineElement>> = {
87
+ [HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ automation: value ?? undefined }),
88
+ [HF_AUDIO_FX_ATTR]: (value) => ({ fxChain: value ?? undefined }),
89
+ [HF_AUDIO_FADE_IN_ATTR]: (value) => ({ fadeIn: storedFade(readFadeSeconds(value)) }),
90
+ [HF_AUDIO_FADE_OUT_ATTR]: (value) => ({ fadeOut: storedFade(readFadeSeconds(value)) }),
80
91
  };
81
92
 
82
- /** Record a saved automation or FX-chain value on one element's stored copy. */
93
+ /** Record a saved automation, FX-chain or fade value on one element's stored copy. */
83
94
  export function syncStoredElementAttribute(
84
95
  target: TimelineElement,
85
96
  attr: string,
86
97
  value: string | null,
87
98
  ): void {
88
- const field = STORED_FIELD[attr];
89
- if (!field) return;
99
+ const fields = STORED_FIELD[attr]?.(value);
100
+ if (!fields) return;
90
101
  const key = getTimelineElementIdentity(target);
91
- const next = value ?? undefined;
102
+ const same = (element: TimelineElement) =>
103
+ (Object.keys(fields) as (keyof TimelineElement)[]).every((k) => element[k] === fields[k]);
92
104
  usePlayerStore.setState((state) => {
93
105
  let changed = false;
94
106
  const elements = state.elements.map((element) => {
95
- if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
107
+ if (getTimelineElementIdentity(element) !== key || same(element)) return element;
96
108
  changed = true;
97
- return { ...element, [field]: next };
109
+ return { ...element, ...fields };
98
110
  });
99
111
  return changed ? { elements } : {};
100
112
  });
@@ -17,6 +17,24 @@ export function onPreviewContentReplaced(
17
17
  };
18
18
  }
19
19
 
20
+ const PREVIEW_PROMOTED = "hf-preview-promoted";
21
+ export type PreviewPromotion = { retired: HTMLIFrameElement | null; live: HTMLIFrameElement };
22
+
23
+ export function announcePreviewPromoted(promotion: PreviewPromotion): void {
24
+ promotion.live.ownerDocument.dispatchEvent(
25
+ new CustomEvent(PREVIEW_PROMOTED, { detail: promotion }),
26
+ );
27
+ }
28
+
29
+ export function onPreviewPromoted(
30
+ doc: Document,
31
+ listener: (promotion: PreviewPromotion) => void,
32
+ ): () => void {
33
+ const handle = (event: Event) => listener((event as CustomEvent<PreviewPromotion>).detail);
34
+ doc.addEventListener(PREVIEW_PROMOTED, handle);
35
+ return () => doc.removeEventListener(PREVIEW_PROMOTED, handle);
36
+ }
37
+
20
38
  export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
21
39
  const doc = iframe?.contentDocument;
22
40
  const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
@@ -253,6 +253,32 @@
253
253
  border-radius: var(--timeline-clip-audio-radius);
254
254
  }
255
255
 
256
+ .timeline-fade-tab {
257
+ position: absolute;
258
+ border-radius: 2px;
259
+ background: var(--timeline-fade-dot);
260
+ box-shadow:
261
+ 0 0 0 1px var(--timeline-fade-dot-ring),
262
+ 0 1px 3px var(--timeline-fade-dot-ring);
263
+ transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
264
+ }
265
+
266
+ .timeline-fade-handle:hover .timeline-fade-tab,
267
+ .timeline-fade-handle[data-dragging] .timeline-fade-tab {
268
+ transform: scaleY(1.2);
269
+ }
270
+
271
+ .timeline-fade-handle:focus-visible .timeline-fade-tab {
272
+ outline: 2px solid var(--timeline-accent);
273
+ outline-offset: 2px;
274
+ }
275
+
276
+ @media (prefers-reduced-motion: reduce) {
277
+ .timeline-fade-tab {
278
+ transition: none;
279
+ }
280
+ }
281
+
256
282
  .timeline-clip.is-audio.is-hovered {
257
283
  background-color: var(--timeline-clip-audio-bg-hover);
258
284
  }
@@ -16,6 +16,12 @@
16
16
  }
17
17
  }
18
18
 
19
+ [data-theme-reveal]::view-transition-old(root),
20
+ [data-theme-reveal]::view-transition-new(root) {
21
+ animation: none;
22
+ mix-blend-mode: normal;
23
+ }
24
+
19
25
  body {
20
26
  margin: 0;
21
27
  padding: 0;
@@ -77,6 +77,16 @@
77
77
  --color-media: light-dark(oklch(0.5 0.1 215), #00e3ff);
78
78
  --color-container: light-dark(oklch(0.55 0.13 70), #f5a623);
79
79
 
80
+ /* Code editor syntax: One Light and One Dark hues, held at 4.5:1 on a line, the active line and a selection on it. */
81
+ --color-code-keyword: light-dark(oklch(0.509 0.211 329), oklch(0.829 0.164 318));
82
+ --color-code-name: light-dark(oklch(0.504 0.179 28), oklch(0.856 0.145 17));
83
+ --color-code-function: light-dark(oklch(0.492 0.193 263), oklch(0.805 0.121 245));
84
+ --color-code-constant: light-dark(oklch(0.495 0.13 50), oklch(0.815 0.11 50));
85
+ --color-code-type: light-dark(oklch(0.48 0.12 92), oklch(0.805 0.1 92));
86
+ --color-code-operator: light-dark(oklch(0.471 0.127 237), oklch(0.793 0.092 206));
87
+ --color-code-comment: light-dark(oklch(0.48 0.009 279), oklch(0.806 0.03 262));
88
+ --color-code-string: light-dark(oklch(0.468 0.142 143), oklch(0.793 0.11 133));
89
+
80
90
  /* The two stops of the HeyGen brand gradient, worn by the header logo. Fixed brand values
81
91
  * rather than roles, so they are not reachable as a background or text colour by mistake. */
82
92
  --color-brand-gradient-from: #06e3fa;
@@ -17,6 +17,7 @@ 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";
@@ -63,7 +64,15 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
63
64
  });
64
65
  if (clips.length === 0) return null;
65
66
  const projectId = typeof obj.projectId === "string" ? obj.projectId : undefined;
66
- 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
+ };
67
76
  }
68
77
  if (obj.kind === "dom-element") {
69
78
  if (typeof obj.html !== "string") return null;
@@ -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
  }