From f1d537efa961b8a6af3a7eff421fd89089d308aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Sat, 16 May 2026 20:00:37 -0400 Subject: [PATCH] feat(timeline): tint video and image clips by element type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video clips on the timeline previously had a transparent background, which made them blend into the timeline surface and only become visible when selected. Image clips share the same track type as video and therefore had the same problem. This adds a per-element-type tint layer that applies a subtle background colour to clips on the timeline: - video clips: sky tint (\`bg-sky-500/20\`) - image clips: amber tint (\`bg-amber-500/20\`) - audio / text / sticker / graphic / effect: unchanged, still driven by \`TIMELINE_TRACK_THEME\` The new \`TIMELINE_ELEMENT_THEME\` map is a Partial> so the element-level theme can override per type while keeping the existing track-level theme as the fallback. A small unit test covers the override and fallback behaviour. --- .../components/__tests__/theme.test.ts | 64 +++++++++++++++++++ apps/web/src/timeline/components/theme.ts | 32 +++++++++- .../timeline/components/timeline-element.tsx | 11 +++- 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/timeline/components/__tests__/theme.test.ts diff --git a/apps/web/src/timeline/components/__tests__/theme.test.ts b/apps/web/src/timeline/components/__tests__/theme.test.ts new file mode 100644 index 000000000..47b909cf2 --- /dev/null +++ b/apps/web/src/timeline/components/__tests__/theme.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { + getTimelineElementClassName, + getTimelineElementClassNameForElement, + TIMELINE_ELEMENT_THEME, + TIMELINE_TRACK_THEME, +} from "../theme"; + +describe("getTimelineElementClassName", () => { + test("returns the configured class for each track type", () => { + expect(getTimelineElementClassName({ type: "audio" })).toBe( + TIMELINE_TRACK_THEME.audio.elementClassName.trim(), + ); + expect(getTimelineElementClassName({ type: "text" })).toBe( + TIMELINE_TRACK_THEME.text.elementClassName.trim(), + ); + }); +}); + +describe("getTimelineElementClassNameForElement", () => { + test("uses the element-level theme when one is defined", () => { + expect( + getTimelineElementClassNameForElement({ + elementType: "video", + trackType: "video", + }), + ).toBe(TIMELINE_ELEMENT_THEME.video?.elementClassName.trim()); + }); + + test("distinguishes video and image even though they share the video track", () => { + const videoClass = getTimelineElementClassNameForElement({ + elementType: "video", + trackType: "video", + }); + const imageClass = getTimelineElementClassNameForElement({ + elementType: "image", + trackType: "video", + }); + expect(videoClass).not.toBe(imageClass); + }); + + test("falls back to the track-level theme when an element type has no override", () => { + expect( + getTimelineElementClassNameForElement({ + elementType: "audio", + trackType: "audio", + }), + ).toBe(TIMELINE_TRACK_THEME.audio.elementClassName.trim()); + + expect( + getTimelineElementClassNameForElement({ + elementType: "text", + trackType: "text", + }), + ).toBe(TIMELINE_TRACK_THEME.text.elementClassName.trim()); + + expect( + getTimelineElementClassNameForElement({ + elementType: "sticker", + trackType: "graphic", + }), + ).toBe(TIMELINE_TRACK_THEME.graphic.elementClassName.trim()); + }); +}); diff --git a/apps/web/src/timeline/components/theme.ts b/apps/web/src/timeline/components/theme.ts index 9f93dc16c..5f0ca8f40 100644 --- a/apps/web/src/timeline/components/theme.ts +++ b/apps/web/src/timeline/components/theme.ts @@ -1,4 +1,4 @@ -import type { TrackType } from "@/timeline"; +import type { ElementType, TrackType } from "@/timeline"; export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)"; @@ -19,6 +19,22 @@ export const TIMELINE_TRACK_THEME: Record< effect: { elementClassName: "bg-[#5d93ba]" }, } as const; +/** + * Per-element-type background tint applied to clips on the timeline. + * + * Falls back to the track-level theme when an element type has no + * dedicated entry. Used so that clips on the same track (e.g. video + * and image, which both live on the "video" track) can still be + * visually distinguished from each other and from the timeline + * background. + */ +export const TIMELINE_ELEMENT_THEME: Partial< + Record +> = { + video: { elementClassName: "bg-sky-500/20" }, + image: { elementClassName: "bg-amber-500/20" }, +} as const; + export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50"; export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff"; @@ -29,3 +45,17 @@ export function getTimelineElementClassName({ }): string { return TIMELINE_TRACK_THEME[type].elementClassName.trim(); } + +export function getTimelineElementClassNameForElement({ + elementType, + trackType, +}: { + elementType: ElementType; + trackType: TrackType; +}): string { + const elementTheme = TIMELINE_ELEMENT_THEME[elementType]; + if (elementTheme) { + return elementTheme.elementClassName.trim(); + } + return TIMELINE_TRACK_THEME[trackType].elementClassName.trim(); +} diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 1b549a150..48083c9a4 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -23,7 +23,11 @@ import { timelineTimeToSnappedPixels, } from "@/timeline"; import { getTrackHeight } from "./track-layout"; -import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme"; +import { + getTimelineElementClassName, + getTimelineElementClassNameForElement, + TIMELINE_TRACK_THEME, +} from "./theme"; import { ContextMenu, ContextMenuContent, @@ -585,8 +589,9 @@ function ElementInner({