@hyperframes/studio 0.8.59 → 0.8.61

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 (94) hide show
  1. package/dist/assets/{hyperframes-player-BBub3ZdI.js → hyperframes-player-nmVkPQza.js} +1 -1
  2. package/dist/assets/{index-CI7WON-E.js → index-2-HsQB2C.js} +1 -1
  3. package/dist/assets/{index-D1KNPijK.js → index-BK36b0XY.js} +162 -162
  4. package/dist/assets/{index-BxZ5Cw5w.js → index-COG0K-CQ.js} +1 -1
  5. package/dist/assets/{index-DZBHG9TW.css → index-DS0kLbuM.css} +1 -1
  6. package/dist/index.d.ts +5 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4977 -4273
  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 +2 -0
  13. package/src/components/StudioHeader.tsx +2 -13
  14. package/src/components/TimelineToolbar.test.tsx +42 -0
  15. package/src/components/TimelineToolbar.tsx +5 -10
  16. package/src/components/editor/propertyPanelFill.tsx +3 -2
  17. package/src/components/icons/AudioMetersIcon.tsx +16 -0
  18. package/src/components/icons/InspectorIcon.tsx +18 -0
  19. package/src/components/icons/ReverseGradientIcon.tsx +23 -0
  20. package/src/components/icons/RippleEditIcon.tsx +19 -0
  21. package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
  22. package/src/components/nle/AudioMeterStrip.tsx +264 -47
  23. package/src/components/ui/Menu.test.tsx +3 -4
  24. package/src/components/ui/Menu.tsx +18 -5
  25. package/src/components/ui/Select.tsx +7 -1
  26. package/src/components/ui/Tooltip.tsx +6 -1
  27. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
  28. package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
  29. package/src/hooks/useRenderClipContent.test.ts +33 -4
  30. package/src/hooks/useRenderClipContent.ts +7 -3
  31. package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
  32. package/src/hooks/useSdkSession.ts +84 -14
  33. package/src/hooks/useStudioSdkSessions.ts +8 -1
  34. package/src/player/components/AudioWaveform.test.tsx +53 -1
  35. package/src/player/components/AudioWaveform.tsx +124 -65
  36. package/src/player/components/ImageThumbnail.tsx +3 -5
  37. package/src/player/components/LayerDisclosureRow.tsx +9 -4
  38. package/src/player/components/PlayheadIndicator.tsx +3 -3
  39. package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
  40. package/src/player/components/TimelineAutomationLane.tsx +5 -5
  41. package/src/player/components/TimelineCanvas.tsx +18 -15
  42. package/src/player/components/TimelineClip.test.tsx +41 -4
  43. package/src/player/components/TimelineClip.tsx +11 -4
  44. package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
  45. package/src/player/components/TimelineClipDiamonds.tsx +2 -2
  46. package/src/player/components/TimelineClipFades.tsx +64 -31
  47. package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
  48. package/src/player/components/TimelineEmptyState.tsx +1 -1
  49. package/src/player/components/TimelineFxButton.tsx +10 -8
  50. package/src/player/components/TimelineGroupHeader.tsx +18 -9
  51. package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
  52. package/src/player/components/TimelineGroupRow.tsx +1 -1
  53. package/src/player/components/TimelineLanes.test.tsx +14 -0
  54. package/src/player/components/TimelineLanes.tsx +15 -8
  55. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  56. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  57. package/src/player/components/TimelineRuler.tsx +3 -2
  58. package/src/player/components/TimelineShortcutHint.tsx +2 -2
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTrackHeader.tsx +4 -6
  61. package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
  62. package/src/player/components/TimelineTransitionBadge.tsx +59 -0
  63. package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
  64. package/src/player/components/VideoThumbnail.test.tsx +23 -12
  65. package/src/player/components/VideoThumbnail.tsx +86 -32
  66. package/src/player/components/audioClipLink.test.ts +34 -0
  67. package/src/player/components/audioClipLink.ts +33 -0
  68. package/src/player/components/audioWaveformPeaks.test.ts +32 -0
  69. package/src/player/components/audioWaveformPeaks.ts +46 -0
  70. package/src/player/components/thumbnailUtils.test.ts +17 -0
  71. package/src/player/components/thumbnailUtils.ts +7 -0
  72. package/src/player/components/timelineMotionStyles.test.ts +96 -30
  73. package/src/player/components/timelineTheme.test.ts +14 -0
  74. package/src/player/components/timelineTheme.ts +18 -11
  75. package/src/player/components/timelineTransitionSeams.test.ts +48 -0
  76. package/src/player/components/timelineTransitionSeams.ts +36 -0
  77. package/src/player/components/timelineZones.test.ts +18 -0
  78. package/src/player/components/timelineZones.ts +4 -1
  79. package/src/player/lib/playbackTypes.ts +2 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -0
  81. package/src/player/lib/timelineDOM.ts +24 -2
  82. package/src/player/lib/timelineIframeHelpers.ts +7 -0
  83. package/src/player/lib/timelineTransitionMetadata.ts +52 -0
  84. package/src/player/lib/timelineViewportBudgets.ts +2 -1
  85. package/src/player/store/timelineElement.ts +1 -0
  86. package/src/styles/hex-baseline.json +3 -22
  87. package/src/styles/studio.css +126 -46
  88. package/src/styles/theme.css +113 -1
  89. package/src/styles/theme.test.ts +9 -0
  90. package/src/utils/audioMeterVisibility.ts +2 -2
  91. package/src/utils/sdkCutover.test.ts +102 -1
  92. package/src/utils/sdkCutover.ts +16 -21
  93. package/src/utils/sdkEditTransaction.ts +50 -1
  94. package/src/utils/studioUiPreferences.ts +1 -1
@@ -192,13 +192,14 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
192
192
  viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
193
193
  preserveAspectRatio="none"
194
194
  >
195
- {/* The shaded wedge is the gain that is NOT there: above the ramp. */}
195
+ {/* Keep the #4250 wedge geometry, but let the real waveform read through it. */}
196
196
  {showIn && (
197
197
  <>
198
198
  <polygon
199
199
  data-testid="clip-fade-in"
200
200
  points={`0,0 ${inPx},0 0,100`}
201
201
  fill="var(--timeline-fade-shade)"
202
+ fillOpacity={0.35}
202
203
  />
203
204
  <line
204
205
  x1={0}
@@ -218,6 +219,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
218
219
  data-testid="clip-fade-out"
219
220
  points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
220
221
  fill="var(--timeline-fade-shade)"
222
+ fillOpacity={0.35}
221
223
  />
222
224
  <line
223
225
  x1={widthPx - outPx}
@@ -235,54 +237,85 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
235
237
  )}
236
238
  {canEdit && (
237
239
  <>
238
- <div
239
- role="slider"
240
- tabIndex={-1}
241
- aria-label="Fade in"
242
- aria-valuemin={0}
243
- aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
244
- aria-valuenow={fades.fadeIn}
245
- aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
246
- data-testid="clip-fade-handle-in"
247
- title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
240
+ <FadeHandle
241
+ direction="in"
242
+ value={fades.fadeIn}
243
+ max={Math.max(0, el.duration - fades.fadeOut)}
248
244
  style={handleStyle(inPx)}
245
+ dragging={dragging === "in"}
249
246
  onPointerDown={onHandlePointerDown("in")}
250
247
  onPointerMove={onHandlePointerMove}
251
248
  onPointerUp={(e) => finish(e, false)}
252
249
  onPointerCancel={(e) => finish(e, true)}
253
250
  onKeyDown={onHandleKeyDown}
254
- onClick={(e) => e.stopPropagation()}
255
- onDoubleClick={(e) => e.stopPropagation()}
256
- >
257
- <FadeDot active={dragging === "in"} />
258
- </div>
259
- <div
260
- role="slider"
261
- tabIndex={-1}
262
- aria-label="Fade out"
263
- aria-valuemin={0}
264
- aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
265
- aria-valuenow={fades.fadeOut}
266
- aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
267
- data-testid="clip-fade-handle-out"
268
- title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
251
+ />
252
+ <FadeHandle
253
+ direction="out"
254
+ value={fades.fadeOut}
255
+ max={Math.max(0, el.duration - fades.fadeIn)}
269
256
  style={handleStyle(widthPx - outPx)}
257
+ dragging={dragging === "out"}
270
258
  onPointerDown={onHandlePointerDown("out")}
271
259
  onPointerMove={onHandlePointerMove}
272
260
  onPointerUp={(e) => finish(e, false)}
273
261
  onPointerCancel={(e) => finish(e, true)}
274
262
  onKeyDown={onHandleKeyDown}
275
- onClick={(e) => e.stopPropagation()}
276
- onDoubleClick={(e) => e.stopPropagation()}
277
- >
278
- <FadeDot active={dragging === "out"} />
279
- </div>
263
+ />
280
264
  </>
281
265
  )}
282
266
  </>
283
267
  );
284
268
  }
285
269
 
270
+ function FadeHandle({
271
+ direction,
272
+ value,
273
+ max,
274
+ style,
275
+ dragging,
276
+ onPointerDown,
277
+ onPointerMove,
278
+ onPointerUp,
279
+ onPointerCancel,
280
+ onKeyDown,
281
+ }: {
282
+ direction: "in" | "out";
283
+ value: number;
284
+ max: number;
285
+ style: CSSProperties;
286
+ dragging: boolean;
287
+ onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
288
+ onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
289
+ onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
290
+ onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
291
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
292
+ }) {
293
+ const label = direction === "in" ? "Fade in" : "Fade out";
294
+ return (
295
+ <div
296
+ role="slider"
297
+ tabIndex={-1}
298
+ aria-label={label}
299
+ aria-valuemin={0}
300
+ aria-valuemax={max}
301
+ aria-valuenow={value}
302
+ aria-valuetext={`${formatFadeSeconds(value)}s`}
303
+ data-testid={`clip-fade-handle-${direction}`}
304
+ title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
305
+ style={style}
306
+ onPointerDown={onPointerDown}
307
+ onPointerMove={onPointerMove}
308
+ onPointerUp={onPointerUp}
309
+ onPointerCancel={onPointerCancel}
310
+ onKeyDown={onKeyDown}
311
+ onClick={(e) => e.stopPropagation()}
312
+ onDoubleClick={(e) => e.stopPropagation()}
313
+ >
314
+ <FadeDot active={dragging} />
315
+ </div>
316
+ );
317
+ }
318
+
286
319
  function FadeDot({ active }: { active: boolean }) {
287
320
  return (
288
321
  <span
@@ -213,8 +213,8 @@ function SegmentEaseControl({
213
213
  zIndex: 3,
214
214
  pointerEvents: "auto",
215
215
  padding: 0,
216
- border: "1px solid rgba(255, 255, 255, 0.14)",
217
- background: "#171717",
216
+ border: "1px solid var(--timeline-diamond-border)",
217
+ background: "var(--timeline-diamond-bg)",
218
218
  cursor: "pointer",
219
219
  }}
220
220
  onPointerDown={(e) => {
@@ -18,7 +18,7 @@ export function TimelineEmptyState({
18
18
  }: TimelineEmptyStateProps) {
19
19
  return (
20
20
  <div
21
- className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
21
+ className={`h-full border-t bg-[var(--timeline-shell-bg)] flex flex-col select-none transition-colors duration-150 ${
22
22
  isDragOver ? "border-studio-accent/50 bg-studio-accent/3" : "border-neutral-800/50"
23
23
  }`}
24
24
  onDragOver={onDragOver}
@@ -79,7 +79,7 @@ function GroupNameDialog({
79
79
  <div
80
80
  role="dialog"
81
81
  aria-label="This track cannot be grouped"
82
- className="z-200 w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] leading-snug text-white/75 shadow-xl"
82
+ className="z-200 w-64 rounded-md border border-[var(--timeline-tick-major)] bg-[var(--timeline-dialog-bg)] p-3 text-[11px] leading-snug text-[var(--timeline-text-soft)] shadow-xl"
83
83
  style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
84
84
  onPointerDown={(event) => event.stopPropagation()}
85
85
  onKeyDown={(event) => {
@@ -97,7 +97,7 @@ function GroupNameDialog({
97
97
  <div
98
98
  role="dialog"
99
99
  aria-label="Name this group"
100
- className="z-200 w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] text-white/75 shadow-xl"
100
+ className="z-200 w-64 rounded-md border border-[var(--timeline-tick-major)] bg-[var(--timeline-dialog-bg)] p-3 text-[11px] text-[var(--timeline-text-soft)] shadow-xl"
101
101
  style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
102
102
  onPointerDown={(event) => event.stopPropagation()}
103
103
  onKeyDown={(event) => {
@@ -108,14 +108,14 @@ function GroupNameDialog({
108
108
  if (event.key === "Enter") confirm();
109
109
  }}
110
110
  >
111
- <p className="mb-1.5 font-medium text-white">Name this group</p>
111
+ <p className="mb-1.5 font-medium text-[var(--timeline-text-solid)]">Name this group</p>
112
112
  <input
113
113
  ref={inputRef}
114
114
  type="text"
115
115
  aria-label="Group name"
116
116
  value={label}
117
117
  onChange={(event) => setLabel(event.currentTarget.value)}
118
- className="w-full rounded-sm border border-white/20 bg-black/30 px-1.5 py-1 text-[11px] text-white outline-hidden focus:border-[#3CE6AC]"
118
+ className="w-full rounded-sm border border-[var(--timeline-border-strong)] bg-[var(--timeline-input-bg)] px-1.5 py-1 text-[11px] text-[var(--timeline-text-solid)] outline-hidden focus:border-[var(--timeline-accent)]"
119
119
  />
120
120
  {/* The sentence. No jargon, and it names both things a bus does. */}
121
121
  <p className="mt-2 leading-snug">
@@ -125,14 +125,14 @@ function GroupNameDialog({
125
125
  <div className="mt-2.5 flex justify-end gap-1.5">
126
126
  <button
127
127
  type="button"
128
- className="rounded-sm border border-white/20 px-2 py-1 text-[10px] text-white/75 hover:bg-white/10"
128
+ className="rounded-sm border border-[var(--timeline-border-strong)] px-2 py-1 text-[10px] text-[var(--timeline-text-soft)] hover:bg-[var(--timeline-tick-major)]"
129
129
  onClick={onCancel}
130
130
  >
131
131
  Cancel
132
132
  </button>
133
133
  <button
134
134
  type="button"
135
- className="rounded-sm border border-[#3CE6AC] bg-[#3CE6AC]/15 px-2 py-1 text-[10px] font-semibold text-[#3CE6AC] hover:bg-[#3CE6AC]/25"
135
+ className="rounded-sm border border-[var(--timeline-accent)] bg-[var(--timeline-accent-button-bg)] px-2 py-1 text-[10px] font-semibold text-[var(--timeline-accent)] hover:bg-[var(--timeline-accent-hover)]"
136
136
  onClick={confirm}
137
137
  >
138
138
  Group
@@ -212,7 +212,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
212
212
  ref={buttonRef}
213
213
  aria-label="Effects — group these clips first"
214
214
  title="Group these clips to add effects to all of them"
215
- className="flex h-6 items-center justify-center rounded-sm border-0 bg-transparent px-1 text-[10px] font-semibold text-white/35 hover:text-white/75"
215
+ className="flex h-6 items-center justify-center rounded-sm border-0 bg-transparent px-1 text-[10px] font-semibold text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
216
216
  onPointerDown={(event) => event.stopPropagation()}
217
217
  onClick={(event) => {
218
218
  event.stopPropagation();
@@ -253,7 +253,9 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
253
253
  aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
254
254
  title="Effects"
255
255
  className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
256
- open || nodeCount > 0 ? "text-[#3CE6AC]" : "text-white/35 hover:text-white/75"
256
+ open || nodeCount > 0
257
+ ? "text-[var(--timeline-accent)]"
258
+ : "text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
257
259
  }`}
258
260
  onPointerDown={(event) => event.stopPropagation()}
259
261
  onClick={(event) => {
@@ -55,21 +55,24 @@ function GroupNameButton({
55
55
  // No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
56
56
  // claiming it here would push the controls off the right edge — and the
57
57
  // count with them, since it rides inside this button.
58
- className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-white hover:text-[#3CE6AC] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
58
+ className="flex h-6 min-w-0 items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 text-left text-[11px] text-[var(--timeline-text-solid)] hover:text-[var(--timeline-accent)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
59
59
  onPointerDown={(event) => event.stopPropagation()}
60
60
  onClick={(event) => {
61
61
  event.stopPropagation();
62
62
  onOpenFxRack();
63
63
  }}
64
64
  >
65
- <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
65
+ <span
66
+ aria-hidden="true"
67
+ className="shrink-0 text-[12px] leading-none text-[var(--timeline-track-label)]"
68
+ >
66
69
  ▤
67
70
  </span>
68
71
  <span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
69
72
  {label}
70
73
  </span>
71
74
  <span
72
- className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
75
+ className="shrink-0 rounded-full bg-[var(--timeline-tick-major)] px-1 text-[9px] leading-[14px] tabular-nums text-[var(--timeline-handle)]"
73
76
  aria-hidden="true"
74
77
  title={`${memberCount} tracks`}
75
78
  >
@@ -103,7 +106,7 @@ export function TimelineGroupHeader({
103
106
  style={{
104
107
  width: columnWidth,
105
108
  height: TRACK_H,
106
- color: "#ffffff",
109
+ color: "var(--timeline-text-solid)",
107
110
  background: theme.gutterBackground,
108
111
  borderRight: `1px solid ${theme.gutterBorder}`,
109
112
  }}
@@ -119,8 +122,10 @@ export function TimelineGroupHeader({
119
122
  // 13px mono, matching the property panel's preset-run caret
120
123
  // (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
121
124
  // at the same size rather than a smaller one unique to this row.
122
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
123
- isExpanded ? "text-white" : "text-white/55 hover:text-white"
125
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
126
+ isExpanded
127
+ ? "text-[var(--timeline-text-solid)]"
128
+ : "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
124
129
  }`}
125
130
  onPointerDown={(event) => event.stopPropagation()}
126
131
  onClick={(event) => {
@@ -157,8 +162,10 @@ export function TimelineGroupHeader({
157
162
  aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
158
163
  title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
159
164
  // Anchored right, matching every other header's lane toggle.
160
- className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
161
- isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
165
+ className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
166
+ isLaneOpen
167
+ ? "text-[var(--timeline-accent)]"
168
+ : "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
162
169
  }`}
163
170
  onPointerDown={(event) => event.stopPropagation()}
164
171
  onClick={(event) => {
@@ -167,7 +174,9 @@ export function TimelineGroupHeader({
167
174
  }}
168
175
  >
169
176
  <span aria-hidden="true">∿</span>
170
- <span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
177
+ <span className="text-[9px] tabular-nums text-[var(--timeline-handle)]">
178
+ {laneCount}
179
+ </span>
171
180
  </button>
172
181
  )}
173
182
  </div>
@@ -64,7 +64,7 @@ export function TimelineGroupLaneLabels({
64
64
  key={lane.target}
65
65
  data-group-lane-label={lane.target}
66
66
  aria-label={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
67
- className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-white/65 hover:text-white focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
67
+ className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
68
68
  style={{
69
69
  top: top + index * AUTOMATION_LANE_H,
70
70
  width: columnWidth,
@@ -79,16 +79,23 @@ export function TimelineGroupLaneLabels({
79
79
  onReveal?.(lane.target);
80
80
  }}
81
81
  >
82
- <span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
82
+ <span
83
+ aria-hidden="true"
84
+ className="shrink-0 text-[11px] text-[var(--timeline-text-dim)]"
85
+ >
83
86
  ▤
84
87
  </span>
85
88
  <span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
86
- <span className="truncate font-mono text-[9px] text-white/70">{parts.name}</span>
89
+ <span className="truncate font-mono text-[9px] text-[var(--timeline-text-bright)]">
90
+ {parts.name}
91
+ </span>
87
92
  {parts.param ? (
88
- <span className="truncate font-mono text-[9px] text-white/40">{parts.param}</span>
93
+ <span className="truncate font-mono text-[9px] text-[var(--timeline-text-dim)]">
94
+ {parts.param}
95
+ </span>
89
96
  ) : null}
90
97
  </span>
91
- <span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
98
+ <span className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--timeline-handle)]">
92
99
  {value.toFixed(2)}
93
100
  </span>
94
101
  </button>
@@ -21,7 +21,7 @@ import { usePlayerStore } from "../store/playerStore";
21
21
 
22
22
  /** Accent rail on a group-owned lane — the same green the member rail uses, so
23
23
  * "this belongs to the group" reads the same in both places (groups doc §5). */
24
- const GROUP_LANE_ACCENT = "#3CE6AC";
24
+ const GROUP_LANE_ACCENT = "var(--timeline-accent)";
25
25
 
26
26
  interface TimelineGroupRowProps {
27
27
  index: number;
@@ -432,6 +432,20 @@ describe("TimelineLanes selection", () => {
432
432
  });
433
433
 
434
434
  describe("TimelineLanes clip thumbnails", () => {
435
+ it("keeps thumbnail content inside a selected clip", () => {
436
+ const selected = element("clip-a", TRACK_A);
437
+ const view = renderLanes({
438
+ elements: [selected],
439
+ selectedElementIds: new Set([selected.id]),
440
+ renderClipContent: () => <div className="absolute inset-0 bg-neutral-900" data-thumbnail />,
441
+ });
442
+
443
+ const clip = view.host.querySelector('[data-el-id="clip-a"]');
444
+ expect(clip?.classList.contains("is-selected")).toBe(true);
445
+ expect(clip?.querySelector("[data-thumbnail]")).not.toBeNull();
446
+ act(() => view.root.unmount());
447
+ });
448
+
435
449
  it("asks for the same frames at rest, hovered and selected", () => {
436
450
  const rich: unknown[] = [];
437
451
  const renderClipContent = vi.fn(
@@ -32,6 +32,8 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
32
32
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
33
33
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
34
34
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
35
+ import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
36
+ import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
35
37
 
36
38
  export function TimelineLanes({
37
39
  pps,
@@ -104,13 +106,15 @@ export function TimelineLanes({
104
106
  const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
105
107
  useTimelineGroupDisclosure();
106
108
  const automationLanes = useAutomationLanes();
109
+ const allTransitionElements = useMemo(
110
+ () => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
111
+ [getPreviewElement, tracks],
112
+ );
107
113
  // A group's automation clock is COMPOSITION time (groups doc §1.3), so its
108
114
  // synthetic lane element spans the whole composition rather than a clip.
109
115
  const compositionDuration = usePlayerStore((s) => s.duration);
110
116
  useAutomationSelectionKeyboard({ lanes: automationLanes });
111
117
  const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
112
- // Which tracks are group MEMBERS, so their headers can render the level-2
113
- // nesting their `aria-level` already reports.
114
118
  const groupMemberTracks = useMemo(
115
119
  () => new Set(groups.flatMap((group) => group.memberTracks)),
116
120
  [groups],
@@ -197,11 +201,7 @@ export function TimelineLanes({
197
201
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
198
202
  const isPendingTrack =
199
203
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
200
- // All lanes use the same uniform color — no alternating stripes.
201
204
  const rowBackground = theme.rowBackground;
202
- // The beat-dot strip occupies the top of this track's lane (active track,
203
- // or the music track when nothing is selected). When shown, keyframe
204
- // diamonds shrink + drop to the bottom half so they don't collide with it.
205
205
  const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
206
206
  selectedElementId,
207
207
  isMusicTrack,
@@ -223,9 +223,10 @@ export function TimelineLanes({
223
223
  // property lanes are showing. Undefined means "fill the row", which is
224
224
  // right only while it is collapsed and the row is nothing but bar.
225
225
  const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
226
- // The clips whose envelopes this row draws, at their dragged positions.
227
- // Once per row, not once per clip in the map below.
228
226
  const automationElements = els.map(getPreviewElement);
227
+ const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
228
+ (seam) => seam.incoming.track === trackNum,
229
+ );
229
230
  // Minted here because this is the only place that sees BOTH ends of
230
231
  // the disclosure: the caret in the sticky header and the diamond lanes
231
232
  // on the canvas. Keyed by display row, not by `trackNum`, which is a
@@ -553,6 +554,12 @@ export function TimelineLanes({
553
554
  );
554
555
  })
555
556
  }
557
+ <TimelineTransitionOverlays
558
+ seams={transitionSeams}
559
+ pixelsPerSecond={pps}
560
+ rowHeight={rowHeight}
561
+ clipBarHeight={clipBarHeight}
562
+ />
556
563
  {/* The automation lanes belong to the ROW, so they are mounted
557
564
  here rather than under the active clip's property lanes.
558
565
  Hanging off that clip meant selecting a sibling moved the
@@ -386,7 +386,7 @@ describe("TimelinePropertyLanes", () => {
386
386
  const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child");
387
387
  const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child");
388
388
  expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2");
389
- expect(visualFill?.getAttribute("fill")).toBe("#a3a3a3");
389
+ expect(visualFill?.getAttribute("fill")).toBe("var(--timeline-diamond-muted)");
390
390
  act(() => root.unmount());
391
391
  });
392
392
 
@@ -54,7 +54,7 @@ export function TimelineRazorGuideOverlay({ x }: { x: number }) {
54
54
  return (
55
55
  <div
56
56
  className="absolute top-0 bottom-0 pointer-events-none z-10"
57
- style={{ left: x, width: 1, background: "rgba(239,68,68,0.7)" }}
57
+ style={{ left: x, width: 1, background: "var(--timeline-danger)" }}
58
58
  />
59
59
  );
60
60
  }
@@ -69,7 +69,8 @@ export const TimelineRuler = memo(function TimelineRuler({
69
69
  y1={0}
70
70
  x2={x}
71
71
  y2={totalH}
72
- stroke={`rgba(34, 197, 94, ${opacity.toFixed(3)})`}
72
+ stroke="var(--timeline-beat)"
73
+ strokeOpacity={opacity}
73
74
  strokeWidth="1"
74
75
  />
75
76
  );
@@ -99,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
99
100
  style={{
100
101
  height: RULER_H,
101
102
  width: trackContentWidth,
102
- // Ruler background = panel surface (#0A0A0B) — no bottom border,
103
+ // Ruler background = the panel surface token — no bottom border,
103
104
  // no tick lines (CapCut-style clean ruler, labels only).
104
105
  background: theme.shellBackground,
105
106
  }}
@@ -9,11 +9,11 @@ export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
9
9
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
10
10
  <div
11
11
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
12
- style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
12
+ style={{ background: "var(--timeline-shortcut-bg)", borderColor: theme.gutterBorder }}
13
13
  >
14
14
  <kbd
15
15
  className="text-[9px] font-mono px-1 py-0.5 rounded-sm"
16
- style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
16
+ style={{ color: theme.textSecondary, background: "var(--timeline-tick-minor)" }}
17
17
  >
18
18
  Shift
19
19
  </kbd>
@@ -793,7 +793,7 @@ describe("TimelineTrackHeader", () => {
793
793
  isGroupMember: true,
794
794
  });
795
795
  expect(header()?.style.paddingLeft).toBe("14px");
796
- expect(header()?.style.borderLeft).toContain("2px");
796
+ expect(header()?.style.borderLeft).toContain("var(--timeline-accent-rail)");
797
797
  act(() => view.root.unmount());
798
798
  });
799
799
 
@@ -26,12 +26,10 @@ import { useMemo } from "react";
26
26
 
27
27
  /** Accent rail + inset marking a row as a group MEMBER, matching the level-2
28
28
  * nesting its `aria-level` already reports. */
29
- const GROUP_MEMBER_RAIL = "#3CE6AC59";
29
+ const GROUP_MEMBER_RAIL = "var(--timeline-accent-rail)";
30
30
  const GROUP_MEMBER_INDENT = 14;
31
- /** A hair lighter than `gutterBackground`, so a member row reads as sitting
32
- * INSIDE its group rather than beside it. Overlaid rather than hard-coded so
33
- * it tracks whatever the theme's gutter is. */
34
- const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
31
+ /** A neutral wash keeps group membership visible without changing the row's hue. */
32
+ const GROUP_MEMBER_TINT = "var(--timeline-group-member-tint)";
35
33
 
36
34
  /** The gutter fill for a row, tinted when it belongs to a group. */
37
35
  export function gutterFill(base: string, isGroupMember: boolean): string {
@@ -288,7 +286,7 @@ export function TimelineTrackHeader({
288
286
  <div
289
287
  className={
290
288
  showTrackLabel
291
- ? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
289
+ ? "flex flex-col justify-center gap-0.5 px-1.5 text-[var(--timeline-handle)]"
292
290
  : "flex flex-col items-center justify-center gap-0.5"
293
291
  }
294
292
  style={{ height: TRACK_H }}
@@ -39,8 +39,10 @@ export function VisibilityButton({
39
39
  type="button"
40
40
  aria-label={label}
41
41
  title={label}
42
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-[#3CE6AC] ${
43
- hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
42
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-[var(--timeline-accent)] ${
43
+ hidden
44
+ ? "text-[var(--timeline-accent)] hover:text-[var(--timeline-text-solid)]"
45
+ : "text-[var(--timeline-text-faint)] hover:text-[var(--timeline-text-soft)]"
44
46
  }`}
45
47
  onPointerDown={(event) => event.stopPropagation()}
46
48
  onClick={(event) => {
@@ -89,7 +91,12 @@ export function PlainTrackHeader({
89
91
  and the name gives way instead. */}
90
92
  <div className="flex min-w-0 items-center gap-1">
91
93
  {isAudioTrack && (
92
- <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
94
+ <Music
95
+ size={12}
96
+ weight="fill"
97
+ aria-hidden="true"
98
+ className="text-[var(--timeline-text-faint)]"
99
+ />
93
100
  )}
94
101
  {/* No `flex-1`: the name takes only the width it needs, so the clip
95
102
  count sits against it rather than being pushed out to meet the
@@ -0,0 +1,59 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ interface TimelineTransitionBadgeProps {
4
+ centerPx: number;
5
+ top: number;
6
+ widthPx: number;
7
+ outgoingSrc?: string;
8
+ incomingSrc?: string;
9
+ }
10
+
11
+ export function TimelineTransitionBadge({
12
+ centerPx,
13
+ top,
14
+ widthPx,
15
+ outgoingSrc,
16
+ incomingSrc,
17
+ }: TimelineTransitionBadgeProps) {
18
+ const style = {
19
+ left: centerPx,
20
+ top,
21
+ "--timeline-transition-zone-width": `${widthPx}px`,
22
+ } as CSSProperties;
23
+ return (
24
+ <div className="timeline-transition" style={style} aria-hidden="true">
25
+ <div className="timeline-transition__zone">
26
+ <div
27
+ className="timeline-transition__image timeline-transition__image--out"
28
+ style={outgoingSrc ? { backgroundImage: `url("${outgoingSrc}")` } : undefined}
29
+ />
30
+ <div
31
+ className="timeline-transition__image timeline-transition__image--in"
32
+ style={incomingSrc ? { backgroundImage: `url("${incomingSrc}")` } : undefined}
33
+ />
34
+ </div>
35
+ <div className="timeline-transition__badge">
36
+ <svg viewBox="0 0 20 20" role="presentation">
37
+ <defs>
38
+ <filter id="timeline-transition-glyph-blur">
39
+ <feGaussianBlur stdDeviation="0.55" />
40
+ </filter>
41
+ </defs>
42
+ <path
43
+ className="timeline-transition__frame timeline-transition__frame--out"
44
+ d="M3 5h8v10H3z"
45
+ />
46
+ <path
47
+ className="timeline-transition__frame timeline-transition__frame--in"
48
+ d="M9 5h8v10H9z"
49
+ />
50
+ <path
51
+ className="timeline-transition__intersection"
52
+ d="M9 5h2v10H9z"
53
+ filter="url(#timeline-transition-glyph-blur)"
54
+ />
55
+ </svg>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,30 @@
1
+ import { CLIP_Y } from "./timelineLayout";
2
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
+ import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
4
+ import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
5
+
6
+ interface TimelineTransitionOverlaysProps {
7
+ seams: readonly TimelineTransitionSeam[];
8
+ pixelsPerSecond: number;
9
+ rowHeight: number;
10
+ clipBarHeight?: number;
11
+ }
12
+
13
+ export function TimelineTransitionOverlays({
14
+ seams,
15
+ pixelsPerSecond,
16
+ rowHeight,
17
+ clipBarHeight,
18
+ }: TimelineTransitionOverlaysProps) {
19
+ const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
20
+ return seams.map((seam) => (
21
+ <TimelineTransitionBadge
22
+ key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
23
+ centerPx={seam.centerTime * pixelsPerSecond}
24
+ top={top}
25
+ widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
26
+ outgoingSrc={seam.outgoing.src}
27
+ incomingSrc={seam.incoming.src}
28
+ />
29
+ ));
30
+ }