diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f5791bf8f..0a13203f1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -223,6 +223,71 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci
+ studio-load-smoke:
+ name: "Studio: load smoke"
+ needs: [changes]
+ if: needs.changes.outputs.code == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ lfs: true
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+ - run: bun install --frozen-lockfile
+ - run: bun run --cwd packages/core build:hyperframes-runtime
+ - name: Start studio and check for runtime errors
+ run: |
+ # Start the studio dev server in the background
+ bun run --filter '@hyperframes/studio' dev -- --port 5199 &
+ SERVER_PID=$!
+
+ # Wait for the server to be ready (up to 20s)
+ for i in $(seq 1 40); do
+ if curl -sf http://localhost:5199/ >/dev/null 2>&1; then break; fi
+ sleep 0.5
+ done
+
+ if ! curl -sf http://localhost:5199/ >/dev/null 2>&1; then
+ echo "FAIL: studio dev server did not start"
+ kill $SERVER_PID 2>/dev/null || true
+ exit 1
+ fi
+
+ # Load the studio in headless Chrome and capture console errors
+ # puppeteer is a dependency of @hyperframes/producer; resolve from there
+ cd packages/producer
+ node --input-type=module <<'SMOKE_EOF'
+ import puppeteer from "puppeteer";
+ const browser = await puppeteer.launch({
+ headless: "new",
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
+ });
+ const page = await browser.newPage();
+ const errors = [];
+ page.on("pageerror", (err) => errors.push(err.message));
+ page.on("console", (msg) => {
+ if (msg.type() === "error") errors.push(msg.text());
+ });
+ await page.goto("http://localhost:5199/", { waitUntil: "networkidle0", timeout: 30000 });
+ await new Promise((r) => setTimeout(r, 3000));
+ await browser.close();
+ const fatal = errors.filter(
+ (e) => !e.includes("favicon") && !e.includes("ERR_CONNECTION_REFUSED"),
+ );
+ if (fatal.length > 0) {
+ console.error("FAIL: studio had runtime errors on load:");
+ for (const e of fatal) console.error(" •", e);
+ process.exit(1);
+ }
+ console.log("PASS: studio loaded without runtime errors");
+ SMOKE_EOF
+
+ kill $SERVER_PID 2>/dev/null || true
+
smoke-global-install:
name: "Smoke: global install"
needs: [changes, build]
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..a26a2119a 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,223 @@ 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/);
+ });
+
+ it("rejects event handler attributes", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "onload", value: "fetch('/evil')" },
+ ]);
+
+ expect(result).not.toContain("onload");
+ expect(result).not.toContain("fetch");
+ });
+
+ it("rejects javascript: URLs in src", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "src", value: "javascript:alert(1)" },
+ ]);
+
+ expect(result).not.toContain("javascript:");
+ });
+
+ it("allows aria-* and data-* attributes", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "aria-label", value: "greeting" },
+ { type: "html-attribute", property: "data-custom", value: "test" },
+ ]);
+
+ expect(result).toContain('aria-label="greeting"');
+ expect(result).toContain('data-custom="test"');
+ });
+
+ it("rejects srcdoc and formaction attributes", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "srcdoc", value: "" },
+ { type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
+ ]);
+
+ expect(result).not.toContain("srcdoc");
+ expect(result).not.toContain("formaction");
+ });
+
+ it("rejects on* event handlers regardless of casing", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "onClick", value: "alert(1)" },
+ { type: "html-attribute", property: "ONERROR", value: "alert(2)" },
+ { type: "html-attribute", property: "onmouseover", value: "alert(3)" },
+ ]);
+
+ expect(result).not.toContain("alert");
+ });
+
+ it("rejects data:text/html URIs in src", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ {
+ type: "html-attribute",
+ property: "src",
+ value: "data:text/html,",
+ },
+ ]);
+
+ expect(result).not.toContain("data:text/html");
+ });
+
+ it("allows safe href values", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "href", value: "https://example.com" },
+ ]);
+
+ expect(result).toContain('href="https://example.com"');
+ });
+
+ it("rejects javascript: in href", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "href", value: "javascript:alert(1)" },
+ ]);
+
+ expect(result).not.toContain("javascript:");
+ });
+
+ it("allows legitimate form and media attributes", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "placeholder", value: "Enter text" },
+ { type: "html-attribute", property: "target", value: "_blank" },
+ { type: "html-attribute", property: "rel", value: "noopener" },
+ { type: "html-attribute", property: "srcset", value: "img-2x.png 2x" },
+ ]);
+
+ expect(result).toContain('placeholder="Enter text"');
+ expect(result).toContain('target="_blank"');
+ expect(result).toContain('rel="noopener"');
+ expect(result).toContain("srcset");
+ });
+
+ it("rejects unknown/dangerous attributes", () => {
+ const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
+ { type: "html-attribute", property: "xmlns", value: "http://evil.com" },
+ { type: "html-attribute", property: "background", value: "http://evil.com/bg.js" },
+ { type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" },
+ ]);
+
+ expect(result).not.toContain("xmlns");
+ expect(result).not.toContain("background=");
+ expect(result).not.toContain("dynsrc");
+ });
+});
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index 2be500ebd..97f1a3899 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -54,3 +54,148 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
element.remove();
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
+
+function isHTMLElement(el: Element): boolean {
+ const HTMLEl = el.ownerDocument.defaultView?.HTMLElement;
+ return HTMLEl ? el instanceof HTMLEl : "style" in el;
+}
+
+export interface PatchOperation {
+ type: "inline-style" | "attribute" | "html-attribute" | "text-content";
+ property: string;
+ value: string | null;
+}
+
+const ALLOWED_HTML_ATTRS = new Set([
+ // Identity & structure
+ "id",
+ "class",
+ "style",
+ "title",
+ "name",
+ "for",
+ "type",
+ // Internationalization
+ "lang",
+ "dir",
+ "translate",
+ // Interaction
+ "hidden",
+ "tabindex",
+ "draggable",
+ "contenteditable",
+ // Accessibility
+ "role",
+ "slot",
+ // Links & navigation
+ "href",
+ "target",
+ "rel",
+ // Media
+ "src",
+ "srcset",
+ "sizes",
+ "alt",
+ "poster",
+ "loading",
+ "decoding",
+ "crossorigin",
+ "preload",
+ "autoplay",
+ "loop",
+ "muted",
+ "controls",
+ "playsinline",
+ // Layout
+ "width",
+ "height",
+ "colspan",
+ "rowspan",
+ "scope",
+ // Form
+ "placeholder",
+ "value",
+ "min",
+ "max",
+ "step",
+ "pattern",
+ "required",
+ "disabled",
+ "readonly",
+ "checked",
+ "selected",
+ "multiple",
+ "accept",
+ "maxlength",
+ "minlength",
+ "rows",
+ "cols",
+ "wrap",
+]);
+
+const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
+const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
+
+function isAllowedHtmlAttribute(name: string): boolean {
+ const lower = name.toLowerCase();
+ if (lower.startsWith("on")) return false;
+ if (ALLOWED_HTML_ATTRS.has(lower)) return true;
+ if (lower.startsWith("data-")) return true;
+ if (lower.startsWith("aria-")) return true;
+ return false;
+}
+
+const URI_ATTRS = new Set(["src", "href", "action", "formaction", "poster", "srcset"]);
+
+function isSafeAttributeValue(name: string, value: string): boolean {
+ if (URI_ATTRS.has(name.toLowerCase())) {
+ const trimmed = value.trim();
+ if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
+ if (DANGEROUS_DATA_URI.test(trimmed)) return false;
+ }
+ return true;
+}
+
+export function patchElementInHtml(
+ source: string,
+ target: SourceMutationTarget,
+ operations: PatchOperation[],
+): string {
+ const { document, wrappedFragment } = parseSourceDocument(source);
+ const el = findTargetElement(document, target);
+ if (!el || !isHTMLElement(el)) 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 (!isAllowedHtmlAttribute(op.property)) break;
+ if (op.value != null) {
+ if (!isSafeAttributeValue(op.property, op.value)) break;
+ 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(
+ /(