Files
hyperframes/packages/core/src/figma/nodeToHtml.test.ts
T
Vance IngallsandClaude Fable 5 e2c88ef689 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>
2026-07-08 02:28:16 -07:00

259 lines
7.9 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from "vitest";
import { nodeToHtml } from "./nodeToHtml";
import type { FigmaNodeDocument } from "./client";
const BOX = (x: number, y: number, width: number, height: number) => ({ x, y, width, height });
const SOLID_BLUE = { type: "SOLID", color: { r: 0, g: 0.4, b: 1, a: 1 } };
function frame(children: FigmaNodeDocument[]): FigmaNodeDocument {
return {
id: "1:1",
name: "Hero Card",
type: "FRAME",
absoluteBoundingBox: BOX(100, 200, 800, 600),
fills: [{ type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 } }],
children,
};
}
describe("nodeToHtml", () => {
it("renders the root frame at exact size with a stable id", () => {
const out = nodeToHtml(frame([]), { resolved: [], unresolved: [] });
expect(out.html).toContain('id="hero-card"');
expect(out.html).toContain('data-figma-id="1:1"');
expect(out.html).toContain("width: 800px");
expect(out.html).toContain("height: 600px");
expect(out.html).toContain("position: relative");
expect(out.html).toContain("background-color: #FFFFFF");
});
it("absolutely positions children relative to the root frame", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: BOX(140, 260, 120, 40),
fills: [SOLID_BLUE],
cornerRadius: 8,
opacity: 0.9,
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).toContain("left: 40px");
expect(out.html).toContain("top: 60px");
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-color: #0066FF");
});
it("emits var() with literal fallback for resolved bindings", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 10, 10),
fills: [SOLID_BLUE],
},
]),
{
resolved: [
{
nodeId: "1:2",
property: "fills",
figmaId: "VariableID:1:1",
compositionVariableId: "figma:Blue/500",
},
],
unresolved: [],
},
);
expect(out.html).toContain("background-color: var(--figma-blue-500, #0066FF)");
});
it("bakes literals and flags unresolved bindings — never a dangling var()", () => {
const out = nodeToHtml(
frame([
{
id: "1:2",
name: "Badge",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 10, 10),
fills: [SOLID_BLUE],
},
]),
{
resolved: [],
unresolved: [{ nodeId: "1:2", property: "fills", figmaId: "VariableID:9:9" }],
},
);
expect(out.html).toContain("background-color: #0066FF");
expect(out.html).not.toContain("var(");
expect(out.html).toContain('data-figma-unresolved="fills"');
});
it("renders text with font styles and escaped content", () => {
const out = nodeToHtml(
frame([
{
id: "1:3",
name: "Title",
type: "TEXT",
absoluteBoundingBox: BOX(100, 200, 300, 50),
characters: "Ship <fast> & true",
style: {
fontFamily: "Inter",
fontWeight: 700,
fontSize: 32,
lineHeightPx: 40,
letterSpacing: -0.5,
},
fills: [{ type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 } }],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).toContain("Ship &lt;fast&gt; &amp; true");
expect(out.html).toContain("font-family: 'Inter'");
expect(out.html).toContain("font-weight: 700");
expect(out.html).toContain("font-size: 32px");
expect(out.html).toContain("line-height: 40px");
expect(out.html).toContain("color: #000000");
});
it("routes vectors to the rasterize list with an img placeholder", () => {
const out = nodeToHtml(
frame([
{
id: "1:4",
name: "Logo Mark",
type: "VECTOR",
absoluteBoundingBox: BOX(120, 220, 64, 64),
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.rasterize).toEqual([{ nodeId: "1:4", name: "Logo Mark", slug: "logo-mark" }]);
expect(out.html).toContain('data-figma-rasterize="1:4"');
expect(out.html).toContain("<img");
});
it("skips invisible nodes and invisible fills (respects visible:false)", () => {
const out = nodeToHtml(
frame([
{
id: "1:5",
name: "Hidden",
type: "RECTANGLE",
visible: false,
absoluteBoundingBox: BOX(0, 0, 5, 5),
fills: [SOLID_BLUE],
},
{
id: "1:6",
name: "NoFill",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 5, 5),
fills: [{ ...SOLID_BLUE, visible: false }],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).not.toContain("1:5");
expect(out.html).toContain('data-figma-id="1:6"');
expect(out.html).not.toContain("background-color: #0066FF");
});
it("maps linear gradients and drop shadows", () => {
const out = nodeToHtml(
frame([
{
id: "1:7",
name: "Grad",
type: "RECTANGLE",
absoluteBoundingBox: BOX(100, 200, 10, 10),
fills: [
{
type: "GRADIENT_LINEAR",
gradientStops: [
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 },
],
},
],
effects: [
{
type: "DROP_SHADOW",
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 12,
},
],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).toContain("linear-gradient(180deg, #FF0000 0%, #0000FF 100%)");
expect(out.html).toContain("box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25)");
});
});
describe("nodeToHtml review fixes", () => {
it("routes TEXT color through the binding-aware path (var() when resolved)", () => {
const text: FigmaNodeDocument = {
id: "2:1",
name: "Title",
type: "TEXT",
absoluteBoundingBox: BOX(0, 0, 100, 20),
fills: [SOLID_BLUE],
characters: "Hi",
};
const { html } = nodeToHtml(frame([text]), {
resolved: [
{ nodeId: "2:1", property: "fills", figmaId: "V:1", compositionVariableId: "figma:Ink" },
],
unresolved: [],
});
expect(html).toContain("color: var(--figma-ink,");
});
it("renders ELLIPSE with border-radius 50%", () => {
const ellipse: FigmaNodeDocument = {
id: "2:2",
name: "Dot",
type: "ELLIPSE",
absoluteBoundingBox: BOX(0, 0, 10, 10),
fills: [SOLID_BLUE],
};
const { html } = nodeToHtml(frame([ellipse]), { resolved: [], unresolved: [] });
expect(html).toContain("border-radius: 50%");
});
it("emits overflow hidden for clipsContent frames", () => {
const clipped = { ...frame([]), clipsContent: true };
const { html } = nodeToHtml(clipped, { resolved: [], unresolved: [] });
expect(html).toContain("overflow: hidden");
});
it("neutralizes a hostile fontFamily instead of breaking out of the style attribute", () => {
const text: FigmaNodeDocument = {
id: "2:3",
name: "Evil",
type: "TEXT",
absoluteBoundingBox: BOX(0, 0, 100, 20),
style: { fontFamily: `Mal"; onerror="alert(1)` },
characters: "x",
};
const { html } = nodeToHtml(frame([text]), { resolved: [], unresolved: [] });
expect(html).not.toContain('onerror="');
expect(html).not.toMatch(/style="[^"]*" onerror/);
});
});