/** * 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 `