Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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;
}

/**
* 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;
}
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
129 changes: 127 additions & 2 deletions src/core/engine/design-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;
}

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 {
// 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<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++;
}
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,
Expand All @@ -174,6 +275,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 +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);
}
}
Expand Down Expand Up @@ -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",
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