@hyperframes/studio 0.8.59 → 0.8.60

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 (60) hide show
  1. package/dist/assets/{hyperframes-player-BBub3ZdI.js → hyperframes-player-Di7GyCOP.js} +1 -1
  2. package/dist/assets/{index-DZBHG9TW.css → index-CBKb_0TI.css} +1 -1
  3. package/dist/assets/{index-D1KNPijK.js → index-Dpq2lkOm.js} +111 -111
  4. package/dist/assets/{index-BxZ5Cw5w.js → index-Dx_pt9m4.js} +1 -1
  5. package/dist/assets/{index-CI7WON-E.js → index-jAjhQTrB.js} +1 -1
  6. package/dist/index.d.ts +1 -5
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4439 -4118
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/StudioHeader.tsx +2 -13
  13. package/src/components/TimelineToolbar.test.tsx +42 -0
  14. package/src/components/TimelineToolbar.tsx +5 -10
  15. package/src/components/editor/propertyPanelFill.tsx +3 -2
  16. package/src/components/icons/AudioMetersIcon.tsx +16 -0
  17. package/src/components/icons/InspectorIcon.tsx +18 -0
  18. package/src/components/icons/ReverseGradientIcon.tsx +23 -0
  19. package/src/components/icons/RippleEditIcon.tsx +19 -0
  20. package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
  21. package/src/components/nle/AudioMeterStrip.tsx +264 -47
  22. package/src/components/ui/Menu.test.tsx +3 -4
  23. package/src/components/ui/Menu.tsx +18 -5
  24. package/src/components/ui/Select.tsx +7 -1
  25. package/src/components/ui/Tooltip.tsx +6 -1
  26. package/src/player/components/AudioWaveform.tsx +16 -7
  27. package/src/player/components/ImageThumbnail.tsx +3 -5
  28. package/src/player/components/LayerDisclosureRow.tsx +9 -4
  29. package/src/player/components/PlayheadIndicator.tsx +3 -3
  30. package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
  31. package/src/player/components/TimelineAutomationLane.tsx +5 -5
  32. package/src/player/components/TimelineCanvas.tsx +18 -15
  33. package/src/player/components/TimelineClip.test.tsx +41 -4
  34. package/src/player/components/TimelineClip.tsx +11 -4
  35. package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
  36. package/src/player/components/TimelineClipDiamonds.tsx +2 -2
  37. package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
  38. package/src/player/components/TimelineEmptyState.tsx +1 -1
  39. package/src/player/components/TimelineFxButton.tsx +10 -8
  40. package/src/player/components/TimelineGroupHeader.tsx +18 -9
  41. package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
  42. package/src/player/components/TimelineGroupRow.tsx +1 -1
  43. package/src/player/components/TimelineLanes.test.tsx +14 -0
  44. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  45. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  46. package/src/player/components/TimelineRuler.tsx +3 -2
  47. package/src/player/components/TimelineShortcutHint.tsx +2 -2
  48. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  49. package/src/player/components/TimelineTrackHeader.tsx +4 -6
  50. package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
  51. package/src/player/components/VideoThumbnail.tsx +3 -5
  52. package/src/player/components/timelineMotionStyles.test.ts +96 -30
  53. package/src/player/components/timelineTheme.test.ts +14 -0
  54. package/src/player/components/timelineTheme.ts +12 -11
  55. package/src/styles/hex-baseline.json +4 -23
  56. package/src/styles/studio.css +43 -47
  57. package/src/styles/theme.css +105 -1
  58. package/src/styles/theme.test.ts +9 -0
  59. package/src/utils/audioMeterVisibility.ts +2 -2
  60. package/src/utils/studioUiPreferences.ts +1 -1
@@ -1,7 +1,27 @@
1
- import { memo, useEffect, useMemo, useRef, type Ref, type RefObject } from "react";
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ type PointerEvent as ReactPointerEvent,
8
+ type Ref,
9
+ type RefObject,
10
+ } from "react";
11
+ import {
12
+ AUDIO_GAIN_FADER_MIN,
13
+ AUDIO_GAIN_FADER_MAX,
14
+ audioGainToFaderPosition,
15
+ audioFaderPositionToGain,
16
+ audioGainToText,
17
+ formatAudioGain,
18
+ } from "@hyperframes/core/audio-gain";
19
+ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
2
20
  import { usePlayerStore } from "../../player";
21
+ import { clampNumber } from "../../utils/studioHelpers";
3
22
  import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
4
23
  import { useStudioShellContext } from "../../contexts/StudioContext";
24
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
5
25
  import {
6
26
  METER_DB_MARKS,
7
27
  useProjectHasAudio,
@@ -11,8 +31,8 @@ import {
11
31
  } from "../../utils/audioMeterMath";
12
32
 
13
33
  interface Levels {
14
- master: { l: number; r: number };
15
- groups: Record<string, { l: number; r: number }>;
34
+ master: StereoLevel;
35
+ groups: Record<string, StereoLevel>;
16
36
  }
17
37
  interface AudioMeterHook {
18
38
  start(): void;
@@ -22,23 +42,67 @@ interface AudioMeterHook {
22
42
  interface Strip {
23
43
  id: string | null;
24
44
  label: string;
45
+ volume: number;
25
46
  }
26
- type Bars = { fill: HTMLElement | null; peak: HTMLElement | null };
47
+ type Bars = { mask: HTMLElement | null; peak: HTMLElement | null };
27
48
  type StripBars = [Bars, Bars];
28
49
 
29
- const MASTER: Strip = { id: null, label: "Master" };
50
+ const MONITOR_LABEL = "Monitor";
51
+
52
+ /** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
53
+ const AMBER_AT = markFraction(-6);
54
+ const RED_AT = markFraction(-3);
30
55
 
31
56
  function useStrips(): Strip[] {
32
57
  const elements = usePlayerStore((s) => s.elements);
58
+ const masterVolume = usePlayerStore((s) => s.audioVolume);
33
59
  return useMemo(() => {
34
60
  const labels = new Map<string, string>();
61
+ const volumes = new Map<string, number>();
35
62
  for (const el of elements) {
36
63
  if (el.audioGroup && !labels.has(el.audioGroup)) {
37
64
  labels.set(el.audioGroup, el.audioGroupLabel ?? el.audioGroup);
65
+ volumes.set(el.audioGroup, el.audioGroupVolume ?? 1);
38
66
  }
39
67
  }
40
- return [...[...labels].map(([id, label]) => ({ id, label })), MASTER];
41
- }, [elements]);
68
+ return [
69
+ ...[...labels].map(([id, label]) => ({ id, label, volume: volumes.get(id) ?? 1 })),
70
+ { id: null, label: MONITOR_LABEL, volume: masterVolume },
71
+ ];
72
+ }, [elements, masterVolume]);
73
+ }
74
+
75
+ /** Group volume through the existing `data-volume` write path (live while dragging, one
76
+ * undo entry on release); monitor volume through the player store's own volume action —
77
+ * the same one `VolumeControl` in `PlayerControls` already drives. */
78
+ function useVolumeHandlers(): {
79
+ onLive: (id: string | null, volume: number) => void;
80
+ onCommit: (id: string | null, volume: number) => void;
81
+ } {
82
+ const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
83
+ useTimelineEditContextOptional();
84
+ const setAudioVolume = usePlayerStore((s) => s.setAudioVolume);
85
+ const onLive = useCallback(
86
+ (id: string | null, volume: number) => {
87
+ if (id === null) setAudioVolume(volume);
88
+ else onSetAudioGroupAttributeLive?.(id, "data-volume", formatAudioGain(volume));
89
+ },
90
+ [onSetAudioGroupAttributeLive, setAudioVolume],
91
+ );
92
+ const onCommit = useCallback(
93
+ (id: string | null, volume: number) => {
94
+ if (id === null) setAudioVolume(volume);
95
+ else
96
+ void onSetAudioGroupAttributeQuiet?.(
97
+ id,
98
+ "data-volume",
99
+ formatAudioGain(volume),
100
+ "Set volume",
101
+ );
102
+ },
103
+ [onSetAudioGroupAttributeQuiet, setAudioVolume],
104
+ );
105
+ return { onLive, onCommit };
42
106
  }
43
107
 
44
108
  type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
@@ -59,13 +123,61 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
59
123
  el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
60
124
  }
61
125
 
126
+ /** The fill is a fixed green/amber/red backdrop; painting only moves the dark
127
+ * mask that covers the unlit top portion, so a loud peak lights the real red
128
+ * band instead of tinting a flat colour brighter. */
62
129
  function paint(bars: StripBars | undefined, channels: Pair): void {
63
130
  channels.forEach((ch, i) => {
64
- bars?.[i]?.fill?.style.setProperty("transform", `scaleY(${ch.level})`);
131
+ bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
65
132
  paintPeak(bars?.[i]?.peak ?? null, ch.peak);
66
133
  });
67
134
  }
68
135
 
136
+ /** Attach to the live preview hook. Always returns `live`, even if start/stop throw. */
137
+ export function followMeterHook(
138
+ active: AudioMeterHook | null,
139
+ live: AudioMeterHook | null,
140
+ ): AudioMeterHook | null {
141
+ if (live === active) return active;
142
+ try {
143
+ active?.stop();
144
+ } catch {
145
+ // Preview iframe was torn down; the old hook is uncallable.
146
+ }
147
+ try {
148
+ live?.start();
149
+ } catch {
150
+ // New preview is not ready to attach yet.
151
+ }
152
+ return live;
153
+ }
154
+
155
+ export function evictGoneMeterState(
156
+ state: Map<string | null, Pair>,
157
+ liveIds: ReadonlySet<string | null>,
158
+ ): void {
159
+ for (const id of [...state.keys()]) {
160
+ if (!liveIds.has(id)) state.delete(id);
161
+ }
162
+ }
163
+
164
+ export function stepAndPaintStrips(
165
+ strips: readonly { id: string | null }[],
166
+ state: Map<string | null, Pair>,
167
+ bars: Map<string | null, StripBars>,
168
+ levels: Levels | undefined,
169
+ now: number,
170
+ dt: number,
171
+ ): void {
172
+ for (const { id } of strips) {
173
+ const prev = state.get(id);
174
+ const next = stepPair(prev, id === null ? levels?.master : levels?.groups[id], now, dt);
175
+ if (next === prev) continue;
176
+ state.set(id, next);
177
+ paint(bars.get(id), next);
178
+ }
179
+ }
180
+
69
181
  /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
70
182
  function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
71
183
  const { previewIframeRef } = useStudioShellContext();
@@ -78,34 +190,12 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
78
190
  const state = new Map<string | null, Pair>();
79
191
  const tick = (now: number) => {
80
192
  raf = requestAnimationFrame(tick);
81
- const live = readHook(previewIframeRef.current);
82
- if (live !== active) {
83
- try {
84
- active?.stop();
85
- } catch {
86
- // Preview iframe was torn down; the old hook is uncallable.
87
- }
88
- try {
89
- live?.start();
90
- } catch {
91
- // New preview is not ready to attach yet.
92
- }
93
- active = live;
94
- }
193
+ active = followMeterHook(active, readHook(previewIframeRef.current));
95
194
  const levels = active?.read();
96
195
  const dt = now - last;
97
196
  last = now;
98
- for (const { id } of stripsRef.current) {
99
- const next = stepPair(
100
- state.get(id),
101
- id === null ? levels?.master : levels?.groups[id],
102
- now,
103
- dt,
104
- );
105
- if (next === state.get(id)) continue;
106
- state.set(id, next);
107
- paint(bars.current.get(id), next);
108
- }
197
+ evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
198
+ stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
109
199
  };
110
200
  raf = requestAnimationFrame(tick);
111
201
  return () => {
@@ -119,18 +209,124 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
119
209
  }, [previewIframeRef, bars]);
120
210
  }
121
211
 
122
- function Bar({ fillRef, peakRef }: { fillRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
212
+ function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
123
213
  return (
124
- <div className="relative h-full w-1.5 overflow-hidden rounded-[1px] bg-neutral-900">
214
+ <div className="relative h-full w-[18px] overflow-hidden rounded-[2px] bg-neutral-900">
215
+ <div
216
+ className="absolute inset-x-0 bottom-0 bg-green-500"
217
+ style={{ height: `${AMBER_AT * 100}%` }}
218
+ />
219
+ <div
220
+ className="absolute inset-x-0 bg-amber-500"
221
+ style={{ bottom: `${AMBER_AT * 100}%`, height: `${(RED_AT - AMBER_AT) * 100}%` }}
222
+ />
223
+ <div
224
+ className="absolute inset-x-0 top-0 bg-red-500"
225
+ style={{ height: `${(1 - RED_AT) * 100}%` }}
226
+ />
125
227
  <div
126
- ref={fillRef}
127
- className="absolute inset-0 origin-bottom bg-green-500"
128
- style={{ transform: "scaleY(0)" }}
228
+ ref={maskRef}
229
+ data-testid="meter-mask"
230
+ className="absolute inset-x-0 top-0 bg-neutral-900"
231
+ style={{ height: "100%" }}
129
232
  />
130
233
  <div
131
234
  ref={peakRef}
132
235
  data-testid="meter-peak"
133
- className="absolute inset-x-0 bottom-0 h-px bg-green-400"
236
+ className="absolute inset-x-0 bottom-0 h-px bg-white"
237
+ />
238
+ </div>
239
+ );
240
+ }
241
+
242
+ /** Uses the authored clip-gain scale; meter readings have a separate scale. */
243
+ function Fader({
244
+ label,
245
+ title,
246
+ volume,
247
+ maxPosition,
248
+ onLive,
249
+ onCommit,
250
+ }: {
251
+ label: string;
252
+ title: string;
253
+ volume: number;
254
+ maxPosition: number;
255
+ onLive: (v: number) => void;
256
+ onCommit: (v: number) => void;
257
+ }) {
258
+ const trackRef = useRef<HTMLDivElement>(null);
259
+ const draggingRef = useRef<number | null>(null);
260
+ const position = audioGainToFaderPosition(volume);
261
+ const span = maxPosition - AUDIO_GAIN_FADER_MIN;
262
+ const fraction = (position - AUDIO_GAIN_FADER_MIN) / span;
263
+ const readout = audioGainToText(volume);
264
+
265
+ const positionAt = useCallback(
266
+ (clientY: number): number => {
267
+ const rect = trackRef.current?.getBoundingClientRect();
268
+ if (!rect || rect.height === 0) return position;
269
+ return (
270
+ AUDIO_GAIN_FADER_MIN + clampNumber(1 - (clientY - rect.top) / rect.height, 0, 1) * span
271
+ );
272
+ },
273
+ [position, span],
274
+ );
275
+
276
+ const moveTo = (clientY: number) => {
277
+ const gain = audioFaderPositionToGain(positionAt(clientY));
278
+ draggingRef.current = gain;
279
+ onLive(gain);
280
+ };
281
+ const finishDrag = () => {
282
+ if (draggingRef.current === null) return;
283
+ const gain = draggingRef.current;
284
+ draggingRef.current = null;
285
+ onCommit(gain);
286
+ };
287
+ const keyPositions = new Map([
288
+ ["ArrowUp", position + span * 0.02],
289
+ ["ArrowDown", position - span * 0.02],
290
+ ["PageUp", position + span * 0.1],
291
+ ["PageDown", position - span * 0.1],
292
+ ["Home", AUDIO_GAIN_FADER_MIN],
293
+ ["End", maxPosition],
294
+ ]);
295
+
296
+ return (
297
+ <div
298
+ ref={trackRef}
299
+ role="slider"
300
+ tabIndex={0}
301
+ aria-label={label}
302
+ title={`${title}: ${readout}`}
303
+ aria-orientation="vertical"
304
+ aria-valuemin={AUDIO_GAIN_FADER_MIN}
305
+ aria-valuemax={maxPosition}
306
+ aria-valuenow={Math.round(position)}
307
+ aria-valuetext={readout}
308
+ onPointerDown={(e: ReactPointerEvent<HTMLDivElement>) => {
309
+ e.currentTarget.setPointerCapture(e.pointerId);
310
+ moveTo(e.clientY);
311
+ }}
312
+ onPointerMove={(e: ReactPointerEvent<HTMLDivElement>) => {
313
+ if (draggingRef.current === null) return;
314
+ moveTo(e.clientY);
315
+ }}
316
+ onPointerUp={finishDrag}
317
+ onPointerCancel={finishDrag}
318
+ onLostPointerCapture={finishDrag}
319
+ onKeyDown={(e) => {
320
+ const next = keyPositions.get(e.key);
321
+ if (next === undefined) return;
322
+ onCommit(audioFaderPositionToGain(clampNumber(next, AUDIO_GAIN_FADER_MIN, maxPosition)));
323
+ e.preventDefault();
324
+ }}
325
+ className="relative h-full w-2 shrink-0 cursor-ns-resize touch-none rounded-full bg-neutral-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
326
+ >
327
+ <div
328
+ className="absolute inset-x-[-3px] h-1.5 -translate-y-1/2 rounded-full bg-neutral-200"
329
+ style={{ bottom: `${fraction * 100}%` }}
134
330
  />
135
331
  </div>
136
332
  );
@@ -139,9 +335,13 @@ function Bar({ fillRef, peakRef }: { fillRef: Ref<HTMLDivElement>; peakRef: Ref<
139
335
  function MeterStrip({
140
336
  strip,
141
337
  register,
338
+ onLive,
339
+ onCommit,
142
340
  }: {
143
341
  strip: Strip;
144
342
  register: (id: string | null, bars: StripBars | null) => void;
343
+ onLive: (id: string | null, volume: number) => void;
344
+ onCommit: (id: string | null, volume: number) => void;
145
345
  }) {
146
346
  const refs = [
147
347
  useRef<HTMLDivElement>(null),
@@ -151,22 +351,28 @@ function MeterStrip({
151
351
  ] as const;
152
352
  useEffect(() => {
153
353
  register(strip.id, [
154
- { fill: refs[0].current, peak: refs[1].current },
155
- { fill: refs[2].current, peak: refs[3].current },
354
+ { mask: refs[0].current, peak: refs[1].current },
355
+ { mask: refs[2].current, peak: refs[3].current },
156
356
  ]);
157
357
  return () => register(strip.id, null);
158
358
  // refs are stable objects
159
359
  // eslint-disable-next-line react-hooks/exhaustive-deps
160
360
  }, [strip.id, register]);
161
361
  return (
162
- <div className="flex w-16 shrink-0 flex-col items-center gap-1 px-1 pt-2 pb-1">
362
+ <div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
163
363
  <div
164
- className="flex min-h-0 flex-1 items-stretch gap-0.5"
364
+ className="flex min-h-0 flex-1 items-stretch gap-1.5"
165
365
  aria-label={`${strip.label} level`}
166
366
  >
167
- <Bar fillRef={refs[0]} peakRef={refs[1]} />
168
- <Bar fillRef={refs[2]} peakRef={refs[3]} />
169
- <div className="relative ml-1 w-5 font-mono text-[9px] leading-none text-neutral-500">
367
+ <Fader
368
+ label={`${strip.label} volume`}
369
+ title={strip.id === null ? "Preview monitor volume" : `${strip.label} volume`}
370
+ volume={strip.volume}
371
+ maxPosition={strip.id === null ? audioGainToFaderPosition(1) : AUDIO_GAIN_FADER_MAX}
372
+ onLive={(v) => onLive(strip.id, v)}
373
+ onCommit={(v) => onCommit(strip.id, v)}
374
+ />
375
+ <div className="relative w-5 font-mono text-[9px] leading-none text-neutral-500">
170
376
  {METER_DB_MARKS.map((db) => (
171
377
  <span
172
378
  key={db}
@@ -177,6 +383,10 @@ function MeterStrip({
177
383
  </span>
178
384
  ))}
179
385
  </div>
386
+ <div className="flex items-stretch gap-0.5">
387
+ <Bar maskRef={refs[0]} peakRef={refs[1]} />
388
+ <Bar maskRef={refs[2]} peakRef={refs[3]} />
389
+ </div>
180
390
  </div>
181
391
  <span className="max-w-full truncate text-[10px] text-neutral-400" title={strip.label}>
182
392
  {strip.label}
@@ -199,6 +409,7 @@ function MeterStripBody() {
199
409
  if (b) bars.current.set(id, b);
200
410
  else bars.current.delete(id);
201
411
  }).current;
412
+ const { onLive, onCommit } = useVolumeHandlers();
202
413
  useMeterLoop(strips, bars);
203
414
  return (
204
415
  <div
@@ -206,7 +417,13 @@ function MeterStripBody() {
206
417
  className="flex shrink-0 overflow-x-auto border-l border-neutral-800/50 bg-neutral-950"
207
418
  >
208
419
  {strips.map((strip) => (
209
- <MeterStrip key={strip.id ?? "master"} strip={strip} register={register} />
420
+ <MeterStrip
421
+ key={strip.id ?? "master"}
422
+ strip={strip}
423
+ register={register}
424
+ onLive={onLive}
425
+ onCommit={onCommit}
426
+ />
210
427
  ))}
211
428
  </div>
212
429
  );
@@ -360,10 +360,9 @@ describe("open motion", () => {
360
360
 
361
361
  // AE4.
362
362
  it("names a motion token that zeroes itself under reduced motion", async () => {
363
- // The zero-duration case lives in the `duration-open` utility itself
364
- // (theme.css), not in a `motion-reduce:` class beside it, so a caller
365
- // cannot use the token and forget the reduced-motion half. The menu's job
366
- // is to name the token; the stylesheet's job is the media query.
363
+ // Reduced motion lives in the `duration-open` utility itself (theme.css),
364
+ // not in a `motion-reduce:` class beside it, so a caller cannot use the
365
+ // token and forget that half. The menu's job is to name the token.
367
366
  await openActionMenu();
368
367
 
369
368
  expect([...popup()!.classList]).toContain("duration-open");
@@ -27,15 +27,28 @@ const SIDE_OFFSET = 6;
27
27
  const VIEWPORT_MARGIN = 8;
28
28
 
29
29
  /**
30
- * Chrome shared by every floating panel (`Popover` too); callers add their own shadow token.
30
+ * Enter and exit shared by menus, popovers, dropdowns, and tooltips.
31
+ * The open duration class differs; the shape does not.
31
32
  * `data-starting-style`/`data-ending-style` are Base UI's transition attributes.
32
33
  */
34
+ export function floatingMotion(openDuration: "duration-open" | "duration-tooltip") {
35
+ return cn(
36
+ "origin-[var(--transform-origin)] outline-hidden",
37
+ "transition-[opacity,transform] ease-out-quint",
38
+ openDuration,
39
+ "data-[ending-style]:duration-close data-[ending-style]:ease-in",
40
+ "data-[starting-style]:[opacity:var(--popup-enter-opacity)]",
41
+ "data-[starting-style]:[scale:var(--popup-enter-scale)]",
42
+ "data-[ending-style]:opacity-0",
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Chrome shared by every floating panel (`Popover` too); callers add their own shadow token.
48
+ */
33
49
  export const popupSurface = cn(
34
50
  "rounded-lg border border-border-input bg-surface",
35
- "origin-[var(--transform-origin)] outline-hidden",
36
- "transition-[opacity,transform] ease-out-quint duration-open",
37
- "data-[starting-style]:opacity-0 data-[starting-style]:scale-95",
38
- "data-[ending-style]:opacity-0 data-[ending-style]:scale-95",
51
+ floatingMotion("duration-open"),
39
52
  "data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
40
53
  );
41
54
 
@@ -6,6 +6,7 @@
6
6
  import { Select as BaseSelect } from "@base-ui/react/select";
7
7
  import { cn } from "./cn";
8
8
  import { fieldBase } from "./Input";
9
+ import { floatingMotion } from "./Menu";
9
10
  import type { PreviewState } from "./Button";
10
11
 
11
12
  export interface SelectOption {
@@ -77,7 +78,12 @@ export function Select({
77
78
 
78
79
  <BaseSelect.Portal>
79
80
  <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
80
- <BaseSelect.Popup className="min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu outline-hidden">
81
+ <BaseSelect.Popup
82
+ className={cn(
83
+ "min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
84
+ floatingMotion("duration-open"),
85
+ )}
86
+ >
81
87
  <BaseSelect.List>
82
88
  {options.map((option) => (
83
89
  <BaseSelect.Item
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
7
7
  import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
8
+ import { cn } from "./cn";
9
+ import { floatingMotion } from "./Menu";
8
10
 
9
11
  interface TooltipProps {
10
12
  label: string;
@@ -42,7 +44,10 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
42
44
  <BaseTooltip.Popup
43
45
  id={tooltipId}
44
46
  role="tooltip"
45
- className="pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 whitespace-nowrap shadow-menu"
47
+ className={cn(
48
+ "pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 whitespace-nowrap shadow-menu",
49
+ floatingMotion("duration-tooltip"),
50
+ )}
46
51
  >
47
52
  {label}
48
53
  </BaseTooltip.Popup>
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useMemo, useRef } from "react";
1
+ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
@@ -136,11 +136,12 @@ export const AudioWaveform = memo(function AudioWaveform({
136
136
  const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
137
137
  const span = end - start;
138
138
  const barCount = Math.floor(width / BAR_STEP);
139
+ const waveformBarRgb = getComputedStyle(canvas).getPropertyValue("--timeline-waveform-bar-rgb");
139
140
  for (let index = 0; index < barCount; index++) {
140
141
  const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
141
142
  const amplitude = peaks[peakIndex] ?? 0;
142
143
  const barHeight = Math.max(2, amplitude * height);
143
- context.fillStyle = `rgba(75,163,210,${(0.45 + amplitude * 0.4).toFixed(2)})`;
144
+ context.fillStyle = `rgba(${waveformBarRgb},${(0.45 + amplitude * 0.4).toFixed(2)})`;
144
145
  context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
145
146
  }
146
147
  }, [peaks, trimEndFraction, trimStartFraction]);
@@ -157,6 +158,16 @@ export const AudioWaveform = memo(function AudioWaveform({
157
158
  [draw],
158
159
  );
159
160
 
161
+ useEffect(() => {
162
+ const root = document.documentElement;
163
+ const observer = new MutationObserver(draw);
164
+ observer.observe(root, {
165
+ attributes: true,
166
+ attributeFilter: ["class", "data-chrome", "data-theme", "style"],
167
+ });
168
+ return () => observer.disconnect();
169
+ }, [draw]);
170
+
160
171
  useMountEffect(() => () => observerRef.current?.disconnect());
161
172
 
162
173
  return (
@@ -170,8 +181,7 @@ export const AudioWaveform = memo(function AudioWaveform({
170
181
  <div
171
182
  className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
172
183
  style={{
173
- background:
174
- "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
184
+ background: "var(--timeline-thumbnail-shimmer)",
175
185
  }}
176
186
  />
177
187
  )}
@@ -187,8 +197,7 @@ export const AudioWaveform = memo(function AudioWaveform({
187
197
  style={{
188
198
  bottom: "20%",
189
199
  height: 2,
190
- background:
191
- "repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
200
+ background: "var(--timeline-waveform-error)",
192
201
  }}
193
202
  />
194
203
  <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
@@ -200,7 +209,7 @@ export const AudioWaveform = memo(function AudioWaveform({
200
209
  <div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
201
210
  <span
202
211
  className="block truncate text-[9px] font-semibold leading-tight"
203
- style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
212
+ style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
204
213
  >
205
214
  {label}
206
215
  </span>
@@ -91,8 +91,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
91
91
  <div
92
92
  className="absolute inset-0 animate-pulse"
93
93
  style={{
94
- background:
95
- "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
94
+ background: "var(--timeline-thumbnail-shimmer)",
96
95
  }}
97
96
  />
98
97
  )}
@@ -100,13 +99,12 @@ export const ImageThumbnail = memo(function ImageThumbnail({
100
99
  <div
101
100
  className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
102
101
  style={{
103
- background:
104
- "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
102
+ background: "var(--timeline-thumbnail-label-gradient)",
105
103
  }}
106
104
  >
107
105
  <span
108
106
  className="block truncate text-[9px] font-semibold leading-tight"
109
- style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
107
+ style={{ color: labelColor, textShadow: "var(--timeline-thumbnail-label-shadow)" }}
110
108
  >
111
109
  {label}
112
110
  </span>
@@ -37,8 +37,10 @@ export function LaneToggleButton({
37
37
  title={`${isExpanded ? "Hide" : "Show"} lanes`}
38
38
  // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
39
39
  // only the hit box grows.
40
- className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
41
- isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
40
+ className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)] ${
41
+ isExpanded
42
+ ? "text-[var(--timeline-accent)]"
43
+ : "text-[var(--timeline-handle)] hover:text-[var(--timeline-text-solid)]"
42
44
  }`}
43
45
  onPointerDown={(event) => event.stopPropagation()}
44
46
  onClick={(event) => {
@@ -86,13 +88,16 @@ export function LayerDisclosureRow({
86
88
  style={{
87
89
  width: columnWidth,
88
90
  height: TRACK_H,
89
- color: "#ffffff",
91
+ color: "var(--timeline-text-solid)",
90
92
  background: gutterBackground,
91
93
  }}
92
94
  >
93
95
  {/* Decorative: the disclosure button above already names the row's keyframe
94
96
  state, and aria-label on a plain span is not exposed reliably anyway. */}
95
- <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
97
+ <span
98
+ aria-hidden="true"
99
+ className="shrink-0 text-[13px] leading-none text-[var(--timeline-text-dim)]"
100
+ >
96
101
  ◇
97
102
  </span>
98
103
  <span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
@@ -26,8 +26,8 @@ interface PlayheadIndicatorProps {
26
26
  }
27
27
 
28
28
  export function PlayheadIndicator({
29
- color = "var(--hf-accent, #3CE6AC)",
30
- glowColor = "rgba(60,230,172,0.14)",
29
+ color = "var(--timeline-accent)",
30
+ glowColor = "var(--timeline-playhead-glow)",
31
31
  scrubbing = false,
32
32
  stickyHead = true,
33
33
  }: PlayheadIndicatorProps) {
@@ -93,7 +93,7 @@ export function PlayheadIndicator({
93
93
  background: scrubbing ? color : "transparent",
94
94
  border: `1.5px solid ${color}`,
95
95
  boxSizing: "border-box",
96
- boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
96
+ boxShadow: `0 1px 3px var(--timeline-playhead-shadow), 0 0 5px ${glowColor}`,
97
97
  transform: "rotate(45deg)",
98
98
  }}
99
99
  />
@@ -483,7 +483,7 @@ describe("TimelineAutomationLane", () => {
483
483
  // Bigger than the points around them, and ringed rather than recoloured — the
484
484
  // fill is the parameter's own colour and stays that way.
485
485
  expect(Math.min(...radii)).toBeGreaterThan(Math.max(...plain));
486
- expect(marked[0]?.getAttribute("stroke")).toBe("#fff");
486
+ expect(marked[0]?.getAttribute("stroke")).toBe("var(--timeline-text-solid)");
487
487
  });
488
488
 
489
489
  it("marks nothing when there is no range", () => {