mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user