Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/cli/commands/save-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)");
}
Expand Down
79 changes: 79 additions & 0 deletions src/core/adapters/component-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,40 @@ export function collectComponentIds(node: AnalysisNode): Set<string> {
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<string> {
const ids = new Set<string>();

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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolve component master node trees via multi-pass fetching.
*
Expand Down Expand Up @@ -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<string, AnalysisNode>,
): Promise<Record<string, AnalysisNode>> {
const destIds = collectInteractionDestinationIds(document);
if (destIds.size === 0) return {};

const allDestinations: Record<string, AnalysisNode> = {};
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
17 changes: 17 additions & 0 deletions src/core/adapters/figma-file-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function loadFigmaFileFromJson(
const content = await readFile(filePath, "utf-8");
const data = JSON.parse(content) as GetFileResponse & {
componentDefinitions?: Record<string, unknown>;
interactionDestinations?: Record<string, unknown>;
};

const fileKey = extractFileKey(filePath);
Expand All @@ -51,6 +52,22 @@ export async function loadFigmaFileFromJson(
}
}

// Preserve interactionDestinations from previously-saved fixtures
if (data.interactionDestinations) {
const parsed: Record<string, AnalysisNode> = {};
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;
}

Expand Down
1 change: 1 addition & 0 deletions src/core/contracts/figma-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
127 changes: 125 additions & 2 deletions src/core/engine/design-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,105 @@ 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<string, string> {
const styles: Record<string, string> = {};
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const HOVER_STYLE_DEFAULTS: Record<string, string> = {
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<string, string>,
hoverStyles: Record<string, string>,
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 {
return node.id ?? (node.name ? `name:${node.name}` : 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<string, AnalysisNode>();
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++;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!hc) continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const ccStyles = extractVisualStyles(cc);
const hcStyles = extractVisualStyles(hc);
appendStyleDiffs(ccStyles, hcStyles, diffs, cc.name);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return diffs.length > 0 ? diffs.join("; ") : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Render a single node and its children as indented design-tree text. */
function renderNode(
node: AnalysisNode,
Expand All @@ -174,6 +273,7 @@ function renderNode(
imageMapping?: Record<string, string>,
vectorMapping?: Record<string, string>,
fileStyles?: AnalysisFile["styles"],
interactionDests?: Record<string, AnalysisNode>,
): string {
if (node.visible === false) return "";

Expand Down Expand Up @@ -381,10 +481,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);
}
}
Expand Down Expand Up @@ -450,7 +573,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",
Expand Down
14 changes: 13 additions & 1 deletion src/core/engine/loader.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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 };
}
Loading