Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e7a2339
docs: design spec for /understand-figma (ingestion + structure founda…
smjeong84 Jun 24, 2026
2c10068
docs: Korean translation of /understand-figma foundation spec
smjeong84 Jun 24, 2026
557cbcf
docs: implementation plan for /understand-figma foundation
smjeong84 Jun 24, 2026
3572232
feat(core): add design NodeType/EdgeType/FigmaMeta + design kind
smjeong84 Jun 24, 2026
0fb7125
feat(core): validate design node/edge types, figmaMeta, promote insta…
smjeong84 Jun 24, 2026
8282627
feat(core): FigmaSource adapter + FigmaApiSource (token via env, no l…
smjeong84 Jun 24, 2026
654fa34
feat(core): deterministic Figma document parser (pages/screens/compon…
smjeong84 Jun 24, 2026
cbdf019
feat(core): extract design tokens from published styles + uses_token …
smjeong84 Jun 24, 2026
e167438
fix(core): keep componentSet nodes through validateGraph sanitize low…
smjeong84 Jun 24, 2026
5fb2ea8
feat(core): merge Figma manifest + analysis into kind:design graph wi…
smjeong84 Jun 24, 2026
75b0c93
feat(core): barrel export for the figma module
smjeong84 Jun 24, 2026
20ebfb5
feat(agents): add design-analyzer (semantic enrichment for Figma graphs)
smjeong84 Jun 24, 2026
007822f
feat(figma): core ./figma export + scan/merge wrapper scripts
smjeong84 Jun 24, 2026
8e7edb3
feat(skill): /understand-figma orchestration (fetch/parse, analyze, m…
smjeong84 Jun 24, 2026
e3a663a
feat(figma): pre-fetch screen thumbnails into figmaMeta.thumbnailUrl
smjeong84 Jun 24, 2026
a0162f3
feat(dashboard): node colors for design node types
smjeong84 Jun 24, 2026
6e04c0f
feat(dashboard): design badges + sidebar Figma thumbnail
smjeong84 Jun 24, 2026
9056407
fix(dashboard): map design node types to a filter category
smjeong84 Jun 24, 2026
66c6e56
feat(figma): incremental skip when file version is unchanged
smjeong84 Jun 24, 2026
c1f7544
fix(dashboard): render design node types in the drill-in canvas + gua…
smjeong84 Jun 24, 2026
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
1,544 changes: 1,544 additions & 0 deletions docs/superpowers/plans/2026-06-24-understand-figma-foundation.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions understand-anything-plugin/agents/design-analyzer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: design-analyzer
description: |
Analyzes Figma structural nodes (pages, screens, components, instances, tokens) from a deterministic manifest and adds semantic enrichment — concise summaries, tags, and a screen's purpose — plus conservative `related` edges. Does NOT invent structural nodes or edges.
---

# Design Analyzer Agent

You enrich a Figma design graph. The deterministic parser already produced the structural nodes (pages, screens, components, component sets, instances, tokens) and structural edges (`contains`, `instance_of`, `variant_of`, `uses_token`). Your job is the semantic layer only.

## Input

A JSON batch of manifest nodes. Each has:
- `id`, `type` (page | screen | component | componentSet | instance | token), `name`
- `figmaMeta` (dimensions, tokenKind, componentKey, etc.)
- `childSummary`: names of notable children (for screens/components)
- `tokenUsage`: token names this node uses (if any)

You also receive the full list of existing node IDs so you can reference them.

## Task

For each node, produce an enrichment object:
- `summary`: one or two sentences — what the screen/component is FOR (purpose), not a description of pixels. For tokens, state the role (e.g., "Primary brand color used on CTAs").
- `tags`: 2–5 lowercase tags (feature area, role, state). Examples: `auth`, `entry`, `cta`, `list`, `empty-state`, `primary`.

Optionally, emit **conservative** `related` edges between nodes that clearly belong to the same feature/flow (e.g., two screens of the same onboarding flow). Only when names/structure make it obvious.

## Rules

1. **Do NOT** emit `page`/`screen`/`component`/`componentSet`/`instance`/`token` nodes — they already exist. Only enrichment + optional `related` edges.
2. **Do NOT** re-emit structural edges (`contains`, `instance_of`, `variant_of`, `uses_token`).
3. Use exact existing `id`s when emitting `related` edges.
4. Be concise. For a batch of ~15 nodes, expect ~15 enrichments and 0–8 `related` edges.

## Output Format

Write a JSON file to `$INTERMEDIATE_DIR/analysis-batch-$BATCH_NUM.json`:

```json
{
"nodes": [
{ "id": "screen:1:1", "summary": "The sign-in screen where returning users authenticate.", "tags": ["auth", "entry"] }
],
"edges": [
{ "source": "screen:1:1", "target": "screen:1:5", "type": "related", "direction": "forward", "weight": 0.5, "description": "Both part of the sign-in flow" }
]
}
```

Output ONLY enrichment objects (`id` + `summary`/`tags`) and optional `related` edges. Nothing else.
4 changes: 4 additions & 0 deletions understand-anything-plugin/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"./languages": {
"types": "./dist/languages/index.d.ts",
"default": "./dist/languages/index.js"
},
"./figma": {
"types": "./dist/figma/index.d.ts",
"default": "./dist/figma/index.js"
}
},
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -727,3 +727,57 @@ describe("Extended node/edge types", () => {
expect(result.success).toBe(true);
});
});

function designGraph() {
return {
version: "1.0.0",
kind: "design",
project: { name: "F", languages: ["figma"], frameworks: [], description: "d", analyzedAt: "t", gitCommitHash: "" },
nodes: [
{ id: "screen:1:2", type: "screen", name: "Login", summary: "s", tags: ["auth"], complexity: "simple" },
{ id: "component:3:4", type: "component", name: "Button/Primary", summary: "s", tags: ["ds"], complexity: "simple" },
{ id: "instance:5:6", type: "instance", name: "SignInBtn", summary: "s", tags: ["use"], complexity: "simple" },
{ id: "token:color:brand", type: "token", name: "color/brand", summary: "s", tags: ["token"], complexity: "simple" },
],
edges: [
{ source: "instance:5:6", target: "component:3:4", type: "instance_of", direction: "forward", weight: 0.8 },
{ source: "component:3:4", target: "token:color:brand", type: "uses_token", direction: "forward", weight: 0.5 },
],
layers: [],
tour: [],
};
}

describe("design graph schema", () => {
it("accepts design node and edge types", () => {
const res = validateGraph(designGraph());
expect(res.success).toBe(true);
expect(res.data!.nodes).toHaveLength(4);
expect(res.data!.edges).toHaveLength(2);
});

it("keeps instance_of as a first-class edge (NOT rewritten to exemplifies)", () => {
const res = validateGraph(designGraph());
const e = res.data!.edges.find((x) => x.source === "instance:5:6");
expect(e!.type).toBe("instance_of");
});

it("normalizes figma node-type aliases (frame → screen)", () => {
const g = designGraph();
g.nodes[0].type = "frame";
const res = validateGraph(g);
expect(res.data!.nodes.find((n) => n.id === "screen:1:2")!.type).toBe("screen");
});

it("keeps componentSet (the only camelCase node type) through sanitize lowercasing", () => {
const g = designGraph();
g.nodes.push({ id: "componentSet:7:8", type: "componentSet", name: "Button", summary: "s", tags: ["ds"], complexity: "simple" });
g.edges.push({ source: "component:3:4", target: "componentSet:7:8", type: "variant_of", direction: "forward", weight: 0.9 });
const res = validateGraph(g);
const set = res.data!.nodes.find((n) => n.id === "componentSet:7:8");
expect(set).toBeTruthy();
expect(set!.type).toBe("componentSet");
// the variant_of edge must survive (its target was not dropped)
expect(res.data!.edges.some((e) => e.target === "componentSet:7:8" && e.type === "variant_of")).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { parseFileKey, FigmaApiSource } from "../source/api-source";

afterEach(() => { vi.restoreAllMocks(); delete process.env.FIGMA_TOKEN; });

describe("parseFileKey", () => {
it("extracts key from a /file/ URL", () => {
expect(parseFileKey("https://www.figma.com/file/ABC123/My-App")).toBe("ABC123");
});
it("extracts key from a /design/ URL with query", () => {
expect(parseFileKey("https://www.figma.com/design/XYZ789/App?node-id=1-2")).toBe("XYZ789");
});
it("accepts a bare key", () => {
expect(parseFileKey("ABC123")).toBe("ABC123");
});
it("throws on unparseable input", () => {
expect(() => parseFileKey("not a key!!")).toThrow();
});
});

describe("FigmaApiSource", () => {
it("throws a friendly error when FIGMA_TOKEN is missing", () => {
delete process.env.FIGMA_TOKEN;
expect(() => new FigmaApiSource("ABC123")).toThrow(/FIGMA_TOKEN/);
});
it("fetches the document and sends the token header", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ name: "Doc", document: { id: "0:0", type: "DOCUMENT", name: "Doc", children: [] } }),
});
vi.stubGlobal("fetch", fetchMock);
const src = new FigmaApiSource("ABC123", "tok_secret");
const doc = await src.fetchDocument();
expect(doc.name).toBe("Doc");
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/files/ABC123");
expect((init.headers as Record<string, string>)["X-Figma-Token"]).toBe("tok_secret");
});
it("never leaks the token in error messages", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403, statusText: "Forbidden" }));
const src = new FigmaApiSource("ABC123", "tok_secret");
await expect(src.fetchDocument()).rejects.toThrow(/403/);
await expect(src.fetchDocument()).rejects.not.toThrow(/tok_secret/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { mergeDesignGraph } from "../merge";
import type { GraphNode, GraphEdge, ProjectMeta } from "../../types";

const project: ProjectMeta = { name: "MyApp", languages: ["figma"], frameworks: [], description: "d", analyzedAt: "t", gitCommitHash: "" };
const manifest = {
nodes: [
{ id: "page:1:0", type: "page", name: "Onboarding", summary: "Onboarding", tags: ["page"], complexity: "simple" },
{ id: "screen:1:1", type: "screen", name: "Login", summary: "Login", tags: ["screen"], complexity: "simple" },
{ id: "component:2:1", type: "component", name: "Primary", summary: "Primary", tags: ["component"], complexity: "simple" },
{ id: "token:color:brand", type: "token", name: "brand", summary: "brand", tags: ["token"], complexity: "simple" },
] as GraphNode[],
edges: [
{ source: "page:1:0", target: "screen:1:1", type: "contains", direction: "forward", weight: 1 },
{ source: "component:2:1", target: "token:color:brand", type: "uses_token", direction: "forward", weight: 0.5 },
] as GraphEdge[],
};

describe("mergeDesignGraph", () => {
it("produces a valid kind:design graph", () => {
const res = mergeDesignGraph(manifest, [], project);
expect(res.success).toBe(true);
expect(res.data!.kind).toBe("design");
});
it("groups screens under their page layer and DS nodes under Design System", () => {
const { data } = mergeDesignGraph(manifest, [], project);
const ds = data!.layers.find((l) => l.id === "layer:design-system")!;
expect(ds.nodeIds).toEqual(expect.arrayContaining(["component:2:1", "token:color:brand"]));
const page = data!.layers.find((l) => l.name === "Onboarding")!;
expect(page.nodeIds).toEqual(expect.arrayContaining(["page:1:0", "screen:1:1"]));
});
it("applies design-analyzer enrichment by id", () => {
const { data } = mergeDesignGraph(manifest, [{ nodes: [{ id: "screen:1:1", summary: "The sign-in screen", tags: ["auth", "entry"] }] }], project);
const screen = data!.nodes.find((n) => n.id === "screen:1:1")!;
expect(screen.summary).toBe("The sign-in screen");
expect(screen.tags).toEqual(["auth", "entry"]);
});
it("builds a tour that starts with the Design System", () => {
const { data } = mergeDesignGraph(manifest, [], project);
expect(data!.tour[0].title).toBe("Design System");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { parseDocument } from "../parse/parse-document";
import type { FigmaDocument } from "../source/types";

const doc: FigmaDocument = {
name: "MyApp",
document: {
id: "0:0", name: "Document", type: "DOCUMENT", children: [
{ id: "1:0", name: "Onboarding", type: "CANVAS", children: [
{ id: "1:1", name: "Login", type: "FRAME", absoluteBoundingBox: { width: 375, height: 812 }, children: [
{ id: "1:2", name: "SignInBtn", type: "INSTANCE", componentId: "2:1", children: [] },
] },
] },
{ id: "1:9", name: "Components", type: "CANVAS", children: [
{ id: "2:0", name: "Button", type: "COMPONENT_SET", children: [
{ id: "2:1", name: "Primary", type: "COMPONENT", children: [] },
{ id: "2:2", name: "Secondary", type: "COMPONENT", children: [] },
] },
] },
],
},
};

describe("parseDocument", () => {
const { nodes, edges } = parseDocument(doc, "ABC123");
const ids = nodes.map((n) => n.id);
const has = (s: string, t: string, ty: string) =>
edges.some((e) => e.source === s && e.target === t && e.type === ty);

it("creates page/screen/instance/componentSet/component nodes", () => {
expect(ids).toEqual(expect.arrayContaining([
"page:1:0", "screen:1:1", "instance:1:2", "page:1:9", "componentSet:2:0", "component:2:1", "component:2:2",
]));
});
it("links containment, instance_of, and variant_of", () => {
expect(has("page:1:0", "screen:1:1", "contains")).toBe(true);
expect(has("screen:1:1", "instance:1:2", "contains")).toBe(true);
expect(has("instance:1:2", "component:2:1", "instance_of")).toBe(true);
expect(has("component:2:1", "componentSet:2:0", "variant_of")).toBe(true);
expect(has("component:2:2", "componentSet:2:0", "variant_of")).toBe(true);
});
it("captures screen dimensions and fileKey in figmaMeta", () => {
const screen = nodes.find((n) => n.id === "screen:1:1")!;
expect(screen.figmaMeta?.dimensions?.width).toBe(375);
expect(screen.figmaMeta?.fileKey).toBe("ABC123");
});
it("emits validateGraph-ready nodes (summary/tags/complexity present)", () => {
expect(nodes.every((n) => n.summary && n.tags.length > 0 && n.complexity)).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { extractTokens } from "../parse/tokens";
import type { FigmaDocument, FigmaStyles } from "../source/types";
import type { GraphNode } from "../../types";

const doc: FigmaDocument = {
name: "MyApp",
document: {
id: "0:0", name: "Document", type: "DOCUMENT", children: [
{ id: "1:9", name: "Components", type: "CANVAS", children: [
{ id: "2:1", name: "Primary", type: "COMPONENT", styles: { fill: "S_KEY" }, children: [] },
] },
],
},
};
const styles: FigmaStyles = { meta: { styles: [{ key: "S_KEY", name: "color/brand-500", style_type: "FILL" }] } };
const structural: GraphNode[] = [
{ id: "component:2:1", type: "component", name: "Primary", summary: "Primary", tags: ["component"], complexity: "simple", figmaMeta: { fileKey: "ABC", nodeId: "2:1" } },
];

describe("extractTokens", () => {
const { nodes, edges } = extractTokens(doc, styles, structural, "ABC");
it("creates a token node per published style with tokenKind", () => {
const token = nodes.find((n) => n.type === "token");
expect(token).toBeTruthy();
expect(token!.figmaMeta?.tokenKind).toBe("color");
expect(token!.name).toBe("color/brand-500");
});
it("links consumers to tokens via uses_token", () => {
const token = nodes.find((n) => n.type === "token")!;
expect(edges).toEqual(expect.arrayContaining([
expect.objectContaining({ source: "component:2:1", target: token.id, type: "uses_token" }),
]));
});
});
5 changes: 5 additions & 0 deletions understand-anything-plugin/packages/core/src/figma/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { parseFileKey, FigmaApiSource } from "./source/api-source.js";
export type { FigmaSource, FigmaDocument, FigmaStyles, FigmaNode } from "./source/types.js";
export { parseDocument } from "./parse/parse-document.js";
export { extractTokens } from "./parse/tokens.js";
export { mergeDesignGraph, type DesignAnalysis } from "./merge.js";
84 changes: 84 additions & 0 deletions understand-anything-plugin/packages/core/src/figma/merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { KnowledgeGraph, GraphNode, GraphEdge, Layer, TourStep, ProjectMeta } from "../types.js";
import { validateGraph, type ValidationResult } from "../schema.js";

export interface DesignAnalysis {
nodes?: Array<Pick<GraphNode, "id"> & Partial<Pick<GraphNode, "summary" | "tags">>>;
edges?: GraphEdge[];
}

const DS_TYPES = new Set<GraphNode["type"]>(["component", "componentSet", "token"]);

export function mergeDesignGraph(
manifest: { nodes: GraphNode[]; edges: GraphEdge[] },
analyses: DesignAnalysis[],
project: ProjectMeta,
): ValidationResult {
// 1. index manifest nodes (clone so we can enrich)
const byId = new Map<string, GraphNode>();
for (const n of manifest.nodes) byId.set(n.id, { ...n });
const edges: GraphEdge[] = [...manifest.edges];

// 2. apply LLM enrichment; design-analyzer must not invent structural nodes
for (const a of analyses) {
for (const patch of a.nodes ?? []) {
const base = byId.get(patch.id);
if (!base) continue;
if (patch.summary) base.summary = patch.summary;
if (patch.tags && patch.tags.length) base.tags = patch.tags;
}
for (const e of a.edges ?? []) edges.push(e);
}
const nodes = [...byId.values()];

// 3. layers: one per page (+ descendants), plus a Design System layer
const parent = new Map<string, string>();
for (const e of manifest.edges) if (e.type === "contains") parent.set(e.target, e.source);
const pageOf = (id: string): string | undefined => {
let cur: string | undefined = id;
const guard = new Set<string>();
while (cur && !guard.has(cur)) {
guard.add(cur);
if (byId.get(cur)?.type === "page") return cur;
cur = parent.get(cur);
}
return undefined;
};
const layerMap = new Map<string, string[]>();
const ds: string[] = [];
for (const n of nodes) {
if (DS_TYPES.has(n.type)) { ds.push(n.id); continue; }
const key = n.type === "page" ? n.id : (pageOf(n.id) ?? "layer:unscoped");
if (!layerMap.has(key)) layerMap.set(key, []);
layerMap.get(key)!.push(n.id);
}
const layers: Layer[] = [];
for (const [pageId, ids] of layerMap) {
const pageNode = byId.get(pageId);
layers.push({
id: `layer:${pageId}`,
name: pageNode?.name ?? "Unscoped",
description: pageNode ? `Figma page: ${pageNode.name}` : "Nodes not under a page",
nodeIds: ids,
});
}
if (ds.length) {
layers.push({ id: "layer:design-system", name: "Design System", description: "Components, variants, and design tokens", nodeIds: ds });
}

// 4. tour: Design System first, then each page
const tour: TourStep[] = [];
let order = 1;
if (ds.length) tour.push({ order: order++, title: "Design System", description: "Shared components, variants, and tokens the screens are built from.", nodeIds: ds.slice(0, 8) });
for (const l of layers) {
if (l.id === "layer:design-system") continue;
tour.push({ order: order++, title: l.name, description: `Screens on the "${l.name}" page.`, nodeIds: l.nodeIds.slice(0, 8) });
}

// 5. assemble + validate, then re-attach kind (validateGraph drops it)
const graph: KnowledgeGraph = { version: "1.0.0", kind: "design", project, nodes, edges, layers, tour };
const result = validateGraph(graph);
if (result.success && result.data) {
(result.data as KnowledgeGraph).kind = "design";
}
return result;
}
Loading
Loading