diff --git a/packages/core/package.json b/packages/core/package.json
index c209f838a..42610260b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -95,6 +95,18 @@
"import": "./src/runtime/clipTree.ts",
"types": "./src/runtime/clipTree.ts"
},
+ "./runtime/start-expression": {
+ "bun": "./src/runtime/startExpression.ts",
+ "node": "./dist/runtime/startExpression.js",
+ "import": "./src/runtime/startExpression.ts",
+ "types": "./src/runtime/startExpression.ts"
+ },
+ "./compiler/html-document": {
+ "bun": "./src/compiler/htmlDocument.ts",
+ "node": "./dist/compiler/htmlDocument.js",
+ "import": "./src/compiler/htmlDocument.ts",
+ "types": "./src/compiler/htmlDocument.ts"
+ },
"./runtime/position-edits": {
"bun": "./src/runtime/positionEdits.ts",
"node": "./dist/runtime/positionEdits.js",
@@ -275,6 +287,14 @@
"import": "./dist/runtime/clipTree.js",
"types": "./dist/runtime/clipTree.d.ts"
},
+ "./runtime/start-expression": {
+ "import": "./dist/runtime/startExpression.js",
+ "types": "./dist/runtime/startExpression.d.ts"
+ },
+ "./compiler/html-document": {
+ "import": "./dist/compiler/htmlDocument.js",
+ "types": "./dist/compiler/htmlDocument.d.ts"
+ },
"./runtime/position-edits": {
"import": "./dist/runtime/positionEdits.js",
"types": "./dist/runtime/positionEdits.d.ts"
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 0a527d9a3..5a7a0ae57 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -158,6 +158,7 @@ export {
type SubCompositionValidity,
type SubCompositionValidityReason,
} from "./compiler/subCompositionValidity";
+export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument";
export { queryByAttr } from "./utils/cssSelector";
export { decodeUrlPathVariants } from "./utils/urlPath";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts
index d05959cd7..f721fd9dc 100644
--- a/packages/sdk/src/engine/model.ts
+++ b/packages/sdk/src/engine/model.ts
@@ -134,6 +134,17 @@ export function resolveScoped(document: Document, id: string): Element | null {
return context as Element;
}
+/**
+ * Bare leaf id from a scoped hf-id ("hf-HOST/hf-LEAF" → "hf-LEAF"; a bare id
+ * passes through unchanged). The live DOM's `data-hf-id` attribute never
+ * carries the host-chain prefix, so a consumer holding a scopedId (from
+ * getElements()/getElement()) needs this to query the rendered DOM directly.
+ */
+export function bareId(scopedId: string): string {
+ const parts = scopedId.split("/");
+ return parts[parts.length - 1] ?? scopedId;
+}
+
/**
* Returns true when this element starts a new sub-composition scope — i.e. it
* is a host element (has data-composition-file) and is NOT the outerHTML
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 731dc4514..ed9df3df5 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -26,6 +26,8 @@ export { UnsupportedOpError } from "./engine/mutate.js";
export { buildDocument, buildRoots, flatElements } from "./document.js";
+export { isNewHostBoundary, bareId } from "./engine/model.js";
+
export { openComposition } from "./session.js";
export type { OpenCompositionOptions } from "./session.js";
diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts
index fede50e2b..2b320440f 100644
--- a/packages/sdk/src/session.subcomp.test.ts
+++ b/packages/sdk/src/session.subcomp.test.ts
@@ -13,7 +13,7 @@
import { describe, it, expect } from "vitest";
import { parseHTML } from "linkedom";
import { ensureHfIds } from "@hyperframes/core/hf-ids";
-import { resolveScoped, findById } from "./engine/model.js";
+import { resolveScoped, findById, isNewHostBoundary, bareId } from "./engine/model.js";
import { parseMutable } from "./engine/model.js";
import { buildRoots, flatElements } from "./document.js";
import { openComposition } from "./session.js";
@@ -531,3 +531,120 @@ describe("scopedId stability across serialize/re-parse", () => {
expect(ids1).toEqual(ids2);
});
});
+
+// ─── 7. isNewHostBoundary ──────────────────────────────────────────────────────
+
+describe("isNewHostBoundary", () => {
+ it("is true for a host with no ancestor dcf (top-level sub-comp host)", () => {
+ const doc = makeDoc(
+ inlinedHtml(`
`),
+ ) as unknown as Document;
+ const host = doc.querySelector('[data-hf-id="hf-host"]') as unknown as Element;
+ expect(isNewHostBoundary(host)).toBe(true);
+ });
+
+ it("is false for an element with no data-composition-file at all", () => {
+ const doc = makeDoc(inlinedHtml(``)) as unknown as Document;
+ const el = doc.querySelector('[data-hf-id="hf-plain"]') as unknown as Element;
+ expect(isNewHostBoundary(el)).toBe(false);
+ });
+
+ it("is false for the outerHTML innerRoot (same dcf value as its host parent)", () => {
+ const doc = makeDoc(
+ inlinedHtml(`
+
+ `),
+ ) as unknown as Document;
+ const inner = doc.querySelector('[data-hf-id="hf-inner"]') as unknown as Element;
+ expect(isNewHostBoundary(inner)).toBe(false);
+ });
+
+ it("is true for a nested host with a DIFFERENT dcf from its parent", () => {
+ const doc = makeDoc(
+ inlinedHtml(`
+
+ `),
+ ) as unknown as Document;
+ const innerHost = doc.querySelector('[data-hf-id="hf-inner-host"]') as unknown as Element;
+ expect(isNewHostBoundary(innerHost)).toBe(true);
+ });
+});
+
+// ─── 8. bareId ──────────────────────────────────────────────────────────────────
+
+describe("bareId", () => {
+ it("returns the leaf segment of a scoped id", () => {
+ expect(bareId("hf-host/hf-leaf")).toBe("hf-leaf");
+ });
+
+ it("returns a deeply nested id's leaf segment", () => {
+ expect(bareId("hf-a/hf-b/hf-c")).toBe("hf-c");
+ });
+
+ it("passes a bare id through unchanged", () => {
+ expect(bareId("hf-solo")).toBe("hf-solo");
+ });
+});
+
+// ─── 9. getRootElements — no descendant duplication ────────────────────────────
+
+describe("getRootElements", () => {
+ it("excludes descendants that getElements() also lists as top-level entries", async () => {
+ const html = inlinedHtml(`
+
+
Title
+
+ solo
+ `);
+ const comp = await openComposition(html);
+
+ // getElements() is flat: hf-title appears once nested under hf-panel AND
+ // once again as its own top-level entry.
+ const flatIds = comp.getElements().map((e) => e.id);
+ expect(flatIds).toContain("hf-title");
+ expect(flatIds).toContain("hf-panel");
+
+ // getRootElements() only returns true roots — hf-title is not one, since
+ // it's hf-panel's descendant.
+ const rootIds = comp.getRootElements().map((e) => e.id);
+ expect(rootIds).toEqual(["hf-panel", "hf-solo"]);
+ expect(comp.getRootElements().find((e) => e.id === "hf-panel")?.children[0]?.id).toBe(
+ "hf-title",
+ );
+ });
+
+ it("treats a sub-composition host as a root even though it has descendants", async () => {
+ const html = inlinedHtml(`
+
+ `);
+ const comp = await openComposition(html);
+ expect(comp.getRootElements().map((e) => e.id)).toEqual(["hf-host"]);
+ });
+});
+
+// ─── 10. serialize({ stripRuntime }) ───────────────────────────────────────────
+
+describe("serialize({ stripRuntime })", () => {
+ const RUNTIME_SCRIPT =
+ '';
+
+ it("keeps the embedded runtime script by default", async () => {
+ const html = `${RUNTIME_SCRIPT}`;
+ const comp = await openComposition(html);
+ expect(comp.serialize()).toContain("hyperframe.runtime");
+ });
+
+ it("strips the embedded runtime script when stripRuntime is true", async () => {
+ const html = `${RUNTIME_SCRIPT}`;
+ const comp = await openComposition(html);
+ const out = comp.serialize({ stripRuntime: true });
+ expect(out).not.toContain("hyperframe.runtime");
+ expect(out).toContain('data-hf-id="hf-a"');
+ });
+});
diff --git a/packages/sdk/src/session.timings.test.ts b/packages/sdk/src/session.timings.test.ts
index 0f1907b08..4b9168bc5 100644
--- a/packages/sdk/src/session.timings.test.ts
+++ b/packages/sdk/src/session.timings.test.ts
@@ -113,6 +113,46 @@ describe("getElementTimings — GSAP labels", () => {
});
});
+// ─── getElementTimings — relative data-start references ──────────────────────
+
+/** "intro" starts at data-start=1 for 3s (ends at 4). "outro" starts 2s after intro ends. */
+const RELATIVE_START_HTML = `
+
+
Intro
+
Outro
+
Right after
+
+`.trim();
+
+describe("getElementTimings — relative data-start references", () => {
+ it("resolves 'ref + offset' against the referenced element's resolved end", async () => {
+ const comp = await openComposition(RELATIVE_START_HTML);
+ const timings = comp.getElementTimings();
+
+ // hf-intro: enterAt=1, exitAt=4
+ expect(timings["hf-intro"]).toMatchObject({ enterAt: 1, exitAt: 4 });
+ // hf-outro: "hf-intro + 2" = intro's exitAt (4) + 2 = 6
+ expect(timings["hf-outro"]).toMatchObject({ enterAt: 6, exitAt: 10 });
+ });
+
+ it("resolves a bare reference (no offset) to the referenced element's exitAt", async () => {
+ const comp = await openComposition(RELATIVE_START_HTML);
+ const timings = comp.getElementTimings();
+ expect(timings["hf-right-after"]).toMatchObject({ enterAt: 4, exitAt: 5 });
+ });
+
+ it("resolves to 0 (not NaN) when the reference target doesn't exist", async () => {
+ const html = `
+
+ `.trim();
+ const comp = await openComposition(html);
+ const timings = comp.getElementTimings();
+ expect(timings["hf-orphan"]).toMatchObject({ enterAt: 0, exitAt: 2 });
+ });
+});
+
// ─── setElementTiming — sparse map + batched dispatch ────────────────────────
describe("setElementTiming", () => {
diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts
index cbeb781ec..317fda9b5 100644
--- a/packages/sdk/src/session.ts
+++ b/packages/sdk/src/session.ts
@@ -36,6 +36,8 @@ import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScript, resolveScoped } from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
+import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
+import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
import { serializeDocument } from "./engine/serialize.js";
import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js";
import { buildPatchEvent, pathToKey } from "./engine/patches.js";
@@ -174,33 +176,61 @@ class CompositionImpl implements Composition {
this._gsapLabelCache = script ? { script, labels: allLabels } : null;
}
+ // Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" —
+ // parseStartExpression's grammar) into an absolute second, recursively against the
+ // referenced element's own resolved start + duration. A plain numeric data-start keeps
+ // the old parseFloat path unchanged — this only touches the case that used to silently
+ // resolve to 0 (parseFloat("intro + 2") is NaN). Node-safe static counterpart of the
+ // runtime's own resolver (runtime/startResolver.ts): no live GSAP timeline to fall back
+ // on, so an unauthored sub-composition duration still resolves to 0, same as before.
+ const startCache = new Map();
+ const visiting = new Set();
+ const resolveStart = (el: Element): number => {
+ const cached = startCache.get(el);
+ if (cached !== undefined) return cached;
+ if (visiting.has(el)) return 0; // reference cycle — fail safe, don't loop
+ visiting.add(el);
+ let resolved: number;
+ try {
+ const startStr = el.getAttribute("data-start");
+ const expr = parseStartExpression(startStr);
+ if (expr?.kind === "reference") {
+ const target = resolveScoped(this.parsed.document, expr.refId);
+ resolved = target
+ ? Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + expr.offset)
+ : 0;
+ } else {
+ resolved = startStr !== null ? parseFloat(startStr) : 0;
+ }
+ } finally {
+ visiting.delete(el);
+ }
+ const finite = Number.isFinite(resolved) ? resolved : 0;
+ startCache.set(el, finite);
+ return finite;
+ };
+ // Same preference as handleSetTiming: prefer data-duration, fall back to end - start.
+ const resolveDuration = (el: Element): number | null => {
+ const durationStr = el.getAttribute("data-duration");
+ const durationAttr = durationStr !== null ? parseFloat(durationStr) : null;
+ if (durationAttr !== null && Number.isFinite(durationAttr)) return durationAttr;
+ const endStr = el.getAttribute("data-end");
+ const endAttr = endStr !== null ? parseFloat(endStr) : null;
+ if (endAttr !== null && Number.isFinite(endAttr)) return endAttr - resolveStart(el);
+ return null;
+ };
+
const result: Record = {};
const elements = this.getElements();
for (const el of elements) {
const domEl = resolveScoped(this.parsed.document, el.scopedId);
if (!domEl) continue;
- const startStr = domEl.getAttribute("data-start");
- const endStr = domEl.getAttribute("data-end");
- const durationStr = domEl.getAttribute("data-duration");
+ const enterAt = resolveStart(domEl);
+ const duration = resolveDuration(domEl);
+ if (duration === null) continue; // no timing info — skip non-timed elements
- // Same preference as handleSetTiming: prefer data-duration, fall back to end - start.
- const start = startStr !== null ? parseFloat(startStr) : 0;
- const durationAttr = durationStr !== null ? parseFloat(durationStr) : null;
- const endAttr = endStr !== null ? parseFloat(endStr) : null;
-
- let duration: number;
- if (durationAttr !== null && Number.isFinite(durationAttr)) {
- duration = durationAttr;
- } else if (endAttr !== null && Number.isFinite(endAttr)) {
- duration = endAttr - start;
- } else {
- // No timing info — skip non-timed elements.
- continue;
- }
-
- const enterAt = Number.isFinite(start) ? start : 0;
- const exitAt = enterAt + (Number.isFinite(duration) ? duration : 0);
+ const exitAt = enterAt + duration;
// Labels whose position falls within [enterAt, exitAt] (end-inclusive: a
// label exactly at exitAt is treated as within the element's window).
@@ -307,6 +337,18 @@ class CompositionImpl implements Composition {
return [...this.elementsCache];
}
+ /**
+ * Top-level elements only (each still carrying its full descendant subtree via
+ * `.children`) — unlike `getElements()`, no element appears twice. Consumers building a
+ * tree view (a layer panel) want this, not `getElements()`: that method's flat list
+ * includes every descendant a second time as its own top-level entry, since each
+ * snapshot in it still carries its children. `buildRoots` already computes true roots
+ * internally for `getElements()` to flatten — this just returns them unflattened.
+ */
+ getRootElements(): ElementSnapshot[] {
+ return buildRoots(this.parsed.document);
+ }
+
getElement(id: HfId): ElementSnapshot | null {
// Accept both bare ids (top-level) and scoped ids (sub-composition elements).
// Match by scopedId first (canonical); bare-id fallback keeps top-level compat
@@ -567,8 +609,13 @@ class CompositionImpl implements Composition {
// ── Serialization ────────────────────────────────────────────────────────────
- serialize(): string {
- return serializeDocument(this.parsed);
+ serialize(opts?: { stripRuntime?: boolean }): string {
+ const html = serializeDocument(this.parsed);
+ // Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own
+ // HTML. A host driving its own clock (an editing iframe) must not let that runtime
+ // self-init — it races the host's first seek and resets the timeline to t=0. Opt-in
+ // (default false) since a host playing the composition normally wants the runtime.
+ return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html;
}
// ── T3 embedded-mode extras ──────────────────────────────────────────────────
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 59476fae3..83b875af0 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -461,6 +461,8 @@ export interface Composition {
// ── Query API (F1) ─────────────────────────────────────────────────────────
getElements(): ElementSnapshot[];
+ /** Top-level elements only, each carrying its full subtree — no id appears twice. */
+ getRootElements(): ElementSnapshot[];
getElement(id: HfId): ElementSnapshot | null;
find(query: FindQuery): string[];
/**