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
+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);