@hyperframes/studio 0.8.54 → 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 (90) hide show
  1. package/dist/assets/{hyperframes-player-Cy_AwUa8.js → hyperframes-player-k8LzMtXX.js} +1 -1
  2. package/dist/assets/{index-DPUJoO8n.js → index-BlkJ_Axe.js} +192 -192
  3. package/dist/assets/{index-CTa-6Kso.js → index-CxR7F2YQ.js} +1 -1
  4. package/dist/assets/{index-DkAzKuPZ.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 +3152 -3010
  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/gsapDragCommit.ts +1 -1
  42. package/src/hooks/gsapEditOutcome.test.ts +115 -0
  43. package/src/hooks/gsapEditOutcome.ts +39 -10
  44. package/src/hooks/gsapResizeIntercept.test.ts +42 -3
  45. package/src/hooks/gsapResizeIntercept.ts +12 -2
  46. package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
  47. package/src/hooks/gsapRuntimeBridge.ts +14 -3
  48. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  49. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  50. package/src/hooks/timelineTrackVisibility.ts +7 -8
  51. package/src/hooks/useAppHotkeys.test.ts +71 -3
  52. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  53. package/src/hooks/useAppHotkeys.ts +4 -0
  54. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  55. package/src/hooks/useDomEditCommits.ts +3 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -0
  57. package/src/hooks/useDomEditSession.ts +7 -4
  58. package/src/hooks/useDomEditWiring.ts +40 -40
  59. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  60. package/src/hooks/useDomGeometryCommits.ts +26 -20
  61. package/src/hooks/useGestureCommit.test.tsx +29 -0
  62. package/src/hooks/useGestureCommit.ts +23 -0
  63. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  64. package/src/hooks/useInlineTextEdit.ts +11 -2
  65. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  66. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  67. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  68. package/src/hooks/useTimelineEditing.ts +2 -7
  69. package/src/player/components/Timeline.tsx +22 -23
  70. package/src/player/components/TimelineClip.test.tsx +52 -0
  71. package/src/player/components/TimelineClip.tsx +13 -2
  72. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  73. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  74. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  75. package/src/player/components/timelineTheme.test.ts +4 -4
  76. package/src/player/components/timelineTheme.ts +34 -30
  77. package/src/player/components/timelineTrackDisplay.ts +1 -4
  78. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  79. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  80. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  81. package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
  82. package/src/player/store/playerStore.ts +0 -4
  83. package/src/player/store/timelineElement.ts +2 -4
  84. package/src/player/store/timelineResetState.ts +0 -1
  85. package/src/styles/hex-baseline.json +3 -4
  86. package/src/styles/studio.css +7 -7
  87. package/src/styles/theme.css +29 -0
  88. package/src/utils/studioSaveDiagnostics.ts +12 -0
  89. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  90. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -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);
@@ -22,6 +22,8 @@ export interface TimelineTheme {
22
22
  tickMinor: string;
23
23
  clipBackground: string;
24
24
  clipBackgroundActive: string;
25
+ clipBackgroundHover: string;
26
+ clipBackgroundDragging: string;
25
27
  clipBorder: string;
26
28
  clipBorderHover: string;
27
29
  clipBorderActive: string;
@@ -36,43 +38,45 @@ export interface TimelineTheme {
36
38
  }
37
39
 
38
40
  const TRACK_STYLE: TimelineTrackStyle = {
39
- clip: "rgba(255,255,255,0.055)",
40
- clipActive: "rgba(60,230,172,0.16)",
41
- accent: "#3CE6AC",
42
- label: "rgba(255,255,255,0.5)",
41
+ clip: "var(--timeline-track-clip-fill)",
42
+ clipActive: "var(--timeline-track-clip-active)",
43
+ accent: "var(--color-accent)",
44
+ label: "var(--timeline-track-label)",
43
45
  };
44
46
 
47
+ // Every field reads a CSS custom property (declared in styles/theme.css)
48
+ // rather than a literal, so a host themes the timeline the same way it
49
+ // themes the rest of Studio: by overriding the token, not this object.
45
50
  export const defaultTimelineTheme: TimelineTheme = {
46
- // Near-black card surfaces: the panels sit dark while the shell canvas
47
- // between them is a step LIGHTER (#18181B), so the gaps read as visible
48
- // seams around dark cards (CapCut-style).
49
- shellBackground: "#0A0A0B",
50
- shellBorder: "rgba(255,255,255,0.05)",
51
- rulerBorder: "rgba(255,255,255,0.16)",
52
- // All track lanes use a single uniform color — one step lighter than the panel
53
- // surface (#0A0A0B) so lanes are visibly distinct from the ruler/shell.
54
- rowBackground: "#101014",
55
- rowBorder: "rgba(255,255,255,0.06)",
56
- gutterBackground: "#0E0F12",
57
- gutterBorder: "rgba(255,255,255,0.10)",
58
- textPrimary: "rgba(255,255,255,0.92)",
59
- textSecondary: "rgba(255,255,255,0.62)",
60
- tickText: "rgba(255,255,255,0.34)",
61
- tickMajor: "rgba(255,255,255,0.10)",
62
- tickMinor: "rgba(255,255,255,0.06)",
63
- clipBackground: "#141922",
64
- clipBackgroundActive: "#181e28",
65
- clipBorder: "rgba(255,255,255,0.10)",
66
- clipBorderHover: "rgba(255,255,255,0.18)",
67
- clipBorderActive: "rgba(255,255,255,0.24)",
51
+ shellBackground: "var(--timeline-shell-bg)",
52
+ shellBorder: "var(--timeline-shell-border)",
53
+ rulerBorder: "var(--timeline-ruler-border)",
54
+ rowBackground: "var(--timeline-row-bg)",
55
+ rowBorder: "var(--timeline-row-border)",
56
+ gutterBackground: "var(--timeline-gutter-bg)",
57
+ gutterBorder: "var(--timeline-gutter-border)",
58
+ textPrimary: "var(--timeline-text-primary)",
59
+ textSecondary: "var(--timeline-text-secondary)",
60
+ tickText: "var(--timeline-tick-text)",
61
+ tickMajor: "var(--timeline-tick-major)",
62
+ tickMinor: "var(--timeline-tick-minor)",
63
+ clipBackground: "var(--timeline-clip-bg)",
64
+ clipBackgroundActive: "var(--timeline-clip-bg-active)",
65
+ clipBackgroundHover: "var(--timeline-clip-bg-hover)",
66
+ clipBackgroundDragging: "var(--timeline-clip-bg-dragging)",
67
+ clipBorder: "var(--timeline-clip-border)",
68
+ clipBorderHover: "var(--timeline-clip-border-hover)",
69
+ clipBorderActive: "var(--timeline-clip-border-active)",
70
+ // Shadows stay literal: a light host still wants a dark contact shadow, so
71
+ // these are not part of the surface/hairline/text/clip-colour theming ask.
68
72
  clipShadow: "none",
69
73
  clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
70
74
  clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
71
75
  clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
72
- handleColor: "rgba(255,255,255,0.2)",
73
- panelResizeSeam: "rgba(255,255,255,0.12)",
74
- panelResizeActive: "rgba(255,255,255,0.24)",
75
- clipRadius: "8px",
76
+ handleColor: "var(--timeline-handle)",
77
+ panelResizeSeam: "var(--timeline-resize-seam)",
78
+ panelResizeActive: "var(--timeline-resize-active)",
79
+ clipRadius: "var(--radius-lg)",
76
80
  };
77
81
 
78
82
  export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
@@ -1,10 +1,7 @@
1
1
  /**
2
2
  * The one owner of "what track number does the user see".
3
3
  *
4
- * `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
5
- * sub-composition child gets a synthesized fractional key (`host.track + n /
6
- * (siblings + 2)`, see `useTimelineRowElements`), so putting it in a string
7
- * announces "Hide track 0.16666666666666666". Every user-visible track number,
4
+ * `TimelineElement.track` is a z-order SORT key, not a row number. Every
8
5
  * whether it is rendered by a component or baked into an undo-history label,
9
6
  * routes through here; the raw key stays in callbacks and lookups only.
10
7
  */
@@ -98,8 +98,8 @@ function buildGroupInfo(
98
98
  id: groupId,
99
99
  label: membership.labelByGroup.get(groupId) ?? groupId,
100
100
  // Exactly x.5, which sub-composition child rows are now kept strictly below
101
- // (`useTimelineRowElements`) — they used to be able to land here and
102
- // collide, duplicating the group header.
101
+ // the timeline row source — they used to be able to land here and collide,
102
+ // duplicating the group header.
103
103
  anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
104
104
  memberTracks,
105
105
  memberElements: memberTracks.flatMap((track) => rawByTrack.get(track) ?? []),
@@ -3,7 +3,6 @@
3
3
  import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  collectSubCompositionDomChildren,
6
- collectTopLevelElementIds,
7
6
  collectSubCompositionHostState,
8
7
  } from "./timelineSyncHydration";
9
8
  import type { ClipManifestClip } from "../lib/playbackTypes";
@@ -95,20 +94,3 @@ describe("collectSubCompositionHostState", () => {
95
94
  expect(state.size).toBe(0);
96
95
  });
97
96
  });
98
-
99
- describe("collectTopLevelElementIds", () => {
100
- const docOf = (html: string) => new DOMParser().parseFromString(html, "text/html");
101
-
102
- it("names the sub-composition host and a timed element, not what is nested inside them", () => {
103
- const doc = docOf(`<div data-composition-id="root">
104
- <div id="wrap"><div id="a" data-start="0" data-duration="2"><div id="inner" data-start="0"></div></div></div>
105
- <div id="scene" data-composition-id="scene" data-composition-src="scene.html"><div id="box" data-start="0"></div></div>
106
- </div>`);
107
- expect([...collectTopLevelElementIds(doc)!].sort()).toEqual(["a", "scene"]);
108
- });
109
-
110
- it("is null when the document has no composition root", () => {
111
- expect(collectTopLevelElementIds(docOf("<p>hi</p>"))).toBeNull();
112
- expect(collectTopLevelElementIds(null)).toBeNull();
113
- });
114
- });
@@ -14,7 +14,6 @@ import { usePlayerStore } from "../store/playerStore";
14
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
15
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
16
16
  import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
17
- import { topLevelElements, type StructureNode } from "@hyperframes/parsers/top-level-elements";
18
17
  import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
19
18
  import { groupInfoFor } from "../lib/timelineGroupInfo";
20
19
  import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
@@ -137,28 +136,6 @@ export function collectSubCompositionDomChildren(
137
136
  return out;
138
137
  }
139
138
 
140
- interface DomStructureNode extends StructureNode<DomStructureNode> {
141
- id: string;
142
- }
143
-
144
- function toStructureNode(el: Element): DomStructureNode {
145
- const attrs: Record<string, string> = {};
146
- for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
147
- return {
148
- tag: el.tagName,
149
- attrs,
150
- id: el.id,
151
- children: Array.from(el.children).map(toStructureNode),
152
- };
153
- }
154
-
155
- /** DOM ids of the timeline's rows, by the definition the structure lint shares; null when there is no readable root. */
156
- export function collectTopLevelElementIds(doc: Document | null): Set<string> | null {
157
- const root = doc?.querySelector("[data-composition-id]");
158
- if (!root) return null;
159
- return new Set(topLevelElements(toStructureNode(root)).flatMap((n) => (n.id ? [n.id] : [])));
160
- }
161
-
162
139
  /** The host-element `data-*` state one element carries, or null when it has none. */
163
140
  function readSubCompositionHostState(el: Element): SubCompositionHostState | null {
164
141
  const state: SubCompositionHostState = {};
@@ -19,7 +19,6 @@ import {
19
19
  buildTimelineElementsFromClips,
20
20
  syncManifestTimeline,
21
21
  clipTreeParentMap,
22
- collectTopLevelElementIds,
23
22
  collectSubCompositionDomChildren,
24
23
  collectSubCompositionHostState,
25
24
  hydrateTimelineFromPreview,
@@ -130,13 +129,11 @@ export function useTimelineSyncCallbacks({
130
129
  const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
131
130
  const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
132
131
  usePlayerStore.getState().setClipParentMap(parentMap);
133
- usePlayerStore.getState().setTopLevelIds(collectTopLevelElementIds(iframeDoc));
134
132
  usePlayerStore.getState().setDomClipChildren(domClipChildren);
135
133
  usePlayerStore
136
134
  .getState()
137
135
  .setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
138
136
  } catch {
139
- usePlayerStore.getState().setTopLevelIds(null);
140
137
  // cross-origin or __clipTree not available — maps stay empty
141
138
  }
142
139
 
@@ -202,8 +202,6 @@ interface PlayerState extends PlayerStoreSlices {
202
202
  clipManifest: ClipManifestClip[] | null;
203
203
  setClipManifest: (clips: ClipManifestClip[] | null) => void;
204
204
  clipParentMap: Map<string, string>;
205
- topLevelIds: ReadonlySet<string> | null;
206
- setTopLevelIds: (ids: ReadonlySet<string> | null) => void;
207
205
  setClipParentMap: (map: Map<string, string>) => void;
208
206
  /**
209
207
  * Sub-composition DOM descendants (groups + their children) that have no
@@ -394,8 +392,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
394
392
  setClipManifest: (clips) => set({ clipManifest: clips }),
395
393
  clipParentMap: new Map(),
396
394
  setClipParentMap: (map) => set({ clipParentMap: map }),
397
- topLevelIds: null,
398
- setTopLevelIds: (ids) => set({ topLevelIds: ids }),
399
395
  domClipChildren: [],
400
396
  setDomClipChildren: (children) => set({ domClipChildren: children }),
401
397
  subCompositionHostState: new Map(),
@@ -77,10 +77,8 @@ export interface TimelineElement {
77
77
  audioGroupFxChain?: string;
78
78
  audioGroupAutomation?: string;
79
79
  /**
80
- * Set by useTimelineRowElements on an inline-expanded sub-composition
81
- * child: the absolute master-timeline start of the sub-comp host the child
82
- * lives in. Presence marks the element as expanded; edits subtract it to get
83
- * the child's local (sourceFile-relative) time. Works at any nesting depth.
80
+ * Legacy source-coordinate marker for an inline sub-composition child. The
81
+ * current timeline keeps sub-compositions as single rows.
84
82
  */
85
83
  expandedParentStart?: number;
86
84
  expandedHostKey?: string;
@@ -44,7 +44,6 @@ export function createTimelineResetState() {
44
44
  beatPersist: null,
45
45
  clipManifest: null,
46
46
  clipParentMap: new Map<string, string>(),
47
- topLevelIds: null,
48
47
  domClipChildren: [],
49
48
  subCompositionHostState: new Map<string, SubCompositionHostState>(),
50
49
  };