From d0d1abaa10f4275e8b6a40ea97d672f66f36273c Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Wed, 19 Aug 2026 23:40:13 +0000 Subject: [PATCH 1/5] feat: export selection as a Design Bundle (JSON + assets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new export mode alongside the existing HTML/Tailwind/Flutter/ SwiftUI backends: serialize the resolved node tree for the current selection into a target-neutral design-bundle.json, plus a raster/vector assets folder, packaged as a zip. Unlike the other four, this is not a finished code target — it's an intermediate format meant to be consumed by downstream tooling. - packages/backend/src/designBundle/: builds the bundle from the resolved node tree (designBundleTree/Main), extracted text styles (designBundleTextStyles), exported raster/vector assets (designBundleAssets), and zips the result (designBundleZip). - packages/types/src/types.ts: DesignBundle* schema types. - apps/plugin/plugin-src/code.ts: handles the export-design-bundle message from the UI and returns the generated zip. - apps/plugin/ui-src/App.tsx, packages/plugin-ui/src/PluginUI.tsx: wires an "Export Design Bundle" button into the plugin UI's top toolbar (framework tabs, then this button, then About last), independent of whichever framework tab happens to be selected. - packages/backend/src/altNodes/jsonNodeConversion.ts: two supporting fixes surfaced while building the bundle serializer — inlined GROUP children now get layoutPositioning: "ABSOLUTE" so their original arrangement survives losing their GROUP parent, and a live-Plugin-API layoutPositioning read overrides the REST API v1 snapshot when the snapshot didn't carry it. --- apps/plugin/plugin-src/code.ts | 38 + apps/plugin/ui-src/App.tsx | 61 ++ .../src/altNodes/jsonNodeConversion.ts | 50 +- .../src/designBundle/designBundleAssets.ts | 96 +++ .../src/designBundle/designBundleMain.ts | 124 ++++ .../designBundle/designBundleTextStyles.ts | 99 +++ .../src/designBundle/designBundleTree.ts | 649 ++++++++++++++++++ .../src/designBundle/designBundleUtils.ts | 16 + .../src/designBundle/designBundleZip.ts | 31 + packages/backend/src/index.ts | 1 + packages/plugin-ui/src/PluginUI.tsx | 42 +- packages/types/src/types.ts | 346 ++++++++++ 12 files changed, 1547 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/designBundle/designBundleAssets.ts create mode 100644 packages/backend/src/designBundle/designBundleMain.ts create mode 100644 packages/backend/src/designBundle/designBundleTextStyles.ts create mode 100644 packages/backend/src/designBundle/designBundleTree.ts create mode 100644 packages/backend/src/designBundle/designBundleUtils.ts create mode 100644 packages/backend/src/designBundle/designBundleZip.ts diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index 47f5fdb6..eab6e36d 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -9,6 +9,7 @@ import { generateProjectZip, postSettingsChanged, replaceProjectImagePlaceholders, + buildDesignBundle, } from "backend"; import { nodesToJSON } from "backend/src/altNodes/jsonNodeConversion"; import { oldConvertNodesToAltNodes } from "backend/src/altNodes/oldAltConversion"; @@ -94,6 +95,7 @@ const initSettings = async () => { let isLoading = false; let isDownloadingProject = false; let rerunAfterDownload = false; +let isExportingDesignBundle = false; const safeRun = async (settings: PluginSettings) => { console.log( "[DEBUG] safeRun - Called with isLoading =", @@ -455,6 +457,42 @@ const standardMode = async () => { void safeRun(userPluginSettings); } } + } else if (msg.type === "export-design-bundle") { + if (isExportingDesignBundle) { + figma.ui.postMessage({ + type: "design-bundle-error", + error: "A design bundle export is already in progress.", + }); + return; + } + + const selection = [...figma.currentPage.selection]; + isExportingDesignBundle = true; + try { + const result = await buildDesignBundle(selection, userPluginSettings); + const zip = result.zip.buffer.slice( + result.zip.byteOffset, + result.zip.byteOffset + result.zip.byteLength, + ); + figma.ui.postMessage({ + type: "design-bundle-zip", + zip, + fileName: result.fileName, + designCount: result.designCount, + assetCount: result.assetCount, + warnings: result.warnings, + }); + } catch (error) { + console.error("Design bundle export failed:", error); + figma.ui.postMessage({ + type: "design-bundle-error", + error: `Failed to create design bundle: ${ + error instanceof Error ? error.message : "Unknown error occurred" + }`, + }); + } finally { + isExportingDesignBundle = false; + } } else if (msg.type === "pluginSettingWillChange") { const { key, value } = msg as SettingWillChangeMessage; console.log(`[DEBUG] Setting changed: ${key} = ${value}`); diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 96eb1464..bae66fbc 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -14,6 +14,8 @@ import { DownloadProjectFormat, ProjectDownloadErrorMessage, ProjectZipMessage, + DesignBundleZipMessage, + DesignBundleErrorMessage, } from "types"; import { postUISettingsChangingMessage } from "./messaging"; import copy from "copy-to-clipboard"; @@ -29,6 +31,9 @@ interface AppState { warnings: Warning[]; isDownloadingProject: boolean; projectDownloadError: string | null; + isExportingDesignBundle: boolean; + designBundleExportError: string | null; + designBundleWarnings: Warning[]; } const emptyPreview = { size: { width: 0, height: 0 }, content: "" }; @@ -56,6 +61,9 @@ export default function App() { warnings: [], isDownloadingProject: false, projectDownloadError: null, + isExportingDesignBundle: false, + designBundleExportError: null, + designBundleWarnings: [], }); const rootStyles = getComputedStyle(document.documentElement); @@ -157,6 +165,39 @@ export default function App() { break; } + case "design-bundle-zip": { + const bundleMessage = untypedMessage as DesignBundleZipMessage; + const blob = new Blob([bundleMessage.zip], { + type: "application/zip", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = bundleMessage.fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + setState((prevState) => ({ + ...prevState, + isExportingDesignBundle: false, + designBundleExportError: null, + designBundleWarnings: bundleMessage.warnings ?? [], + })); + break; + } + + case "design-bundle-error": { + const bundleError = untypedMessage as DesignBundleErrorMessage; + setState((prevState) => ({ + ...prevState, + isExportingDesignBundle: false, + designBundleExportError: bundleError.error, + designBundleWarnings: [], + })); + break; + } + default: break; } @@ -208,6 +249,22 @@ export default function App() { "*", ); }; + const handleExportDesignBundle = () => { + if (state.isExportingDesignBundle) { + return; + } + + setState((prevState) => ({ + ...prevState, + isExportingDesignBundle: true, + designBundleExportError: null, + designBundleWarnings: [], + })); + parent.postMessage( + { pluginMessage: { type: "export-design-bundle" } }, + "*", + ); + }; const darkMode = isDarkFigmaBackground(figmaColorBgValue); @@ -237,6 +294,10 @@ export default function App() { onDownloadProject={handleDownloadProject} isDownloadingProject={state.isDownloadingProject} projectDownloadError={state.projectDownloadError} + onExportDesignBundle={handleExportDesignBundle} + isExportingDesignBundle={state.isExportingDesignBundle} + designBundleExportError={state.designBundleExportError} + designBundleWarnings={state.designBundleWarnings} /> ); diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts index d9b23f32..df2dc883 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -341,13 +341,27 @@ const processNodePair = async ( parentCumulativeRotation + (jsonNode.rotation || 0), ); - // Push the processed group children directly + // Push the processed group children directly. A GROUP has no Auto + // Layout of its own, so whatever arrangement its children had (e.g. + // two buttons placed side by side) exists only via their raw x/y — + // once the GROUP node itself is discarded here, that arrangement + // has no other representation. Mark each resulting node + // `layoutPositioning: "ABSOLUTE"` so designBundleTree.ts's existing + // `isAbsoluteInAutoLayout` escape hatch (built for a real Figma + // per-child "position absolutely" override) also captures inlined + // former-GROUP children, instead of silently letting them fall into + // the new parent's normal Auto Layout flow. Their x/y were already + // computed above relative to `parentNode` (the group's own parent, + // not the discarded group), via the absoluteBoundingBox diff — so + // no coordinate rebasing is needed here, only the flag. if (processedChild !== null) { - if (Array.isArray(processedChild)) { - processedChildren.push(...processedChild); - } else { - processedChildren.push(processedChild); + const resultNodes = Array.isArray(processedChild) + ? processedChild + : [processedChild]; + for (const resultNode of resultNodes) { + (resultNode as any).layoutPositioning = "ABSOLUTE"; } + processedChildren.push(...resultNodes); } } } @@ -366,6 +380,32 @@ const processNodePair = async ( (jsonNode as any).parent = parentNode; } + // D58: `jsonNode` originates entirely from `node.exportAsync({ format: + // "JSON_REST_V1" })` (nodesToJSON, above) — a static snapshot in + // Figma's REST API v1 shape, not live Plugin API property access. + // Found via a real, reproducible case: six related-product Cards with + // Figma's per-child "Position: Absolute" toggle enabled (no GROUP + // involved — confirmed by Sean directly in Figma), inside a real + // HORIZONTAL Auto Layout "Card grid" parent. Every one of them rendered + // with zero positioning at all — not wrong coordinates, nothing — + // meaning `layout.position` was never captured in Stage 1 + // (`designBundleTree.ts`'s `isAbsoluteInAutoLayout` check reads + // `node.layoutPositioning === "ABSOLUTE"`, which depends entirely on + // this field surviving from that snapshot). `layoutPositioning` (the + // per-child Auto Layout "position absolutely" override) is a + // comparatively recent Figma feature — plausible the frozen REST API + // v1 export format simply never included it, even though it's + // declared in this project's own `api_types.ts` (a hand-written type, + // not a guarantee the export payload actually populates it). The live + // `figmaNode` parameter (the real Plugin API SceneNode, available at + // every level of this recursion) is authoritative here regardless of + // what the snapshot did or didn't carry — read it directly as an + // override whenever present, rather than trusting the snapshot alone + // for this one property. + if ("layoutPositioning" in figmaNode && (figmaNode as any).layoutPositioning) { + (jsonNode as any).layoutPositioning = (figmaNode as any).layoutPositioning; + } + // Ensure node has a unique name with simple numbering const cleanName = jsonNode.name.trim(); diff --git a/packages/backend/src/designBundle/designBundleAssets.ts b/packages/backend/src/designBundle/designBundleAssets.ts new file mode 100644 index 00000000..10b8dcc9 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleAssets.ts @@ -0,0 +1,96 @@ +import { DesignBundleAsset } from "types"; +import { addWarning } from "../common/commonConversionWarnings"; +import { encodeUtf8Text } from "./designBundleUtils"; + +export interface ExportedDesignBundleAsset { + fileName: string; + bytes: Uint8Array; +} + +/** + * Explicit Images-API asset export (D9). FigmaToCode's default codegen path + * leaves image `src` as placehold.co placeholders and never calls + * `exportAsync` for plain layout/text output — the Design Bundle needs real + * files regardless of which codegen path (if any) is otherwise in use, so + * this is a standalone step over the asset manifest `buildDesignNode` + * already collected, not a reuse of any HTML/Tailwind/etc. image handling. + * + * Raster (IMAGE) nodes export as PNG at 2x, per + * docs/03-design-bundle-schema-draft.md's asset-handling section. Vector + * (VECTOR/STAR/POLYGON/BOOLEAN_OPERATION/LINE) nodes export as SVG so + * Stage 2 can inline them directly instead of rasterizing. + */ +export const exportDesignBundleAssets = async ( + assets: DesignBundleAsset[], +): Promise => { + const exported: ExportedDesignBundleAsset[] = []; + + for (const asset of assets) { + // D51: a background-image asset (DesignNode.backgroundAssetRef, not + // assetRef) carries `imageHash` instead — resolved via + // `figma.getImageByHash`, not `node.exportAsync()`. The containing + // node also has real child content painted on top of this fill (the + // whole reason it's a background-image asset rather than a normal + // leaf IMAGE asset — see designBundleTree.ts's D51 comment), so + // exporting *that node* would flatten the children into the raster + // too. `getImageByHash` resolves the fill's own raw bytes directly, + // independent of anything else the node renders. Figma's REST API v1 + // calls this same value `imageRef`; the Plugin API's `getImageByHash` + // accepts it under the name `hash` — same underlying image reference. + if (asset.imageHash) { + try { + const image = figma.getImageByHash(asset.imageHash); + if (!image) { + addWarning( + `Could not export background-image asset (${asset.fileName}) — image hash ${asset.imageHash} not found.`, + ); + continue; + } + const bytes = await image.getBytesAsync(); + exported.push({ fileName: asset.fileName, bytes }); + } catch (error) { + addWarning( + `Failed exporting background-image asset ${asset.fileName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + continue; + } + + const figmaNode = (await figma.getNodeByIdAsync( + asset.figmaNodeId, + )) as (SceneNode & ExportMixin) | null; + + if (!figmaNode || !("exportAsync" in figmaNode)) { + addWarning( + `Could not export asset for node ${asset.figmaNodeId} (${asset.fileName}) — node missing or not exportable.`, + ); + continue; + } + + try { + if (asset.kind === "vector") { + const svg = await figmaNode.exportAsync({ format: "SVG_STRING" }); + exported.push({ + fileName: asset.fileName, + bytes: encodeUtf8Text(svg), + }); + } else { + const bytes = await figmaNode.exportAsync({ + format: "PNG", + constraint: { type: "SCALE", value: 2 }, + }); + exported.push({ fileName: asset.fileName, bytes }); + } + } catch (error) { + addWarning( + `Failed exporting asset ${asset.fileName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + return exported; +}; diff --git a/packages/backend/src/designBundle/designBundleMain.ts b/packages/backend/src/designBundle/designBundleMain.ts new file mode 100644 index 00000000..5f807513 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleMain.ts @@ -0,0 +1,124 @@ +import { DesignBundle, DesignBundleAsset, DesignBundleStyles, PluginSettings } from "types"; +import { nodesToJSON } from "../altNodes/jsonNodeConversion"; +import { addWarning, clearWarnings, warnings } from "../common/commonConversionWarnings"; +import { buildDesignNode, resetDesignBundleTreeState } from "./designBundleTree"; +import { collectTextStyleIds, resolveTextStyles } from "./designBundleTextStyles"; +import { exportDesignBundleAssets } from "./designBundleAssets"; +import { generateDesignBundleZip } from "./designBundleZip"; + +export const DESIGN_BUNDLE_SOURCE_TOOL = "FigmaToCode-fork/design-bundle@0.1.0"; + +const toKebab = (value: string) => + (value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + +export interface DesignBundleExportResult { + zip: Uint8Array; + fileName: string; + designCount: number; + assetCount: number; + warnings: string[]; +} + +/** + * Stage 1 (Phase 2) entry point: turns the current Figma selection into a + * Design Bundle zip (design-bundle.json + /assets), per + * docs/03-design-bundle-schema-draft.md. + * + * Reuses `nodesToJSON` for the actual node-tree normalization (Auto Layout, + * variables, styled text segments, empty-frame flattening, GROUP inlining — + * all already handled there and already multi-selection-safe, see D10 note + * in the decisions log) rather than re-deriving any of that. This module's + * only job is mapping that AltNode-shaped output onto the bundle's + * `DesignNode` shape and wiring up the explicit asset export step D9 calls + * for. + */ +export const buildDesignBundle = async ( + selection: readonly SceneNode[], + settings: PluginSettings, +): Promise => { + if (selection.length === 0) { + throw new Error("Please select at least one layer to export."); + } + + clearWarnings(); + resetDesignBundleTreeState(); + + const convertedSelection = await nodesToJSON(selection, settings); + + if (convertedSelection.length !== selection.length) { + // nodesToJSON can return more entries than the input selection when a + // top-level GROUP gets inlined into multiple sibling nodes (see + // jsonNodeConversion.ts). D10 assumed a clean 1:1 mapping between + // selected layers and designs[] entries; a top-level GROUP breaks that + // assumption. Logged as a real Phase 2 finding (see decisions log D18) + // rather than silently mismatching names below. + console.warn( + "[design-bundle] convertedSelection count does not match selection count " + + "(likely a top-level GROUP was inlined) — falling back to converted node names.", + ); + } + + const assets: DesignBundleAsset[] = []; + const styles: DesignBundleStyles = { colors: {}, textStyles: {} }; + + const designs = convertedSelection.map((node: any, index: number) => { + const originalNode = selection[index]; + const root = buildDesignNode(node, assets, styles, undefined); + return { + figmaNodeId: root.id, + // Raw, as-authored Figma layer name only — no slug/title (D15). + // Falls back to the converted node's own name if the index-aligned + // original selection entry is unavailable (see mismatch note above). + layerName: originalNode?.name ?? node.name ?? root.uniqueName, + root, + }; + }); + + // Named-text-style resolution (D23): a separate async pass after tree- + // building, since Figma's style lookup (getStyleByIdAsync) is async and + // buildDesignNode itself is kept synchronous (see designBundleTextStyles.ts). + const textStyleIds = new Set(); + for (const design of designs) { + collectTextStyleIds(design.root, textStyleIds); + } + const textStyleWarnings = await resolveTextStyles(textStyleIds, styles.textStyles); + // Routed through addWarning (not a bare console.warn) so these actually + // reach the plugin UI's WarningsPanel — see D19, where warnings silently + // not reaching the UI was itself a real bug, not just a missing feature. + for (const w of textStyleWarnings) addWarning(w); + + const exportedAssets = await exportDesignBundleAssets(assets); + + const bundle: DesignBundle = { + schemaVersion: 1, + meta: { + figmaFileKey: figma.fileKey ?? "", + figmaFileName: figma.root.name, + figmaPageName: figma.currentPage.name, + exportedAt: new Date().toISOString(), + exportedBy: DESIGN_BUNDLE_SOURCE_TOOL, + sourceTool: "FigmaToCode-fork", + }, + designs, + assets, + styles, + }; + + const zip = generateDesignBundleZip(bundle, exportedAssets); + const rootLabel = + designs.length === 1 + ? toKebab(designs[0].layerName) + : toKebab(figma.currentPage.name) || "design-bundle"; + const fileName = `${rootLabel || "design-bundle"}-design-bundle.zip`; + + return { + zip, + fileName, + designCount: designs.length, + assetCount: assets.length, + warnings: [...warnings], + }; +}; diff --git a/packages/backend/src/designBundle/designBundleTextStyles.ts b/packages/backend/src/designBundle/designBundleTextStyles.ts new file mode 100644 index 00000000..28cc24d5 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleTextStyles.ts @@ -0,0 +1,99 @@ +import { DesignBundleTextStyle, DesignNode } from "types"; +import { commonLineHeight } from "../common/commonTextHeightSpacing"; + +/** + * Best-effort numeric font-weight string from a Figma FontName's `style` + * (e.g. "Regular", "Semi Bold", "Black Italic"). Figma's TextStyle object + * has no numeric weight field directly — only the human-readable style + * name — so this is a keyword match, most-specific pattern first (checking + * "semi bold" before the plainer "bold" substring, etc.). Falls back to + * "400" for anything unrecognized rather than guessing further. + */ +export const fontStyleToWeight = (styleName: string | undefined): string => { + const style = (styleName ?? "").toLowerCase(); + const patterns: Array<[RegExp, string]> = [ + [/thin/, "100"], + [/extra ?light|ultra ?light/, "200"], + [/\blight\b/, "300"], + [/medium/, "500"], + [/extra ?bold|ultra ?bold/, "800"], + [/semi ?bold|demi ?bold/, "600"], + [/\bbold\b/, "700"], + [/black|heavy/, "900"], + [/regular|normal/, "400"], + ]; + for (const [pattern, weight] of patterns) { + if (pattern.test(style)) return weight; + } + return "400"; +}; + +/** Recursively collects every distinct textStyleId referenced by a design's TEXT nodes. */ +export const collectTextStyleIds = (node: DesignNode, into: Set = new Set()): Set => { + for (const segment of node.text?.segments ?? []) { + if (segment.textStyleId) into.add(segment.textStyleId); + } + for (const child of node.children) { + collectTextStyleIds(child, into); + } + return into; +}; + +/** + * Resolves a set of textStyleIds against Figma's style registry + * (`getStyleByIdAsync`) into the bundle's `styles.textStyles` dictionary + * (D23). Done as a separate pass after tree-building rather than inline in + * `buildDesignNode`, since `buildDesignNode` is synchronous (matches the + * existing colors/variables handling in `designBundleTree.ts`, which never + * needs an async call because bound-variable data is already present + * synchronously on the paint object) and style resolution requires an + * async Figma API call. Failures for an individual id are logged and + * skipped rather than aborting the whole export — a missing/deleted style + * shouldn't block the bundle. + */ +export const resolveTextStyles = async ( + textStyleIds: ReadonlySet, + target: Record, +): Promise => { + const warnings: string[] = []; + + await Promise.all( + Array.from(textStyleIds).map(async (id) => { + if (target[id]) return; + try { + const style = await figma.getStyleByIdAsync(id); + if (!style || style.type !== "TEXT") { + warnings.push(`[design-bundle] textStyleId "${id}" did not resolve to a text style — skipped.`); + return; + } + const textStyle = style as TextStyle; + const fontSize = textStyle.fontSize ?? 0; + // Same unit as DesignBundleTextSegment.lineHeight (a px-per-fontSize + // ratio, not raw px/percent) — computed the same way mapTextSegments + // does in designBundleTree.ts, via the shared commonLineHeight + // helper, so both are directly comparable. + let lineHeightRatio = 0; + try { + const lineHeightPx = textStyle.lineHeight ? commonLineHeight(textStyle.lineHeight, fontSize) : 0; + lineHeightRatio = fontSize > 0 ? (lineHeightPx || 0) / fontSize : 0; + } catch { + lineHeightRatio = 0; + } + + target[id] = { + name: textStyle.name, + fontFamily: textStyle.fontName?.family ?? "", + fontSize, + fontWeight: fontStyleToWeight(textStyle.fontName?.style), + lineHeight: lineHeightRatio, + }; + } catch (error) { + warnings.push( + `[design-bundle] Failed to resolve textStyleId "${id}": ${(error as Error).message}`, + ); + } + }), + ); + + return warnings; +}; diff --git a/packages/backend/src/designBundle/designBundleTree.ts b/packages/backend/src/designBundle/designBundleTree.ts new file mode 100644 index 00000000..4d0fd8c7 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleTree.ts @@ -0,0 +1,649 @@ +import { + DesignBundleAsset, + DesignBundleBlendMode, + DesignBundleColorStyle, + DesignBundleEffect, + DesignBundleFill, + DesignBundleGradient, + DesignBundleNodeStyle, + DesignBundleStyles, + DesignBundleTextSegment, + DesignNode, + DesignNodeType, +} from "types"; +import { commonLetterSpacing, commonLineHeight } from "../common/commonTextHeightSpacing"; + +// The tree produced by `nodesToJSON` (packages/backend/src/altNodes/jsonNodeConversion.ts) +// is a standard Figma REST API v1 `Node` (packages/backend/src/api_types.ts) plus the +// AltNode extras documented in 03-design-bundle-schema-draft.md (`x/y/width/height`, +// `uniqueName`, `cumulativeRotation`, `canBeFlattened`, `styledTextSegments`). There is no +// single exported type for that combination, so we work against a loosely-typed shape here +// rather than fighting the type system — consistent with how the rest of the backend +// (code.ts, jsonNodeConversion.ts) already treats `convertedSelection` as `any`. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ConvertedNode = any; + +const VECTOR_LIKE_TYPES = new Set([ + "VECTOR", + "STAR", + "POLYGON", + "BOOLEAN_OPERATION", + "LINE", +]); + +let assetCounter = 0; +let nameCounters: Map = new Map(); +// D63: primary asset-dedup mechanism — keyed on the node's identity *within +// its master Component definition*, not on the specific Instance's own node +// id. See assetIdentityKeyFor's doc comment below for the ID-shape this +// relies on. Session-scoped, same lifetime/reset semantics as +// assetCounter/nameCounters above. +let assetIdentityMap: Map = new Map(); + +export const resetDesignBundleTreeState = () => { + assetCounter = 0; + nameCounters = new Map(); + assetIdentityMap = new Map(); +}; + +// D63: Figma's REST API v1 (what nodesToJSON's whole tree is built from — +// see the ConvertedNode comment above) gives every node *inside* an +// Instance an id of the shape `I{instanceId};{masterChildId}` — confirmed +// directly against real exported bundles (e.g. `I2011:161;1:1468`). The +// part after the first semicolon is that node's own id *inside the master +// Component definition*, and is identical across every Instance of that +// component regardless of which design placed it — Figma's node-id space is +// unique file-wide, so this substring alone (no separate componentId lookup +// needed) already uniquely identifies "the same original node." A node +// that's directly part of a design's own tree (not inside any Instance) has +// a plain id with no semicolon and never matches — always exported fresh, +// unchanged from pre-D63 behavior. +// +// Deliberately identity-based, not content-based: Stage 2 has a separate, +// secondary content-hash pass (`loadBundle.ts`) for anything this doesn't +// explain. This only recognizes "the same node position inside the same +// component," and — per Sean's explicit call — assumes no per-instance +// content overrides on shared header/footer content. A real override would +// currently dedupe silently wrong; revisit if that assumption ever proves +// false in practice. +const INSTANCE_DESCENDANT_ID = /^I[^;]+;(.+)$/; +const assetIdentityKeyFor = (nodeId: string): string | undefined => + INSTANCE_DESCENDANT_ID.exec(nodeId)?.[1]; + +const toSlug = (value: string) => + (value || "layer") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "layer"; + +const nextAssetFileName = (uniqueName: string, ext: string): string => { + assetCounter += 1; + const slug = toSlug(uniqueName); + const count = (nameCounters.get(slug) ?? 0) + 1; + nameCounters.set(slug, count); + const suffix = String(count).padStart(2, "0"); + return `assets/${slug}-${suffix}.${ext}`; +}; + +const rgbToHex = (color: { r: number; g: number; b: number }): string => { + const toHex = (channel: number) => + Math.round(Math.max(0, Math.min(1, channel)) * 255) + .toString(16) + .padStart(2, "0"); + return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`.toUpperCase(); +}; + +const rgbaToHex8 = (color: { r: number; g: number; b: number; a?: number }): string => { + const alpha = color.a ?? 1; + const toHex = (channel: number) => + Math.round(Math.max(0, Math.min(1, channel)) * 255) + .toString(16) + .padStart(2, "0"); + return `${rgbToHex(color)}${toHex(alpha)}`; +}; + +const findImageFill = (node: ConvertedNode): any | undefined => { + const fills = node.fills; + if (!Array.isArray(fills)) return undefined; + return fills.find((fill: any) => fill?.type === "IMAGE" && fill.visible !== false); +}; + +const hasImageFill = (node: ConvertedNode): boolean => findImageFill(node) !== undefined; + +const hasRealChildren = (node: ConvertedNode): boolean => + Array.isArray(node.children) && node.children.length > 0; + +const classifyNodeType = (node: ConvertedNode): DesignNodeType => { + if (node.type === "TEXT") return "TEXT"; + if (VECTOR_LIKE_TYPES.has(node.type)) return "VECTOR"; + // Only collapse an image-filled node to a flattened IMAGE leaf when it has + // no real children. Originally this collapsed *any* image-filled node + // regardless of children — validated against a synthetic "hero banner with + // an overlaid heading" fixture during Phase 2 and found to silently drop + // the heading, a real content-loss bug (see decisions log D18). A frame + // with both an image fill and child content now stays a FRAME so its + // children survive; the background image itself is still not + // representable in style.fills (schema only models solid/gradient fills) + // — that narrower gap is left as a Phase 5 long-tail item. + if (hasImageFill(node) && !hasRealChildren(node)) return "IMAGE"; + if (node.type === "RECTANGLE" || node.type === "ELLIPSE") return "RECTANGLE"; + return "FRAME"; +}; + +const resolveCornerRadius = (node: ConvertedNode): number => { + if (typeof node.cornerRadius === "number") return node.cornerRadius; + if (Array.isArray(node.rectangleCornerRadii)) { + const [topLeft, topRight, bottomRight, bottomLeft] = node.rectangleCornerRadii; + if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + return topLeft ?? 0; + } + // Schema v1 only carries a single cornerRadius number (see D18) — non-uniform + // corners are approximated by their largest corner rather than dropped. + return Math.max(topLeft ?? 0, topRight ?? 0, bottomRight ?? 0, bottomLeft ?? 0); + } + if (typeof node.topLeftRadius === "number") { + return Math.max( + node.topLeftRadius ?? 0, + node.topRightRadius ?? 0, + node.bottomRightRadius ?? 0, + node.bottomLeftRadius ?? 0, + ); + } + return 0; +}; + +// D46: Figma's `paint.color.a` (alpha baked into the fill's own color) and +// `paint.opacity` (the fill's separate "opacity" slider) are two distinct +// fields that blend together — Figma's own doc comment on Paint.opacity: +// "colors within the paint can also have opacity values which would blend +// with this" — so they're combined into one effective alpha here, at the +// point of capture, rather than carried through as two separate numbers +// with no real Stage-2 use for keeping them apart. `undefined` (not just +// `1`) is treated as "fully opaque" for both, matching Figma's own default. +const fillOpacity = (paint: any): number | undefined => { + const colorAlpha = typeof paint.color?.a === "number" ? paint.color.a : 1; + const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; + const combined = colorAlpha * paintOpacity; + return combined < 1 ? combined : undefined; +}; + +// D69 (Phase 5 gradients): the three gradient kinds CSS can render +// natively. GRADIENT_DIAMOND is deliberately absent — no CSS equivalent, +// Sean's explicit call to leave it collapsed to a flat fallback color +// rather than approximate it. +const GRADIENT_KIND_BY_PAINT_TYPE: Record = { + GRADIENT_LINEAR: "LINEAR", + GRADIENT_RADIAL: "RADIAL", + GRADIENT_ANGULAR: "ANGULAR", +}; + +// D69: structured gradient data (stops + Figma's own raw handle geometry, +// unconverted — see DesignBundleGradient's doc comment in types.ts for why +// the trig stays out of Stage 1). Returns undefined for GRADIENT_DIAMOND, +// any unrecognized gradient kind, or if Figma's own gradientStops/ +// gradientHandlePositions are missing on this paint — mapFill's caller +// still gets a flat `hex` fallback in every case via the first stop. +const mapGradient = (paint: any): DesignBundleGradient | undefined => { + const kind = GRADIENT_KIND_BY_PAINT_TYPE[paint.type as string]; + if (!kind) return undefined; + const stops = Array.isArray(paint.gradientStops) ? paint.gradientStops : []; + const handles = Array.isArray(paint.gradientHandlePositions) ? paint.gradientHandlePositions : []; + if (stops.length === 0 || handles.length === 0) return undefined; + const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; + return { + kind, + stops: stops.map((stop: any) => ({ + hex: rgbaToHex8({ ...(stop.color ?? {}), a: (stop.color?.a ?? 1) * paintOpacity }), + position: typeof stop.position === "number" ? stop.position : 0, + })), + handles: handles.map((handle: any) => ({ x: handle?.x ?? 0, y: handle?.y ?? 0 })), + }; +}; + +const mapFill = ( + paint: any, + styles: DesignBundleStyles, +): DesignBundleFill | null => { + if (!paint || paint.visible === false) return null; + if (paint.type === "IMAGE") return null; // handled via node.assetRef instead + + const variableId: string | undefined = paint.boundVariables?.color?.id; + if (variableId && !styles.colors[variableId]) { + const entry: DesignBundleColorStyle = { + name: paint.boundVariables?.color?.name ?? variableId, + hex: paint.color ? rgbToHex(paint.color) : "#000000", + }; + styles.colors[variableId] = entry; + } + + if (paint.type === "SOLID") { + return { + type: "SOLID", + hex: paint.color ? rgbToHex(paint.color) : undefined, + variableRef: variableId, + opacity: fillOpacity(paint), + }; + } + + if (typeof paint.type === "string" && paint.type.startsWith("GRADIENT")) { + // D69: always carry a flat-color fallback — the first stop's own + // color, with its alpha already combined with the paint's overall + // opacity, as an 8-digit hex so no separate `opacity` field is + // needed on the fallback either. Covers GRADIENT_DIAMOND and any + // future gradient kind Stage 2 can't render as real CSS. Previously + // this branch produced no `hex` at all, so any gradient-filled node + // rendered with *no* background whatsoever — this fixes that gap too, + // not just the LINEAR/RADIAL/ANGULAR cases. + const firstStopColor = Array.isArray(paint.gradientStops) ? paint.gradientStops[0]?.color : undefined; + const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; + const fallbackHex = firstStopColor + ? rgbaToHex8({ ...firstStopColor, a: (firstStopColor.a ?? 1) * paintOpacity }) + : undefined; + return { + type: "GRADIENT", + hex: fallbackHex, + variableRef: variableId, + gradient: mapGradient(paint), + }; + } + + return { type: "OTHER", variableRef: variableId, opacity: fillOpacity(paint) }; +}; + +const mapStrokes = (node: ConvertedNode) => { + const strokes = Array.isArray(node.strokes) ? node.strokes : []; + const weight = typeof node.strokeWeight === "number" ? node.strokeWeight : 1; + return strokes + .filter((stroke: any) => stroke?.visible !== false && stroke?.color) + .map((stroke: any) => ({ hex: rgbToHex(stroke.color), weight })); +}; + +const mapEffects = (node: ConvertedNode): DesignBundleEffect[] => { + const effects = Array.isArray(node.effects) ? node.effects : []; + return effects + .filter((effect: any) => effect?.visible !== false) + .map((effect: any) => { + if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") { + return { + type: effect.type, + x: effect.offset?.x ?? 0, + y: effect.offset?.y ?? 0, + blur: effect.radius ?? 0, + hex: effect.color ? rgbaToHex8(effect.color) : undefined, + // D70: only meaningful for shadows — Figma's own `spread`, + // already present on the raw effect object, just wasn't carried + // through before (Stage 2 didn't consume `style.effects` at + // all pre-D70, so there was nothing to wire it to yet). + spread: typeof effect.spread === "number" ? effect.spread : undefined, + }; + } + return { type: effect.type, blur: effect.radius ?? 0 }; + }); +}; + +// D46: the node's own layer opacity (`HasBlendModeAndOpacityTrait.opacity` +// in the REST API v1 shape — every node type carries this), distinct from +// any individual fill's opacity above (see DesignBundleNodeStyle.opacity's +// doc comment in types.ts for why these aren't collapsed together). +// `undefined`/missing is Figma's own default for "fully opaque." +const nodeOpacity = (node: ConvertedNode): number | undefined => { + const value = typeof node.opacity === "number" ? node.opacity : 1; + return value < 1 ? value : undefined; +}; + +// D72: Figma's 18 `BlendMode` values -> the 13 CSS `mix-blend-mode` has a +// native keyword for. PASS_THROUGH/NORMAL map to `undefined` (no +// blending, same as this schema's other sparse-field opacity/gradient +// conventions) rather than being listed here with no value — they're +// absent from this table entirely, so the fallthrough `undefined` return +// below covers them along with LINEAR_BURN/LINEAR_DODGE (no CSS +// equivalent) and any future/unrecognized blend mode. +const CSS_BLEND_MODE_BY_FIGMA_BLEND_MODE: Record = { + MULTIPLY: "multiply", + SCREEN: "screen", + OVERLAY: "overlay", + DARKEN: "darken", + LIGHTEN: "lighten", + COLOR_DODGE: "color-dodge", + COLOR_BURN: "color-burn", + HARD_LIGHT: "hard-light", + SOFT_LIGHT: "soft-light", + DIFFERENCE: "difference", + EXCLUSION: "exclusion", + HUE: "hue", + SATURATION: "saturation", + COLOR: "color", + LUMINOSITY: "luminosity", +}; + +const nodeBlendMode = (node: ConvertedNode): DesignBundleBlendMode | undefined => { + return CSS_BLEND_MODE_BY_FIGMA_BLEND_MODE[node.blendMode as string]; +}; + +const mapStyle = ( + node: ConvertedNode, + styles: DesignBundleStyles, +): DesignBundleNodeStyle => { + const fills = Array.isArray(node.fills) + ? (node.fills + .map((fill: any) => mapFill(fill, styles)) + .filter(Boolean) as DesignBundleFill[]) + : []; + return { + fills, + strokes: mapStrokes(node), + cornerRadius: resolveCornerRadius(node), + effects: mapEffects(node), + opacity: nodeOpacity(node), + blendMode: nodeBlendMode(node), + }; +}; + +const sizingValue = ( + sizingMode: string | undefined, + fixedValue: number | undefined, +): "fill" | "hug" | number => { + if (sizingMode === "FILL") return "fill"; + if (sizingMode === "HUG") return "hug"; + return typeof fixedValue === "number" ? Math.round(fixedValue) : 0; +}; + +const mapTextSegments = ( + node: ConvertedNode, + uniqueName: string, + styles: DesignBundleStyles, +): DesignBundleTextSegment[] => { + const segments = Array.isArray(node.styledTextSegments) + ? node.styledTextSegments + : []; + + if (segments.length === 0) { + // Fallback for nodes where per-run segmentation wasn't collected + // (see jsonNodeConversion.ts — segments are only gathered when the + // source node's style actually varies at the run level). + const fallbackFill = mapFill(node.fills?.[0], styles); + return [ + { + uniqueId: `${uniqueName}_span`, + characters: node.characters ?? "", + fontFamily: node.style?.fontFamily ?? "", + fontSize: node.style?.fontSize ?? 0, + fontWeight: String(node.style?.fontWeight ?? "400"), + lineHeight: 0, + letterSpacing: node.style?.letterSpacing ?? 0, + textCase: node.style?.textCase ?? "ORIGINAL", + textDecoration: node.style?.textDecoration ?? "NONE", + fillHex: fallbackFill?.hex, + fillRef: fallbackFill?.variableRef, + fillOpacity: fallbackFill?.opacity, + }, + ]; + } + + return segments.map((segment: any, index: number) => { + const fontSize = segment.fontSize ?? 0; + const lineHeightPx = segment.lineHeight + ? safeLineHeight(segment.lineHeight, fontSize) + : 0; + const letterSpacing = segment.letterSpacing + ? safeLetterSpacing(segment.letterSpacing, fontSize) + : 0; + + // Reuses mapFill (same hex+variableRef resolution node-level fills + // already get, including registering variable-bound colors into + // styles.colors) rather than only grabbing the variable id like + // before — that silently dropped color entirely for any text run + // using a plain, non-variable-bound color, which is the common case. + const textFill = mapFill(segment.fills?.[0], styles); + + return { + uniqueId: `${uniqueName}_span_${index}`, + characters: segment.characters ?? "", + fontFamily: segment.fontName?.family ?? segment.fontFamily ?? "", + fontSize, + fontWeight: String(segment.fontWeight ?? "400"), + lineHeight: fontSize > 0 ? lineHeightPx / fontSize : 0, + letterSpacing, + textCase: segment.textCase ?? "ORIGINAL", + textDecoration: segment.textDecoration ?? "NONE", + fillHex: textFill?.hex, + fillRef: textFill?.variableRef, + fillOpacity: textFill?.opacity, + // Already requested in getStyledTextSegments' field list + // (jsonNodeConversion.ts) — just wasn't threaded through until D23. + textStyleId: segment.textStyleId || undefined, + }; + }); +}; + +// Wrapped so a malformed/unexpected LineHeight or LetterSpacing shape +// (e.g. from a node that isn't a real live Figma TEXT node, seen while +// testing against non-Auto-Layout content per D16) degrades to 0 instead +// of throwing and aborting the whole export. +const safeLineHeight = (lineHeight: any, fontSize: number): number => { + try { + return commonLineHeight(lineHeight, fontSize) || 0; + } catch { + return 0; + } +}; +const safeLetterSpacing = (letterSpacing: any, fontSize: number): number => { + try { + return commonLetterSpacing(letterSpacing, fontSize) || 0; + } catch { + return 0; + } +}; + +/** + * Recursively converts one converted (AltNode-shaped) tree into a Design + * Bundle `DesignNode` tree, per docs/03-design-bundle-schema-draft.md. + * Mutates `assets` and `styles` as it walks, collecting exactly what D9/D13 + * require: an assets manifest for IMAGE/VECTOR leaves, and a resolved + * colors dictionary for anything bound to a Figma variable. + */ +export const buildDesignNode = ( + node: ConvertedNode, + assets: DesignBundleAsset[], + styles: DesignBundleStyles, + parentLayoutMode: string | undefined, + // D47: this node's index among its original parent's children (Figma's + // paint/z-order — see the `paintOrder` field doc in types.ts). Only the + // recursive call site below passes this; the root call + // (designBundleMain.ts) omits it, since a `designs[].root` entry has no + // real siblings within the bundle. + siblingIndex?: number, +): DesignNode => { + const uniqueName: string = node.uniqueName ?? node.name ?? node.id; + const type = classifyNodeType(node); + + const layout: DesignNode["layout"] = { + mode: (node.layoutMode as any) ?? "NONE", + primaryAxisAlign: (node.primaryAxisAlignItems as any) ?? "MIN", + counterAxisAlign: (node.counterAxisAlignItems as any) ?? "MIN", + gap: node.itemSpacing ?? 0, + padding: { + top: node.paddingTop ?? 0, + right: node.paddingRight ?? 0, + bottom: node.paddingBottom ?? 0, + left: node.paddingLeft ?? 0, + }, + sizing: { + width: sizingValue(node.layoutSizingHorizontal, node.width), + height: sizingValue(node.layoutSizingVertical, node.height), + }, + }; + // D59: Figma's Auto Layout wrap — `NO_WRAP` (the default) is never + // recorded, matching D55's convention for default-valued fields. + // `counterAxisSpacing` (row gap) only has real meaning when wrap is on. + if (node.layoutWrap === "WRAP") { + layout.wrap = true; + if (typeof node.counterAxisSpacing === "number") { + layout.rowGap = node.counterAxisSpacing; + } + } + // Position carries meaning when either the *parent* lays its children out + // freely (mode NONE), or this specific node opts out of its parent's Auto + // Layout flow (`layoutPositioning: "ABSOLUTE"`, Figma's per-child escape + // hatch available even inside a HORIZONTAL/VERTICAL auto-layout parent). + // The first version of this check only looked at the parent's overall + // mode and silently dropped x/y for absolutely-positioned children of an + // auto-layout frame — caught by a synthetic "decorative blob inside a + // vertical form" fixture during Phase 2 (see decisions log D18). Root + // designs[] entries have no parent, so position is always included there. + const isAbsoluteInAutoLayout = node.layoutPositioning === "ABSOLUTE"; + if ( + parentLayoutMode === undefined || + parentLayoutMode === "NONE" || + isAbsoluteInAutoLayout + ) { + layout.position = { + x: Math.round(node.x ?? 0), + y: Math.round(node.y ?? 0), + }; + } + + const designNode: DesignNode = { + id: node.id, + uniqueName, + type, + layout, + style: mapStyle(node, styles), + children: [], + // D47: index within *this specific call's* parent — i.e. relative to + // whatever `node`'s immediate parent was at the point Stage 1 walked + // it. Never a global/whole-tree counter. That single, uniform rule is + // what makes this work correctly both for a Template Part's own + // internal children (e.g. a header's logo/nav/button get 0/1/2, + // relative to the header — correct regardless of which design the + // header came from, or how many designs reuse the same header) *and* + // for the "socket" case (the header node itself, as it sits in one + // specific design's root.children, carries its own paintOrder equal + // to its index in *that* design's root — the exact value Stage 2 + // needs to remember where the header used to sit once it extracts + // that node out of the array entirely). + paintOrder: siblingIndex, + }; + + // D22: capture Figma's main-component id, independent of what `type` + // above collapsed to. Already present on the REST-v1 JSON export this + // whole tree is built from (api_types.ts's InstanceNode shape) — no + // extra Figma API call required. + // + // Two cases, both need to resolve to the *same* id so an instance and + // its own main component group together: + // - INSTANCE nodes carry `componentId`, pointing at their main + // component's node id. + // - The main COMPONENT (or COMPONENT_SET) node itself has no + // `componentId` field — it doesn't reference itself — but Figma's + // `componentId` on an instance *is* the main component's own `id`. So + // a COMPONENT/COMPONENT_SET node self-references its own `id` here. + // Found live: a Figma file's "master" page for a component (where the + // component is actually defined, not just instanced) holds the real + // COMPONENT node, not an INSTANCE — without this, that page's + // header/footer wouldn't group with every other page's instances of + // the same component, breaking D22's cross-design majority vote for + // exactly the one design that matters most for defining the part. + if (node.type === "INSTANCE" && typeof node.componentId === "string") { + designNode.componentId = node.componentId; + } else if ( + (node.type === "COMPONENT" || node.type === "COMPONENT_SET") && + typeof node.id === "string" + ) { + designNode.componentId = node.id; + } + + if (type === "TEXT") { + // D55: only CENTER/RIGHT/JUSTIFIED are ever recorded — LEFT (Figma's + // most common default) is deliberately omitted rather than captured + // as an explicit "LEFT" value, matching Stage 2's existing convention + // of never emitting a CSS declaration for a value that's already the + // browser default. + const align = + node.textAlignHorizontal === "CENTER" || + node.textAlignHorizontal === "RIGHT" || + node.textAlignHorizontal === "JUSTIFIED" + ? node.textAlignHorizontal + : undefined; + designNode.text = { segments: mapTextSegments(node, uniqueName, styles), ...(align ? { align } : {}) }; + } + + if (type === "IMAGE" || type === "VECTOR") { + // D63: reuse an already-registered asset for the same master-component + // node, rather than re-exporting/re-registering an identical copy for + // every Instance. See assetIdentityKeyFor's doc comment. + const identityKey = assetIdentityKeyFor(node.id); + const existing = identityKey ? assetIdentityMap.get(identityKey) : undefined; + if (existing) { + designNode.assetRef = existing.id; + return designNode; + } + + const ext = type === "IMAGE" ? "png" : "svg"; + const fileName = nextAssetFileName(uniqueName, ext); + const assetId = `asset_${String(assets.length + 1).padStart(2, "0")}`; + const asset: DesignBundleAsset = { + id: assetId, + figmaNodeId: node.id, + fileName, + kind: type === "IMAGE" ? "raster" : "vector", + width: Math.round(node.width ?? 0), + height: Math.round(node.height ?? 0), + }; + assets.push(asset); + if (identityKey) { + assetIdentityMap.set(identityKey, asset); + } + designNode.assetRef = assetId; + // IMAGE/VECTOR nodes are treated as leaves — matches the schema draft's + // examples, and avoids emitting redundant child markup for content + // Stage 2 would just discard in favor of the exported asset. + return designNode; + } + + // D51: this node stayed a FRAME/RECTANGLE (not collapsed to a leaf IMAGE + // above) specifically because it has real children — classifyNodeType's + // whole D18 fix. That means it can still have its own image fill sitting + // *behind* those children (a photographic hero background behind an + // overlay + heading text, the motivating real case), which style.fills + // never captures (SOLID/GRADIENT only). Registered as a distinct asset + // kind — `imageHash` set, not `figmaNodeId`-exportable the normal way — + // since there's no API to export just this one fill in isolation from a + // node that also has other content painted on top of it. + const backgroundFill = findImageFill(node); + if (backgroundFill && typeof backgroundFill.imageRef === "string") { + // D63: same identity-based dedup as the leaf IMAGE/VECTOR branch above + // — a repeated component instance's own background-image fill (e.g. a + // Frame background inside a duplicated header/footer) shouldn't be + // re-registered per Instance either. + const identityKey = assetIdentityKeyFor(node.id); + const existing = identityKey ? assetIdentityMap.get(identityKey) : undefined; + if (existing) { + designNode.backgroundAssetRef = existing.id; + } else { + const fileName = nextAssetFileName(`${uniqueName}_bg`, "png"); + const assetId = `asset_${String(assets.length + 1).padStart(2, "0")}`; + const asset: DesignBundleAsset = { + id: assetId, + figmaNodeId: node.id, + fileName, + kind: "raster", + width: Math.round(node.width ?? 0), + height: Math.round(node.height ?? 0), + imageHash: backgroundFill.imageRef, + }; + assets.push(asset); + if (identityKey) { + assetIdentityMap.set(identityKey, asset); + } + designNode.backgroundAssetRef = assetId; + } + } + + const children = Array.isArray(node.children) ? node.children : []; + designNode.children = children.map((child: ConvertedNode, index: number) => + buildDesignNode(child, assets, styles, layout.mode, index), + ); + + return designNode; +}; diff --git a/packages/backend/src/designBundle/designBundleUtils.ts b/packages/backend/src/designBundle/designBundleUtils.ts new file mode 100644 index 00000000..5e341187 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleUtils.ts @@ -0,0 +1,16 @@ +// Figma's plugin sandbox does not provide the `TextEncoder` global (it's a +// restricted JS environment, not a browser or Node) — confirmed at runtime +// via `TextEncoder is not defined` when exporting SVG assets during Phase 2 +// testing. Every place that needs UTF-8 bytes from a string must go through +// this manual fallback rather than assuming `TextEncoder` exists. +export const encodeUtf8Text = (text: string): Uint8Array => { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(text); + } + const utf8 = unescape(encodeURIComponent(text)); + const bytes = new Uint8Array(utf8.length); + for (let i = 0; i < utf8.length; i += 1) { + bytes[i] = utf8.charCodeAt(i); + } + return bytes; +}; diff --git a/packages/backend/src/designBundle/designBundleZip.ts b/packages/backend/src/designBundle/designBundleZip.ts new file mode 100644 index 00000000..cef564a8 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleZip.ts @@ -0,0 +1,31 @@ +import { zipSync } from "fflate"; +import { DesignBundle } from "types"; +import { ExportedDesignBundleAsset } from "./designBundleAssets"; +import { encodeUtf8Text as encodeText } from "./designBundleUtils"; + +/** + * Packages a Design Bundle as a zip: `design-bundle.json` at the root plus + * an `assets/` folder, matching the on-disk layout documented in + * docs/03-design-bundle-schema-draft.md's "Asset handling" section. + */ +export const generateDesignBundleZip = ( + bundle: DesignBundle, + assets: ExportedDesignBundleAsset[], +): Uint8Array => { + const files: Record = { + "design-bundle.json": encodeText(JSON.stringify(bundle, null, 2)), + }; + + for (const asset of assets) { + files[asset.fileName] = asset.bytes; + } + + try { + return zipSync(files, { level: 6 }); + } catch (error) { + console.error("Design bundle zip creation failed:", error); + throw new Error( + "Failed to create design bundle archive. The selection might be too large or complex.", + ); + } +}; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3a636fb1..9e007360 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -10,3 +10,4 @@ export { } from "./zipGenerator"; export { run } from "./code"; export * from "./messaging"; +export { buildDesignBundle } from "./designBundle/designBundleMain"; diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index 25ac2278..7ba00d4b 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -21,7 +21,7 @@ import { } from "./codegenPreferenceOptions"; import Loading from "./components/Loading"; import { useEffect, useState } from "react"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, PackageOpen, LoaderCircle } from "lucide-react"; import React from "react"; import { Button } from "./components/ui/button"; import { ScrollArea } from "./components/ui/scroll-area"; @@ -44,6 +44,10 @@ type PluginUIProps = { onDownloadProject?: (format: DownloadProjectFormat) => void; isDownloadingProject?: boolean; projectDownloadError?: string | null; + onExportDesignBundle?: () => void; + isExportingDesignBundle?: boolean; + designBundleExportError?: string | null; + designBundleWarnings?: Warning[]; }; const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; @@ -133,6 +137,28 @@ export const PluginUI = (props: PluginUIProps) => { showAbout={showAbout} setShowAbout={setShowAbout} /> + {props.onExportDesignBundle && ( + + )} + {(props.designBundleExportError || + (props.designBundleWarnings?.length ?? 0) > 0) && ( +
+ {props.designBundleExportError && ( +

+ {props.designBundleExportError} +

+ )} + {props.designBundleWarnings && + props.designBundleWarnings.length > 0 && ( + + )} +
+ )}
; +} + +export interface DesignBundleFill { + type: DesignBundleFillType; + hex?: string; + variableRef?: string; + // D46: this fill's own *combined* opacity — Figma's `paint.color.a` + // (alpha baked into the color itself) and `paint.opacity` (the paint's + // separate "opacity" slider) are two distinct fields that blend + // together (Figma's own doc comment on Paint.opacity: "colors within + // the paint can also have opacity values which would blend with + // this"), so they're collapsed into one number here at Stage 1 rather + // than carried as two — there's no meaningful reason for a Stage 2 + // consumer to ever want them separately, they represent the same + // "how see-through is this fill" concept. Omitted (undefined) when + // fully opaque (1), matching this schema's existing sparse-field + // convention (e.g. `layout.position`). Deliberately NOT collapsed + // together with the node's own `style.opacity` below — that's a + // different, non-collapsible axis (see that field's comment). + // For a GRADIENT fill this is always undefined — each stop already + // carries its own combined alpha (see DesignBundleGradientStop.hex + // above), so there's no single opacity number left to apply on top. + opacity?: number; + // D69: present only when `type === "GRADIENT"` and Figma's paint kind + // is one of the three CSS can represent (LINEAR/RADIAL/ANGULAR). + // DIAMOND-kind (and any future unrecognized gradient kind) omits this + // and falls back to `hex` only. + gradient?: DesignBundleGradient; +} +export interface DesignBundleStroke { + hex: string; + weight: number; +} +export interface DesignBundleEffect { + type: string; + x?: number; + y?: number; + blur?: number; + hex?: string; + // D70 (Phase 5 shadows/effects): DROP_SHADOW/INNER_SHADOW only — Figma's + // own `spread` (expands a drop shadow / contracts an inner shadow; + // undefined defaults to 0, same as Figma's own default). Maps directly + // to CSS box-shadow's spread-radius value with no conversion — the + // sign/growth semantics already match (D70's log entry has the detail). + spread?: number; +} +// D72 (Phase 5 blend modes, last of three long-tail items): the 13 of +// Figma's 18 blend modes CSS `mix-blend-mode` has a native keyword for — +// a plain kebab-case rename in every case (MULTIPLY -> "multiply", etc.). +// PASS_THROUGH/NORMAL are deliberately absent: both mean "no blending," +// so `DesignBundleNodeStyle.blendMode` is left undefined for them rather +// than modeled as a value (same sparse-field convention as `opacity`). +// LINEAR_BURN and LINEAR_DODGE are also absent — CSS has no equivalent +// (they're a different blend formula than color-burn/color-dodge, not +// just a naming difference) — same "narrower gap, logged not fixed" +// precedent as D18/D69's GRADIENT_DIAMOND. +export type DesignBundleBlendMode = + | "multiply" + | "screen" + | "overlay" + | "darken" + | "lighten" + | "color-dodge" + | "color-burn" + | "hard-light" + | "soft-light" + | "difference" + | "exclusion" + | "hue" + | "saturation" + | "color" + | "luminosity"; + +export interface DesignBundleNodeStyle { + fills: DesignBundleFill[]; + strokes: DesignBundleStroke[]; + cornerRadius: number; + effects: DesignBundleEffect[]; + // D46: the *node's own* layer opacity (Figma's `node.opacity`, the + // "Opacity" field in the right-hand panel for the whole layer) — + // distinct from any individual fill's opacity above. This affects the + // node's entire rendered result as a group: background, strokes, text, + // every descendant — not just one fill layer. A node can legitimately + // have both a translucent fill *and* fully-opaque child content sitting + // on top of it (e.g. a card with a dimmed background but readable + // text); collapsing this into a per-fill alpha would incorrectly fade + // that content too, which real Figma rendering never does. Maps to CSS + // `opacity` on the node's own wrapping element, not a color-channel + // adjustment. Omitted (undefined) when fully opaque (1). + opacity?: number; + // D72: the *node's own* Blending mode (Figma's `node.blendMode`, same + // right-hand-panel struct as `opacity` above, `HasBlendModeAndOpacityTrait` + // in the REST API v1 shape) — scoped deliberately to this one node-level + // field, not per-fill or per-effect blend modes (Figma also allows a + // blend mode on an individual paint or shadow effect, a much rarer, + // finer-grained case left out of scope here — same "narrower gap" + // treatment). Maps to CSS `mix-blend-mode` on the node's own wrapping + // element. Omitted (undefined) for PASS_THROUGH/NORMAL (no blending) + // and for LINEAR_BURN/LINEAR_DODGE (no CSS equivalent). + blendMode?: DesignBundleBlendMode; +} +export type DesignBundleSizeValue = "fill" | "hug" | number; +export interface DesignBundleLayout { + mode: "NONE" | "HORIZONTAL" | "VERTICAL"; + primaryAxisAlign: "MIN" | "CENTER" | "MAX" | "SPACE_BETWEEN"; + counterAxisAlign: "MIN" | "CENTER" | "MAX" | "BASELINE"; + gap: number; + padding: { top: number; right: number; bottom: number; left: number }; + sizing: { width: DesignBundleSizeValue; height: DesignBundleSizeValue }; + // Populated only when the *parent* frame's layout.mode is "NONE" (i.e. the + // parent uses absolute positioning) — see D18 in the decisions log for why + // this diverges from a literal reading of the schema draft. + position?: { x: number; y: number }; + // D59: Figma's Auto Layout "wrap" (`layoutWrap: "WRAP"`) — a real, + // distinct layout mechanism from `position` above, found via the + // Product Detail page's related-products grid: six fixed-width cards + // in a fixed-width HORIZONTAL container, with no absolute positioning + // at all (initially mistaken for one — see D58 — before Sean traced + // the real Figma mechanism directly). CSS's `flex-wrap: wrap` is the + // literal equivalent; only ever true, mirroring D55's convention of + // never recording the non-default case (`NO_WRAP`) explicitly. + wrap?: boolean; + // Figma's `counterAxisSpacing` — the gap between wrapped *rows/tracks*, + // distinct from `gap` above (which is the item gap along the main + // axis). Only meaningful, and only ever populated, when `wrap` is true. + // Maps to CSS `gap`'s row-gap component (`gap: {rowGap}px {gap}px`) + // rather than reusing `gap` for both axes, in case a design's item + // spacing and row spacing genuinely differ. + rowGap?: number; +} +export interface DesignBundleTextSegment { + uniqueId: string; + characters: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + lineHeight: number; + letterSpacing: number; + textCase: string; + textDecoration: string; + // Figma's named text style id for this run, when the run has one applied. + // Resolves via bundle.styles.textStyles[textStyleId] -> DesignBundleTextStyle. + // Populated per D23 — the primary heading/paragraph signal Stage 2 uses, + // ahead of the fontSize/fontWeight fallback heuristic. + textStyleId?: string; + // Text fill color. `fillHex` is always populated when the run has a + // solid fill at all (the literal resolved color); `fillRef` is only set + // when that fill is bound to a Figma variable. Previously only fillRef + // was captured, which silently dropped color for any text run using a + // plain, non-variable-bound color — the common case. Both now mirror + // DesignBundleFill's hex+variableRef pairing (mapFill in + // designBundleTree.ts) rather than introducing a different shape. + fillHex?: string; + fillRef?: string; + // D46: mirrors DesignBundleFill.opacity (same combined color.a * paint.opacity + // calculation, via the same mapFill/fillOpacity path) — a text run's own + // fill can be translucent same as any other fill. Omitted when opaque. + fillOpacity?: number; +} +export type DesignNodeType = "FRAME" | "TEXT" | "IMAGE" | "VECTOR" | "RECTANGLE"; +export interface DesignNode { + id: string; + uniqueName: string; + type: DesignNodeType; + layout: DesignBundleLayout; + style: DesignBundleNodeStyle; + // D55: Figma's `textAlignHorizontal`, node-level (not per-run — Figma + // models horizontal alignment as a property of the whole TEXT node, not + // individual styled runs, unlike fontFamily/fontSize/etc. above). + // Omitted entirely — not just set to "LEFT" — when Figma's own value is + // "LEFT", since that's the CSS default and Stage 2 skips emitting a + // redundant `text-align: left` the same way it already skips other + // default-valued declarations elsewhere. This project's Design Bundle + // schema never captured this at all before D55 — confirmed via direct + // code search, not assumed — a genuine, previously-latent capture gap, + // not a regression from any prior Phase 5 fix. + text?: { segments: DesignBundleTextSegment[]; align?: "CENTER" | "RIGHT" | "JUSTIFIED" }; + assetRef?: string; + // Figma's main-component id, present when this node was originally an + // INSTANCE (already available synchronously on the REST-v1 JSON export + // Stage 1 already uses — no extra API call needed). Populated regardless + // of what `type` above collapses to (INSTANCE always maps to FRAME/ + // RECTANGLE here, same as any other frame — see classifyNodeType). + // Used by Stage 2 (D22) to identify header/footer Template Part + // candidates via real component identity rather than layer-name matching + // (which D14 already rejected as too fragile). + componentId?: string; + // D47: this node's index among its original parent's children, at the + // point Stage 1 walked the tree — i.e. Figma's own paint/z-order + // (confirmed repeatedly this project: `children[]` array order *is* + // paint order, not visual position — see D35/D43). Captured as an + // explicit field, independent of this node's *current* position in any + // `children[]` array, specifically so it survives a node being pulled + // out of that array entirely — the header/footer Template Part + // extraction case (`classifyTemplateParts`/`pruneTemplatePartChildren` + // in Stage 2's `templateParts.ts`/`generateThemeFiles.ts`), where a + // node that used to be "child 3 of the root" becomes the independent + // root of its own separate render context and has no `children[]` + // membership at all to infer order from anymore. Without this, Stage 2 + // has no way to know a header was originally *above or below* some + // other now-unrelated sibling in paint order once they're split into + // separate template files (D45's punted header/hero overlap case). + // Root `designs[].root` entries have no real parent/siblings within the + // bundle, so this is omitted (undefined) there — same convention as + // `layout.position` being root-conditional. + // + // Deliberately a plain ordinal (0 = painted first/bottommost in normal + // top-down z stacking), not a pre-computed CSS z-index — keeping Stage 2 + // free to decide its own sign/offset convention (e.g. `z-index: + // {paintOrder}` or `-{paintOrder}`) rather than baking a + // WordPress/CSS-specific decision into the target-neutral bundle (D17). + paintOrder?: number; + // D51: a FRAME/RECTANGLE's own background *image* fill — distinct from + // `assetRef` (leaf IMAGE/VECTOR nodes, where the exported asset *is* + // the node's entire visual content) and distinct from `style.fills` + // (which only ever models SOLID/GRADIENT paints, never IMAGE — see + // `classifyNodeType`'s doc comment in designBundleTree.ts, D18). A node + // with both an image fill *and* real children stays a FRAME so its + // children survive as separate, editable content (D18's fix), but that + // left the background image itself uncaptured entirely — confirmed as + // a real, concrete gap on a real bundle: a "Dimmer" overlay (D44) sits + // in front of a photographic hero background that never made it into + // the bundle at all. Resolves the same way `assetRef` does — via + // `bundle.assets[]`, keyed by this id — Stage 2 renders it as a CSS + // `background-image`, layered under any `style.fills` background-color + // (and under any real children rendered on top, same as Figma's own + // paint order for this exact configuration). + backgroundAssetRef?: string; + children: DesignNode[]; +} +export interface DesignBundleAsset { + id: string; + figmaNodeId: string; + fileName: string; + kind: "raster" | "vector"; + width: number; + height: number; + // D51: present only for a background-image asset (referenced via a + // DesignNode's `backgroundAssetRef`, not `assetRef`). Figma has no API + // to export "just this one fill" from a node that also has other + // visual content (children) painted on top of it — calling the usual + // `node.exportAsync()` on the *containing* frame would flatten those + // children into the raster too, which is exactly what D18 fixed by + // keeping such a frame's children as separate, real content instead of + // a flattened image. `imageHash` is the paint's own image reference + // (Figma REST API v1 calls this `imageRef`; the Plugin API's + // `getImageByHash` accepts the same underlying value) — resolving the + // fill's raw bytes directly, independent of whatever else the + // containing node renders. + imageHash?: string; +} +export interface DesignBundleColorStyle { + name: string; + hex: string; +} +export interface DesignBundleTextStyle { + name: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + lineHeight: number; +} +export interface DesignBundleStyles { + colors: Record; + textStyles: Record; +} +export interface DesignBundleDesign { + figmaNodeId: string; + layerName: string; + root: DesignNode; +} +export interface DesignBundleMeta { + figmaFileKey: string; + figmaFileName: string; + figmaPageName: string; + exportedAt: string; + exportedBy: string; + sourceTool: string; +} +export interface DesignBundle { + schemaVersion: 1; + meta: DesignBundleMeta; + designs: DesignBundleDesign[]; + assets: DesignBundleAsset[]; + styles: DesignBundleStyles; +} +export type ExportDesignBundleMessage = Message & { + type: "export-design-bundle"; +}; +export type DesignBundleZipMessage = Message & { + type: "design-bundle-zip"; + zip: ArrayBuffer; + fileName: string; + designCount: number; + assetCount: number; + warnings: string[]; +}; +export type DesignBundleErrorMessage = Message & { + type: "design-bundle-error"; + error: string; +}; + // Nodes export type ParentNode = BaseNode & ChildrenMixin; From e66cc9b961997029b82abfadf6664c0a8a9f7e1e Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Wed, 19 Aug 2026 23:58:23 +0000 Subject: [PATCH 2/5] docs: document Design Bundle export in the README Adds a Design Bundle row to the "Output targets" table (with a caveat that it's an intermediate format, not finished code), a short new "Design Bundle export" section in the same register as "How conversion works" covering the zip layout, multi-selection behavior, and where to export it from, and a "Repository structure" entry for packages/backend/src/designBundle. Field-level schema detail is left to the DesignBundle* TSDoc comments in packages/types/src/types.ts rather than duplicated here, matching how the rest of the README defers detail to the source. --- README.md | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6adbe1df..e09e726f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,15 @@ The generator is deterministic and runs inside Figma's plugin sandbox. It does n ## Output targets -| Target | Available output modes | -| ------------ | ----------------------------------------------------------- | -| HTML | HTML, React (JSX), Svelte, styled-components | -| Tailwind CSS | HTML, React (JSX), Twig; supports Tailwind 3 and Tailwind 4 | -| Flutter | Full app, stateless widget, or snippet | -| SwiftUI | Preview, `View` struct, or snippet | +| Target | Available output modes | +| ------------- | ----------------------------------------------------------- | +| HTML | HTML, React (JSX), Svelte, styled-components | +| Tailwind CSS | HTML, React (JSX), Twig; supports Tailwind 3 and Tailwind 4 | +| Flutter | Full app, stateless widget, or snippet | +| SwiftUI | Preview, `View` struct, or snippet | +| Design Bundle | JSON manifest + exported assets, zipped (see below) | + +Design Bundle is different from the other four rows: it isn't finished code, it's a target-neutral snapshot of the selection's layout, styling, and content for another tool to read. See [Design Bundle export](#design-bundle-export) below. The plugin can also package generated code and local image assets into downloadable starters: @@ -46,6 +49,17 @@ The plugin can also package generated code and local image assets into downloada These exports are deliberately small and dependency-light. They are starting points, not generated production applications. +## Design Bundle export + +Alongside the four code targets above, the plugin can export the same normalized node tree as a **Design Bundle** instead of code: a `design-bundle.json` manifest plus an `assets/` folder of exported raster and vector images, packaged as a zip. It's meant to be consumed by another tool, not pasted into an application directly — think of it as the "Normalize" stage of [How conversion works](#how-conversion-works) written to disk, before any framework-specific "Generate" step runs. + +A couple of things make it different from the other four targets: + +- **Multiple top-level layers in one export.** Where the code targets work from a single converted selection, a Design Bundle turns each top-level layer in your selection into its own named entry in the bundle's `designs` array — useful for exporting several distinct sections or pages in one pass. +- **No code-specific tuning.** None of the "What you can tune" options below apply; the bundle carries the resolved layout and style data itself, and leaves interpreting it (as CMS content blocks, a design system, or anything else) up to whatever reads the bundle. + +Export a bundle from the toolbar button next to the framework tabs. The bundle's shape is documented via TSDoc comments on the `DesignBundle*` types in [`packages/types/src/types.ts`](packages/types/src/types.ts) — start there for field-level detail. + ## What you can tune Options appear only when they apply to the selected target: @@ -160,14 +174,15 @@ pnpm format:check # Check formatting without writing ### Repository structure -| Path | Purpose | -| -------------------- | ---------------------------------------------------------------------------------------- | -| `packages/backend` | Figma node processing, intermediate representation, code generators, and project exports | -| `packages/plugin-ui` | Shared React interface used by the plugin and interactive website demo | -| `packages/types` | Shared settings, message, preview, and output types | -| `packages/tsconfig` | Shared TypeScript configuration | -| `apps/plugin` | Figma controller and UI entry points; builds `code.js` and `index.html` | -| `apps/web` | Public website, interactive preview, privacy page, and comparison guides | +| Path | Purpose | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `packages/backend` | Figma node processing, intermediate representation, code generators, and project exports | +| `packages/backend/src/designBundle` | Design Bundle export — serializes the normalized node tree to `design-bundle.json` + assets instead of code | +| `packages/plugin-ui` | Shared React interface used by the plugin and interactive website demo | +| `packages/types` | Shared settings, message, preview, and output types | +| `packages/tsconfig` | Shared TypeScript configuration | +| `apps/plugin` | Figma controller and UI entry points; builds `code.js` and `index.html` | +| `apps/web` | Public website, interactive preview, privacy page, and comparison guides | ## Contributing and support From ff500a234c9fca26b0234fa1224e459774a14488 Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Thu, 20 Aug 2026 00:15:09 +0000 Subject: [PATCH 3/5] fix: satisfy oxlint on designBundleTree.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the useless ?? {} fallback in the gradient stop color spread — spreading undefined/null in an object literal is already a no-op, so the fallback guarded against nothing (no-useless-fallback-in-spread). - Remove a stale eslint-disable-next-line comment on ConvertedNode that oxlint (what this project actually lints with) never flagged in the first place. --- packages/backend/src/designBundle/designBundleTree.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend/src/designBundle/designBundleTree.ts b/packages/backend/src/designBundle/designBundleTree.ts index 4d0fd8c7..223497d9 100644 --- a/packages/backend/src/designBundle/designBundleTree.ts +++ b/packages/backend/src/designBundle/designBundleTree.ts @@ -20,7 +20,6 @@ import { commonLetterSpacing, commonLineHeight } from "../common/commonTextHeigh // single exported type for that combination, so we work against a loosely-typed shape here // rather than fighting the type system — consistent with how the rest of the backend // (code.ts, jsonNodeConversion.ts) already treats `convertedSelection` as `any`. -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type ConvertedNode = any; const VECTOR_LIKE_TYPES = new Set([ @@ -193,7 +192,7 @@ const mapGradient = (paint: any): DesignBundleGradient | undefined => { return { kind, stops: stops.map((stop: any) => ({ - hex: rgbaToHex8({ ...(stop.color ?? {}), a: (stop.color?.a ?? 1) * paintOpacity }), + hex: rgbaToHex8({ ...stop.color, a: (stop.color?.a ?? 1) * paintOpacity }), position: typeof stop.position === "number" ? stop.position : 0, })), handles: handles.map((handle: any) => ({ x: handle?.x ?? 0, y: handle?.y ?? 0 })), From a025a0049ea9a7d1da239af6dd82fda084a85cd0 Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Thu, 20 Aug 2026 01:52:18 +0000 Subject: [PATCH 4/5] docs: remove internal decision-log and pipeline-stage references from comments Strips citations to this project's internal decision log (D-numbers), Phase/Stage pipeline vocabulary, and a broken reference to a doc path that doesn't exist in this repo from every comment touched by the Design Bundle export change. Comments now explain the 'why' inline, standalone, without assuming a reader has access to project-internal docs. --- .../src/altNodes/jsonNodeConversion.ts | 10 +- .../src/designBundle/designBundleAssets.ts | 24 +- .../src/designBundle/designBundleMain.ts | 30 ++- .../designBundle/designBundleTextStyles.ts | 4 +- .../src/designBundle/designBundleTree.ts | 166 +++++++------ .../src/designBundle/designBundleUtils.ts | 6 +- .../src/designBundle/designBundleZip.ts | 4 +- packages/types/src/types.ts | 221 ++++++++---------- 8 files changed, 221 insertions(+), 244 deletions(-) diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts index df2dc883..01311b8a 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -380,15 +380,15 @@ const processNodePair = async ( (jsonNode as any).parent = parentNode; } - // D58: `jsonNode` originates entirely from `node.exportAsync({ format: + // `jsonNode` originates entirely from `node.exportAsync({ format: // "JSON_REST_V1" })` (nodesToJSON, above) — a static snapshot in // Figma's REST API v1 shape, not live Plugin API property access. // Found via a real, reproducible case: six related-product Cards with // Figma's per-child "Position: Absolute" toggle enabled (no GROUP - // involved — confirmed by Sean directly in Figma), inside a real - // HORIZONTAL Auto Layout "Card grid" parent. Every one of them rendered - // with zero positioning at all — not wrong coordinates, nothing — - // meaning `layout.position` was never captured in Stage 1 + // involved — confirmed directly in Figma), inside a real HORIZONTAL + // Auto Layout "Card grid" parent. Every one of them rendered with zero + // positioning at all — not wrong coordinates, nothing — meaning + // `layout.position` was never captured // (`designBundleTree.ts`'s `isAbsoluteInAutoLayout` check reads // `node.layoutPositioning === "ABSOLUTE"`, which depends entirely on // this field surviving from that snapshot). `layoutPositioning` (the diff --git a/packages/backend/src/designBundle/designBundleAssets.ts b/packages/backend/src/designBundle/designBundleAssets.ts index 10b8dcc9..dbc73253 100644 --- a/packages/backend/src/designBundle/designBundleAssets.ts +++ b/packages/backend/src/designBundle/designBundleAssets.ts @@ -8,17 +8,16 @@ export interface ExportedDesignBundleAsset { } /** - * Explicit Images-API asset export (D9). FigmaToCode's default codegen path + * Explicit Images-API asset export. FigmaToCode's default codegen path * leaves image `src` as placehold.co placeholders and never calls * `exportAsync` for plain layout/text output — the Design Bundle needs real * files regardless of which codegen path (if any) is otherwise in use, so * this is a standalone step over the asset manifest `buildDesignNode` * already collected, not a reuse of any HTML/Tailwind/etc. image handling. * - * Raster (IMAGE) nodes export as PNG at 2x, per - * docs/03-design-bundle-schema-draft.md's asset-handling section. Vector - * (VECTOR/STAR/POLYGON/BOOLEAN_OPERATION/LINE) nodes export as SVG so - * Stage 2 can inline them directly instead of rasterizing. + * Raster (IMAGE) nodes export as PNG at 2x. Vector + * (VECTOR/STAR/POLYGON/BOOLEAN_OPERATION/LINE) nodes export as SVG so a + * downstream consumer can inline them directly instead of rasterizing. */ export const exportDesignBundleAssets = async ( assets: DesignBundleAsset[], @@ -26,17 +25,18 @@ export const exportDesignBundleAssets = async ( const exported: ExportedDesignBundleAsset[] = []; for (const asset of assets) { - // D51: a background-image asset (DesignNode.backgroundAssetRef, not + // A background-image asset (DesignNode.backgroundAssetRef, not // assetRef) carries `imageHash` instead — resolved via // `figma.getImageByHash`, not `node.exportAsync()`. The containing // node also has real child content painted on top of this fill (the // whole reason it's a background-image asset rather than a normal - // leaf IMAGE asset — see designBundleTree.ts's D51 comment), so - // exporting *that node* would flatten the children into the raster - // too. `getImageByHash` resolves the fill's own raw bytes directly, - // independent of anything else the node renders. Figma's REST API v1 - // calls this same value `imageRef`; the Plugin API's `getImageByHash` - // accepts it under the name `hash` — same underlying image reference. + // leaf IMAGE asset — see designBundleTree.ts's matching comment on + // `backgroundAssetRef`), so exporting *that node* would flatten the + // children into the raster too. `getImageByHash` resolves the fill's + // own raw bytes directly, independent of anything else the node + // renders. Figma's REST API v1 calls this same value `imageRef`; the + // Plugin API's `getImageByHash` accepts it under the name `hash` — + // same underlying image reference. if (asset.imageHash) { try { const image = figma.getImageByHash(asset.imageHash); diff --git a/packages/backend/src/designBundle/designBundleMain.ts b/packages/backend/src/designBundle/designBundleMain.ts index 5f807513..56ab5f64 100644 --- a/packages/backend/src/designBundle/designBundleMain.ts +++ b/packages/backend/src/designBundle/designBundleMain.ts @@ -23,17 +23,15 @@ export interface DesignBundleExportResult { } /** - * Stage 1 (Phase 2) entry point: turns the current Figma selection into a - * Design Bundle zip (design-bundle.json + /assets), per - * docs/03-design-bundle-schema-draft.md. + * Entry point: turns the current Figma selection into a Design Bundle zip + * (design-bundle.json + /assets). * * Reuses `nodesToJSON` for the actual node-tree normalization (Auto Layout, * variables, styled text segments, empty-frame flattening, GROUP inlining — - * all already handled there and already multi-selection-safe, see D10 note - * in the decisions log) rather than re-deriving any of that. This module's - * only job is mapping that AltNode-shaped output onto the bundle's - * `DesignNode` shape and wiring up the explicit asset export step D9 calls - * for. + * all already handled there and already multi-selection-safe) rather than + * re-deriving any of that. This module's only job is mapping that + * AltNode-shaped output onto the bundle's `DesignNode` shape and wiring up + * the explicit asset-export step (exportDesignBundleAssets, below). */ export const buildDesignBundle = async ( selection: readonly SceneNode[], @@ -51,9 +49,9 @@ export const buildDesignBundle = async ( if (convertedSelection.length !== selection.length) { // nodesToJSON can return more entries than the input selection when a // top-level GROUP gets inlined into multiple sibling nodes (see - // jsonNodeConversion.ts). D10 assumed a clean 1:1 mapping between - // selected layers and designs[] entries; a top-level GROUP breaks that - // assumption. Logged as a real Phase 2 finding (see decisions log D18) + // jsonNodeConversion.ts) — a top-level GROUP breaks the otherwise + // clean 1:1 mapping between selected layers and designs[] entries. + // Handled explicitly here (falling back to converted node names) // rather than silently mismatching names below. console.warn( "[design-bundle] convertedSelection count does not match selection count " + @@ -69,7 +67,7 @@ export const buildDesignBundle = async ( const root = buildDesignNode(node, assets, styles, undefined); return { figmaNodeId: root.id, - // Raw, as-authored Figma layer name only — no slug/title (D15). + // Raw, as-authored Figma layer name only — no slug/title. // Falls back to the converted node's own name if the index-aligned // original selection entry is unavailable (see mismatch note above). layerName: originalNode?.name ?? node.name ?? root.uniqueName, @@ -77,8 +75,8 @@ export const buildDesignBundle = async ( }; }); - // Named-text-style resolution (D23): a separate async pass after tree- - // building, since Figma's style lookup (getStyleByIdAsync) is async and + // Named-text-style resolution: a separate async pass after tree-building, + // since Figma's style lookup (getStyleByIdAsync) is async and // buildDesignNode itself is kept synchronous (see designBundleTextStyles.ts). const textStyleIds = new Set(); for (const design of designs) { @@ -86,8 +84,8 @@ export const buildDesignBundle = async ( } const textStyleWarnings = await resolveTextStyles(textStyleIds, styles.textStyles); // Routed through addWarning (not a bare console.warn) so these actually - // reach the plugin UI's WarningsPanel — see D19, where warnings silently - // not reaching the UI was itself a real bug, not just a missing feature. + // reach the plugin UI's WarningsPanel — a bare console.warn here would + // never surface these to the user. for (const w of textStyleWarnings) addWarning(w); const exportedAssets = await exportDesignBundleAssets(assets); diff --git a/packages/backend/src/designBundle/designBundleTextStyles.ts b/packages/backend/src/designBundle/designBundleTextStyles.ts index 28cc24d5..7ce29d42 100644 --- a/packages/backend/src/designBundle/designBundleTextStyles.ts +++ b/packages/backend/src/designBundle/designBundleTextStyles.ts @@ -41,8 +41,8 @@ export const collectTextStyleIds = (node: DesignNode, into: Set = new Se /** * Resolves a set of textStyleIds against Figma's style registry - * (`getStyleByIdAsync`) into the bundle's `styles.textStyles` dictionary - * (D23). Done as a separate pass after tree-building rather than inline in + * (`getStyleByIdAsync`) into the bundle's `styles.textStyles` dictionary. + * Done as a separate pass after tree-building rather than inline in * `buildDesignNode`, since `buildDesignNode` is synchronous (matches the * existing colors/variables handling in `designBundleTree.ts`, which never * needs an async call because bound-variable data is already present diff --git a/packages/backend/src/designBundle/designBundleTree.ts b/packages/backend/src/designBundle/designBundleTree.ts index 223497d9..bbfe2517 100644 --- a/packages/backend/src/designBundle/designBundleTree.ts +++ b/packages/backend/src/designBundle/designBundleTree.ts @@ -14,12 +14,12 @@ import { import { commonLetterSpacing, commonLineHeight } from "../common/commonTextHeightSpacing"; // The tree produced by `nodesToJSON` (packages/backend/src/altNodes/jsonNodeConversion.ts) -// is a standard Figma REST API v1 `Node` (packages/backend/src/api_types.ts) plus the -// AltNode extras documented in 03-design-bundle-schema-draft.md (`x/y/width/height`, -// `uniqueName`, `cumulativeRotation`, `canBeFlattened`, `styledTextSegments`). There is no -// single exported type for that combination, so we work against a loosely-typed shape here -// rather than fighting the type system — consistent with how the rest of the backend -// (code.ts, jsonNodeConversion.ts) already treats `convertedSelection` as `any`. +// is a standard Figma REST API v1 `Node` (packages/backend/src/api_types.ts) plus a handful +// of AltNode extras (`x/y/width/height`, `uniqueName`, `cumulativeRotation`, `canBeFlattened`, +// `styledTextSegments`). There is no single exported type for that combination, so we work +// against a loosely-typed shape here rather than fighting the type system — consistent with +// how the rest of the backend (code.ts, jsonNodeConversion.ts) already treats +// `convertedSelection` as `any`. export type ConvertedNode = any; const VECTOR_LIKE_TYPES = new Set([ @@ -32,7 +32,7 @@ const VECTOR_LIKE_TYPES = new Set([ let assetCounter = 0; let nameCounters: Map = new Map(); -// D63: primary asset-dedup mechanism — keyed on the node's identity *within +// Primary asset-dedup mechanism — keyed on the node's identity *within // its master Component definition*, not on the specific Instance's own node // id. See assetIdentityKeyFor's doc comment below for the ID-shape this // relies on. Session-scoped, same lifetime/reset semantics as @@ -45,7 +45,7 @@ export const resetDesignBundleTreeState = () => { assetIdentityMap = new Map(); }; -// D63: Figma's REST API v1 (what nodesToJSON's whole tree is built from — +// Figma's REST API v1 (what nodesToJSON's whole tree is built from — // see the ConvertedNode comment above) gives every node *inside* an // Instance an id of the shape `I{instanceId};{masterChildId}` — confirmed // directly against real exported bundles (e.g. `I2011:161;1:1468`). The @@ -55,14 +55,14 @@ export const resetDesignBundleTreeState = () => { // unique file-wide, so this substring alone (no separate componentId lookup // needed) already uniquely identifies "the same original node." A node // that's directly part of a design's own tree (not inside any Instance) has -// a plain id with no semicolon and never matches — always exported fresh, -// unchanged from pre-D63 behavior. +// a plain id with no semicolon and never matches — it is always exported +// fresh. // -// Deliberately identity-based, not content-based: Stage 2 has a separate, -// secondary content-hash pass (`loadBundle.ts`) for anything this doesn't -// explain. This only recognizes "the same node position inside the same -// component," and — per Sean's explicit call — assumes no per-instance -// content overrides on shared header/footer content. A real override would +// Deliberately identity-based, not content-based: a downstream consumer is +// free to layer a separate content-hash pass on top for anything this +// doesn't explain. This only recognizes "the same node position inside the +// same component," and deliberately assumes no per-instance content +// overrides on shared header/footer content. A real override would // currently dedupe silently wrong; revisit if that assumption ever proves // false in practice. const INSTANCE_DESCENDANT_ID = /^I[^;]+;(.+)$/; @@ -118,12 +118,12 @@ const classifyNodeType = (node: ConvertedNode): DesignNodeType => { // Only collapse an image-filled node to a flattened IMAGE leaf when it has // no real children. Originally this collapsed *any* image-filled node // regardless of children — validated against a synthetic "hero banner with - // an overlaid heading" fixture during Phase 2 and found to silently drop - // the heading, a real content-loss bug (see decisions log D18). A frame - // with both an image fill and child content now stays a FRAME so its - // children survive; the background image itself is still not - // representable in style.fills (schema only models solid/gradient fills) - // — that narrower gap is left as a Phase 5 long-tail item. + // an overlaid heading" fixture and found to silently drop the heading, a + // real content-loss bug. A frame with both an image fill and child + // content now stays a FRAME so its children survive; the background + // image itself is still not representable in style.fills (schema only + // models solid/gradient fills) — see the `backgroundAssetRef` handling + // further down for how that gap is covered instead. if (hasImageFill(node) && !hasRealChildren(node)) return "IMAGE"; if (node.type === "RECTANGLE" || node.type === "ELLIPSE") return "RECTANGLE"; return "FRAME"; @@ -136,7 +136,7 @@ const resolveCornerRadius = (node: ConvertedNode): number => { if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { return topLeft ?? 0; } - // Schema v1 only carries a single cornerRadius number (see D18) — non-uniform + // Schema v1 only carries a single cornerRadius number — non-uniform // corners are approximated by their largest corner rather than dropped. return Math.max(topLeft ?? 0, topRight ?? 0, bottomRight ?? 0, bottomLeft ?? 0); } @@ -151,13 +151,13 @@ const resolveCornerRadius = (node: ConvertedNode): number => { return 0; }; -// D46: Figma's `paint.color.a` (alpha baked into the fill's own color) and +// Figma's `paint.color.a` (alpha baked into the fill's own color) and // `paint.opacity` (the fill's separate "opacity" slider) are two distinct // fields that blend together — Figma's own doc comment on Paint.opacity: // "colors within the paint can also have opacity values which would blend // with this" — so they're combined into one effective alpha here, at the // point of capture, rather than carried through as two separate numbers -// with no real Stage-2 use for keeping them apart. `undefined` (not just +// with no real downstream use for keeping them apart. `undefined` (not just // `1`) is treated as "fully opaque" for both, matching Figma's own default. const fillOpacity = (paint: any): number | undefined => { const colorAlpha = typeof paint.color?.a === "number" ? paint.color.a : 1; @@ -166,19 +166,18 @@ const fillOpacity = (paint: any): number | undefined => { return combined < 1 ? combined : undefined; }; -// D69 (Phase 5 gradients): the three gradient kinds CSS can render -// natively. GRADIENT_DIAMOND is deliberately absent — no CSS equivalent, -// Sean's explicit call to leave it collapsed to a flat fallback color -// rather than approximate it. +// The three gradient kinds CSS can render natively. GRADIENT_DIAMOND is +// deliberately absent — no CSS equivalent, so it's left collapsed to a flat +// fallback color rather than approximated. const GRADIENT_KIND_BY_PAINT_TYPE: Record = { GRADIENT_LINEAR: "LINEAR", GRADIENT_RADIAL: "RADIAL", GRADIENT_ANGULAR: "ANGULAR", }; -// D69: structured gradient data (stops + Figma's own raw handle geometry, +// Structured gradient data (stops + Figma's own raw handle geometry, // unconverted — see DesignBundleGradient's doc comment in types.ts for why -// the trig stays out of Stage 1). Returns undefined for GRADIENT_DIAMOND, +// the trig stays out of this step). Returns undefined for GRADIENT_DIAMOND, // any unrecognized gradient kind, or if Figma's own gradientStops/ // gradientHandlePositions are missing on this paint — mapFill's caller // still gets a flat `hex` fallback in every case via the first stop. @@ -225,14 +224,13 @@ const mapFill = ( } if (typeof paint.type === "string" && paint.type.startsWith("GRADIENT")) { - // D69: always carry a flat-color fallback — the first stop's own + // Always carry a flat-color fallback — the first stop's own // color, with its alpha already combined with the paint's overall // opacity, as an 8-digit hex so no separate `opacity` field is // needed on the fallback either. Covers GRADIENT_DIAMOND and any - // future gradient kind Stage 2 can't render as real CSS. Previously - // this branch produced no `hex` at all, so any gradient-filled node - // rendered with *no* background whatsoever — this fixes that gap too, - // not just the LINEAR/RADIAL/ANGULAR cases. + // future gradient kind a downstream consumer can't render as real CSS. + // Without this, any gradient-filled node would render with *no* + // background whatsoever — not just for the GRADIENT_DIAMOND case. const firstStopColor = Array.isArray(paint.gradientStops) ? paint.gradientStops[0]?.color : undefined; const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; const fallbackHex = firstStopColor @@ -269,10 +267,9 @@ const mapEffects = (node: ConvertedNode): DesignBundleEffect[] => { y: effect.offset?.y ?? 0, blur: effect.radius ?? 0, hex: effect.color ? rgbaToHex8(effect.color) : undefined, - // D70: only meaningful for shadows — Figma's own `spread`, - // already present on the raw effect object, just wasn't carried - // through before (Stage 2 didn't consume `style.effects` at - // all pre-D70, so there was nothing to wire it to yet). + // Only meaningful for shadows — Figma's own `spread`, already + // present on the raw effect object, is carried straight + // through here. spread: typeof effect.spread === "number" ? effect.spread : undefined, }; } @@ -280,7 +277,7 @@ const mapEffects = (node: ConvertedNode): DesignBundleEffect[] => { }); }; -// D46: the node's own layer opacity (`HasBlendModeAndOpacityTrait.opacity` +// The node's own layer opacity (`HasBlendModeAndOpacityTrait.opacity` // in the REST API v1 shape — every node type carries this), distinct from // any individual fill's opacity above (see DesignBundleNodeStyle.opacity's // doc comment in types.ts for why these aren't collapsed together). @@ -290,7 +287,7 @@ const nodeOpacity = (node: ConvertedNode): number | undefined => { return value < 1 ? value : undefined; }; -// D72: Figma's 18 `BlendMode` values -> the 13 CSS `mix-blend-mode` has a +// Figma's 18 `BlendMode` values -> the 13 CSS `mix-blend-mode` has a // native keyword for. PASS_THROUGH/NORMAL map to `undefined` (no // blending, same as this schema's other sparse-field opacity/gradient // conventions) rather than being listed here with no value — they're @@ -409,7 +406,7 @@ const mapTextSegments = ( fillRef: textFill?.variableRef, fillOpacity: textFill?.opacity, // Already requested in getStyledTextSegments' field list - // (jsonNodeConversion.ts) — just wasn't threaded through until D23. + // (jsonNodeConversion.ts) and threaded straight through here. textStyleId: segment.textStyleId || undefined, }; }); @@ -417,7 +414,7 @@ const mapTextSegments = ( // Wrapped so a malformed/unexpected LineHeight or LetterSpacing shape // (e.g. from a node that isn't a real live Figma TEXT node, seen while -// testing against non-Auto-Layout content per D16) degrades to 0 instead +// testing against non-Auto-Layout content) degrades to 0 instead // of throwing and aborting the whole export. const safeLineHeight = (lineHeight: any, fontSize: number): number => { try { @@ -436,9 +433,8 @@ const safeLetterSpacing = (letterSpacing: any, fontSize: number): number => { /** * Recursively converts one converted (AltNode-shaped) tree into a Design - * Bundle `DesignNode` tree, per docs/03-design-bundle-schema-draft.md. - * Mutates `assets` and `styles` as it walks, collecting exactly what D9/D13 - * require: an assets manifest for IMAGE/VECTOR leaves, and a resolved + * Bundle `DesignNode` tree. Mutates `assets` and `styles` as it walks, + * collecting an assets manifest for IMAGE/VECTOR leaves, and a resolved * colors dictionary for anything bound to a Figma variable. */ export const buildDesignNode = ( @@ -446,7 +442,7 @@ export const buildDesignNode = ( assets: DesignBundleAsset[], styles: DesignBundleStyles, parentLayoutMode: string | undefined, - // D47: this node's index among its original parent's children (Figma's + // This node's index among its original parent's children (Figma's // paint/z-order — see the `paintOrder` field doc in types.ts). Only the // recursive call site below passes this; the root call // (designBundleMain.ts) omits it, since a `designs[].root` entry has no @@ -472,9 +468,10 @@ export const buildDesignNode = ( height: sizingValue(node.layoutSizingVertical, node.height), }, }; - // D59: Figma's Auto Layout wrap — `NO_WRAP` (the default) is never - // recorded, matching D55's convention for default-valued fields. - // `counterAxisSpacing` (row gap) only has real meaning when wrap is on. + // Figma's Auto Layout wrap — `NO_WRAP` (the default) is never + // recorded, matching this schema's general convention for + // default-valued fields. `counterAxisSpacing` (row gap) only has real + // meaning when wrap is on. if (node.layoutWrap === "WRAP") { layout.wrap = true; if (typeof node.counterAxisSpacing === "number") { @@ -488,8 +485,8 @@ export const buildDesignNode = ( // The first version of this check only looked at the parent's overall // mode and silently dropped x/y for absolutely-positioned children of an // auto-layout frame — caught by a synthetic "decorative blob inside a - // vertical form" fixture during Phase 2 (see decisions log D18). Root - // designs[] entries have no parent, so position is always included there. + // vertical form" fixture. Root designs[] entries have no parent, so + // position is always included there. const isAbsoluteInAutoLayout = node.layoutPositioning === "ABSOLUTE"; if ( parentLayoutMode === undefined || @@ -509,22 +506,22 @@ export const buildDesignNode = ( layout, style: mapStyle(node, styles), children: [], - // D47: index within *this specific call's* parent — i.e. relative to - // whatever `node`'s immediate parent was at the point Stage 1 walked - // it. Never a global/whole-tree counter. That single, uniform rule is - // what makes this work correctly both for a Template Part's own - // internal children (e.g. a header's logo/nav/button get 0/1/2, - // relative to the header — correct regardless of which design the - // header came from, or how many designs reuse the same header) *and* - // for the "socket" case (the header node itself, as it sits in one - // specific design's root.children, carries its own paintOrder equal - // to its index in *that* design's root — the exact value Stage 2 - // needs to remember where the header used to sit once it extracts - // that node out of the array entirely). + // Index within *this specific call's* parent — i.e. relative to + // whatever `node`'s immediate parent was at the point this walk + // reached it. Never a global/whole-tree counter. That single, uniform + // rule is what makes this work correctly both for a repeated + // component's own internal children (e.g. a header's logo/nav/button + // get 0/1/2, relative to the header — correct regardless of which + // design the header came from, or how many designs reuse the same + // header) *and* for the case where the header node itself, as it sits + // in one specific design's root.children, carries its own paintOrder + // equal to its index in *that* design's root — the value a downstream + // consumer needs to remember where the header used to sit if it ever + // extracts that node out of the array entirely. paintOrder: siblingIndex, }; - // D22: capture Figma's main-component id, independent of what `type` + // Capture Figma's main-component id, independent of what `type` // above collapsed to. Already present on the REST-v1 JSON export this // whole tree is built from (api_types.ts's InstanceNode shape) — no // extra Figma API call required. @@ -541,8 +538,8 @@ export const buildDesignNode = ( // component is actually defined, not just instanced) holds the real // COMPONENT node, not an INSTANCE — without this, that page's // header/footer wouldn't group with every other page's instances of - // the same component, breaking D22's cross-design majority vote for - // exactly the one design that matters most for defining the part. + // the same component, breaking cross-design grouping for exactly the + // one design that matters most for defining the part. if (node.type === "INSTANCE" && typeof node.componentId === "string") { designNode.componentId = node.componentId; } else if ( @@ -553,11 +550,10 @@ export const buildDesignNode = ( } if (type === "TEXT") { - // D55: only CENTER/RIGHT/JUSTIFIED are ever recorded — LEFT (Figma's + // Only CENTER/RIGHT/JUSTIFIED are ever recorded — LEFT (Figma's // most common default) is deliberately omitted rather than captured - // as an explicit "LEFT" value, matching Stage 2's existing convention - // of never emitting a CSS declaration for a value that's already the - // browser default. + // as an explicit "LEFT" value, matching this schema's general + // convention of never emitting a value that's already the default. const align = node.textAlignHorizontal === "CENTER" || node.textAlignHorizontal === "RIGHT" || @@ -568,7 +564,7 @@ export const buildDesignNode = ( } if (type === "IMAGE" || type === "VECTOR") { - // D63: reuse an already-registered asset for the same master-component + // Reuse an already-registered asset for the same master-component // node, rather than re-exporting/re-registering an identical copy for // every Instance. See assetIdentityKeyFor's doc comment. const identityKey = assetIdentityKeyFor(node.id); @@ -594,24 +590,24 @@ export const buildDesignNode = ( assetIdentityMap.set(identityKey, asset); } designNode.assetRef = assetId; - // IMAGE/VECTOR nodes are treated as leaves — matches the schema draft's - // examples, and avoids emitting redundant child markup for content - // Stage 2 would just discard in favor of the exported asset. + // IMAGE/VECTOR nodes are treated as leaves — matches the schema's own + // examples, and avoids emitting redundant child markup for content a + // downstream consumer would just discard in favor of the exported asset. return designNode; } - // D51: this node stayed a FRAME/RECTANGLE (not collapsed to a leaf IMAGE - // above) specifically because it has real children — classifyNodeType's - // whole D18 fix. That means it can still have its own image fill sitting - // *behind* those children (a photographic hero background behind an - // overlay + heading text, the motivating real case), which style.fills - // never captures (SOLID/GRADIENT only). Registered as a distinct asset - // kind — `imageHash` set, not `figmaNodeId`-exportable the normal way — - // since there's no API to export just this one fill in isolation from a - // node that also has other content painted on top of it. + // This node stayed a FRAME/RECTANGLE (not collapsed to a leaf IMAGE + // above) specifically because it has real children — see + // classifyNodeType above. That means it can still have its own image + // fill sitting *behind* those children (a photographic hero background + // behind an overlay + heading text, the motivating real case), which + // style.fills never captures (SOLID/GRADIENT only). Registered as a + // distinct asset kind — `imageHash` set, not `figmaNodeId`-exportable the + // normal way — since there's no API to export just this one fill in + // isolation from a node that also has other content painted on top of it. const backgroundFill = findImageFill(node); if (backgroundFill && typeof backgroundFill.imageRef === "string") { - // D63: same identity-based dedup as the leaf IMAGE/VECTOR branch above + // Same identity-based dedup as the leaf IMAGE/VECTOR branch above // — a repeated component instance's own background-image fill (e.g. a // Frame background inside a duplicated header/footer) shouldn't be // re-registered per Instance either. diff --git a/packages/backend/src/designBundle/designBundleUtils.ts b/packages/backend/src/designBundle/designBundleUtils.ts index 5e341187..a2025f03 100644 --- a/packages/backend/src/designBundle/designBundleUtils.ts +++ b/packages/backend/src/designBundle/designBundleUtils.ts @@ -1,8 +1,8 @@ // Figma's plugin sandbox does not provide the `TextEncoder` global (it's a // restricted JS environment, not a browser or Node) — confirmed at runtime -// via `TextEncoder is not defined` when exporting SVG assets during Phase 2 -// testing. Every place that needs UTF-8 bytes from a string must go through -// this manual fallback rather than assuming `TextEncoder` exists. +// via `TextEncoder is not defined` when exporting SVG assets. Every place +// that needs UTF-8 bytes from a string must go through this manual +// fallback rather than assuming `TextEncoder` exists. export const encodeUtf8Text = (text: string): Uint8Array => { if (typeof TextEncoder !== "undefined") { return new TextEncoder().encode(text); diff --git a/packages/backend/src/designBundle/designBundleZip.ts b/packages/backend/src/designBundle/designBundleZip.ts index cef564a8..125ddb51 100644 --- a/packages/backend/src/designBundle/designBundleZip.ts +++ b/packages/backend/src/designBundle/designBundleZip.ts @@ -5,8 +5,8 @@ import { encodeUtf8Text as encodeText } from "./designBundleUtils"; /** * Packages a Design Bundle as a zip: `design-bundle.json` at the root plus - * an `assets/` folder, matching the on-disk layout documented in - * docs/03-design-bundle-schema-draft.md's "Asset handling" section. + * an `assets/` folder containing every exported raster/vector asset, + * referenced from the manifest by relative path. */ export const generateDesignBundleZip = ( bundle: DesignBundle, diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 5b3401fc..fc68ad18 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -100,30 +100,26 @@ export type ProjectDownloadErrorMessage = Message & { error: string; }; -// Design Bundle (Phase 2 — Stage 1 extraction output) -// See docs/03-design-bundle-schema-draft.md in the project knowledge base -// (Design Bundle v1, revised per D14/D15/D17) for the authoritative shape. -// designs[] and DesignNode below are the runtime types this fork's -// serializer produces; keep them in sync with that doc when either changes. +// Design Bundle schema. designs[] and DesignNode below are the runtime +// types the serializer in packages/backend/src/designBundle/ produces — +// keep them in sync with that code when either changes. export type DesignBundleFillType = "SOLID" | "GRADIENT" | "OTHER"; -// D69 (Phase 5 gradients): the three gradient kinds CSS has a native -// equivalent for. Figma's fourth kind, GRADIENT_DIAMOND, has no CSS -// equivalent (`conic-gradient()` can't reproduce its four-quadrant -// shape) and stays out of scope per Sean's explicit call — a -// DIAMOND-kind paint still gets `DesignBundleFill.hex` (its first -// stop's color, same fallback every gradient kind gets) but no -// `gradient` field, so Stage 2 renders it as a flat color, same -// "narrower gap, logged not fixed" treatment as D18's background-image -// limitation. +// The three gradient kinds CSS has a native equivalent for. Figma's +// fourth kind, GRADIENT_DIAMOND, has no CSS equivalent +// (`conic-gradient()` can't reproduce its four-quadrant shape) and is +// deliberately out of scope — a DIAMOND-kind paint still gets +// `DesignBundleFill.hex` (its first stop's color, same fallback every +// gradient kind gets) but no `gradient` field, so a downstream consumer +// renders it as a flat color instead. export type DesignBundleGradientKind = "LINEAR" | "RADIAL" | "ANGULAR"; export interface DesignBundleGradientStop { // 8-digit #RRGGBBAA — this stop's own color with its alpha already // combined with the gradient paint's overall `opacity` slider (same - // "collapse at Stage 1, one number in, one number out" precedent as - // DesignBundleFill.opacity below / D46), so Stage 2 never needs a - // separate opacity pass for gradient stops. + // one-number-in-one-number-out treatment as DesignBundleFill.opacity + // below), so a downstream consumer never needs a separate opacity + // pass for gradient stops. hex: string; // 0-1 position along the gradient axis (Figma's own ColorStop.position). position: number; @@ -134,15 +130,13 @@ export interface DesignBundleGradient { stops: DesignBundleGradientStop[]; // Figma's own raw `gradientHandlePositions` (REST API v1 / Plugin API // shape), normalized 0-1 within the node's own bounding box, carried - // through unconverted rather than pre-baked into a CSS angle/radius at - // Stage 1 — the actual trig lives in Stage 2 (`styleHelpers.ts`'s - // `gradientToCss`, ported from this fork's existing - // `html/builderImpl/htmlColor.ts` linear/radial/angular math) so a - // future non-CSS Gen 2 target isn't stuck consuming a - // WordPress-specific number. Meaning depends on `kind`: 2 handles - // (start, end) for LINEAR; 3 (center, x-axis handle, y-axis handle) - // for RADIAL; 3 (center, unused, start-direction handle) for ANGULAR — - // matches Figma's own `gradientHandlePositions` doc comment. + // through unconverted rather than pre-baked into a CSS angle/radius — + // computing the angle/radius from these handles is left to whatever + // consumes the bundle, so it isn't locked into a CSS-specific + // representation. Meaning depends on `kind`: 2 handles (start, end) + // for LINEAR; 3 (center, x-axis handle, y-axis handle) for RADIAL; 3 + // (center, unused, start-direction handle) for ANGULAR — matches + // Figma's own `gradientHandlePositions` doc comment. handles: Array<{ x: number; y: number }>; } @@ -150,14 +144,14 @@ export interface DesignBundleFill { type: DesignBundleFillType; hex?: string; variableRef?: string; - // D46: this fill's own *combined* opacity — Figma's `paint.color.a` - // (alpha baked into the color itself) and `paint.opacity` (the paint's + // This fill's own *combined* opacity — Figma's `paint.color.a` (alpha + // baked into the color itself) and `paint.opacity` (the paint's // separate "opacity" slider) are two distinct fields that blend // together (Figma's own doc comment on Paint.opacity: "colors within // the paint can also have opacity values which would blend with - // this"), so they're collapsed into one number here at Stage 1 rather - // than carried as two — there's no meaningful reason for a Stage 2 - // consumer to ever want them separately, they represent the same + // this"), so they're collapsed into one number here rather than + // carried as two — there's no meaningful reason for a consumer to + // ever want them separately, they represent the same // "how see-through is this fill" concept. Omitted (undefined) when // fully opaque (1), matching this schema's existing sparse-field // convention (e.g. `layout.position`). Deliberately NOT collapsed @@ -167,8 +161,8 @@ export interface DesignBundleFill { // carries its own combined alpha (see DesignBundleGradientStop.hex // above), so there's no single opacity number left to apply on top. opacity?: number; - // D69: present only when `type === "GRADIENT"` and Figma's paint kind - // is one of the three CSS can represent (LINEAR/RADIAL/ANGULAR). + // Present only when `type === "GRADIENT"` and Figma's paint kind is + // one of the three CSS can represent (LINEAR/RADIAL/ANGULAR). // DIAMOND-kind (and any future unrecognized gradient kind) omits this // and falls back to `hex` only. gradient?: DesignBundleGradient; @@ -183,23 +177,21 @@ export interface DesignBundleEffect { y?: number; blur?: number; hex?: string; - // D70 (Phase 5 shadows/effects): DROP_SHADOW/INNER_SHADOW only — Figma's - // own `spread` (expands a drop shadow / contracts an inner shadow; - // undefined defaults to 0, same as Figma's own default). Maps directly - // to CSS box-shadow's spread-radius value with no conversion — the - // sign/growth semantics already match (D70's log entry has the detail). + // DROP_SHADOW/INNER_SHADOW only — Figma's own `spread` (expands a drop + // shadow / contracts an inner shadow; undefined defaults to 0, same as + // Figma's own default). Maps directly to CSS box-shadow's + // spread-radius value with no conversion — the sign/growth semantics + // already match. spread?: number; } -// D72 (Phase 5 blend modes, last of three long-tail items): the 13 of -// Figma's 18 blend modes CSS `mix-blend-mode` has a native keyword for — -// a plain kebab-case rename in every case (MULTIPLY -> "multiply", etc.). -// PASS_THROUGH/NORMAL are deliberately absent: both mean "no blending," -// so `DesignBundleNodeStyle.blendMode` is left undefined for them rather -// than modeled as a value (same sparse-field convention as `opacity`). -// LINEAR_BURN and LINEAR_DODGE are also absent — CSS has no equivalent -// (they're a different blend formula than color-burn/color-dodge, not -// just a naming difference) — same "narrower gap, logged not fixed" -// precedent as D18/D69's GRADIENT_DIAMOND. +// The 13 of Figma's 18 blend modes CSS `mix-blend-mode` has a native +// keyword for — a plain kebab-case rename in every case (MULTIPLY -> +// "multiply", etc.). PASS_THROUGH/NORMAL are deliberately absent: both +// mean "no blending," so `DesignBundleNodeStyle.blendMode` is left +// undefined for them rather than modeled as a value (same sparse-field +// convention as `opacity`). LINEAR_BURN and LINEAR_DODGE are also +// absent — CSS has no equivalent (they're a different blend formula +// than color-burn/color-dodge, not just a naming difference). export type DesignBundleBlendMode = | "multiply" | "screen" @@ -222,7 +214,7 @@ export interface DesignBundleNodeStyle { strokes: DesignBundleStroke[]; cornerRadius: number; effects: DesignBundleEffect[]; - // D46: the *node's own* layer opacity (Figma's `node.opacity`, the + // The *node's own* layer opacity (Figma's `node.opacity`, the // "Opacity" field in the right-hand panel for the whole layer) — // distinct from any individual fill's opacity above. This affects the // node's entire rendered result as a group: background, strokes, text, @@ -234,7 +226,7 @@ export interface DesignBundleNodeStyle { // `opacity` on the node's own wrapping element, not a color-channel // adjustment. Omitted (undefined) when fully opaque (1). opacity?: number; - // D72: the *node's own* Blending mode (Figma's `node.blendMode`, same + // The *node's own* Blending mode (Figma's `node.blendMode`, same // right-hand-panel struct as `opacity` above, `HasBlendModeAndOpacityTrait` // in the REST API v1 shape) — scoped deliberately to this one node-level // field, not per-fill or per-effect blend modes (Figma also allows a @@ -253,18 +245,19 @@ export interface DesignBundleLayout { gap: number; padding: { top: number; right: number; bottom: number; left: number }; sizing: { width: DesignBundleSizeValue; height: DesignBundleSizeValue }; - // Populated only when the *parent* frame's layout.mode is "NONE" (i.e. the - // parent uses absolute positioning) — see D18 in the decisions log for why - // this diverges from a literal reading of the schema draft. + // Populated only when the *parent* frame's layout.mode is "NONE" (i.e. + // the parent uses absolute positioning) — coordinates are meaningless + // outside that case, since Auto Layout computes a child's position + // itself. position?: { x: number; y: number }; - // D59: Figma's Auto Layout "wrap" (`layoutWrap: "WRAP"`) — a real, - // distinct layout mechanism from `position` above, found via the - // Product Detail page's related-products grid: six fixed-width cards - // in a fixed-width HORIZONTAL container, with no absolute positioning - // at all (initially mistaken for one — see D58 — before Sean traced - // the real Figma mechanism directly). CSS's `flex-wrap: wrap` is the - // literal equivalent; only ever true, mirroring D55's convention of - // never recording the non-default case (`NO_WRAP`) explicitly. + // Figma's Auto Layout "wrap" (`layoutWrap: "WRAP"`) — a real, distinct + // layout mechanism from `position` above; a wrapped, fixed-width + // HORIZONTAL container can look identical to an absolutely-positioned + // one at a glance, so this is captured as its own explicit field + // rather than inferred. CSS's `flex-wrap: wrap` is the literal + // equivalent. Only ever `true` — the non-default case (`NO_WRAP`) is + // never recorded explicitly, matching this schema's usual + // default-omission convention. wrap?: boolean; // Figma's `counterAxisSpacing` — the gap between wrapped *rows/tracks*, // distinct from `gap` above (which is the item gap along the main @@ -286,7 +279,7 @@ export interface DesignBundleTextSegment { textDecoration: string; // Figma's named text style id for this run, when the run has one applied. // Resolves via bundle.styles.textStyles[textStyleId] -> DesignBundleTextStyle. - // Populated per D23 — the primary heading/paragraph signal Stage 2 uses, + // The primary heading/paragraph signal for a downstream consumer, // ahead of the fontSize/fontWeight fallback heuristic. textStyleId?: string; // Text fill color. `fillHex` is always populated when the run has a @@ -298,7 +291,7 @@ export interface DesignBundleTextSegment { // designBundleTree.ts) rather than introducing a different shape. fillHex?: string; fillRef?: string; - // D46: mirrors DesignBundleFill.opacity (same combined color.a * paint.opacity + // Mirrors DesignBundleFill.opacity (same combined color.a * paint.opacity // calculation, via the same mapFill/fillOpacity path) — a text run's own // fill can be translucent same as any other fill. Omitted when opaque. fillOpacity?: number; @@ -310,67 +303,58 @@ export interface DesignNode { type: DesignNodeType; layout: DesignBundleLayout; style: DesignBundleNodeStyle; - // D55: Figma's `textAlignHorizontal`, node-level (not per-run — Figma - // models horizontal alignment as a property of the whole TEXT node, not - // individual styled runs, unlike fontFamily/fontSize/etc. above). - // Omitted entirely — not just set to "LEFT" — when Figma's own value is - // "LEFT", since that's the CSS default and Stage 2 skips emitting a - // redundant `text-align: left` the same way it already skips other - // default-valued declarations elsewhere. This project's Design Bundle - // schema never captured this at all before D55 — confirmed via direct - // code search, not assumed — a genuine, previously-latent capture gap, - // not a regression from any prior Phase 5 fix. + // Figma's `textAlignHorizontal`, node-level (not per-run — Figma + // models horizontal alignment as a property of the whole TEXT node, + // not individual styled runs, unlike fontFamily/fontSize/etc. above). + // Omitted entirely — not just set to "LEFT" — when Figma's own value + // is "LEFT", since that's the CSS default and there's no reason to + // emit a redundant `text-align: left`. text?: { segments: DesignBundleTextSegment[]; align?: "CENTER" | "RIGHT" | "JUSTIFIED" }; assetRef?: string; // Figma's main-component id, present when this node was originally an - // INSTANCE (already available synchronously on the REST-v1 JSON export - // Stage 1 already uses — no extra API call needed). Populated regardless + // INSTANCE (already available synchronously on the REST API v1 JSON + // export this uses — no extra API call needed). Populated regardless // of what `type` above collapses to (INSTANCE always maps to FRAME/ // RECTANGLE here, same as any other frame — see classifyNodeType). - // Used by Stage 2 (D22) to identify header/footer Template Part - // candidates via real component identity rather than layer-name matching - // (which D14 already rejected as too fragile). + // Lets a downstream consumer recognize repeated instances of the same + // component by real identity rather than falling back to fragile + // layer-name matching. componentId?: string; - // D47: this node's index among its original parent's children, at the - // point Stage 1 walked the tree — i.e. Figma's own paint/z-order - // (confirmed repeatedly this project: `children[]` array order *is* - // paint order, not visual position — see D35/D43). Captured as an - // explicit field, independent of this node's *current* position in any - // `children[]` array, specifically so it survives a node being pulled - // out of that array entirely — the header/footer Template Part - // extraction case (`classifyTemplateParts`/`pruneTemplatePartChildren` - // in Stage 2's `templateParts.ts`/`generateThemeFiles.ts`), where a - // node that used to be "child 3 of the root" becomes the independent - // root of its own separate render context and has no `children[]` - // membership at all to infer order from anymore. Without this, Stage 2 - // has no way to know a header was originally *above or below* some - // other now-unrelated sibling in paint order once they're split into - // separate template files (D45's punted header/hero overlap case). - // Root `designs[].root` entries have no real parent/siblings within the - // bundle, so this is omitted (undefined) there — same convention as - // `layout.position` being root-conditional. + // This node's index among its original parent's children at the point + // the tree was walked — i.e. Figma's own paint/z-order (`children[]` + // array order is paint order, not visual position). Captured as an + // explicit field, independent of this node's *current* position in + // any `children[]` array, so it survives a node being pulled out of + // that array entirely and re-rooted elsewhere — a downstream consumer + // that reorganizes the tree (e.g. lifting a repeated header/footer out + // into its own reusable unit) otherwise has no way to know whether + // that node was originally above or below some other, now-unrelated + // sibling in paint order once they're split apart. + // Root `designs[].root` entries have no real parent/siblings within + // the bundle, so this is omitted (undefined) there — same convention + // as `layout.position` being root-conditional. // // Deliberately a plain ordinal (0 = painted first/bottommost in normal - // top-down z stacking), not a pre-computed CSS z-index — keeping Stage 2 - // free to decide its own sign/offset convention (e.g. `z-index: - // {paintOrder}` or `-{paintOrder}`) rather than baking a - // WordPress/CSS-specific decision into the target-neutral bundle (D17). + // top-down z stacking), not a pre-computed CSS z-index — leaving a + // downstream consumer free to decide its own sign/offset convention + // (e.g. `z-index: {paintOrder}` or `-{paintOrder}`) rather than baking + // a CSS-specific decision into this target-neutral bundle. paintOrder?: number; - // D51: a FRAME/RECTANGLE's own background *image* fill — distinct from + // A FRAME/RECTANGLE's own background *image* fill — distinct from // `assetRef` (leaf IMAGE/VECTOR nodes, where the exported asset *is* // the node's entire visual content) and distinct from `style.fills` // (which only ever models SOLID/GRADIENT paints, never IMAGE — see - // `classifyNodeType`'s doc comment in designBundleTree.ts, D18). A node + // `classifyNodeType`'s doc comment in designBundleTree.ts). A node // with both an image fill *and* real children stays a FRAME so its - // children survive as separate, editable content (D18's fix), but that - // left the background image itself uncaptured entirely — confirmed as - // a real, concrete gap on a real bundle: a "Dimmer" overlay (D44) sits - // in front of a photographic hero background that never made it into - // the bundle at all. Resolves the same way `assetRef` does — via - // `bundle.assets[]`, keyed by this id — Stage 2 renders it as a CSS - // `background-image`, layered under any `style.fills` background-color - // (and under any real children rendered on top, same as Figma's own - // paint order for this exact configuration). + // children survive as separate, editable content, but that leaves the + // background image itself needing its own place to live — e.g. an + // overlay frame sitting in front of a photographic hero background + // that would otherwise never make it into the bundle at all. Resolves + // the same way `assetRef` does — via `bundle.assets[]`, keyed by this + // id — a downstream consumer renders it as a CSS `background-image`, + // layered under any `style.fills` background-color (and under any + // real children rendered on top, same as Figma's own paint order for + // this exact configuration). backgroundAssetRef?: string; children: DesignNode[]; } @@ -381,18 +365,17 @@ export interface DesignBundleAsset { kind: "raster" | "vector"; width: number; height: number; - // D51: present only for a background-image asset (referenced via a + // Present only for a background-image asset (referenced via a // DesignNode's `backgroundAssetRef`, not `assetRef`). Figma has no API // to export "just this one fill" from a node that also has other // visual content (children) painted on top of it — calling the usual // `node.exportAsync()` on the *containing* frame would flatten those - // children into the raster too, which is exactly what D18 fixed by - // keeping such a frame's children as separate, real content instead of - // a flattened image. `imageHash` is the paint's own image reference - // (Figma REST API v1 calls this `imageRef`; the Plugin API's - // `getImageByHash` accepts the same underlying value) — resolving the - // fill's raw bytes directly, independent of whatever else the - // containing node renders. + // children into the raster too, which is exactly why such a frame + // keeps its children as separate, real content instead of a flattened + // image. `imageHash` is the paint's own image reference (Figma REST + // API v1 calls this `imageRef`; the Plugin API's `getImageByHash` + // accepts the same underlying value) — resolving the fill's raw bytes + // directly, independent of whatever else the containing node renders. imageHash?: string; } export interface DesignBundleColorStyle { From 7ce92387f9b717b6be4025387557a18ab9ce8beb Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Wed, 19 Aug 2026 22:47:29 -0600 Subject: [PATCH 5/5] Address CodeRabbit review feedback on PR #263 --- .../src/altNodes/jsonNodeConversion.ts | 21 ++++-- .../src/designBundle/designBundleAssets.ts | 66 +++++++++++++++--- .../src/designBundle/designBundleMain.ts | 69 ++++++++++++++++--- .../src/designBundle/designBundleTree.ts | 45 ++++++++++-- .../src/designBundle/designBundleUtils.ts | 15 ++-- packages/types/src/types.ts | 8 +++ 6 files changed, 187 insertions(+), 37 deletions(-) diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts index 01311b8a..671cd56d 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -345,12 +345,19 @@ const processNodePair = async ( // Layout of its own, so whatever arrangement its children had (e.g. // two buttons placed side by side) exists only via their raw x/y — // once the GROUP node itself is discarded here, that arrangement - // has no other representation. Mark each resulting node - // `layoutPositioning: "ABSOLUTE"` so designBundleTree.ts's existing - // `isAbsoluteInAutoLayout` escape hatch (built for a real Figma - // per-child "position absolutely" override) also captures inlined - // former-GROUP children, instead of silently letting them fall into - // the new parent's normal Auto Layout flow. Their x/y were already + // has no other representation. Mark each resulting node with a + // bundle-only `inlinedFromGroup` flag rather than reusing the real + // `layoutPositioning: "ABSOLUTE"` field: this conversion path is + // shared by every codegen target (HTML, Tailwind, Flutter, SwiftUI, + // Compose), and `layoutPositioning` feeds real per-target behavior + // there (see `common/commonPosition.ts`'s `commonIsAbsolutePosition`, + // and the Flutter/Compose backends) as well as this file's own + // `adjustChildrenOrder`/`isRelative` checks below — stamping it here + // would silently change output for every target, not just the + // Design Bundle. `designBundleTree.ts`'s `isAbsoluteInAutoLayout` + // check reads this bundle-only flag in addition to the real field, + // so only the Design Bundle path captures inlined former-GROUP + // children as explicitly positioned. Their x/y were already // computed above relative to `parentNode` (the group's own parent, // not the discarded group), via the absoluteBoundingBox diff — so // no coordinate rebasing is needed here, only the flag. @@ -359,7 +366,7 @@ const processNodePair = async ( ? processedChild : [processedChild]; for (const resultNode of resultNodes) { - (resultNode as any).layoutPositioning = "ABSOLUTE"; + (resultNode as any).inlinedFromGroup = true; } processedChildren.push(...resultNodes); } diff --git a/packages/backend/src/designBundle/designBundleAssets.ts b/packages/backend/src/designBundle/designBundleAssets.ts index dbc73253..e4d8cfdc 100644 --- a/packages/backend/src/designBundle/designBundleAssets.ts +++ b/packages/backend/src/designBundle/designBundleAssets.ts @@ -7,6 +7,32 @@ export interface ExportedDesignBundleAsset { bytes: Uint8Array; } +export interface DesignBundleAssetExportResult { + exported: ExportedDesignBundleAsset[]; + // Ids (DesignBundleAsset.id) of assets that failed to export — a missing + // node, a getImageByHash miss, or a thrown exportAsync/getBytesAsync call. + // `buildDesignBundle` (designBundleMain.ts) uses this to drop the asset + // from the manifest's `assets[]` (and any DesignNode.assetRef/ + // backgroundAssetRef pointing at it) so `design-bundle.json` never + // references a file that doesn't actually exist in the zip's /assets — + // previously a failed export was only ever logged as a warning, leaving + // the dangling reference in place. + failedAssetIds: string[]; +} + +// Shared with designBundleTree.ts so the manifest's `DesignBundleAsset.scale` +// field always matches the constraint actually passed to `exportAsync` +// below, rather than a second hardcoded "2" drifting out of sync with it. +export const DESIGN_BUNDLE_RASTER_SCALE = 2; + +// Caps how many assets are exported concurrently. Fully sequential export +// makes total time grow linearly with selection size for no benefit — each +// `exportAsync`/`getBytesAsync` call is an independent round trip through +// Figma's renderer, not CPU-bound work competing for the same resource, so a +// small in-flight limit shortens wall-clock time on large selections without +// the unbounded memory/scheduling cost of firing every export at once. +const ASSET_EXPORT_CONCURRENCY = 4; + /** * Explicit Images-API asset export. FigmaToCode's default codegen path * leaves image `src` as placehold.co placeholders and never calls @@ -18,13 +44,16 @@ export interface ExportedDesignBundleAsset { * Raster (IMAGE) nodes export as PNG at 2x. Vector * (VECTOR/STAR/POLYGON/BOOLEAN_OPERATION/LINE) nodes export as SVG so a * downstream consumer can inline them directly instead of rasterizing. + * Exports run with bounded concurrency (see ASSET_EXPORT_CONCURRENCY) rather + * than one at a time. */ export const exportDesignBundleAssets = async ( assets: DesignBundleAsset[], -): Promise => { +): Promise => { const exported: ExportedDesignBundleAsset[] = []; + const failedAssetIds: string[] = []; - for (const asset of assets) { + const exportOne = async (asset: DesignBundleAsset): Promise => { // A background-image asset (DesignNode.backgroundAssetRef, not // assetRef) carries `imageHash` instead — resolved via // `figma.getImageByHash`, not `node.exportAsync()`. The containing @@ -44,7 +73,8 @@ export const exportDesignBundleAssets = async ( addWarning( `Could not export background-image asset (${asset.fileName}) — image hash ${asset.imageHash} not found.`, ); - continue; + failedAssetIds.push(asset.id); + return; } const bytes = await image.getBytesAsync(); exported.push({ fileName: asset.fileName, bytes }); @@ -54,8 +84,9 @@ export const exportDesignBundleAssets = async ( error instanceof Error ? error.message : String(error) }`, ); + failedAssetIds.push(asset.id); } - continue; + return; } const figmaNode = (await figma.getNodeByIdAsync( @@ -66,7 +97,8 @@ export const exportDesignBundleAssets = async ( addWarning( `Could not export asset for node ${asset.figmaNodeId} (${asset.fileName}) — node missing or not exportable.`, ); - continue; + failedAssetIds.push(asset.id); + return; } try { @@ -79,7 +111,7 @@ export const exportDesignBundleAssets = async ( } else { const bytes = await figmaNode.exportAsync({ format: "PNG", - constraint: { type: "SCALE", value: 2 }, + constraint: { type: "SCALE", value: DESIGN_BUNDLE_RASTER_SCALE }, }); exported.push({ fileName: asset.fileName, bytes }); } @@ -89,8 +121,26 @@ export const exportDesignBundleAssets = async ( error instanceof Error ? error.message : String(error) }`, ); + failedAssetIds.push(asset.id); + } + }; + + // Simple bounded worker pool: each of up to ASSET_EXPORT_CONCURRENCY + // workers pulls the next asset off a shared cursor and exports it, so at + // most that many exports are ever in flight at once. `exported`/ + // `failedAssetIds` are mutated by `exportOne` directly rather than + // collected per-worker, since downstream consumption (designBundleMain.ts, + // generateDesignBundleZip) keys off `fileName`/`asset.id`, not array order. + let nextIndex = 0; + const worker = async (): Promise => { + while (nextIndex < assets.length) { + const asset = assets[nextIndex]; + nextIndex += 1; + await exportOne(asset); } - } + }; + const workerCount = Math.min(ASSET_EXPORT_CONCURRENCY, assets.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); - return exported; + return { exported, failedAssetIds }; }; diff --git a/packages/backend/src/designBundle/designBundleMain.ts b/packages/backend/src/designBundle/designBundleMain.ts index 56ab5f64..6d4561a9 100644 --- a/packages/backend/src/designBundle/designBundleMain.ts +++ b/packages/backend/src/designBundle/designBundleMain.ts @@ -1,4 +1,4 @@ -import { DesignBundle, DesignBundleAsset, DesignBundleStyles, PluginSettings } from "types"; +import { DesignBundle, DesignBundleAsset, DesignBundleStyles, DesignNode, PluginSettings } from "types"; import { nodesToJSON } from "../altNodes/jsonNodeConversion"; import { addWarning, clearWarnings, warnings } from "../common/commonConversionWarnings"; import { buildDesignNode, resetDesignBundleTreeState } from "./designBundleTree"; @@ -6,6 +6,25 @@ import { collectTextStyleIds, resolveTextStyles } from "./designBundleTextStyles import { exportDesignBundleAssets } from "./designBundleAssets"; import { generateDesignBundleZip } from "./designBundleZip"; +// Clears assetRef/backgroundAssetRef on any node pointing at an asset that +// failed to export (see exportDesignBundleAssets' failedAssetIds) — run +// after filtering those ids out of the manifest's assets[] so a design's +// nodes never reference an asset id that no longer appears anywhere in the +// bundle (the whole point of the failedAssetIds plumbing; filtering +// assets[] alone would just move the dangling reference from assets[] to +// designs[].root...children[]). +const clearFailedAssetRefs = (node: DesignNode, failedAssetIds: Set) => { + if (node.assetRef && failedAssetIds.has(node.assetRef)) { + delete node.assetRef; + } + if (node.backgroundAssetRef && failedAssetIds.has(node.backgroundAssetRef)) { + delete node.backgroundAssetRef; + } + for (const child of node.children ?? []) { + clearFailedAssetRefs(child, failedAssetIds); + } +}; + export const DESIGN_BUNDLE_SOURCE_TOOL = "FigmaToCode-fork/design-bundle@0.1.0"; const toKebab = (value: string) => @@ -51,25 +70,36 @@ export const buildDesignBundle = async ( // top-level GROUP gets inlined into multiple sibling nodes (see // jsonNodeConversion.ts) — a top-level GROUP breaks the otherwise // clean 1:1 mapping between selected layers and designs[] entries. - // Handled explicitly here (falling back to converted node names) - // rather than silently mismatching names below. + // Matched by node id below (rather than array index) so this doesn't + // silently pair a converted entry with the wrong original selection + // layer once the two arrays are out of step. console.warn( "[design-bundle] convertedSelection count does not match selection count " + - "(likely a top-level GROUP was inlined) — falling back to converted node names.", + "(likely a top-level GROUP was inlined) — matching by node id instead of index.", ); } + // Keyed by id so a converted entry is only ever paired with the + // selected layer it actually came from — an index-based lookup + // (`selection[index]`) silently drifts out of alignment as soon as one + // top-level GROUP expands into multiple entries, pairing every + // subsequent design with the wrong original layer's name instead of + // just failing to find one. + const selectionById = new Map(selection.map((s) => [s.id, s])); + const assets: DesignBundleAsset[] = []; const styles: DesignBundleStyles = { colors: {}, textStyles: {} }; - const designs = convertedSelection.map((node: any, index: number) => { - const originalNode = selection[index]; + const designs = convertedSelection.map((node: any) => { const root = buildDesignNode(node, assets, styles, undefined); + const originalNode = selectionById.get(root.id); return { figmaNodeId: root.id, // Raw, as-authored Figma layer name only — no slug/title. - // Falls back to the converted node's own name if the index-aligned - // original selection entry is unavailable (see mismatch note above). + // Falls back to the converted node's own name when no original + // selection entry shares this id (e.g. this design came from an + // inlined GROUP's child, which was never itself a top-level + // selection entry — see mismatch note above). layerName: originalNode?.name ?? node.name ?? root.uniqueName, root, }; @@ -88,7 +118,24 @@ export const buildDesignBundle = async ( // never surface these to the user. for (const w of textStyleWarnings) addWarning(w); - const exportedAssets = await exportDesignBundleAssets(assets); + const { exported: exportedAssets, failedAssetIds } = await exportDesignBundleAssets(assets); + + // Drop any asset that failed to export from the manifest — otherwise + // design-bundle.json lists an asset with no corresponding file in the + // zip's /assets (exportDesignBundleAssets already logged a warning for + // each one via addWarning). Also clear any assetRef/backgroundAssetRef + // in the design tree that pointed at one of these, so nothing in the + // manifest references a dropped id. + const failedAssetIdSet = new Set(failedAssetIds); + const finalAssets = + failedAssetIdSet.size > 0 + ? assets.filter((asset) => !failedAssetIdSet.has(asset.id)) + : assets; + if (failedAssetIdSet.size > 0) { + for (const design of designs) { + clearFailedAssetRefs(design.root, failedAssetIdSet); + } + } const bundle: DesignBundle = { schemaVersion: 1, @@ -101,7 +148,7 @@ export const buildDesignBundle = async ( sourceTool: "FigmaToCode-fork", }, designs, - assets, + assets: finalAssets, styles, }; @@ -116,7 +163,7 @@ export const buildDesignBundle = async ( zip, fileName, designCount: designs.length, - assetCount: assets.length, + assetCount: finalAssets.length, warnings: [...warnings], }; }; diff --git a/packages/backend/src/designBundle/designBundleTree.ts b/packages/backend/src/designBundle/designBundleTree.ts index bbfe2517..29fba629 100644 --- a/packages/backend/src/designBundle/designBundleTree.ts +++ b/packages/backend/src/designBundle/designBundleTree.ts @@ -12,6 +12,7 @@ import { DesignNodeType, } from "types"; import { commonLetterSpacing, commonLineHeight } from "../common/commonTextHeightSpacing"; +import { DESIGN_BUNDLE_RASTER_SCALE } from "./designBundleAssets"; // The tree produced by `nodesToJSON` (packages/backend/src/altNodes/jsonNodeConversion.ts) // is a standard Figma REST API v1 `Node` (packages/backend/src/api_types.ts) plus a handful @@ -356,16 +357,29 @@ const mapTextSegments = ( if (segments.length === 0) { // Fallback for nodes where per-run segmentation wasn't collected // (see jsonNodeConversion.ts — segments are only gathered when the - // source node's style actually varies at the run level). + // source node's style actually varies at the run level). `node.style` + // here is the raw REST API v1 `TypeStyle` (see jsonNodeConversion.ts — + // `Object.assign(jsonNode, jsonNode.style)` — `style` itself survives + // alongside the flattened copy), which does carry `lineHeightPx` + // (declared in api_types.ts) even though it isn't read elsewhere in + // this file — compute the same px-per-fontSize ratio the segmented + // path below uses instead of hardcoding 0, which silently dropped + // line-height for any text node without per-run style variation. const fallbackFill = mapFill(node.fills?.[0], styles); + const fallbackFontSize = node.style?.fontSize ?? 0; + const fallbackLineHeightPx = node.style?.lineHeightPx; + const fallbackLineHeight = + typeof fallbackLineHeightPx === "number" && fallbackFontSize > 0 + ? fallbackLineHeightPx / fallbackFontSize + : 0; return [ { uniqueId: `${uniqueName}_span`, characters: node.characters ?? "", fontFamily: node.style?.fontFamily ?? "", - fontSize: node.style?.fontSize ?? 0, + fontSize: fallbackFontSize, fontWeight: String(node.style?.fontWeight ?? "400"), - lineHeight: 0, + lineHeight: fallbackLineHeight, letterSpacing: node.style?.letterSpacing ?? 0, textCase: node.style?.textCase ?? "ORIGINAL", textDecoration: node.style?.textDecoration ?? "NONE", @@ -393,7 +407,13 @@ const mapTextSegments = ( const textFill = mapFill(segment.fills?.[0], styles); return { - uniqueId: `${uniqueName}_span_${index}`, + // The converter (jsonNodeConversion.ts) already assigns each segment a + // `uniqueId` — 1-based, zero-padded (`_span_01`, `_span_02`, ...) for + // multi-segment text, `_span` for a lone segment. Prefer that value + // over regenerating one here (0-based, unpadded) so the two don't + // disagree; only fall back to a freshly generated id if the segment + // somehow arrived without one. + uniqueId: segment.uniqueId ?? `${uniqueName}_span_${index}`, characters: segment.characters ?? "", fontFamily: segment.fontName?.family ?? segment.fontFamily ?? "", fontSize, @@ -487,7 +507,13 @@ export const buildDesignNode = ( // auto-layout frame — caught by a synthetic "decorative blob inside a // vertical form" fixture. Root designs[] entries have no parent, so // position is always included there. - const isAbsoluteInAutoLayout = node.layoutPositioning === "ABSOLUTE"; + // `inlinedFromGroup` is a Design-Bundle-only flag set by + // jsonNodeConversion.ts for children of an inlined GROUP (see its + // comment there) — kept separate from the real `layoutPositioning` + // field so this bundle-specific treatment doesn't leak into the other + // codegen targets that share that conversion path. + const isAbsoluteInAutoLayout = + node.layoutPositioning === "ABSOLUTE" || node.inlinedFromGroup === true; if ( parentLayoutMode === undefined || parentLayoutMode === "NONE" || @@ -584,6 +610,10 @@ export const buildDesignNode = ( kind: type === "IMAGE" ? "raster" : "vector", width: Math.round(node.width ?? 0), height: Math.round(node.height ?? 0), + // Only raster (PNG) exports have a fixed pixel scale relative to + // `width`/`height` above — see exportDesignBundleAssets. Vector + // (SVG) assets scale losslessly, so `scale` is left unset for those. + ...(type === "IMAGE" ? { scale: DESIGN_BUNDLE_RASTER_SCALE } : {}), }; assets.push(asset); if (identityKey) { @@ -618,6 +648,11 @@ export const buildDesignNode = ( } else { const fileName = nextAssetFileName(`${uniqueName}_bg`, "png"); const assetId = `asset_${String(assets.length + 1).padStart(2, "0")}`; + // Note: unlike the leaf IMAGE/VECTOR branch above, this asset is + // resolved via `figma.getImageByHash(...).getBytesAsync()` (see + // exportDesignBundleAssets), which returns the fill's own raw image + // bytes as-is — no `exportAsync` SCALE constraint is applied here, + // so `scale` is intentionally left unset rather than assumed to be 2x. const asset: DesignBundleAsset = { id: assetId, figmaNodeId: node.id, diff --git a/packages/backend/src/designBundle/designBundleUtils.ts b/packages/backend/src/designBundle/designBundleUtils.ts index a2025f03..e7bc8ee4 100644 --- a/packages/backend/src/designBundle/designBundleUtils.ts +++ b/packages/backend/src/designBundle/designBundleUtils.ts @@ -1,16 +1,19 @@ +import { strToU8 } from "fflate"; + // Figma's plugin sandbox does not provide the `TextEncoder` global (it's a // restricted JS environment, not a browser or Node) — confirmed at runtime // via `TextEncoder is not defined` when exporting SVG assets. Every place // that needs UTF-8 bytes from a string must go through this manual // fallback rather than assuming `TextEncoder` exists. +// +// The fallback uses `fflate`'s `strToU8` (already a dependency — see +// designBundleZip.ts's `zipSync` import — so this doesn't pull in anything +// new) instead of the old `unescape(encodeURIComponent(...))` trick, which +// relies on a deprecated global and does the same UTF-8-bytes-from-string +// job less directly. export const encodeUtf8Text = (text: string): Uint8Array => { if (typeof TextEncoder !== "undefined") { return new TextEncoder().encode(text); } - const utf8 = unescape(encodeURIComponent(text)); - const bytes = new Uint8Array(utf8.length); - for (let i = 0; i < utf8.length; i += 1) { - bytes[i] = utf8.charCodeAt(i); - } - return bytes; + return strToU8(text); }; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index fc68ad18..88c517c4 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -377,6 +377,14 @@ export interface DesignBundleAsset { // accepts the same underlying value) — resolving the fill's raw bytes // directly, independent of whatever else the containing node renders. imageHash?: string; + // Multiplier between this asset's `width`/`height` (the node's logical + // layout size) and the exported file's actual pixel dimensions. Raster + // (PNG) assets are exported at a fixed 2x scale (see + // `exportDesignBundleAssets` in designBundleAssets.ts) — without this, + // a downstream consumer has no way to know the PNG is 2x without + // decoding it and comparing dimensions itself. Omitted for vector (SVG) + // assets, which have no fixed pixel scale. + scale?: number; } export interface DesignBundleColorStyle { name: string;