feat(studio): timeline clips take the designed material (#4270)

* feat(studio): timeline clips take the designed material

Picture clips use an 8px radius and audio clips a 21px pill.
Clips sit one step above the lane. The label is a chip that
drops under 60px. Selection keeps the inset outline plus a
1px inner highlight. Hover background is the only transition.

* style(studio): format timeline clip shadow

* fix(studio): restore handle-bar transition and de-pin box-shadow test
This commit is contained in:
Miguel Ángel
2026-09-22 02:15:55 -04:00
committed by GitHub
parent c029bf4087
commit 5203312c34
8 changed files with 211 additions and 88 deletions
@@ -69,7 +69,7 @@ function renderClip({
describe("TimelineClip", () => {
it("renders the clip label above custom content without showing default timecode", () => {
const { host, root } = renderClip({
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
});
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
@@ -78,18 +78,55 @@ describe("TimelineClip", () => {
act(() => root.unmount());
});
it("keeps selected narrow clips labeled even when they render custom content", () => {
it("drops the label chip under 60px even when the clip is selected", () => {
const { host, root } = renderClip({
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
pps: 59,
isSelected: true,
});
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
expect(host.querySelector(".timeline-clip__label")).toBeNull();
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
expect(host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("picture");
act(() => root.unmount());
});
it("keeps the label at 60px and fills one frame under 24px", () => {
const labeled = renderClip({
element: { id: "wide", label: "City", tag: "video", start: 0, duration: 1, track: 0 },
pps: 200,
});
expect(labeled.host.querySelector(".timeline-clip__label")?.textContent).toBe("City");
expect(labeled.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe(
"labeled",
);
expect(labeled.host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
"var(--timeline-clip-radius)",
);
act(() => labeled.root.unmount());
const frame = renderClip({
element: { id: "sliver", label: "City", tag: "img", start: 0, duration: 1, track: 0 },
pps: 23,
isSelected: true,
});
expect(frame.host.querySelector(".timeline-clip__label")).toBeNull();
expect(frame.host.querySelector(".timeline-clip")?.getAttribute("data-ladder")).toBe("frame");
act(() => frame.root.unmount());
});
it("gives audio clips the pill radius", () => {
const { host, root } = renderClip({
element: { id: "vo", label: "Voice", tag: "audio", start: 0, duration: 2, track: 1 },
pps: 100,
});
expect(host.querySelector<HTMLElement>(".timeline-clip")?.style.borderRadius).toBe(
"var(--timeline-clip-audio-radius)",
);
act(() => root.unmount());
});
it("marks hidden clips for active-state suppression", () => {
const { host, root } = renderClip({
element: {
@@ -1,6 +1,11 @@
import { memo, type CSSProperties, type ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
import {
clipWidthLadder,
defaultTimelineTheme,
getClipHandleOpacity,
type TimelineTheme,
} from "./timelineTheme";
import type { TimelineEditCapabilities } from "./timelineEditing";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
@@ -60,9 +65,10 @@ export const TimelineClip = memo(function TimelineClip({
const widthPx = Math.max(el.duration * pps, 4);
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
const displayLabel = el.label || el.id || el.tag;
const ladder = clipWidthLadder(widthPx);
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
const showLabel = widthPx >= 40 || isSelected;
const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
const showLabel = ladder === "labeled";
const showDefaultText = !hasCustomContent && ladder === "labeled";
const startLabel = el.start.toFixed(1);
const endLabel = (el.start + el.duration).toFixed(1);
const themeVariables = {
@@ -92,7 +98,7 @@ export const TimelineClip = memo(function TimelineClip({
width: widthPx,
top: clipY,
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
borderRadius: theme.clipRadius,
borderRadius: isAudioClip ? theme.audioClipRadius : theme.clipRadius,
...themeVariables,
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
@@ -114,6 +120,7 @@ export const TimelineClip = memo(function TimelineClip({
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
data-ladder={ladder}
data-active={isActive ? "" : undefined}
aria-hidden={isGestureActor ? "true" : undefined}
tabIndex={isGestureActor ? undefined : tabIndex}
@@ -13,14 +13,6 @@ const timelineOverlaySources = [
const timelineClipSource = readFileSync(new URL("./TimelineClip.tsx", import.meta.url), "utf8");
const playheadSource = readFileSync(new URL("./PlayheadIndicator.tsx", import.meta.url), "utf8");
const allowedTimelineTransitionProperties = [
"background-color",
"border-color",
"box-shadow",
"color",
"opacity",
];
function expectRule(css: string, selector: string): string {
const selectorStart = css.indexOf(`${selector} {`);
expect(selectorStart).toBeGreaterThanOrEqual(0);
@@ -39,27 +31,6 @@ function expectDeclaration(ruleBody: string, property: string): string {
return declarationMatch?.[1].trim() ?? "";
}
function transitionProperties(transitionDeclaration: string): string[] {
const items: string[] = [];
let depth = 0;
let item = "";
for (const char of transitionDeclaration) {
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (char === "," && depth === 0) {
items.push(item.trim());
item = "";
continue;
}
item += char;
}
if (item.trim().length > 0) items.push(item.trim());
return items.map((transition) => transition.split(/\s+/)[0]);
}
function themeTokenValue(token: string): string {
const match = new RegExp(`${token}:\\s*([^;]+);`).exec(themeCss);
expect(match?.[1]).toBeDefined();
@@ -90,19 +61,15 @@ describe("timeline motion styles", () => {
const motionMediaCss = studioCss.slice(mediaStart);
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
const clipTransition = expectDeclaration(timelineClipMotionRule, "transition");
expect(transitionProperties(clipTransition)).toEqual(allowedTimelineTransitionProperties);
expect(clipTransition).not.toMatch(/\b(?:all|left|width|top|bottom|transform)\b/);
expect(expectDeclaration(timelineClipMotionRule, "transition")).toBe(
"background-color 120ms ease-out",
);
});
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
const timelineClipLabelRule = expectRule(studioCss, ".timeline-clip__label");
const activeTimelineClipLabelRule = expectRule(
studioCss,
".timeline-clip[data-active] .timeline-clip__label",
);
const timelineClipTimecodeRule = expectRule(studioCss, ".timeline-clip__timecode");
const audioClipRule = expectRule(studioCss, ".timeline-clip.is-audio");
const audioClipHoverRule = expectRule(studioCss, ".timeline-clip.is-audio.is-hovered");
@@ -118,10 +85,14 @@ describe("timeline motion styles", () => {
expect(baseTimelineClipRule).toContain("background-color: var(--clip-bg)");
expect(baseTimelineClipRule).toContain("border: 1px solid var(--clip-border)");
expect(timelineClipLabelRule).toContain("color: var(--timeline-track-label)");
expect(timelineClipLabelRule).toContain("text-shadow: var(--timeline-clip-label-shadow)");
expect(baseTimelineClipRule).toContain("box-shadow:");
expect(baseTimelineClipRule).toContain("inset 0 1px 0 var(--timeline-clip-highlight)");
expect(baseTimelineClipRule).toContain("var(--timeline-clip-shadow)");
expect(timelineClipLabelRule).toContain("background: var(--timeline-clip-chip-bg)");
expect(timelineClipLabelRule).toContain("color: var(--timeline-clip-chip-text)");
expect(timelineClipLabelRule).toContain("text-overflow: ellipsis");
expect(timelineClipLabelRule).toContain("max-width: calc(100% - 22px)");
expect(timelineClipTimecodeRule).toContain("color: var(--timeline-tick-text)");
expect(activeTimelineClipLabelRule).toContain("color: var(--timeline-clip-label-active)");
expect(themeCss).toContain("--timeline-clip-bg: rgba(255, 255, 255, 0.12)");
expect(themeCss).toContain("--timeline-clip-border: rgba(255, 255, 255, 0.22)");
expect(themeCss).toContain("--timeline-track-label: rgba(255, 255, 255, 0.5)");
@@ -129,6 +100,17 @@ describe("timeline motion styles", () => {
expect(themeCss).toContain("--timeline-clip-label-active: rgba(232, 255, 247, 0.95)");
expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
expect(themeCss).toContain("--timeline-clip-radius: 8px");
expect(themeCss).toContain("--timeline-clip-audio-radius: 21px");
expect(themeCss).toContain("--timeline-clip-highlight: rgb(255 255 255 / 0.08)");
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3)");
expect(themeCss).toContain("--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73)");
expect(themeCss).toContain("--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7)");
expect(themeCss).toContain("--timeline-clip-chip-text: #ffffff");
expect(themeCss).toContain("--timeline-row-bg: #edf0ed");
expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12)");
expect(themeCss).toContain("--timeline-clip-selection: #0f766e");
expect(themeCss).toContain("--timeline-clip-audio-bg: rgba(167, 139, 250, 0.16)");
expect(themeCss).toContain("--timeline-clip-audio-border: rgba(167, 139, 250, 0.4)");
expect(themeCss).toContain("--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24)");
@@ -136,9 +118,7 @@ describe("timeline motion styles", () => {
expect(audioClipRule).toContain("background-color: var(--timeline-clip-audio-bg)");
expect(audioClipRule).toContain("border-color: var(--timeline-clip-audio-border)");
expect(audioClipHoverRule).toContain("background-color: var(--timeline-clip-audio-bg-hover)");
expect(audioClipDraggingRule).toContain(
"background-color: var(--timeline-clip-audio-bg-dragging)",
);
expect(audioClipDraggingRule).toContain("var(--timeline-clip-audio-bg-dragging)");
expect(themeCss).toContain("--timeline-clip-selection: rgba(255, 255, 255, 0.85)");
expect(selectedTimelineClipRule).toContain(
"outline: 1.5px solid var(--timeline-clip-selection)",
@@ -1,11 +1,25 @@
import { describe, expect, it } from "vitest";
import {
clipWidthLadder,
defaultTimelineTheme,
getClipHandleOpacity,
getRenderedTimelineElement,
getTimelineTrackStyle,
} from "./timelineTheme";
import { getTrackStyle } from "./timelineIcons";
describe("clipWidthLadder", () => {
it("labels from 60px, keeps the picture below that, and uses one frame below 24px", () => {
expect(clipWidthLadder(200)).toBe("labeled");
expect(clipWidthLadder(60)).toBe("labeled");
expect(clipWidthLadder(59)).toBe("picture");
expect(clipWidthLadder(24)).toBe("picture");
expect(clipWidthLadder(23)).toBe("frame");
expect(defaultTimelineTheme.clipRadius).toBe("var(--timeline-clip-radius)");
expect(defaultTimelineTheme.audioClipRadius).toBe("var(--timeline-clip-audio-radius)");
});
});
describe("getTimelineTrackStyle", () => {
it("uses one neutral clip style for every timeline tag", () => {
const expectedStyle = {
@@ -31,6 +31,7 @@ export interface TimelineTheme {
panelResizeSeam: string;
panelResizeActive: string;
clipRadius: string;
audioClipRadius: string;
}
const TRACK_STYLE: TimelineTrackStyle = {
@@ -66,9 +67,19 @@ export const defaultTimelineTheme: TimelineTheme = {
handleColor: "var(--timeline-handle)",
panelResizeSeam: "var(--timeline-resize-seam)",
panelResizeActive: "var(--timeline-resize-active)",
clipRadius: "var(--radius-lg)",
clipRadius: "var(--timeline-clip-radius)",
audioClipRadius: "var(--timeline-clip-audio-radius)",
};
export type ClipWidthLadder = "labeled" | "picture" | "frame";
/** Label chip from 60px. Under 24px the clip is one cropped frame. */
export function clipWidthLadder(widthPx: number): ClipWidthLadder {
if (widthPx < 24) return "frame";
if (widthPx < 60) return "picture";
return "labeled";
}
export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
return TRACK_STYLE;
}
+36 -28
View File
@@ -202,7 +202,10 @@ body {
.timeline-clip {
background-color: var(--clip-bg);
border: 1px solid var(--clip-border);
border-radius: 8px;
border-radius: var(--timeline-clip-radius);
box-shadow:
inset 0 1px 0 var(--timeline-clip-highlight),
var(--timeline-clip-shadow);
}
.timeline-clip::before {
@@ -229,6 +232,7 @@ body {
.timeline-clip.is-audio {
background-color: var(--timeline-clip-audio-bg);
border-color: var(--timeline-clip-audio-border);
border-radius: var(--timeline-clip-audio-radius);
}
.timeline-clip.is-audio.is-hovered {
@@ -236,7 +240,9 @@ body {
}
.timeline-clip.is-audio.is-dragging {
background-color: var(--timeline-clip-audio-bg-dragging);
box-shadow:
inset 0 0 0 999px var(--timeline-clip-audio-bg-dragging),
0 8px 24px var(--timeline-clip-shadow-dragging);
}
.timeline-clip.is-hovered {
@@ -252,37 +258,52 @@ body {
outline-offset: -1.5px;
}
.timeline-clip.is-selected::after {
content: "";
position: absolute;
inset: 0;
z-index: 4;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--timeline-clip-selection-highlight);
pointer-events: none;
}
.timeline-clip[data-active].is-selected {
outline: 1.5px solid var(--timeline-clip-selection);
outline-offset: -1.5px;
}
.timeline-clip.is-dragging {
/* Solid background so the picked-up clip reads clearly while dragging — audio
clips (waveform drawn on a separate layer) would otherwise look transparent. */
background-color: var(--clip-bg-dragging);
box-shadow: 0 8px 24px var(--timeline-clip-shadow-dragging);
/* Veil is a shadow, not background-color, so drop does not fade the hover fill. */
box-shadow:
inset 0 0 0 999px var(--clip-bg-dragging),
0 8px 24px var(--timeline-clip-shadow-dragging);
}
.timeline-clip__label {
position: absolute;
top: 6px;
left: 12px;
right: 10px;
top: 5px;
left: 6px;
z-index: 6;
width: fit-content;
max-width: calc(100% - 22px);
overflow: hidden;
color: var(--timeline-track-label);
padding: 1px 5px;
border-radius: 4px;
background: var(--timeline-clip-chip-bg);
color: var(--timeline-clip-chip-text);
font-size: 10px;
font-weight: 500;
line-height: 1;
line-height: 16px;
pointer-events: none;
text-shadow: var(--timeline-clip-label-shadow);
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-clip[data-active] .timeline-clip__label {
color: var(--timeline-clip-label-active);
.timeline-clip.is-audio .timeline-clip__label {
top: 11px;
left: 9px;
background: var(--timeline-clip-chip-audio-bg);
}
.timeline-clip__timecode {
@@ -301,20 +322,7 @@ body {
@media (prefers-reduced-motion: no-preference) {
.timeline-clip {
transition:
background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1),
color 200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.timeline-clip::before {
transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.timeline-clip__label {
transition: color 200ms cubic-bezier(0.22, 1, 0.36, 1);
transition: background-color 120ms ease-out;
}
.timeline-clip__handle-bar {
+35
View File
@@ -69,6 +69,14 @@
--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
--timeline-clip-selection: rgba(255, 255, 255, 0.85);
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
--timeline-clip-radius: 8px;
--timeline-clip-audio-radius: 21px;
--timeline-clip-highlight: rgb(255 255 255 / 0.08);
--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
--timeline-clip-chip-text: #ffffff;
--timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
--timeline-thumbnail-shimmer: linear-gradient(
90deg,
@@ -333,6 +341,33 @@
--color-white: #fff;
}
/* One paper block. A host sets data-theme="paper" on the document element. */
:root[data-theme="paper"] {
--timeline-shell-bg: #f4f6f3;
--timeline-row-bg: #edf0ed;
--timeline-clip-bg: #c9d1cb;
--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12);
--timeline-clip-highlight: rgb(255 255 255 / 0.08);
--timeline-clip-selection: #0f766e;
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
--timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
--timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
--timeline-clip-chip-text: #ffffff;
--timeline-clip-audio-bg: rgb(150, 128, 180);
--timeline-clip-audio-bg-hover: rgb(135, 109, 170);
--timeline-clip-audio-bg-dragging: rgb(232, 224, 242);
--timeline-clip-audio-border: rgba(113, 80, 155, 0.45);
--timeline-clip-audio-muted-bg: rgb(156, 161, 157);
--timeline-audio-link: #626d66;
--timeline-waveform-bar-rgb: 20, 12, 32;
--timeline-waveform-muted-rgb: 24, 28, 26;
--timeline-waveform-error: repeating-linear-gradient(
90deg,
rgba(20, 12, 32, 0.35) 0 2px,
transparent 2px 5px
);
}
/* Motion durations as utilities, since Tailwind has no `--duration-*`
* namespace. Each one zeroes itself under `prefers-reduced-motion`, so a
* caller cannot forget the reduced-motion case. */
+40 -9
View File
@@ -42,8 +42,8 @@
},
{
"id": "clip-label",
"foreground": "--timeline-track-label",
"background": ["--timeline-row-bg", "--timeline-clip-bg"],
"foreground": "--timeline-clip-chip-text",
"background": ["--timeline-row-bg", "--timeline-clip-bg", "--timeline-clip-chip-bg"],
"role": "text",
"minimum": 4.5,
"sources": [
@@ -52,16 +52,18 @@
]
},
{
"id": "active-clip-label",
"foreground": "--timeline-clip-label-active",
"background": ["--timeline-row-bg", "--timeline-clip-bg", "--timeline-clip-bg-active"],
"id": "audio-clip-label",
"foreground": "--timeline-clip-chip-text",
"background": [
"--timeline-row-bg",
"--timeline-clip-audio-bg",
"--timeline-clip-chip-audio-bg"
],
"role": "text",
"minimum": 4.5,
"sources": [
"packages/studio/src/styles/studio.css:203",
"packages/studio/src/styles/studio.css:213",
"packages/studio/src/styles/studio.css:223",
"packages/studio/src/styles/studio.css:297"
"packages/studio/src/styles/studio.css:230",
"packages/studio/src/styles/studio.css:300"
]
},
{
@@ -257,6 +259,35 @@
"format": "rgb-channels"
}
]
},
{
"id": "paper",
"selector": ":root[data-theme=\"paper\"]",
"canvas": "--timeline-row-bg",
"pairs": [
{
"id": "clip-label",
"foreground": "--timeline-clip-chip-text",
"background": ["--timeline-clip-bg", "--timeline-clip-chip-bg"],
"role": "text",
"minimum": 4.5,
"sources": [
"packages/studio/src/styles/theme.css",
"packages/studio/src/styles/studio.css"
]
},
{
"id": "selected-outline",
"foreground": "--timeline-clip-selection",
"background": ["--timeline-row-bg"],
"role": "graphic",
"minimum": 3,
"sources": [
"packages/studio/src/styles/theme.css",
"packages/studio/src/styles/studio.css"
]
}
]
}
]
}