diff --git a/packages/core/src/parsers/stableIds.test.ts b/packages/core/src/parsers/stableIds.test.ts
new file mode 100644
index 000000000..757cb23e0
--- /dev/null
+++ b/packages/core/src/parsers/stableIds.test.ts
@@ -0,0 +1,134 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * T2 — Stable id spec (spec for R1).
+ *
+ * These tests define what "stable hf- id" means BEFORE R1 implements it.
+ * They are intentionally red until R1 lands.
+ *
+ * Currently failing (spec): tests 1, 2, 3 — parser assigns `element-N` not `hf-xxxx`.
+ * Currently passing (baseline): tests 4, 5, 6, 7 — these already hold and must not regress.
+ *
+ * Scope: id assignment and stability only. Round-trip fidelity is T1 territory.
+ */
+import { describe, expect, it } from "vitest";
+import { parseHtml } from "./htmlParser.js";
+import { generateHyperframesHtml } from "../generators/hyperframes.js";
+import type { ParsedHtml } from "./htmlParser.js";
+
+function maxEndTime(elements: ParsedHtml["elements"]): number {
+ if (elements.length === 0) return 0;
+ return Math.max(...elements.map((e) => e.startTime + e.duration));
+}
+
+function serialize(parsed: ParsedHtml): string {
+ return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), {
+ compositionId: "test-comp",
+ resolution: parsed.resolution,
+ styles: parsed.styles ?? undefined,
+ keyframes: parsed.keyframes,
+ stageZoomKeyframes: parsed.stageZoomKeyframes,
+ });
+}
+
+describe("T2 — stable element ids (spec for R1)", () => {
+ // --- Spec (red until R1) ---
+
+ it.fails("[spec] elements without an id get a hf- prefixed id at parse", () => {
+ const html = `
+

+
+
`;
+ const { elements } = parseHtml(html);
+ for (const el of elements) {
+ expect(el.id).toMatch(/^hf-/);
+ }
+ });
+
+ it.fails("[spec] generated hf- ids match /^hf-[a-z0-9]{4}$/", () => {
+ const html = ``;
+ const { elements } = parseHtml(html);
+ const noPreExistingId = elements.filter((e) => e.id !== "stage");
+ for (const el of noPreExistingId) {
+ expect(el.id).toMatch(/^hf-[a-z0-9]{4}$/);
+ }
+ });
+
+ it.fails("[spec] adding an element before existing ones does not change existing ids", () => {
+ const base = ``;
+ const withPrepend = ``;
+ const baseAlpha = parseHtml(base).elements.find((e) => e.name === "AlphaEl");
+ const extendedAlpha = parseHtml(withPrepend).elements.find((e) => e.name === "AlphaEl");
+ expect(baseAlpha).toBeDefined();
+ expect(extendedAlpha).toBeDefined();
+ // With counter-based ids: base AlphaEl = element-1, extended AlphaEl = element-2 — FAILS.
+ // With hf- stable ids: both = same hf-xxxx — PASSES (R1 target).
+ expect(extendedAlpha?.id).toBe(baseAlpha?.id);
+ });
+
+ // --- Baseline (already pass, must not regress) ---
+
+ it("elements with an existing id keep it unchanged", () => {
+ const html = ``;
+ const { elements } = parseHtml(html);
+ expect(elements.some((e) => e.id === "my-title")).toBe(true);
+ });
+
+ it("ids are deterministic: same input produces same ids on re-parse", () => {
+ const html = ``;
+ const first = parseHtml(html).elements.map((e) => e.id);
+ const second = parseHtml(html).elements.map((e) => e.id);
+ expect(first).toEqual(second);
+ });
+
+ it("ids are unique within a document", () => {
+ const html = ``;
+ const ids = parseHtml(html).elements.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("two elements with identical markup get distinct ids (no content-hash collision)", () => {
+ // Ensures R1's id derivation includes position or a sibling counter,
+ // not just content — two structurally identical elements must not collide.
+ const html = ``;
+ const { elements } = parseHtml(html);
+ const ids = elements.map((e) => e.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("ids survive a serialize → re-parse round-trip", () => {
+ const html = `
+
+

+
`;
+ const original = parseHtml(html);
+ const reparsed = parseHtml(serialize(original));
+ const origIds = original.elements.map((e) => e.id).sort();
+ const roundIds = reparsed.elements.map((e) => e.id).sort();
+ expect(roundIds).toEqual(origIds);
+ });
+
+ it.todo("sub-composition instances get scoped ids (compositionId/hf-x) — requires SDK session");
+});