/**
* Pure, side-effect-free motion compiler for the Design Studio (§6.3).
*
* Converts a `MotionTimeline` (JSON tracks) into a single managed
* ``;
if (openMatch) {
const bodyStart = openMatch.index + openMatch[0].length;
const afterOpen = html.slice(bodyStart);
const closeMatch = /<\s*\/\s*style\b[^>]*>/i.exec(afterOpen);
if (closeMatch) {
const closeEnd = bodyStart + closeMatch.index + closeMatch[0].length;
return html.slice(0, openMatch.index) + block + html.slice(closeEnd);
}
}
const headClose = html.lastIndexOf("");
if (headClose !== -1) {
return html.slice(0, headClose) + block + "\n" + html.slice(headClose);
}
return block + "\n" + html;
}
/**
* Return the djb2 hash of a CSS string — useful for verifying stored
* `compiled_hash` values without re-compiling a full timeline.
*/
export function hashCss(css: string): string {
return djb2(css);
}
/**
* Parse the first `animation-duration` declaration in a managed motion CSS
* body and return it in milliseconds, or `null` when absent/unparsable. Used
* by CSS-recovery so a recovered timeline keeps the compiled duration instead
* of inventing a default that the next save would then persist.
*/
export function parseFirstAnimationDurationMs(css: string): number | null {
const m = /animation-duration\s*:\s*([^;]+)/.exec(css);
if (!m) return null;
const first = m[1].split(",")[0].trim();
const value = /^([\d.]+)(ms|s)$/.exec(first);
if (!value) return null;
const n = parseFloat(value[1]);
if (!Number.isFinite(n)) return null;
const ms = value[2] === "ms" ? n : n * 1000;
return ms > 0 ? Math.round(ms) : null;
}
/**
* Reject caller-supplied CSS declaration values before interpolation into the
* managed motion stylesheet. Motion values still allow useful CSS functions
* such as `translateY(...)`, `calc(...)`, `cubic-bezier(...)`, and `var(...)`,
* but block declaration/rule/style breakouts and remote-resource hooks.
*/
export function assertSafeMotionCssToken(value: string, field: string): string {
if (typeof value !== "string") {
throw new Error(`Invalid ${field}: expected a CSS string value.`);
}
if (value.trim().length === 0) {
throw new Error(`Invalid ${field}: motion CSS values cannot be empty.`);
}
if (CSS_TOKEN_CONTROL_RE.test(value) || CSS_TOKEN_BREAKOUT_RE.test(value)) {
throw new Error(
`Invalid ${field}: semicolons, braces, comments, angle brackets, control characters, and url(...) are not allowed in motion CSS values.`,
);
}
return value;
}
/**
* Validate that a CSS property name is a safe CSS identifier.
*
* Accepts standard and vendor-prefixed property names (e.g. "opacity",
* "transform", "-webkit-transform") and nothing else.
*/
export function assertSafeMotionCssProperty(
property: string,
field: string,
): string {
if (!/^-?[a-zA-Z][a-zA-Z0-9-]*$/.test(property)) {
throw new Error(
`Invalid ${field}: "${property}" is not a valid CSS property identifier. ` +
"Only ASCII letters, digits, hyphens, and an optional leading hyphen are allowed.",
);
}
return property;
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
const CSS_TOKEN_BREAKOUT_RE = /[;{}<>]|\/\*|\*\/|\burl\s*\(/i;
const CSS_TOKEN_CONTROL_RE = /[\u0000-\u001f\u007f]/;
/**
* Build a deterministic CSS animation name from a node id and CSS property.
* Non-ident characters are replaced with `_`; when sanitisation changed the
* node id, a short hash of the RAW id is appended so distinct ids that
* sanitise identically (e.g. "a:b" vs "a_b") never collide.
*
* Format: `an-motion-[_]--`
*/
function animationName(nodeId: string, property: string): string {
const safe = (s: string) => s.replace(/[^a-zA-Z0-9-]/g, "_");
const safeNode = safe(nodeId);
const suffix =
safeNode === nodeId ? "" : `_${djb2Num(nodeId).toString(36).slice(-4)}`;
return `an-motion-${safeNode}${suffix}--${safe(property)}`;
}
/** Reverse `animationName` — returns `null` when the name doesn't match. */
function decodeAnimationName(
name: string,
): { targetNodeId: string; property: string } | null {
const prefix = "an-motion-";
if (!name.startsWith(prefix)) return null;
const rest = name.slice(prefix.length);
const sep = rest.indexOf("--");
if (sep === -1) return null;
return { targetNodeId: rest.slice(0, sep), property: rest.slice(sep + 2) };
}
/**
* Build a `@keyframes` block for one (property, keyframes) pair.
* Each stop sets `animation-timing-function` to control easing to the NEXT
* stop (standard CSS keyframe easing semantics).
*/
function keyframesBlock(
name: string,
property: string,
keyframes: MotionKeyframe[],
defaultEase: MotionEase,
): string {
const sorted = [...keyframes].sort((a, b) => a.t - b.t);
const stops = sorted.map((kf) => {
const pct = formatPercent(kf.t);
const ease = kf.ease ?? defaultEase;
assertSafeMotionCssToken(kf.value, "keyframe value");
assertSafeMotionCssToken(ease, "keyframe ease");
return (
` ${pct} {\n` +
` ${property}: ${kf.value};\n` +
` animation-timing-function: ${cssEase(ease)};\n` +
` }`
);
});
return `@keyframes ${name} {\n${stops.join("\n")}\n}`;
}
/**
* Convert a model ease token to its CSS form for emission. `spring(...)`
* tokens (not valid CSS) compile to sampled `linear(...)` stop lists; all
* other tokens pass through unchanged. The converted output is re-validated
* so nothing unsafe can enter the managed stylesheet.
*/
function cssEase(ease: MotionEase): string {
const raw = String(ease);
const converted = motionEaseToCss(raw);
if (converted !== raw) {
assertSafeMotionCssToken(converted, "compiled spring ease");
}
return converted;
}
/**
* Build the `@media (prefers-reduced-motion: reduce)` block.
* Always emitted so managed blocks are easily identified by parsers.
*
* Selects ONLY the node ids that carry compiled motion rules — a blanket
* `[data-agent-native-node-id]` selector would disable every animation on
* every stamped node, including ones this compiler does not manage.
*/
function reducedMotionBlock(targetNodeIds: string[]): string {
if (targetNodeIds.length === 0) {
return `@media (prefers-reduced-motion: reduce) {\n /* no animations */\n}`;
}
const selector = targetNodeIds
.map((id) => `[data-agent-native-node-id="${escAttr(id)}"]`)
.join(",\n ");
return (
`@media (prefers-reduced-motion: reduce) {\n` +
` ${selector} {\n` +
` animation: none !important;\n` +
` }\n` +
`}`
);
}
/** Format a millisecond duration as a CSS `