@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.
- package/dist/assets/{hyperframes-player-BBub3ZdI.js → hyperframes-player-Di7GyCOP.js} +1 -1
- package/dist/assets/{index-DZBHG9TW.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-D1KNPijK.js → index-Dpq2lkOm.js} +111 -111
- package/dist/assets/{index-BxZ5Cw5w.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-CI7WON-E.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +1 -5
- package/dist/index.html +2 -2
- package/dist/index.js +4439 -4118
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +5 -10
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
- package/src/components/nle/AudioMeterStrip.tsx +264 -47
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +18 -15
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +11 -4
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +105 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterVisibility.ts +2 -2
- 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 ? "
|
|
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-
|
|
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="
|
|
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="
|
|
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-
|
|
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 ? "
|
|
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(--
|
|
116
|
-
background: "color-mix(in srgb, var(--
|
|
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(--
|
|
136
|
-
background: "color-mix(in srgb, var(--
|
|
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: "
|
|
155
|
-
boxShadow: "0 0 3px
|
|
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:
|
|
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
|
|
174
|
-
: "0 0 6px
|
|
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: "
|
|
205
|
-
border: "1px dashed
|
|
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: "
|
|
224
|
-
borderLeft: "1px solid
|
|
225
|
-
borderRight: "1px solid
|
|
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:
|
|
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("
|
|
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:
|
|
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")
|
|
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 {
|
|
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 =
|
|
65
|
-
const showDefaultText = !hasCustomContent &&
|
|
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(
|
|
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 : "
|
|
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 : "
|
|
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
|
|
217
|
-
background: "
|
|
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-[
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-[
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
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: "
|
|
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-[
|
|
123
|
-
isExpanded
|
|
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-[
|
|
161
|
-
isLaneOpen
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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 = "
|
|
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("
|
|
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: "
|
|
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=
|
|
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
|
|
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: "
|
|
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: "
|
|
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("
|
|
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 = "
|
|
29
|
+
const GROUP_MEMBER_RAIL = "var(--timeline-accent-rail)";
|
|
30
30
|
const GROUP_MEMBER_INDENT = 14;
|
|
31
|
-
/** A
|
|
32
|
-
|
|
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-
|
|
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-[
|
|
43
|
-
hidden
|
|
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
|
|
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
|