From def276524bc786625fce17f130e5b350996f1f4f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 7 Jul 2026 23:19:45 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(core,cli,lint):=20close=20the=20figma?= =?UTF-8?q?=20brand-token=20loop=20=E2=80=94=20runtime=20CSS=20variables,?= =?UTF-8?q?=20--name,=20snippet=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../cli/src/commands/figma/component.test.ts | 13 ++ packages/cli/src/commands/figma/component.ts | 136 +++++++++++------- packages/core/src/compiler/htmlBundler.ts | 120 +++++++++++----- packages/core/src/figma/nodeToHtml.ts | 49 ++++--- .../core/src/runtime/compositionLoader.ts | 117 +++++++-------- packages/core/src/runtime/flattenedRoot.ts | 32 +++++ .../core/src/runtime/getVariables.test.ts | 93 ++++++++++++ packages/core/src/runtime/getVariables.ts | 125 ++++++++++++++++ packages/core/src/runtime/init.ts | 10 ++ packages/core/src/tokenSlug.ts | 35 +++++ packages/lint/src/project.ts | 13 ++ packages/lint/src/snippetFragment.test.ts | 47 ++++++ skills-manifest.json | 2 +- skills/figma/SKILL.md | 4 + 14 files changed, 623 insertions(+), 173 deletions(-) create mode 100644 packages/core/src/runtime/flattenedRoot.ts create mode 100644 packages/core/src/tokenSlug.ts create mode 100644 packages/lint/src/snippetFragment.test.ts diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts index cc47031ec6..c44e246e99 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 3458675e1f..c824719866 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 39640525df..817d095866 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 f5dca4007d..220cae29ec 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 0000000000..c844e46c77 --- /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 8d82b5c629..a9bfd730e2 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 6ab47fac61..3678570521 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 ae6b079b92..a55984df76 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 0000000000..d641d6fed4 --- /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 ef068c3e18..6b5c9967da 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 0000000000..5997af8ae6 --- /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 3c2a3a4260..38c0422628 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 dc1360a14f..b1ab210ebf 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 From e2c88ef689a11fb293934907d36c93584c57d083 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 8 Jul 2026 02:28:16 -0700 Subject: [PATCH 2/3] fix(core,producer): composition CSS variables reach the render path at eval time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing of the compile-time variable emission surfaced four gaps: - The producer render path never emitted the compile-time stylesheet (only the preview bundler did), so eval-time reads — GSAP .from immediateRender, top-level getComputedStyle — saw undefined vars in rendered output. The producer's inlineSubCompositions now calls the shared emitRootCompositionVariableStyles and passes the variable hooks. - --variables overrides weren't visible at eval time. They now thread from the orchestrator / distributed plan through compileStage into the emitted rules (window.__hfVariables still covers script reads). - Per-declarer rules anchored on data-composition-id, which two inlined instances of one sub-composition share — instance A's rule restyled instance B, and a rule directly on the declarer defeated the host's inherited data-variable-values. Rules now anchor on per-instance data-hf-var-scope markers and layer nearest-host values over declared defaults, mirroring the runtime loader. - Emission ignored authored CSS; a declared default now yields to a var already defined in an authored