From d0d1abaa10f4275e8b6a40ea97d672f66f36273c Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Wed, 19 Aug 2026 23:40:13 +0000 Subject: [PATCH 01/29] 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 02/29] 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 03/29] 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 04/29] 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 05/29] 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; From 8249755aa1d22765df45bfd6a15086dd84a7abbc Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Mon, 31 Aug 2026 20:01:15 +0000 Subject: [PATCH 06/29] feat: add WordPress target tab (UI shell) Add a WordPress tab to the framework selector, styled green per the UI spec (see project decision D115), with "WP Theme" and "Design Bundle" output-mode options plus a font-inclusion toggle. Both outputs are stubbed/non-functional this pass -- the download button is disabled with a "coming soon" tooltip, and the feedback panel shows explanatory placeholder text rather than fabricated numbers. Real generation is deferred: "Design Bundle" can reuse F2C's existing export-bundle logic, and "WP Theme" needs wp-figma-gen's generation pipeline ported into packages/backend -- both are separate follow-up work. Verified with pnpm install / pnpm build / pnpm lint, all clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014AHYgosynYxehFSW9sQPLR --- apps/plugin/plugin-src/code.ts | 10 + apps/web/next-env.d.ts | 1 + .../src/common/retrieveUI/convertToCode.ts | 6 + packages/plugin-ui/src/PluginUI.tsx | 65 +++-- .../plugin-ui/src/codegenPreferenceOptions.ts | 30 ++ .../plugin-ui/src/components/CodePanel.tsx | 57 +++- .../src/components/WordPressPanel.tsx | 79 ++++++ packages/types/src/types.ts | 30 +- pnpm-lock.yaml | 268 ++++++++++++++++-- 9 files changed, 480 insertions(+), 66 deletions(-) create mode 100644 packages/plugin-ui/src/components/WordPressPanel.tsx diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index eab6e36d..7f4ddd97 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -47,6 +47,12 @@ export const defaultPluginSettings: PluginSettings = { thresholdPercent: 15, baseFontFamily: "", fontFamilyCustomConfig: {}, + // Phase 9 (D115/D118): WordPress tab defaults -- "WP Theme" is the + // primary output (Design Bundle is the secondary, lower-visibility + // option per D115), and Include Fonts defaults checked per D115's + // original OE2 plan. + wpOutputMode: "theme", + wpIncludeFonts: true, }; // A helper type guard to ensure the key belongs to the PluginSettings type @@ -327,6 +333,10 @@ const downloadProject = async (format: DownloadProjectFormat) => { const pluginSettings = { ...userPluginSettings }; if ( pluginSettings.framework === "Compose" || + // Phase 9: WordPress's two outputs ("WP Theme"/"Design Bundle") are + // not DownloadProjectFormat values and don't go through this + // download-project path at all -- see CodePanel's WordPress branch. + pluginSettings.framework === "WordPress" || !allowedFormatsByFramework[pluginSettings.framework].includes(format) ) { throw new Error( diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/backend/src/common/retrieveUI/convertToCode.ts b/packages/backend/src/common/retrieveUI/convertToCode.ts index e5c9c80f..3ecf1197 100644 --- a/packages/backend/src/common/retrieveUI/convertToCode.ts +++ b/packages/backend/src/common/retrieveUI/convertToCode.ts @@ -18,6 +18,12 @@ export const convertToCode = async ( return await swiftuiMain(nodes, settings); case "Compose": return composeMain(nodes, settings); + case "WordPress": + // Phase 9 (D115/D118): the WordPress tab shows no syntax-highlighted + // code at all (CodePanel renders a feedback panel instead), and + // generation isn't wired up yet -- no point running a full HTML + // conversion just to discard it. + return ""; case "HTML": default: return (await htmlMain(nodes, settings)).html; diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index 7ba00d4b..164a7eb8 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -50,7 +50,18 @@ type PluginUIProps = { designBundleWarnings?: Warning[]; }; -const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; +// Phase 9 (D115): "WordPress" added as a fifth tab, peer to the code- +// generation frameworks -- not itself a code-generation language (see +// CodePanel's dedicated WordPress branch). "Compose" stays deliberately +// unlisted here, same as before this change -- it has real preference +// options (composeGenerationMode) but isn't surfaced as a top-level tab. +const frameworks: Framework[] = [ + "HTML", + "Tailwind", + "Flutter", + "SwiftUI", + "WordPress", +]; const LOADING_INDICATOR_DELAY_MS = 250; const DelayedLoading = () => { @@ -83,26 +94,38 @@ const FrameworkTabs = ({ setShowAbout, }: FrameworkTabsProps) => { return ( -
- {frameworks.map((tab) => ( - - ))} +
+ {frameworks.map((tab) => { + const isSelected = selectedFramework === tab && !showAbout; + // Phase 9 (D115): "a green WordPress tab" -- distinguishes it from + // the blue/primary code-generation frameworks, consistent with + // green already being this UI's own accent color elsewhere + // (SettingsGroup's toggle checkmarks, CodePanel's hover ring). + const isWordPress = tab === "WordPress"; + return ( + + ); + })}
); }; diff --git a/packages/plugin-ui/src/codegenPreferenceOptions.ts b/packages/plugin-ui/src/codegenPreferenceOptions.ts index fed6d2d9..6c178cc0 100644 --- a/packages/plugin-ui/src/codegenPreferenceOptions.ts +++ b/packages/plugin-ui/src/codegenPreferenceOptions.ts @@ -61,6 +61,18 @@ export const preferenceOptions: LocalCodegenPreferenceOptions[] = [ isDefault: false, includedLanguages: ["HTML", "Tailwind"], }, + { + // Phase 9 (D115): the WordPress tab's "Download Options" checkbox -- + // CodePanel.tsx renders this SettingsGroup under a "Download Options" + // title instead of "Styling Options" specifically for this framework. + itemType: "individual_select", + propertyName: "wpIncludeFonts", + label: "Include Fonts", + description: + "Self-host matching Google Fonts font files at generation time (a network call to fonts.google.com). Uncheck to skip it and use WordPress's normal fallback font stack instead.", + isDefault: true, + includedLanguages: ["WordPress"], + }, ]; export const selectPreferenceOptions: SelectPreferenceOptions[] = [ @@ -120,4 +132,22 @@ export const selectPreferenceOptions: SelectPreferenceOptions[] = [ ], includedLanguages: ["Compose"], }, + { + // Phase 9 (D115): the "WordPress Options" two-option button-group -- + // "WP Theme" (primary) and "Design Bundle" (secondary, lower- + // visibility per D115). Neither is wired to real generation yet. + itemType: "select", + propertyName: "wpOutputMode", + // Note: this top-level `label` is unused for "select"-type preferences + // in the current UI (CodePanel.tsx hardcodes "{selectedFramework} + // Options" as the heading instead -- see its own comment) -- "Mode" + // just matches the convention every sibling select-type entry above + // uses, for consistency if that ever changes. + label: "Mode", + options: [ + { label: "WP Theme", value: "theme", isDefault: true }, + { label: "Design Bundle", value: "designBundle" }, + ], + includedLanguages: ["WordPress"], + }, ]; diff --git a/packages/plugin-ui/src/components/CodePanel.tsx b/packages/plugin-ui/src/components/CodePanel.tsx index 8a35b8af..ec83ad4f 100644 --- a/packages/plugin-ui/src/components/CodePanel.tsx +++ b/packages/plugin-ui/src/components/CodePanel.tsx @@ -14,6 +14,10 @@ import SettingsGroup from "./SettingsGroup"; import FrameworkTabs from "./FrameworkTabs"; import { TailwindSettings } from "./TailwindSettings"; import DownloadMenu from "./DownloadMenu"; +import { + WordPressDownloadButton, + WordPressFeedbackPanel, +} from "./WordPressPanel"; interface CodePanelProps { code: string; @@ -45,7 +49,15 @@ const CodePanel = (props: CodePanelProps) => { isDownloadingProject = false, projectDownloadError, } = props; - const isCodeEmpty = code === ""; + // Phase 9 (D115/D118): the WordPress tab has no generated code at all + // (convertToCode.ts's WordPress case returns "" deliberately) -- but it + // still needs its own settings panel (WordPress Options, Download + // Options) and feedback panel to show, which the rest of this file + // otherwise gates on "isCodeEmpty". Treat WordPress as never empty here; + // its own branches below (WordPressDownloadButton/WordPressFeedbackPanel) + // handle the "nothing to show yet" case explicitly instead of falling + // into EmptyState. + const isCodeEmpty = code === "" && selectedFramework !== "WordPress"; // Helper function to add the prefix before every class (or className) in the code. // It finds every occurrence of class="..." or className="..." and, for each class, @@ -130,6 +142,10 @@ const CodePanel = (props: CodePanelProps) => { "showLayerNames", "embedImages", "embedVectors", + // Phase 9 (D115): WordPress's "Include Fonts" checkbox lives in the + // renamed-for-this-tab "Download Options" group below, not a new + // grouping mechanism. + "wpIncludeFonts", ]; // Group preferences by category @@ -157,18 +173,26 @@ const CodePanel = (props: CodePanelProps) => {

{!isCodeEmpty && (
- {onDownloadProject && canDownloadProject && ( - + ) : ( + <> + {onDownloadProject && canDownloadProject && ( + + )} + + )} -
)}
@@ -222,7 +246,14 @@ const CodePanel = (props: CodePanelProps) => { selectedFramework === "Tailwind") && (
{ > {isCodeEmpty ? ( + ) : selectedFramework === "WordPress" ? ( + ) : ( <> {showCodeCopyButton && ( diff --git a/packages/plugin-ui/src/components/WordPressPanel.tsx b/packages/plugin-ui/src/components/WordPressPanel.tsx new file mode 100644 index 00000000..1323d744 --- /dev/null +++ b/packages/plugin-ui/src/components/WordPressPanel.tsx @@ -0,0 +1,79 @@ +import { Download } from "lucide-react"; +import { WordPressOutputMode } from "types"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +/** + * Phase 9 (D115/D118): the WordPress tab's own download control and + * feedback panel. Split out of CodePanel.tsx into their own small file + * since neither one is a syntax-highlighted-code concern the way the rest + * of that file is -- this is the seam a future real generation wire-up + * (producing an actual theme.zip/design-bundle.zip via the OutputSink/ + * fflate mechanism D117 already ported) will replace, without CodePanel + * itself needing to change shape again. + * + * Deliberately disabled for now (D118) -- neither "WP Theme" nor "Design + * Bundle" can produce real output yet; wp-figma-gen's generation logic + * (theme-creator-for-figma's cli/src/core, cli/src/targets/wordpress) + * hasn't been ported into this package's own backend. Follows the same + * single-icon, no-popover visual pattern DownloadMenu.tsx already uses + * for Flutter/SwiftUI, per D115's UI spec -- kept as its own component + * rather than extending DownloadMenu's props, since DownloadMenu's whole + * job is resolving a `DownloadProjectFormat`, a type this doesn't produce. + */ + +const outputLabel: Record = { + theme: "WP Theme", + designBundle: "Design Bundle", +}; + +export const WordPressDownloadButton = ({ + outputMode, +}: { + outputMode: WordPressOutputMode; +}) => { + const label = `Download ${outputLabel[outputMode]}`; + return ( + + + } + > + + + + {label} isn't wired up to real generation yet -- coming soon. + + + ); +}; + +export const WordPressFeedbackPanel = ({ + outputMode, +}: { + outputMode: WordPressOutputMode; +}) => { + return ( +
+

+ {outputLabel[outputMode]} feedback +

+

+ This panel will summarize what the Download button produces for the + selected output -- page/template/pattern counts, asset counts, and + mapping warnings (surfaced above, via the same warnings panel every + other tab uses) -- once WordPress generation is wired up to real + output. There's no generated code to preview here the way the other + tabs show, since a WordPress export is a theme.zip or a Design + Bundle zip, not source you'd read. +

+
+ ); +}; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 88c517c4..05a79fc7 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -1,6 +1,17 @@ import "@figma/plugin-typings"; // Settings -export type Framework = "Flutter" | "SwiftUI" | "HTML" | "Tailwind" | "Compose"; +export type Framework = + | "Flutter" + | "SwiftUI" + | "HTML" + | "Tailwind" + | "Compose" + // Phase 9 (Figma -> WordPress pipeline, see AvetosDesign's + // theme-creator-for-figma repo): a target peer to the code-generation + // frameworks above, not a code-generation language itself -- selecting + // it doesn't show generated code (see PluginSettings.wpOutputMode/ + // WordPressSettings below and CodePanel's WordPress-specific branch). + | "WordPress"; export interface HTMLSettings { showLayerNames: boolean; embedImages: boolean; @@ -31,13 +42,28 @@ export interface SwiftUISettings { export interface ComposeSettings { composeGenerationMode: "snippet" | "composable" | "screen"; } +/** + * Phase 9 UI spec (D115): the WordPress tab's own two settings -- which of + * its two outputs is selected ("WP Theme" vs. "Design Bundle", the + * two-option button-group under "WordPress Options"), and the "Include + * Fonts" checkbox under "Download Options" (D115: defaulted checked, + * governs a Google Fonts network call at generation time). Neither output + * is wired to real generation yet -- see CodePanel's WordPress branch and + * D118 in the decisions log. + */ +export type WordPressOutputMode = "theme" | "designBundle"; +export interface WordPressSettings { + wpOutputMode: WordPressOutputMode; + wpIncludeFonts: boolean; +} export interface PluginSettings extends HTMLSettings, TailwindSettings, FlutterSettings, SwiftUISettings, - ComposeSettings { + ComposeSettings, + WordPressSettings { framework: Framework; useOldPluginVersion2025: boolean; responsiveRoot: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f57ef91..e5fee486 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,202 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + '@pnpm/exe': + specifier: ^11.0.0 + version: 11.21.0 + pnpm: + specifier: ^11.0.0 + version: 11.21.0 + +packages: + + '@pnpm/exe@11.21.0': + resolution: {integrity: sha512-zawQxIewH1od72HhlmXWq3No6XyuWn+nMvQ9BjWWGNBVskmS+RDlTu7ey2ruL650PbbyuCATYSal1DaXFKBdcw==} + hasBin: true + + '@pnpm/linux-arm64@11.21.0': + resolution: {integrity: sha512-gOSfQKr6kZjEwHyoRwMt9qrqQ9sqbZmUm2hbgJJG8bp0ZR9YkQ4BZV2k4qlQA2jtsmHV1u1MwiaLcuK7DauvBg==} + cpu: [arm64] + os: [linux] + + '@pnpm/linux-x64@11.21.0': + resolution: {integrity: sha512-X+kBR8yscKyhhElO+WLrb6sFbl/3Ow70B+6fqZUYI8T8wtmlCw5GtcPXVBJPDJcLN5joe227h7lyCCZo4tdKdw==} + cpu: [x64] + os: [linux] + + '@pnpm/linuxstatic-arm64@11.21.0': + resolution: {integrity: sha512-IUJfAclH0b3QxaHuQuVxXQIzEkDtTm0C+G3tgG0ET5tDRGc7wH7eU0GEM75ojHOwzqv7s0y00xPVVCIsxUM4Nw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/linuxstatic-x64@11.21.0': + resolution: {integrity: sha512-6Y2u+AfOUuTqWgTCpFhySL8HAcONDucCFixKle9tWoW7bm8RF0+fwQBRWNWGdx+Toau07wZ1LNZPqN8gpgeBDQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/macos-arm64@11.21.0': + resolution: {integrity: sha512-sLMGvVJXWdhFAouY2icjeZ2VCFmyPPZvvtHkfj1oeCWGppsbWcP5cExSw9yU1Uw7ALwrV0I2lRZv33yWYfDtcQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/win-arm64@11.21.0': + resolution: {integrity: sha512-79Nc+YI5B2ddH5MQD2YITL/PKnmXdcQKwmwx0HaD4QnsCco8DFaTho242e5sd9QfXKxYRvB9AOnuqIV4VjhAAw==} + cpu: [arm64] + os: [win32] + + '@pnpm/win-x64@11.21.0': + resolution: {integrity: sha512-zT3TufmVOroWPrzXTPPPgYvIsTZIsK13kjpmgXlICyEFrhd16RFLoTWFhP+8UqHXpTNmVbtTHzg+fEVYy9rlEQ==} + cpu: [x64] + os: [win32] + + '@reflink/reflink-darwin-arm64@0.1.19': + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@reflink/reflink-darwin-x64@0.1.19': + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@reflink/reflink-linux-arm64-musl@0.1.19': + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@reflink/reflink-linux-x64-gnu@0.1.19': + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@reflink/reflink-linux-x64-musl@0.1.19': + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@reflink/reflink-win32-x64-msvc@0.1.19': + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@reflink/reflink@0.1.19': + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} + engines: {node: '>= 10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + pnpm@11.21.0: + resolution: {integrity: sha512-UhcFvOaJkk6scvWjWHEi82JonvZXHlW6gAdv1jfBETLs/62ib61Op5xIW/3b/T1aKlsFgFp36JPeceyKbMo7sQ==} + engines: {node: '>=22.13'} + hasBin: true + +snapshots: + + '@pnpm/exe@11.21.0': + dependencies: + '@reflink/reflink': 0.1.19 + detect-libc: 2.1.2 + optionalDependencies: + '@pnpm/linux-arm64': 11.21.0 + '@pnpm/linux-x64': 11.21.0 + '@pnpm/linuxstatic-arm64': 11.21.0 + '@pnpm/linuxstatic-x64': 11.21.0 + '@pnpm/macos-arm64': 11.21.0 + '@pnpm/win-arm64': 11.21.0 + '@pnpm/win-x64': 11.21.0 + + '@pnpm/linux-arm64@11.21.0': + optional: true + + '@pnpm/linux-x64@11.21.0': + optional: true + + '@pnpm/linuxstatic-arm64@11.21.0': + optional: true + + '@pnpm/linuxstatic-x64@11.21.0': + optional: true + + '@pnpm/macos-arm64@11.21.0': + optional: true + + '@pnpm/win-arm64@11.21.0': + optional: true + + '@pnpm/win-x64@11.21.0': + optional: true + + '@reflink/reflink-darwin-arm64@0.1.19': + optional: true + + '@reflink/reflink-darwin-x64@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-musl@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-musl@0.1.19': + optional: true + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + optional: true + + '@reflink/reflink-win32-x64-msvc@0.1.19': + optional: true + + '@reflink/reflink@0.1.19': + optionalDependencies: + '@reflink/reflink-darwin-arm64': 0.1.19 + '@reflink/reflink-darwin-x64': 0.1.19 + '@reflink/reflink-linux-arm64-gnu': 0.1.19 + '@reflink/reflink-linux-arm64-musl': 0.1.19 + '@reflink/reflink-linux-x64-gnu': 0.1.19 + '@reflink/reflink-linux-x64-musl': 0.1.19 + '@reflink/reflink-win32-arm64-msvc': 0.1.19 + '@reflink/reflink-win32-x64-msvc': 0.1.19 + + detect-libc@2.1.2: {} + + pnpm@11.21.0: {} + +--- lockfileVersion: '9.0' settings: @@ -77,10 +276,10 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-react': specifier: ^6.0.5 - version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0)) + version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)) '@vitejs/plugin-react-swc': specifier: ^4.3.3 - version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0)) + version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)) concurrently: specifier: ^10.0.4 version: 10.0.4 @@ -104,10 +303,10 @@ importers: version: 7.0.2 vite: specifier: ^8.2.0 - version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0) + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0) vite-plugin-singlefile: specifier: ^2.3.3 - version: 2.3.3(rollup@4.62.4)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0)) + version: 2.3.3(rollup@4.62.4)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)) apps/web: dependencies: @@ -214,13 +413,13 @@ importers: version: link:../tsconfig tsup: specifier: ^8.5.1 - version: 8.5.1(@swc/core@1.15.47)(jiti@2.7.0)(postcss@8.5.25)(supports-color@10.2.2)(typescript@7.0.2)(yaml@2.7.0) + version: 8.5.1(@swc/core@1.15.47)(jiti@2.7.0)(postcss@8.5.25)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.7.0) typescript: specifier: ^7.0.2 version: 7.0.2 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0)) + version: 4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)) packages/plugin-ui: dependencies: @@ -1897,11 +2096,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - baseline-browser-mapping@2.11.11: - resolution: {integrity: sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.12: resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} engines: {node: '>=6.0.0'} @@ -2683,6 +2877,11 @@ packages: typescript: optional: true + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.10.8: resolution: {integrity: sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg==} hasBin: true @@ -3795,18 +3994,18 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react-swc@4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0))': + '@vitejs/plugin-react-swc@4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 '@swc/core': 1.15.47 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0) transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0))': + '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0) '@vitest/expect@4.1.10': dependencies: @@ -3817,13 +4016,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -3859,9 +4058,6 @@ snapshots: assertion-error@2.0.1: {} - baseline-browser-mapping@2.11.11: - optional: true - baseline-browser-mapping@2.11.12: {} braces@3.0.3: @@ -3870,7 +4066,7 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.11.11 + baseline-browser-mapping: 2.11.12 caniuse-lite: 1.0.30001806 electron-to-chromium: 1.5.354 node-releases: 2.0.44 @@ -4385,12 +4581,13 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.25)(yaml@2.7.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.12)(yaml@2.7.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 postcss: 8.5.25 + tsx: 4.23.12 yaml: 2.7.0 postcss@8.5.23: @@ -4619,7 +4816,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@swc/core@1.15.47)(jiti@2.7.0)(postcss@8.5.25)(supports-color@10.2.2)(typescript@7.0.2)(yaml@2.7.0): + tsup@8.5.1(@swc/core@1.15.47)(jiti@2.7.0)(postcss@8.5.25)(supports-color@10.2.2)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.7.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -4630,7 +4827,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.25)(yaml@2.7.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.12)(yaml@2.7.0) resolve-from: 5.0.0 rollup: 4.62.4 source-map: 0.7.6 @@ -4648,6 +4845,13 @@ snapshots: - tsx - yaml + tsx@4.23.12: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + optional: true + turbo@2.10.8: optionalDependencies: '@turbo/darwin-64': 2.10.8 @@ -4695,14 +4899,14 @@ snapshots: dependencies: react: 19.2.8 - vite-plugin-singlefile@2.3.3(rollup@4.62.4)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0)): + vite-plugin-singlefile@2.3.3(rollup@4.62.4)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)): dependencies: micromatch: 4.0.8 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0) optionalDependencies: rollup: 4.62.4 - vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -4714,9 +4918,10 @@ snapshots: esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 + tsx: 4.23.12 yaml: 2.7.0 - vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.7.0): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -4728,12 +4933,13 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 + tsx: 4.23.12 yaml: 2.7.0 - vitest@4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0)): + vitest@4.1.10(@types/node@26.1.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -4750,7 +4956,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.7.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.2 From 61205b26d93c2bb013cbc204158df1d03aca7f6f Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Mon, 31 Aug 2026 20:21:19 +0000 Subject: [PATCH 07/29] feat: remove standalone Design Bundle export button Delete the Phase 7 (PR #263) standalone "Design Bundle" toolbar button and its backend generation code -- packages/backend/src/ designBundle/, the buildDesignBundle export, PluginUI's toolbar button/props, App.tsx's message handling, and code.ts's plugin-side handler. This was the exact UI shape bernaferrari rejected (D114); D115/D118 already replaced it with the WordPress tab's own "Design Bundle" output-mode option, which is unaffected by this change and stays in place. The capability returns later via a different mechanism -- not scheduled yet. Verified with pnpm build / pnpm lint, both clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014AHYgosynYxehFSW9sQPLR --- README.md | 15 - apps/plugin/plugin-src/code.ts | 38 - apps/plugin/ui-src/App.tsx | 62 -- .../src/designBundle/designBundleAssets.ts | 146 ---- .../src/designBundle/designBundleMain.ts | 169 ----- .../designBundle/designBundleTextStyles.ts | 99 --- .../src/designBundle/designBundleTree.ts | 679 ------------------ .../src/designBundle/designBundleUtils.ts | 19 - .../src/designBundle/designBundleZip.ts | 31 - packages/backend/src/index.ts | 1 - packages/plugin-ui/src/PluginUI.tsx | 42 +- packages/types/src/types.ts | 336 --------- 12 files changed, 1 insertion(+), 1636 deletions(-) delete mode 100644 packages/backend/src/designBundle/designBundleAssets.ts delete mode 100644 packages/backend/src/designBundle/designBundleMain.ts delete mode 100644 packages/backend/src/designBundle/designBundleTextStyles.ts delete mode 100644 packages/backend/src/designBundle/designBundleTree.ts delete mode 100644 packages/backend/src/designBundle/designBundleUtils.ts delete mode 100644 packages/backend/src/designBundle/designBundleZip.ts diff --git a/README.md b/README.md index e09e726f..20631434 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,6 @@ The generator is deterministic and runs inside Figma's plugin sandbox. It does n | 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: @@ -49,17 +46,6 @@ 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: @@ -177,7 +163,6 @@ pnpm format:check # Check formatting without writing | 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 | diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index 7f4ddd97..5c9cbef2 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -9,7 +9,6 @@ import { generateProjectZip, postSettingsChanged, replaceProjectImagePlaceholders, - buildDesignBundle, } from "backend"; import { nodesToJSON } from "backend/src/altNodes/jsonNodeConversion"; import { oldConvertNodesToAltNodes } from "backend/src/altNodes/oldAltConversion"; @@ -101,7 +100,6 @@ 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 =", @@ -467,42 +465,6 @@ 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 bae66fbc..21cad6ae 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -14,8 +14,6 @@ import { DownloadProjectFormat, ProjectDownloadErrorMessage, ProjectZipMessage, - DesignBundleZipMessage, - DesignBundleErrorMessage, } from "types"; import { postUISettingsChangingMessage } from "./messaging"; import copy from "copy-to-clipboard"; @@ -31,9 +29,6 @@ interface AppState { warnings: Warning[]; isDownloadingProject: boolean; projectDownloadError: string | null; - isExportingDesignBundle: boolean; - designBundleExportError: string | null; - designBundleWarnings: Warning[]; } const emptyPreview = { size: { width: 0, height: 0 }, content: "" }; @@ -61,9 +56,6 @@ export default function App() { warnings: [], isDownloadingProject: false, projectDownloadError: null, - isExportingDesignBundle: false, - designBundleExportError: null, - designBundleWarnings: [], }); const rootStyles = getComputedStyle(document.documentElement); @@ -165,39 +157,6 @@ 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; } @@ -249,23 +208,6 @@ 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); useEffect(() => { @@ -294,10 +236,6 @@ 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/designBundle/designBundleAssets.ts b/packages/backend/src/designBundle/designBundleAssets.ts deleted file mode 100644 index e4d8cfdc..00000000 --- a/packages/backend/src/designBundle/designBundleAssets.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { DesignBundleAsset } from "types"; -import { addWarning } from "../common/commonConversionWarnings"; -import { encodeUtf8Text } from "./designBundleUtils"; - -export interface ExportedDesignBundleAsset { - fileName: string; - 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 - * `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. 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 => { - const exported: ExportedDesignBundleAsset[] = []; - const failedAssetIds: string[] = []; - - 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 - // 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 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); - if (!image) { - addWarning( - `Could not export background-image asset (${asset.fileName}) — image hash ${asset.imageHash} not found.`, - ); - failedAssetIds.push(asset.id); - return; - } - 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) - }`, - ); - failedAssetIds.push(asset.id); - } - return; - } - - 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.`, - ); - failedAssetIds.push(asset.id); - return; - } - - 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: DESIGN_BUNDLE_RASTER_SCALE }, - }); - exported.push({ fileName: asset.fileName, bytes }); - } - } catch (error) { - addWarning( - `Failed exporting asset ${asset.fileName}: ${ - 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, failedAssetIds }; -}; diff --git a/packages/backend/src/designBundle/designBundleMain.ts b/packages/backend/src/designBundle/designBundleMain.ts deleted file mode 100644 index 6d4561a9..00000000 --- a/packages/backend/src/designBundle/designBundleMain.ts +++ /dev/null @@ -1,169 +0,0 @@ -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"; -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) => - (value || "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); - -export interface DesignBundleExportResult { - zip: Uint8Array; - fileName: string; - designCount: number; - assetCount: number; - warnings: string[]; -} - -/** - * 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) 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[], - 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) — a top-level GROUP breaks the otherwise - // clean 1:1 mapping between selected layers and designs[] entries. - // 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) — 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) => { - 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 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, - }; - }); - - // 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) { - 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 — a bare console.warn here would - // never surface these to the user. - for (const w of textStyleWarnings) addWarning(w); - - 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, - 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: finalAssets, - 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: finalAssets.length, - warnings: [...warnings], - }; -}; diff --git a/packages/backend/src/designBundle/designBundleTextStyles.ts b/packages/backend/src/designBundle/designBundleTextStyles.ts deleted file mode 100644 index 7ce29d42..00000000 --- a/packages/backend/src/designBundle/designBundleTextStyles.ts +++ /dev/null @@ -1,99 +0,0 @@ -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. - * 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 deleted file mode 100644 index 29fba629..00000000 --- a/packages/backend/src/designBundle/designBundleTree.ts +++ /dev/null @@ -1,679 +0,0 @@ -import { - DesignBundleAsset, - DesignBundleBlendMode, - DesignBundleColorStyle, - DesignBundleEffect, - DesignBundleFill, - DesignBundleGradient, - DesignBundleNodeStyle, - DesignBundleStyles, - DesignBundleTextSegment, - DesignNode, - 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 -// 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([ - "VECTOR", - "STAR", - "POLYGON", - "BOOLEAN_OPERATION", - "LINE", -]); - -let assetCounter = 0; -let nameCounters: Map = new Map(); -// 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(); -}; - -// 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 — it is always exported -// fresh. -// -// 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[^;]+;(.+)$/; -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 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"; -}; - -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 — 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; -}; - -// 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 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; - const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; - const combined = colorAlpha * paintOpacity; - return combined < 1 ? combined : undefined; -}; - -// 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", -}; - -// 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 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. -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")) { - // 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 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 - ? 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, - // 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, - }; - } - return { type: effect.type, blur: effect.radius ?? 0 }; - }); -}; - -// 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; -}; - -// 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). `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: fallbackFontSize, - fontWeight: String(node.style?.fontWeight ?? "400"), - lineHeight: fallbackLineHeight, - 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 { - // 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, - 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) and threaded straight through here. - 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) 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. 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 = ( - node: ConvertedNode, - assets: DesignBundleAsset[], - styles: DesignBundleStyles, - parentLayoutMode: string | undefined, - // 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), - }, - }; - // 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") { - 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. Root designs[] entries have no parent, so - // position is always included there. - // `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" || - 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: [], - // 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, - }; - - // 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 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 ( - (node.type === "COMPONENT" || node.type === "COMPONENT_SET") && - typeof node.id === "string" - ) { - designNode.componentId = node.id; - } - - if (type === "TEXT") { - // 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 this schema's general - // convention of never emitting a value that's already the 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") { - // 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), - // 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) { - assetIdentityMap.set(identityKey, asset); - } - designNode.assetRef = assetId; - // 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; - } - - // 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") { - // 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")}`; - // 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, - 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 deleted file mode 100644 index e7bc8ee4..00000000 --- a/packages/backend/src/designBundle/designBundleUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -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); - } - return strToU8(text); -}; diff --git a/packages/backend/src/designBundle/designBundleZip.ts b/packages/backend/src/designBundle/designBundleZip.ts deleted file mode 100644 index 125ddb51..00000000 --- a/packages/backend/src/designBundle/designBundleZip.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 containing every exported raster/vector asset, - * referenced from the manifest by relative path. - */ -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 9e007360..3a636fb1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -10,4 +10,3 @@ 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 164a7eb8..73e4001a 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, PackageOpen, LoaderCircle } from "lucide-react"; +import { InfoIcon } from "lucide-react"; import React from "react"; import { Button } from "./components/ui/button"; import { ScrollArea } from "./components/ui/scroll-area"; @@ -44,10 +44,6 @@ type PluginUIProps = { onDownloadProject?: (format: DownloadProjectFormat) => void; isDownloadingProject?: boolean; projectDownloadError?: string | null; - onExportDesignBundle?: () => void; - isExportingDesignBundle?: boolean; - designBundleExportError?: string | null; - designBundleWarnings?: Warning[]; }; // Phase 9 (D115): "WordPress" added as a fifth tab, peer to the code- @@ -160,28 +156,6 @@ 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; - // 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 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 - // 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; - // 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; - // 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; -} -// 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" - | "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[]; - // 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; - // 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) — coordinates are meaningless - // outside that case, since Auto Layout computes a child's position - // itself. - position?: { x: number; y: number }; - // 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 - // 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. - // 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 - // 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; - // 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; - // 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 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). - // 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; - // 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 — 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; - // 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). A node - // with both an image fill *and* real children stays a FRAME so its - // 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[]; -} -export interface DesignBundleAsset { - id: string; - figmaNodeId: string; - fileName: string; - kind: "raster" | "vector"; - width: number; - height: number; - // 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 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; - // 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; - 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 39d592e2da301aacea3e8d682f3aba258fd6f0f4 Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Mon, 31 Aug 2026 20:45:28 +0000 Subject: [PATCH 08/29] fix: WordPress tab styling, empty-selection gate, and stale turbo build cache Three bugs found via real-world testing of the WordPress tab (D118): - The unselected WordPress tab rendered green text instead of matching the other tabs' neutral styling -- green should only apply when it's the active tab. - The WordPress tab always showed the "nothing selected" EmptyState even with a real selection, because the top-level empty-content gate inferred "nothing selected" from code === "", which is always true for WordPress by design. Replaced with an explicit isEmptySelection prop driven by the actual backend empty/code messages. - turbo.json's build task had no dependsOn, so builds of apps/plugin and apps/web weren't invalidated by changes to sibling source-only packages (plugin-ui, types, backend) they bundle from directly. This silently served stale cached builds and had been masking a real type error in apps/web/app/PreviewLab.tsx (missing WordPressSettings fields) since the WordPress tab landed. Fixed the cache config and the masked type error together. Verified with pnpm build / pnpm lint, both clean, plus a live Figma session confirming both UI fixes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014AHYgosynYxehFSW9sQPLR --- apps/plugin/ui-src/App.tsx | 8 ++++++++ apps/web/app/PreviewLab.tsx | 6 ++++++ packages/plugin-ui/src/PluginUI.tsx | 12 ++++++++---- turbo.json | 1 + 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 21cad6ae..359a7fc0 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -29,6 +29,9 @@ interface AppState { warnings: Warning[]; isDownloadingProject: boolean; projectDownloadError: string | null; + // Set by the "empty"/"code" backend messages -- see PluginUI's + // isEmptySelection prop for why this can't be derived from `code`. + isEmptySelection: boolean; } const emptyPreview = { size: { width: 0, height: 0 }, content: "" }; @@ -56,6 +59,7 @@ export default function App() { warnings: [], isDownloadingProject: false, projectDownloadError: null, + isEmptySelection: true, }); const rootStyles = getComputedStyle(document.documentElement); @@ -84,6 +88,7 @@ export default function App() { ...conversionMessage, selectedFramework: conversionMessage.settings.framework, isLoading: false, + isEmptySelection: false, })); break; @@ -106,6 +111,7 @@ export default function App() { colors: [], gradients: [], isLoading: false, + isEmptySelection: true, })); break; @@ -118,6 +124,7 @@ export default function App() { gradients: [], code: `Error :(\n// ${errorMessage.error}`, isLoading: false, + isEmptySelection: false, })); break; @@ -236,6 +243,7 @@ export default function App() { onDownloadProject={handleDownloadProject} isDownloadingProject={state.isDownloadingProject} projectDownloadError={state.projectDownloadError} + isEmptySelection={state.isEmptySelection} />
); diff --git a/apps/web/app/PreviewLab.tsx b/apps/web/app/PreviewLab.tsx index 79b5ec19..5eb21c4f 100644 --- a/apps/web/app/PreviewLab.tsx +++ b/apps/web/app/PreviewLab.tsx @@ -28,6 +28,8 @@ const defaultSettings: PluginSettings = { thresholdPercent: 15, baseFontFamily: "", fontFamilyCustomConfig: {}, + wpOutputMode: "theme", + wpIncludeFonts: true, }; const htmlMarkup = `
@@ -376,6 +378,7 @@ export default function PreviewLab() { selectedFramework={selectedFramework} setSelectedFramework={handleFrameworkChanged} onPreferenceChanged={handlePreferenceChanged} + isEmptySelection={previewState === "empty"} /> @@ -393,6 +396,7 @@ type PluginPreviewProps = { key: keyof PluginSettings, value: PluginSettings[keyof PluginSettings], ) => void; + isEmptySelection: boolean; }; function PluginPreview({ @@ -402,6 +406,7 @@ function PluginPreview({ selectedFramework, setSelectedFramework, onPreferenceChanged, + isEmptySelection, }: PluginPreviewProps) { return (
@@ -433,6 +438,7 @@ function PluginPreview({ colors={[]} gradients={[]} warnings={warnings} + isEmptySelection={isEmptySelection} /> diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index 73e4001a..10395cd4 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -44,6 +44,12 @@ type PluginUIProps = { onDownloadProject?: (format: DownloadProjectFormat) => void; isDownloadingProject?: boolean; projectDownloadError?: string | null; + // Whether the current Figma selection is empty. Deliberately separate + // from `code === ""`: WordPress's `code` is always "" by design (see + // convertToCode.ts), so that check alone can't tell "nothing selected" + // apart from "WordPress selected, real content exists" -- this bit is + // what the EmptyState gate below actually needs. + isEmptySelection: boolean; }; // Phase 9 (D115): "WordPress" added as a fifth tab, peer to the code- @@ -109,9 +115,7 @@ const FrameworkTabs = ({ ? isWordPress ? "bg-green-600 text-white shadow-xs hover:bg-green-600 hover:text-white dark:bg-green-600 dark:hover:bg-green-600" : "bg-primary text-primary-foreground shadow-xs hover:bg-primary hover:text-primary-foreground dark:hover:bg-primary" - : isWordPress - ? "bg-muted text-green-700 hover:bg-green-600/90 hover:text-white dark:text-green-400 dark:hover:bg-green-600/90 dark:hover:text-white" - : "bg-muted text-foreground hover:bg-primary/90 hover:text-primary-foreground dark:hover:bg-primary/90" + : "bg-muted text-foreground hover:bg-primary/90 hover:text-primary-foreground dark:hover:bg-primary/90" }`} onClick={() => { setSelectedFramework(tab as Framework); @@ -141,7 +145,7 @@ export const PluginUI = (props: PluginUIProps) => { return ; } - const isEmpty = props.code === ""; + const isEmpty = props.isEmptySelection; const warnings = props.warnings ?? []; return ( diff --git a/turbo.json b/turbo.json index 9636020e..336d9dc2 100644 --- a/turbo.json +++ b/turbo.json @@ -2,6 +2,7 @@ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { + "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "dev": { From aaf1cba4749e6b0678cfb44d62a8b3bd65869a97 Mon Sep 17 00:00:00 2001 From: AvetosDesign Date: Mon, 31 Aug 2026 21:03:10 +0000 Subject: [PATCH 09/29] feat: mechanical port of wp-figma-gen's WordPress generation logic Ports theme-creator-for-figma's core/, blocks/, theme/, and patterns/ generation code (plus the small targets/target.ts interface file) into packages/backend/src/wordpress/, unchanged except for two Node-only gaps not caught by that project's own portability work: a Buffer usage in generateThemeFiles.ts (replaced with a new decodeText() counterpart to the existing encodeText()), and the optional cliVersion fallback to a filesystem-walking getCliVersion() (removed -- callers must now supply their own version string). This is stage 1 of 2 for wiring "WP Theme" to real generation: a mechanical port only, verified to run correctly and entirely in-memory via standalone Node smoke tests, but not yet wired to the UI or to a real Figma selection. Unwired and unimported from anywhere, so this adds no risk to the existing build. Stage 2 (translation layer from F2C's own selection data, UI wiring, manifest.json changes) is unscheduled follow-up work. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014AHYgosynYxehFSW9sQPLR --- .../src/wordpress/blocks/formMapping.ts | 172 ++++ .../backend/src/wordpress/blocks/index.ts | 5 + .../src/wordpress/blocks/linkMapping.ts | 137 ++++ .../backend/src/wordpress/blocks/mapNode.ts | 635 +++++++++++++++ .../backend/src/wordpress/blocks/render.ts | 84 ++ .../backend/src/wordpress/blocks/types.ts | 66 ++ .../wordpress/core/classify/chromeDetect.ts | 143 ++++ .../src/wordpress/core/classify/formDetect.ts | 138 ++++ .../core/classify/headingHeuristic.test.ts | 71 ++ .../core/classify/headingHeuristic.ts | 63 ++ .../src/wordpress/core/classify/linkDetect.ts | 77 ++ .../src/wordpress/core/contentHash.test.ts | 26 + .../backend/src/wordpress/core/contentHash.ts | 40 + .../src/wordpress/core/designTree.test.ts | 205 +++++ .../backend/src/wordpress/core/designTree.ts | 111 +++ .../src/wordpress/core/outputSink.test.ts | 25 + .../backend/src/wordpress/core/outputSink.ts | 38 + .../src/wordpress/core/slugify.test.ts | 56 ++ .../backend/src/wordpress/core/slugify.ts | 46 ++ .../src/wordpress/core/style/nodeClass.ts | 11 + .../src/wordpress/core/style/styleHelpers.ts | 391 +++++++++ .../wordpress/core/style/stylesheet.test.ts | 38 + .../src/wordpress/core/style/stylesheet.ts | 18 + .../src/wordpress/core/textEncoding.ts | 51 ++ .../src/wordpress/core/types/designBundle.ts | 334 ++++++++ .../patterns/generatePatternFiles.test.ts | 73 ++ .../patterns/generatePatternFiles.ts | 139 ++++ .../backend/src/wordpress/targets/target.ts | 109 +++ .../theme/generateThemeFiles.test.ts | 95 +++ .../src/wordpress/theme/generateThemeFiles.ts | 749 ++++++++++++++++++ .../theme/generateThemeTokens.test.ts | 98 +++ .../wordpress/theme/generateThemeTokens.ts | 79 ++ .../src/wordpress/theme/googleFonts.ts | 250 ++++++ .../src/wordpress/theme/templateParts.ts | 72 ++ 34 files changed, 4645 insertions(+) create mode 100644 packages/backend/src/wordpress/blocks/formMapping.ts create mode 100644 packages/backend/src/wordpress/blocks/index.ts create mode 100644 packages/backend/src/wordpress/blocks/linkMapping.ts create mode 100644 packages/backend/src/wordpress/blocks/mapNode.ts create mode 100644 packages/backend/src/wordpress/blocks/render.ts create mode 100644 packages/backend/src/wordpress/blocks/types.ts create mode 100644 packages/backend/src/wordpress/core/classify/chromeDetect.ts create mode 100644 packages/backend/src/wordpress/core/classify/formDetect.ts create mode 100644 packages/backend/src/wordpress/core/classify/headingHeuristic.test.ts create mode 100644 packages/backend/src/wordpress/core/classify/headingHeuristic.ts create mode 100644 packages/backend/src/wordpress/core/classify/linkDetect.ts create mode 100644 packages/backend/src/wordpress/core/contentHash.test.ts create mode 100644 packages/backend/src/wordpress/core/contentHash.ts create mode 100644 packages/backend/src/wordpress/core/designTree.test.ts create mode 100644 packages/backend/src/wordpress/core/designTree.ts create mode 100644 packages/backend/src/wordpress/core/outputSink.test.ts create mode 100644 packages/backend/src/wordpress/core/outputSink.ts create mode 100644 packages/backend/src/wordpress/core/slugify.test.ts create mode 100644 packages/backend/src/wordpress/core/slugify.ts create mode 100644 packages/backend/src/wordpress/core/style/nodeClass.ts create mode 100644 packages/backend/src/wordpress/core/style/styleHelpers.ts create mode 100644 packages/backend/src/wordpress/core/style/stylesheet.test.ts create mode 100644 packages/backend/src/wordpress/core/style/stylesheet.ts create mode 100644 packages/backend/src/wordpress/core/textEncoding.ts create mode 100644 packages/backend/src/wordpress/core/types/designBundle.ts create mode 100644 packages/backend/src/wordpress/patterns/generatePatternFiles.test.ts create mode 100644 packages/backend/src/wordpress/patterns/generatePatternFiles.ts create mode 100644 packages/backend/src/wordpress/targets/target.ts create mode 100644 packages/backend/src/wordpress/theme/generateThemeFiles.test.ts create mode 100644 packages/backend/src/wordpress/theme/generateThemeFiles.ts create mode 100644 packages/backend/src/wordpress/theme/generateThemeTokens.test.ts create mode 100644 packages/backend/src/wordpress/theme/generateThemeTokens.ts create mode 100644 packages/backend/src/wordpress/theme/googleFonts.ts create mode 100644 packages/backend/src/wordpress/theme/templateParts.ts diff --git a/packages/backend/src/wordpress/blocks/formMapping.ts b/packages/backend/src/wordpress/blocks/formMapping.ts new file mode 100644 index 00000000..69dd1539 --- /dev/null +++ b/packages/backend/src/wordpress/blocks/formMapping.ts @@ -0,0 +1,172 @@ +import type { GeneratedBlock } from "./types.ts"; +import type { MapNodeContext } from "./mapNode.ts"; +import { + escapeHtml, + layoutToDeclarations, + nodeStyleToDeclarations, + joinStyles, + fontFamilyDeclaration, + withAlpha, +} from "../core/style/styleHelpers.ts"; +import { nodeClassFor } from "../core/style/nodeClass.ts"; +import { addRule } from "../core/style/stylesheet.ts"; +import { toSlug } from "../core/slugify.ts"; +import type { DesignNode } from "../core/types/designBundle"; +import type { DetectedField, DetectedButton, DetectedForm } from "../core/classify/formDetect.ts"; + +/** + * D62 — Forms and in-form buttons: rendering half. Takes the target-neutral + * `DetectedForm` shape produced by `core/classify/formDetect.ts`'s + * `detectForm` and renders real `
`/`