muddle/views/stage.ts
2026-10-23 09:12:49 -04:00

86 lines
2.7 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();
}