fix(core,cli,lint): close the figma brand-token loop — runtime CSS variables, --name, snippet lint

Brand-loop live test (SDS duplicate, plans/figma/brand-loop-test-plan.md)
proved the recolor chain end-to-end and surfaced three gaps:

- runtime now defines every declared composition variable as a CSS
  custom property (document root at init + scoped sub-comp hosts in the
  loader), so imported var(--slug, literal) fills resolve live — without
  this the frozen literal always won and variable-driven rebranding
  could not propagate. Slug kept byte-compatible with the figma
  importer (parity test). render --variables overrides win.
- figma component --name: variant frames are often all named
  'Platform=Desktop' and slug-collided across imports.
- imported fragments carry data-hf-snippet and the project linter skips
  composition-root rules for them.
- /figma skill documents the field-tested non-Enterprise tokens path
  (MCP get_variable_defs joined with REST boundVariables ids).

Shared-helper extractions (injectScopedStyles, flattenedRoot module,
parseHostVariableValues, rasterizeFallback, shapeCss) satisfy the
dedup/complexity audit the runtime changes tripped.

Validated live: brand-loop renders purple from the attribute alone (no
manual :root); 118 figma + 662 runtime/compiler + 331 lint tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-08 00:47:13 -07:00
co-authored by Claude Fable 5
parent 80271863d3
commit def276524b
14 changed files with 623 additions and 173 deletions
+28 -21
View File
@@ -65,17 +65,8 @@ function boxOf(node: FigmaNodeDocument): Box | null {
return null;
}
export function slugify(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "node";
}
function cssVarName(compositionVariableId: string): string {
return `--${slugify(compositionVariableId)}`;
}
export { slugify } from "../tokenSlug";
import { cssVariableName as cssVarName, slugify } from "../tokenSlug";
function escapeHtml(text: string): string {
return text
@@ -217,6 +208,17 @@ function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolea
return styles;
}
function shapeCss(node: FigmaNodeDocument, styles: string[]): void {
if (node.type === "ELLIPSE") {
styles.push("border-radius: 50%");
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
}
if (node.clipsContent === true) styles.push("overflow: hidden");
if (typeof node.opacity === "number" && node.opacity < 1)
styles.push(`opacity: ${round(node.opacity)}`);
}
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
const styles: string[] = [];
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
@@ -228,14 +230,7 @@ function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
} else if (bg !== null) {
styles.push(`background: ${bg}`);
}
if (node.type === "ELLIPSE") {
styles.push("border-radius: 50%");
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
}
if (node.clipsContent === true) styles.push("overflow: hidden");
if (typeof node.opacity === "number" && node.opacity < 1)
styles.push(`opacity: ${round(node.opacity)}`);
shapeCss(node, styles);
effectsCss(node, styles);
return styles;
}
@@ -264,7 +259,10 @@ function renderNodeHtml(
const style = escapeHtml(
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "),
);
const idAttrs = `id="${slug}" data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
// data-hf-snippet marks the file as a mountable fragment, not a standalone
// composition — the project linter skips composition-root rules for it.
const snippetAttr = isRoot ? ' data-hf-snippet=""' : "";
const idAttrs = `id="${slug}"${snippetAttr} data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
if (RASTERIZE_TYPES.has(node.type)) {
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
@@ -279,12 +277,21 @@ function renderNodeHtml(
return `<div ${idAttrs} style="${style}">${renderChildren(node, ctx, depth)}</div>`;
}
export interface NodeToHtmlOptions {
/** override for the ROOT element's slug/id — variant frames are often all
* named "Platform=Desktop", so the caller's --name must reach the DOM id,
* not just the output directory */
rootName?: string;
}
export function nodeToHtml(
root: FigmaNodeDocument,
bindings: ResolveBindingsResult,
opts: NodeToHtmlOptions = {},
): NodeToHtmlResult {
const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 };
const ctx: RenderContext = { origin, bindings, rasterize: [], usedSlugs: new Set() };
const html = renderNodeHtml(root, ctx, true);
const rootForRender = opts.rootName !== undefined ? { ...root, name: opts.rootName } : root;
const html = renderNodeHtml(rootForRender, ctx, true);
return { html, rasterize: ctx.rasterize };
}