@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
@@ -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;
@@ -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(
@@ -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