Files
hyperframes/packages/studio/src/player/components/timelineMotionStyles.test.ts
T
Miguel Ángel 037266e72b feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling

WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload

Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
2026-07-07 04:26:56 -04:00

102 lines
4.1 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
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);
const bodyStart = css.indexOf("{", selectorStart);
const bodyEnd = css.indexOf("}", bodyStart);
expect(bodyStart).toBeGreaterThanOrEqual(0);
expect(bodyEnd).toBeGreaterThan(bodyStart);
return css.slice(bodyStart + 1, bodyEnd).trim();
}
function expectDeclaration(ruleBody: string, property: string): string {
const declarationMatch = new RegExp(`${property}:\\s*([^;]+);`).exec(ruleBody);
expect(declarationMatch?.[1]).toBeDefined();
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]);
}
describe("timeline motion styles", () => {
it("keeps clip motion reduced-motion gated and layout safe", () => {
const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
expect(mediaStart).toBeGreaterThanOrEqual(0);
const beforeMotionMedia = studioCss.slice(0, mediaStart);
const baseTimelineClipRule = expectRule(beforeMotionMedia, ".timeline-clip");
expect(baseTimelineClipRule).not.toContain("transition");
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/);
});
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
expect(baseTimelineClipRule).toContain("background-color: rgba(255, 255, 255, 0.055)");
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
expect(activeTimelineClipRule).toContain("border-color: rgba(60, 230, 172, 0.55)");
expect(activeTimelineClipRule).not.toContain("box-shadow");
expect(bloomOverlayRule).toContain("background: rgba(60, 230, 172, 0.2)");
expect(bloomOverlayRule).not.toContain("linear-gradient");
expect(bloomOverlayRule).toContain("opacity: 0");
expect(activeBloomOverlayRule).toContain("opacity: 1");
});
it("targets trim handle bars without changing drag geometry", () => {
const handleClassMatches = timelineClipSource.match(/className="timeline-clip__handle-bar"/g);
expect(handleClassMatches).toHaveLength(2);
expect(timelineClipSource).toContain('transform: isDragging ? "translateY(-1px)" : undefined');
expect(timelineClipSource).not.toContain("scale(");
});
it("keeps the playhead polish static, without transition-driven positioning", () => {
expect(playheadSource).toContain("boxShadow");
expect(playheadSource).toContain("rotate(45deg)");
expect(playheadSource).not.toContain("transition");
});
});