diff --git a/src/cli/commands/save-fixture.ts b/src/cli/commands/save-fixture.ts index 2320ca3e..7785c70a 100644 --- a/src/cli/commands/save-fixture.ts +++ b/src/cli/commands/save-fixture.ts @@ -69,7 +69,7 @@ export function registerSaveFixture(cli: CAC): void { const figmaTokenForComponents = options.token ?? getFigmaToken(); if (figmaTokenForComponents) { const { FigmaClient: FC } = await import("../../core/adapters/figma-client.js"); - const { resolveComponentDefinitions } = await import("../../core/adapters/component-resolver.js"); + const { resolveComponentDefinitions, resolveInteractionDestinations } = await import("../../core/adapters/component-resolver.js"); const componentClient = new FC({ token: figmaTokenForComponents }); try { const definitions = await resolveComponentDefinitions(componentClient, file.fileKey, file.document); @@ -78,6 +78,13 @@ export function registerSaveFixture(cli: CAC): void { file.componentDefinitions = definitions; console.log(`Resolved ${count} component master node tree(s)`); } + // Resolve interaction destinations (hover variants, etc.) + const interactionDests = await resolveInteractionDestinations(componentClient, file.fileKey, file.document, file.componentDefinitions); + const destCount = Object.keys(interactionDests).length; + if (destCount > 0) { + file.interactionDestinations = interactionDests; + console.log(`Resolved ${destCount} interaction destination(s)`); + } } catch { console.warn("Warning: failed to resolve component definitions (continuing)"); } diff --git a/src/core/adapters/component-resolver.ts b/src/core/adapters/component-resolver.ts index 4d97aedb..b1ba23c1 100644 --- a/src/core/adapters/component-resolver.ts +++ b/src/core/adapters/component-resolver.ts @@ -25,6 +25,40 @@ export function collectComponentIds(node: AnalysisNode): Set { return ids; } +/** + * Recursively collect all unique interaction destination IDs from nodes. + * These are the node IDs that interactions (e.g., ON_HOVER → CHANGE_TO) point to. + */ +export function collectInteractionDestinationIds(node: AnalysisNode): Set { + const ids = new Set(); + + function walk(n: AnalysisNode): void { + if (n.interactions && Array.isArray(n.interactions)) { + for (const interaction of n.interactions) { + const i = interaction as { + trigger?: { type?: string }; + actions?: Array<{ destinationId?: string; navigation?: string }>; + }; + if (i.trigger?.type === "ON_HOVER" && i.actions) { + for (const action of i.actions) { + if (action.navigation === "CHANGE_TO" && action.destinationId) { + ids.add(action.destinationId); + } + } + } + } + } + if (n.children) { + for (const child of n.children) { + walk(child); + } + } + } + + walk(node); + return ids; +} + /** * Resolve component master node trees via multi-pass fetching. * @@ -88,3 +122,48 @@ export async function resolveComponentDefinitions( return allDefinitions; } + +/** + * Resolve interaction destination nodes (e.g., hover variants). + * + * Collects all destinationId values from interactions in the document, + * excludes those already in componentDefinitions, and fetches them. + */ +export async function resolveInteractionDestinations( + client: FigmaClient, + fileKey: string, + document: AnalysisNode, + existingDefinitions?: Record, +): Promise> { + const destIds = collectInteractionDestinationIds(document); + if (destIds.size === 0) return {}; + + const allDestinations: Record = {}; + const idsToFetch: string[] = []; + + for (const id of destIds) { + const existing = existingDefinitions?.[id]; + if (existing) { + allDestinations[id] = existing; + } else { + idsToFetch.push(id); + } + } + + if (idsToFetch.length === 0) return allDestinations; + + for (let i = 0; i < idsToFetch.length; i += BATCH_SIZE) { + const batch = idsToFetch.slice(i, i + BATCH_SIZE); + try { + const response = await client.getFileNodes(fileKey, batch); + const transformed = transformComponentMasterNodes(response, batch); + for (const [id, node] of Object.entries(transformed)) { + allDestinations[id] = node; + } + } catch (err) { + console.debug(`[component-resolver] interaction destination fetch failed (${batch.length} ids):`, err); + } + } + + return allDestinations; +} diff --git a/src/core/adapters/figma-file-loader.ts b/src/core/adapters/figma-file-loader.ts index d5fe30d4..5c6868ad 100644 --- a/src/core/adapters/figma-file-loader.ts +++ b/src/core/adapters/figma-file-loader.ts @@ -29,6 +29,7 @@ export async function loadFigmaFileFromJson( const content = await readFile(filePath, "utf-8"); const data = JSON.parse(content) as GetFileResponse & { componentDefinitions?: Record; + interactionDestinations?: Record; }; const fileKey = extractFileKey(filePath); @@ -51,6 +52,22 @@ export async function loadFigmaFileFromJson( } } + // Preserve interactionDestinations from previously-saved fixtures + if (data.interactionDestinations) { + const parsed: Record = {}; + for (const [id, raw] of Object.entries(data.interactionDestinations)) { + const result = AnalysisNodeSchema.safeParse(raw); + if (result.success) { + parsed[id] = result.data; + } else { + console.debug(`[figma-file-loader] interactionDestinations[${id}] failed validation:`, result.error.issues); + } + } + if (Object.keys(parsed).length > 0) { + file.interactionDestinations = parsed; + } + } + return file; } diff --git a/src/core/contracts/figma-node.ts b/src/core/contracts/figma-node.ts index 55ed0deb..7bd1e7da 100644 --- a/src/core/contracts/figma-node.ts +++ b/src/core/contracts/figma-node.ts @@ -199,6 +199,7 @@ export const AnalysisFileSchema = z.object({ }) ), componentDefinitions: z.record(z.string(), AnalysisNodeSchema).optional(), + interactionDestinations: z.record(z.string(), AnalysisNodeSchema).optional(), styles: z.record( z.string(), z.object({ diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index 7b84445f..d4a6e371 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -165,6 +165,107 @@ function formatComponentProperties(node: AnalysisNode): string | null { return entries.join(", "); } +/** Extract key visual styles from a node for hover diff comparison. */ +function extractVisualStyles(node: AnalysisNode): Record { + const styles: Record = {}; + const fillInfo = getFillInfo(node); + if (fillInfo.color && node.type !== "TEXT") styles["background"] = fillInfo.color; + const stroke = getStroke(node); + if (stroke) styles["border-color"] = stroke; + if (node.cornerRadius) styles["border-radius"] = `${node.cornerRadius}px`; + if (node.opacity !== undefined && node.opacity < 1) styles["opacity"] = `${Math.round(node.opacity * 100) / 100}`; + const shadow = getShadow(node); + if (shadow) styles["box-shadow"] = shadow; + // Text color + if (node.type === "TEXT") { + const textColor = getFill(node); + if (textColor) styles["color"] = textColor; + } + return styles; +} + +const HOVER_STYLE_DEFAULTS: Record = { + background: "transparent", + "border-color": "transparent", + "border-radius": "0px", + opacity: "1", + "box-shadow": "none", + color: "inherit", +}; + +function getHoverResetValue(styleKey: string): string { + return HOVER_STYLE_DEFAULTS[styleKey] ?? "initial"; +} + +function appendStyleDiffs( + currentStyles: Record, + hoverStyles: Record, + diffs: string[], + namePrefix?: string, +): void { + const styleKeys = new Set([...Object.keys(currentStyles), ...Object.keys(hoverStyles)]); + for (const key of styleKeys) { + const currentValue = currentStyles[key]; + const hoverValue = hoverStyles[key] ?? getHoverResetValue(key); + if (currentValue !== hoverValue) { + const prefix = namePrefix ? `${namePrefix}: ` : ""; + diffs.push(`${prefix}${key}: ${hoverValue}`); + } + } +} + +function getChildStableKey(node: AnalysisNode): string | null { + // Prefer name over id: variant children share the same name but have different ids + if (node.name) return `name:${node.name}`; + return node.id ?? null; +} + +/** Compute style diff between current node and its hover variant. */ +function computeHoverDiff( + currentNode: AnalysisNode, + hoverNode: AnalysisNode, +): string | null { + const current = extractVisualStyles(currentNode); + const hover = extractVisualStyles(hoverNode); + const diffs: string[] = []; + appendStyleDiffs(current, hover, diffs); + // Check children for text/color changes (first level only) + if (currentNode.children && hoverNode.children) { + const hoverByStableKey = new Map(); + const hoverUnmatchedByIndex: AnalysisNode[] = []; + + for (const child of hoverNode.children) { + const key = getChildStableKey(child); + if (key) { + // Keep the first occurrence to reduce noisy collisions. + if (!hoverByStableKey.has(key)) hoverByStableKey.set(key, child); + } else { + hoverUnmatchedByIndex.push(child); + } + } + + let unkeyedIdx = 0; + for (let i = 0; i < currentNode.children.length; i++) { + const cc = currentNode.children[i]; + if (!cc) continue; + + const stableKey = getChildStableKey(cc); + let hc: AnalysisNode | undefined; + if (stableKey) { + hc = hoverByStableKey.get(stableKey); + } else { + hc = hoverUnmatchedByIndex[unkeyedIdx]; + unkeyedIdx++; + } + if (!hc) continue; + const ccStyles = extractVisualStyles(cc); + const hcStyles = extractVisualStyles(hc); + appendStyleDiffs(ccStyles, hcStyles, diffs, cc.name); + } + } + return diffs.length > 0 ? diffs.join("; ") : null; +} + /** Render a single node and its children as indented design-tree text. */ function renderNode( node: AnalysisNode, @@ -174,6 +275,7 @@ function renderNode( imageMapping?: Record, vectorMapping?: Record, fileStyles?: AnalysisFile["styles"], + interactionDests?: Record, ): string { if (node.visible === false) return ""; @@ -381,10 +483,33 @@ function renderNode( lines.push(`${prefix} style: ${styles.join("; ")}`); } + // Interaction states (hover) + if (node.interactions && interactionDests) { + for (const interaction of node.interactions) { + const i = interaction as { + trigger?: { type?: string }; + actions?: Array<{ destinationId?: string; navigation?: string }>; + }; + if (i.trigger?.type === "ON_HOVER" && i.actions) { + for (const action of i.actions) { + if (action.destinationId && action.navigation === "CHANGE_TO") { + const hoverNode = interactionDests[action.destinationId]; + if (hoverNode) { + const diff = computeHoverDiff(node, hoverNode); + if (diff) { + lines.push(`${prefix} [hover]: ${diff}`); + } + } + } + } + } + } + } + // Children if (node.children) { for (const child of node.children) { - const childOutput = renderNode(child, indent + 1, vectorDir, components, imageMapping, vectorMapping, fileStyles); + const childOutput = renderNode(child, indent + 1, vectorDir, components, imageMapping, vectorMapping, fileStyles, interactionDests); if (childOutput) lines.push(childOutput); } } @@ -450,7 +575,7 @@ export function generateDesignTreeWithStats(file: AnalysisFile, options?: Design } } - const tree = renderNode(root, 0, options?.vectorDir, file.components, imageMapping, vectorMapping, file.styles); + const tree = renderNode(root, 0, options?.vectorDir, file.components, imageMapping, vectorMapping, file.styles, file.interactionDestinations); const result = [ "# Design Tree", diff --git a/src/core/engine/loader.ts b/src/core/engine/loader.ts index 6d9c5411..f1cd9b58 100644 --- a/src/core/engine/loader.ts +++ b/src/core/engine/loader.ts @@ -1,7 +1,7 @@ import { existsSync, statSync } from "node:fs"; import { resolve, join } from "node:path"; import { FigmaClient } from "../adapters/figma-client.js"; -import { resolveComponentDefinitions } from "../adapters/component-resolver.js"; +import { resolveComponentDefinitions, resolveInteractionDestinations } from "../adapters/component-resolver.js"; import { loadFigmaFileFromJson } from "../adapters/figma-file-loader.js"; import { transformFigmaResponse, transformFileNodesResponse } from "../adapters/figma-transformer.js"; import { parseFigmaUrl } from "../adapters/figma-url-parser.js"; @@ -103,6 +103,12 @@ async function loadFromApi( file.componentDefinitions = componentDefs; } + // Resolve interaction destination nodes (e.g., hover variants) + const interactionDests = await resolveInteractionDestinations(client, fileKey, file.document, file.componentDefinitions); + if (Object.keys(interactionDests).length > 0) { + file.interactionDestinations = interactionDests; + } + return { file, nodeId }; } @@ -115,5 +121,11 @@ async function loadFromApi( file.componentDefinitions = componentDefs; } + // Resolve interaction destination nodes (e.g., hover variants) + const interactionDests = await resolveInteractionDestinations(client, fileKey, file.document, file.componentDefinitions); + if (Object.keys(interactionDests).length > 0) { + file.interactionDestinations = interactionDests; + } + return { file, nodeId }; }