@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
@@ -1,7 +1,8 @@
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";
5
+ import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
5
6
 
6
7
  interface AudioWaveformProps {
7
8
  audioUrl: string;
@@ -13,11 +14,68 @@ interface AudioWaveformProps {
13
14
  projectId: string;
14
15
  sessionEpoch: number;
15
16
  priority: ThumbnailPriority;
17
+ /** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
18
+ muted?: boolean;
19
+ /** Same media file as a video clip. Draws the 1px parent tick. */
20
+ linked?: boolean;
16
21
  }
17
22
 
18
- const BAR_WIDTH = 2;
19
23
  const BAR_STEP = 3;
20
24
 
25
+ type BarGeometry = { x: number; width: number; height: number };
26
+
27
+ function paintWaveformBars(
28
+ context: CanvasRenderingContext2D,
29
+ bars: readonly BarGeometry[],
30
+ height: number,
31
+ waveformBarRgb: string,
32
+ waveformBaselineRgb: string,
33
+ amplitudes: readonly number[],
34
+ ) {
35
+ bars.forEach((bar, index) => {
36
+ const amplitude = amplitudes[index] ?? 0;
37
+ context.fillStyle = `rgb(${waveformBaselineRgb})`;
38
+ context.fillRect(bar.x, height - 2, bar.width, 2);
39
+ context.fillStyle = `rgba(${waveformBarRgb},${loudnessToOpacity(amplitude).toFixed(2)})`;
40
+ context.fillRect(bar.x, height - bar.height, bar.width, bar.height);
41
+ });
42
+ }
43
+
44
+ export function drawWaveformCanvas(
45
+ canvas: HTMLCanvasElement,
46
+ peaks: readonly number[],
47
+ muted: boolean,
48
+ trimStartFraction: number,
49
+ trimEndFraction: number,
50
+ ) {
51
+ const width = Math.max(1, canvas.clientWidth);
52
+ const height = Math.max(1, canvas.clientHeight);
53
+ const scale = window.devicePixelRatio || 1;
54
+ canvas.width = Math.ceil(width * scale);
55
+ canvas.height = Math.ceil(height * scale);
56
+ const context = canvas.getContext("2d");
57
+ if (!context) return;
58
+ context.scale(scale, scale);
59
+ context.clearRect(0, 0, width, height);
60
+ const amplitudes = decimatePeaks(
61
+ peaks,
62
+ trimStartFraction,
63
+ trimEndFraction,
64
+ Math.max(1, Math.ceil(width / BAR_STEP)),
65
+ );
66
+ const bars = amplitudes.map((amplitude, index) => ({
67
+ x: (index * width) / amplitudes.length,
68
+ width: Math.max(1, width / amplitudes.length),
69
+ height: Math.max(3, amplitude * height),
70
+ }));
71
+ const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
72
+ const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
73
+ const waveformBaselineRgb = getComputedStyle(canvas).getPropertyValue(
74
+ "--timeline-waveform-baseline-rgb",
75
+ );
76
+ paintWaveformBars(context, bars, height, waveformBarRgb, waveformBaselineRgb, amplitudes);
77
+ }
78
+
21
79
  function extractPeaks(channelData: Float32Array, barCount: number): number[] {
22
80
  const peaks: number[] = [];
23
81
  const samplesPerBar = Math.floor(channelData.length / barCount);
@@ -92,7 +150,10 @@ export const AudioWaveform = memo(function AudioWaveform({
92
150
  projectId,
93
151
  sessionEpoch,
94
152
  priority,
153
+ muted = false,
154
+ linked = false,
95
155
  }: AudioWaveformProps) {
156
+ const rootRef = useRef<HTMLDivElement | null>(null);
96
157
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
97
158
  const observerRef = useRef<ResizeObserver | null>(null);
98
159
  const cacheKey = waveformUrl ?? audioUrl;
@@ -121,29 +182,8 @@ export const AudioWaveform = memo(function AudioWaveform({
121
182
  const draw = useCallback(() => {
122
183
  const canvas = canvasRef.current;
123
184
  if (!canvas || !peaks) return;
124
- const width = Math.max(1, canvas.clientWidth);
125
- const height = Math.max(1, canvas.clientHeight);
126
- const scale = window.devicePixelRatio || 1;
127
- canvas.width = Math.ceil(width * scale);
128
- canvas.height = Math.ceil(height * scale);
129
- const context = canvas.getContext("2d");
130
- if (!context) return;
131
- context.scale(scale, scale);
132
- context.clearRect(0, 0, width, height);
133
- const startFraction = Math.max(0, Math.min(1, trimStartFraction ?? 0));
134
- const endFraction = Math.max(startFraction, Math.min(1, trimEndFraction ?? 1));
135
- const start = Math.floor(startFraction * peaks.length);
136
- const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
137
- const span = end - start;
138
- const barCount = Math.floor(width / BAR_STEP);
139
- for (let index = 0; index < barCount; index++) {
140
- const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
141
- const amplitude = peaks[peakIndex] ?? 0;
142
- const barHeight = Math.max(2, amplitude * height);
143
- context.fillStyle = `rgba(75,163,210,${(0.45 + amplitude * 0.4).toFixed(2)})`;
144
- context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
145
- }
146
- }, [peaks, trimEndFraction, trimStartFraction]);
185
+ drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
186
+ }, [muted, peaks, trimEndFraction, trimStartFraction]);
147
187
 
148
188
  const setCanvasRef = useCallback(
149
189
  (canvas: HTMLCanvasElement | null) => {
@@ -157,55 +197,74 @@ export const AudioWaveform = memo(function AudioWaveform({
157
197
  [draw],
158
198
  );
159
199
 
200
+ useEffect(() => {
201
+ const root = document.documentElement;
202
+ const observer = new MutationObserver(draw);
203
+ observer.observe(root, {
204
+ attributes: true,
205
+ attributeFilter: ["class", "data-chrome", "data-theme", "style"],
206
+ });
207
+ return () => observer.disconnect();
208
+ }, [draw]);
209
+
160
210
  useMountEffect(() => () => observerRef.current?.disconnect());
161
211
 
212
+ useEffect(() => {
213
+ const clip = rootRef.current?.closest(".timeline-clip");
214
+ if (!(clip instanceof HTMLElement)) return;
215
+ if (muted) clip.setAttribute("data-audio-muted", "true");
216
+ else clip.removeAttribute("data-audio-muted");
217
+ return () => clip.removeAttribute("data-audio-muted");
218
+ }, [muted]);
219
+
162
220
  return (
163
- <div className="absolute inset-0 overflow-hidden">
164
- <canvas
165
- ref={setCanvasRef}
166
- className="absolute inset-x-0 bottom-0 w-full"
167
- style={{ top: 16 }}
168
- />
169
- {snapshot.status === "loading" && (
170
- <div
171
- className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
172
- 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%)",
175
- }}
221
+ <div ref={rootRef} className="absolute inset-0">
222
+ {linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
223
+ <div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
224
+ <canvas
225
+ ref={setCanvasRef}
226
+ className="absolute inset-x-0 bottom-0 w-full"
227
+ style={{ top: 16, height: "calc(100% - 16px)" }}
176
228
  />
177
- )}
178
- {/* Degraded state — the decode failed; say so rather than paint a
179
- waveform the author could edit against. */}
180
- {snapshot.status === "error" && (
181
- <div
182
- className="absolute inset-x-0 flex items-center justify-center gap-1.5"
183
- style={{ top: 16, bottom: 0 }}
184
- >
229
+ {snapshot.status === "loading" && (
185
230
  <div
186
- className="absolute inset-x-0"
231
+ className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
187
232
  style={{
188
- bottom: "20%",
189
- height: 2,
190
- background:
191
- "repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
233
+ background: "var(--timeline-thumbnail-shimmer)",
192
234
  }}
193
235
  />
194
- <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
195
- waveform unavailable
196
- </span>
197
- </div>
198
- )}
199
- {label && (
200
- <div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
201
- <span
202
- className="block truncate text-[9px] font-semibold leading-tight"
203
- style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
236
+ )}
237
+ {/* Degraded state — the decode failed; say so rather than paint a
238
+ waveform the author could edit against. */}
239
+ {snapshot.status === "error" && (
240
+ <div
241
+ className="absolute inset-x-0 flex items-center justify-center gap-1.5"
242
+ style={{ top: 16, bottom: 0 }}
204
243
  >
205
- {label}
206
- </span>
207
- </div>
208
- )}
244
+ <div
245
+ className="absolute inset-x-0"
246
+ style={{
247
+ bottom: "20%",
248
+ height: 2,
249
+ background: "var(--timeline-waveform-error)",
250
+ }}
251
+ />
252
+ <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
253
+ waveform unavailable
254
+ </span>
255
+ </div>
256
+ )}
257
+ {label && (
258
+ <div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
259
+ <span
260
+ className="block truncate text-[9px] font-semibold leading-tight"
261
+ style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
262
+ >
263
+ {label}
264
+ </span>
265
+ </div>
266
+ )}
267
+ </div>
209
268
  </div>
210
269
  );
211
270
  });
@@ -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", () => {
@@ -75,7 +75,7 @@ function pointCircleStyle(
75
75
  radius: POINT_R * (dragging ? 1.3 : inRange ? 1.15 : 1),
76
76
  // A white ring rather than a different fill: the fill is the parameter's
77
77
  // own colour, and a lane with two envelopes on it is read by colour first.
78
- stroke: inRange ? "#fff" : "rgba(0,0,0,0.5)",
78
+ stroke: inRange ? "var(--timeline-text-solid)" : "var(--timeline-automation-point-muted)",
79
79
  strokeWidth: inRange ? 1.5 : 1,
80
80
  };
81
81
  }
@@ -132,7 +132,7 @@ function ReadOnlyNote({
132
132
  return (
133
133
  <div
134
134
  data-automation-readonly-note=""
135
- className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-black/85 px-1.5 py-0.5 text-[9px] text-white/80"
135
+ className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-[var(--timeline-overlay-bg-strong)] px-1.5 py-0.5 text-[9px] text-[var(--timeline-overlay-text)]"
136
136
  style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
137
137
  >
138
138
  {note}
@@ -470,7 +470,7 @@ export function TimelineAutomationLane({
470
470
  x2={PAD_X + widthPx}
471
471
  y1={pad + inner / 2}
472
472
  y2={pad + inner / 2}
473
- stroke="rgba(255,255,255,0.08)"
473
+ stroke="var(--timeline-automation-rail)"
474
474
  strokeDasharray="3 4"
475
475
  />
476
476
  {rangeSelection ? (
@@ -541,7 +541,7 @@ export function TimelineAutomationLane({
541
541
  cx={xOf(playheadSec)}
542
542
  cy={yOf(currentValue)}
543
543
  r={2.5}
544
- fill="#fff"
544
+ fill="var(--timeline-text-solid)"
545
545
  opacity={0.8}
546
546
  pointerEvents="none"
547
547
  />
@@ -561,7 +561,7 @@ export function TimelineAutomationLane({
561
561
 
562
562
  {hint ? (
563
563
  <div
564
- className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-black/80 px-1 py-0.5 font-mono text-[9px] text-white"
564
+ className="hf-automation-hint pointer-events-none absolute rounded-[3px] bg-[var(--timeline-overlay-bg)] px-1 py-0.5 font-mono text-[9px] text-[var(--timeline-text-solid)]"
565
565
  style={{ left: leftPx + 6, top: 2, zIndex: 3 }}
566
566
  >
567
567
  {hint}
@@ -94,7 +94,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
94
94
  left: props.contentOrigin + gap.start * props.pps,
95
95
  width: Math.max((gap.end - gap.start) * props.pps, 2),
96
96
  height: TRACK_H - CLIP_Y * 2,
97
- background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
97
+ background: loud ? "var(--timeline-accent-soft)" : "var(--timeline-accent-faint)",
98
98
  borderRadius: 4,
99
99
  zIndex: 25,
100
100
  }}
@@ -112,8 +112,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
112
112
  left: props.contentOrigin + draggedClip.previewStart * props.pps,
113
113
  width: Math.max(draggedClip.element.duration * props.pps, 4),
114
114
  height: draggedClipHeight,
115
- border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
116
- background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
115
+ border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
116
+ background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
117
117
  borderRadius: 4,
118
118
  zIndex: 30,
119
119
  }}
@@ -132,8 +132,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
132
132
  left: props.contentOrigin + props.dropPreview.start * props.pps,
133
133
  width: DROP_PREVIEW_SECONDS * props.pps,
134
134
  height: TRACK_H - CLIP_Y * 2,
135
- border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
136
- background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
135
+ border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
136
+ background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
137
137
  borderRadius: 4,
138
138
  zIndex: 30,
139
139
  }}
@@ -151,8 +151,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
151
151
  left: props.contentOrigin,
152
152
  width: props.trackContentWidth,
153
153
  height: 1,
154
- background: "#3CE6AC",
155
- boxShadow: "0 0 3px rgba(60,230,172,0.5)",
154
+ background: "var(--timeline-accent)",
155
+ boxShadow: "0 0 3px var(--timeline-accent-glow)",
156
156
  zIndex: 55,
157
157
  }}
158
158
  />
@@ -167,11 +167,14 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
167
167
  top: RULER_H,
168
168
  bottom: 0,
169
169
  width: 1,
170
- background: snapGuide.type === "playhead" ? "#3CE6AC" : "rgba(255,255,255,0.6)",
170
+ background:
171
+ snapGuide.type === "playhead"
172
+ ? "var(--timeline-accent)"
173
+ : "var(--timeline-snap-guide)",
171
174
  boxShadow:
172
175
  snapGuide.type === "playhead"
173
- ? "0 0 6px rgba(60,230,172,0.5)"
174
- : "0 0 6px rgba(255,255,255,0.4)",
176
+ ? "0 0 6px var(--timeline-accent-glow)"
177
+ : "0 0 6px var(--timeline-text-dim)",
175
178
  zIndex: 60,
176
179
  }}
177
180
  />
@@ -201,8 +204,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
201
204
  top: props.marqueeRect.top,
202
205
  width: props.marqueeRect.width,
203
206
  height: props.marqueeRect.height,
204
- background: "rgba(60,230,172,0.10)",
205
- border: "1px dashed rgba(60,230,172,0.7)",
207
+ background: "var(--timeline-accent-fill)",
208
+ border: "1px dashed var(--timeline-accent-border)",
206
209
  borderRadius: 2,
207
210
  zIndex: 70,
208
211
  }}
@@ -220,9 +223,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
220
223
  width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
221
224
  top: RULER_H,
222
225
  bottom: 0,
223
- backgroundColor: "rgba(59, 130, 246, 0.12)",
224
- borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
225
- borderRight: "1px solid rgba(59, 130, 246, 0.4)",
226
+ backgroundColor: "var(--timeline-info-bg)",
227
+ borderLeft: "1px solid var(--timeline-info-border)",
228
+ borderRight: "1px solid var(--timeline-info-border)",
226
229
  zIndex: 50,
227
230
  }}
228
231
  />
@@ -69,7 +69,7 @@ function renderClip({
69
69
  describe("TimelineClip", () => {
70
70
  it("renders the clip label above custom content without showing default timecode", () => {
71
71
  const { host, root } = renderClip({
72
- element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
72
+ element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
73
73
  });
74
74
 
75
75
  expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
@@ -78,18 +78,55 @@ describe("TimelineClip", () => {
78
78
  act(() => root.unmount());
79
79
  });
80
80
 
81
- it("keeps selected narrow clips labeled even when they render custom content", () => {
81
+ it("drops the label chip under 60px even when the clip is selected", () => {
82
82
  const { host, root } = renderClip({
83
- element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
83
+ element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
84
+ pps: 59,
84
85
  isSelected: true,
85
86
  });
86
87
 
87
- expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
88
+ expect(host.querySelector(".timeline-clip__label")).toBeNull();
88
89
  expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
90
+ expect(host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("picture");
89
91
 
90
92
  act(() => root.unmount());
91
93
  });
92
94
 
95
+ it("keeps the label at 60px and fills one frame under 24px", () => {
96
+ const labeled = renderClip({
97
+ element: { id: "wide", label: "City", tag: "video", start: 0, duration: 1, track: 0 },
98
+ pps: 200,
99
+ });
100
+ expect(labeled.host.querySelector(".timeline-clip__label")?.textContent).toBe("City");
101
+ expect(labeled.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe(
102
+ "labeled",
103
+ );
104
+ expect(labeled.host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
105
+ "var(--timeline-clip-radius)",
106
+ );
107
+ act(() => labeled.root.unmount());
108
+
109
+ const frame = renderClip({
110
+ element: { id: "sliver", label: "City", tag: "img", start: 0, duration: 1, track: 0 },
111
+ pps: 23,
112
+ isSelected: true,
113
+ });
114
+ expect(frame.host.querySelector(".timeline-clip__label")).toBeNull();
115
+ expect(frame.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("frame");
116
+ act(() => frame.root.unmount());
117
+ });
118
+
119
+ it("gives audio clips the pill radius", () => {
120
+ const { host, root } = renderClip({
121
+ element: { id: "vo", label: "Voice", tag: "audio", start: 0, duration: 2, track: 1 },
122
+ pps: 100,
123
+ });
124
+ expect(host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
125
+ "var(--timeline-clip-audio-radius)",
126
+ );
127
+ act(() => root.unmount());
128
+ });
129
+
93
130
  it("marks hidden clips for active-state suppression", () => {
94
131
  const { host, root } = renderClip({
95
132
  element: {
@@ -1,6 +1,11 @@
1
1
  import { memo, type CSSProperties, type ReactNode } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
3
+ import {
4
+ clipWidthLadder,
5
+ defaultTimelineTheme,
6
+ getClipHandleOpacity,
7
+ type TimelineTheme,
8
+ } from "./timelineTheme";
4
9
  import type { TimelineEditCapabilities } from "./timelineEditing";
5
10
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
6
11
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
@@ -60,9 +65,10 @@ export const TimelineClip = memo(function TimelineClip({
60
65
  const widthPx = Math.max(el.duration * pps, 4);
61
66
  const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
62
67
  const displayLabel = el.label || el.id || el.tag;
68
+ const ladder = clipWidthLadder(widthPx);
63
69
  const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
64
- const showLabel = widthPx >= 40 || isSelected;
65
- const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
70
+ const showLabel = ladder === "labeled";
71
+ const showDefaultText = !hasCustomContent && ladder === "labeled";
66
72
  const startLabel = el.start.toFixed(1);
67
73
  const endLabel = (el.start + el.duration).toFixed(1);
68
74
  const themeVariables = {
@@ -92,7 +98,7 @@ export const TimelineClip = memo(function TimelineClip({
92
98
  width: widthPx,
93
99
  top: clipY,
94
100
  ...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
95
- borderRadius: theme.clipRadius,
101
+ borderRadius: isAudioClip ? theme.audioClipRadius : theme.clipRadius,
96
102
  ...themeVariables,
97
103
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
98
104
  // Regular cursor over clips (CapCut-style, user preference) — no grab hand.
@@ -114,6 +120,7 @@ export const TimelineClip = memo(function TimelineClip({
114
120
  data-clip-start={el.start}
115
121
  data-clip-end={el.start + el.duration}
116
122
  data-clip-hidden={el.hidden ? "true" : undefined}
123
+ data-ladder={ladder}
117
124
  data-active={isActive ? "" : undefined}
118
125
  aria-hidden={isGestureActor ? "true" : undefined}
119
126
  tabIndex={isGestureActor ? undefined : tabIndex}
@@ -258,7 +258,9 @@ describe("TimelineClipDiamonds", () => {
258
258
  const selectedDiamond = host.querySelector<HTMLButtonElement>('button[title="60%"]');
259
259
  expect(playheadDiamond?.dataset.keyframeAtPlayhead).toBe("true");
260
260
  expect(playheadDiamond?.dataset.keyframeSelected).toBe("false");
261
- expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#a3a3a3");
261
+ expect(playheadDiamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe(
262
+ "var(--timeline-diamond-muted)",
263
+ );
262
264
  expect(playheadDiamond?.querySelector('path[stroke="#4ba3d2"]')).not.toBeNull();
263
265
  expect(selectedDiamond?.dataset.keyframeAtPlayhead).toBe("false");
264
266
  expect(selectedDiamond?.dataset.keyframeSelected).toBe("true");
@@ -225,7 +225,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
225
225
  isSelected && halfFramePct > 0
226
226
  ? nearestKeyframeWithin(sorted, currentPercentage, halfFramePct)
227
227
  : null;
228
- const baseColor = isSelected ? accentColor : "#a3a3a3";
228
+ const baseColor = isSelected ? accentColor : "var(--timeline-diamond-muted)";
229
229
  const baseOpacity = isSelected ? 0.4 : 0.25;
230
230
  const canDrag = isSelected && !!onMoveKeyframe;
231
231
 
@@ -280,7 +280,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
280
280
  const isKfSelected = selectedKeyframes.has(kfKey);
281
281
  const atPlayhead = kf === playheadKeyframe;
282
282
  const isHighlighted = isKfSelected || atPlayhead;
283
- const color = isKfSelected ? accentColor : "#a3a3a3";
283
+ const color = isKfSelected ? accentColor : "var(--timeline-diamond-muted)";
284
284
 
285
285
  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
286
286
  if (e.button !== 0) return;