@hyperframes/studio 0.8.55 → 0.8.56

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 (82) hide show
  1. package/dist/assets/{hyperframes-player-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
  2. package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
  3. package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
  4. package/dist/assets/{index-BEIRytvV.js → index-DFlILHJe.js} +1 -1
  5. package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
  6. package/dist/index.d.ts +16 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3111 -2998
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +14 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +59 -50
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/editor/DomEditOverlay.tsx +19 -40
  18. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  19. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  20. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  21. package/src/components/editor/PropertyPanel.tsx +4 -3
  22. package/src/components/editor/SnapToolbar.test.tsx +1 -0
  23. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  25. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  26. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  27. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  29. package/src/components/editor/useDomEditNudge.ts +9 -0
  30. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  31. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  32. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  33. package/src/components/nle/PreviewOverlays.tsx +15 -8
  34. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  35. package/src/components/nle/TimelinePane.tsx +5 -1
  36. package/src/components/nle/previewReadOnly.test.tsx +128 -0
  37. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  38. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  39. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  40. package/src/hooks/appHotkeysDispatch.ts +16 -1
  41. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  42. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  43. package/src/hooks/timelineTrackVisibility.ts +7 -8
  44. package/src/hooks/useAppHotkeys.test.ts +71 -3
  45. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  46. package/src/hooks/useAppHotkeys.ts +4 -0
  47. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  48. package/src/hooks/useDomEditCommits.ts +3 -0
  49. package/src/hooks/useDomEditSession.test.tsx +1 -0
  50. package/src/hooks/useDomEditSession.ts +7 -4
  51. package/src/hooks/useDomEditWiring.ts +40 -40
  52. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  53. package/src/hooks/useDomGeometryCommits.ts +26 -20
  54. package/src/hooks/useGestureCommit.test.tsx +29 -0
  55. package/src/hooks/useGestureCommit.ts +23 -0
  56. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  57. package/src/hooks/useInlineTextEdit.ts +11 -2
  58. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  59. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  60. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  61. package/src/hooks/useTimelineEditing.ts +2 -7
  62. package/src/player/components/Timeline.tsx +22 -23
  63. package/src/player/components/TimelineClip.test.tsx +52 -0
  64. package/src/player/components/TimelineClip.tsx +13 -2
  65. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  66. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  67. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  68. package/src/player/components/timelineTheme.test.ts +4 -4
  69. package/src/player/components/timelineTheme.ts +34 -30
  70. package/src/player/components/timelineTrackDisplay.ts +1 -4
  71. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  72. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  73. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  74. package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
  75. package/src/player/store/playerStore.ts +0 -4
  76. package/src/player/store/timelineElement.ts +2 -4
  77. package/src/player/store/timelineResetState.ts +0 -1
  78. package/src/styles/hex-baseline.json +3 -4
  79. package/src/styles/studio.css +7 -7
  80. package/src/styles/theme.css +29 -0
  81. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  82. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
6
7
  import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -20,7 +21,7 @@ function heading(text = "Motion Playground"): HTMLElement {
20
21
  return element;
21
22
  }
22
23
 
23
- function mount(onCommit = vi.fn(), onPause = vi.fn()) {
24
+ function mount(onCommit = vi.fn(), onPause = vi.fn(), readOnly = false) {
24
25
  const controls: { current: InlineTextEditControls | null } = { current: null };
25
26
  function Probe() {
26
27
  controls.current = useInlineTextEdit({ onCommit, onPause });
@@ -29,7 +30,13 @@ function mount(onCommit = vi.fn(), onPause = vi.fn()) {
29
30
  const host = document.createElement("div");
30
31
  document.body.append(host);
31
32
  const root = createRoot(host);
32
- act(() => root.render(<Probe />));
33
+ act(() =>
34
+ root.render(
35
+ <PreviewReadOnlyProvider readOnly={readOnly}>
36
+ <Probe />
37
+ </PreviewReadOnlyProvider>,
38
+ ),
39
+ );
33
40
  return { controls: () => controls.current!, root, onCommit, onPause };
34
41
  }
35
42
 
@@ -552,4 +559,18 @@ describe("useInlineTextEdit with styled runs", () => {
552
559
  expect(insertText).toHaveBeenCalledWith("insertText", false, "plain words");
553
560
  act(() => root.unmount());
554
561
  });
562
+
563
+ it("refuses to open an edit while the preview is read-only", () => {
564
+ const element = heading();
565
+ const { controls, root, onCommit, onPause } = mount(vi.fn(), vi.fn(), true);
566
+ let opened = true;
567
+ act(() => {
568
+ opened = controls().start(element);
569
+ });
570
+ expect(opened).toBe(false);
571
+ expect(element.hasAttribute("contenteditable")).toBe(false);
572
+ expect(onPause).not.toHaveBeenCalled();
573
+ expect(onCommit).not.toHaveBeenCalled();
574
+ act(() => root.unmount());
575
+ });
555
576
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
3
+ import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
3
4
 
4
5
  /**
5
6
  * Editing an element's text where it sits, in the composition itself.
@@ -89,6 +90,7 @@ export function useInlineTextEdit({
89
90
  /** Stop playback, so the element is not animating under the caret. */
90
91
  onPause?: () => void;
91
92
  }): InlineTextEditControls {
93
+ const readOnly = usePreviewReadOnly();
92
94
  const [session, setSession] = useState<InlineTextEditSession | null>(null);
93
95
  // The teardown reads this rather than the state, so an exit path that runs
94
96
  // before React re-renders still sees the element it has to clean up.
@@ -123,7 +125,7 @@ export function useInlineTextEdit({
123
125
 
124
126
  const start = useCallback(
125
127
  (element: HTMLElement, caretAt?: { x: number; y: number }): boolean => {
126
- if (openRef.current) return false;
128
+ if (openRef.current || readOnly) return false;
127
129
 
128
130
  const open = {
129
131
  element,
@@ -156,7 +158,7 @@ export function useInlineTextEdit({
156
158
  framesRef.current = raf ?? null;
157
159
  return true;
158
160
  },
159
- [onPause],
161
+ [onPause, readOnly],
160
162
  );
161
163
 
162
164
  const commit = useCallback(() => {
@@ -179,6 +181,13 @@ export function useInlineTextEdit({
179
181
  teardown();
180
182
  }, [teardown]);
181
183
 
184
+ // Read-only can be enabled while an edit is already open. Close that
185
+ // session as a cancellation so the preview cannot keep committing through
186
+ // the now-disabled editing surface.
187
+ useEffect(() => {
188
+ if (readOnly && openRef.current) cancel();
189
+ }, [cancel, readOnly]);
190
+
182
191
  // The keys belong to the element, not to the document: the element lives in
183
192
  // the preview's own document, so a listener on Studio's would never see them.
184
193
  useEffect(() => {
@@ -103,7 +103,7 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
103
103
  expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
104
104
  });
105
105
 
106
- it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
106
+ it("measures the insert row against every manifest timeline row", async () => {
107
107
  const clip = (id: string, track: number): TimelineElement => ({
108
108
  id,
109
109
  key: id,
@@ -123,7 +123,6 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
123
123
  }),
124
124
  "</main>",
125
125
  ].join("\n");
126
- usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
127
126
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
128
127
  const getDrop = renderDropHook(source, writeProjectFile, [
129
128
  clip("a", 0),
@@ -136,8 +135,8 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
136
135
  });
137
136
 
138
137
  const [, written] = writeProjectFile.mock.calls[0] as [string, string];
139
- expect(written).toContain('id="b" data-start="0" data-track-index="2"');
140
- expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
138
+ expect(written).toContain('id="b" data-start="0" data-track-index="3"');
139
+ expect(written).toContain('id="hidden" data-start="0" data-track-index="2"');
141
140
  expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
142
141
  });
143
142
 
@@ -25,8 +25,6 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
25
25
  import { extendRootDurationInSource } from "../utils/rootDuration";
26
26
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
27
27
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
28
- import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
- import { usePlayerStore } from "../player/store/playerStore";
30
28
 
31
29
  /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
32
30
  function fileDropPlacement(
@@ -127,10 +125,7 @@ export function useTimelineAssetDropOps({
127
125
  const { source: sourceWithRoom, track } = resolveDropTrack({
128
126
  source: originalContent,
129
127
  // insertRow counts the rows the timeline shows, so plan against those.
130
- elements: selectTimelineRowElements(
131
- relevantElements,
132
- usePlayerStore.getState().topLevelIds,
133
- ),
128
+ elements: relevantElements,
134
129
  placement,
135
130
  dropped: {
136
131
  id: newId,
@@ -1561,21 +1561,24 @@ describe("useTimelineEditing: canEdit gate", () => {
1561
1561
  unmount();
1562
1562
  });
1563
1563
 
1564
- it("resolves toggle-track-hidden against the expanded rows, not the raw timelineElements prop", async () => {
1565
- // Regression: canEdit must resolve against the expanded rows
1566
- // (timelineTrackVisibility.ts's own invariant), not the raw
1567
- // timelineElements prop — proven by making the two disagree below.
1564
+ it("resolves toggle-track-hidden against the store-owned timeline rows", async () => {
1565
+ // The visibility hook reads the single store-owned row source. The editing
1566
+ // guard must receive that same source so a blocked row cannot fall through
1567
+ // to the write path.
1568
1568
  const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
1569
1569
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1570
1570
  usePlayerStore.getState().setElements([clip]);
1571
1571
  const showToast = vi.fn();
1572
1572
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1573
+ const canEdit = vi.fn((element: TimelineElement) =>
1574
+ element.id === "clip" ? { blocked: true as const, reason: "Reserved by an agent" } : true,
1575
+ );
1573
1576
  let hook: ReturnType<typeof useTimelineEditing> | null = null;
1574
1577
  function Harness() {
1575
1578
  hook = useTimelineEditing({
1576
1579
  projectId: "p1",
1577
1580
  activeCompPath: "index.html",
1578
- timelineElements: [], // deliberately does not contain `clip`
1581
+ timelineElements: [clip],
1579
1582
  showToast,
1580
1583
  writeProjectFile,
1581
1584
  recordEdit: vi.fn(),
@@ -1583,8 +1586,7 @@ describe("useTimelineEditing: canEdit gate", () => {
1583
1586
  previewIframeRef: { current: iframe },
1584
1587
  pendingTimelineEditPathRef: { current: new Set<string>() },
1585
1588
  uploadProjectFiles: vi.fn(),
1586
- canEdit: (element) =>
1587
- element.id === "clip" ? { blocked: true, reason: "Reserved by an agent" } : true,
1589
+ canEdit,
1588
1590
  });
1589
1591
  return null;
1590
1592
  }
@@ -1596,6 +1598,7 @@ describe("useTimelineEditing: canEdit gate", () => {
1596
1598
  await flushAsyncWork();
1597
1599
  });
1598
1600
 
1601
+ expect(canEdit).toHaveBeenCalledWith(clip);
1599
1602
  expect(showToast).toHaveBeenCalledWith("Reserved by an agent", "error");
1600
1603
  expect(writeProjectFile).not.toHaveBeenCalled();
1601
1604
  unmount();
@@ -25,7 +25,6 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
25
25
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
26
26
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
27
27
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
28
- import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
28
  import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
30
29
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
31
30
  import {
@@ -77,10 +76,6 @@ export function useTimelineEditing({
77
76
  const checkEditable = useTimelineEditGate(canEdit, showToast);
78
77
  const checkEditableRef = useRef(checkEditable);
79
78
  checkEditableRef.current = checkEditable;
80
- // Same expanded-row source the hide handlers themselves resolve against
81
- // (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
82
- // only exists here, not in the raw store list canEdit would otherwise miss.
83
- const timelineRowElements = useTimelineRowElements();
84
79
  const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
85
80
  // Refuses (no call, no write, no history entry) when any target is
86
81
  // blocked; otherwise runs fn as before. Cached by fn identity — like
@@ -514,14 +509,14 @@ export function useTimelineEditing({
514
509
  handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
515
510
  handleToggleTrackHidden: track(
516
511
  guard(
517
- (trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
512
+ (trackIndex) => timelineElements.filter((el) => el.track === trackIndex),
518
513
  handleToggleTrackHidden,
519
514
  ),
520
515
  ),
521
516
  handleToggleElementHidden: track(
522
517
  guard((elementKey) => {
523
518
  const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
524
- return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
519
+ return timelineElements.filter((el) => keys.has(el.key ?? el.id));
525
520
  }, handleToggleElementHidden),
526
521
  ),
527
522
  handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
@@ -1,7 +1,6 @@
1
1
  import { useRef, useMemo, useCallback, useState, memo } from "react";
2
2
  import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
3
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
- import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
5
4
  import { defaultTimelineTheme } from "./timelineTheme";
6
5
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
7
6
  import { usePublishRangeSelection } from "./usePublishRangeSelection";
@@ -113,7 +112,7 @@ export const Timeline = memo(function Timeline({
113
112
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
114
113
  useMusicBeatAnalysis();
115
114
  const rawElements = usePlayerStore((s) => s.elements);
116
- const expandedElements = useTimelineRowElements();
115
+ const timelineElements = rawElements;
117
116
  const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
118
117
  const duration = usePlayerStore((s) => s.duration);
119
118
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -123,8 +122,8 @@ export const Timeline = memo(function Timeline({
123
122
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
124
123
  const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
125
124
  const labelMode = useMemo(
126
- () => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
127
- [gsapAnimations, expandedElements],
125
+ () => timelineNeedsLabelColumn(gsapAnimations, timelineElements),
126
+ [gsapAnimations, timelineElements],
128
127
  );
129
128
  // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
130
129
  const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
@@ -163,13 +162,13 @@ export const Timeline = memo(function Timeline({
163
162
  groups,
164
163
  trackGroupOf,
165
164
  } = useTimelineTrackLayout(
166
- expandedElements,
165
+ timelineElements,
167
166
  gsapAnimations,
168
167
  selectedElementId,
169
168
  selectedElementIds,
170
169
  );
171
- const expandedElementsRef = useRef(expandedElements);
172
- expandedElementsRef.current = expandedElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
170
+ const timelineElementsRef = useRef(timelineElements);
171
+ timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
173
172
  const ppsRef = useRef(100);
174
173
  const durationRef = useRef(effectiveDuration);
175
174
  durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration
@@ -198,7 +197,7 @@ export const Timeline = memo(function Timeline({
198
197
  onCompositionDrop,
199
198
  });
200
199
  const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
201
- expandedElementsRef,
200
+ expandedElementsRef: timelineElementsRef,
202
201
  });
203
202
  const {
204
203
  gapMenuModel,
@@ -210,7 +209,7 @@ export const Timeline = memo(function Timeline({
210
209
  closeAllTrackGaps,
211
210
  } = useTrackGapMenu({
212
211
  tracks,
213
- expandedElementsRef,
212
+ expandedElementsRef: timelineElementsRef,
214
213
  trackOrderRef,
215
214
  onMoveElement: pinnedOnMoveElement,
216
215
  onMoveElements: pinnedOnMoveElements,
@@ -259,14 +258,14 @@ export const Timeline = memo(function Timeline({
259
258
  resizingClip?.groupPreview?.map((change) => change.key) ??
260
259
  (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined);
261
260
  const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
262
- totalClipCount: expandedElements.length,
261
+ totalClipCount: timelineElements.length,
263
262
  totalRowCount: displayLayout.displayTrackOrder.length,
264
263
  zoomMode,
265
264
  });
266
265
  const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } =
267
266
  useTimelineScrollViewport(scrollRef, [
268
267
  timelineReady,
269
- expandedElements.length,
268
+ timelineElements.length,
270
269
  displayLayout.totalH,
271
270
  ]);
272
271
  const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
@@ -279,7 +278,7 @@ export const Timeline = memo(function Timeline({
279
278
  fitPpsRef,
280
279
  draggedClip,
281
280
  resizingClip,
282
- expandedElements,
281
+ expandedElements: timelineElements,
283
282
  isDragging,
284
283
  scrollRef,
285
284
  lastScrollLeftRef,
@@ -295,7 +294,7 @@ export const Timeline = memo(function Timeline({
295
294
  groups,
296
295
  trackGroupOf,
297
296
  gsapAnimations,
298
- elements: expandedElements,
297
+ elements: timelineElements,
299
298
  pixelsPerSecond: pps,
300
299
  contentOrigin,
301
300
  allowHorizontal: zoomMode === "manual",
@@ -312,7 +311,7 @@ export const Timeline = memo(function Timeline({
312
311
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
313
312
  const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
314
313
  useTimelineKeyframeHandlers({
315
- expandedElements,
314
+ expandedElements: timelineElements,
316
315
  keyframeCache,
317
316
  onSelectElement,
318
317
  onSeek,
@@ -343,14 +342,14 @@ export const Timeline = memo(function Timeline({
343
342
  scrollRef,
344
343
  currentTime,
345
344
  clipStateVersion: renderTimeRange,
346
- elementStateVersion: expandedElements,
345
+ elementStateVersion: timelineElements,
347
346
  });
348
347
  const laneGapStrips = useTimelineGapHighlights({
349
348
  gapHighlight,
350
349
  tracks,
351
350
  selectedElementId,
352
351
  selectedElementIds,
353
- expandedElements,
352
+ expandedElements: timelineElements,
354
353
  dragActive: draggedClip?.started === true || resizingClip != null,
355
354
  displayDuration,
356
355
  });
@@ -371,7 +370,7 @@ export const Timeline = memo(function Timeline({
371
370
  effectiveDuration,
372
371
  pps,
373
372
  timelineReady,
374
- elementsLength: expandedElements.length,
373
+ elementsLength: timelineElements.length,
375
374
  setZoomMode,
376
375
  setManualZoomPercent,
377
376
  onSeek,
@@ -407,7 +406,7 @@ export const Timeline = memo(function Timeline({
407
406
  dragScrollRaf,
408
407
  isDragging,
409
408
  setShowPopover,
410
- elementsRef: expandedElementsRef,
409
+ elementsRef: timelineElementsRef,
411
410
  clipIndex,
412
411
  rowGeometryRef,
413
412
  onSelectElement,
@@ -417,7 +416,7 @@ export const Timeline = memo(function Timeline({
417
416
  usePublishRangeSelection(rangeSelection, onRangeSelect);
418
417
  setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag
419
418
 
420
- useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () =>
419
+ useTimelineSelectionLifecycle(timelineElements, selectedElementId, setShowPopover, () =>
421
420
  setRangeSelection(null),
422
421
  );
423
422
 
@@ -433,7 +432,7 @@ export const Timeline = memo(function Timeline({
433
432
  [resizingClip],
434
433
  );
435
434
 
436
- if (!timelineReady || expandedElements.length === 0) {
435
+ if (!timelineReady || timelineElements.length === 0) {
437
436
  return (
438
437
  <TimelineEmptyState
439
438
  isDragOver={assetDrop.isDragOver}
@@ -449,7 +448,7 @@ export const Timeline = memo(function Timeline({
449
448
  <div
450
449
  ref={setContainerRef}
451
450
  aria-label="Timeline track view"
452
- data-timeline-element-count={expandedElements.length}
451
+ data-timeline-element-count={timelineElements.length}
453
452
  className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
454
453
  onMouseMove={updateRazorGuide}
455
454
  onMouseLeave={clearRazorGuide}
@@ -566,8 +565,8 @@ export const Timeline = memo(function Timeline({
566
565
  {activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
567
566
  </div>
568
567
  <TimelineOverlays
569
- elements={expandedElements}
570
- elementsRef={expandedElementsRef}
568
+ elements={timelineElements}
569
+ elementsRef={timelineElementsRef}
571
570
  theme={theme}
572
571
  showShortcutHint={showShortcutHint}
573
572
  showPopover={showPopover}
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { TimelineClip } from "./TimelineClip";
8
8
  import type { TimelineEditCapabilities } from "./timelineEditing";
9
+ import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
9
10
 
10
11
  Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
12
  configurable: true,
@@ -27,11 +28,13 @@ function renderClip({
27
28
  pps = 100,
28
29
  isSelected = false,
29
30
  hasCustomContent = true,
31
+ theme = defaultTimelineTheme,
30
32
  }: {
31
33
  element: TimelineElement;
32
34
  pps?: number;
33
35
  isSelected?: boolean;
34
36
  hasCustomContent?: boolean;
37
+ theme?: TimelineTheme;
35
38
  }) {
36
39
  const host = document.createElement("div");
37
40
  document.body.append(host);
@@ -48,6 +51,7 @@ function renderClip({
48
51
  isHovered={false}
49
52
  hasCustomContent={hasCustomContent}
50
53
  capabilities={capabilities}
54
+ theme={theme}
51
55
  isComposition={false}
52
56
  onHoverStart={vi.fn()}
53
57
  onHoverEnd={vi.fn()}
@@ -115,6 +119,54 @@ describe("TimelineClip", () => {
115
119
  act(() => root.unmount());
116
120
  });
117
121
 
122
+ it("passes clip and handle theme tokens to the rendered elements", () => {
123
+ const theme: TimelineTheme = {
124
+ ...defaultTimelineTheme,
125
+ clipBackground: "var(--test-clip-bg)",
126
+ clipBackgroundActive: "var(--test-clip-bg-active)",
127
+ clipBackgroundHover: "var(--test-clip-bg-hover)",
128
+ clipBackgroundDragging: "var(--test-clip-bg-dragging)",
129
+ clipBorder: "var(--test-clip-border)",
130
+ clipBorderHover: "var(--test-clip-border-hover)",
131
+ clipBorderActive: "var(--test-clip-border-active)",
132
+ handleColor: "var(--test-handle)",
133
+ };
134
+ const { host, root } = renderClip({
135
+ element: { id: "themed", label: "Themed", tag: "div", start: 0, duration: 1, track: 0 },
136
+ isSelected: true,
137
+ theme,
138
+ });
139
+ const clip = host.querySelector<HTMLElement>(".timeline-clip")!;
140
+ expect(clip.style.getPropertyValue("--clip-bg")).toBe("var(--test-clip-bg)");
141
+ expect(clip.style.getPropertyValue("--clip-border-active")).toBe(
142
+ "var(--test-clip-border-active)",
143
+ );
144
+ expect(clip.style.getPropertyValue("--clip-handle")).toBe("var(--test-handle)");
145
+ expect(clip.querySelector<HTMLElement>(".timeline-clip__handle-bar")?.style.background).toBe(
146
+ "var(--clip-handle)",
147
+ );
148
+ act(() => root.unmount());
149
+ });
150
+
151
+ it("keeps default token references off the properties they resolve", () => {
152
+ const { host, root } = renderClip({
153
+ element: {
154
+ id: "default-theme",
155
+ label: "Default",
156
+ tag: "div",
157
+ start: 0,
158
+ duration: 1,
159
+ track: 0,
160
+ },
161
+ isSelected: true,
162
+ });
163
+ const clip = host.querySelector<HTMLElement>(".timeline-clip")!;
164
+ expect(clip.style.getPropertyValue("--clip-bg")).toBe("var(--timeline-clip-bg)");
165
+ expect(clip.style.getPropertyValue("--clip-bg")).not.toBe("var(--clip-bg)");
166
+ expect(clip.style.getPropertyValue("--clip-handle")).toBe("var(--timeline-handle)");
167
+ act(() => root.unmount());
168
+ });
169
+
118
170
  it("is a roving native button with explicit selection semantics", () => {
119
171
  const { host, onClick, root } = renderClip({
120
172
  element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
@@ -64,6 +64,16 @@ export const TimelineClip = memo(function TimelineClip({
64
64
  const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
65
65
  const startLabel = el.start.toFixed(1);
66
66
  const endLabel = (el.start + el.duration).toFixed(1);
67
+ const themeVariables = {
68
+ "--clip-bg": theme.clipBackground,
69
+ "--clip-bg-active": theme.clipBackgroundActive,
70
+ "--clip-bg-hover": theme.clipBackgroundHover,
71
+ "--clip-bg-dragging": theme.clipBackgroundDragging,
72
+ "--clip-border": theme.clipBorder,
73
+ "--clip-border-hover": theme.clipBorderHover,
74
+ "--clip-border-active": theme.clipBorderActive,
75
+ "--clip-handle": theme.handleColor,
76
+ } as CSSProperties;
67
77
  const clipClassName = [
68
78
  "timeline-clip",
69
79
  "absolute",
@@ -81,6 +91,7 @@ export const TimelineClip = memo(function TimelineClip({
81
91
  top: clipY,
82
92
  ...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
83
93
  borderRadius: theme.clipRadius,
94
+ ...themeVariables,
84
95
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
85
96
  // Regular cursor over clips (CapCut-style, user preference) — no grab hand.
86
97
  cursor: "default",
@@ -144,7 +155,7 @@ export const TimelineClip = memo(function TimelineClip({
144
155
  bottom: 6,
145
156
  width: 2,
146
157
  borderRadius: 1,
147
- background: "rgba(255, 255, 255, 0.55)",
158
+ background: "var(--clip-handle)",
148
159
  opacity: handleOpacity * 0.6,
149
160
  }}
150
161
  />
@@ -174,7 +185,7 @@ export const TimelineClip = memo(function TimelineClip({
174
185
  bottom: 6,
175
186
  width: 2,
176
187
  borderRadius: 1,
177
- background: "rgba(255, 255, 255, 0.55)",
188
+ background: "var(--clip-handle)",
178
189
  opacity: handleOpacity * 0.6,
179
190
  }}
180
191
  />
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8
- import { TimelineTrackHeader } from "./TimelineTrackHeader";
8
+ import { TimelineTrackHeader, gutterFill } from "./TimelineTrackHeader";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { type TimelineElement } from "../store/playerStore";
11
11
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -794,10 +794,6 @@ describe("TimelineTrackHeader", () => {
794
794
  });
795
795
  expect(header()?.style.paddingLeft).toBe("14px");
796
796
  expect(header()?.style.borderLeft).toContain("2px");
797
- // And a lighter gutter, so the row reads as sitting INSIDE its group
798
- // rather than beside it. Overlaid on the theme's own fill rather than a
799
- // hard-coded colour, so it follows whatever the gutter is.
800
- expect(header()?.style.background).toContain("linear-gradient");
801
797
  act(() => view.root.unmount());
802
798
  });
803
799
 
@@ -912,3 +908,28 @@ describe("TimelineTrackHeader", () => {
912
908
  });
913
909
  });
914
910
  });
911
+
912
+ // A host themes the timeline by overriding --timeline-* on theme.css, so the
913
+ // rendered gutter must carry the CSS variable, not a baked-in colour.
914
+ describe("theming", () => {
915
+ it("reads the gutter background and border from timeline theme tokens", () => {
916
+ const view = renderHeader({});
917
+ const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
918
+ expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground);
919
+ expect(header?.style.background).toContain("var(--timeline-gutter-bg)");
920
+ expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder);
921
+ act(() => view.root.unmount());
922
+ });
923
+
924
+ // happy-dom's `background` shorthand garbles a `var()` layer next to
925
+ // `linear-gradient(...)` (verified), so this checks the same value through
926
+ // the pure function instead of the broken CSSOM roundtrip.
927
+ it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => {
928
+ const filled = gutterFill(defaultTimelineTheme.gutterBackground, true);
929
+ expect(filled).toContain("linear-gradient");
930
+ expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true);
931
+ expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe(
932
+ defaultTimelineTheme.gutterBackground,
933
+ );
934
+ });
935
+ });
@@ -34,7 +34,7 @@ const GROUP_MEMBER_INDENT = 14;
34
34
  const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
35
35
 
36
36
  /** The gutter fill for a row, tinted when it belongs to a group. */
37
- function gutterFill(base: string, isGroupMember: boolean): string {
37
+ export function gutterFill(base: string, isGroupMember: boolean): string {
38
38
  return isGroupMember
39
39
  ? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}`
40
40
  : base;
@@ -75,11 +75,11 @@ describe("timeline motion styles", () => {
75
75
  const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
76
76
  const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
77
77
 
78
- expect(baseTimelineClipRule).toContain("background-color: rgba(255, 255, 255, 0.055)");
78
+ expect(baseTimelineClipRule).toContain("background-color: var(--clip-bg)");
79
79
  expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
80
- expect(activeTimelineClipRule).toContain("border-color: rgba(60, 230, 172, 0.55)");
80
+ expect(activeTimelineClipRule).toContain("border-color: var(--clip-border-active)");
81
81
  expect(activeTimelineClipRule).not.toContain("box-shadow");
82
- expect(bloomOverlayRule).toContain("background: rgba(60, 230, 172, 0.2)");
82
+ expect(bloomOverlayRule).toContain("background: var(--clip-bg-active)");
83
83
  expect(bloomOverlayRule).not.toContain("linear-gradient");
84
84
  expect(bloomOverlayRule).toContain("opacity: 0");
85
85
  expect(activeBloomOverlayRule).toContain("opacity: 1");
@@ -9,10 +9,10 @@ import { getTrackStyle } from "./timelineIcons";
9
9
  describe("getTimelineTrackStyle", () => {
10
10
  it("uses one neutral clip style for every timeline tag", () => {
11
11
  const expectedStyle = {
12
- clip: "rgba(255,255,255,0.055)",
13
- clipActive: "rgba(60,230,172,0.16)",
14
- accent: "#3CE6AC",
15
- label: "rgba(255,255,255,0.5)",
12
+ clip: "var(--timeline-track-clip-fill)",
13
+ clipActive: "var(--timeline-track-clip-active)",
14
+ accent: "var(--color-accent)",
15
+ label: "var(--timeline-track-label)",
16
16
  };
17
17
 
18
18
  expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);