#!/usr/bin/env node
import path from "node:path";
import { docsDir, parseArgs, readJson, resolveOutputDir, writeJson } from "./lib/common.mjs";
async function main() {
const args = parseArgs();
const outputDir = resolveOutputDir(args._[0] || args.url, args.out);
const contract = await generateSceneContract({ outputDir });
if (args.json) process.stdout.write(`${JSON.stringify(contract, null, 2)}\n`);
}
export async function generateSceneContract({ outputDir, pages: suppliedPages, interactionMap: suppliedInteractionMap } = {}) {
const docs = docsDir(outputDir);
const interactionMap = suppliedInteractionMap || await readJson(path.join(docs, "interaction-map.json"));
let sourceMap = null;
if (!suppliedPages) {
try {
sourceMap = await readJson(path.join(docs, "source-map.json"));
} catch {
// Standalone interaction extraction embeds enriched pages in interaction-map.json.
}
}
const pages = suppliedPages || sourceMap?.pages || interactionMap.pages || [];
const interactions = interactionMap.interactions || [];
const media = interactionMap.structural?.media || [];
const scenes = pages.flatMap((page) => (page.sections || []).map((section) => buildScene({ page, section, interactions, media }))).filter(Boolean);
const contract = {
schemaVersion: 3,
generatedAt: new Date().toISOString(),
sourceUrl: interactionMap.sourceUrl || pages[0]?.url || "",
scenes,
};
await writeJson(path.join(docs, "scene-contract.json"), contract);
return contract;
}
export function buildScene({ page, section, interactions, media }) {
const sectionInteractions = page.interactionDiscovery?.sectionInteractions?.find((entry) => entry.sectionId === section.id)?.interactions || [];
const kinds = new Set(sectionInteractions.map((item) => item.kind));
const classText = `${section.kind || ""} ${section.className || ""} ${(section.domRef?.classTokens || []).join(" ")} ${section.heading || ""}`.toLowerCase();
const isHero = section.kind === "hero" || /hero/.test(classText) || section === page.sections?.[0];
if (isHero && media.some((item) => item.role === "background")) kinds.add("embedded-media");
if (/carousel|slider|rail|gallery/.test(classText)) kinds.add("carousel");
if (/scroll|scene|sticky|pinned/.test(classText)) {
if (interactions.some((item) => item.kind === "scroll-state")) kinds.add("scroll-state");
if (interactions.some((item) => item.kind === "tabs")) kinds.add("tabs");
}
if (!kinds.size) return null;
const sceneInteractions = interactions.filter((item) =>
item.sectionIds?.includes(section.id) || sectionInteractions.some((summary) => interactionMatchesSummary(item, summary))
);
const sceneMedia = media.filter((item) => {
const tokenText = `${item.domRef?.id || ""} ${(item.domRef?.classTokens || []).join(" ")} ${(item.domRef?.parentClassTokens || []).join(" ")} ${(item.domRef?.sectionClassTokens || []).join(" ")}`.toLowerCase();
if (isHero) return item.id === media.find((candidate) => candidate.role === "background")?.id;
if (kinds.has("scroll-state")) return item.role === "background" && /scroll|scene|sticky|pin|embed/.test(tokenText);
return false;
});
const activeKinds = Array.from(kinds);
const primitive = modelFor(activeKinds);
const requirements = [];
if (sceneMedia.some((item) => item.src)) requirements.push("Render each listed source-backed media surface with the recorded playback flags and scene role.");
if (sceneMedia.some((item) => item.fallback?.policy === "fallback-only")) requirements.push("Treat recorded posters/backgrounds as loading, blocked-media, or error fallbacks only; do not visibly blend them behind active media.");
if (sceneMedia.some((item) => !item.src)) requirements.push("Recreate the recorded runtime visual surface and its scene behavior; do not replace it with unrelated static imagery.");
if (kinds.has("carousel")) requirements.push("Render a finite-width data-rp-viewport that clips or scrolls a wider data-rp-track, preserve the captured card geometry, separation mechanism, and exact initial active state, then manage the captured active-card state on click plus every captured hover state.");
if (kinds.has("scroll-state")) requirements.push("Implement both entry progress and internal scroll progress: preserve media framing/sideways expansion through the captured entry and pinned phases, while applying transforms only to the measured motion owner and keeping static copy outside the visual scale scope.");
if (kinds.has("tabs")) requirements.push("Implement visible labels as buttons and switch the captured content state without replacing the scene background.");
const acceptance = [];
if (sceneMedia.some((item) => item.src)) acceptance.push("The implementation references the exact source URLs and preserves applicable playback flags.");
if (sceneMedia.some((item) => item.fallback?.policy === "fallback-only")) acceptance.push("No full-cover poster or image remains visibly composited with the active video/iframe.");
if (sceneMedia.some((item) => !item.src)) acceptance.push("The implementation preserves the runtime surface's visual role and recorded state changes.");
if (kinds.has("carousel")) acceptance.push("The viewport is narrower than the content track, and clicking a card changes its active/expanded state rather than only scrolling.");
if (kinds.has("scroll-state")) acceptance.push("Each captured scroll phase produces the corresponding distinct media/copy geometry; add a midpoint phase if source evidence has only endpoints.");
if (kinds.has("tabs")) acceptance.push("Each visible tab changes the text/content state while the section remains a single scene.");
return {
id: `${safeId(page)}-${section.id}`,
sectionId: section.id,
heading: section.heading || section.tag || "Untitled scene",
sourceScreenshot: screenshotFor(section, page),
interactionIds: sceneInteractions.map((item) => item.id),
media: sceneMedia,
states: sceneInteractions.map(sceneStateFor),
implementation: {
model: primitive,
primitive,
runtimeAdapter: runtimeAdapterFor(primitive),
runtimeModule: "/site-clone/rp-interactions-bootstrap.mjs",
bootstrap: "",
markerContract: markerContractFor(primitive, sceneInteractions),
initialState: initialStateFor(primitive, sceneInteractions),
assertions: assertionsFor({ primitive, interactions: sceneInteractions, media: sceneMedia }),
recipe: recipeFor(primitive),
requirements,
acceptance,
},
manualContentInventory: kinds.has("carousel") && !(page.repeaters || []).length,
};
}
function sceneStateFor(item) {
return {
id: item.id,
kind: item.kind,
trigger: item.trigger,
importance: item.importance,
interactionMapRef: `interaction-map.json#${item.id}`,
states: item.states,
...(item.scope ? { scope: item.scope } : {}),
...(item.timeline ? {
timelineSummary: {
sampledAtMs: item.timeline.sampledAtMs,
observedSettleMs: item.timeline.observedSettleMs,
changedNodeCount: item.timeline.changedNodeCount,
changedNodes: (item.timeline.changes || []).slice(0, 16).map((change) => ({
path: change.path,
role: change.role,
changedProperties: change.changedProperties,
})),
animations: item.timeline.animations || [],
},
} : {}),
...(item.invariants ? { invariants: item.invariants } : {}),
screenshot: item.evidence?.screenshot || null,
};
}
function interactionMatchesSummary(interaction, summary) {
if (summary.id) return summary.id === interaction.id;
const interactionTrigger = typeof interaction.trigger === "string" ? interaction.trigger : interaction.trigger?.type;
const summaryTrigger = typeof summary.trigger === "string" ? summary.trigger : summary.trigger?.type;
return interaction.kind === summary.kind && interaction.label === summary.label && interactionTrigger === summaryTrigger;
}
function markerContractFor(primitive, interactions = []) {
const base = { scene: "data-rp-scene", initialized: "data-rp-initialized" };
if (primitive === "active-card-rail") {
const mode = interactions.find((item) => item.kind === "carousel")?.invariants?.initialState || "single-active";
return { ...base, initialState: `data-rp-initial-state=\"${mode}\"`, viewport: "data-rp-viewport", track: "data-rp-track", item: "data-rp-item", hoverTarget: "data-rp-hover-target", activate: "data-rp-activate", control: "data-rp-direction", active: "data-rp-active=true" };
}
if (primitive === "content-switcher") {
return { ...base, item: "data-rp-item", activate: "data-rp-activate", active: "data-rp-active=true", panel: "data-rp-panel", state: "data-rp-state" };
}
if (primitive === "scroll-scene") {
return { ...base, phase: "data-rp-phase", visual: "data-rp-visual", content: "data-rp-content", curtain: "data-rp-curtain", panel: "data-rp-panel", item: "data-rp-item", activate: "data-rp-activate", active: "data-rp-active=true", state: "data-rp-state" };
}
return base;
}
function initialStateFor(primitive, interactions) {
if (primitive !== "active-card-rail") return null;
const invariants = interactions.find((item) => item.kind === "carousel")?.invariants || {};
return {
mode: invariants.initialState || "single-active",
activeIndexes: invariants.initialActiveIndexes || [],
};
}
function runtimeAdapterFor(primitive) {
if (primitive === "active-card-rail") return "bindActiveItemRail";
if (primitive === "content-switcher") return "bindContentSwitcher";
if (primitive === "scroll-scene") return "bindScrollScene + bindContentSwitcher";
return null;
}
function assertionsFor({ primitive, interactions, media }) {
const assertions = [];
if (primitive === "active-card-rail") {
const observed = interactions.find((item) => item.kind === "carousel")?.invariants || {};
assertions.push(
{ kind: "scene-present" },
{ kind: "runtime-initialized", primitive: "active-card-rail" },
{ kind: "minimum-item-count", value: Math.max(3, Number(observed.itemCount) || 3) },
{ kind: "horizontal-overflow", minimumRatio: roundTolerance(observed.horizontalOverflowRatio, 0.8, 1.05) },
{ kind: "initial-active-item-count", value: Number(observed.initialActiveCount) || 0 },
{ kind: "single-active-item", value: 1 },
{ kind: "activation-changes-state" },
);
if (Number(observed.expandedWidthRatio) > 1.1) {
assertions.push({ kind: "active-item-width-ratio", minimumRatio: roundTolerance(observed.expandedWidthRatio, 0.8, 1.15) });
}
if (Number(observed.observedSettleMs) > 0) {
assertions.push({ kind: "transition-settles", maximumMs: Math.ceil(Number(observed.observedSettleMs) * 1.5 + 100) });
}
if (observed.separation) assertions.push({ kind: "item-separation", observed: observed.separation });
if (interactions.some((item) => (typeof item.trigger === "string" ? item.trigger : item.trigger?.type) === "hover")) {
assertions.push({ kind: "hover-changes-visual-state" });
}
}
if (primitive === "content-switcher" || primitive === "scroll-scene" && interactions.some((item) => item.kind === "tabs")) {
assertions.push({ kind: "runtime-initialized", primitive: "content-switcher" });
assertions.push({ kind: "activation-changes-content" }, { kind: "single-active-item", value: 1 });
}
if (primitive === "scroll-scene") {
assertions.push({ kind: "runtime-initialized", primitive: "scroll-scene" });
assertions.push({ kind: "scroll-produces-distinct-phases", minimumPhaseCount: 2 });
const scrollInteraction = interactions.find((item) => item.kind === "scroll-state") || {};
const phases = scrollInteraction.states || [];
if (JSON.stringify(phases).includes('"position":"fixed"')) assertions.push({ kind: "visual-pins-during-scroll" });
const expansion = scrollInteraction.invariants?.visualExpansion;
const expansionRatio = Math.max(Number(expansion?.score) || 1, Number(expansion?.widthRatio) || 1, Number(expansion?.transformScaleRatio) || 1);
if (expansionRatio > 1.1) {
assertions.push({
kind: "scroll-visual-expansion",
minimumRatio: rangeRatioTolerance(expansionRatio, 0.8, 1.05),
});
}
const contentMotion = scrollInteraction.invariants?.motionOwnership?.content;
if (contentMotion && contentMotion.scales === false) {
assertions.push({ kind: "scroll-content-scale-bound", maximumRatio: Number(contentMotion.maximumAllowedScaleRatio) || 1.03 });
}
}
for (const item of media.filter((entry) => entry.src)) {
assertions.push({ kind: "media-source-present", source: item.src, playback: item.playback || {} });
if (item.fallback?.policy === "fallback-only") assertions.push({ kind: "exclusive-media-layer", fallbackSource: item.fallback.src });
}
return assertions;
}
function recipeFor(primitive) {
const common = {
bootstrap: "Add exactly once near the end of the document: ",
warning: "A raw browser script must not import a relative module from src/. Use the public bootstrap above, or use the framework's compiled source import without type=module.",
};
if (primitive === "active-card-rail") return {
...common,
steps: [
"Put data-rp-viewport on a finite-width overflow-x:auto or overflow:hidden element.",
"Put data-rp-track on its wider max-content flex/grid row; never put overflow on a max-content track.",
"Put data-rp-item on every card and data-rp-activate on the card activation control.",
"Set data-rp-initial-state to the captured invariant. Use all-collapsed when the source has no active item; never pre-open a card for visual variety.",
"Reproduce the recorded separation mechanism (track gap, margins, divider, or inner inset) rather than guessing a generic gap.",
"Mark the element with the source hover treatment as data-rp-hover-target and reproduce that treatment with :hover/:focus-visible CSS.",
"Style data-rp-active=true to reproduce the observed expanded/collapsed ratio; keep active links above any activation hit target.",
],
};
if (primitive === "scroll-scene") return {
...common,
steps: [
"Give the scene enough scroll height and mark only the measured pinned/scaled wrapper with data-rp-visual.",
"Mark independently positioned copy with data-rp-content. Do not nest it inside a scaling visual wrapper unless the source evidence explicitly records content scaling.",
"Use --rp-entry-progress for the approach expansion and --rp-scroll-progress for pinned/release phases.",
"When the source uses side curtains/masks, mark both with data-rp-curtain and reduce their widths as entry progress increases.",
"If tabs exist, give controls and panels matching data-rp-state values.",
"Do not render a fallback poster at the same time as an opaque active video/iframe.",
],
};
if (primitive === "content-switcher") return {
...common,
steps: [
"Give each visible control data-rp-item, data-rp-activate, and a unique data-rp-state.",
"Give its content panel the matching data-rp-panel and data-rp-state.",
],
};
return common;
}
function roundTolerance(value, factor, floor) {
const numeric = Number(value) || 0;
return Math.round(Math.max(floor, numeric * factor) * 1000) / 1000;
}
function rangeRatioTolerance(value, factor, floor) {
const numeric = Math.max(1, Number(value) || 1);
return Math.round(Math.max(floor, 1 + (numeric - 1) * factor) * 1000) / 1000;
}
function modelFor(kinds) {
if (kinds.includes("scroll-state")) return "scroll-scene";
if (kinds.includes("carousel")) return "active-card-rail";
if (kinds.includes("tabs")) return "content-switcher";
return "media-scene";
}
function screenshotFor(section, page) {
if (section.kind === "hero") return page.screenshots?.desktop || null;
return page.screenshots?.desktop || page.screenshots?.tablet || null;
}
function safeId(page) {
return `${page.area || "page"}-${String(page.path || "home").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "home"}`;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}