From d2d3f25f5d7649fc8116ef77b11a329f9794000a Mon Sep 17 00:00:00 2001 From: let-sunny Date: Fri, 27 Mar 2026 19:20:48 +0900 Subject: [PATCH 1/5] feat: resolve interaction destinations and show hover styles in design-tree Add support for fetching and displaying hover variant data: - component-resolver: collectInteractionDestinationIds() collects all destinationId values from ON_HOVER/CHANGE_TO interactions - component-resolver: resolveInteractionDestinations() fetches hover variant nodes via Figma API (batched, same pattern as component defs) - figma-node schema: add interactionDestinations field to AnalysisFile - loader/save-fixture: wire up interaction resolution after component defs - figma-file-loader: preserve interactionDestinations from saved fixtures - design-tree: output [hover] blocks showing style diffs between current state and hover variant (background, border, opacity, shadow, text color) Example output: Button (INSTANCE, 120x40) [component: Button] style: background: #2C2C2C [hover]: background: #1E1E1E /* var:VariableID:3919:36431 */ This enables survey item #11 (hover/interaction states) to measure actual value with real data instead of hypothetical questions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/commands/save-fixture.ts | 9 ++- src/core/adapters/component-resolver.ts | 73 +++++++++++++++++++++++ src/core/adapters/figma-file-loader.ts | 17 ++++++ src/core/contracts/figma-node.ts | 1 + src/core/engine/design-tree.ts | 79 ++++++++++++++++++++++++- src/core/engine/loader.ts | 14 ++++- 6 files changed, 189 insertions(+), 4 deletions(-) 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..8f4a67e2 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.actions) { + for (const action of i.actions) { + if (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,42 @@ 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 {}; + + // Skip IDs already resolved as component definitions + const idsToFetch = [...destIds].filter( + (id) => !existingDefinitions?.[id] + ); + if (idsToFetch.length === 0) return {}; + + const allDestinations: Record = {}; + + 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..b4b59834 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -165,6 +165,57 @@ 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) 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; +} + +/** 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[] = []; + for (const [key, val] of Object.entries(hover)) { + if (current[key] !== val) { + diffs.push(`${key}: ${val}`); + } + } + // Check children for text/color changes (first level only) + if (currentNode.children && hoverNode.children) { + const len = Math.min(currentNode.children.length, hoverNode.children.length); + for (let i = 0; i < len; i++) { + const cc = currentNode.children[i]; + const hc = hoverNode.children[i]; + if (!cc || !hc) continue; + const ccStyles = extractVisualStyles(cc); + const hcStyles = extractVisualStyles(hc); + for (const [key, val] of Object.entries(hcStyles)) { + if (ccStyles[key] !== val) { + diffs.push(`${cc.name}: ${key}: ${val}`); + } + } + } + } + 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 +225,7 @@ function renderNode( imageMapping?: Record, vectorMapping?: Record, fileStyles?: AnalysisFile["styles"], + interactionDests?: Record, ): string { if (node.visible === false) return ""; @@ -381,10 +433,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 +525,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 }; } From d40f3e7cc96bf2b5c5594ce2de330d4b82ab5235 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Fri, 27 Mar 2026 19:44:50 +0900 Subject: [PATCH 2/5] fix: correct hover interaction destination resolution Ensure [hover] rendering only uses ON_HOVER + CHANGE_TO targets and that style diffs include resets/removals while avoiding bogus TEXT background diffs. Made-with: Cursor --- src/core/adapters/component-resolver.ts | 22 ++++++++----- src/core/engine/design-tree.ts | 44 ++++++++++++++++++------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/core/adapters/component-resolver.ts b/src/core/adapters/component-resolver.ts index 8f4a67e2..b1ba23c1 100644 --- a/src/core/adapters/component-resolver.ts +++ b/src/core/adapters/component-resolver.ts @@ -39,9 +39,9 @@ export function collectInteractionDestinationIds(node: AnalysisNode): Set; }; - if (i.actions) { + if (i.trigger?.type === "ON_HOVER" && i.actions) { for (const action of i.actions) { - if (action.destinationId) { + if (action.navigation === "CHANGE_TO" && action.destinationId) { ids.add(action.destinationId); } } @@ -138,13 +138,19 @@ export async function resolveInteractionDestinations( const destIds = collectInteractionDestinationIds(document); if (destIds.size === 0) return {}; - // Skip IDs already resolved as component definitions - const idsToFetch = [...destIds].filter( - (id) => !existingDefinitions?.[id] - ); - if (idsToFetch.length === 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); diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index b4b59834..6edfa43a 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -169,7 +169,7 @@ function formatComponentProperties(node: AnalysisNode): string | null { function extractVisualStyles(node: AnalysisNode): Record { const styles: Record = {}; const fillInfo = getFillInfo(node); - if (fillInfo.color) styles["background"] = fillInfo.color; + 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`; @@ -184,6 +184,36 @@ function extractVisualStyles(node: AnalysisNode): Record { 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}`); + } + } +} + /** Compute style diff between current node and its hover variant. */ function computeHoverDiff( currentNode: AnalysisNode, @@ -192,11 +222,7 @@ function computeHoverDiff( const current = extractVisualStyles(currentNode); const hover = extractVisualStyles(hoverNode); const diffs: string[] = []; - for (const [key, val] of Object.entries(hover)) { - if (current[key] !== val) { - diffs.push(`${key}: ${val}`); - } - } + appendStyleDiffs(current, hover, diffs); // Check children for text/color changes (first level only) if (currentNode.children && hoverNode.children) { const len = Math.min(currentNode.children.length, hoverNode.children.length); @@ -206,11 +232,7 @@ function computeHoverDiff( if (!cc || !hc) continue; const ccStyles = extractVisualStyles(cc); const hcStyles = extractVisualStyles(hc); - for (const [key, val] of Object.entries(hcStyles)) { - if (ccStyles[key] !== val) { - diffs.push(`${cc.name}: ${key}: ${val}`); - } - } + appendStyleDiffs(ccStyles, hcStyles, diffs, cc.name); } } return diffs.length > 0 ? diffs.join("; ") : null; From 90bff305989aa3fa282de6142c24cda323eae9ee Mon Sep 17 00:00:00 2001 From: let-sunny Date: Fri, 27 Mar 2026 19:55:32 +0900 Subject: [PATCH 3/5] fix: robust hover diff child matching Made-with: Cursor --- src/core/engine/design-tree.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index 6edfa43a..95c9bcdf 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -214,6 +214,10 @@ function appendStyleDiffs( } } +function getChildStableKey(node: AnalysisNode): string | null { + return node.id ?? (node.name ? `name:${node.name}` : null); +} + /** Compute style diff between current node and its hover variant. */ function computeHoverDiff( currentNode: AnalysisNode, @@ -225,11 +229,26 @@ function computeHoverDiff( appendStyleDiffs(current, hover, diffs); // Check children for text/color changes (first level only) if (currentNode.children && hoverNode.children) { - const len = Math.min(currentNode.children.length, hoverNode.children.length); - for (let i = 0; i < len; i++) { + 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); + } + } + + for (let i = 0; i < currentNode.children.length; i++) { const cc = currentNode.children[i]; - const hc = hoverNode.children[i]; - if (!cc || !hc) continue; + if (!cc) continue; + + const stableKey = getChildStableKey(cc); + const hc = stableKey ? hoverByStableKey.get(stableKey) : hoverUnmatchedByIndex[i]; + if (!hc) continue; const ccStyles = extractVisualStyles(cc); const hcStyles = extractVisualStyles(hc); appendStyleDiffs(ccStyles, hcStyles, diffs, cc.name); From da75cd69f44af9be4e9885b994e5170e02f02481 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Fri, 27 Mar 2026 20:01:43 +0900 Subject: [PATCH 4/5] fix: correct unkeyed child index drift in hover diff Use separate unkeyedIdx counter for fallback matching instead of the loop index i, which drifts when keyed children precede unkeyed ones. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/engine/design-tree.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index 95c9bcdf..6beecbed 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -242,12 +242,19 @@ function computeHoverDiff( } } + let unkeyedIdx = 0; for (let i = 0; i < currentNode.children.length; i++) { const cc = currentNode.children[i]; if (!cc) continue; const stableKey = getChildStableKey(cc); - const hc = stableKey ? hoverByStableKey.get(stableKey) : hoverUnmatchedByIndex[i]; + 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); From 42c9f494e383843b6b4beaad44fa29f710f62c1d Mon Sep 17 00:00:00 2001 From: let-sunny Date: Fri, 27 Mar 2026 20:10:33 +0900 Subject: [PATCH 5/5] fix: prefer name over id for hover child matching Variant children share the same name but have different ids. Using name-first matching ensures hover diffs correctly pair corresponding children across default and hover variants. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/engine/design-tree.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index 6beecbed..d4a6e371 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -215,7 +215,9 @@ function appendStyleDiffs( } function getChildStableKey(node: AnalysisNode): string | null { - return node.id ?? (node.name ? `name:${node.name}` : 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. */