mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core,producer): composition CSS variables reach the render path at eval time
Live testing of the compile-time variable emission surfaced four gaps: - The producer render path never emitted the compile-time stylesheet (only the preview bundler did), so eval-time reads — GSAP .from immediateRender, top-level getComputedStyle — saw undefined vars in rendered output. The producer's inlineSubCompositions now calls the shared emitRootCompositionVariableStyles and passes the variable hooks. - --variables overrides weren't visible at eval time. They now thread from the orchestrator / distributed plan through compileStage into the emitted rules (window.__hfVariables still covers script reads). - Per-declarer rules anchored on data-composition-id, which two inlined instances of one sub-composition share — instance A's rule restyled instance B, and a rule directly on the declarer defeated the host's inherited data-variable-values. Rules now anchor on per-instance data-hf-var-scope markers and layer nearest-host values over declared defaults, mirroring the runtime loader. - Emission ignored authored CSS; a declared default now yields to a var already defined in an authored <style> block (define-if-absent, matching the runtime injection). Also: the figma importer emits background-color (longhand) for solid fills. GSAP backgroundColor tweens cannot read a var() through the background shorthand — its pending-substitution longhands serialize empty, so .from captured nothing and settled on transparent (pre-existing GSAP interaction, reproduced with no composition variables involved). Validated live: eval-time default + override, .from + override, two-instance host branding, authored :root precedence, SDS brand-loop pixel parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
def276524b
commit
e2c88ef689
@@ -74,7 +74,7 @@ describe("runComponentImport", () => {
|
||||
|
||||
it("bakes literals and reports unresolved bindings when the index is empty", async () => {
|
||||
const { out, html } = await importHero();
|
||||
expect(html).toContain("background: #0066FF");
|
||||
expect(html).toContain("background-color: #0066FF");
|
||||
expect(html).not.toContain("var(");
|
||||
expect(out.unresolved).toHaveLength(1);
|
||||
expect(out.unresolved[0]?.figmaId).toBe("VariableID:1:1");
|
||||
|
||||
@@ -950,7 +950,7 @@ export async function bundleToSingleHtml(
|
||||
document.body.appendChild(compScript);
|
||||
}
|
||||
|
||||
emitRootCompositionVariableStyles(document);
|
||||
emitRootCompositionVariableStyles(document, compVariablesByComp);
|
||||
|
||||
enforceCompositionPixelSizing(document);
|
||||
autoHealMissingCompositionIds(document);
|
||||
@@ -1014,29 +1014,114 @@ function compositionVariablesCssBlock(
|
||||
* every element declaring data-composition-variables gets a scoped stylesheet
|
||||
* rule so var(--slug, literal) references resolve during body parse. The
|
||||
* runtime injection remains define-if-absent, so it won't double-apply.
|
||||
*
|
||||
* `variablesByComp` (host-merged sub-composition values, keyed by runtime
|
||||
* composition id) adds one rule per scope — the flattened inner root loses
|
||||
* its data-composition-id, so the host selector is the only stable anchor.
|
||||
* Exported for the producer's render compiler, which inlines sub-compositions
|
||||
* through the shared module rather than this bundler.
|
||||
* Returns whether a style element was appended.
|
||||
*/
|
||||
function emitRootCompositionVariableStyles(document: Document): void {
|
||||
const rules: string[] = [];
|
||||
const htmlDeclared = readDeclaredDefaults(document.documentElement);
|
||||
const htmlRule = compositionVariablesCssBlock(htmlDeclared, ":root");
|
||||
if (htmlRule) rules.push(htmlRule);
|
||||
for (const el of [...document.querySelectorAll("[data-composition-variables]")]) {
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
const elId = el.getAttribute("id");
|
||||
const selector = compId
|
||||
? cssAttributeSelector("data-composition-id", compId)
|
||||
: elId
|
||||
? `#${elId}`
|
||||
: null;
|
||||
if (!selector) continue;
|
||||
const rule = compositionVariablesCssBlock(readDeclaredDefaults(el), selector);
|
||||
if (rule) rules.push(rule);
|
||||
}
|
||||
if (rules.length === 0) return;
|
||||
export function emitRootCompositionVariableStyles(
|
||||
document: Document,
|
||||
variablesByComp: Record<string, Record<string, unknown>> = {},
|
||||
overrides: Record<string, unknown> = {},
|
||||
): boolean {
|
||||
const layerFor = makeVariableLayer(document, overrides);
|
||||
const rules = [
|
||||
...hostScopedVariableRules(variablesByComp, overrides),
|
||||
...rootDeclaredVariableRules(document, layerFor),
|
||||
...declarerVariableRules(document, layerFor),
|
||||
];
|
||||
if (rules.length === 0) return false;
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-hf-composition-variables", "");
|
||||
style.textContent = rules.join("\n\n");
|
||||
document.head.appendChild(style);
|
||||
return true;
|
||||
}
|
||||
|
||||
type VariableLayer = (
|
||||
declared: Record<string, unknown>,
|
||||
hostValues: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Layering for one declarer: authored stylesheet definitions win over
|
||||
* declared defaults (the runtime's define-if-absent, applied statically) —
|
||||
* a var already defined in any authored <style> block is not emitted. Host
|
||||
* values and --variables overrides are explicit intent, never filtered.
|
||||
*/
|
||||
function makeVariableLayer(document: Document, overrides: Record<string, unknown>): VariableLayer {
|
||||
const authoredCss = [...document.querySelectorAll("style:not([data-hf-composition-variables])")]
|
||||
.map((s) => s.textContent || "")
|
||||
.join("\n");
|
||||
const authoredDefines = (id: string): boolean =>
|
||||
new RegExp(`${cssVariableName(id)}\\s*:`).test(authoredCss);
|
||||
return (declared, hostValues) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [id, value] of Object.entries(declared)) {
|
||||
if (!authoredDefines(id)) out[id] = value;
|
||||
}
|
||||
for (const [id, value] of Object.entries(hostValues)) {
|
||||
if (id in declared) out[id] = value;
|
||||
}
|
||||
for (const [id, value] of Object.entries(overrides)) {
|
||||
if (id in declared || id in hostValues) out[id] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/** Host-scoped rules: per-instance values inherited by the host's subtree. */
|
||||
function hostScopedVariableRules(
|
||||
variablesByComp: Record<string, Record<string, unknown>>,
|
||||
overrides: Record<string, unknown>,
|
||||
): string[] {
|
||||
const rules: string[] = [];
|
||||
for (const [compId, vars] of Object.entries(variablesByComp)) {
|
||||
const withOverrides = { ...vars };
|
||||
for (const [id, value] of Object.entries(overrides)) {
|
||||
if (id in vars) withOverrides[id] = value;
|
||||
}
|
||||
const rule = compositionVariablesCssBlock(
|
||||
withOverrides,
|
||||
cssAttributeSelector("data-composition-id", compId),
|
||||
);
|
||||
if (rule) rules.push(rule);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rootDeclaredVariableRules(document: Document, layerFor: VariableLayer): string[] {
|
||||
const htmlDeclared = readDeclaredDefaults(document.documentElement);
|
||||
const htmlRule = compositionVariablesCssBlock(layerFor(htmlDeclared, {}), ":root");
|
||||
return htmlRule ? [htmlRule] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarer rules anchor on a per-instance marker attribute, not the
|
||||
* composition id: two inlined instances of one sub-composition share a
|
||||
* data-composition-id, and a shared selector would let instance A's rule
|
||||
* restyle instance B. The nearest ancestor host's data-variable-values
|
||||
* layer over the declared defaults (mirrors the runtime loader).
|
||||
*/
|
||||
function declarerVariableRules(document: Document, layerFor: VariableLayer): string[] {
|
||||
const rules: string[] = [];
|
||||
let markerSeq = 0;
|
||||
for (const el of [...document.querySelectorAll("[data-composition-variables]")]) {
|
||||
const declared = readDeclaredDefaults(el);
|
||||
const hostEl =
|
||||
typeof el.closest === "function" ? el.parentElement?.closest("[data-variable-values]") : null;
|
||||
const hostValues = hostEl ? parseHostVariableValues(hostEl) : {};
|
||||
const vars = layerFor(declared, hostValues);
|
||||
if (Object.keys(vars).length === 0) continue;
|
||||
markerSeq += 1;
|
||||
el.setAttribute("data-hf-var-scope", String(markerSeq));
|
||||
const rule = compositionVariablesCssBlock(vars, `[data-hf-var-scope="${markerSeq}"]`);
|
||||
if (rule) rules.push(rule);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,9 @@ export {
|
||||
type BundleOptions,
|
||||
prepareFlattenedInnerRoot,
|
||||
FLATTENED_INNER_ROOT_STRIP_ATTRS,
|
||||
emitRootCompositionVariableStyles,
|
||||
} from "./htmlBundler";
|
||||
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
|
||||
|
||||
export {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("nodeToHtml", () => {
|
||||
expect(out.html).toContain("width: 800px");
|
||||
expect(out.html).toContain("height: 600px");
|
||||
expect(out.html).toContain("position: relative");
|
||||
expect(out.html).toContain("background: #FFFFFF");
|
||||
expect(out.html).toContain("background-color: #FFFFFF");
|
||||
});
|
||||
|
||||
it("absolutely positions children relative to the root frame", () => {
|
||||
@@ -49,7 +49,7 @@ describe("nodeToHtml", () => {
|
||||
expect(out.html).toContain("width: 120px");
|
||||
expect(out.html).toContain("border-radius: 8px");
|
||||
expect(out.html).toContain("opacity: 0.9");
|
||||
expect(out.html).toContain("background: #0066FF");
|
||||
expect(out.html).toContain("background-color: #0066FF");
|
||||
});
|
||||
|
||||
it("emits var() with literal fallback for resolved bindings", () => {
|
||||
@@ -75,7 +75,7 @@ describe("nodeToHtml", () => {
|
||||
unresolved: [],
|
||||
},
|
||||
);
|
||||
expect(out.html).toContain("background: var(--figma-blue-500, #0066FF)");
|
||||
expect(out.html).toContain("background-color: var(--figma-blue-500, #0066FF)");
|
||||
});
|
||||
|
||||
it("bakes literals and flags unresolved bindings — never a dangling var()", () => {
|
||||
@@ -94,7 +94,7 @@ describe("nodeToHtml", () => {
|
||||
unresolved: [{ nodeId: "1:2", property: "fills", figmaId: "VariableID:9:9" }],
|
||||
},
|
||||
);
|
||||
expect(out.html).toContain("background: #0066FF");
|
||||
expect(out.html).toContain("background-color: #0066FF");
|
||||
expect(out.html).not.toContain("var(");
|
||||
expect(out.html).toContain('data-figma-unresolved="fills"');
|
||||
});
|
||||
@@ -168,7 +168,7 @@ describe("nodeToHtml", () => {
|
||||
);
|
||||
expect(out.html).not.toContain("1:5");
|
||||
expect(out.html).toContain('data-figma-id="1:6"');
|
||||
expect(out.html).not.toContain("background: #0066FF");
|
||||
expect(out.html).not.toContain("background-color: #0066FF");
|
||||
});
|
||||
|
||||
it("maps linear gradients and drop shadows", () => {
|
||||
|
||||
@@ -228,7 +228,11 @@ function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
||||
if (bg !== null) styles.push(`color: ${bg}`);
|
||||
textCss(node, styles);
|
||||
} else if (bg !== null) {
|
||||
styles.push(`background: ${bg}`);
|
||||
// background-color (longhand) for solid fills, never the shorthand: GSAP
|
||||
// backgroundColor tweens can't read a var() through the shorthand (its
|
||||
// pending-substitution longhands serialize empty), so .from/.to on an
|
||||
// imported node would settle on transparent instead of the token color.
|
||||
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
|
||||
}
|
||||
shapeCss(node, styles);
|
||||
effectsCss(node, styles);
|
||||
|
||||
@@ -344,6 +344,7 @@ export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DIST
|
||||
* gate.
|
||||
*/
|
||||
export class FormatNotSupportedInDistributedError extends Error {
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
|
||||
readonly format: string;
|
||||
readonly reason: string;
|
||||
@@ -808,6 +809,7 @@ export async function plan(
|
||||
// Distributed renders must not capture host-specific system fonts —
|
||||
// the Lambda/worker filesystem won't have the same fonts installed.
|
||||
allowSystemFontCapture: false,
|
||||
variables: config.variables,
|
||||
});
|
||||
let compiled = compileResult.compiled;
|
||||
const composition = compileResult.composition;
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
import {
|
||||
inlineSubCompositions as inlineSubCompositionsShared,
|
||||
prepareFlattenedInnerRoot,
|
||||
emitRootCompositionVariableStyles,
|
||||
readDeclaredDefaults,
|
||||
parseHostVariableValues,
|
||||
} from "@hyperframes/core/compiler";
|
||||
import {
|
||||
checkSubCompositionUsability,
|
||||
@@ -761,18 +764,32 @@ function inlineSubCompositions(
|
||||
html: string,
|
||||
subCompositions: Map<string, string>,
|
||||
projectDir: string,
|
||||
variableOverrides: Record<string, unknown> = {},
|
||||
): string {
|
||||
const { document } = parseHTML(html);
|
||||
const head = document.querySelector("head");
|
||||
const body = document.querySelector("body");
|
||||
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
|
||||
|
||||
if (!hosts.length) return html;
|
||||
if (!hosts.length) {
|
||||
// Even with no sub-compositions, declared composition variables need a
|
||||
// compile-time stylesheet so eval-time reads (GSAP .from immediateRender,
|
||||
// top-level script getComputedStyle) resolve var(--slug) — the runtime's
|
||||
// DOMContentLoaded injection is too late for those.
|
||||
const emitted = emitRootCompositionVariableStyles(
|
||||
document as unknown as Document,
|
||||
{},
|
||||
variableOverrides,
|
||||
);
|
||||
return emitted ? document.toString() : html;
|
||||
}
|
||||
|
||||
const result = inlineSubCompositionsShared(
|
||||
document as unknown as Document,
|
||||
hosts as unknown as Element[],
|
||||
{
|
||||
readVariableDefaults: readDeclaredDefaults,
|
||||
parseHostVariables: parseHostVariableValues,
|
||||
resolveHtml: (srcPath: string) => {
|
||||
let compHtml = subCompositions.get(srcPath) || null;
|
||||
if (!compHtml) {
|
||||
@@ -872,6 +889,15 @@ function inlineSubCompositions(
|
||||
body.appendChild(scriptEl);
|
||||
}
|
||||
|
||||
// Compile-time CSS custom properties (mirrors the preview bundler): root
|
||||
// declarers plus one scoped rule per sub-composition host, so var(--slug)
|
||||
// resolves at script eval time, not just after runtime injection.
|
||||
emitRootCompositionVariableStyles(
|
||||
document as unknown as Document,
|
||||
result.variablesByComp,
|
||||
variableOverrides,
|
||||
);
|
||||
|
||||
return document.toString();
|
||||
}
|
||||
|
||||
@@ -1547,6 +1573,13 @@ export interface CompileForRenderOptions {
|
||||
animatedGifCacheDir?: string;
|
||||
/** FFmpeg timeout for animated GIF transcodes. */
|
||||
ffmpegProcessTimeout?: number;
|
||||
/**
|
||||
* Render-time variable overrides (`--variables`). Layered over declared
|
||||
* defaults in the compile-time CSS custom-property stylesheet so eval-time
|
||||
* reads (GSAP .from immediateRender) see the overridden value — the
|
||||
* `window.__hfVariables` injection covers script reads, not var() in CSS.
|
||||
*/
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
|
||||
@@ -1611,7 +1644,12 @@ export async function compileForRender(
|
||||
// Inline sub-compositions into the main HTML so the runtime takes the same
|
||||
// synchronous code path as the bundled preview (no async fetch of
|
||||
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
|
||||
const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
|
||||
const inlinedHtml = inlineSubCompositions(
|
||||
fullHtml,
|
||||
subCompositions,
|
||||
projectDir,
|
||||
options.variables ?? {},
|
||||
);
|
||||
|
||||
// Strip preload="none" from media elements — the renderer needs to load all
|
||||
// media upfront for frame capture. Users add this to reduce browser memory in
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface CompileStageInput {
|
||||
* on the local filesystem and embedded. Distributed renders pass `false`.
|
||||
*/
|
||||
allowSystemFontCapture?: boolean;
|
||||
/** Render-time variable overrides (`--variables`); see CompileForRenderOptions.variables. */
|
||||
variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CompileStageResult {
|
||||
@@ -127,6 +129,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
|
||||
log,
|
||||
failClosedFontFetch: failClosedFontFetch === true,
|
||||
allowSystemFontCapture,
|
||||
variables: input.variables,
|
||||
animatedGifCacheDir: cfg.extractCacheDir
|
||||
? join(cfg.extractCacheDir, "animated-gif")
|
||||
: undefined,
|
||||
|
||||
@@ -1292,6 +1292,7 @@ export async function executeRenderJob(
|
||||
needsAlpha,
|
||||
log,
|
||||
assertNotAborted,
|
||||
variables: job.config.variables,
|
||||
}),
|
||||
);
|
||||
let compiled = compileResult.compiled;
|
||||
|
||||
Reference in New Issue
Block a user