mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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>
259 lines
7.9 KiB
TypeScript
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 <fast> & 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/);
|
|
});
|
|
});
|