muddle/views/stage.ts
2026-10-23 08:53:07 -04:00

69 lines
2.6 KiB
TypeScript

export interface KeyframeStep {
o: Record<string, string | number>;
}
export interface ScrollSceneOptions {
selector: string;
keyframes: Record<string, KeyframeStep | Record<string, string | number>>;
range?: string;
timeline?: "view" | "scroll" | string;
animation?: string;
duration?: string;
easing?: string;
fill?: string;
}
export interface TransitionPortalOptions {
name: string;
old?: Record<string, string | number>;
new?: Record<string, string | number>;
duration?: string;
easing?: string;
}
function declarations(values: Record<string, string | number>): string {
return Object.entries(values).map(([property, value]) => `${property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}: ${value};`).join(" ");
}
/** Builds declarative native CSS scenes and view transitions without a client runtime. */
export class StageManager {
private readonly scenes: ScrollSceneOptions[] = [];
private readonly portals: TransitionPortalOptions[] = [];
scene(name: string, options: Omit<ScrollSceneOptions, "animation">): this {
this.scenes.push({ ...options, animation: name });
return this;
}
portal(name: string, options: Omit<TransitionPortalOptions, "name"> = {}): this {
this.portals.push({ name, ...options });
return this;
}
css(): string {
return [
...this.scenes.flatMap((scene) => {
const animation = scene.animation ?? "muddle-scene";
const timeline = scene.timeline ?? "view";
return [
`@keyframes ${animation} { ${Object.entries(scene.keyframes).map(([offset, step]) => {
const values = "to" in step && typeof step.to === "object" ? step.to : step as Record<string, string | number>;
return `${offset} { ${declarations(values)} }`;
}).join(" ")} }`,
`${scene.selector} { animation-name: ${animation}; animation-duration: ${scene.duration ?? "1ms"}; animation-timing-function: ${scene.easing ?? "linear"}; animation-fill-mode: ${scene.fill ?? "both"}; animation-timeline: ${timeline === "view" ? "view()" : timeline === "scroll" ? "scroll()" : timeline}; animation-range: ${scene.range ?? "entry 0% cover 100%"}; }`,
];
}),
...this.portals.flatMap((portal) => {
const timing = `animation-duration: ${portal.duration ?? "240ms"}; animation-timing-function: ${portal.easing ?? "ease"};`;
return [
portal.old ? `::view-transition-old(${portal.name}) { ${timing} ${declarations(portal.old)} }` : "",
portal.new ? `::view-transition-new(${portal.name}) { ${timing} ${declarations(portal.new)} }` : "",
].filter(Boolean);
}),
].join("\n");
}
}
export function stage(): StageManager {
return new StageManager();
}