/**
* Convert a decoded Figma document (NODE_CHANGES message) into one HTML
* file per top-level frame. Each node renders as a
(or for
* TEXT) with an inline `style="..."` covering layout, background, border,
* radius, shadows, blurs, text, transform, autolayout (flexbox), and
* opacity/blend. Component instances are inlined and tagged with
* data-component-name / data-variant-name / data-component-description /
* data-component-doc-link / data-annotations attributes, matching the
* figma-plugin smart-export conventions.
*/
import * as path from "node:path";
import {
cssBlendMode,
gradientAngleDegreesFromHandles,
gradientGeometryFromTransform,
remapLinearStopPosition,
} from "./figma-node-to-html.js";
export interface Guid {
sessionID: number;
localID: number;
}
interface Color {
r: number;
g: number;
b: number;
a: number;
}
interface Paint {
type?: string;
color?: Color;
opacity?: number;
visible?: boolean;
blendMode?: string;
stops?: Array<{ color: Color; position: number }>;
transform?: {
m00: number;
m01: number;
m02: number;
m10: number;
m11: number;
m12: number;
};
// hash may be a hex string (JSON-decoded) or raw bytes (kiwi-decoder).
image?: { hash?: string | Uint8Array | number[]; name?: string };
imageScaleMode?: string;
rotation?: number;
scale?: number;
}
interface Effect {
type?: string;
visible?: boolean;
color?: Color;
offset?: { x: number; y: number };
radius?: number;
spread?: number;
blendMode?: string;
}
interface Annotation {
label?: string;
labelV2?: string;
properties?: unknown[];
}
interface ComponentPropDef {
id?: Guid;
name?: string;
type?: string;
}
export interface FigNode {
guid?: Guid;
// Library-stable identifier used by override paths (`symbolOverrides[].guidPath`)
// and `derivedSymbolData[].guidPath`. When a node is a remix of a library
// component, the `guid` is local to this document but `overrideKey` is the
// GUID from the source library. Override-path matching MUST use this when
// available so that overrides authored against the library still apply.
overrideKey?: Guid;
parentIndex?: { guid?: Guid; position?: string };
type?: string;
name?: string;
description?: string;
componentKey?: string;
componentPropDefs?: ComponentPropDef[];
componentPropAssignments?: unknown[];
componentPropRefs?: unknown[];
annotations?: Annotation[];
isSymbolPublishable?: boolean;
visible?: boolean;
size?: { x: number; y: number };
// Auto-layout min/max constraints. Either axis may be null when unconstrained.
// A min-width keeps e.g. a single-digit count badge circular.
minSize?: { value?: { x: number | null; y: number | null } };
maxSize?: { value?: { x: number | null; y: number | null } };
transform?: {
m00: number;
m01: number;
m02: number;
m10: number;
m11: number;
m12: number;
};
fillPaints?: Paint[];
strokePaints?: Paint[];
// Style references — when present, the actual paint comes from the
// referenced shared-style node's fillPaints/strokePaints rather than the
// stale `fillPaints`/`strokePaints` cached on this node.
styleIdForFill?: {
guid?: Guid;
assetRef?: { key?: string; version?: string };
};
styleIdForStroke?: {
guid?: Guid;
assetRef?: { key?: string; version?: string };
};
styleIdForText?: {
guid?: Guid;
assetRef?: { key?: string; version?: string };
};
// Library style nodes carry their stable key here (matched against
// `styleIdForFill.assetRef.key` etc. on the consuming nodes).
key?: string;
styleType?: string;
strokeWeight?: number;
strokeAlign?: string;
strokeTopWeight?: number;
strokeRightWeight?: number;
strokeBottomWeight?: number;
strokeLeftWeight?: number;
effects?: Effect[];
opacity?: number;
blendMode?: string;
cornerRadius?: number;
rectangleTopLeftCornerRadius?: number;
rectangleTopRightCornerRadius?: number;
rectangleBottomLeftCornerRadius?: number;
rectangleBottomRightCornerRadius?: number;
fontSize?: number;
fontName?: { family?: string; style?: string };
letterSpacing?: { value: number; units?: string };
lineHeight?: { value: number; units?: string };
textAlignHorizontal?: string;
textAlignVertical?: string;
textData?: {
characters?: string;
// Per-character style index (one entry per UTF-16 code unit); the index
// keys into `styleOverrideTable`. Absent/0 means the node's base style.
characterStyleIDs?: number[];
styleOverrideTable?: Array<{
styleID?: number;
fillPaints?: Paint[];
fontSize?: number;
}>;
};
textAutoResize?: string;
symbolData?: {
symbolID?: Guid;
symbolOverrides?: SymbolOverride[];
};
stackMode?: string;
stackPrimaryAlignItems?: string;
stackCounterAlignItems?: string;
stackSpacing?: number;
stackHorizontalPadding?: number;
stackVerticalPadding?: number;
stackPaddingLeft?: number;
stackPaddingRight?: number;
stackPaddingTop?: number;
stackPaddingBottom?: number;
stackPrimarySizing?: string;
stackCounterSizing?: string;
stackChildPrimaryGrow?: number;
stackChildAlignSelf?: string;
// "ABSOLUTE" marks a child that ignores its parent's auto-layout (Figma's
// "ignore auto layout"): it is positioned by its own transform and removed
// from the flex flow instead of stacking as a flex item.
stackPositioning?: string;
resizeToFit?: boolean;
horizontalConstraint?: string;
verticalConstraint?: string;
frameMaskDisabled?: boolean;
internalOnly?: boolean;
// Vector geometry. Each path's `commandsBlob` is an index into the
// document `blobs` array; the bytes there are a Figma path command
// stream (see decodePathCommands).
fillGeometry?: Array<{
commandsBlob?: number;
windingRule?: string;
styleID?: number;
}>;
strokeGeometry?: Array<{
commandsBlob?: number;
windingRule?: string;
styleID?: number;
}>;
strokeJoin?: string;
strokeCap?: string;
strokeDashes?: number[];
vectorData?: {
normalizedSize?: { x: number; y: number };
vectorNetworkBlob?: number;
};
// Literal values are stale baked snapshots; resolveVariableBindings rewrites from here.
variableConsumptionMap?: {
entries?: Array<{
variableField?: string;
variableData?: { value?: VariableValue };
}>;
};
// Which mode to resolve for each variable set; set may be referenced by assetRef.key or guid.
variableModeBySetMap?: {
entries?: Array<{
variableSetID?: VariableSetRef;
variableModeID?: Guid;
}>;
};
// On VARIABLE_SET nodes: the modes this set defines (first is the default).
variableSetModes?: Array<{ id?: Guid; name?: string }>;
// On VARIABLE nodes: this variable's per-mode values and owning set.
variableSetID?: VariableSetRef;
variableDataValues?: {
entries?: Array<{
modeID?: Guid;
variableData?: { value?: VariableValue };
}>;
};
}
// IS_TRUTHY(alias) expressions appear on VISIBLE bindings driven by variant props.
interface VariableValue {
alias?: { guid?: Guid; assetRef?: { key?: string } };
boolValue?: boolean;
expressionValue?: {
expressionFunction?: string;
expressionArguments?: Array<{ value?: VariableValue }>;
};
[field: string]: unknown;
}
// A variable set is referenced either by its published `assetRef.key` or, for
// a set local to the document, by `guid`.
interface VariableSetRef {
guid?: Guid;
assetRef?: { key?: string };
}
/**
* Names Figma assigns by default — when a layer keeps its placeholder name
* we treat it as having no meaningful name (matches the figma-plugin's
* smart-export `isDummyName`).
*/
/**
* Split a Figma component master name into a base component name and a
* variant suffix. Figma uses two conventions:
*
* - Slash-separated: "ComponentName/VariantA/VariantB" — everything after
* the first slash is the variant name (this matches the plugin's
* smart-export behavior).
* - Variant-set children: a SYMBOL named like "Style=Action, Size=Large"
* is one variant inside a parent component-set FRAME. In that case the
* SYMBOL name IS the variant key/value list and the real component
* name is the parent FRAME's name (resolved separately by the caller).
*/
function isVariantSymbolName(name: string | undefined): boolean {
if (!name) return false;
// Variant SYMBOL naming: comma-separated `Key=Value` pairs.
return /^[^/=]+=[^=]+(,\s*[^/=]+=[^=]+)*$/.test(name);
}
function splitComponentName(name: string | undefined): {
base: string;
variant: string | null;
} {
if (!name) return { base: "", variant: null };
if (isVariantSymbolName(name)) return { base: "", variant: name };
const i = name.indexOf("/");
if (i < 0) return { base: name, variant: null };
return { base: name.slice(0, i), variant: name.slice(i + 1) };
}
/**
* Resolve the canonical component name + variant string for a SYMBOL,
* walking up to the parent component-set FRAME when the SYMBOL itself is
* a variant child (e.g. "Style=Action" inside a "Right Element" FRAME).
*/
function resolveComponentIdentity(
symbol: FigNode,
ctx: Ctx,
): { base: string; variant: string | null } {
const ident = splitComponentName(symbol.name);
if (ident.base) return ident;
// Variant SYMBOL — use the parent FRAME (component set) as the base name.
const parentKey = guidKey(symbol.parentIndex?.guid);
const parent = ctx.byGuid.get(parentKey);
const parentName = parent?.name?.trim();
if (parentName) {
return { base: parentName, variant: ident.variant };
}
return { base: symbol.name ?? "", variant: null };
}
function htmlToPlain(html: string | undefined): string {
if (!html) return "";
return html
.replace(/ /gi, "\n")
.replace(/<\/p>\s*
]*>/gi, "\n\n")
.replace(/<[^>]+>/g, "")
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.trim();
}
function extractDocLinks(html: string | undefined): string[] {
if (!html) return [];
const out: string[] = [];
const re = /href="([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) out.push(m[1]!);
return out;
}
export function guidKey(g: Guid | undefined): string {
return g ? `${g.sessionID}:${g.localID}` : "";
}
/**
* Collect the raw Figma component-prop data on a node into a single object
* suitable for stringifying into a `props="..."` attribute.
*
* - SYMBOL nodes get their `componentPropDefs` (the prop schema)
* - INSTANCE nodes get the `componentPropAssignments` (overrides) plus any
* `componentPropRefs` (per-child wirings) and the master's defs for
* context.
* - Any node may have its own `componentPropRefs` (e.g. an inner layer
* bound to a parent component prop).
*/
function collectRawProps(
node: FigNode,
componentSymbol: FigNode | null,
): Record | null {
const out: Record = {};
const sym = componentSymbol ?? (node.type === "SYMBOL" ? node : null);
if (sym?.componentPropDefs?.length) out.defs = sym.componentPropDefs;
if (
node.componentPropAssignments &&
(node.componentPropAssignments as unknown[]).length
)
out.assignments = node.componentPropAssignments;
if (node.componentPropRefs && (node.componentPropRefs as unknown[]).length)
out.refs = node.componentPropRefs;
return Object.keys(out).length > 0 ? out : null;
}
/**
* A resolved Figma component prop value, normalized across the (legacy)
* `value` and (modern) `varValue` shapes. Only the fields we actually act on
* are extracted: bool (for VISIBLE), text (for TEXT_DATA), and guid (for
* OVERRIDDEN_SYMBOL_ID and SLOT_CONTENT_ID).
*/
interface ResolvedPropValue {
bool?: boolean;
text?: string;
guid?: Guid;
}
function resolvePropAssignment(a: unknown): ResolvedPropValue | null {
const ax = a as {
value?: {
boolValue?: boolean;
textValue?: { characters?: string };
guidValue?: Guid;
};
varValue?: {
value?: {
boolValue?: boolean;
textValue?: { characters?: string };
guidValue?: Guid;
symbolIdValue?: { guid?: Guid };
textIdValue?: { value?: string };
};
};
};
const vv = ax.varValue?.value;
const v = ax.value;
const out: ResolvedPropValue = {};
if (typeof vv?.boolValue === "boolean") out.bool = vv.boolValue;
else if (typeof v?.boolValue === "boolean") out.bool = v.boolValue;
if (vv?.textValue?.characters !== undefined)
out.text = vv.textValue.characters;
else if (v?.textValue?.characters !== undefined)
out.text = v.textValue.characters;
else if (vv?.textIdValue?.value !== undefined)
out.text = vv.textIdValue.value;
if (vv?.symbolIdValue?.guid) out.guid = vv.symbolIdValue.guid;
else if (vv?.guidValue) out.guid = vv.guidValue;
else if (v?.guidValue) out.guid = v.guidValue;
return Object.keys(out).length > 0 ? out : null;
}
function buildPropEnv(
node: FigNode,
inherited: Map,
): Map {
const assignments = (node.componentPropAssignments ?? []) as Array<{
defID?: Guid;
}>;
if (assignments.length === 0) return inherited;
const next = new Map(inherited);
for (const a of assignments) {
const key = guidKey(a.defID);
if (!key) continue;
const resolved = resolvePropAssignment(a);
if (resolved) next.set(key, resolved);
}
return next;
}
/**
* Apply parent-instance prop overrides to a node. Returns either the same
* node (no overrides), a shallow-cloned node with `textData` / `symbolData`
* patched, or `null` to indicate the node should be hidden by a VISIBLE
* prop ref resolving to false.
*/
function applyPropRefs(
node: FigNode,
env: Map,
): FigNode | null {
const refs = (node.componentPropRefs ?? []) as Array<{
defID?: Guid;
componentPropNodeField?: string;
}>;
if (refs.length === 0 || env.size === 0) return node;
let patched = node;
for (const ref of refs) {
const v = env.get(guidKey(ref.defID));
if (!v) continue;
const field = ref.componentPropNodeField;
if (field === "VISIBLE" && v.bool === false) return null;
if (field === "TEXT_DATA" && v.text !== undefined) {
patched = {
...patched,
textData: { ...(patched.textData ?? {}), characters: v.text },
};
}
if (field === "OVERRIDDEN_SYMBOL_ID" && v.guid) {
patched = {
...patched,
symbolData: { ...(patched.symbolData ?? {}), symbolID: v.guid },
};
}
}
return patched;
}
/**
* A raw symbol override entry from `symbolData.symbolOverrides`. It is
* effectively a partial NodeChange targeting a descendant of the master
* symbol via `guidPath`. Any field present (other than `guidPath` and
* `overriddenSymbolID`) is shallow-merged onto the descendant node when
* it's emitted, so layout-affecting overrides like `size`, `textAutoResize`,
* `stackChildAlignSelf`, `stackCounterSizing`, `textAlignVertical`, etc.
* all flow through.
*/
interface SymbolOverride extends Partial {
overriddenSymbolID?: Guid;
guidPath?: { guids?: Guid[] };
}
type OverrideEntry = SymbolOverride;
/**
* An active override scope contributed by an enclosing INSTANCE. `startIndex`
* is the position in the running guid path at which this instance's master
* tree begins; override keys in `map` are joined-guid paths RELATIVE to that
* point (matching what Figma stores in `symbolOverrides[].guidPath`).
*
* Multiple layers stack: an outer instance's overrides remain valid even
* after we descend through nested inner instances, because the descendant's
* absolute path under the outer instance is still well-defined.
*/
interface OverrideLayer {
startIndex: number;
map: Map;
}
function buildSymbolOverrideLayer(node: FigNode): Map {
const out = new Map();
for (const o of node.symbolData?.symbolOverrides ?? []) {
const guids = o.guidPath?.guids ?? [];
if (guids.length === 0) continue;
const key = guids.map((g) => guidKey(g)).join("/");
// Multiple override entries can share the same guidPath (e.g. one with
// `overriddenSymbolID` for a variant swap and another with layout
// tweaks). Merge them so neither is lost.
const existing = out.get(key);
if (existing) {
out.set(key, { ...existing, ...o });
} else {
out.set(key, o);
}
}
// `derivedSymbolData` carries pre-computed geometry / text layout for
// descendants of this instance whose actual definition lives in a remote
// library (so the local document has only a stub master). Each entry is
// keyed by a guidPath into the library tree — same coordinate space as
// `symbolOverrides[].guidPath` — so we can fold them into the same layer.
// Only fill/stroke geometry are merged; positional fields (`transform`,
// `size`) and `derivedTextData` are intentionally skipped because they
// describe library-resolved layout that would overwrite the (already
// correct) values cached on the local master node.
for (const d of (
node as {
derivedSymbolData?: Array<
Partial & { guidPath?: { guids?: Guid[] } }
>;
}
).derivedSymbolData ?? []) {
const guids = d.guidPath?.guids ?? [];
if (guids.length === 0) continue;
if (!d.fillGeometry?.length && !d.strokeGeometry?.length) continue;
const key = guids.map((g) => guidKey(g)).join("/");
const patch: SymbolOverride = {};
if (d.fillGeometry?.length) patch.fillGeometry = d.fillGeometry;
if (d.strokeGeometry?.length) patch.strokeGeometry = d.strokeGeometry;
const existing = out.get(key);
out.set(key, existing ? { ...existing, ...patch } : patch);
}
return out;
}
/**
* Apply any matching override entry from the active override layers to a
* node about to be emitted. Returns `null` if the node is hidden by an
* override; otherwise returns the (possibly patched) node.
*/
function applyOverrideLayers(
node: FigNode,
layers: OverrideLayer[],
instancePath: string[],
): FigNode | null {
if (layers.length === 0) return node;
// The lookup key for THIS node within a layer is the chain of inner
// INSTANCE overrideKeys we've descended into since that layer was pushed,
// followed by this node's own overrideKey. Override paths only grow at
// INSTANCE boundaries — descending through plain frames/groups within the
// same master keeps the path the same length.
const nodeKey = guidKey(node.overrideKey ?? node.guid);
if (!nodeKey) return node;
for (const layer of layers) {
const prefix = instancePath.slice(layer.startIndex);
const relKey =
prefix.length > 0 ? `${prefix.join("/")}/${nodeKey}` : nodeKey;
const entry = layer.map.get(relKey);
if (!entry) continue;
if (entry.visible === false) return null;
// Shallow-merge every field present on the override (except the
// routing fields and `overriddenSymbolID`, which goes into symbolData).
// This applies layout overrides like `size`, `textAutoResize`,
// `stackChildAlignSelf`, `stackCounterSizing`, `textAlignVertical`,
// styling fields, etc., in addition to text/visibility.
const merged: FigNode = { ...node };
for (const [field, value] of Object.entries(entry)) {
if (field === "guidPath" || field === "overriddenSymbolID") continue;
if (value === undefined) continue;
(merged as Record)[field] = value;
}
if (entry.overriddenSymbolID) {
merged.symbolData = {
...(merged.symbolData ?? {}),
symbolID: entry.overriddenSymbolID,
};
}
node = merged;
}
return node;
}
function sanitizeFilename(name: string | undefined, fallback: string): string {
if (!name) return fallback;
const cleaned = name
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80);
return cleaned || fallback;
}
function escapeHtmlText(s: string): string {
return s.replace(/&/g, "&").replace(//g, ">");
}
function escapeHtmlAttr(s: string): string {
return s
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/ `-${c.toLowerCase()}`);
}
function colorToCss(c: Color | undefined, alphaMul = 1): string | null {
if (!c) return null;
const r = Math.round(c.r * 255);
const g = Math.round(c.g * 255);
const b = Math.round(c.b * 255);
const a = c.a * alphaMul;
if (a >= 0.999) return `rgb(${r}, ${g}, ${b})`;
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`;
}
function num(n: number | null | undefined): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Math.round(n * 100) / 100;
}
interface TextRun {
text: string;
/** CSS color when a per-character override differs from the base fill. */
color?: string;
}
/**
* Split TEXT into color runs from `characterStyleIDs` + `styleOverrideTable`
* (how one node holds two colors). Overridden runs carry an explicit color;
* base-fill runs inherit the element's `color`. One plain run when unstyled.
*/
function textStyleRuns(node: FigNode): TextRun[] {
const chars = node.textData?.characters ?? "";
const ids = node.textData?.characterStyleIDs;
const table = node.textData?.styleOverrideTable;
if (!chars) return [];
if (!ids || ids.length === 0 || !table || table.length === 0) {
return [{ text: chars }];
}
const colorByStyle = new Map();
for (const entry of table) {
if (entry?.styleID == null) continue;
// Topmost visible solid in the override's fill list.
let solid: Paint | undefined;
for (const p of entry.fillPaints ?? []) {
if (p.visible !== false && p.type === "SOLID") solid = p;
}
colorByStyle.set(
entry.styleID,
solid
? (colorToCss(solid.color, solid.opacity ?? 1) ?? undefined)
: undefined,
);
}
const runs: TextRun[] = [];
let curText = "";
let curColor: string | undefined;
let started = false;
for (let i = 0; i < chars.length; i++) {
const color = colorByStyle.get(ids[i] ?? 0);
if (!started) {
curColor = color;
started = true;
} else if (color !== curColor) {
runs.push({ text: curText, color: curColor });
curText = "";
curColor = color;
}
curText += chars[i];
}
if (curText) runs.push({ text: curText, color: curColor });
return runs;
}
function tagFor(type: string | undefined): string {
// Everything renders as a real DOM tag rather than a synthetic component.
// TEXT becomes so it inlines nicely; everything else is
.
if (type === "TEXT") return "span";
return "div";
}
const STACK_ALIGN: Record = {
MIN: "flex-start",
CENTER: "center",
MAX: "flex-end",
BASELINE: "baseline",
SPACE_BETWEEN: "space-between",
};
const TEXT_ALIGN: Record = {
LEFT: "left",
CENTER: "center",
RIGHT: "right",
JUSTIFIED: "justify",
};
function fontWeightFromStyle(style: string | undefined): number | null {
if (!style) return null;
const s = style.toLowerCase();
if (s.includes("thin")) return 100;
if (s.includes("extralight") || s.includes("ultralight")) return 200;
if (s.includes("light")) return 300;
if (s.includes("regular") || s === "normal") return 400;
if (s.includes("medium")) return 500;
if (s.includes("semibold") || s.includes("demibold")) return 600;
if (s.includes("extrabold") || s.includes("ultrabold")) return 800;
if (s.includes("black") || s.includes("heavy")) return 900;
if (s.includes("bold")) return 700;
return null;
}
function lengthFromUnits(
v: { value: number; units?: string } | undefined,
fontSize?: number,
) {
if (!v) return null;
if (v.units === "PIXELS") return `${num(v.value)}px`;
if (v.units === "PERCENT") {
if (fontSize) return `${num((v.value / 100) * fontSize)}px`;
return `${num(v.value)}%`;
}
if (v.units === "RAW") return num(v.value);
return num(v.value);
}
/**
* Normalize a Figma image hash into a hex string. The kiwi decoder emits
* the hash as a Uint8Array / number[]; the JSON-roundtripped form is
* already a hex string.
*/
function hashToHex(
h: string | Uint8Array | number[] | undefined,
): string | null {
if (!h) return null;
if (typeof h === "string") return h;
const arr = h instanceof Uint8Array ? Array.from(h) : (h as number[]);
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
}
/**
* Resolve an image hash to a usable URL path. Looks up the actual filename
* (which may be `` or `.png` depending on whether the source
* was a zip-format or kiwi-format `.fig`) in the ctx imageMap.
*/
function imageUrl(hashHex: string, ctx: Ctx): string {
const resolved = ctx.imageMap.get(hashHex);
if (!resolved && ctx.missingImageUrl) return ctx.missingImageUrl;
const filename = resolved ?? hashHex;
if (/^(?:https?:|blob:|about:|data:|file:)/i.test(filename)) return filename;
const base = ctx.imageRefBase ?? "images";
return `${base}/${filename}`;
}
/**
* Resolve a style reference (`styleIdForFill` / `styleIdForStroke` /
* `styleIdForText`) to the actual style node. Style refs come in two
* flavors: a local `guid` for in-document styles and an `assetRef.key`
* for library styles. Library style definitions get embedded in the
* document under the same `key`, so we look them up via `ctx.byKey`.
*/
function resolveStyleNode(
ref: { guid?: Guid; assetRef?: { key?: string } } | undefined,
ctx: Ctx,
): FigNode | undefined {
if (!ref) return undefined;
if (ref.guid) {
const n = ctx.byGuid.get(guidKey(ref.guid));
if (n) return n;
}
if (ref.assetRef?.key) {
const n = ctx.byKey.get(ref.assetRef.key);
if (n) return n;
}
return undefined;
}
/**
* Resolve the effective fill paints for a node. When the node references a
* shared FILL style via `styleIdForFill`, the cached `fillPaints` baked
* into the node may be stale (the design token's actual color can have
* changed since). Prefer the style node's `fillPaints` whenever a fill
* style reference is present.
*
* Do NOT fall back to `styleIdForText` here: a text style carries
* typography (font/size/weight/line-height) and its `fillPaints` is just
* the swatch color used for the style's preview glyphs ("Ag") — typically
* black regardless of where the style is actually applied. The text node's
* own `fillPaints` is the source of truth for color.
*/
function effectiveFillPaints(node: FigNode, ctx: Ctx): Paint[] | undefined {
const style = resolveStyleNode(node.styleIdForFill, ctx);
if (style?.fillPaints?.length) return style.fillPaints;
return node.fillPaints;
}
function effectiveStrokePaints(node: FigNode, ctx: Ctx): Paint[] | undefined {
const style = resolveStyleNode(node.styleIdForStroke, ctx);
if (style?.fillPaints?.length) return style.fillPaints;
if (style?.strokePaints?.length) return style.strokePaints;
return node.strokePaints;
}
function paintToBackground(p: Paint, node: FigNode, ctx: Ctx): string | null {
if (p.visible === false) return null;
if (p.type === "SOLID") {
const color = colorToCss(p.color, p.opacity ?? 1);
return color ? `linear-gradient(${color}, ${color})` : null;
}
if (p.type?.startsWith("GRADIENT") && Array.isArray(p.stops)) {
const box = node.size ? { width: node.size.x, height: node.size.y } : null;
const kind = p.type.slice("GRADIENT_".length) as
| "LINEAR"
| "RADIAL"
| "ANGULAR"
| "DIAMOND";
const geometry =
p.transform && box
? gradientGeometryFromTransform(kind, p.transform, box)
: null;
const stopPosition =
geometry && kind === "LINEAR" && box
? remapLinearStopPosition(
geometry.handles,
box,
gradientAngleDegreesFromHandles(geometry.handles, box),
)
: (position: number) => position;
const stops = p.stops
.map(
(s) =>
`${colorToCss(s.color, p.opacity ?? 1)} ${num(stopPosition(s.position) * 100)}%`,
)
.join(", ");
if (p.type === "GRADIENT_LINEAR") {
if (geometry && box) {
const angle = gradientAngleDegreesFromHandles(geometry.handles, box);
return `linear-gradient(${num(angle)}deg, ${stops})`;
}
return `linear-gradient(${stops})`;
}
if (p.type === "GRADIENT_RADIAL") {
if (geometry) {
return `radial-gradient(ellipse ${num(geometry.rx)}px ${num(geometry.ry)}px at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`;
}
return `radial-gradient(${stops})`;
}
if (p.type === "GRADIENT_ANGULAR") {
if (geometry) {
return `conic-gradient(from ${num(geometry.fromDeg)}deg at ${num(geometry.center.x)}px ${num(geometry.center.y)}px, ${stops})`;
}
return `conic-gradient(${stops})`;
}
if (p.type === "GRADIENT_DIAMOND") {
ctx.approximatedNodes.push({
nodeId: guidKey(node.guid),
nodeName: node.name,
nodeType: node.type,
notes: ["GRADIENT_DIAMOND approximated as radial-gradient"],
});
return `radial-gradient(${stops})`;
}
}
if (p.type === "IMAGE") {
const hex = hashToHex(p.image?.hash);
if (hex) {
const u = imageUrl(hex, ctx);
return `url('${u.replace(/'/g, "%27")}')`;
}
}
return null;
}
function backgroundShorthand(
node: FigNode,
ctx: Ctx,
): {
backgroundColor?: string;
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
backgroundBlendMode?: string;
} {
const fills = (effectiveFillPaints(node, ctx) ?? []).filter(
(f) => f.visible !== false,
);
if (fills.length === 0) return {};
const result: {
backgroundColor?: string;
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
backgroundBlendMode?: string;
} = {};
// Optimization: when there is exactly one fill and it's a plain SOLID at the
// bottom, emit it as `background-color` (cheaper CSS, same visual) and skip
// adding it to the bgImages layer list so we don't double-render it.
const isSingleSolidOnly = fills.length === 1 && fills[0]?.type === "SOLID";
if (isSingleSolidOnly) {
const color = colorToCss(fills[0]!.color, fills[0]!.opacity ?? 1);
if (color) result.backgroundColor = color;
return result;
}
const bgImages: string[] = [];
const bgSizes: string[] = [];
const bgPositions: string[] = [];
const bgRepeats: string[] = [];
const bgBlends: string[] = [];
for (const f of fills) {
const image = paintToBackground(f, node, ctx);
if (!image) continue;
bgImages.push(image);
bgBlends.push(blendModeCss(f.blendMode) ?? "normal");
if (f.type !== "IMAGE") {
bgSizes.push("auto");
bgPositions.push("0% 0%");
bgRepeats.push("repeat");
continue;
}
const mode = f.imageScaleMode ?? "FILL";
if (mode === "FILL") bgSizes.push("cover");
else if (mode === "FIT") bgSizes.push("contain");
else if (mode === "STRETCH") bgSizes.push("100% 100%");
else bgSizes.push("auto");
bgPositions.push(mode === "TILE" ? "0% 0%" : "center");
bgRepeats.push(mode === "TILE" ? "repeat" : "no-repeat");
}
bgImages.reverse();
bgSizes.reverse();
bgPositions.reverse();
bgRepeats.reverse();
bgBlends.reverse();
if (bgImages.length > 0) {
result.backgroundImage = bgImages.join(", ");
result.backgroundSize = bgSizes.join(", ");
result.backgroundPosition = bgPositions.join(", ");
result.backgroundRepeat = bgRepeats.join(", ");
if (bgBlends.some((blend) => blend !== "normal")) {
result.backgroundBlendMode = bgBlends.join(", ");
}
}
return result;
}
function borderShorthand(node: FigNode, ctx: Ctx): Record {
const strokes = (effectiveStrokePaints(node, ctx) ?? []).filter(
(p) => p.visible !== false,
);
if (strokes.length === 0) return {};
const first = strokes[0]!;
const color = colorToCss(first.color, first.opacity ?? 1);
if (!color) return {};
const hasPerSide =
node.strokeTopWeight !== undefined ||
node.strokeRightWeight !== undefined ||
node.strokeBottomWeight !== undefined ||
node.strokeLeftWeight !== undefined;
const uniformW = node.strokeWeight ?? 0;
if (!hasPerSide) {
if (!uniformW) return {};
if (node.strokeAlign === "OUTSIDE") {
return { outline: `${num(uniformW)}px solid ${color}` };
}
if (node.strokeAlign === "INSIDE") {
// box-shadow keeps the border inside the element without expanding its dimensions
return { boxShadow: `inset 0 0 0 ${num(uniformW)}px ${color}` };
}
return { border: `${num(uniformW)}px solid ${color}` };
}
// Per-side stroke weights: fall back to uniformW for unspecified sides
const topW = node.strokeTopWeight ?? uniformW;
const rightW = node.strokeRightWeight ?? uniformW;
const bottomW = node.strokeBottomWeight ?? uniformW;
const leftW = node.strokeLeftWeight ?? uniformW;
if (!topW && !rightW && !bottomW && !leftW) return {};
const result: Record = {};
if (node.strokeAlign === "INSIDE") {
// Use border-side + border-box so the border stays within Figma's stated dimensions.
// For absolute children this is only exact when there's no top or left border
// (the common case for Fluent UI's active bottom border).
if (topW) result.borderTop = `${num(topW)}px solid ${color}`;
if (rightW) result.borderRight = `${num(rightW)}px solid ${color}`;
if (bottomW) result.borderBottom = `${num(bottomW)}px solid ${color}`;
if (leftW) result.borderLeft = `${num(leftW)}px solid ${color}`;
result.boxSizing = "border-box";
return result;
}
if (node.strokeAlign === "OUTSIDE") {
// outline doesn't support per-side; simulate with box-shadow (no inset)
const shadows: string[] = [];
if (topW) shadows.push(`0 -${num(topW)}px 0 0 ${color}`);
if (rightW) shadows.push(`${num(rightW)}px 0 0 0 ${color}`);
if (bottomW) shadows.push(`0 ${num(bottomW)}px 0 0 ${color}`);
if (leftW) shadows.push(`-${num(leftW)}px 0 0 0 ${color}`);
return shadows.length ? { boxShadow: shadows.join(", ") } : {};
}
// CENTER: individual border-side properties
if (topW) result.borderTop = `${num(topW)}px solid ${color}`;
if (rightW) result.borderRight = `${num(rightW)}px solid ${color}`;
if (bottomW) result.borderBottom = `${num(bottomW)}px solid ${color}`;
if (leftW) result.borderLeft = `${num(leftW)}px solid ${color}`;
return result;
}
function radiusStyles(node: FigNode): Record {
const out: Record = {};
const corners = [
node.rectangleTopLeftCornerRadius,
node.rectangleTopRightCornerRadius,
node.rectangleBottomRightCornerRadius,
node.rectangleBottomLeftCornerRadius,
];
const allEqual =
corners.every((c) => c === corners[0]) &&
typeof corners[0] === "number" &&
corners[0] > 0;
if (allEqual) {
out.borderRadius = `${num(corners[0])}px`;
return out;
}
if (corners.some((c) => typeof c === "number" && c > 0)) {
out.borderTopLeftRadius = `${num(corners[0] ?? 0)}px`;
out.borderTopRightRadius = `${num(corners[1] ?? 0)}px`;
out.borderBottomRightRadius = `${num(corners[2] ?? 0)}px`;
out.borderBottomLeftRadius = `${num(corners[3] ?? 0)}px`;
return out;
}
if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
out.borderRadius = `${num(node.cornerRadius)}px`;
}
if (node.type === "ELLIPSE") out.borderRadius = "50%";
return out;
}
/**
* @param shadowAsFilter Render DROP_SHADOWs as `filter: drop-shadow()` instead
* of `box-shadow`. `box-shadow` traces the element's box, so an overflowing
* child (e.g. a tooltip's caret) is left shadowless and the box shadow cuts
* straight across behind it. `filter: drop-shadow()` follows the element's
* rendered alpha — body plus caret — at the cost of the (here unused) spread.
*/
function effectStyles(
node: FigNode,
shadowAsFilter = false,
): Record {
const effects = node.effects?.filter((e) => e.visible !== false) ?? [];
if (effects.length === 0) return {};
const shadows: string[] = [];
const filters: string[] = [];
let backdropBlur: string | null = null;
for (const e of effects) {
if (e.type === "DROP_SHADOW") {
const c = colorToCss(e.color) ?? "rgba(0, 0, 0, 0.25)";
if (shadowAsFilter) {
filters.push(
`drop-shadow(${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${c})`,
);
} else {
shadows.push(
`${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`,
);
}
} else if (e.type === "INNER_SHADOW") {
// Inner shadows have no filter equivalent; always emit as box-shadow.
const c = colorToCss(e.color) ?? "rgba(0, 0, 0, 0.25)";
shadows.push(
`inset ${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`,
);
} else if (e.type === "FOREGROUND_BLUR" || e.type === "LAYER_BLUR") {
filters.push(`blur(${num((e.radius ?? 0) / 2)}px)`);
} else if (e.type === "BACKGROUND_BLUR") {
backdropBlur = `blur(${num((e.radius ?? 0) / 2)}px)`;
}
}
const out: Record = {};
if (shadows.length) out.boxShadow = shadows.join(", ");
if (filters.length) out.filter = filters.join(" ");
if (backdropBlur) out.backdropFilter = backdropBlur;
return out;
}
function transformStyle(node: FigNode): {
transform?: string;
transformOrigin?: string;
} {
const t = node.transform;
if (!t) return {};
const determinant = t.m00 * t.m11 - t.m01 * t.m10;
const hasNonTrivialScale = Math.abs(Math.abs(determinant) - 1) > 0.01;
const angle = Math.atan2(t.m10, t.m00);
const isPureRotation =
Math.abs(t.m00 - Math.cos(angle)) < 0.01 &&
Math.abs(t.m01 + Math.sin(angle)) < 0.01 &&
Math.abs(t.m10 - Math.sin(angle)) < 0.01 &&
Math.abs(t.m11 - Math.cos(angle)) < 0.01;
const hasSkew =
(Math.abs(t.m01) > 0.0001 || Math.abs(t.m10) > 0.0001) && !isPureRotation;
if (hasNonTrivialScale || hasSkew) {
return {
transform: `matrix(${num(t.m00)}, ${num(t.m10)}, ${num(t.m01)}, ${num(t.m11)}, 0, 0)`,
transformOrigin: "0 0",
};
}
const deg = (angle * 180) / Math.PI;
if (Math.abs(deg) < 0.01) return {};
return { transform: `rotate(${num(deg)}deg)`, transformOrigin: "top left" };
}
function autolayoutStyles(node: FigNode): Record {
if (!node.stackMode || node.stackMode === "NONE") return {};
const out: Record = {
display: "flex",
flexDirection: node.stackMode === "VERTICAL" ? "column" : "row",
};
if (node.stackPrimaryAlignItems)
out.justifyContent =
STACK_ALIGN[node.stackPrimaryAlignItems] ?? "flex-start";
if (node.stackCounterAlignItems)
out.alignItems = STACK_ALIGN[node.stackCounterAlignItems] ?? "flex-start";
if (typeof node.stackSpacing === "number")
out.gap = `${num(node.stackSpacing)}px`;
// Padding: prefer per-side; fall back to horizontal/vertical.
const pl = node.stackPaddingLeft ?? node.stackHorizontalPadding;
const pr = node.stackPaddingRight ?? node.stackHorizontalPadding;
const pt = node.stackPaddingTop ?? node.stackVerticalPadding;
const pb = node.stackPaddingBottom ?? node.stackVerticalPadding;
if ([pl, pr, pt, pb].some((v) => typeof v === "number" && v !== 0)) {
out.padding = `${num(pt ?? 0)}px ${num(pr ?? 0)}px ${num(pb ?? 0)}px ${num(pl ?? 0)}px`;
}
return out;
}
function textStyles(node: FigNode, ctx?: Ctx): Record {
if (node.type !== "TEXT") return {};
const out: Record = {};
// A TEXT node may reference a shared text style (`styleIdForText`) whose
// font properties (family, weight, size, line-height, letter-spacing,
// alignment) override the values cached on the node itself. The cached
// values are often stale snapshots of the master and don't reflect the
// current style — prefer the style node when present.
const styleNode = ctx
? resolveStyleNode(node.styleIdForText, ctx)
: undefined;
const fontName = styleNode?.fontName ?? node.fontName;
const fontSize =
typeof styleNode?.fontSize === "number"
? styleNode.fontSize
: node.fontSize;
const lineHeight = styleNode?.lineHeight ?? node.lineHeight;
const letterSpacing = styleNode?.letterSpacing ?? node.letterSpacing;
const textAlignHorizontal =
styleNode?.textAlignHorizontal ?? node.textAlignHorizontal;
if (fontName?.family) {
const fam = fontName.family;
const quoted = /\s/.test(fam) ? `"${fam}"` : fam;
// Append a metric-compatible fallback stack by classifying the family.
// This prevents UA serif from appearing when a Google/system font is missing.
const famLower = fam.toLowerCase();
let fallback: string;
if (
/mono|courier|code|consol|menlo|fira code|source code/i.test(famLower)
) {
fallback =
"ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
} else if (
/serif|georgia|garamond|didot|baskerville|palatino|times/i.test(famLower)
) {
fallback = "'Times New Roman', Georgia, Garamond, serif";
} else {
fallback =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
}
out.fontFamily = `${quoted}, ${fallback}`;
}
const weight = fontWeightFromStyle(fontName?.style);
if (weight !== null) out.fontWeight = weight;
// Track this family/weight/italic combo so the frame template can request
// it from Google Fonts in .
if (ctx && fontName?.family) {
const italic = !!(fontName.style && /italic|oblique/i.test(fontName.style));
ctx.fontUsage.add(`${fontName.family}|${weight ?? 400}|${italic ? 1 : 0}`);
}
if (fontName?.style && /italic|oblique/i.test(fontName.style)) {
out.fontStyle = "italic";
}
if (typeof fontSize === "number") out.fontSize = `${num(fontSize)}px`;
const lh = lengthFromUnits(lineHeight, fontSize);
if (lh !== null && lh !== undefined) out.lineHeight = lh;
const ls = lengthFromUnits(letterSpacing, fontSize);
if (ls !== null && ls !== undefined) out.letterSpacing = ls;
if (textAlignHorizontal)
out.textAlign = TEXT_ALIGN[textAlignHorizontal] ?? "left";
const fills = (
ctx ? effectiveFillPaints(node, ctx) : node.fillPaints
)?.filter((fill) => fill.visible !== false);
if (!fills?.length) {
out.visibility = "hidden";
return out;
}
const firstFill = fills[0]!;
if (firstFill.type === "SOLID") {
const color = colorToCss(firstFill.color, firstFill.opacity ?? 1);
if (color) out.color = color;
} else if (
ctx &&
(firstFill.type?.startsWith("GRADIENT_") || firstFill.type === "IMAGE")
) {
const background = paintToBackground(firstFill, node, ctx);
if (background) {
out.background = background;
out.WebkitBackgroundClip = "text";
out.backgroundClip = "text";
out.color = "transparent";
}
}
return out;
}
function blendModeCss(mode: string | undefined): string | null {
if (!mode) return null;
const result = cssBlendMode(mode);
return result?.cssMode ?? null;
}
function isAutolayout(parent: FigNode | null): boolean {
return !!(parent && parent.stackMode && parent.stackMode !== "NONE");
}
/**
* Compose an INSTANCE node with its inlined master's autolayout / padding /
* sizing properties. The master is the source of truth for how children are
* arranged; the instance's cached `stack*` fields can be a stale snapshot of
* a previous variant. Per-axis sizing (`size`) stays on the instance — only
* the layout description is taken from the master.
*/
function withMasterLayout(instance: FigNode, master: FigNode): FigNode {
const layoutFields: (keyof FigNode)[] = [
"stackMode",
"stackPrimaryAlignItems",
"stackCounterAlignItems",
"stackSpacing",
"stackPaddingLeft",
"stackPaddingRight",
"stackPaddingTop",
"stackPaddingBottom",
"stackHorizontalPadding",
"stackVerticalPadding",
"stackPrimarySizing",
"stackCounterSizing",
];
const merged: FigNode = { ...instance };
// If the master defines its own stack direction, the instance's cached
// stack-related fields are stale (they were captured against whatever
// variant the instance originally pointed at). Take ALL layout fields
// from the master wholesale — including `undefined` values — so we don't
// leak e.g. `stackPrimarySizing="FIXED"` from a HORIZONTAL variant onto a
// VERTICAL one whose master leaves it undefined (HUG).
const masterDrivesLayout =
typeof master.stackMode === "string" && master.stackMode !== "NONE";
for (const f of layoutFields) {
const mv = (master as Record)[f as string];
if (masterDrivesLayout) {
(merged as Record)[f as string] = mv;
} else if (mv !== undefined) {
(merged as Record)[f as string] = mv;
}
}
return merged;
}
/**
* Derive the Figma plugin API's `layoutSizingHorizontal` / `layoutSizingVertical`
* for a node. The kiwi document doesn't store these directly — they're
* computed from the underlying stack/sizing/grow fields the same way
* `figma.currentPage.selection[i].layoutSizingHorizontal` is.
*
* Returns "FIXED" | "HUG" | "FILL" per axis. The cached `node.size` always
* carries a baked pixel value, so callers must consult the derived sizing
* before deciding whether to emit an explicit `width`/`height`.
*/
function layoutSizing(
node: FigNode,
parent: FigNode | null,
): {
horizontal: "FIXED" | "HUG" | "FILL";
vertical: "FIXED" | "HUG" | "FILL";
} {
let horizontal: "FIXED" | "HUG" | "FILL" = "FIXED";
let vertical: "FIXED" | "HUG" | "FILL" = "FIXED";
// 1) Self auto-layout (this node has its own stack). Kiwi default for an
// omitted `stackPrimarySizing`/`stackCounterSizing` is HUG, not FIXED.
if (node.stackMode && node.stackMode !== "NONE") {
// An omitted `stackPrimarySizing` is HUG (Figma writes it only when set,
// and it's only ever written as FIXED). An omitted `stackCounterSizing`,
// however, behaves as FIXED: Figma bakes the counter-axis size (a HUG
// badge would collapse to its content height, but the design keeps its
// fixed height). Treat only an explicit RESIZE_TO_FIT* as HUG.
const primaryHug = (node.stackPrimarySizing ?? "RESIZE_TO_FIT") !== "FIXED";
const counterHug = (node.stackCounterSizing ?? "FIXED") !== "FIXED";
if (node.stackMode === "HORIZONTAL") {
horizontal = primaryHug ? "HUG" : "FIXED";
vertical = counterHug ? "HUG" : "FIXED";
} else {
vertical = primaryHug ? "HUG" : "FIXED";
horizontal = counterHug ? "HUG" : "FIXED";
}
}
// 2) Non-autolayout frames (e.g. Figma groups) position their children
// absolutely in this renderer, and CSS `width/height: auto` cannot hug
// out-of-flow children — a hugged group would collapse to 0×0 and clip
// everything inside it. The baked `node.size` already equals the group's
// content bounds, so keep it FIXED rather than honoring `resizeToFit`.
// 3) TEXT auto-resize hugs along the indicated axis/axes.
if (node.type === "TEXT" && node.textAutoResize) {
if (node.textAutoResize === "WIDTH_AND_HEIGHT") {
horizontal = "HUG";
vertical = "HUG";
} else if (node.textAutoResize === "HEIGHT") {
vertical = "HUG";
}
}
// 4) Auto-layout child of an auto-layout parent: grow/stretch -> FILL.
// A child that ignores auto-layout (stackPositioning ABSOLUTE) is not a
// flex item, so it keeps its FIXED pixel size from `node.size`.
if (
parent &&
parent.stackMode &&
parent.stackMode !== "NONE" &&
node.stackPositioning !== "ABSOLUTE"
) {
const grow = (node.stackChildPrimaryGrow ?? 0) > 0;
const stretch = node.stackChildAlignSelf === "STRETCH";
if (parent.stackMode === "HORIZONTAL") {
if (grow) horizontal = "FILL";
if (stretch) vertical = "FILL";
} else {
if (grow) vertical = "FILL";
if (stretch) horizontal = "FILL";
}
}
return { horizontal, vertical };
}
/**
* Pin an absolutely-positioned node to the edge(s) implied by its Figma
* constraint on one axis. MIN keeps the start offset (top/left); MAX anchors
* to the end edge (bottom/right) so the node stays put when the container is
* taller/wider than Figma's baked size; STRETCH pins both edges and returns
* `true` to drop the fixed size on that axis. CENTER/SCALE fall back to the
* baked start offset. Anchoring to the end edge needs the parent's size; when
* it's unknown we fall back to the start offset. Returns whether the axis's
* fixed width/height should be suppressed.
*/
function applyAxisConstraint(
css: Record,
constraint: string | undefined,
pos: number | null,
nodeSize: number | null,
parentSize: number | null,
startProp: "left" | "top",
endProp: "right" | "bottom",
): boolean {
const endVal =
pos !== null && nodeSize !== null && parentSize !== null
? parentSize - (pos + nodeSize)
: null;
if (constraint === "MAX" && endVal !== null) {
css[endProp] = `${endVal}px`;
return false;
}
if (constraint === "STRETCH" && endVal !== null) {
if (pos !== null) css[startProp] = `${pos}px`;
css[endProp] = `${endVal}px`;
return true;
}
if (pos !== null) css[startProp] = `${pos}px`;
return false;
}
function positionRelativeToParent(
node: FigNode,
parent: FigNode | null,
ctx: Ctx,
): { x: number | null; y: number | null } {
const transform = node.transform;
if (!transform) return { x: null, y: null };
// Figma/Kiwi node transforms are already expressed relative to the parent
// (the node's `relativeTransform`), so the translation is the parent-local
// offset at every depth — including direct children of a top-level frame,
// whose own canvas position is dropped when the frame becomes a screen root.
// Subtracting the parent's canvas translation here double-counts the frame
// offset and flings direct children off-canvas.
return { x: num(transform.m02), y: num(transform.m12) };
}
function buildCss(
node: FigNode,
parent: FigNode | null,
ctx: Ctx,
isPositioned: boolean,
vectorLike = false,
hasAbsoluteChild = false,
shadowAsFilter = false,
): Record {
const css: Record = {};
// An absolutely-positioned node is out of the parent's flex flow, so it must
// not receive flex-child hints (flex-grow / align-self) below.
const parentFlex = !isPositioned && isAutolayout(parent);
// Position / size — mirrors smart-export:
// parent is auto-layout -> position: relative, no left/top, dimensions
// may be replaced by flex hints below.
// parent is not -> position: absolute with left/top/width/height.
// Constraints decide which edges an absolutely-positioned node is pinned to.
// STRETCH pins both edges (and drops the fixed size on that axis).
let suppressWidth = false;
let suppressHeight = false;
if (isPositioned) {
css.position = "absolute";
const { x, y } = positionRelativeToParent(node, parent, ctx);
const nodeW = node.size ? num(node.size.x) : null;
const nodeH = node.size ? num(node.size.y) : null;
const parentW = parent?.size ? num(parent.size.x) : null;
const parentH = parent?.size ? num(parent.size.y) : null;
suppressWidth = applyAxisConstraint(
css,
node.horizontalConstraint,
x,
nodeW,
parentW,
"left",
"right",
);
suppressHeight = applyAxisConstraint(
css,
node.verticalConstraint,
y,
nodeH,
parentH,
"top",
"bottom",
);
} else if (parentFlex) {
css.position = "relative";
} else if (hasAbsoluteChild) {
// Establish a positioning context so an "ignore auto layout" child
// (position: absolute) is offset relative to this container rather than
// the page. Top-level frames are otherwise statically positioned.
css.position = "relative";
}
// Decide whether to emit width/height. The Figma plugin API exposes a
// unified `layoutSizingHorizontal`/`layoutSizingVertical` derived from the
// raw stack/grow/textAutoResize fields (kiwi doesn't store those derived
// values). Only emit a pixel dimension on a FIXED axis — HUG and FILL both
// mean "let CSS size it" via flex / intrinsic content.
const sizing = layoutSizing(node, parent);
const emitWidth = sizing.horizontal === "FIXED";
const emitHeight = sizing.vertical === "FIXED";
if (node.size) {
const w = num(node.size.x);
const h = num(node.size.y);
if (w !== null && emitWidth && !suppressWidth) css.width = `${w}px`;
if (h !== null && emitHeight && !suppressHeight) css.height = `${h}px`;
}
// Line vectors (horizontal/vertical strokes) have a 0-size axis. A 0-size
//