diff --git a/.oxlintrc.json b/.oxlintrc.json
index 0fc2d5f8c..3c14d747d 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -4,5 +4,5 @@
"correctness": "error"
},
"plugins": ["react", "typescript"],
- "ignorePatterns": ["dist/", "coverage/", "node_modules/"]
+ "ignorePatterns": ["dist/", "coverage/", "node_modules/", "playground/"]
}
diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts
index 4362bb956..ddd9a1ab6 100644
--- a/packages/core/src/compiler/compositionScoping.test.ts
+++ b/packages/core/src/compiler/compositionScoping.test.ts
@@ -497,6 +497,55 @@ window.__afterTimeline = window.__timelines.scene;
expect(errorSpy).not.toHaveBeenCalled();
});
+ it("uses compound selector when authored root is the scoped element itself", () => {
+ const scoped = scopeCssToComposition(
+ "#chrome-overlay-root { --primary: #FFDC8B; }",
+ "chrome-overlay",
+ undefined,
+ "chrome-overlay-root",
+ { compoundAuthoredRoot: true },
+ );
+
+ // Both attributes are on the same element after inlining, so the selector
+ // must be compound (no space) to match.
+ expect(scoped).toContain(
+ '[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"]',
+ );
+ expect(scoped).not.toContain(
+ '[data-composition-id="chrome-overlay"] [data-hf-authored-id="chrome-overlay-root"]',
+ );
+ });
+
+ it("uses compound selector for authored root with descendant combinators", () => {
+ const scoped = scopeCssToComposition(
+ "#chrome-overlay-root .chrome { display: flex; }",
+ "chrome-overlay",
+ undefined,
+ "chrome-overlay-root",
+ { compoundAuthoredRoot: true },
+ );
+
+ // The authored root part is compound with scope, .chrome is a descendant
+ expect(scoped).toContain(
+ '[data-composition-id="chrome-overlay"][data-hf-authored-id="chrome-overlay-root"] .chrome',
+ );
+ expect(scoped).not.toMatch(
+ /\[data-composition-id="chrome-overlay"\]\s+\[data-hf-authored-id="chrome-overlay-root"\]\s+\.chrome/,
+ );
+ });
+
+ it("still uses descendant selector for non-root selectors with authoredRootId", () => {
+ const scoped = scopeCssToComposition(
+ ".child-element { color: red; }",
+ "chrome-overlay",
+ undefined,
+ "chrome-overlay-root",
+ );
+
+ // Regular child selectors still get a descendant combinator (space)
+ expect(scoped).toContain('[data-composition-id="chrome-overlay"] .child-element');
+ });
+
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
const scoped = scopeCssToComposition(
`#intro { background: #111; }
diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts
index f6fb503d9..876ab59bc 100644
--- a/packages/core/src/compiler/compositionScoping.ts
+++ b/packages/core/src/compiler/compositionScoping.ts
@@ -101,6 +101,7 @@ function scopeSelector(
scope: string,
compositionId: string,
authoredRootId?: string | null,
+ compoundAuthoredRoot?: boolean,
): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
@@ -120,6 +121,15 @@ function scopeSelector(
}
const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? "";
const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? "";
+ if (compoundAuthoredRoot) {
+ const authoredRootAttr = authoredRootId
+ ? `[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(authoredRootId)}"]`
+ : null;
+ if (authoredRootAttr && trimmed.startsWith(authoredRootAttr)) {
+ const rest = trimmed.slice(authoredRootAttr.length);
+ return `${leading}${scope}${authoredRootAttr}${rest}${trailing}`;
+ }
+ }
return `${leading}${scope} ${trimmed}${trailing}`;
}
@@ -158,6 +168,7 @@ export function scopeCssToComposition(
compositionId: string,
scopeSelectorOverride?: string,
authoredRootId?: string | null,
+ options?: { compoundAuthoredRoot?: boolean },
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
@@ -169,7 +180,13 @@ export function scopeCssToComposition(
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
rule.selectors = rule.selectors.map((selector) =>
- scopeSelector(selector, scope, trimmedCompositionId, authoredRootId),
+ scopeSelector(
+ selector,
+ scope,
+ trimmedCompositionId,
+ authoredRootId,
+ options?.compoundAuthoredRoot,
+ ),
);
});
diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts
index 7e91f165e..dd3693a3e 100644
--- a/packages/core/src/compiler/inlineSubCompositions.test.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.test.ts
@@ -151,4 +151,35 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
expect(host.getAttribute("data-composition-id")).toBe("intro");
});
+
+ it("producer path: scoped CSS matches host element when both attributes coexist", () => {
+ 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,
+ compoundAuthoredRoot: true,
+ });
+
+ // After inlining, the host has both data-composition-id and data-hf-authored-id.
+ // CSS selectors targeting the root must be compound (no space) so they match
+ // when both attributes are on the same element.
+ expect(host.getAttribute("data-composition-id")).toBe("intro");
+ expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
+
+ const scopedCss = result.styles.join("\n");
+
+ // Root-only selector: must be compound
+ expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
+ // Must NOT have a descendant combinator between the two attribute selectors
+ expect(scopedCss).not.toMatch(
+ /\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
+ );
+
+ // Descendant selector: compound root + space + child
+ expect(scopedCss).toMatch(
+ /\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
+ );
+ });
});
diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts
index 3ff1a437a..6d2137564 100644
--- a/packages/core/src/compiler/inlineSubCompositions.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.ts
@@ -60,6 +60,15 @@ export interface InlineSubCompositionsOptions {
*/
flattenInnerRoot?: (innerRoot: Element) => Element;
+ /**
+ * When true, CSS selectors targeting the authored root use a compound
+ * selector (`[scope][root]`) instead of a descendant (`[scope] [root]`).
+ * Enable this in the producer path where the inner root merges onto
+ * the host element via innerHTML — both attributes end up on the same
+ * element and a descendant selector won't match.
+ */
+ compoundAuthoredRoot?: boolean;
+
/**
* Read declared variable defaults from a sub-composition's `` element.
* The bundler passes `readDeclaredDefaults`; the producer can omit this.
@@ -139,6 +148,7 @@ export function inlineSubCompositions(
hostIdentityMap,
rewriteInlineStyles = false,
flattenInnerRoot,
+ compoundAuthoredRoot,
readVariableDefaults,
parseHostVariables,
buildScopeSelector = defaultBuildScopeSelector,
@@ -211,7 +221,9 @@ export function inlineSubCompositions(
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
- ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
+ ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
+ compoundAuthoredRoot: compoundAuthoredRoot === true,
+ })
: css,
);
}
@@ -228,7 +240,9 @@ export function inlineSubCompositions(
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
- ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId)
+ ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
+ compoundAuthoredRoot: compoundAuthoredRoot === true,
+ })
: css,
);
s.remove();
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
index 407d948a5..2b505d03e 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { removeElementFromHtml } from "./sourceMutation.js";
+import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
it("removes a self-closing element by id", () => {
@@ -28,3 +28,122 @@ describe("removeElementFromHtml", () => {
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`
`);
});
});
+
+describe("patchElementInHtml", () => {
+ const FIXTURE = `
+
+`;
+
+ it("patches inline style by id", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "inline-style", property: "color", value: "red" },
+ ]);
+
+ expect(result).toMatch(/color:\s*red/);
+ expect(result).toContain('id="hero"');
+ });
+
+ it("patches inline style by class selector", () => {
+ const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
+ { type: "inline-style", property: "font-size", value: "72px" },
+ ]);
+
+ expect(result).toMatch(/font-size:\s*72px/);
+ });
+
+ it("patches data attribute", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "attribute", property: "hf-studio-path-offset", value: "true" },
+ ]);
+
+ expect(result).toContain('data-hf-studio-path-offset="true"');
+ });
+
+ it("patches html attribute", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "title", value: "greeting" },
+ ]);
+
+ expect(result).toContain('title="greeting"');
+ });
+
+ it("patches text content", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "text-content", property: "", value: "New Title" },
+ ]);
+
+ expect(result).toContain("New Title");
+ expect(result).not.toContain("Hello World");
+ });
+
+ it("applies multiple operations in one call", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "inline-style", property: "color", value: "blue" },
+ { type: "inline-style", property: "font-size", value: "96px" },
+ { type: "attribute", property: "hf-studio-path-offset", value: "true" },
+ ]);
+
+ expect(result).toMatch(/color:\s*blue/);
+ expect(result).toMatch(/font-size:\s*96px/);
+ expect(result).toContain('data-hf-studio-path-offset="true"');
+ });
+
+ it("finds element by composition-id selector", () => {
+ const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
+ { type: "inline-style", property: "opacity", value: "0.5" },
+ ]);
+
+ expect(result).toMatch(/opacity:\s*0\.5/);
+ });
+
+ it("finds element by class with selectorIndex", () => {
+ const html = `A
B
`;
+ const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
+ { type: "text-content", property: "", value: "Changed" },
+ ]);
+
+ expect(result).toContain("A");
+ expect(result).toContain("Changed");
+ expect(result).not.toContain(">B<");
+ });
+
+ it("returns unchanged html when target not found", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
+ { type: "inline-style", property: "color", value: "red" },
+ ]);
+
+ expect(result).toBe(FIXTURE);
+ });
+
+ it("removes inline style when value is null", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "inline-style", property: "font-size", value: null },
+ ]);
+
+ expect(result).not.toContain("font-size");
+ });
+
+ it("removes attribute when value is null", () => {
+ const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
+ { type: "html-attribute", property: "data-composition-src", value: null },
+ ]);
+
+ expect(result).not.toContain("data-composition-src");
+ });
+
+ it("patches fragment html without doctype", () => {
+ const fragment = `Title
`;
+ const result = patchElementInHtml(fragment, { id: "card" }, [
+ { type: "inline-style", property: "padding", value: "16px" },
+ ]);
+
+ expect(result).toMatch(/padding:\s*16px/);
+ });
+});
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index 2be500ebd..da41461f5 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -54,3 +54,51 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
element.remove();
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
+
+export interface PatchOperation {
+ type: "inline-style" | "attribute" | "html-attribute" | "text-content";
+ property: string;
+ value: string | null;
+}
+
+export function patchElementInHtml(
+ source: string,
+ target: SourceMutationTarget,
+ operations: PatchOperation[],
+): string {
+ const { document, wrappedFragment } = parseSourceDocument(source);
+ const el = findTargetElement(document, target);
+ if (!el || !(el instanceof (el.ownerDocument.defaultView?.HTMLElement ?? Element))) return source;
+ const htmlEl = el as unknown as HTMLElement;
+
+ for (const op of operations) {
+ switch (op.type) {
+ case "inline-style":
+ if (op.value != null) {
+ htmlEl.style.setProperty(op.property, op.value);
+ } else {
+ htmlEl.style.removeProperty(op.property);
+ }
+ break;
+ case "attribute":
+ if (op.value != null) {
+ htmlEl.setAttribute(`data-${op.property}`, op.value);
+ } else {
+ htmlEl.removeAttribute(`data-${op.property}`);
+ }
+ break;
+ case "html-attribute":
+ if (op.value != null) {
+ htmlEl.setAttribute(op.property, op.value);
+ } else {
+ htmlEl.removeAttribute(op.property);
+ }
+ break;
+ case "text-content":
+ if (op.value != null) htmlEl.textContent = op.value;
+ break;
+ }
+ }
+
+ return wrappedFragment ? document.body.innerHTML || "" : document.toString();
+}
diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts
index 732f2fd3e..aa870cf5d 100644
--- a/packages/core/src/studio-api/routes/files.ts
+++ b/packages/core/src/studio-api/routes/files.ts
@@ -17,7 +17,11 @@ import { isAudioFile } from "../helpers/mime.js";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath } from "../helpers/safePath.js";
-import { removeElementFromHtml } from "../helpers/sourceMutation.js";
+import {
+ removeElementFromHtml,
+ patchElementInHtml,
+ type PatchOperation,
+} from "../helpers/sourceMutation.js";
// ── Shared helpers ──────────────────────────────────────────────────────────
@@ -236,6 +240,45 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
return c.json({ ok: true, changed: true, content: patchedContent });
});
+ api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
+ const id = c.req.param("id");
+ const project = await adapter.resolveProject(id);
+ if (!project) return c.json({ error: "not found" }, 404);
+
+ const filePath = decodeURIComponent(
+ c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""),
+ );
+ if (filePath.includes("\0")) {
+ return c.json({ error: "forbidden" }, 403);
+ }
+
+ const absPath = resolve(project.dir, filePath);
+ if (!isSafePath(project.dir, absPath)) {
+ return c.json({ error: "forbidden" }, 403);
+ }
+ const body = (await c.req.json().catch(() => null)) as {
+ target?: { id?: string | null; selector?: string; selectorIndex?: number };
+ operations?: PatchOperation[];
+ } | null;
+ if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) {
+ return c.json({ error: "target and operations required" }, 400);
+ }
+
+ let originalContent: string;
+ try {
+ originalContent = readFileSync(absPath, "utf-8");
+ } catch {
+ return c.json({ error: "not found" }, 404);
+ }
+ const patchedContent = patchElementInHtml(originalContent, body.target, body.operations);
+ if (patchedContent === originalContent) {
+ return c.json({ ok: true, changed: false, content: originalContent });
+ }
+
+ writeFileSync(absPath, patchedContent, "utf-8");
+ return c.json({ ok: true, changed: true, content: patchedContent });
+ });
+
// ── Rename / Move ──
api.patch("/projects/:id/files/*", async (c) => {
diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts
index cd2ba50e3..30da87ba6 100644
--- a/packages/core/src/studio-api/routes/preview.ts
+++ b/packages/core/src/studio-api/routes/preview.ts
@@ -111,6 +111,33 @@ function injectStudioMotionScript(
);
}
+const GSAP_CDN_FALLBACK_SCRIPT = ``;
+
+function injectGsapCdnFallback(html: string): string {
+ if (html.includes("data-hf-gsap-fallback")) return html;
+ if (html.includes("")) return html.replace("", "" + GSAP_CDN_FALLBACK_SCRIPT);
+ return GSAP_CDN_FALLBACK_SCRIPT + html;
+}
+
function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
@@ -118,7 +145,9 @@ function injectStudioPreviewAugmentations(
activeCompositionPath: string,
): string {
return injectStudioMotionScript(
- injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
+ injectGsapCdnFallback(
+ injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
+ ),
projectDir,
activeCompositionPath,
);
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index 5894a3e58..0e91f5322 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -575,6 +575,7 @@ function inlineSubCompositions(
},
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
scriptErrorLabel: "[Compiler] Composition script failed",
+ compoundAuthoredRoot: true,
},
);
@@ -834,6 +835,23 @@ export interface CompileForRenderOptions {
failClosedFontFetch?: boolean;
}
+const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
+
+function rewriteUnresolvableGsapToCdn(html: string, projectDir: string): string {
+ return html.replace(
+ /(