diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts
index 6d7407f53..3af882f28 100644
--- a/packages/core/src/compiler/compositionScoping.test.ts
+++ b/packages/core/src/compiler/compositionScoping.test.ts
@@ -496,4 +496,61 @@ window.__afterTimeline = window.__timelines.scene;
expect(fakeWindow.__afterTimeline).toBe("updated");
expect(errorSpy).not.toHaveBeenCalled();
});
+
+ it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
+ const scoped = scopeCssToComposition(
+ `#intro { background: #111; }
+#intro .title { font-size: 120px; color: #fff; }`,
+ "intro",
+ undefined,
+ "intro",
+ );
+
+ // #intro should become [data-hf-authored-id="intro"]
+ expect(scoped).toContain('[data-hf-authored-id="intro"]');
+ expect(scoped).toContain('[data-hf-authored-id="intro"] .title');
+ // Raw #intro selectors should be gone
+ expect(scoped).not.toMatch(/#intro\b/);
+ });
+
+ it("wraps scripts with authored root id normalization for #id GSAP selectors", () => {
+ const { document } = parseHTML(`
+
. The host element should NOT contain a
+ // child with id="intro" — the id attribute is lost.
+ const innerRootById = host.querySelector("#intro");
+ expect(innerRootById).toBeNull();
+
+ // The host itself still has data-composition-id="intro" (from the
+ // original markup), but no element inside has id="intro".
+ expect(host.getAttribute("data-composition-id")).toBe("intro");
+
+ // CSS was scoped: #intro selectors should be rewritten to use
+ // data-hf-authored-id attribute selector so they still resolve.
+ const scopedCss = result.styles.join("\n");
+ expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
+ expect(scopedCss).not.toContain("#intro");
+ });
+
+ it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => {
+ const document = makeHostDocument("intro");
+ const host = document.querySelector('[data-composition-src="intro.html"]')!;
+
+ const result = inlineSubCompositions(document, [host], {
+ resolveHtml: () => SUB_COMP_HTML,
+ parseHtml: (html) => parseHTML(html).document,
+ });
+
+ // The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]`
+ // so that the selector resolves against the flattened structure.
+ const scopedCss = result.styles.join("\n");
+ expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
+ expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title');
+ });
+
+ it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => {
+ const document = makeHostDocument("intro");
+ const host = document.querySelector('[data-composition-src="intro.html"]')!;
+
+ const result = inlineSubCompositions(document, [host], {
+ resolveHtml: () => SUB_COMP_HTML,
+ parseHtml: (html) => parseHTML(html).document,
+ });
+
+ // The wrapped script should contain the authored root id normalization
+ // logic so that runtime querySelector('#intro .title') maps to the
+ // data-hf-authored-id attribute selector.
+ const wrappedScript = result.scripts.join("\n");
+ expect(wrappedScript).toContain("__hfAuthoredRootId");
+ expect(wrappedScript).toContain('"intro"');
+ });
+
+ it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
+ const document = makeHostDocument("intro");
+ const host = document.querySelector('[data-composition-src="intro.html"]')!;
+
+ // Simulate the bundler's flattenInnerRoot: clone the element, add
+ // data-hf-authored-id, strip timing attrs (simplified here).
+ function flattenInnerRoot(innerRoot: Element): Element {
+ const clone = innerRoot.cloneNode(true) as Element;
+ const authoredId = clone.getAttribute("id");
+ if (authoredId) {
+ clone.setAttribute("data-hf-authored-id", authoredId);
+ clone.removeAttribute("id");
+ }
+ clone.removeAttribute("data-start");
+ clone.removeAttribute("data-duration");
+ return clone;
+ }
+
+ const result = inlineSubCompositions(document, [host], {
+ resolveHtml: () => SUB_COMP_HTML,
+ parseHtml: (html) => parseHTML(html).document,
+ flattenInnerRoot,
+ });
+
+ // With flattenInnerRoot, the inner root is preserved as a child of the
+ // host via outerHTML. The data-hf-authored-id attribute is present.
+ const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]');
+ expect(authoredRoot).not.toBeNull();
+
+ // CSS is still rewritten to use the attribute selector.
+ const scopedCss = result.styles.join("\n");
+ expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
+ });
+
+ /**
+ * Known divergence: the producer path strips the inner root element via
+ * innerHTML (line 307 of inlineSubCompositions.ts), losing the id attribute.
+ * The bundler path preserves it via flattenInnerRoot + data-hf-authored-id.
+ *
+ * Both paths rewrite CSS and GSAP selectors from `#intro` to
+ * `[data-hf-authored-id="intro"]`, but only the bundler path actually adds
+ * that attribute to an element. In the producer path, no element carries
+ * `data-hf-authored-id`, so the rewritten selectors match nothing.
+ *
+ * Workaround: use [data-composition-id="name"] as scope in sub-compositions
+ * instead of #name.
+ *
+ * Proper fix (follow-up): make the producer path add data-hf-authored-id
+ * to the host element when the inner root has an id attribute.
+ */
+ it("documents the divergence: producer path lacks data-hf-authored-id element", () => {
+ const document = makeHostDocument("intro");
+ const host = document.querySelector('[data-composition-src="intro.html"]')!;
+
+ // Producer path: no flattenInnerRoot
+ inlineSubCompositions(document, [host], {
+ resolveHtml: () => SUB_COMP_HTML,
+ parseHtml: (html) => parseHTML(html).document,
+ });
+
+ // After producer inlining, no element inside the host has
+ // data-hf-authored-id="intro". The rewritten CSS/GSAP selectors
+ // targeting [data-hf-authored-id="intro"] will match nothing.
+ const authoredIdElement = host.querySelector('[data-hf-authored-id="intro"]');
+ expect(authoredIdElement).toBeNull();
+
+ // The original #intro element is gone — innerHTML stripped it.
+ const introById = host.querySelector("#intro");
+ expect(introById).toBeNull();
+
+ // Only the host itself has data-composition-id="intro", which is the
+ // correct workaround scope to use.
+ expect(host.getAttribute("data-composition-id")).toBe("intro");
+ });
+});
diff --git a/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html
new file mode 100644
index 000000000..91b50b4df
--- /dev/null
+++ b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/intro.html
@@ -0,0 +1,20 @@
+
+
+
diff --git a/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html
new file mode 100644
index 000000000..b2a02b256
--- /dev/null
+++ b/packages/core/src/runtime/__fixtures__/sub-comp-id-selector/root.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+