diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts
index cc47031ec..c44e246e9 100644
--- a/packages/cli/src/commands/figma/component.test.ts
+++ b/packages/cli/src/commands/figma/component.test.ts
@@ -153,4 +153,17 @@ describe("runComponentImport", () => {
}),
).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(/^
/g, ">")
.replace(/"/g, """);
}
-import { runAssetImport } from "./asset.js";
+import { runAssetImport, type AssetImportResult } from "./asset.js";
import { downloadRender } from "./download.js";
import { withFigmaErrors } from "./cliError.js";
@@ -35,6 +36,9 @@ export interface ComponentImportDeps {
projectDir: string;
client: FigmaClient;
download: (url: string) => Promise;
+ /** override the component name — figma variant frames are often all named
+ * "Platform=Desktop", which would slug-collide across imports */
+ name?: string;
}
export interface ComponentImportResult {
@@ -55,9 +59,9 @@ export async function runComponentImport(
const tree = await deps.client.nodeTree(ref);
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);
if (existsSync(componentDir))
console.warn(
@@ -65,52 +69,12 @@ export async function runComponentImport(
);
mkdirSync(componentDir, { recursive: true });
- // Rasterize fallback: export each unmappable node via Phase 1 and point
- // the placeholder img at the frozen file (path relative to the component).
- // The search key must match the EMITTED (html-escaped) node id, and
- // replaceAll covers the same node appearing twice in the tree.
- let html = mapped.html;
- 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 { html, frozenAssets, failedRasterize } = await rasterizeFallback(
+ mapped,
+ ref.fileKey,
+ componentDir,
+ deps,
+ );
const htmlFile = join(componentDir, `${name}.html`);
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 {
+ 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 {
+ 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({
meta: { name: "component", description: "Import a figma frame as an editable HTML component" },
args: {
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: "." },
},
async run({ args }) {
@@ -162,6 +199,7 @@ export default defineCommand({
projectDir: args.dir,
client,
download: downloadRender,
+ name: args.name,
});
console.log(`imported component "${result.name}" → ${result.htmlPath}`);
if (result.rasterized.length > 0) {
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 3458675e1..c82471986 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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 { join, resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
@@ -392,43 +396,9 @@ function assignBundledRuntimeCompositionIds(
return identities;
}
-function parseHostVariableValues(host: Element): Record {
- 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;
-}
-
-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 {
const prepared = innerRoot.cloneNode(true) as Element;
- 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");
+ markFlattenedInnerRoot(prepared);
const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height");
const widthVal = w ? `${w}px` : "100%";
@@ -890,6 +860,13 @@ export async function bundleToSingleHtml(
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables;
}
+ pushSubCompVariableStyles(
+ innerDoc,
+ innerRoot,
+ mergedVariables,
+ runtimeScope,
+ compStyleChunks,
+ );
if (innerRoot) {
// Hoist styles into the collected style chunks
@@ -973,6 +950,8 @@ export async function bundleToSingleHtml(
document.body.appendChild(compScript);
}
+ emitRootCompositionVariableStyles(document);
+
enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document);
@@ -1014,3 +993,74 @@ export async function bundleToSingleHtml(
return document.toString();
}
+
+/** One stylesheet rule defining primitive composition variables under `selector`. */
+function compositionVariablesCssBlock(
+ variables: Record,
+ 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 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,
+ 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);
+}
diff --git a/packages/core/src/figma/nodeToHtml.ts b/packages/core/src/figma/nodeToHtml.ts
index 39640525d..817d09586 100644
--- a/packages/core/src/figma/nodeToHtml.ts
+++ b/packages/core/src/figma/nodeToHtml.ts
@@ -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 `${renderChildren(node, ctx, depth)}
`;
}
+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 };
}
diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts
index f5dca4007..220cae29e 100644
--- a/packages/core/src/runtime/compositionLoader.ts
+++ b/packages/core/src/runtime/compositionLoader.ts
@@ -1,5 +1,12 @@
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
-import { readDeclaredDefaults } from "./getVariables";
+import { markFlattenedInnerRoot } from "./flattenedRoot";
+import {
+ applyCssVariables,
+ clearAppliedCssVariables,
+ parseHostVariableValues,
+ readDeclaredDefaults,
+ readRenderOverrides,
+} from "./getVariables";
type LoadExternalCompositionsParams = {
injectedStyles: HTMLStyleElement[];
@@ -172,30 +179,9 @@ function resetCompositionHost(host: Element) {
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 {
const prepared = document.importNode(innerRoot, true) as HTMLElement;
- 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");
+ markFlattenedInnerRoot(prepared);
const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height");
prepared.style.width = w ? `${w}px` : "100%";
@@ -220,19 +206,6 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
}
}
-function parseHostVariableValues(host: Element): Record {
- 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;
-}
-
type HostCompositionIdentity = {
authoredCompositionId: string | null;
runtimeCompositionId: string | null;
@@ -423,10 +396,8 @@ async function mountCompositionContent(params: {
}
}
- // Inject styles from non-template sub-compositions first (they define
- // element styles like backgrounds and positioning that the composition needs).
- if (params.headStyles) {
- for (const style of params.headStyles) {
+ const injectScopedStyles = (styleEls: Iterable): void => {
+ for (const style of styleEls) {
const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
if (authoredScopeCompositionId) {
@@ -440,23 +411,12 @@ async function mountCompositionContent(params: {
document.head.appendChild(clonedStyle);
params.injectedStyles.push(clonedStyle);
}
- }
-
- const styles = Array.from(contentNode.querySelectorAll("style"));
- for (const style of styles) {
- const clonedStyle = style.cloneNode(true);
- if (!(clonedStyle instanceof HTMLStyleElement)) continue;
- if (authoredScopeCompositionId) {
- clonedStyle.textContent = scopeCssToComposition(
- clonedStyle.textContent || "",
- authoredScopeCompositionId,
- runtimeScopeSelector,
- authoredRootId,
- );
- }
- document.head.appendChild(clonedStyle);
- params.injectedStyles.push(clonedStyle);
- }
+ };
+ // Inject styles from non-template sub-compositions first (they define
+ // element styles like backgrounds and positioning that the composition needs),
+ // then the content styles.
+ if (params.headStyles) injectScopedStyles(params.headStyles);
+ injectScopedStyles(Array.from(contentNode.querySelectorAll("style")));
// Collect head scripts first (e.g. GSAP CDN loaded in of non-template sub-comps),
// 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
// before the wrapped IIFE evaluates.
if (runtimeScopeCompositionId) {
- const merged = {
- ...(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];
- }
+ stashInstanceVariables(params, contentNode, runtimeScopeCompositionId);
}
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 },
+ 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];
+ }
+}
diff --git a/packages/core/src/runtime/flattenedRoot.ts b/packages/core/src/runtime/flattenedRoot.ts
new file mode 100644
index 000000000..c844e46c7
--- /dev/null
+++ b/packages/core/src/runtime/flattenedRoot.ts
@@ -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");
+}
diff --git a/packages/core/src/runtime/getVariables.test.ts b/packages/core/src/runtime/getVariables.test.ts
index 8d82b5c62..a9bfd730e 100644
--- a/packages/core/src/runtime/getVariables.test.ts
+++ b/packages/core/src/runtime/getVariables.test.ts
@@ -203,3 +203,96 @@ describe("readDeclaredDefaults", () => {
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 = ``;
+ 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 =
+ `` +
+ ``;
+ 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 = ``;
+ 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 = ``;
+ (window as Window & { __hfVariables?: Record }).__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 = ``;
+ expect(getVariables()["figma:brand"]).toBe("#445566");
+ });
+});
diff --git a/packages/core/src/runtime/getVariables.ts b/packages/core/src/runtime/getVariables.ts
index 6ab47fac6..367857052 100644
--- a/packages/core/src/runtime/getVariables.ts
+++ b/packages/core/src/runtime/getVariables.ts
@@ -24,7 +24,13 @@ export function getVariables<
>(): Partial {
if (typeof document === "undefined") return {} as Partial;
+ // Same collection the CSS-variable injection uses: first, then any
+ // composition element carrying the attribute (later declarers win), then
+ // render-time overrides.
const declaredDefaults = readDeclaredDefaults(document.documentElement);
+ for (const el of Array.from(document.querySelectorAll("[data-composition-variables]"))) {
+ Object.assign(declaredDefaults, readDeclaredDefaults(el));
+ }
const overrides = readOverrides();
return { ...declaredDefaults, ...overrides } as Partial;
@@ -59,6 +65,125 @@ export function readDeclaredDefaults(root: Element | null): Record): 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();
+ 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,
+ view: Window | null,
+): string[] {
+ if (!hasInlineStyle(el)) return [];
+ const declared = readDeclaredDefaults(el);
+ const toApply: Record = {};
+ 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 {
+ 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;
+}
+
+/** Render-time variable overrides (`hyperframes render --variables`). */
+export function readRenderOverrides(): Record {
+ return readOverrides();
+}
+
function readOverrides(): Record {
if (typeof window === "undefined") return {};
const raw = (window as Window & { __hfVariables?: unknown }).__hfVariables;
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index ae6b079b9..a55984df7 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -1,6 +1,7 @@
// fallow-ignore-file code-duplication complexity
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
+import { injectCompositionCssVariables } from "./getVariables";
import { createCssAdapter } from "./adapters/css";
import { createGsapAdapter } from "./adapters/gsap";
import { createAnimeJsAdapter } from "./adapters/animejs";
@@ -141,6 +142,15 @@ export function initSandboxRuntimeModular(): void {
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 || {};
// Resolve the root composition element with the same priority the rest of
diff --git a/packages/core/src/tokenSlug.ts b/packages/core/src/tokenSlug.ts
new file mode 100644
index 000000000..d641d6fed
--- /dev/null
+++ b/packages/core/src/tokenSlug.ts
@@ -0,0 +1,35 @@
+/**
+ * THE slug for composition-variable ids → CSS custom-property names. Shared
+ * by the figma importer (emits `var(--, literal)`) and the runtime
+ * (defines `--` 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[][] {
+ const bySlug = new Map();
+ 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);
+}
diff --git a/packages/lint/src/project.ts b/packages/lint/src/project.ts
index ef068c3e1..6b5c9967d 100644
--- a/packages/lint/src/project.ts
+++ b/packages/lint/src/project.ts
@@ -210,6 +210,11 @@ export async function lintProject(projectDir: string): Promise]*>/);
+ if (!firstTag) return false;
+ return /\bdata-hf-snippet\b/.test(firstTag[0]);
+}
diff --git a/packages/lint/src/snippetFragment.test.ts b/packages/lint/src/snippetFragment.test.ts
new file mode 100644
index 000000000..5997af8ae
--- /dev/null
+++ b/packages/lint/src/snippetFragment.test.ts
@@ -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 = `
+
+
+`;
+
+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",
+ '\n',
+ );
+ expect(findings).toHaveLength(0);
+ });
+
+ it("still lints a composition that merely CONTAINS snippet markup", async () => {
+ const results = await lintWithFragment(
+ "scene.html",
+ '\n',
+ );
+ expect(results).toHaveLength(1);
+ expect(results[0]?.result.findings.some((f) => f.code === "root_missing_composition_id")).toBe(
+ true,
+ );
+ });
+});
diff --git a/skills-manifest.json b/skills-manifest.json
index 3c2a3a426..38c042262 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -10,7 +10,7 @@
"files": 18
},
"figma": {
- "hash": "26676992c1aed316",
+ "hash": "aabcd697d0823efb",
"files": 1
},
"general-video": {
diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md
index dc1360a14..b1ab210eb 100644
--- a/skills/figma/SKILL.md
+++ b/skills/figma/SKILL.md
@@ -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.
+**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:", 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 ''` overrides them at render time.
+
## Components (Phase 3 — CLI)
```bash