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
@@ -153,4 +153,17 @@ describe("runComponentImport", () => {
}), }),
).rejects.toThrow(/rate limit/); ).rejects.toThrow(/rate limit/);
}); });
it("honors a name override so variant frames don't slug-collide", async () => {
const out = await runComponentImport("FILE:1-1", {
projectDir: dir,
client: client(),
download: () => Promise.resolve(SVG),
name: "Hero Actions",
});
expect(out.name).toBe("hero-actions");
expect(out.htmlPath).toContain("hero-actions");
const html = readFileSync(join(dir, out.htmlPath), "utf8");
expect(html).toMatch(/^<div id="hero-actions"/);
});
}); });
+87 -49
View File
@@ -15,6 +15,7 @@ import {
slugify, slugify,
type BindingSite, type BindingSite,
type FigmaClient, type FigmaClient,
type NodeToHtmlResult,
type RasterizeRequest, type RasterizeRequest,
} from "@hyperframes/core/figma"; } from "@hyperframes/core/figma";
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -27,7 +28,7 @@ function escapeAttr(value: string): string {
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
import { runAssetImport } from "./asset.js"; import { runAssetImport, type AssetImportResult } from "./asset.js";
import { downloadRender } from "./download.js"; import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js"; import { withFigmaErrors } from "./cliError.js";
@@ -35,6 +36,9 @@ export interface ComponentImportDeps {
projectDir: string; projectDir: string;
client: FigmaClient; client: FigmaClient;
download: (url: string) => Promise<Uint8Array>; download: (url: string) => Promise<Uint8Array>;
/** override the component name — figma variant frames are often all named
* "Platform=Desktop", which would slug-collide across imports */
name?: string;
} }
export interface ComponentImportResult { export interface ComponentImportResult {
@@ -55,9 +59,9 @@ export async function runComponentImport(
const tree = await deps.client.nodeTree(ref); const tree = await deps.client.nodeTree(ref);
const bindings = resolveBindings(tree, readBindings(deps.projectDir)); const bindings = resolveBindings(tree, readBindings(deps.projectDir));
const mapped = nodeToHtml(tree, bindings); const mapped = nodeToHtml(tree, bindings, { rootName: deps.name });
const name = slugify(tree.name); const name = slugify(deps.name ?? tree.name);
const componentDir = join(deps.projectDir, "compositions", "components", name); const componentDir = join(deps.projectDir, "compositions", "components", name);
if (existsSync(componentDir)) if (existsSync(componentDir))
console.warn( console.warn(
@@ -65,52 +69,12 @@ export async function runComponentImport(
); );
mkdirSync(componentDir, { recursive: true }); mkdirSync(componentDir, { recursive: true });
// Rasterize fallback: export each unmappable node via Phase 1 and point const { html, frozenAssets, failedRasterize } = await rasterizeFallback(
// the placeholder img at the frozen file (path relative to the component). mapped,
// The search key must match the EMITTED (html-escaped) node id, and ref.fileKey,
// replaceAll covers the same node appearing twice in the tree. componentDir,
let html = mapped.html; deps,
const frozenAssets: string[] = []; );
const failedRasterize: string[] = [];
for (const req of mapped.rasterize) {
// figma sometimes refuses to render a node (nested instances commonly
// fail as svg) — retry once as png, then skip THIS node and keep the
// import: one unrenderable node must not abort the whole component. The
// placeholder keeps its data-figma-rasterize marker (no src) so the gap
// is visible and hand-fixable.
let asset = null;
for (const format of ["svg", "png"] as const) {
try {
asset = await runAssetImport(
`${ref.fileKey}:${req.nodeId}`,
{ format, description: req.name },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
break;
} catch (err) {
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
}
}
if (asset === null) {
failedRasterize.push(req.nodeId);
console.warn(
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
);
continue;
}
frozenAssets.push(asset.record.path);
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
"\\",
"/",
);
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
);
}
const htmlFile = join(componentDir, `${name}.html`); const htmlFile = join(componentDir, `${name}.html`);
writeFileSync(htmlFile, html + "\n"); writeFileSync(htmlFile, html + "\n");
@@ -148,10 +112,83 @@ export async function runComponentImport(
}; };
} }
interface RasterizeOutcome {
html: string;
frozenAssets: string[];
failedRasterize: string[];
}
/**
* Rasterize fallback: export each unmappable node via Phase 1 and point the
* placeholder img at the frozen file (path relative to the component). figma
* sometimes refuses to render a node (nested instances commonly fail as svg)
* — retry once as png, then skip THAT node and keep the import: one
* unrenderable node must not abort the whole component. Skipped placeholders
* keep their data-figma-rasterize marker (no src) so the gap is visible.
*/
async function rasterizeFallback(
mapped: NodeToHtmlResult,
fileKey: string,
componentDir: string,
deps: ComponentImportDeps,
): Promise<RasterizeOutcome> {
let html = mapped.html;
const frozenAssets: string[] = [];
const failedRasterize: string[] = [];
for (const req of mapped.rasterize) {
const asset = await renderWithPngRetry(fileKey, req, deps);
if (asset === null) {
failedRasterize.push(req.nodeId);
console.warn(
`could not render node ${req.nodeId} ("${req.name}") as svg or png — leaving its placeholder without src`,
);
continue;
}
frozenAssets.push(asset.record.path);
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
"\\",
"/",
);
// The search key must match the EMITTED (html-escaped) node id, and
// replaceAll covers the same node appearing twice in the tree.
const emittedId = escapeAttr(req.nodeId);
html = html.replaceAll(
`data-figma-rasterize="${emittedId}" `,
`data-figma-rasterize="${emittedId}" src="${escapeAttr(srcRel)}" `,
);
}
return { html, frozenAssets, failedRasterize };
}
async function renderWithPngRetry(
fileKey: string,
req: RasterizeRequest,
deps: ComponentImportDeps,
): Promise<AssetImportResult | null> {
for (const format of ["svg", "png"] as const) {
try {
return await runAssetImport(
`${fileKey}:${req.nodeId}`,
{ format, description: req.name },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
} catch (err) {
if (!(err instanceof FigmaClientError) || err.code !== "RENDER_FAILED") throw err;
}
}
return null;
}
export default defineCommand({ export default defineCommand({
meta: { name: "component", description: "Import a figma frame as an editable HTML component" }, meta: { name: "component", description: "Import a figma frame as an editable HTML component" },
args: { args: {
ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true }, ref: { type: "positional", description: "figma URL or fileKey:nodeId", required: true },
name: {
type: "string",
description: "component name override (variant frames often share a name and would collide)",
},
dir: { type: "string", description: "project directory", default: "." }, dir: { type: "string", description: "project directory", default: "." },
}, },
async run({ args }) { async run({ args }) {
@@ -162,6 +199,7 @@ export default defineCommand({
projectDir: args.dir, projectDir: args.dir,
client, client,
download: downloadRender, download: downloadRender,
name: args.name,
}); });
console.log(`imported component "${result.name}" → ${result.htmlPath}`); console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0) { if (result.rasterized.length > 0) {
+85 -35
View File
@@ -1,3 +1,7 @@
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues } from "../runtime/getVariables";
import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs"; import { readFileSync, existsSync } from "fs";
import { join, resolve, relative, dirname, isAbsolute, sep } from "path"; import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js"; import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
@@ -392,43 +396,9 @@ function assignBundledRuntimeCompositionIds(
return identities; return identities;
} }
function parseHostVariableValues(host: Element): Record<string, unknown> {
const raw = host.getAttribute("data-variable-values");
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
}
export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
"data-composition-id",
"data-composition-file",
"data-start",
"data-duration",
"data-end",
"data-track-index",
"data-track",
"data-composition-src",
"data-hf-authored-duration",
"data-hf-authored-end",
];
export function prepareFlattenedInnerRoot(innerRoot: Element): Element { export function prepareFlattenedInnerRoot(innerRoot: Element): Element {
const prepared = innerRoot.cloneNode(true) as Element; const prepared = innerRoot.cloneNode(true) as Element;
const authoredRootId = prepared.getAttribute("id")?.trim(); markFlattenedInnerRoot(prepared);
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
prepared.removeAttribute(attrName);
}
if (authoredRootId) {
prepared.removeAttribute("id");
prepared.setAttribute("data-hf-authored-id", authoredRootId);
}
prepared.setAttribute("data-hf-inner-root", "true");
const w = prepared.getAttribute("data-width"); const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height"); const h = prepared.getAttribute("data-height");
const widthVal = w ? `${w}px` : "100%"; const widthVal = w ? `${w}px` : "100%";
@@ -890,6 +860,13 @@ export async function bundleToSingleHtml(
if (runtimeCompId && Object.keys(mergedVariables).length > 0) { if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables; compVariablesByComp[runtimeCompId] = mergedVariables;
} }
pushSubCompVariableStyles(
innerDoc,
innerRoot,
mergedVariables,
runtimeScope,
compStyleChunks,
);
if (innerRoot) { if (innerRoot) {
// Hoist styles into the collected style chunks // Hoist styles into the collected style chunks
@@ -973,6 +950,8 @@ export async function bundleToSingleHtml(
document.body.appendChild(compScript); document.body.appendChild(compScript);
} }
emitRootCompositionVariableStyles(document);
enforceCompositionPixelSizing(document); enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document); autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document); coalesceHeadStylesAndBodyScripts(document);
@@ -1014,3 +993,74 @@ export async function bundleToSingleHtml(
return document.toString(); return document.toString();
} }
/** One stylesheet rule defining primitive composition variables under `selector`. */
function compositionVariablesCssBlock(
variables: Record<string, unknown>,
selector: string,
): string | null {
const lines: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
lines.push(` ${cssVariableName(id)}: ${String(value)};`);
}
}
if (lines.length === 0) return null;
return `${selector} {\n${lines.join("\n")}\n}`;
}
/**
* Compile-time counterpart of the runtime's injectCompositionCssVariables:
* 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.
*/
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;
const style = document.createElement("style");
style.setAttribute("data-hf-composition-variables", "");
style.textContent = rules.join("\n\n");
document.head.appendChild(style);
}
/**
* Compile-time CSS custom properties for a sub-comp scope: declared defaults
* layered under per-instance host values, emitted as a stylesheet rule on the
* host selector. A stylesheet in <head> is in effect while the body parses,
* so eval-time reads (GSAP .from immediateRender, canvas tinting) see the
* right values — the runtime's DOMContentLoaded injection is too late for
* those on compiled pages.
*/
function pushSubCompVariableStyles(
innerDoc: Document,
innerRoot: Element | null,
mergedVariables: Record<string, unknown>,
runtimeScope: string,
compStyleChunks: string[],
): void {
if (!runtimeScope) return;
const declaredForCss = readDeclaredDefaults(innerDoc.documentElement);
const innerRootForVars = innerRoot ?? innerDoc.querySelector("[data-composition-variables]");
if (innerRootForVars) Object.assign(declaredForCss, readDeclaredDefaults(innerRootForVars));
const cssVars = compositionVariablesCssBlock(
{ ...declaredForCss, ...mergedVariables },
runtimeScope,
);
if (cssVars) compStyleChunks.push(cssVars);
}
+28 -21
View File
@@ -65,17 +65,8 @@ function boxOf(node: FigmaNodeDocument): Box | null {
return null; return null;
} }
export function slugify(name: string): string { export { slugify } from "../tokenSlug";
const slug = name import { cssVariableName as cssVarName, slugify } from "../tokenSlug";
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "node";
}
function cssVarName(compositionVariableId: string): string {
return `--${slugify(compositionVariableId)}`;
}
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text return text
@@ -217,6 +208,17 @@ function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolea
return styles; 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[] { function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
const styles: string[] = []; const styles: string[] = [];
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT // 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) { } else if (bg !== null) {
styles.push(`background: ${bg}`); styles.push(`background: ${bg}`);
} }
if (node.type === "ELLIPSE") { shapeCss(node, styles);
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)}`);
effectsCss(node, styles); effectsCss(node, styles);
return styles; return styles;
} }
@@ -264,7 +259,10 @@ function renderNodeHtml(
const style = escapeHtml( const style = escapeHtml(
[...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "), [...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)) { if (RASTERIZE_TYPES.has(node.type)) {
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug }); 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>`; 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( export function nodeToHtml(
root: FigmaNodeDocument, root: FigmaNodeDocument,
bindings: ResolveBindingsResult, bindings: ResolveBindingsResult,
opts: NodeToHtmlOptions = {},
): NodeToHtmlResult { ): NodeToHtmlResult {
const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 }; const origin = boxOf(root) ?? { x: 0, y: 0, width: 0, height: 0 };
const ctx: RenderContext = { origin, bindings, rasterize: [], usedSlugs: new Set() }; 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 }; return { html, rasterize: ctx.rasterize };
} }
+50 -67
View File
@@ -1,5 +1,12 @@
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping"; import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
import { readDeclaredDefaults } from "./getVariables"; import { markFlattenedInnerRoot } from "./flattenedRoot";
import {
applyCssVariables,
clearAppliedCssVariables,
parseHostVariableValues,
readDeclaredDefaults,
readRenderOverrides,
} from "./getVariables";
type LoadExternalCompositionsParams = { type LoadExternalCompositionsParams = {
injectedStyles: HTMLStyleElement[]; injectedStyles: HTMLStyleElement[];
@@ -172,30 +179,9 @@ function resetCompositionHost(host: Element) {
host.textContent = ""; host.textContent = "";
} }
const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
"data-composition-id",
"data-composition-file",
"data-start",
"data-duration",
"data-end",
"data-track-index",
"data-track",
"data-composition-src",
"data-hf-authored-duration",
"data-hf-authored-end",
];
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement { function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
const prepared = document.importNode(innerRoot, true) as HTMLElement; const prepared = document.importNode(innerRoot, true) as HTMLElement;
const authoredRootId = prepared.getAttribute("id")?.trim(); markFlattenedInnerRoot(prepared);
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
prepared.removeAttribute(attrName);
}
if (authoredRootId) {
prepared.removeAttribute("id");
prepared.setAttribute("data-hf-authored-id", authoredRootId);
}
prepared.setAttribute("data-hf-inner-root", "true");
const w = prepared.getAttribute("data-width"); const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height"); const h = prepared.getAttribute("data-height");
prepared.style.width = w ? `${w}px` : "100%"; prepared.style.width = w ? `${w}px` : "100%";
@@ -220,19 +206,6 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
} }
} }
function parseHostVariableValues(host: Element): Record<string, unknown> {
const raw = host.getAttribute("data-variable-values");
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
}
type HostCompositionIdentity = { type HostCompositionIdentity = {
authoredCompositionId: string | null; authoredCompositionId: string | null;
runtimeCompositionId: string | null; runtimeCompositionId: string | null;
@@ -423,10 +396,8 @@ async function mountCompositionContent(params: {
} }
} }
// Inject <head> styles from non-template sub-compositions first (they define const injectScopedStyles = (styleEls: Iterable<HTMLStyleElement>): void => {
// element styles like backgrounds and positioning that the composition needs). for (const style of styleEls) {
if (params.headStyles) {
for (const style of params.headStyles) {
const clonedStyle = style.cloneNode(true); const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue; if (!(clonedStyle instanceof HTMLStyleElement)) continue;
if (authoredScopeCompositionId) { if (authoredScopeCompositionId) {
@@ -440,23 +411,12 @@ async function mountCompositionContent(params: {
document.head.appendChild(clonedStyle); document.head.appendChild(clonedStyle);
params.injectedStyles.push(clonedStyle); params.injectedStyles.push(clonedStyle);
} }
} };
// Inject <head> styles from non-template sub-compositions first (they define
const styles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style")); // element styles like backgrounds and positioning that the composition needs),
for (const style of styles) { // then the content styles.
const clonedStyle = style.cloneNode(true); if (params.headStyles) injectScopedStyles(params.headStyles);
if (!(clonedStyle instanceof HTMLStyleElement)) continue; injectScopedStyles(Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style")));
if (authoredScopeCompositionId) {
clonedStyle.textContent = scopeCssToComposition(
clonedStyle.textContent || "",
authoredScopeCompositionId,
runtimeScopeSelector,
authoredRootId,
);
}
document.head.appendChild(clonedStyle);
params.injectedStyles.push(clonedStyle);
}
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps), // Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
// then content scripts. Head scripts must execute before content scripts. // then content scripts. Head scripts must execute before content scripts.
@@ -536,16 +496,7 @@ async function mountCompositionContent(params: {
// `window.__hfVariablesByComp[compId]`, so this table must be populated // `window.__hfVariablesByComp[compId]`, so this table must be populated
// before the wrapped IIFE evaluates. // before the wrapped IIFE evaluates.
if (runtimeScopeCompositionId) { if (runtimeScopeCompositionId) {
const merged = { stashInstanceVariables(params, contentNode, runtimeScopeCompositionId);
...(params.declaredVariableDefaults ?? {}),
...parseHostVariableValues(params.host),
};
if (Object.keys(merged).length > 0) {
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
} else if (window.__hfVariablesByComp) {
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
}
} }
for (const scriptPayload of scriptPayloads) { for (const scriptPayload of scriptPayloads) {
@@ -761,3 +712,35 @@ export async function loadExternalCompositions(
}), }),
); );
} }
/**
* Stash per-instance variables BEFORE running scripts (the scoped
* getVariables() reads window.__hfVariablesByComp[compId]) and mirror them
* as CSS custom properties on the host so imported var(--slug, literal)
* fills inside the sub-comp resolve per instance (cascade beats the document
* root). Inline templates carry declared defaults on the content root;
* external loads pass them explicitly. Render-time overrides (--variables)
* always win. Stale custom properties from a previous mount are cleared
* before (re)applying.
*/
function stashInstanceVariables(
params: { host: Element; declaredVariableDefaults?: Record<string, unknown> },
contentNode: Node,
runtimeScopeCompositionId: string,
): void {
const declaredDefaults =
params.declaredVariableDefaults ??
(contentNode instanceof Element ? readDeclaredDefaults(contentNode) : {});
const merged = {
...declaredDefaults,
...parseHostVariableValues(params.host),
};
clearAppliedCssVariables(params.host);
if (Object.keys(merged).length > 0) {
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
applyCssVariables(params.host, { ...merged, ...readRenderOverrides() });
} else if (window.__hfVariablesByComp) {
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
}
}
@@ -0,0 +1,32 @@
/**
* Shared between the build-time bundler (htmlBundler) and the runtime
* composition loader: when a sub-composition's inner root is flattened into
* its host, these timing/identity attributes must be stripped and the
* authored id preserved as data identical semantics in both worlds.
*/
export const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
"data-composition-id",
"data-composition-file",
"data-start",
"data-duration",
"data-end",
"data-track-index",
"data-track",
"data-composition-src",
"data-hf-authored-duration",
"data-hf-authored-end",
];
/** Strip timing attrs, demote the authored id, and mark the flattened root. */
export function markFlattenedInnerRoot(prepared: Element): void {
const authoredRootId = prepared.getAttribute("id")?.trim();
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
prepared.removeAttribute(attrName);
}
if (authoredRootId) {
prepared.removeAttribute("id");
prepared.setAttribute("data-hf-authored-id", authoredRootId);
}
prepared.setAttribute("data-hf-inner-root", "true");
}
@@ -203,3 +203,96 @@ describe("readDeclaredDefaults", () => {
expect(readDeclaredDefaults(b)).toEqual({}); expect(readDeclaredDefaults(b)).toEqual({});
}); });
}); });
describe("css variable injection (figma brand-token chain)", () => {
afterEach(() => {
document.documentElement.removeAttribute(VARIABLES_ATTR);
document.documentElement.removeAttribute("data-hf-css-vars");
document.documentElement.style.cssText = "";
document.body.innerHTML = "";
delete (window as Window & { __hfVariables?: unknown }).__hfVariables;
});
it("slug stays byte-compatible with the figma importer", async () => {
const { slugify } = await import("../figma/nodeToHtml");
const { cssVariableName } = await import("../tokenSlug");
for (const id of [
"figma:sds-color-background-brand-default",
"figma:Brand/Primary 500",
"figma:Acme/Semantic/Blue-500",
"!!!",
]) {
expect(cssVariableName(id)).toBe(`--${slugify(id)}`);
}
});
it("defines declared variables on the DECLARING element, not globally", async () => {
const { injectCompositionCssVariables } = await import("./getVariables");
document.body.innerHTML = `<div id="root" ${VARIABLES_ATTR}='[{"id":"figma:brand/primary","type":"color","label":"p","default":"#112233"}]'></div>`;
injectCompositionCssVariables(document);
const root = document.getElementById("root") as HTMLElement;
expect(root.style.getPropertyValue("--figma-brand-primary")).toBe("#112233");
expect(document.documentElement.style.getPropertyValue("--figma-brand-primary")).toBe("");
});
it("two compositions on one page keep their own same-id values", async () => {
const { injectCompositionCssVariables } = await import("./getVariables");
document.body.innerHTML =
`<div id="a" ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#aa0000"}]'></div>` +
`<div id="b" ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#0000bb"}]'></div>`;
injectCompositionCssVariables(document);
expect(
(document.getElementById("a") as HTMLElement).style.getPropertyValue("--figma-brand"),
).toBe("#aa0000");
expect(
(document.getElementById("b") as HTMLElement).style.getPropertyValue("--figma-brand"),
).toBe("#0000bb");
});
it("declared defaults do NOT clobber an authored inline definition (define-if-absent)", async () => {
const { injectCompositionCssVariables } = await import("./getVariables");
document.body.innerHTML = `<div id="root" style="--accent: #3b82f6" ${VARIABLES_ATTR}='[{"id":"accent","type":"color","label":"a","default":"#ff5722"}]'></div>`;
injectCompositionCssVariables(document);
expect(
(document.getElementById("root") as HTMLElement).style.getPropertyValue("--accent"),
).toBe("#3b82f6");
});
it("render-time overrides win over declared defaults AND authored values", async () => {
const { injectCompositionCssVariables } = await import("./getVariables");
document.body.innerHTML = `<div id="root" style="--figma-brand: #000000" ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#2c2c2c"}]'></div>`;
(window as Window & { __hfVariables?: Record<string, unknown> }).__hfVariables = {
"figma:brand": "#00ff99",
};
injectCompositionCssVariables(document);
expect(
(document.getElementById("root") as HTMLElement).style.getPropertyValue("--figma-brand"),
).toBe("#00ff99");
});
it("skips empty-string values (setProperty would remove, not define)", async () => {
const { applyCssVariables } = await import("./getVariables");
const el = document.createElement("div");
applyCssVariables(el, { blank: "", real: "#123456" });
expect(el.style.getPropertyValue("--blank")).toBe("");
expect(el.style.getPropertyValue("--real")).toBe("#123456");
expect(el.getAttribute("data-hf-css-vars")).toBe("--real");
});
it("clearAppliedCssVariables removes exactly what was applied", async () => {
const { applyCssVariables, clearAppliedCssVariables } = await import("./getVariables");
const el = document.createElement("div");
el.style.setProperty("--authored", "keep");
applyCssVariables(el, { "figma:brand": "#111111" });
clearAppliedCssVariables(el);
expect(el.style.getPropertyValue("--figma-brand")).toBe("");
expect(el.style.getPropertyValue("--authored")).toBe("keep");
expect(el.hasAttribute("data-hf-css-vars")).toBe(false);
});
it("getVariables() honors element-declared variables like the injection does", async () => {
const { getVariables } = await import("./getVariables");
document.body.innerHTML = `<div ${VARIABLES_ATTR}='[{"id":"figma:brand","type":"color","label":"b","default":"#445566"}]'></div>`;
expect(getVariables()["figma:brand"]).toBe("#445566");
});
});
+125
View File
@@ -24,7 +24,13 @@ export function getVariables<
>(): Partial<T> { >(): Partial<T> {
if (typeof document === "undefined") return {} as Partial<T>; if (typeof document === "undefined") return {} as Partial<T>;
// Same collection the CSS-variable injection uses: <html> first, then any
// composition element carrying the attribute (later declarers win), then
// render-time overrides.
const declaredDefaults = readDeclaredDefaults(document.documentElement); const declaredDefaults = readDeclaredDefaults(document.documentElement);
for (const el of Array.from(document.querySelectorAll("[data-composition-variables]"))) {
Object.assign(declaredDefaults, readDeclaredDefaults(el));
}
const overrides = readOverrides(); const overrides = readOverrides();
return { ...declaredDefaults, ...overrides } as Partial<T>; return { ...declaredDefaults, ...overrides } as Partial<T>;
@@ -59,6 +65,125 @@ export function readDeclaredDefaults(root: Element | null): Record<string, unkno
return out; return out;
} }
import { cssVariableName, detectSlugCollisions } from "../tokenSlug";
const APPLIED_VARS_ATTR = "data-hf-css-vars";
function hasInlineStyle(target: Element): target is Element & ElementCSSInlineStyle {
return "style" in target && typeof (target as HTMLElement).style?.setProperty === "function";
}
/**
* Define primitive-valued variables as CSS custom properties on `target`,
* recording the applied names so re-init/unmount can clear them. Empty
* strings are skipped CSSOM setProperty("", ) REMOVES the property, and a
* blank default has no CSS meaning anyway.
*/
export function applyCssVariables(target: Element, variables: Record<string, unknown>): void {
if (!hasInlineStyle(target)) return;
const applied: string[] = [];
for (const [id, value] of Object.entries(variables)) {
if ((typeof value === "string" && value !== "") || typeof value === "number") {
const name = cssVariableName(id);
target.style.setProperty(name, String(value));
applied.push(name);
}
}
if (applied.length > 0) target.setAttribute(APPLIED_VARS_ATTR, applied.join(" "));
}
/** Remove custom properties a previous applyCssVariables call defined. */
export function clearAppliedCssVariables(target: Element): void {
if (!hasInlineStyle(target)) return;
const applied = target.getAttribute(APPLIED_VARS_ATTR);
if (!applied) return;
for (const name of applied.split(" ")) {
if (name.startsWith("--")) target.style.removeProperty(name);
}
target.removeAttribute(APPLIED_VARS_ATTR);
}
/**
* Imported figma components reference brand tokens as `var(--slug, literal)`.
* Define each declaring element's composition variables as CSS custom
* properties ON THAT ELEMENT scoping by the cascade, so two compositions
* on one page can't clobber each other and a flattened sub-composition root
* correctly styles only its own subtree.
*
* Declared DEFAULTS are define-if-absent: a value the author already styled
* (stylesheet `:root` rule, compile-time emission, hand-written inline) wins
* over the declared default, preserving pre-existing conventions where a
* variable id coincides with an authored custom property. Render-time
* overrides (`--variables` `window.__hfVariables`) always win that's
* explicit user intent.
*/
export function injectCompositionCssVariables(doc: Document): void {
const declarers = new Set<Element>();
if (doc.documentElement?.hasAttribute("data-composition-variables")) {
declarers.add(doc.documentElement);
}
for (const el of Array.from(doc.querySelectorAll("[data-composition-variables]"))) {
declarers.add(el);
}
const overrides = readOverrides();
const allIds: string[] = [];
for (const el of declarers) {
allIds.push(...applyDeclaredForElement(el, overrides, doc.defaultView));
}
for (const group of detectSlugCollisions(allIds)) {
console.warn(
`composition variables ${group.join(", ")} collapse to the same CSS property ${cssVariableName(group[0] ?? "")} — rename one to avoid cross-talk`,
);
}
}
/** Apply one declarer's variables (define-if-absent for defaults, overrides
* always win). Returns the declared ids for collision reporting. */
function applyDeclaredForElement(
el: Element,
overrides: Record<string, unknown>,
view: Window | null,
): string[] {
if (!hasInlineStyle(el)) return [];
const declared = readDeclaredDefaults(el);
const toApply: Record<string, unknown> = {};
for (const [id, value] of Object.entries(declared)) {
if (id in overrides) continue; // overrides applied below, always win
const name = cssVariableName(id);
// define-if-absent: respect authored/compiled definitions
const existing =
el.style.getPropertyValue(name) ||
(view ? view.getComputedStyle(el).getPropertyValue(name) : "");
if (existing.trim() !== "") continue;
toApply[id] = value;
}
for (const [id, value] of Object.entries(overrides)) {
if (id in declared) toApply[id] = value;
}
applyCssVariables(el, toApply);
return Object.keys(declared);
}
/** Parse a host element's `data-variable-values` JSON attribute (per-instance
* sub-composition overrides). Shared by the runtime loader and the bundler. */
export function parseHostVariableValues(host: Element): Record<string, unknown> {
const raw = host.getAttribute("data-variable-values");
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
}
/** Render-time variable overrides (`hyperframes render --variables`). */
export function readRenderOverrides(): Record<string, unknown> {
return readOverrides();
}
function readOverrides(): Record<string, unknown> { function readOverrides(): Record<string, unknown> {
if (typeof window === "undefined") return {}; if (typeof window === "undefined") return {};
const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables; const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables;
+10
View File
@@ -1,6 +1,7 @@
// fallow-ignore-file code-duplication complexity // fallow-ignore-file code-duplication complexity
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge"; import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics"; import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
import { injectCompositionCssVariables } from "./getVariables";
import { createCssAdapter } from "./adapters/css"; import { createCssAdapter } from "./adapters/css";
import { createGsapAdapter } from "./adapters/gsap"; import { createGsapAdapter } from "./adapters/gsap";
import { createAnimeJsAdapter } from "./adapters/animejs"; import { createAnimeJsAdapter } from "./adapters/animejs";
@@ -141,6 +142,15 @@ export function initSandboxRuntimeModular(): void {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
} }
// figma brand-token chain: define declared composition variables as CSS
// custom properties so imported var(--slug, literal) fills resolve from the
// live variable instead of always falling back to the frozen literal.
try {
injectCompositionCssVariables(document);
} catch (err) {
swallow("runtime.init.cssVariables", err);
}
window.__timelines = window.__timelines || {}; window.__timelines = window.__timelines || {};
// Resolve the root composition element with the same priority the rest of // Resolve the root composition element with the same priority the rest of
+35
View File
@@ -0,0 +1,35 @@
/**
* THE slug for composition-variable ids CSS custom-property names. Shared
* by the figma importer (emits `var(--<slug>, literal)`) and the runtime
* (defines `--<slug>` from declared variables) one function so the two
* sides can never drift. The slug is lossy (case-folded, symbol runs
* collapse to "-"), so distinct ids CAN collide; callers surface
* detectSlugCollisions() as a warning rather than silently merging.
*/
export function slugify(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "node";
}
export function cssVariableName(id: string): string {
return `--${slugify(id)}`;
}
/** Groups of distinct ids that collapse to the same CSS variable name. */
export function detectSlugCollisions(ids: Iterable<string>): string[][] {
const bySlug = new Map<string, string[]>();
for (const id of ids) {
const slug = cssVariableName(id);
const group = bySlug.get(slug);
if (group) {
if (!group.includes(id)) group.push(id);
} else {
bySlug.set(slug, [id]);
}
}
return [...bySlug.values()].filter((g) => g.length > 1);
}
+13
View File
@@ -210,6 +210,11 @@ export async function lintProject(projectDir: string): Promise<ProjectLintResult
const html = readFileSync(filePath, "utf-8"); const html = readFileSync(filePath, "utf-8");
const compSrcPath = `compositions/${file}`; const compSrcPath = `compositions/${file}`;
allHtmlSources.push({ html, compSrcPath }); allHtmlSources.push({ html, compSrcPath });
// Mountable fragments (figma component imports, registry snippets) are
// not standalone compositions — composition-root rules don't apply.
// Anchored to the file's ROOT element so a real composition that merely
// inlines snippet markup (or mentions the token in text) is still linted.
if (isSnippetFragment(html)) continue;
const result = await lintHyperframeHtml(html, { const result = await lintHyperframeHtml(html, {
filePath, filePath,
isSubComposition: true, isSubComposition: true,
@@ -610,3 +615,11 @@ function lintMissingOrEmptySubComposition(
return findings; return findings;
} }
/** True when the file's first element carries data-hf-snippet i.e. the file
* IS a mountable fragment, not a composition that merely contains one. */
function isSnippetFragment(html: string): boolean {
const firstTag = html.match(/<[a-zA-Z][^>]*>/);
if (!firstTag) return false;
return /\bdata-hf-snippet\b/.test(firstTag[0]);
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { lintProject } from "./project.js";
let dirs: string[] = [];
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs = [];
});
const INDEX = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="2" data-width="640" data-height="360"></div>
<script>window.__timelines = { main: { paused: true } };</script>
</body></html>`;
async function lintWithFragment(name: string, html: string) {
const dir = mkdtempSync(join(tmpdir(), "hf-snippet-lint-"));
dirs.push(dir);
writeFileSync(join(dir, "index.html"), INDEX);
mkdirSync(join(dir, "compositions"), { recursive: true });
writeFileSync(join(dir, "compositions", name), html);
const result = await lintProject(dir);
return result.results.filter((r) => r.file.includes(name.replace(".html", "")));
}
describe("snippet fragment exemption", () => {
it("skips composition-root rules for files whose ROOT carries data-hf-snippet", async () => {
const findings = await lintWithFragment(
"frag.html",
'<div id="frag" data-hf-snippet="" data-figma-id="1:1" style="width: 10px"></div>\n',
);
expect(findings).toHaveLength(0);
});
it("still lints a composition that merely CONTAINS snippet markup", async () => {
const results = await lintWithFragment(
"scene.html",
'<div id="scene"><div data-hf-snippet="" data-figma-id="1:1"></div></div>\n',
);
expect(results).toHaveLength(1);
expect(results[0]?.result.findings.some((f) => f.code === "root_missing_composition_id")).toBe(
true,
);
});
});
+1 -1
View File
@@ -10,7 +10,7 @@
"files": 18 "files": 18
}, },
"figma": { "figma": {
"hash": "26676992c1aed316", "hash": "aabcd697d0823efb",
"files": 1 "files": 1
}, },
"general-video": { "general-video": {
+4
View File
@@ -64,6 +64,10 @@ Imports variables as composition brand-variable entries + `figma-tokens.json` si
**Import tokens before components** when both are wanted — that's what lets component colors link to brand variables instead of baking duplicates. **Import tokens before components** when both are wanted — that's what lets component colors link to brand variables instead of baking duplicates.
**Non-Enterprise variables path (field-tested):** REST variables are Enterprise-gated, but the Figma MCP `get_variable_defs` is not. When `tokens` reports `REQUIRES_ENTERPRISE` and the user has the MCP connector, you can build the index yourself: (1) `get_variable_defs` on the scene's parent node — ONE call, cache the raw JSON to `.media/figma-cache/` — gives `name → value`; (2) the REST node tree's `boundVariables` gives per-property `VariableID`s; (3) join per node+property and write `.media/figma-bindings.jsonl` rows (`{kind:"binding", figmaId, sourceFileKey, compositionVariableId: "figma:<name>", version}`) plus the composition-variable entries. Everything downstream (component `var()` resolution, refresh, runtime CSS variables) is the shipped machinery. Label it for the user: "tokens via the Figma connector — Enterprise plans get this from `hyperframes figma tokens` directly."
The runtime defines every declared composition variable as a CSS custom property (document root + sub-comp hosts), so imported `var(--slug, literal)` fills recolor when the variable default changes — updating one value in `data-composition-variables` re-brands every imported component without re-importing anything. `hyperframes render --variables '<json>'` overrides them at render time.
## Components (Phase 3 — CLI) ## Components (Phase 3 — CLI)
```bash ```bash