diff --git a/packages/core/src/compiler/htmlParityContract.test.ts b/packages/core/src/compiler/htmlParityContract.test.ts
new file mode 100644
index 0000000000..01ec5909d1
--- /dev/null
+++ b/packages/core/src/compiler/htmlParityContract.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { extractCompiledHtmlParityContract } from "./htmlParityContract";
+
+describe("extractCompiledHtmlParityContract", () => {
+ it("normalizes legacy timing and nested variable JSON", () => {
+ const contract = extractCompiledHtmlParityContract(`
+
+ `);
+ expect(contract.compositions[0]).toMatchObject({
+ start: 1,
+ duration: 3,
+ trackIndex: 2,
+ variableValues: '{"a":[{"c":3,"d":4}],"z":{"a":1,"b":2}}',
+ });
+ expect(contract.timedElements[0]).toMatchObject({ start: 2, duration: 1, trackIndex: 5 });
+ });
+
+ it("treats embedded and project-relative resources as the same local contract", () => {
+ const embedded = extractCompiledHtmlParityContract(
+ `
`,
+ );
+ const project = extractCompiledHtmlParityContract(`
`);
+ expect(embedded.resources).toEqual(project.resources);
+ });
+});
diff --git a/packages/core/src/compiler/htmlParityContract.ts b/packages/core/src/compiler/htmlParityContract.ts
new file mode 100644
index 0000000000..2a610b7513
--- /dev/null
+++ b/packages/core/src/compiler/htmlParityContract.ts
@@ -0,0 +1,168 @@
+import { parseHTML } from "linkedom";
+
+export interface HtmlParityComposition {
+ id: string;
+ originalId: string | null;
+ start: number;
+ duration: number | null;
+ trackIndex: number | null;
+ width: number | null;
+ height: number | null;
+ variableValues: string | null;
+}
+
+export interface HtmlParityTimedElement {
+ identity: string;
+ start: number;
+ duration: number | null;
+ trackIndex: number | null;
+}
+
+export interface HtmlParityResource {
+ identity: string;
+ attribute: "src" | "href" | "poster";
+ locality: "local" | "remote";
+}
+
+export interface CompiledHtmlParityContract {
+ compositions: HtmlParityComposition[];
+ timedElements: HtmlParityTimedElement[];
+ authoredStyleSignatures: string[];
+ parityFontFamilies: string[];
+ resources: HtmlParityResource[];
+ runtimeBootstrap: boolean;
+ variableBootstrap: boolean;
+}
+
+function finiteAttribute(element: Element, name: string): number | null {
+ const raw = element.getAttribute(name);
+ if (raw === null || raw.trim() === "") return null;
+ const value = Number(raw);
+ return Number.isFinite(value) ? value : null;
+}
+
+function timing(element: Element): {
+ start: number;
+ duration: number | null;
+ trackIndex: number | null;
+} {
+ const start = finiteAttribute(element, "data-start") ?? 0;
+ const duration =
+ finiteAttribute(element, "data-duration") ??
+ (() => {
+ const end = finiteAttribute(element, "data-end");
+ return end === null ? null : Math.max(0, end - start);
+ })();
+ return {
+ start,
+ duration,
+ trackIndex:
+ finiteAttribute(element, "data-track-index") ?? finiteAttribute(element, "data-layer"),
+ };
+}
+
+function normalizeJson(value: string | null): string | null {
+ if (value === null) return null;
+ try {
+ return JSON.stringify(canonicalizeJson(JSON.parse(value) as unknown));
+ } catch {
+ return value.trim();
+ }
+}
+
+function canonicalizeJson(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(canonicalizeJson);
+ if (value === null || typeof value !== "object") return value;
+ return Object.fromEntries(
+ Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, nested]) => [key, canonicalizeJson(nested)]),
+ );
+}
+
+function styleSignatures(document: Document): string[] {
+ return [...document.querySelectorAll("style")]
+ .map((style) => style.textContent ?? "")
+ .flatMap((css) => css.match(/[^{}]+\{[^{}]*--parity-contract\s*:[^{}]+\}/g) ?? [])
+ .map((rule) =>
+ rule
+ .replace(/\s+/g, " ")
+ .replace(/\s*([:;{},])\s*/g, "$1")
+ .trim(),
+ )
+ .sort();
+}
+
+function parityFontFamilies(html: string): string[] {
+ const families = new Set();
+ for (const match of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
+ for (const family of match[1]!.split(",")) {
+ const normalized = family.trim().replace(/^['"]|['"]$/g, "");
+ if (normalized.startsWith("Parity")) families.add(normalized);
+ }
+ }
+ return [...families].sort();
+}
+
+function resources(document: Document): HtmlParityResource[] {
+ const resources: HtmlParityResource[] = [];
+ for (const [index, element] of [
+ ...document.querySelectorAll("[src], [href], [poster]"),
+ ].entries()) {
+ for (const attribute of ["src", "href", "poster"] as const) {
+ const value = element.getAttribute(attribute)?.trim();
+ if (!value) continue;
+ if (element.tagName.toLowerCase() === "script" && /hyperframes|runtime/i.test(value))
+ continue;
+ resources.push({
+ identity:
+ element.getAttribute("data-hf-id") ??
+ element.getAttribute("id") ??
+ `${element.tagName.toLowerCase()}[${index}]`,
+ attribute,
+ locality: /^https?:\/\//i.test(value) ? "remote" : "local",
+ });
+ }
+ }
+ return resources.sort((left, right) =>
+ `${left.identity}:${left.attribute}`.localeCompare(`${right.identity}:${right.attribute}`),
+ );
+}
+
+/** Extract the browser-visible contract shared by preview and render compilation. */
+export function extractCompiledHtmlParityContract(html: string): CompiledHtmlParityContract {
+ const { document } = parseHTML(html);
+ const compositions = [...document.querySelectorAll("[data-composition-id]")].map((element) => {
+ const resolved = timing(element);
+ return {
+ id: element.getAttribute("data-composition-id") ?? "",
+ originalId: element.getAttribute("data-hf-original-composition-id"),
+ ...resolved,
+ width: finiteAttribute(element, "data-width"),
+ height: finiteAttribute(element, "data-height"),
+ variableValues: normalizeJson(element.getAttribute("data-variable-values")),
+ };
+ });
+ const timedElements = [
+ ...document.querySelectorAll(
+ "[data-start], [data-duration], [data-end], [data-track-index], [data-layer]",
+ ),
+ ]
+ .filter((element) => !element.hasAttribute("data-composition-id"))
+ .map((element, index) => ({
+ identity:
+ element.getAttribute("data-hf-id") ??
+ element.getAttribute("id") ??
+ `${element.tagName.toLowerCase()}[${index}]`,
+ ...timing(element),
+ }));
+ return {
+ compositions,
+ timedElements,
+ authoredStyleSignatures: styleSignatures(document),
+ parityFontFamilies: parityFontFamilies(html),
+ resources: resources(document),
+ runtimeBootstrap: /__hyperframes|data-hyperframes-(?:preview-)?runtime/i.test(html),
+ variableBootstrap: /__hfVariables(?:ByComp)?/.test(html),
+ };
+}
diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts
index 0474d19d57..fff2ec1422 100644
--- a/packages/core/src/compiler/index.ts
+++ b/packages/core/src/compiler/index.ts
@@ -38,6 +38,14 @@ export {
} from "./htmlBundler";
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
+export {
+ extractCompiledHtmlParityContract,
+ type CompiledHtmlParityContract,
+ type HtmlParityComposition,
+ type HtmlParityResource,
+ type HtmlParityTimedElement,
+} from "./htmlParityContract";
+
export {
RUNTIME_BOOTSTRAP_ATTR,
injectScriptsAtHeadStart,
diff --git a/packages/producer/src/services/htmlCompiler.parity.test.ts b/packages/producer/src/services/htmlCompiler.parity.test.ts
new file mode 100644
index 0000000000..462afec2a7
--- /dev/null
+++ b/packages/producer/src/services/htmlCompiler.parity.test.ts
@@ -0,0 +1,96 @@
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ bundleToSingleHtml,
+ extractCompiledHtmlParityContract,
+ injectScriptsIntoHtml,
+} from "@hyperframes/core/compiler";
+import { compileForRender } from "./htmlCompiler.js";
+import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
+
+const tempDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+function project(files: Record): string {
+ const dir = mkdtempSync(join(tmpdir(), "hf-compiler-parity-"));
+ tempDirs.push(dir);
+ for (const [relative, content] of Object.entries(files)) {
+ const path = join(dir, relative);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, content);
+ }
+ return dir;
+}
+
+async function contracts(files: Record) {
+ const dir = project(files);
+ const preview = await bundleToSingleHtml(dir);
+ const render = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
+ allowSystemFontCapture: false,
+ });
+ const servedRender = injectScriptsIntoHtml(
+ render.html,
+ [getVerifiedHyperframeRuntimeSource()],
+ [],
+ true,
+ );
+ return {
+ preview: extractCompiledHtmlParityContract(preview),
+ render: extractCompiledHtmlParityContract(servedRender),
+ };
+}
+
+const shell = (body: string, head = "") => `
+${head}${body}
+`;
+
+describe("preview/render semantic compilation parity", () => {
+ it("preserves canonical timing, track, authored style, font, and resource contracts", async () => {
+ const result = await contracts({
+ "index.html": shell(
+ `
+ Title
+
+ `,
+ ``,
+ ),
+ "assets/logo.svg": ``,
+ });
+ expect(result.render).toEqual(result.preview);
+ });
+
+ it("keeps legacy end/layer timing semantically identical", async () => {
+ const result = await contracts({
+ "index.html":
+ shell(`
+ Legacy timing
+ `),
+ });
+ expect(result.render).toEqual(result.preview);
+ });
+
+ it("keeps flattened sub-composition identity and variable bootstrap identical", async () => {
+ const result = await contracts({
+ "index.html":
+ shell(`
+
+ `),
+ "compositions/card.html": `
+
+ Card
+
+
+
+ `,
+ });
+ expect(result.render).toEqual(result.preview);
+ });
+});