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:
Vance Ingalls
2026-07-08 02:28:16 -07:00
co-authored by Claude Fable 5
parent def276524b
commit e2c88ef689
9 changed files with 163 additions and 28 deletions
@@ -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");
+104 -19
View File
@@ -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;
}
/**
+2
View File
@@ -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,
+5 -5
View File
@@ -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", () => {
+5 -1
View File
@@ -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;
+40 -2
View File
@@ -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;