From 2a18a4a4ddf513d634b458b30ca249bc8312a14c Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 16:03:27 +0900 Subject: [PATCH 01/23] docs: design spec for /understand-figma (ingestion + structure foundation) Sub-project 1 of 5: Figma REST API ingestion behind a source-adapter seam, shallow structural graph (page/screen/component/componentSet/ instance) + light design-system model (token nodes, uses_token), a new design-analyzer LLM agent, kind:"design" dashboard view with hybrid text-node + sidebar-thumbnail rendering. B/C/D/E captured as roadmap. --- ...6-24-understand-figma-foundation-design.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.md diff --git a/docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.md b/docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.md new file mode 100644 index 000000000..d060adb25 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.md @@ -0,0 +1,315 @@ +# /understand-figma — Figma Ingestion & Structure (Foundation) Design + +**Date**: 2026-06-24 +**Status**: Approved +**Approach**: Foundation-first — this is **Sub-project 1 of 5**. It delivers Figma ingestion + structural analysis + a light design-system model. User flows (B), design↔code mapping (C), design-system audit (D), and planning-document analysis (E) are scoped here as a **roadmap**, each to get its own spec → plan → implementation cycle. + +## Overview + +A new `/understand-figma` skill within the existing Understand Anything plugin that takes a Figma file and produces an interactive knowledge graph — pages, screens, components, component sets, instances, and design tokens — visualized in the existing dashboard with a `kind: "design"` layout. + +This mirrors how `/understand-knowledge` (wikis) and `/understand-domain` (business domains) extended the tool to non-code inputs: deterministic parsing builds a structural skeleton, an LLM agent adds semantics, a merge step assembles the same `knowledge-graph.json`, and the same dashboard renders it. + +### Goals + +- Ingest a Figma file via the **Figma REST API** (`GET /v1/files/:key`) behind a pluggable source-adapter seam, so an offline local-JSON source can be added later without rework. +- Produce a **shallow** structural graph: `page → screen → component / componentSet / instance`, plus a light **design-system model** (`token` nodes for color/type/spacing/effect styles, with `uses_token` relationships). +- Add semantic enrichment (summaries, tags, layer hints, screen purpose) via a new `design-analyzer` LLM agent. +- Reuse the existing schema, persistence, validation, and dashboard; add only a `kind: "design"` view and a sidebar thumbnail. +- Render with a **hybrid** strategy: lightweight text nodes in the graph; the selected node's thumbnail in the sidebar (on demand). +- Record forward-looking metadata (`prototypeTargets`, `componentKey`) during v1 parsing so roadmap items B and C can be enabled later without re-parsing. + +### Non-Goals + +- **Generating** design-system artifacts (code component libraries, token files, Storybook). This is analysis/modeling only — confirmed with the user (interpretation "i", not "ii"). +- In-node thumbnail rendering in the graph canvas (perf/storage cost) — sidebar preview only in v1. +- Turning every Figma layer into a node (a screen can have hundreds of layers). Deeper layers are **read** (for `instance_of` links, token usage, future planning-text extraction) but not made nodes. "Deep-expand a screen" is a future enhancement. +- User flows (B), design↔code mapping (C), design-system audit (D), planning-document analysis (E) — these are the roadmap, not v1. +- Offline `.fig` parsing (proprietary binary). Offline support arrives later via a local-JSON source adapter. + +--- + +## Scope Decomposition (why foundation-first) + +The user wants all five capabilities. They layer on a shared foundation, so we build the foundation first: + +``` + ③ C Design ↔ Code + (needs Figma graph + code graph + matching) + ▲ + ② B Flows ② D Audit ② E Planning-text ← built on the parsed structure + ▲ ▲ ▲ + └──────────┴────────────┘ + │ + ① Foundation: Figma ingestion + structure (+ light design-system model) ← THIS SPEC +``` + +- **A (this spec)** establishes ingestion, the `kind: "design"` schema, the parsing module, the skill skeleton, and the dashboard view — the prerequisite for everything else. +- **B / D / E** are additional extractions over the same parsed data. +- **C** is the capstone: it needs both a Figma graph (A) and a code graph (`/understand`) plus a matching strategy. + +Each roadmap item gets its own spec → plan → implementation cycle. + +--- + +## Schema Extensions + +Same mechanism as the `domain` and `knowledge` extensions: the `NodeType`/`EdgeType` zod enums are **closed** (`validateGraph` drops unknown types), so new types are **added** to the enums, and alias-map entries normalize LLM vocabulary. `GraphNode` uses `.passthrough()`, so a typed `figmaMeta` field rides alongside `domainMeta`/`knowledgeMeta`. + +### Graph-Level Kind Flag + +```typescript +export interface KnowledgeGraph { + version: string; + kind?: "codebase" | "knowledge" | "design"; // add "design" + // ... +} +``` + +Graphs without a `kind` default to `"codebase"` (unchanged). The dashboard switches layout/styling on `kind`. + +### New Node Types (6) — 21 → 27 + +| Type | What it represents | Example | ID convention | +|------|-------------------|---------|---------------| +| `page` | A Figma page (canvas) | "Onboarding" | `page:` | +| `screen` | A top-level frame / artboard (a UI screen) | "Login" | `screen:` | +| `component` | A main component | "Button/Primary" | `component:` | +| `componentSet` | A set of variants | "Button" | `componentSet:` | +| `instance` | A use of a component | "Login › SignInBtn" | `instance:` | +| `token` | A design token / published style (color, type, spacing, effect, grid) | "color/brand-500" | `token::` | + +Figma "styles" are folded into `token` (distinguished by `figmaMeta.tokenKind`) to keep the type count down. + +### New Edge Types (3) — 35 → 38 (+ reuse `contains`) + +| Type | Direction | Meaning | +|------|-----------|---------| +| `contains` *(reuse)* | page → screen, screen → instance, componentSet → component | Structural containment | +| `instance_of` *(new)* | instance → component | An instance of a component | +| `variant_of` *(new)* | component → componentSet | A variant within a set | +| `uses_token` *(new)* | component / screen / instance → token | Applies a token / published style | + +**⚠️ `instance_of` alias conflict.** `instance_of` is currently an entry in `EDGE_TYPE_ALIASES` mapping to `exemplifies` (added for knowledge mode). For design it must be a first-class edge. Resolution: **promote `instance_of` to a canonical `EdgeType` and remove its alias entry.** Knowledge-mode agents emit `exemplifies` directly (the alias was only a safety net), so the impact is negligible. This change is called out explicitly here and must be covered by a schema test. + +`navigates_to` (prototype links, screen → screen) is **not** added in v1 — it belongs to roadmap item B. Prototype link data is preserved in `figmaMeta.prototypeTargets` so B can emit those edges later without re-parsing. + +### New Metadata Interface + +```typescript +export interface FigmaMeta { + fileKey?: string; + nodeId?: string; // Figma node id, e.g. "1:23" + figmaType?: string; // raw Figma type: FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ... + thumbnailUrl?: string; // lazily filled from GET /v1/images + dimensions?: { width: number; height: number }; + tokenKind?: "color" | "type" | "spacing" | "effect" | "grid"; + tokenValue?: string; // e.g. "#0A84FF", "16px" + prototypeTargets?: string[]; // for roadmap B (flows) — recorded in v1, edges later + componentKey?: string; // for roadmap C (design↔code) — recorded in v1 +} +``` + +Added as an optional field on `GraphNode`: + +```typescript +export interface GraphNode { + // ...existing fields + figmaMeta?: FigmaMeta; +} +``` + +### Alias-Map Additions + +For LLM/vocabulary robustness in the merge step: + +- `NODE_TYPE_ALIASES`: `frame → screen`, `artboard → screen`, `canvas → page`, `main_component → component`, `variant_set → componentSet`, `component_set → componentSet`, `design_token → token`, `style → token`. +- `EDGE_TYPE_ALIASES`: `instantiates → instance_of`, `variant → variant_of`, `styled_by → uses_token`, `applies_token → uses_token`. (Remove the existing `instance_of → exemplifies` entry per the note above.) + +--- + +## Ingestion: Source Adapter + +A pluggable seam isolates "where the Figma document comes from" from "how it is parsed." + +```typescript +// packages/core/src/figma/source/types.ts +export interface FigmaSource { + /** Returns the raw Figma document tree (shape of GET /v1/files/:key). */ + fetchDocument(): Promise; + /** Returns published styles metadata (shape of GET /v1/files/:key/styles). */ + fetchStyles(): Promise; + /** Renders thumbnails for the given node ids (GET /v1/images). */ + renderImages(nodeIds: string[]): Promise>; +} +``` + +**v1 implementation — `FigmaApiSource`** (`source/api-source.ts`, Node-only): +- Reads the token from `process.env.FIGMA_TOKEN`. If absent, the skill stops with a friendly message ("create a token at figma.com/settings, then `export FIGMA_TOKEN=…`"). +- `GET https://api.figma.com/v1/files/:key` for the document tree; `GET /v1/files/:key/styles` for styles; `GET /v1/images/:key?ids=…` for thumbnails (on demand). +- Accepts a Figma URL or a bare file key (URL parsed for the key). + +**Future — `LocalJsonSource`**: reads a pre-exported JSON document. Same `FigmaSource` interface, no token, no network. This is how the foundation evolves from API-only (A) toward "both" (C in the earlier input-source discussion). + +The API client and any `fetch` usage live in the Node-only part of `core` and are **never** exported from the browser-safe subpaths (`./search`, `./types`, `./schema`). The dashboard shares only schema types. + +--- + +## Parsing & Granularity + +The deterministic parser (`packages/core/src/figma/parse/`) walks the document tree and emits the structural skeleton. Granularity is **shallow**: + +- **Nodes:** `page`, `screen` (top-level frames), `component`, `componentSet`, `instance`, `token`. +- **Not nodes:** Figma "sections" are flattened in v1 (their child frames attach to the parent `page`); nested groups and text/vector/shape leaf layers are not nodes either. +- **Still read (not nodes):** deeper layers are traversed to resolve `instance_of` targets, collect `uses_token` usage, capture `prototypeTargets`/`componentKey` into `figmaMeta`, and (later, for E) read planning text. + +Node granularity ≠ parse granularity: the parser reads the full tree but only promotes the shallow set to nodes. + +**Tokens (bounded on purpose):** v1 promotes **published styles and variables** (color/text/effect/grid styles, design variables) to `token` nodes — this keeps the token set meaningful and prevents node explosion. Raw inline values (e.g. a one-off hex) are recorded on the consuming node's `figmaMeta` but are **not** promoted to `token` nodes unless they resolve to a published style/variable. Each `token` node carries `figmaMeta.tokenKind` + `tokenValue`; `uses_token` edges connect consumers. + +Output: `scan-manifest.json` (deterministic, no LLM) — the structural base graph. + +--- + +## Agent Pipeline + +Four phases, mirroring `/understand-knowledge` (deterministic parse → LLM enrich → merge → save). + +| Phase | Step | Where | Output | +|-------|------|-------|--------| +| 1 | FETCH & PARSE | `core/figma` (deterministic) | `scan-manifest.json` | +| 2 | ANALYZE | `design-analyzer` LLM subagents (batched) | `analysis-batch-*.json` | +| 3 | MERGE | `core/figma/merge` + reuse `validateGraph` | `assembled-graph.json` | +| 4 | SAVE & LAUNCH | skill + `/understand-dashboard` | `knowledge-graph.json` | + +### New Agent + +| Agent | Input | Output | +|-------|-------|--------| +| `design-analyzer` *(new, modeled on `article-analyzer`)* | Batch of manifest nodes (id, name, type, `figmaMeta`, child summary, token usage) + existing node IDs | Per-node enrichment (summary, tags, layer hint, screen purpose) + conservative `related` edges. **Does not** re-emit structural nodes/edges. | + +No scanner agent is needed — scanning is the Phase-1 deterministic parser (same as the wiki parse script). + +### Intermediate Files + +`.understand-anything/intermediate/` (cleaned up after assembly): `figma-doc.json` (raw tree cache), `scan-manifest.json`, `analysis-batch-*.json`, `assembled-graph.json`. + +### Layers & Tour + +- **Layers:** one per Figma page, plus a dedicated "Design System" layer (components, component sets, tokens). +- **Tour:** "Design System first → key screens", reusing the existing tour structure. + +### Incremental Mode + +On re-run, compare the Figma file `version`/`lastModified` (from the API) stored in `meta.json`. Unchanged → skip. Changed → full re-analyze in v1 (node-level incremental by Figma `nodeId` is a future optimization). This is the Figma analog of `/understand`'s commit-hash incremental. + +--- + +## Dashboard Changes + +All changes are scoped to `kind: "design"`. Net-new work is four spots; everything else is reused. + +1. **`kind: "design"` branch** in `App.tsx` — adds a design view (like `KnowledgeGraphView` was added). The structure is hierarchical, so reuse the existing dagre/ELK hierarchical layout (similar to `DomainGraphView`'s LR). +2. **Node styling by type** — extend `CustomNode` with a type→color map: + +| Node | Accent | Note | +|------|--------|------| +| `page` | Container / neutral | groups screens (also forms a layer) | +| `screen` | Blue (accent) | | +| `instance` | Green | | +| `component` | Violet | | +| `componentSet` | Amber | | +| `token` | Neutral + **color swatch** | color tokens show their actual color | + +3. **Sidebar (`NodeInfo`) thumbnail — the only net-new UI.** On selecting a figma node, show a thumbnail block (name, type, dimensions, tags, relationships), reusing the existing slide-up/NodeInfo panel pattern. +4. **Thumbnail supply** — reuse the existing token-gated + path-allowlist dev-server endpoint pattern (as used by the code viewer's `/file-content.json`) as a `/figma-image` endpoint serving thumbnails on demand; or store thumbnail URLs in the graph. + +Legend and filter gain the new node-type entries. Layout, search, filter, theme, and export are reused unchanged. + +--- + +## Skill Interface + +### Usage + +```bash +/understand-figma https://www.figma.com/file// # URL +/understand-figma # bare key +/understand-figma --page "Onboarding" # scope to one page (optional) +/understand-figma --language ko # reuse existing --language +``` + +### Behavior + +1. Parse URL/key; verify `FIGMA_TOKEN` (friendly error if missing). +2. Phase 1 fetch & parse → announce ("found N pages, N screens, N components, N tokens"). +3. Phase 2 `design-analyzer` batches (up to 5 concurrent, as in `/understand`; tolerate batch failure — the manifest is a solid base). +4. Phase 3 merge → normalize → `validateGraph` → `kind: "design"`. +5. Phase 4 write `knowledge-graph.json` + `meta.json` (with Figma file version) → auto-launch `/understand-dashboard`. + +### File Structure + +``` +understand-anything-plugin/ + skills/understand-figma/ + SKILL.md — thin orchestration + agents/ + design-analyzer.md — new LLM agent + packages/core/src/figma/ + source/ + types.ts — FigmaSource interface (adapter seam) + api-source.ts — FigmaApiSource (REST, Node-only) + parse/ + parse-document.ts — tree → nodes/edges (deterministic, tested) + tokens.ts — token/style extraction + merge.ts — manifest + analysis assembly + index.ts — Node-only entry (not exposed to dashboard subpaths) + __tests__/ — vitest unit tests +``` + +--- + +## Roadmap (B · C · D · E) + +Each is a later spec → plan → implementation cycle, built on this foundation. + +| Item | Capability | Adds on top of v1 | Main new work | +|------|-----------|-------------------|---------------| +| **B** | User flows | `figmaMeta.prototypeTargets` → `navigates_to` edges + flow view | `navigates_to` edge type; flow layout (reuse flow/step + `DomainGraphView`) | +| **C** | Design ↔ code | `figmaMeta.componentKey` ↔ code-graph components | Combine two graphs; matching strategy (name/structure/LLM); cross-graph edges | +| **D** | Design-system audit | Analyze instance/token usage → reuse rate, detached instances, inconsistencies | Deterministic audit rules; dashboard badges | +| **E** | Planning-document analysis | LLM reads Figma planning text → `claim`/`entity` nodes (reuse knowledge mode) | Deep text-layer reading; extend or add an analyzer | + +v1 records `prototypeTargets`, `componentKey`, and reads deep layers, so B/C/E attach without re-parsing. + +--- + +## Backward Compatibility, Coexistence & Security + +### Backward Compatibility + +- All new node/edge types are additive (enum additions). Existing codebase/knowledge/domain graphs remain valid. +- Graphs without `kind` default to `"codebase"`. +- `figmaMeta` is an optional passthrough field — existing nodes are unaffected. +- Removing the `instance_of → exemplifies` alias has negligible impact (knowledge agents emit `exemplifies` directly); covered by a schema test. + +### Coexistence + +- Like the other modes, `/understand-figma` writes the shared `.understand-anything/knowledge-graph.json`. Running one mode replaces the prior graph (existing policy). +- For mixed repos, a `figma-knowledge-graph.json` subdomain graph can be produced and merged via the existing `merge-subdomain-graphs.py` pattern. + +### Security + +- **`FIGMA_TOKEN` is read from the environment only.** It is never written to the graph, config, `meta.json`, logs, or intermediate files. Request headers (carrying the token) are never printed in errors or logs. +- The pipeline makes an **outbound network call** to `api.figma.com` — a departure from `/understand`'s fully-offline nature. This is surfaced to the user in the skill's output and documented. +- `figma-doc.json` (raw tree cache) and thumbnails are design data (not secrets) but `.understand-anything/` should remain git-ignored by default. +- The thumbnail endpoint follows the existing token-gate + path-allowlist pattern used by the code viewer. + +--- + +## Open Questions / Future Enhancements + +- **Deep-expand a screen:** on-demand promotion of a single screen's deeper layers to nodes. +- **In-node thumbnails:** opt-in richer rendering once the sidebar-thumbnail pipeline is proven. +- **Local-JSON source:** the `FigmaSource` seam's offline implementation (evolves A → "both"). +- **Node-level incremental:** diff by Figma `nodeId` instead of full re-analyze on file change. From 3306791dfaa8a0a51d240da9c90e497a29b31931 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 16:20:56 +0900 Subject: [PATCH 02/23] docs: Korean translation of /understand-figma foundation spec --- ...4-understand-figma-foundation-design.ko.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.ko.md diff --git a/docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.ko.md b/docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.ko.md new file mode 100644 index 000000000..a7fe98757 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.ko.md @@ -0,0 +1,317 @@ +# /understand-figma — Figma 수집 & 구조 분석 (기반) 설계 + +**날짜**: 2026-06-24 +**상태**: 승인됨 +**접근**: 기반 우선 — 본 문서는 **5개 하위 프로젝트 중 1번**입니다. Figma 수집 + 구조 분석 + 가벼운 디자인 시스템 모델을 제공합니다. 사용자 플로우(B), 디자인↔코드 매핑(C), 디자인 시스템 감사(D), 기획문서 분석(E)은 여기서 **로드맵**으로만 정의하며, 각각 별도의 스펙 → 플랜 → 구현 사이클을 가집니다. + +> 이 문서는 영문 원본 `2026-06-24-understand-figma-foundation-design.md`의 한국어 번역본입니다. 내용이 충돌할 경우 영문 원본이 기준입니다. + +## 개요 + +기존 Understand Anything 플러그인 안에 추가되는 새 `/understand-figma` 스킬로, Figma 파일을 받아 인터랙티브 지식 그래프 — 페이지, 화면, 컴포넌트, 컴포넌트셋, 인스턴스, 디자인 토큰 — 를 만들어 `kind: "design"` 레이아웃으로 기존 대시보드에 시각화합니다. + +이는 `/understand-knowledge`(위키)와 `/understand-domain`(비즈니스 도메인)이 비(非)코드 입력으로 도구를 확장한 방식 그대로입니다: 결정적 파싱이 구조 골격을 만들고, LLM 에이전트가 의미를 더하고, 머지 단계가 동일한 `knowledge-graph.json`으로 조립하며, 동일한 대시보드가 렌더링합니다. + +### 목표 (Goals) + +- **Figma REST API**(`GET /v1/files/:key`)를 통해 Figma 파일을 수집하되, 교체 가능한 소스 어댑터 경계 뒤에 두어 추후 오프라인 로컬-JSON 소스를 재작업 없이 추가할 수 있게 한다. +- **얕은(shallow)** 구조 그래프를 생성한다: `page → screen → component / componentSet / instance`, 그리고 가벼운 **디자인 시스템 모델**(색/타이포/간격/이펙트 스타일을 위한 `token` 노드와 `uses_token` 관계). +- 새 `design-analyzer` LLM 에이전트로 의미 보강(요약, 태그, 레이어 힌트, 화면 목적)을 더한다. +- 기존 스키마·영속화·검증·대시보드를 재사용하고, `kind: "design"` 뷰와 사이드바 썸네일만 추가한다. +- **하이브리드** 전략으로 렌더링한다: 그래프는 가벼운 텍스트 노드, 선택된 노드의 썸네일은 사이드바에 (온디맨드로) 표시. +- v1 파싱 중 미래 지향 메타데이터(`prototypeTargets`, `componentKey`)를 기록해, 로드맵 B·C를 재파싱 없이 켤 수 있게 한다. + +### 비목표 (Non-Goals) + +- 디자인 시스템 산출물(코드 컴포넌트 라이브러리, 토큰 파일, Storybook)의 **생성**. 본 작업은 분석/모델링 전용 — 사용자와 확정(해석 "i", "ii" 아님). +- 그래프 캔버스 내 인노드(in-node) 썸네일 렌더링(성능/저장 비용) — v1은 사이드바 미리보기만. +- 모든 Figma 레이어를 노드로 만들기(한 화면에 수백 개 레이어 가능). 더 깊은 레이어는 **읽되**(`instance_of` 링크, 토큰 사용, 추후 기획텍스트 추출용) 노드로 만들지 않는다. "화면 깊이 펼치기"는 향후 개선 항목. +- 사용자 플로우(B), 디자인↔코드(C), 디자인 시스템 감사(D), 기획문서 분석(E) — 이들은 로드맵이며 v1 아님. +- 오프라인 `.fig` 파싱(독점 바이너리). 오프라인 지원은 추후 로컬-JSON 소스 어댑터로. + +--- + +## 범위 분해 (왜 기반 우선인가) + +사용자는 다섯 가지 기능을 모두 원합니다. 이들은 공통 기반 위에 얹히므로, 기반을 먼저 만듭니다: + +``` + ③ C 디자인 ↔ 코드 + (Figma 그래프 + 코드 그래프 + 매칭 필요) + ▲ + ② B 플로우 ② D 감사 ② E 기획텍스트 ← 파싱된 구조 위에 구축 + ▲ ▲ ▲ + └──────────┴────────────┘ + │ + ① 기반: Figma 수집 + 구조 (+ 가벼운 디자인 시스템 모델) ← 본 스펙 +``` + +- **A(본 스펙)** 는 수집, `kind: "design"` 스키마, 파싱 모듈, 스킬 골격, 대시보드 뷰를 확립 — 나머지 전부의 전제. +- **B / D / E** 는 동일한 파싱 데이터 위의 추가 추출. +- **C** 는 캡스톤: Figma 그래프(A) + 코드 그래프(`/understand`) + 매칭 전략이 모두 필요. + +각 로드맵 항목은 별도의 스펙 → 플랜 → 구현 사이클을 가집니다. + +--- + +## 스키마 확장 + +`domain`·`knowledge` 확장과 동일한 메커니즘입니다: `NodeType`/`EdgeType` zod enum은 **닫혀(closed)** 있어(`validateGraph`가 알 수 없는 타입을 드롭) 새 타입은 enum에 **추가**하고, alias 맵 항목이 LLM 어휘를 정규화합니다. `GraphNode`는 `.passthrough()`라 타입드 `figmaMeta` 필드가 `domainMeta`/`knowledgeMeta`와 나란히 실립니다. + +### 그래프 수준 Kind 플래그 + +```typescript +export interface KnowledgeGraph { + version: string; + kind?: "codebase" | "knowledge" | "design"; // "design" 추가 + // ... +} +``` + +`kind`가 없는 그래프는 `"codebase"`로 기본 처리(변경 없음). 대시보드는 `kind`에 따라 레이아웃/스타일을 전환합니다. + +### 신규 노드 타입 (6) — 21 → 27 + +| 타입 | 의미 | 예 | ID 규칙 | +|------|------|----|---------| +| `page` | Figma 페이지(캔버스) | "Onboarding" | `page:` | +| `screen` | 최상위 프레임/아트보드(UI 화면) | "Login" | `screen:` | +| `component` | 메인 컴포넌트 | "Button/Primary" | `component:` | +| `componentSet` | 변형 묶음 | "Button" | `componentSet:` | +| `instance` | 컴포넌트 사용처 | "Login › SignInBtn" | `instance:` | +| `token` | 디자인 토큰/퍼블리시된 스타일(색·타이포·간격·이펙트·그리드) | "color/brand-500" | `token::` | + +Figma "styles"는 `token`으로 통합하며 `figmaMeta.tokenKind`로 구분 — 타입 수를 줄입니다. + +### 신규 엣지 타입 (3) — 35 → 38 (+ `contains` 재사용) + +| 타입 | 방향 | 의미 | +|------|------|------| +| `contains` *(재사용)* | page → screen, screen → instance, componentSet → component | 구조적 포함 | +| `instance_of` *(신규)* | instance → component | 컴포넌트의 인스턴스 | +| `variant_of` *(신규)* | component → componentSet | 세트 내 변형 | +| `uses_token` *(신규)* | component / screen / instance → token | 토큰/퍼블리시된 스타일 적용 | + +**⚠️ `instance_of` alias 충돌.** `instance_of`는 현재 `EDGE_TYPE_ALIASES`에서 `exemplifies`로 매핑돼 있습니다(knowledge 모드용). design에서는 1급 엣지여야 합니다. 해결: **`instance_of`를 정식 `EdgeType`으로 승격하고 alias 항목을 제거**합니다. knowledge 모드 에이전트는 `exemplifies`를 직접 내보내므로(alias는 안전망일 뿐) 영향은 미미합니다. 본 변경은 여기 명시하며 스키마 테스트로 커버해야 합니다. + +`navigates_to`(프로토타입 링크, screen → screen)는 v1에 **추가하지 않습니다** — 로드맵 B 소관. 프로토타입 링크 데이터는 `figmaMeta.prototypeTargets`에 보존돼, B가 재파싱 없이 해당 엣지를 나중에 만들 수 있습니다. + +### 신규 메타데이터 인터페이스 + +```typescript +export interface FigmaMeta { + fileKey?: string; + nodeId?: string; // Figma 노드 id, 예: "1:23" + figmaType?: string; // 원본 Figma 타입: FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ... + thumbnailUrl?: string; // GET /v1/images로 지연 채움 + dimensions?: { width: number; height: number }; + tokenKind?: "color" | "type" | "spacing" | "effect" | "grid"; + tokenValue?: string; // 예: "#0A84FF", "16px" + prototypeTargets?: string[]; // 로드맵 B(플로우)용 — v1에 기록, 엣지는 나중 + componentKey?: string; // 로드맵 C(디자인↔코드)용 — v1에 기록 +} +``` + +`GraphNode`에 옵션 필드로 추가: + +```typescript +export interface GraphNode { + // ...기존 필드 + figmaMeta?: FigmaMeta; +} +``` + +### Alias 맵 추가 + +머지 단계에서 LLM/어휘 견고성을 위해: + +- `NODE_TYPE_ALIASES`: `frame → screen`, `artboard → screen`, `canvas → page`, `main_component → component`, `variant_set → componentSet`, `component_set → componentSet`, `design_token → token`, `style → token`. +- `EDGE_TYPE_ALIASES`: `instantiates → instance_of`, `variant → variant_of`, `styled_by → uses_token`, `applies_token → uses_token`. (위 노트대로 기존 `instance_of → exemplifies` 항목은 제거.) + +--- + +## 수집: 소스 어댑터 + +"Figma 문서가 어디서 오는가"를 "어떻게 파싱하는가"로부터 분리하는 교체 가능한 경계입니다. + +```typescript +// packages/core/src/figma/source/types.ts +export interface FigmaSource { + /** Figma 문서 트리 반환 (GET /v1/files/:key 형태). */ + fetchDocument(): Promise; + /** 퍼블리시된 스타일 메타 반환 (GET /v1/files/:key/styles 형태). */ + fetchStyles(): Promise; + /** 주어진 노드 id들의 썸네일 렌더 (GET /v1/images). */ + renderImages(nodeIds: string[]): Promise>; +} +``` + +**v1 구현 — `FigmaApiSource`** (`source/api-source.ts`, Node 전용): +- 토큰을 `process.env.FIGMA_TOKEN`에서 읽음. 없으면 스킬은 친절한 메시지와 함께 중단("figma.com/settings에서 토큰 발급 후 `export FIGMA_TOKEN=…`"). +- 문서 트리는 `GET https://api.figma.com/v1/files/:key`, 스타일은 `GET /v1/files/:key/styles`, 썸네일은 `GET /v1/images/:key?ids=…`(온디맨드). +- Figma URL 또는 순수 파일 키를 모두 수용(URL에서 키 파싱). + +**향후 — `LocalJsonSource`**: 미리 내보낸 JSON 문서를 읽음. 동일한 `FigmaSource` 인터페이스, 토큰·네트워크 불필요. 기반이 API 전용(A)에서 "둘 다"(앞선 입력 소스 논의의 C)로 진화하는 방식입니다. + +API 클라이언트와 모든 `fetch` 사용은 `core`의 Node 전용 영역에 있으며, 브라우저-세이프 서브패스(`./search`, `./types`, `./schema`)에서 **절대 export하지 않습니다**. 대시보드는 스키마 타입만 공유합니다. + +--- + +## 파싱 & 깊이(Granularity) + +결정적 파서(`packages/core/src/figma/parse/`)가 문서 트리를 순회해 구조 골격을 생성합니다. 깊이는 **얕음**: + +- **노드:** `page`, `screen`(최상위 프레임), `component`, `componentSet`, `instance`, `token`. +- **노드 아님:** Figma "섹션"은 v1에서 평탄화(자식 프레임이 상위 `page`에 붙음); 중첩 그룹과 텍스트/벡터/도형 리프 레이어도 노드 아님. +- **읽되 노드 아님:** 더 깊은 레이어는 `instance_of` 대상 해석, `uses_token` 사용 수집, `prototypeTargets`/`componentKey`의 `figmaMeta` 기록, (추후 E용) 기획 텍스트 읽기를 위해 순회. + +노드 깊이 ≠ 파싱 깊이: 파서는 전체 트리를 읽되 얕은 집합만 노드로 승격합니다. + +**토큰(의도적으로 제한):** v1은 **퍼블리시된 스타일과 변수**(색/텍스트/이펙트/그리드 스타일, 디자인 변수)만 `token` 노드로 승격 — 토큰 집합을 의미 있고 폭발하지 않게 유지. 원시 인라인 값(예: 일회성 hex)은 소비 노드의 `figmaMeta`에 기록하되, 퍼블리시된 스타일/변수로 해석되지 않는 한 `token` 노드로 **승격하지 않음**. 각 `token` 노드는 `figmaMeta.tokenKind` + `tokenValue`를 가지며, `uses_token` 엣지가 소비자를 연결. + +출력: `scan-manifest.json`(결정적, LLM 없음) — 구조 베이스 그래프. + +--- + +## 에이전트 파이프라인 + +`/understand-knowledge`(결정적 파싱 → LLM 보강 → 머지 → 저장)를 본뜬 4단계입니다. + +| Phase | 단계 | 위치 | 출력 | +|-------|------|------|------| +| 1 | FETCH & PARSE | `core/figma` (결정적) | `scan-manifest.json` | +| 2 | ANALYZE | `design-analyzer` LLM 서브에이전트(배치) | `analysis-batch-*.json` | +| 3 | MERGE | `core/figma/merge` + `validateGraph` 재사용 | `assembled-graph.json` | +| 4 | SAVE & LAUNCH | 스킬 + `/understand-dashboard` | `knowledge-graph.json` | + +### 신규 에이전트 + +| 에이전트 | 입력 | 출력 | +|----------|------|------| +| `design-analyzer` *(신규, `article-analyzer` 본뜸)* | 매니페스트 노드 배치(id·이름·타입·`figmaMeta`·자식 요약·토큰 사용) + 기존 노드 ID | 노드별 보강(요약·태그·레이어 힌트·화면 목적) + 보수적 `related` 엣지. **구조 노드/엣지는 재생성하지 않음**. | + +스캐너 에이전트는 불필요 — 스캔은 Phase 1 결정적 파서가 담당(위키 파스 스크립트와 동일). + +### 중간 파일 + +`.understand-anything/intermediate/`(조립 후 정리): `figma-doc.json`(원본 트리 캐시), `scan-manifest.json`, `analysis-batch-*.json`, `assembled-graph.json`. + +### 레이어 & 투어 + +- **레이어:** Figma 페이지마다 하나 + 별도 "Design System" 레이어(컴포넌트·컴포넌트셋·토큰). +- **투어:** "디자인 시스템 먼저 → 핵심 화면", 기존 투어 구조 재사용. + +### 증분 모드 + +재실행 시 `meta.json`에 저장된 Figma 파일 `version`/`lastModified`(API 제공)를 비교. 변화 없음 → skip. 변경됨 → v1은 전체 재분석(Figma `nodeId` 단위 증분은 향후 최적화). `/understand`의 커밋 해시 증분의 Figma 버전 버전입니다. + +--- + +## 대시보드 변경 + +모든 변경은 `kind: "design"`에 한정됩니다. 순수 신규 작업은 네 군데이고, 나머지는 재사용입니다. + +1. **`kind: "design"` 분기** (`App.tsx`) — design 뷰 추가(`KnowledgeGraphView`가 추가됐던 것처럼). 구조가 계층적이므로 기존 dagre/ELK 계층 레이아웃 재사용(`DomainGraphView`의 LR과 유사). +2. **타입별 노드 스타일** — `CustomNode`에 타입→색 매핑 추가: + +| 노드 | 강조색 | 비고 | +|------|--------|------| +| `page` | 컨테이너/중립 | 화면들을 묶음(레이어도 형성) | +| `screen` | 블루(accent) | | +| `instance` | 그린 | | +| `component` | 바이올렛 | | +| `componentSet` | 앰버 | | +| `token` | 중립 + **색상 스와치** | 색 토큰은 실제 색 표시 | + +3. **사이드바(`NodeInfo`) 썸네일 — 유일한 순수 신규 UI.** figma 노드 선택 시 썸네일 블록(이름·타입·치수·태그·관계)을 표시, 기존 슬라이드업/NodeInfo 패턴 재사용. +4. **썸네일 공급** — 코드뷰어의 `/file-content.json`이 쓰는 기존 토큰 게이트 + 경로 allowlist 개발 서버 엔드포인트 패턴을 본떠 `/figma-image` 엔드포인트로 온디맨드 제공; 또는 그래프에 썸네일 URL 저장. + +범례·필터에 신규 노드 타입 항목 추가. 레이아웃·검색·필터·테마·내보내기는 변경 없이 재사용. + +--- + +## 스킬 인터페이스 + +### 사용법 + +```bash +/understand-figma https://www.figma.com/file// # URL +/understand-figma # 순수 키 +/understand-figma --page "Onboarding" # 특정 페이지만(선택) +/understand-figma --language ko # 기존 --language 재사용 +``` + +### 동작 + +1. URL/키 파싱; `FIGMA_TOKEN` 확인(없으면 친절한 에러). +2. Phase 1 fetch & parse → 발견 요약 announce("N pages, N screens, N components, N tokens 발견"). +3. Phase 2 `design-analyzer` 배치(최대 5개 동시, `/understand`와 동일; 배치 실패 허용 — 매니페스트가 견고한 베이스). +4. Phase 3 머지 → 정규화 → `validateGraph` → `kind: "design"`. +5. Phase 4 `knowledge-graph.json` + `meta.json`(Figma 파일 버전 포함) 저장 → `/understand-dashboard` 자동 실행. + +### 파일 구조 + +``` +understand-anything-plugin/ + skills/understand-figma/ + SKILL.md — 얇은 오케스트레이션 + agents/ + design-analyzer.md — 신규 LLM 에이전트 + packages/core/src/figma/ + source/ + types.ts — FigmaSource 인터페이스(어댑터 경계) + api-source.ts — FigmaApiSource (REST, Node 전용) + parse/ + parse-document.ts — 트리 → 노드/엣지 (결정적, 테스트) + tokens.ts — 토큰/스타일 추출 + merge.ts — 매니페스트 + 분석 조립 + index.ts — Node 전용 엔트리(대시보드 서브패스에 미노출) + __tests__/ — vitest 단위 테스트 +``` + +--- + +## 로드맵 (B · C · D · E) + +각각 본 기반 위에 구축되는 별도 스펙 → 플랜 → 구현 사이클입니다. + +| 항목 | 기능 | v1 위에 더하는 것 | 주된 신규 작업 | +|------|------|-------------------|----------------| +| **B** | 사용자 플로우 | `figmaMeta.prototypeTargets` → `navigates_to` 엣지 + 플로우 뷰 | `navigates_to` 엣지 타입; 플로우 레이아웃(flow/step + `DomainGraphView` 재사용) | +| **C** | 디자인 ↔ 코드 | `figmaMeta.componentKey` ↔ 코드 그래프 컴포넌트 | 두 그래프 결합; 매칭 전략(이름/구조/LLM); cross-graph 엣지 | +| **D** | 디자인 시스템 감사 | 인스턴스/토큰 사용 분석 → 재사용률, detached 인스턴스, 불일치 | 결정적 감사 규칙; 대시보드 배지 | +| **E** | 기획문서 분석 | LLM이 Figma 기획 텍스트를 읽어 `claim`/`entity` 노드로(knowledge 모드 재사용) | 깊은 텍스트 레이어 읽기; 분석기 확장 또는 신규 | + +v1이 `prototypeTargets`, `componentKey`를 기록하고 깊은 레이어를 읽어두므로, B/C/E는 재파싱 없이 붙습니다. + +--- + +## 하위호환 · 공존 · 보안 + +### 하위호환 + +- 모든 신규 노드/엣지 타입은 추가형(enum 추가). 기존 codebase/knowledge/domain 그래프는 그대로 유효. +- `kind`가 없는 그래프는 `"codebase"`로 기본 처리. +- `figmaMeta`는 옵션 passthrough 필드 — 기존 노드 불영향. +- `instance_of → exemplifies` alias 제거는 영향 미미(knowledge 에이전트는 `exemplifies` 직접 사용); 스키마 테스트로 커버. + +### 공존 + +- 다른 모드와 동일하게 `/understand-figma`는 공유 `.understand-anything/knowledge-graph.json`에 기록. 한 모드 실행 시 이전 그래프를 대체(기존 정책). +- 혼합 레포에서는 `figma-knowledge-graph.json` 서브도메인 그래프를 만들어 기존 `merge-subdomain-graphs.py` 패턴으로 병합 가능. + +### 보안 + +- **`FIGMA_TOKEN`은 환경변수에서만 읽음.** 그래프·config·`meta.json`·로그·중간파일에 절대 기록하지 않음. 토큰을 담은 요청 헤더는 에러/로그에 출력하지 않음. +- 파이프라인은 `api.figma.com`로 **외부 네트워크 호출**을 수행 — `/understand`의 완전 오프라인 성격에서 벗어남. 이는 스킬 출력에서 사용자에게 고지하고 문서화함. +- `figma-doc.json`(원본 트리 캐시)와 썸네일은 디자인 데이터(시크릿 아님)지만, `.understand-anything/`는 기본적으로 git-ignore 유지 권장. +- 썸네일 엔드포인트는 코드뷰어가 쓰는 기존 토큰 게이트 + 경로 allowlist 패턴을 따름. + +--- + +## 향후 과제 / 개선 항목 + +- **화면 깊이 펼치기:** 특정 화면의 더 깊은 레이어를 온디맨드로 노드 승격. +- **인노드 썸네일:** 사이드바 썸네일 파이프라인이 검증된 후 더 풍부한 렌더링(옵트인). +- **로컬-JSON 소스:** `FigmaSource` 경계의 오프라인 구현(A → "둘 다" 진화). +- **노드 단위 증분:** 파일 변경 시 전체 재분석 대신 Figma `nodeId` 단위 diff. From 9f8bd3b580e44fce986e8e10ecd63cf790603464 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 16:39:19 +0900 Subject: [PATCH 03/23] docs: implementation plan for /understand-figma foundation 15 TDD tasks: schema (design types, figmaMeta, instance_of promotion), core figma module (source adapter, parse-document, tokens, merge, barrel), design-analyzer agent, /understand-figma skill + scan/merge scripts, dashboard node colors + sidebar thumbnail, incremental version skip. --- .../2026-06-24-understand-figma-foundation.md | 1544 +++++++++++++++++ 1 file changed, 1544 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-understand-figma-foundation.md diff --git a/docs/superpowers/plans/2026-06-24-understand-figma-foundation.md b/docs/superpowers/plans/2026-06-24-understand-figma-foundation.md new file mode 100644 index 000000000..124fc88d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-understand-figma-foundation.md @@ -0,0 +1,1544 @@ +# /understand-figma Foundation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `/understand-figma` skill that ingests a Figma file via the REST API and produces a `kind:"design"` knowledge graph (pages, screens, components, component sets, instances, design tokens) rendered in the existing dashboard. + +**Architecture:** Deterministic Figma parsing lives in a typed, tested core module (`packages/core/src/figma/`) behind a `FigmaSource` adapter seam; an LLM `design-analyzer` agent adds semantics; a merge step assembles the existing `knowledge-graph.json` shape and reuses `validateGraph`. The dashboard gains a `kind:"design"` branch and a sidebar thumbnail. Net-new code is isolated; schema, persistence, validation, layout, search, and export are reused. + +**Tech Stack:** TypeScript (ESM, strict), Zod (schema), Vitest (tests), Node ≥22 `fetch` (Figma REST API), React + React Flow (dashboard), pnpm workspaces. + +**Spec:** `docs/superpowers/specs/2026-06-24-understand-figma-foundation-design.md` + +--- + +## Scope Check + +This plan covers a single sub-project (the Figma **foundation**: ingestion + structure + light design-system model). Roadmap items B (flows), C (design↔code), D (audit), E (planning-text) are explicitly out of scope and will each get their own spec → plan. This plan produces working, testable software on its own: running `/understand-figma ` yields a valid `kind:"design"` graph that the dashboard renders. + +--- + +## File Structure + +**Core — new module `packages/core/src/figma/` (Node-only; never exported from browser-safe subpaths):** +- `source/types.ts` — `FigmaSource` interface + raw Figma response types (`FigmaDocument`, `FigmaStyles`). +- `source/api-source.ts` — `FigmaApiSource` (reads `FIGMA_TOKEN`, calls `GET /v1/files/:key`, `/styles`, `/images`), `parseFileKey(urlOrKey)`. +- `parse/parse-document.ts` — `parseDocument(doc, fileKey)` → `{ nodes, edges }` (page/screen/component/componentSet/instance + `contains`/`instance_of`/`variant_of`). +- `parse/tokens.ts` — `extractTokens(doc, styles)` → token nodes + `uses_token` edges. +- `merge.ts` — `mergeDesignGraph(manifest, analysisBatches, project)` → full `KnowledgeGraph` (`kind:"design"`), runs `validateGraph`. +- `index.ts` — Node-only barrel re-exporting the above. +- `__tests__/parse-document.test.ts`, `__tests__/tokens.test.ts`, `__tests__/merge.test.ts`, `__tests__/api-source.test.ts`. + +**Core — modify (shared schema/types):** +- `packages/core/src/types.ts` — add `"design"` to `kind`; add 6 `NodeType`s; add 3 `EdgeType`s; add `FigmaMeta` + `figmaMeta?` on `GraphNode`. +- `packages/core/src/schema.ts` — mirror the enum additions in `EdgeTypeSchema`, `GraphNodeSchema.type`, `KnowledgeGraphSchema.kind`; add `FigmaMetaSchema`; add node/edge aliases; **remove** `instance_of → exemplifies` from `EDGE_TYPE_ALIASES`. +- `packages/core/src/__tests__/schema.test.ts` — add cases for the new types + `instance_of` promotion. + +**Skill + agent — new:** +- `understand-anything-plugin/skills/understand-figma/SKILL.md` — orchestration (Phases 1–4). +- `understand-anything-plugin/agents/design-analyzer.md` — LLM enrichment agent. + +**Dashboard — modify:** +- `packages/dashboard/src/App.tsx` — add `kind === "design"` branch. +- `packages/dashboard/src/components/CustomNode.tsx` — type→color map for the 6 design node types. +- `packages/dashboard/src/components/NodeInfo.tsx` — thumbnail block for `figmaMeta` nodes. +- dashboard dev server — `/figma-image` endpoint (token-gated, mirrors `/file-content.json`). + +Each task below is self-contained and ends in a commit. + +--- + +### Task 1: Add design types to the shared type definitions + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/types.ts` + +- [ ] **Step 1: Extend `NodeType` (add 6 design types)** + +In `types.ts`, replace the `NodeType` union's trailing knowledge line so it ends with the design types: + +```typescript +// Node types (27 total: 5 code + 8 non-code + 3 domain + 5 knowledge + 6 design) +export type NodeType = + | "file" | "function" | "class" | "module" | "concept" + | "config" | "document" | "service" | "table" | "endpoint" + | "pipeline" | "schema" | "resource" + | "domain" | "flow" | "step" + | "article" | "entity" | "topic" | "claim" | "source" + | "page" | "screen" | "component" | "componentSet" | "instance" | "token"; +``` + +- [ ] **Step 2: Extend `EdgeType` (add 3 design edges)** + +Append a Design category to the `EdgeType` union (after the Knowledge line): + +```typescript + // Design (3 new → 38 total) + | "instance_of" | "variant_of" | "uses_token"; +``` + +- [ ] **Step 3: Add the `FigmaMeta` interface** + +Add near `KnowledgeMeta`/`DomainMeta`: + +```typescript +// Optional Figma metadata for page/screen/component/componentSet/instance/token nodes +export interface FigmaMeta { + fileKey?: string; + nodeId?: string; // Figma node id, e.g. "1:23" + figmaType?: string; // FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ... + thumbnailUrl?: string; // lazily filled from GET /v1/images + dimensions?: { width: number; height: number }; + tokenKind?: "color" | "type" | "spacing" | "effect" | "grid"; + tokenValue?: string; // e.g. "#0A84FF", "16px" + prototypeTargets?: string[]; // roadmap B — recorded now, edges later + componentKey?: string; // roadmap C — recorded now +} +``` + +- [ ] **Step 4: Add `figmaMeta` to `GraphNode` and `"design"` to `KnowledgeGraph.kind`** + +In `GraphNode`, add alongside `domainMeta`/`knowledgeMeta`: + +```typescript + figmaMeta?: FigmaMeta; +``` + +In `KnowledgeGraph`, widen `kind`: + +```typescript + kind?: "codebase" | "knowledge" | "design"; +``` + +- [ ] **Step 5: Verify the package type-checks** + +Run: `pnpm --filter @understand-anything/core build` +Expected: PASS (tsc emits with no errors). + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/types.ts +git commit -m "feat(core): add design NodeType/EdgeType/FigmaMeta + design kind" +``` + +--- + +### Task 2: Extend the Zod schema (accept design types, normalize aliases, promote instance_of) + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/schema.ts` +- Test: `understand-anything-plugin/packages/core/src/__tests__/schema.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `schema.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { validateGraph } from "../schema"; + +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"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test -- schema` +Expected: FAIL — design types are dropped (invalid-node / invalid-edge) and `frame` is unknown. + +- [ ] **Step 3: Add the design node types to `GraphNodeSchema`** + +In `schema.ts`, extend the `type` enum in `GraphNodeSchema`: + +```typescript + type: z.enum([ + "file", "function", "class", "module", "concept", + "config", "document", "service", "table", "endpoint", + "pipeline", "schema", "resource", + "domain", "flow", "step", + "article", "entity", "topic", "claim", "source", + "page", "screen", "component", "componentSet", "instance", "token", + ]), +``` + +- [ ] **Step 4: Add the design edge types to `EdgeTypeSchema`** + +Append to the `EdgeTypeSchema` enum array: + +```typescript + "instance_of", "variant_of", "uses_token", // Design +``` + +- [ ] **Step 5: Widen `KnowledgeGraphSchema.kind` and add `FigmaMetaSchema`** + +```typescript +// add near DomainMetaSchema / KnowledgeMetaSchema +const FigmaMetaSchema = z.object({ + fileKey: z.string().optional(), + nodeId: z.string().optional(), + figmaType: z.string().optional(), + thumbnailUrl: z.string().optional(), + dimensions: z.object({ width: z.number(), height: z.number() }).optional(), + tokenKind: z.enum(["color", "type", "spacing", "effect", "grid"]).optional(), + tokenValue: z.string().optional(), + prototypeTargets: z.array(z.string()).optional(), + componentKey: z.string().optional(), +}).passthrough(); +``` + +Add `figmaMeta: FigmaMetaSchema.optional(),` to `GraphNodeSchema`, and widen kind: + +```typescript + kind: z.enum(["codebase", "knowledge", "design"]).optional(), +``` + +- [ ] **Step 6: Add aliases and remove the instance_of→exemplifies alias** + +In `NODE_TYPE_ALIASES` add: + +```typescript + frame: "screen", + artboard: "screen", + canvas: "page", + main_component: "component", + component_set: "componentSet", + variant_set: "componentSet", + design_token: "token", + style: "token", +``` + +In `EDGE_TYPE_ALIASES` add the design aliases and **delete** the existing `instance_of: "exemplifies"` line: + +```typescript + instantiates: "instance_of", + variant: "variant_of", + styled_by: "uses_token", + applies_token: "uses_token", + // NOTE: the former `instance_of: "exemplifies"` entry is removed — + // instance_of is now a first-class design edge. +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test -- schema` +Expected: PASS (all three new tests green; existing schema tests still green). + +- [ ] **Step 8: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/schema.ts understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +git commit -m "feat(core): validate design node/edge types, figmaMeta, promote instance_of" +``` + +--- + +### Task 3: Figma source adapter (`FigmaSource` interface + `FigmaApiSource`) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/figma/source/types.ts` +- Create: `understand-anything-plugin/packages/core/src/figma/source/api-source.ts` +- Test: `understand-anything-plugin/packages/core/src/figma/__tests__/api-source.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `figma/__tests__/api-source.test.ts`: + +```typescript +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)["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/); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test -- api-source` +Expected: FAIL — module `../source/api-source` does not exist. + +- [ ] **Step 3: Create the source types** + +Create `figma/source/types.ts`: + +```typescript +export interface FigmaNode { + id: string; + name: string; + type: string; // DOCUMENT | CANVAS | FRAME | SECTION | COMPONENT | COMPONENT_SET | INSTANCE | TEXT | ... + children?: FigmaNode[]; + componentId?: string; // on INSTANCE → main component node id + absoluteBoundingBox?: { width: number; height: number } | null; + styles?: Record; // styleType (fill/text/effect/grid) → style key + transitionNodeID?: string | null; // prototype target node id +} + +export interface FigmaDocument { + name: string; + document: FigmaNode; // root (DOCUMENT) whose children are CANVAS (pages) + components?: Record; + componentSets?: Record; +} + +export interface FigmaStyles { + meta?: { styles?: Array<{ key: string; name: string; style_type: string }> }; +} + +export interface FigmaSource { + fetchDocument(): Promise; + fetchStyles(): Promise; + renderImages(nodeIds: string[]): Promise>; +} +``` + +- [ ] **Step 4: Implement the API source** + +Create `figma/source/api-source.ts`: + +```typescript +import type { FigmaSource, FigmaDocument, FigmaStyles } from "./types"; + +const FIGMA_API = "https://api.figma.com/v1"; + +export function parseFileKey(urlOrKey: string): string { + const m = urlOrKey.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/); + if (m) return m[1]; + if (/^[A-Za-z0-9]+$/.test(urlOrKey.trim())) return urlOrKey.trim(); + throw new Error(`Could not parse a Figma file key from: ${urlOrKey}`); +} + +export class FigmaApiSource implements FigmaSource { + private readonly token: string; + + constructor(private readonly fileKey: string, token: string | undefined = process.env.FIGMA_TOKEN) { + if (!token) { + throw new Error( + "FIGMA_TOKEN is not set. Create a personal access token at " + + "https://www.figma.com/settings, then run: export FIGMA_TOKEN=", + ); + } + this.token = token; + } + + private async get(path: string): Promise { + // Token travels only in the header — never in the URL, never logged. + const res = await fetch(`${FIGMA_API}${path}`, { headers: { "X-Figma-Token": this.token } }); + if (!res.ok) { + throw new Error(`Figma API ${path} failed: ${res.status} ${res.statusText}`); + } + return (await res.json()) as T; + } + + fetchDocument(): Promise { + return this.get(`/files/${this.fileKey}`); + } + + fetchStyles(): Promise { + return this.get(`/files/${this.fileKey}/styles`); + } + + async renderImages(nodeIds: string[]): Promise> { + if (nodeIds.length === 0) return {}; + const ids = encodeURIComponent(nodeIds.join(",")); + const data = await this.get<{ images: Record }>( + `/images/${this.fileKey}?ids=${ids}&format=png&scale=1`, + ); + return data.images ?? {}; + } +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test -- api-source` +Expected: PASS (all 7 cases). + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/figma/source/ understand-anything-plugin/packages/core/src/figma/__tests__/api-source.test.ts +git commit -m "feat(core): FigmaSource adapter + FigmaApiSource (token via env, no leak)" +``` + +--- + +### Task 4: Deterministic document parser (`parseDocument`) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts` +- Test: `understand-anything-plugin/packages/core/src/figma/__tests__/parse-document.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `figma/__tests__/parse-document.test.ts`: + +```typescript +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); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- parse-document` +Expected: FAIL — module `../parse/parse-document` does not exist. + +- [ ] **Step 3: Implement the parser** + +Create `figma/parse/parse-document.ts`: + +```typescript +import type { GraphNode, GraphEdge } from "../../types"; +import type { FigmaDocument, FigmaNode } from "../source/types"; + +function mkNode( + type: GraphNode["type"], + figmaId: string, + name: string, + figmaMeta: GraphNode["figmaMeta"], +): GraphNode { + return { + id: `${type}:${figmaId}`, + type, + name, + summary: name, // placeholder; design-analyzer enriches in Phase 2 + tags: [type], + complexity: "simple", + figmaMeta, + }; +} + +export function parseDocument(doc: FigmaDocument, fileKey: string): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const seen = new Set(); + + const add = (n: GraphNode) => { if (!seen.has(n.id)) { seen.add(n.id); nodes.push(n); } }; + const link = (source: string, target: string, type: GraphEdge["type"], weight: number) => + edges.push({ source, target, type, direction: "forward", weight }); + + // Deep-read a screen subtree to find instances (shallow node set, but deep read). + function collectInstances(n: FigmaNode, screenId: string) { + for (const child of n.children ?? []) { + if (child.type === "INSTANCE") { + const inst = mkNode("instance", child.id, child.name, { + fileKey, nodeId: child.id, figmaType: "INSTANCE", + componentKey: child.componentId, + prototypeTargets: child.transitionNodeID ? [child.transitionNodeID] : undefined, + }); + add(inst); + link(screenId, inst.id, "contains", 1.0); + if (child.componentId) link(inst.id, `component:${child.componentId}`, "instance_of", 0.8); + } + if (child.children) collectInstances(child, screenId); + } + } + + function handlePageChild(child: FigmaNode, pageId: string) { + switch (child.type) { + case "FRAME": { + const screen = mkNode("screen", child.id, child.name, { + fileKey, nodeId: child.id, figmaType: "FRAME", + dimensions: child.absoluteBoundingBox + ? { width: child.absoluteBoundingBox.width, height: child.absoluteBoundingBox.height } + : undefined, + }); + add(screen); + link(pageId, screen.id, "contains", 1.0); + collectInstances(child, screen.id); + break; + } + case "COMPONENT": { + const comp = mkNode("component", child.id, child.name, { fileKey, nodeId: child.id, figmaType: "COMPONENT" }); + add(comp); + link(pageId, comp.id, "contains", 1.0); + break; + } + case "COMPONENT_SET": { + const set = mkNode("componentSet", child.id, child.name, { fileKey, nodeId: child.id, figmaType: "COMPONENT_SET" }); + add(set); + link(pageId, set.id, "contains", 1.0); + for (const variant of child.children ?? []) { + if (variant.type === "COMPONENT") { + const comp = mkNode("component", variant.id, variant.name, { fileKey, nodeId: variant.id, figmaType: "COMPONENT" }); + add(comp); + link(comp.id, set.id, "variant_of", 0.9); + } + } + break; + } + case "SECTION": { + for (const sub of child.children ?? []) handlePageChild(sub, pageId); // flatten sections in v1 + break; + } + default: + break; // other top-level types are ignored in v1 + } + } + + for (const canvas of doc.document.children ?? []) { + if (canvas.type !== "CANVAS") continue; + const page = mkNode("page", canvas.id, canvas.name, { fileKey, nodeId: canvas.id, figmaType: "CANVAS" }); + add(page); + for (const child of canvas.children ?? []) handlePageChild(child, page.id); + } + + return { nodes, edges }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- parse-document` +Expected: PASS (all 4 cases). + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts understand-anything-plugin/packages/core/src/figma/__tests__/parse-document.test.ts +git commit -m "feat(core): deterministic Figma document parser (pages/screens/components/instances)" +``` + +--- + +### Task 5: Token extraction (`extractTokens`) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/figma/parse/tokens.ts` +- Test: `understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `figma/__tests__/tokens.test.ts`: + +```typescript +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" }), + ])); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- tokens` +Expected: FAIL — module `../parse/tokens` does not exist. + +- [ ] **Step 3: Implement token extraction** + +Create `figma/parse/tokens.ts`: + +```typescript +import type { GraphNode, GraphEdge, FigmaMeta } from "../../types"; +import type { FigmaDocument, FigmaNode, FigmaStyles } from "../source/types"; + +const STYLE_KIND: Record> = { + FILL: "color", TEXT: "type", EFFECT: "effect", GRID: "grid", +}; + +function slug(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +export function extractTokens( + doc: FigmaDocument, + styles: FigmaStyles, + structuralNodes: GraphNode[], + fileKey: string, +): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const tokenByStyleKey = new Map(); + + // Only published styles/variables become token nodes (bounded set). + for (const s of styles.meta?.styles ?? []) { + const kind = STYLE_KIND[s.style_type] ?? "color"; + const id = `token:${kind}:${slug(s.name)}`; + if (!tokenByStyleKey.has(s.key)) tokenByStyleKey.set(s.key, id); + if (!nodes.some((n) => n.id === id)) { + nodes.push({ + id, type: "token", name: s.name, summary: s.name, + tags: ["token", kind], complexity: "simple", + figmaMeta: { fileKey, tokenKind: kind }, + }); + } + } + + const graphIdByFigmaId = new Map(); + for (const n of structuralNodes) { + if (n.figmaMeta?.nodeId) graphIdByFigmaId.set(n.figmaMeta.nodeId, n.id); + } + + const usesSeen = new Set(); + function walk(n: FigmaNode) { + const consumerId = graphIdByFigmaId.get(n.id); + if (consumerId && n.styles) { + for (const styleKey of Object.values(n.styles)) { + const tokenId = tokenByStyleKey.get(styleKey); + if (tokenId) { + const dedupe = `${consumerId}|${tokenId}`; + if (!usesSeen.has(dedupe)) { + usesSeen.add(dedupe); + edges.push({ source: consumerId, target: tokenId, type: "uses_token", direction: "forward", weight: 0.5 }); + } + } + } + } + for (const c of n.children ?? []) walk(c); + } + walk(doc.document); + + return { nodes, edges }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- tokens` +Expected: PASS (both cases). + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/figma/parse/tokens.ts understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts +git commit -m "feat(core): extract design tokens from published styles + uses_token edges" +``` + +--- + +### Task 6: Merge manifest + analysis into a `kind:"design"` graph (`mergeDesignGraph`) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/figma/merge.ts` +- Test: `understand-anything-plugin/packages/core/src/figma/__tests__/merge.test.ts` + +> **Note:** the existing `validateGraph` re-assembles its return object and **does not copy `kind`** through. `mergeDesignGraph` therefore re-attaches `kind:"design"` to the validated result. (A future cleanup could make `validateGraph` preserve `kind`; out of scope here.) + +- [ ] **Step 1: Write the failing test** + +Create `figma/__tests__/merge.test.ts`: + +```typescript +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"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm --filter @understand-anything/core test -- merge` +Expected: FAIL — module `../merge` does not exist. + +- [ ] **Step 3: Implement the merge** + +Create `figma/merge.ts`: + +```typescript +import type { KnowledgeGraph, GraphNode, GraphEdge, Layer, TourStep, ProjectMeta } from "../types"; +import { validateGraph, type ValidationResult } from "../schema"; + +export interface DesignAnalysis { + nodes?: Array & Partial>>; + edges?: GraphEdge[]; +} + +const DS_TYPES = new Set(["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(); + 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(); + 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(); + 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(); + 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; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm --filter @understand-anything/core test -- merge` +Expected: PASS (all 4 cases). + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/figma/merge.ts understand-anything-plugin/packages/core/src/figma/__tests__/merge.test.ts +git commit -m "feat(core): merge Figma manifest + analysis into kind:design graph with layers/tour" +``` + +--- + +### Task 7: Core barrel export for the Figma module + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/figma/index.ts` + +- [ ] **Step 1: Create the Node-only barrel** + +Create `figma/index.ts` (NOT referenced by the dashboard's browser-safe subpaths): + +```typescript +export { parseFileKey, FigmaApiSource } from "./source/api-source"; +export type { FigmaSource, FigmaDocument, FigmaStyles, FigmaNode } from "./source/types"; +export { parseDocument } from "./parse/parse-document"; +export { extractTokens } from "./parse/tokens"; +export { mergeDesignGraph, type DesignAnalysis } from "./merge"; +``` + +- [ ] **Step 2: Verify the package builds and all figma tests pass** + +Run: `pnpm --filter @understand-anything/core build && pnpm --filter @understand-anything/core test -- figma` +Expected: PASS (build clean; api-source, parse-document, tokens, merge suites green). + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/figma/index.ts +git commit -m "feat(core): barrel export for the figma module" +``` + +--- + +### Task 8: `design-analyzer` agent definition + +**Files:** +- Create: `understand-anything-plugin/agents/design-analyzer.md` + +- [ ] **Step 1: Create the agent file** + +Create `agents/design-analyzer.md`: + +```markdown +--- +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. +``` + +- [ ] **Step 2: Verify the file is valid markdown with frontmatter** + +Run: `head -5 understand-anything-plugin/agents/design-analyzer.md` +Expected: shows the `---` frontmatter block with `name: design-analyzer`. + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/agents/design-analyzer.md +git commit -m "feat(agents): add design-analyzer (semantic enrichment for Figma graphs)" +``` + +--- + +### Task 9: Skill wrapper scripts + core `./figma` subpath export + +**Files:** +- Modify: `understand-anything-plugin/packages/core/package.json` +- Create: `understand-anything-plugin/skills/understand-figma/figma-scan.mjs` +- Create: `understand-anything-plugin/skills/understand-figma/figma-merge.mjs` + +- [ ] **Step 1: Add the `./figma` subpath export to core** + +In `packages/core/package.json`, add to the `exports` map (after `"./languages"`): + +```json + "./figma": { + "types": "./dist/figma/index.d.ts", + "default": "./dist/figma/index.js" + } +``` + +- [ ] **Step 2: Create the Phase-1 scan script** + +Create `skills/understand-figma/figma-scan.mjs`: + +```javascript +#!/usr/bin/env node +import { writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { parseFileKey, FigmaApiSource, parseDocument, extractTokens } from "@understand-anything/core/figma"; + +const [, , projectRoot, urlOrKey] = process.argv; +if (!projectRoot || !urlOrKey) { + console.error("usage: figma-scan.mjs "); + process.exit(1); +} + +const fileKey = parseFileKey(urlOrKey); +const source = new FigmaApiSource(fileKey); // reads FIGMA_TOKEN from env; throws a friendly error if missing +const doc = await source.fetchDocument(); +const styles = await source.fetchStyles().catch(() => ({ meta: { styles: [] } })); + +const structural = parseDocument(doc, fileKey); +const tokens = extractTokens(doc, styles, structural.nodes, fileKey); +const nodes = [...structural.nodes, ...tokens.nodes]; +const edges = [...structural.edges, ...tokens.edges]; + +const manifest = { + project: { + name: doc.name, + languages: ["figma"], + frameworks: [], + description: `Figma design file: ${doc.name}`, + analyzedAt: new Date().toISOString(), + gitCommitHash: "", + }, + fileKey, + nodes, + edges, +}; + +const interDir = join(projectRoot, ".understand-anything", "intermediate"); +mkdirSync(interDir, { recursive: true }); +writeFileSync(join(interDir, "scan-manifest.json"), JSON.stringify(manifest, null, 2)); + +const count = (t) => nodes.filter((n) => n.type === t).length; +console.error( + `Figma scan: ${count("page")} pages, ${count("screen")} screens, ` + + `${count("component")} components, ${count("componentSet")} sets, ` + + `${count("instance")} instances, ${count("token")} tokens`, +); +``` + +- [ ] **Step 3: Create the Phase-3 merge script** + +Create `skills/understand-figma/figma-merge.mjs`: + +```javascript +#!/usr/bin/env node +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { mergeDesignGraph } from "@understand-anything/core/figma"; + +const [, , projectRoot] = process.argv; +const interDir = join(projectRoot, ".understand-anything", "intermediate"); +const manifest = JSON.parse(readFileSync(join(interDir, "scan-manifest.json"), "utf8")); +const analyses = readdirSync(interDir) + .filter((f) => /^analysis-batch-.*\.json$/.test(f)) + .map((f) => JSON.parse(readFileSync(join(interDir, f), "utf8"))); + +const result = mergeDesignGraph( + { nodes: manifest.nodes, edges: manifest.edges }, + analyses, + manifest.project, +); +if (!result.success || !result.data) { + console.error("Merge failed:", result.fatal ?? "unknown error"); + process.exit(1); +} + +const outDir = join(projectRoot, ".understand-anything"); +writeFileSync(join(outDir, "knowledge-graph.json"), JSON.stringify(result.data, null, 2)); +writeFileSync(join(outDir, "meta.json"), JSON.stringify({ + lastAnalyzedAt: new Date().toISOString(), + gitCommitHash: "", + version: "1.0.0", + analyzedFiles: result.data.nodes.length, +}, null, 2)); + +console.error( + `Design graph: ${result.data.nodes.length} nodes, ${result.data.edges.length} edges, ` + + `${result.data.layers.length} layers, ${result.data.tour.length} tour steps`, +); +for (const issue of result.issues) { + if (issue.level !== "auto-corrected") console.error(`[${issue.level}] ${issue.message}`); +} +``` + +- [ ] **Step 4: Rebuild core and smoke-test the scan script offline** + +Run: `pnpm --filter @understand-anything/core build` +Expected: PASS — `dist/figma/index.js` exists, so `@understand-anything/core/figma` resolves. + +Run: `node understand-anything-plugin/skills/understand-figma/figma-scan.mjs /tmp/nope ABC123` +Expected: if `FIGMA_TOKEN` is unset, it exits with the friendly "FIGMA_TOKEN is not set…" error (this confirms wiring without making a real API call). + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/core/package.json understand-anything-plugin/skills/understand-figma/figma-scan.mjs understand-anything-plugin/skills/understand-figma/figma-merge.mjs +git commit -m "feat(figma): core ./figma export + scan/merge wrapper scripts" +``` + +--- + +### Task 10: `/understand-figma` skill orchestration (`SKILL.md`) + +**Files:** +- Create: `understand-anything-plugin/skills/understand-figma/SKILL.md` + +- [ ] **Step 1: Create the skill file** + +Create `skills/understand-figma/SKILL.md`: + +````markdown +--- +name: understand-figma +description: Analyze a Figma file via the Figma REST API and generate an interactive design knowledge graph (pages, screens, components, component sets, instances, design tokens) with a kind:"design" dashboard. +argument-hint: " [--language ]" +--- + +# /understand-figma + +Analyzes a Figma file and produces an interactive design knowledge graph in the existing dashboard. + +## Prerequisites + +- **`FIGMA_TOKEN`** environment variable — a Figma personal access token (create one at https://www.figma.com/settings). If it is missing, STOP and tell the user: + > Set a Figma token first: create one at figma.com/settings, then `export FIGMA_TOKEN=`. +- Node ≥ 22, pnpm ≥ 10. + +> **Security:** the token is read only from the environment and travels only in the `X-Figma-Token` request header. Never write it to the graph, `meta.json`, logs, or intermediate files. This skill makes outbound calls to `api.figma.com` — unlike `/understand`, it is not fully offline. Tell the user this once. + +## Phase 0 — Pre-flight + +1. Parse `$ARGUMENTS` for a Figma URL or bare file key (the non-flag token) and an optional `--language `. +2. Resolve `PROJECT_ROOT` to the current working directory. +3. Resolve `PLUGIN_ROOT` and ensure core is built (same logic as `/understand` Phase 0.1.5). If `packages/core/dist/figma/index.js` is missing, run: + ```bash + cd "$PLUGIN_ROOT" && (pnpm install --frozen-lockfile 2>/dev/null || pnpm install) && pnpm --filter @understand-anything/core build + ``` +4. `mkdir -p $PROJECT_ROOT/.understand-anything/intermediate`. + +## Phase 1 — FETCH & PARSE (deterministic) + +Run the bundled scan script (`` is this skill's directory): + +```bash +FIGMA_TOKEN="$FIGMA_TOKEN" node /figma-scan.mjs "$PROJECT_ROOT" "" +``` + +It writes `.understand-anything/intermediate/scan-manifest.json` and prints the node counts. Relay the counts to the user. If it exits non-zero, relay stderr and STOP. + +## Phase 2 — ANALYZE (LLM enrichment) + +1. Read `scan-manifest.json`. Group nodes into batches of ~15, grouped by page when possible. +2. For each batch, dispatch a subagent using the `design-analyzer` agent definition (`agents/design-analyzer.md`). Pass: + - the batch of nodes (`id`, `type`, `name`, `figmaMeta`, child names, token usage), + - the full list of existing node IDs, + - `$INTERMEDIATE_DIR = $PROJECT_ROOT/.understand-anything/intermediate`, + - the batch number for output naming. + The agent writes `analysis-batch-.json`. + Append `$LANGUAGE_DIRECTIVE` if `--language` was provided (reuse `/understand`'s directive text). +3. Run up to **5 batches concurrently**. If a batch fails, log a warning and continue — the manifest is a solid base. + +## Phase 3 — MERGE + +```bash +node /figma-merge.mjs "$PROJECT_ROOT" +``` + +It combines `scan-manifest.json` + `analysis-batch-*.json`, runs `mergeDesignGraph` (validates, re-attaches `kind:"design"`), and writes `knowledge-graph.json` + `meta.json`. Relay the printed stats and any non-`auto-corrected` issues. + +## Phase 4 — SAVE & LAUNCH + +1. Clean up intermediate files **except** `scan-manifest.json`: + ```bash + INTER="$PROJECT_ROOT/.understand-anything/intermediate" + find "$INTER" -mindepth 1 -maxdepth 1 -not -name 'scan-manifest.json' -exec rm -rf {} + + ``` +2. Report a summary: project name, counts by node type, edges by type, layers, tour steps, and the path `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`. +3. Auto-launch the dashboard by invoking the `/understand-dashboard` skill. +```` + +- [ ] **Step 2: Verify frontmatter** + +Run: `head -5 understand-anything-plugin/skills/understand-figma/SKILL.md` +Expected: shows the `---` frontmatter with `name: understand-figma`. + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/skills/understand-figma/SKILL.md +git commit -m "feat(skill): /understand-figma orchestration (fetch/parse, analyze, merge, launch)" +``` + +--- + +### Task 11: Pre-fetch screen thumbnails into `figmaMeta.thumbnailUrl` + +Figma's `/v1/images` returns pre-signed image URLs (no auth needed to load them in a browser), so v1 stores a screen's thumbnail URL on its node and the dashboard renders a plain ``. (A dev-server proxy endpoint is a robust follow-up — see Open Questions.) + +**Files:** +- Modify: `understand-anything-plugin/skills/understand-figma/figma-scan.mjs` + +- [ ] **Step 1: Add thumbnail pre-fetch after parsing** + +In `figma-scan.mjs`, after the `extractTokens(...)` line and before building `manifest`, insert: + +```javascript +// Pre-fetch thumbnails for screens only (bounded). URLs are pre-signed and +// may expire after a few hours — fine for view-after-generate; re-run to refresh. +const screens = structural.nodes.filter((n) => n.type === "screen"); +try { + const images = await source.renderImages(screens.map((n) => n.figmaMeta.nodeId)); + for (const s of screens) { + const url = images[s.figmaMeta.nodeId]; + if (url) s.figmaMeta.thumbnailUrl = url; + } +} catch { + // thumbnails are optional — never fail the scan on image render +} +``` + +- [ ] **Step 2: Smoke-test (offline) still fails only on the token** + +Run: `node understand-anything-plugin/skills/understand-figma/figma-scan.mjs /tmp/nope ABC123` +Expected: still exits with the friendly `FIGMA_TOKEN is not set…` error (no syntax errors introduced). + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/skills/understand-figma/figma-scan.mjs +git commit -m "feat(figma): pre-fetch screen thumbnails into figmaMeta.thumbnailUrl" +``` + +--- + +### Task 12: Dashboard node colors for design node types + +`typeColors` and `typeTextColors` in `CustomNode.tsx` are `Record`; after Task 1 widened `NodeType`, the dashboard build FAILS until all 6 design keys are added. Reuse existing theme vars/classes (no new theme tokens in v1). + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx` + +- [ ] **Step 1: Add the 6 design keys to `typeColors`** + +In the `typeColors` object, after the `source:` line, add: + +```typescript + page: "var(--color-node-concept)", + screen: "var(--color-node-service)", + component: "var(--color-node-class)", + componentSet: "var(--color-node-module)", + instance: "var(--color-node-function)", + token: "var(--color-node-config)", +``` + +- [ ] **Step 2: Add the 6 design keys to `typeTextColors`** + +In the `typeTextColors` object, after the `source:` line, add: + +```typescript + page: "text-node-concept", + screen: "text-node-service", + component: "text-node-class", + componentSet: "text-node-module", + instance: "text-node-function", + token: "text-node-config", +``` + +- [ ] **Step 3: Verify the dashboard type-checks** + +Run: `pnpm --filter @understand-anything/dashboard build` +Expected: PASS — no "Property 'page' is missing in type Record" errors. + +- [ ] **Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +git commit -m "feat(dashboard): node colors for design node types" +``` + +--- + +### Task 13: Sidebar thumbnail + design badges (`NodeInfo.tsx`) + +`typeBadgeColors` is also `Record`, so it needs the 6 keys. Then add a small thumbnail block shown whenever the selected node has `figmaMeta.thumbnailUrl`. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx` + +- [ ] **Step 1: Add the 6 design keys to `typeBadgeColors`** + +After the `source:` entry in `typeBadgeColors`, add: + +```typescript + page: "text-node-concept border border-node-concept/30 bg-node-concept/10", + screen: "text-node-service border border-node-service/30 bg-node-service/10", + component: "text-node-class border border-node-class/30 bg-node-class/10", + componentSet: "text-node-module border border-node-module/30 bg-node-module/10", + instance: "text-node-function border border-node-function/30 bg-node-function/10", + token: "text-node-config border border-node-config/30 bg-node-config/10", +``` + +- [ ] **Step 2: Add the `FigmaThumbnail` component** + +Near the top of the file (e.g., just before `function KnowledgeNodeDetails(`), add: + +```typescript +function FigmaThumbnail({ node }: { node: GraphNode }) { + const url = node.figmaMeta?.thumbnailUrl; + if (!url) return null; + return ( +
+ {node.name} +
+ ); +} +``` + +- [ ] **Step 3: Render the thumbnail in the selected-node panel** + +In the main `NodeInfo` component's selected-node render, immediately **below** the node title/type-badge header and **above** the summary paragraph, insert: + +```tsx + +``` + +(`node` is the currently selected `GraphNode` already in scope in that render. The block self-hides when there is no `figmaMeta.thumbnailUrl`, so it is inert for codebase/knowledge graphs.) + +- [ ] **Step 4: Verify the dashboard type-checks** + +Run: `pnpm --filter @understand-anything/dashboard build` +Expected: PASS (all `Record` maps complete; `FigmaThumbnail` compiles). + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +git commit -m "feat(dashboard): design badges + sidebar Figma thumbnail" +``` + +--- + +### Task 14: Full integration verification + +No `App.tsx`/store change is required for v1: a `kind:"design"` graph loads through the existing `validateGraph` + structural (hierarchical) view, now that the schema accepts design types and the node/badge maps are complete. (A dedicated "Design" view mode and legend entries are optional polish — see Open Questions.) + +**Files:** none (verification only). + +- [ ] **Step 1: Build everything** + +Run: `pnpm --filter @understand-anything/core build && pnpm --filter @understand-anything/dashboard build` +Expected: PASS (both packages compile). + +- [ ] **Step 2: Run the full core test suite** + +Run: `pnpm --filter @understand-anything/core test` +Expected: PASS — including the new `schema`, `api-source`, `parse-document`, `tokens`, and `merge` suites, with no regressions in existing suites. + +- [ ] **Step 3: End-to-end smoke (requires a real token + small test file)** + +Run: +```bash +export FIGMA_TOKEN= +node understand-anything-plugin/skills/understand-figma/figma-scan.mjs "$(pwd)" "" +node understand-anything-plugin/skills/understand-figma/figma-merge.mjs "$(pwd)" +``` +Expected: `.understand-anything/knowledge-graph.json` is written with `"kind": "design"` and non-empty `nodes`/`layers`/`tour`. Open `/understand-dashboard` and confirm screens/components/tokens render and selecting a screen shows its thumbnail. + +- [ ] **Step 4: Confirm no token leakage** + +Run: `grep -ri "$FIGMA_TOKEN" .understand-anything/ || echo "clean"` +Expected: `clean` (the token never appears in the graph, meta, or any intermediate file). + +- [ ] **Step 5: Commit (if any verification fixups were needed)** + +```bash +git add -A && git commit -m "test(figma): full build + suite green for design foundation" +``` + +--- + +### Task 15: Incremental skip via Figma file `version` + +The Figma file response includes a `version` string. v1 incremental = if the stored version matches the current one, skip re-analysis (full re-analyze otherwise). This is the Figma analog of `/understand`'s commit-hash check. + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/figma/source/types.ts` +- Modify: `understand-anything-plugin/skills/understand-figma/figma-scan.mjs` +- Modify: `understand-anything-plugin/skills/understand-figma/figma-merge.mjs` +- Modify: `understand-anything-plugin/skills/understand-figma/SKILL.md` + +- [ ] **Step 1: Add `version` to the `FigmaDocument` type** + +In `figma/source/types.ts`, add to `FigmaDocument`: + +```typescript + version?: string; // Figma file version (changes on every edit) + lastModified?: string; // ISO timestamp +``` + +- [ ] **Step 2: Scan compares version and can short-circuit** + +In `figma-scan.mjs`, replace the manifest write block so it records the version and skips when unchanged. After `const doc = await source.fetchDocument();` add: + +```javascript +import { readFileSync, existsSync } from "node:fs"; // (merge with existing node:fs import) + +const metaPath = join(projectRoot, ".understand-anything", "meta.json"); +const prevVersion = existsSync(metaPath) + ? (JSON.parse(readFileSync(metaPath, "utf8")).figmaVersion ?? null) + : null; +if (doc.version && prevVersion === doc.version && process.env.UNDERSTAND_FIGMA_FORCE !== "1") { + console.error("UP_TO_DATE"); + process.exit(0); +} +``` + +And add `figmaVersion: doc.version ?? "",` to the `manifest` object. + +- [ ] **Step 3: Merge persists the version into meta.json** + +In `figma-merge.mjs`, change the `meta.json` write to include the version from the manifest: + +```javascript +writeFileSync(join(outDir, "meta.json"), JSON.stringify({ + lastAnalyzedAt: new Date().toISOString(), + gitCommitHash: "", + figmaVersion: manifest.figmaVersion ?? "", + version: "1.0.0", + analyzedFiles: result.data.nodes.length, +}, null, 2)); +``` + +- [ ] **Step 4: Skill honors the short-circuit** + +In `SKILL.md` Phase 1, add after running `figma-scan.mjs`: + +> If the scan prints `UP_TO_DATE` (and `--full` was not passed), report "Design graph is already up to date for this Figma file version" and STOP. Pass `--full` by setting `UNDERSTAND_FIGMA_FORCE=1` to force a rebuild. + +- [ ] **Step 5: Verify build + offline smoke** + +Run: `pnpm --filter @understand-anything/core build && node understand-anything-plugin/skills/understand-figma/figma-scan.mjs /tmp/nope ABC123` +Expected: build PASS; scan still exits on the `FIGMA_TOKEN` error (token check precedes the version check). + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/figma/source/types.ts understand-anything-plugin/skills/understand-figma/figma-scan.mjs understand-anything-plugin/skills/understand-figma/figma-merge.mjs understand-anything-plugin/skills/understand-figma/SKILL.md +git commit -m "feat(figma): incremental skip when file version is unchanged" +``` + +--- + +## Self-Review (performed) + +- **Spec coverage:** ingestion/adapter (T3), token-no-leak (T3, T14·4), shallow node set (T4–5), `instance_of` promotion + aliases (T1–2), `figmaMeta` (T1–2), `kind:"design"` (T1–2, T6), design-analyzer (T8), pipeline/phases (T10), merge + page/DS layers + DS-first tour (T6), hybrid dashboard (T12–13), incremental (T15), backward-compat/coexistence/security (T2, T3, T14). No uncovered requirement remains. +- **Placeholders:** none — every code step contains complete code; `// ...` only marks elisions of pre-existing surrounding code. +- **Type consistency:** `parseDocument`/`extractTokens`/`mergeDesignGraph` signatures match their callers in `figma-scan.mjs`/`figma-merge.mjs`; `DesignAnalysis` matches `design-analyzer` output; the 6 new `NodeType`s appear consistently in `types.ts`, `schema.ts`, `CustomNode.tsx`, and `NodeInfo.tsx`. + +## Open Questions / Future (not in this plan) + +- Dedicated "Design" view mode + legend entries (v1 reuses the structural hierarchical view). +- Dev-server `/figma-image` proxy endpoint (robust thumbnails that never expire), replacing stored URLs. +- In-node thumbnails; deep-expand a screen; local-JSON `FigmaSource`; node-level incremental. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-24-understand-figma-foundation.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — a fresh subagent per task with two-stage review between tasks (REQUIRED SUB-SKILL: subagent-driven-development). +2. **Inline Execution** — execute tasks in this session with checkpoints (REQUIRED SUB-SKILL: executing-plans). + +Which approach? From 4be7a7283e2af1236aa394d895b2ffd3689cf8a1 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 17:39:16 +0900 Subject: [PATCH 04/23] feat(core): add design NodeType/EdgeType/FigmaMeta + design kind --- .../packages/core/src/types.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/types.ts b/understand-anything-plugin/packages/core/src/types.ts index b7a0fa6e4..3e0ea7316 100644 --- a/understand-anything-plugin/packages/core/src/types.ts +++ b/understand-anything-plugin/packages/core/src/types.ts @@ -1,10 +1,11 @@ -// Node types (21 total: 5 code + 8 non-code + 3 domain + 5 knowledge) +// Node types (27 total: 5 code + 8 non-code + 3 domain + 5 knowledge + 6 design) export type NodeType = | "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource" | "domain" | "flow" | "step" - | "article" | "entity" | "topic" | "claim" | "source"; + | "article" | "entity" | "topic" | "claim" | "source" + | "page" | "screen" | "component" | "componentSet" | "instance" | "token"; // Edge types (35 total in 8 categories: Structural, Behavioral, Data flow, Dependencies, Semantic, Infrastructure/Schema, Domain, Knowledge) export type EdgeType = @@ -16,7 +17,9 @@ export type EdgeType = | "deploys" | "serves" | "provisions" | "triggers" // Infrastructure | "migrates" | "documents" | "routes" | "defines_schema" // Schema/Data | "contains_flow" | "flow_step" | "cross_domain" // Domain - | "cites" | "contradicts" | "builds_on" | "exemplifies" | "categorized_under" | "authored_by"; // Knowledge + | "cites" | "contradicts" | "builds_on" | "exemplifies" | "categorized_under" | "authored_by" // Knowledge + // Design (3 new → 38 total) + | "instance_of" | "variant_of" | "uses_token"; // Optional knowledge metadata for article/entity/topic/claim/source nodes export interface KnowledgeMeta { @@ -35,7 +38,20 @@ export interface DomainMeta { entryType?: "http" | "cli" | "event" | "cron" | "manual"; } -// GraphNode with 21 types: 5 code + 8 non-code + 3 domain + 5 knowledge +// Optional Figma metadata for page/screen/component/componentSet/instance/token nodes +export interface FigmaMeta { + fileKey?: string; + nodeId?: string; // Figma node id, e.g. "1:23" + figmaType?: string; // FRAME | COMPONENT | COMPONENT_SET | INSTANCE | TEXT ... + thumbnailUrl?: string; // lazily filled from GET /v1/images + dimensions?: { width: number; height: number }; + tokenKind?: "color" | "type" | "spacing" | "effect" | "grid"; + tokenValue?: string; // e.g. "#0A84FF", "16px" + prototypeTargets?: string[]; // roadmap B — recorded now, edges later + componentKey?: string; // roadmap C — recorded now +} + +// GraphNode with 27 types: 5 code + 8 non-code + 3 domain + 5 knowledge + 6 design export interface GraphNode { id: string; type: NodeType; @@ -48,6 +64,7 @@ export interface GraphNode { languageNotes?: string; domainMeta?: DomainMeta; knowledgeMeta?: KnowledgeMeta; + figmaMeta?: FigmaMeta; } // GraphEdge with rich relationship modeling @@ -90,7 +107,7 @@ export interface ProjectMeta { // Root KnowledgeGraph export interface KnowledgeGraph { version: string; - kind?: "codebase" | "knowledge"; + kind?: "codebase" | "knowledge" | "design"; project: ProjectMeta; nodes: GraphNode[]; edges: GraphEdge[]; From 6750d471a920ae83a1dca00ad232c549d2544e8e Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 17:45:57 +0900 Subject: [PATCH 05/23] feat(core): validate design node/edge types, figmaMeta, promote instance_of --- .../core/src/__tests__/schema.test.ts | 42 +++++++++++++++++++ .../packages/core/src/schema.ts | 37 ++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts b/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts index d81759bdf..4cab63445 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts @@ -727,3 +727,45 @@ 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"); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/schema.ts b/understand-anything-plugin/packages/core/src/schema.ts index 71b9cd8ab..a9f9fc632 100644 --- a/understand-anything-plugin/packages/core/src/schema.ts +++ b/understand-anything-plugin/packages/core/src/schema.ts @@ -11,6 +11,7 @@ export const EdgeTypeSchema = z.enum([ "migrates", "documents", "routes", "defines_schema", // Schema/Data "contains_flow", "flow_step", "cross_domain", // Domain "cites", "contradicts", "builds_on", "exemplifies", "categorized_under", "authored_by", // Knowledge + "instance_of", "variant_of", "uses_token", // Design ]); // Aliases that LLMs commonly generate instead of canonical node types @@ -58,7 +59,6 @@ export const NODE_TYPE_ALIASES: Record = { business_step: "step", // Knowledge aliases note: "article", - page: "article", wiki_page: "article", person: "entity", actor: "entity", @@ -72,6 +72,17 @@ export const NODE_TYPE_ALIASES: Record = { reference: "source", raw: "source", paper: "source", + // Design aliases (Figma node types). NOTE: the former `page: "article"` + // entry was removed — `page` is now a first-class design node type, so it + // must not be rewritten (and `canvas: "page"` would otherwise chain). + frame: "screen", + artboard: "screen", + canvas: "page", + main_component: "component", + component_set: "componentSet", + variant_set: "componentSet", + design_token: "token", + style: "token", }; // Aliases that LLMs commonly generate instead of canonical edge types @@ -113,12 +124,18 @@ export const EDGE_TYPE_ALIASES: Record = { refines: "builds_on", elaborates: "builds_on", illustrates: "exemplifies", - instance_of: "exemplifies", example_of: "exemplifies", belongs_to: "categorized_under", tagged_with: "categorized_under", written_by: "authored_by", created_by: "authored_by", + // Design aliases + instantiates: "instance_of", + variant: "variant_of", + styled_by: "uses_token", + applies_token: "uses_token", + // NOTE: the former `instance_of: "exemplifies"` entry is removed — + // instance_of is now a first-class design edge. // Note: "implemented_by" is intentionally NOT aliased to "implements" — // it inverts edge direction (see commit fd0df15). The LLM should use // "implements" with correct source/target instead. @@ -365,6 +382,18 @@ const KnowledgeMetaSchema = z.object({ content: z.string().optional(), }).passthrough(); +const FigmaMetaSchema = z.object({ + fileKey: z.string().optional(), + nodeId: z.string().optional(), + figmaType: z.string().optional(), + thumbnailUrl: z.string().optional(), + dimensions: z.object({ width: z.number(), height: z.number() }).optional(), + tokenKind: z.enum(["color", "type", "spacing", "effect", "grid"]).optional(), + tokenValue: z.string().optional(), + prototypeTargets: z.array(z.string()).optional(), + componentKey: z.string().optional(), +}).passthrough(); + export const GraphNodeSchema = z.object({ id: z.string(), type: z.enum([ @@ -373,6 +402,7 @@ export const GraphNodeSchema = z.object({ "pipeline", "schema", "resource", "domain", "flow", "step", "article", "entity", "topic", "claim", "source", + "page", "screen", "component", "componentSet", "instance", "token", ]), name: z.string(), filePath: z.string().optional(), @@ -383,6 +413,7 @@ export const GraphNodeSchema = z.object({ languageNotes: z.string().optional(), domainMeta: DomainMetaSchema.optional(), knowledgeMeta: KnowledgeMetaSchema.optional(), + figmaMeta: FigmaMetaSchema.optional(), }).passthrough(); export const GraphEdgeSchema = z.object({ @@ -420,7 +451,7 @@ export const ProjectMetaSchema = z.object({ export const KnowledgeGraphSchema = z.object({ version: z.string(), - kind: z.enum(["codebase", "knowledge"]).optional(), + kind: z.enum(["codebase", "knowledge", "design"]).optional(), project: ProjectMetaSchema, nodes: z.array(GraphNodeSchema), edges: z.array(GraphEdgeSchema), From e9ce229fa5e6081a1c12f3d02a5f795aa79c7f21 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 18:07:41 +0900 Subject: [PATCH 06/23] feat(core): FigmaSource adapter + FigmaApiSource (token via env, no leak) --- .../src/figma/__tests__/api-source.test.ts | 45 +++++++++++++++++ .../core/src/figma/source/api-source.ts | 50 +++++++++++++++++++ .../packages/core/src/figma/source/types.ts | 27 ++++++++++ 3 files changed, 122 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/figma/__tests__/api-source.test.ts create mode 100644 understand-anything-plugin/packages/core/src/figma/source/api-source.ts create mode 100644 understand-anything-plugin/packages/core/src/figma/source/types.ts diff --git a/understand-anything-plugin/packages/core/src/figma/__tests__/api-source.test.ts b/understand-anything-plugin/packages/core/src/figma/__tests__/api-source.test.ts new file mode 100644 index 000000000..46e086559 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/__tests__/api-source.test.ts @@ -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)["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/); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/figma/source/api-source.ts b/understand-anything-plugin/packages/core/src/figma/source/api-source.ts new file mode 100644 index 000000000..b2e887fcc --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/source/api-source.ts @@ -0,0 +1,50 @@ +import type { FigmaSource, FigmaDocument, FigmaStyles } from "./types"; + +const FIGMA_API = "https://api.figma.com/v1"; + +export function parseFileKey(urlOrKey: string): string { + const m = urlOrKey.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/); + if (m) return m[1]; + if (/^[A-Za-z0-9]+$/.test(urlOrKey.trim())) return urlOrKey.trim(); + throw new Error(`Could not parse a Figma file key from: ${urlOrKey}`); +} + +export class FigmaApiSource implements FigmaSource { + private readonly token: string; + + constructor(private readonly fileKey: string, token: string | undefined = process.env.FIGMA_TOKEN) { + if (!token) { + throw new Error( + "FIGMA_TOKEN is not set. Create a personal access token at " + + "https://www.figma.com/settings, then run: export FIGMA_TOKEN=", + ); + } + this.token = token; + } + + private async get(path: string): Promise { + // Token travels only in the header — never in the URL, never logged. + const res = await fetch(`${FIGMA_API}${path}`, { headers: { "X-Figma-Token": this.token } }); + if (!res.ok) { + throw new Error(`Figma API ${path} failed: ${res.status} ${res.statusText}`); + } + return (await res.json()) as T; + } + + fetchDocument(): Promise { + return this.get(`/files/${this.fileKey}`); + } + + fetchStyles(): Promise { + return this.get(`/files/${this.fileKey}/styles`); + } + + async renderImages(nodeIds: string[]): Promise> { + if (nodeIds.length === 0) return {}; + const ids = encodeURIComponent(nodeIds.join(",")); + const data = await this.get<{ images: Record }>( + `/images/${this.fileKey}?ids=${ids}&format=png&scale=1`, + ); + return data.images ?? {}; + } +} diff --git a/understand-anything-plugin/packages/core/src/figma/source/types.ts b/understand-anything-plugin/packages/core/src/figma/source/types.ts new file mode 100644 index 000000000..3b28e399c --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/source/types.ts @@ -0,0 +1,27 @@ +export interface FigmaNode { + id: string; + name: string; + type: string; // DOCUMENT | CANVAS | FRAME | SECTION | COMPONENT | COMPONENT_SET | INSTANCE | TEXT | ... + children?: FigmaNode[]; + componentId?: string; // on INSTANCE → main component node id + absoluteBoundingBox?: { width: number; height: number } | null; + styles?: Record; // styleType (fill/text/effect/grid) → style key + transitionNodeID?: string | null; // prototype target node id +} + +export interface FigmaDocument { + name: string; + document: FigmaNode; // root (DOCUMENT) whose children are CANVAS (pages) + components?: Record; + componentSets?: Record; +} + +export interface FigmaStyles { + meta?: { styles?: Array<{ key: string; name: string; style_type: string }> }; +} + +export interface FigmaSource { + fetchDocument(): Promise; + fetchStyles(): Promise; + renderImages(nodeIds: string[]): Promise>; +} From b229231e11cc3709aaf6d33c4b315377d84611c8 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 18:09:17 +0900 Subject: [PATCH 07/23] feat(core): deterministic Figma document parser (pages/screens/components/instances) --- .../figma/__tests__/parse-document.test.ts | 50 ++++++++++ .../core/src/figma/parse/parse-document.ts | 97 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/figma/__tests__/parse-document.test.ts create mode 100644 understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts diff --git a/understand-anything-plugin/packages/core/src/figma/__tests__/parse-document.test.ts b/understand-anything-plugin/packages/core/src/figma/__tests__/parse-document.test.ts new file mode 100644 index 000000000..9010c95b1 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/__tests__/parse-document.test.ts @@ -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); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts b/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts new file mode 100644 index 000000000..d48e9d925 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts @@ -0,0 +1,97 @@ +import type { GraphNode, GraphEdge } from "../../types"; +import type { FigmaDocument, FigmaNode } from "../source/types"; + +function mkNode( + type: GraphNode["type"], + figmaId: string, + name: string, + figmaMeta: GraphNode["figmaMeta"], +): GraphNode { + return { + id: `${type}:${figmaId}`, + type, + name, + summary: name, // placeholder; design-analyzer enriches in Phase 2 + tags: [type], + complexity: "simple", + figmaMeta, + }; +} + +export function parseDocument(doc: FigmaDocument, fileKey: string): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const seen = new Set(); + + const add = (n: GraphNode) => { if (!seen.has(n.id)) { seen.add(n.id); nodes.push(n); } }; + const link = (source: string, target: string, type: GraphEdge["type"], weight: number) => + edges.push({ source, target, type, direction: "forward", weight }); + + // Deep-read a screen subtree to find instances (shallow node set, but deep read). + function collectInstances(n: FigmaNode, screenId: string) { + for (const child of n.children ?? []) { + if (child.type === "INSTANCE") { + const inst = mkNode("instance", child.id, child.name, { + fileKey, nodeId: child.id, figmaType: "INSTANCE", + componentKey: child.componentId, + prototypeTargets: child.transitionNodeID ? [child.transitionNodeID] : undefined, + }); + add(inst); + link(screenId, inst.id, "contains", 1.0); + if (child.componentId) link(inst.id, `component:${child.componentId}`, "instance_of", 0.8); + } + if (child.children) collectInstances(child, screenId); + } + } + + function handlePageChild(child: FigmaNode, pageId: string) { + switch (child.type) { + case "FRAME": { + const screen = mkNode("screen", child.id, child.name, { + fileKey, nodeId: child.id, figmaType: "FRAME", + dimensions: child.absoluteBoundingBox + ? { width: child.absoluteBoundingBox.width, height: child.absoluteBoundingBox.height } + : undefined, + }); + add(screen); + link(pageId, screen.id, "contains", 1.0); + collectInstances(child, screen.id); + break; + } + case "COMPONENT": { + const comp = mkNode("component", child.id, child.name, { fileKey, nodeId: child.id, figmaType: "COMPONENT" }); + add(comp); + link(pageId, comp.id, "contains", 1.0); + break; + } + case "COMPONENT_SET": { + const set = mkNode("componentSet", child.id, child.name, { fileKey, nodeId: child.id, figmaType: "COMPONENT_SET" }); + add(set); + link(pageId, set.id, "contains", 1.0); + for (const variant of child.children ?? []) { + if (variant.type === "COMPONENT") { + const comp = mkNode("component", variant.id, variant.name, { fileKey, nodeId: variant.id, figmaType: "COMPONENT" }); + add(comp); + link(comp.id, set.id, "variant_of", 0.9); + } + } + break; + } + case "SECTION": { + for (const sub of child.children ?? []) handlePageChild(sub, pageId); // flatten sections in v1 + break; + } + default: + break; // other top-level types are ignored in v1 + } + } + + for (const canvas of doc.document.children ?? []) { + if (canvas.type !== "CANVAS") continue; + const page = mkNode("page", canvas.id, canvas.name, { fileKey, nodeId: canvas.id, figmaType: "CANVAS" }); + add(page); + for (const child of canvas.children ?? []) handlePageChild(child, page.id); + } + + return { nodes, edges }; +} From 2073dd06de5ec98905d68f4843dd5909f3274443 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 18:11:24 +0900 Subject: [PATCH 08/23] feat(core): extract design tokens from published styles + uses_token edges --- .../core/src/figma/__tests__/tokens.test.ts | 35 +++++++++++ .../packages/core/src/figma/parse/tokens.ts | 61 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts create mode 100644 understand-anything-plugin/packages/core/src/figma/parse/tokens.ts diff --git a/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts b/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts new file mode 100644 index 000000000..2c8fcf9fd --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts @@ -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" }), + ])); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts b/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts new file mode 100644 index 000000000..648a83f6a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts @@ -0,0 +1,61 @@ +import type { GraphNode, GraphEdge, FigmaMeta } from "../../types"; +import type { FigmaDocument, FigmaNode, FigmaStyles } from "../source/types"; + +const STYLE_KIND: Record> = { + FILL: "color", TEXT: "type", EFFECT: "effect", GRID: "grid", +}; + +function slug(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +export function extractTokens( + doc: FigmaDocument, + styles: FigmaStyles, + structuralNodes: GraphNode[], + fileKey: string, +): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const tokenByStyleKey = new Map(); + + // Only published styles/variables become token nodes (bounded set). + for (const s of styles.meta?.styles ?? []) { + const kind = STYLE_KIND[s.style_type] ?? "color"; + const id = `token:${kind}:${slug(s.name)}`; + if (!tokenByStyleKey.has(s.key)) tokenByStyleKey.set(s.key, id); + if (!nodes.some((n) => n.id === id)) { + nodes.push({ + id, type: "token", name: s.name, summary: s.name, + tags: ["token", kind], complexity: "simple", + figmaMeta: { fileKey, tokenKind: kind }, + }); + } + } + + const graphIdByFigmaId = new Map(); + for (const n of structuralNodes) { + if (n.figmaMeta?.nodeId) graphIdByFigmaId.set(n.figmaMeta.nodeId, n.id); + } + + const usesSeen = new Set(); + function walk(n: FigmaNode) { + const consumerId = graphIdByFigmaId.get(n.id); + if (consumerId && n.styles) { + for (const styleKey of Object.values(n.styles)) { + const tokenId = tokenByStyleKey.get(styleKey); + if (tokenId) { + const dedupe = `${consumerId}|${tokenId}`; + if (!usesSeen.has(dedupe)) { + usesSeen.add(dedupe); + edges.push({ source: consumerId, target: tokenId, type: "uses_token", direction: "forward", weight: 0.5 }); + } + } + } + } + for (const c of n.children ?? []) walk(c); + } + walk(doc.document); + + return { nodes, edges }; +} From 07473deb23981d0b963746c4c5bc5164bbec0989 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 18:21:28 +0900 Subject: [PATCH 09/23] fix(core): keep componentSet nodes through validateGraph sanitize lowercasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitizeGraph lowercases every node type, and componentSet is the only camelCase canonical NodeType. Without a lowercased-form alias it became "componentset", failed the enum check, and was dropped along with its variant_of/contains edges — silently losing all component sets from a design graph. Add componentset->componentSet alias + regression test. --- .../packages/core/src/__tests__/schema.test.ts | 12 ++++++++++++ .../packages/core/src/schema.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts b/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts index 4cab63445..bb19279cf 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/schema.test.ts @@ -768,4 +768,16 @@ describe("design graph schema", () => { 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); + }); }); diff --git a/understand-anything-plugin/packages/core/src/schema.ts b/understand-anything-plugin/packages/core/src/schema.ts index a9f9fc632..90c2f48de 100644 --- a/understand-anything-plugin/packages/core/src/schema.ts +++ b/understand-anything-plugin/packages/core/src/schema.ts @@ -81,6 +81,10 @@ export const NODE_TYPE_ALIASES: Record = { main_component: "component", component_set: "componentSet", variant_set: "componentSet", + // sanitizeGraph lowercases every node type, and "componentSet" is the only + // camelCase canonical NodeType — so it arrives here as "componentset" and + // must be mapped back, otherwise it fails the enum check and gets dropped. + componentset: "componentSet", design_token: "token", style: "token", }; From e1fd0c9ae74cb8b25fd4e7ed0e6f16a44a43b552 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 18:46:01 +0900 Subject: [PATCH 10/23] feat(core): merge Figma manifest + analysis into kind:design graph with layers/tour --- .../core/src/figma/__tests__/merge.test.ts | 42 ++++++++++ .../packages/core/src/figma/merge.ts | 84 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/figma/__tests__/merge.test.ts create mode 100644 understand-anything-plugin/packages/core/src/figma/merge.ts diff --git a/understand-anything-plugin/packages/core/src/figma/__tests__/merge.test.ts b/understand-anything-plugin/packages/core/src/figma/__tests__/merge.test.ts new file mode 100644 index 000000000..bbed25815 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/__tests__/merge.test.ts @@ -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"); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/figma/merge.ts b/understand-anything-plugin/packages/core/src/figma/merge.ts new file mode 100644 index 000000000..756a5b976 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/merge.ts @@ -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 & Partial>>; + edges?: GraphEdge[]; +} + +const DS_TYPES = new Set(["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(); + 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(); + 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(); + 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(); + 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; +} From ad22b3796e396b11271a9045a10419fc1a8a164b Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 18:48:21 +0900 Subject: [PATCH 11/23] feat(core): barrel export for the figma module --- understand-anything-plugin/packages/core/src/figma/index.ts | 5 +++++ .../packages/core/src/figma/parse/parse-document.ts | 4 ++-- .../packages/core/src/figma/parse/tokens.ts | 4 ++-- .../packages/core/src/figma/source/api-source.ts | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 understand-anything-plugin/packages/core/src/figma/index.ts diff --git a/understand-anything-plugin/packages/core/src/figma/index.ts b/understand-anything-plugin/packages/core/src/figma/index.ts new file mode 100644 index 000000000..042adaa24 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/index.ts @@ -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"; diff --git a/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts b/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts index d48e9d925..a1affb0cb 100644 --- a/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts +++ b/understand-anything-plugin/packages/core/src/figma/parse/parse-document.ts @@ -1,5 +1,5 @@ -import type { GraphNode, GraphEdge } from "../../types"; -import type { FigmaDocument, FigmaNode } from "../source/types"; +import type { GraphNode, GraphEdge } from "../../types.js"; +import type { FigmaDocument, FigmaNode } from "../source/types.js"; function mkNode( type: GraphNode["type"], diff --git a/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts b/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts index 648a83f6a..31d42539a 100644 --- a/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts +++ b/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts @@ -1,5 +1,5 @@ -import type { GraphNode, GraphEdge, FigmaMeta } from "../../types"; -import type { FigmaDocument, FigmaNode, FigmaStyles } from "../source/types"; +import type { GraphNode, GraphEdge, FigmaMeta } from "../../types.js"; +import type { FigmaDocument, FigmaNode, FigmaStyles } from "../source/types.js"; const STYLE_KIND: Record> = { FILL: "color", TEXT: "type", EFFECT: "effect", GRID: "grid", diff --git a/understand-anything-plugin/packages/core/src/figma/source/api-source.ts b/understand-anything-plugin/packages/core/src/figma/source/api-source.ts index b2e887fcc..e07d5589b 100644 --- a/understand-anything-plugin/packages/core/src/figma/source/api-source.ts +++ b/understand-anything-plugin/packages/core/src/figma/source/api-source.ts @@ -1,4 +1,4 @@ -import type { FigmaSource, FigmaDocument, FigmaStyles } from "./types"; +import type { FigmaSource, FigmaDocument, FigmaStyles } from "./types.js"; const FIGMA_API = "https://api.figma.com/v1"; From 6e9edaf91ff3269305c92cd6d6ca0bf9fda00a04 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:03:55 +0900 Subject: [PATCH 12/23] feat(agents): add design-analyzer (semantic enrichment for Figma graphs) --- .../agents/design-analyzer.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 understand-anything-plugin/agents/design-analyzer.md diff --git a/understand-anything-plugin/agents/design-analyzer.md b/understand-anything-plugin/agents/design-analyzer.md new file mode 100644 index 000000000..7b0ac5474 --- /dev/null +++ b/understand-anything-plugin/agents/design-analyzer.md @@ -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. From 12af092a977d4675827fb3e760363ca05b20a810 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:05:41 +0900 Subject: [PATCH 13/23] feat(figma): core ./figma export + scan/merge wrapper scripts --- .../packages/core/package.json | 4 ++ .../skills/understand-figma/figma-merge.mjs | 38 ++++++++++++++++ .../skills/understand-figma/figma-scan.mjs | 45 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 understand-anything-plugin/skills/understand-figma/figma-merge.mjs create mode 100644 understand-anything-plugin/skills/understand-figma/figma-scan.mjs diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index e54ce7285..5a5770786 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -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": { diff --git a/understand-anything-plugin/skills/understand-figma/figma-merge.mjs b/understand-anything-plugin/skills/understand-figma/figma-merge.mjs new file mode 100644 index 000000000..e7c757c2f --- /dev/null +++ b/understand-anything-plugin/skills/understand-figma/figma-merge.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { mergeDesignGraph } from "@understand-anything/core/figma"; + +const [, , projectRoot] = process.argv; +const interDir = join(projectRoot, ".understand-anything", "intermediate"); +const manifest = JSON.parse(readFileSync(join(interDir, "scan-manifest.json"), "utf8")); +const analyses = readdirSync(interDir) + .filter((f) => /^analysis-batch-.*\.json$/.test(f)) + .map((f) => JSON.parse(readFileSync(join(interDir, f), "utf8"))); + +const result = mergeDesignGraph( + { nodes: manifest.nodes, edges: manifest.edges }, + analyses, + manifest.project, +); +if (!result.success || !result.data) { + console.error("Merge failed:", result.fatal ?? "unknown error"); + process.exit(1); +} + +const outDir = join(projectRoot, ".understand-anything"); +writeFileSync(join(outDir, "knowledge-graph.json"), JSON.stringify(result.data, null, 2)); +writeFileSync(join(outDir, "meta.json"), JSON.stringify({ + lastAnalyzedAt: new Date().toISOString(), + gitCommitHash: "", + version: "1.0.0", + analyzedFiles: result.data.nodes.length, +}, null, 2)); + +console.error( + `Design graph: ${result.data.nodes.length} nodes, ${result.data.edges.length} edges, ` + + `${result.data.layers.length} layers, ${result.data.tour.length} tour steps`, +); +for (const issue of result.issues) { + if (issue.level !== "auto-corrected") console.error(`[${issue.level}] ${issue.message}`); +} diff --git a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs new file mode 100644 index 000000000..bbbffb30c --- /dev/null +++ b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { parseFileKey, FigmaApiSource, parseDocument, extractTokens } from "@understand-anything/core/figma"; + +const [, , projectRoot, urlOrKey] = process.argv; +if (!projectRoot || !urlOrKey) { + console.error("usage: figma-scan.mjs "); + process.exit(1); +} + +const fileKey = parseFileKey(urlOrKey); +const source = new FigmaApiSource(fileKey); // reads FIGMA_TOKEN from env; throws a friendly error if missing +const doc = await source.fetchDocument(); +const styles = await source.fetchStyles().catch(() => ({ meta: { styles: [] } })); + +const structural = parseDocument(doc, fileKey); +const tokens = extractTokens(doc, styles, structural.nodes, fileKey); +const nodes = [...structural.nodes, ...tokens.nodes]; +const edges = [...structural.edges, ...tokens.edges]; + +const manifest = { + project: { + name: doc.name, + languages: ["figma"], + frameworks: [], + description: `Figma design file: ${doc.name}`, + analyzedAt: new Date().toISOString(), + gitCommitHash: "", + }, + fileKey, + nodes, + edges, +}; + +const interDir = join(projectRoot, ".understand-anything", "intermediate"); +mkdirSync(interDir, { recursive: true }); +writeFileSync(join(interDir, "scan-manifest.json"), JSON.stringify(manifest, null, 2)); + +const count = (t) => nodes.filter((n) => n.type === t).length; +console.error( + `Figma scan: ${count("page")} pages, ${count("screen")} screens, ` + + `${count("component")} components, ${count("componentSet")} sets, ` + + `${count("instance")} instances, ${count("token")} tokens`, +); From 61a67febbe0d92e7ad0941662e26691bdb12682d Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:06:13 +0900 Subject: [PATCH 14/23] feat(skill): /understand-figma orchestration (fetch/parse, analyze, merge, launch) --- .../skills/understand-figma/SKILL.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 understand-anything-plugin/skills/understand-figma/SKILL.md diff --git a/understand-anything-plugin/skills/understand-figma/SKILL.md b/understand-anything-plugin/skills/understand-figma/SKILL.md new file mode 100644 index 000000000..18b0fc686 --- /dev/null +++ b/understand-anything-plugin/skills/understand-figma/SKILL.md @@ -0,0 +1,67 @@ +--- +name: understand-figma +description: Analyze a Figma file via the Figma REST API and generate an interactive design knowledge graph (pages, screens, components, component sets, instances, design tokens) with a kind:"design" dashboard. +argument-hint: " [--language ]" +--- + +# /understand-figma + +Analyzes a Figma file and produces an interactive design knowledge graph in the existing dashboard. + +## Prerequisites + +- **`FIGMA_TOKEN`** environment variable — a Figma personal access token (create one at https://www.figma.com/settings). If it is missing, STOP and tell the user: + > Set a Figma token first: create one at figma.com/settings, then `export FIGMA_TOKEN=`. +- Node ≥ 22, pnpm ≥ 10. + +> **Security:** the token is read only from the environment and travels only in the `X-Figma-Token` request header. Never write it to the graph, `meta.json`, logs, or intermediate files. This skill makes outbound calls to `api.figma.com` — unlike `/understand`, it is not fully offline. Tell the user this once. + +## Phase 0 — Pre-flight + +1. Parse `$ARGUMENTS` for a Figma URL or bare file key (the non-flag token) and an optional `--language `. +2. Resolve `PROJECT_ROOT` to the current working directory. +3. Resolve `PLUGIN_ROOT` and ensure core is built (same logic as `/understand` Phase 0.1.5). If `packages/core/dist/figma/index.js` is missing, run: + ```bash + cd "$PLUGIN_ROOT" && (pnpm install --frozen-lockfile 2>/dev/null || pnpm install) && pnpm --filter @understand-anything/core build + ``` +4. `mkdir -p $PROJECT_ROOT/.understand-anything/intermediate`. + +## Phase 1 — FETCH & PARSE (deterministic) + +Run the bundled scan script (`` is this skill's directory): + +```bash +FIGMA_TOKEN="$FIGMA_TOKEN" node /figma-scan.mjs "$PROJECT_ROOT" "" +``` + +It writes `.understand-anything/intermediate/scan-manifest.json` and prints the node counts. Relay the counts to the user. If it exits non-zero, relay stderr and STOP. + +## Phase 2 — ANALYZE (LLM enrichment) + +1. Read `scan-manifest.json`. Group nodes into batches of ~15, grouped by page when possible. +2. For each batch, dispatch a subagent using the `design-analyzer` agent definition (`agents/design-analyzer.md`). Pass: + - the batch of nodes (`id`, `type`, `name`, `figmaMeta`, child names, token usage), + - the full list of existing node IDs, + - `$INTERMEDIATE_DIR = $PROJECT_ROOT/.understand-anything/intermediate`, + - the batch number for output naming. + The agent writes `analysis-batch-.json`. + Append `$LANGUAGE_DIRECTIVE` if `--language` was provided (reuse `/understand`'s directive text). +3. Run up to **5 batches concurrently**. If a batch fails, log a warning and continue — the manifest is a solid base. + +## Phase 3 — MERGE + +```bash +node /figma-merge.mjs "$PROJECT_ROOT" +``` + +It combines `scan-manifest.json` + `analysis-batch-*.json`, runs `mergeDesignGraph` (validates, re-attaches `kind:"design"`), and writes `knowledge-graph.json` + `meta.json`. Relay the printed stats and any non-`auto-corrected` issues. + +## Phase 4 — SAVE & LAUNCH + +1. Clean up intermediate files **except** `scan-manifest.json`: + ```bash + INTER="$PROJECT_ROOT/.understand-anything/intermediate" + find "$INTER" -mindepth 1 -maxdepth 1 -not -name 'scan-manifest.json' -exec rm -rf {} + + ``` +2. Report a summary: project name, counts by node type, edges by type, layers, tour steps, and the path `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`. +3. Auto-launch the dashboard by invoking the `/understand-dashboard` skill. From 0d216f34a2a37b7b733ad88fec815259d4f90611 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:07:13 +0900 Subject: [PATCH 15/23] feat(figma): pre-fetch screen thumbnails into figmaMeta.thumbnailUrl --- .../skills/understand-figma/figma-scan.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs index bbbffb30c..54fb3d960 100644 --- a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs +++ b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs @@ -19,6 +19,19 @@ const tokens = extractTokens(doc, styles, structural.nodes, fileKey); const nodes = [...structural.nodes, ...tokens.nodes]; const edges = [...structural.edges, ...tokens.edges]; +// Pre-fetch thumbnails for screens only (bounded). URLs are pre-signed and +// may expire after a few hours — fine for view-after-generate; re-run to refresh. +const screens = structural.nodes.filter((n) => n.type === "screen"); +try { + const images = await source.renderImages(screens.map((n) => n.figmaMeta.nodeId)); + for (const s of screens) { + const url = images[s.figmaMeta.nodeId]; + if (url) s.figmaMeta.thumbnailUrl = url; + } +} catch { + // thumbnails are optional — never fail the scan on image render +} + const manifest = { project: { name: doc.name, From 12dfa20f539ce23b08aa4f6ec6d05b616f57d5c4 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:35:22 +0900 Subject: [PATCH 16/23] feat(dashboard): node colors for design node types --- .../packages/dashboard/src/components/CustomNode.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx index 5dffd2df3..a0f8ca20c 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx @@ -27,6 +27,12 @@ const typeColors: Record = { topic: "var(--color-node-topic)", claim: "var(--color-node-claim)", source: "var(--color-node-source)", + page: "var(--color-node-concept)", + screen: "var(--color-node-service)", + component: "var(--color-node-class)", + componentSet: "var(--color-node-module)", + instance: "var(--color-node-function)", + token: "var(--color-node-config)", }; const typeTextColors: Record = { @@ -51,6 +57,12 @@ const typeTextColors: Record = { topic: "text-node-topic", claim: "text-node-claim", source: "text-node-source", + page: "text-node-concept", + screen: "text-node-service", + component: "text-node-class", + componentSet: "text-node-module", + instance: "text-node-function", + token: "text-node-config", }; const complexityColors: Record = { From 5c3b371ca9034f1c254efe48a1f391016f6bd01d Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:35:31 +0900 Subject: [PATCH 17/23] feat(dashboard): design badges + sidebar Figma thumbnail --- .../dashboard/src/components/NodeInfo.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx index 2af49934c..a92ad73ce 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx @@ -26,6 +26,12 @@ const typeBadgeColors: Record = { topic: "text-node-topic border border-node-topic/30 bg-node-topic/10", claim: "text-node-claim border border-node-claim/30 bg-node-claim/10", source: "text-node-source border border-node-source/30 bg-node-source/10", + page: "text-node-concept border border-node-concept/30 bg-node-concept/10", + screen: "text-node-service border border-node-service/30 bg-node-service/10", + component: "text-node-class border border-node-class/30 bg-node-class/10", + componentSet: "text-node-module border border-node-module/30 bg-node-module/10", + instance: "text-node-function border border-node-function/30 bg-node-function/10", + token: "text-node-config border border-node-config/30 bg-node-config/10", }; const complexityBadgeColors: Record = { @@ -35,7 +41,10 @@ const complexityBadgeColors: Record = { }; function getDirectionalLabel(edgeType: string, isSource: boolean, t: ReturnType["t"]): string { - const labels = t.edgeLabels[edgeType as EdgeType]; + // edgeLabels only defines the labels that have explicit translations; the new + // design edge types (instance_of/variant_of/uses_token) intentionally fall back + // to the generated label below, so index it as a partial map over EdgeType. + const labels = (t.edgeLabels as Partial>)[edgeType as EdgeType]; if (!labels) { const formatted = edgeType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); return isSource ? formatted : `${formatted} (reverse)`; @@ -43,6 +52,16 @@ function getDirectionalLabel(edgeType: string, isSource: boolean, t: ReturnType< return isSource ? labels.forward : labels.backward; } +function FigmaThumbnail({ node }: { node: GraphNode }) { + const url = node.figmaMeta?.thumbnailUrl; + if (!url) return null; + return ( +
+ {node.name} +
+ ); +} + function KnowledgeNodeDetails({ node, graph }: { node: GraphNode; graph: KnowledgeGraph }) { const navigateToNode = useDashboardStore((s) => s.navigateToNode); const { t } = useI18n(); @@ -380,6 +399,8 @@ export default function NodeInfo() { + +

{node.summary}

From ffb2ed3fb0c9eb5f5c08c93e229e90864d34142d Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:36:03 +0900 Subject: [PATCH 18/23] fix(dashboard): map design node types to a filter category The NodeType union widened with 6 design types (page/screen/component/ componentSet/instance/token). NODE_TYPE_TO_CATEGORY is Record, so the dashboard build failed until every key was present. Group the design types under the existing "code" category (matching the runtime `?? "code"` fallback) so design graphs stay visible by default; a dedicated "design" category is deferred polish. --- .../packages/dashboard/src/components/GraphView.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index 2a5037a1a..c697ca003 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -75,6 +75,9 @@ const NODE_TYPE_TO_CATEGORY: Record = { table: "data", endpoint: "data", schema: "data", domain: "domain", flow: "domain", step: "domain", article: "knowledge", entity: "knowledge", topic: "knowledge", claim: "knowledge", source: "knowledge", + // Design node types (Figma graphs). No dedicated filter category in v1, so they + // group under "code" — matching the runtime fallback below — to stay visible by default. + page: "code", screen: "code", component: "code", componentSet: "code", instance: "code", token: "code", } as const; // ── Helper components that must live inside ──────────────── From ff1bf75fd55356edfa4c9f3598d9adfa2a971cfb Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 19:52:02 +0900 Subject: [PATCH 19/23] feat(figma): incremental skip when file version is unchanged --- .../packages/core/src/figma/source/types.ts | 2 ++ .../skills/understand-figma/SKILL.md | 2 ++ .../skills/understand-figma/figma-merge.mjs | 1 + .../skills/understand-figma/figma-scan.mjs | 18 +++++++++++++++++- 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/understand-anything-plugin/packages/core/src/figma/source/types.ts b/understand-anything-plugin/packages/core/src/figma/source/types.ts index 3b28e399c..665f267c1 100644 --- a/understand-anything-plugin/packages/core/src/figma/source/types.ts +++ b/understand-anything-plugin/packages/core/src/figma/source/types.ts @@ -14,6 +14,8 @@ export interface FigmaDocument { document: FigmaNode; // root (DOCUMENT) whose children are CANVAS (pages) components?: Record; componentSets?: Record; + version?: string; // Figma file version (changes on every edit) + lastModified?: string; // ISO timestamp } export interface FigmaStyles { diff --git a/understand-anything-plugin/skills/understand-figma/SKILL.md b/understand-anything-plugin/skills/understand-figma/SKILL.md index 18b0fc686..b20f256cc 100644 --- a/understand-anything-plugin/skills/understand-figma/SKILL.md +++ b/understand-anything-plugin/skills/understand-figma/SKILL.md @@ -36,6 +36,8 @@ FIGMA_TOKEN="$FIGMA_TOKEN" node /figma-scan.mjs "$PROJECT_ROOT" " If the scan prints `UP_TO_DATE`, report "Design graph is already up to date for this Figma file version" and STOP. To force a full rebuild, re-run with `UNDERSTAND_FIGMA_FORCE=1` set in the environment. + ## Phase 2 — ANALYZE (LLM enrichment) 1. Read `scan-manifest.json`. Group nodes into batches of ~15, grouped by page when possible. diff --git a/understand-anything-plugin/skills/understand-figma/figma-merge.mjs b/understand-anything-plugin/skills/understand-figma/figma-merge.mjs index e7c757c2f..68df456d0 100644 --- a/understand-anything-plugin/skills/understand-figma/figma-merge.mjs +++ b/understand-anything-plugin/skills/understand-figma/figma-merge.mjs @@ -25,6 +25,7 @@ writeFileSync(join(outDir, "knowledge-graph.json"), JSON.stringify(result.data, writeFileSync(join(outDir, "meta.json"), JSON.stringify({ lastAnalyzedAt: new Date().toISOString(), gitCommitHash: "", + figmaVersion: manifest.figmaVersion ?? "", version: "1.0.0", analyzedFiles: result.data.nodes.length, }, null, 2)); diff --git a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs index 54fb3d960..b453f245b 100644 --- a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs +++ b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { writeFileSync, mkdirSync } from "node:fs"; +import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { parseFileKey, FigmaApiSource, parseDocument, extractTokens } from "@understand-anything/core/figma"; @@ -12,6 +12,21 @@ if (!projectRoot || !urlOrKey) { const fileKey = parseFileKey(urlOrKey); const source = new FigmaApiSource(fileKey); // reads FIGMA_TOKEN from env; throws a friendly error if missing const doc = await source.fetchDocument(); + +const metaPath = join(projectRoot, ".understand-anything", "meta.json"); +let prevVersion = null; +if (existsSync(metaPath)) { + try { + prevVersion = JSON.parse(readFileSync(metaPath, "utf8")).figmaVersion ?? null; + } catch { + prevVersion = null; // malformed/partial meta.json → fall through to a full rebuild + } +} +if (doc.version && prevVersion === doc.version && process.env.UNDERSTAND_FIGMA_FORCE !== "1") { + console.error("UP_TO_DATE"); + process.exit(0); +} + const styles = await source.fetchStyles().catch(() => ({ meta: { styles: [] } })); const structural = parseDocument(doc, fileKey); @@ -42,6 +57,7 @@ const manifest = { gitCommitHash: "", }, fileKey, + figmaVersion: doc.version ?? "", nodes, edges, }; From 8d83812404e041d4f653653365b77c33bed7b160 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 24 Jun 2026 20:38:30 +0900 Subject: [PATCH 20/23] fix(dashboard): render design node types in the drill-in canvas + guard test --- .../dashboard/src/components/GraphView.tsx | 33 +++++-- .../__tests__/structuralVisibleTypes.test.ts | 91 +++++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 understand-anything-plugin/packages/dashboard/src/components/__tests__/structuralVisibleTypes.test.ts diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index c697ca003..ef2ffe878 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -80,6 +80,31 @@ const NODE_TYPE_TO_CATEGORY: Record = { page: "code", screen: "code", component: "code", componentSet: "code", instance: "code", token: "code", } as const; +/** + * Node types rendered in the structural drill-in (layer-detail) canvas. + * + * Mirrors every NON-knowledge core NodeType: 5 code + 8 non-code + 3 domain + * + 6 design (Figma graphs reuse this structural view in v1 — there is no + * separate design view). Knowledge types (article/entity/topic/claim/source) + * are intentionally excluded; knowledge graphs render in KnowledgeGraphView. + * + * Hoisted to module scope and exported so the guard test can assert design + * node types stay visible on drill-in (see + * __tests__/structuralVisibleTypes.test.ts). If a node type listed here is + * removed — or a new design NodeType is added to core without being added + * here — that test fails. + */ +export const STRUCTURAL_VISIBLE_TYPES = new Set([ + // code + "file", "function", "class", "module", "concept", + // non-code + "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource", + // domain + "domain", "flow", "step", + // design (Figma) + "page", "screen", "component", "componentSet", "instance", "token", +]); + // ── Helper components that must live inside ──────────────── /** @@ -428,13 +453,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { } const subFileTypes = new Set(["function", "class"]); - const allVisibleTypes = new Set([ - "file", "module", "concept", - "config", "document", "service", "table", - "endpoint", "pipeline", "schema", "resource", - "domain", "flow", "step", - "function", "class", - ]); + const allVisibleTypes = STRUCTURAL_VISIBLE_TYPES; let filteredGraphNodes = graph.nodes.filter((n) => { if (!expandedLayerNodeIds.has(n.id)) return false; diff --git a/understand-anything-plugin/packages/dashboard/src/components/__tests__/structuralVisibleTypes.test.ts b/understand-anything-plugin/packages/dashboard/src/components/__tests__/structuralVisibleTypes.test.ts new file mode 100644 index 000000000..766b5d52c --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/__tests__/structuralVisibleTypes.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { STRUCTURAL_VISIBLE_TYPES } from "../GraphView"; +import type { NodeType } from "@understand-anything/core/types"; + +/** + * Guard for the drill-in (layer-detail) canvas node-type filter. + * + * `kind:"design"` (Figma) knowledge graphs reuse the structural GraphView in + * v1 — there is no separate design view. The drill-in filter only renders node + * types listed in STRUCTURAL_VISIBLE_TYPES, so every non-knowledge core + * NodeType (including the 6 design types) must be present or design nodes + * vanish the moment you click into a layer. + */ + +/** + * Knowledge node types render in the dedicated KnowledgeGraphView, NOT the + * structural drill-in. They are the ONLY core NodeTypes intentionally excluded + * from STRUCTURAL_VISIBLE_TYPES. + */ +const KNOWLEDGE_TYPES = ["article", "entity", "topic", "claim", "source"] as const; +type KnowledgeType = (typeof KNOWLEDGE_TYPES)[number]; + +/** + * Exhaustive, compile-time-checked mirror of every NON-knowledge core + * NodeType. The `satisfies Record, true>` + * constraint is the regression guard: + * + * • Add a new design (or any non-knowledge) NodeType to core → this object is + * missing a key → `tsc -b` (the dashboard `build`) fails until it's listed. + * • The runtime loop below then fails until that type is ALSO added to + * STRUCTURAL_VISIBLE_TYPES. + * + * Together they make it impossible to add a design NodeType without keeping it + * visible on drill-in. Removing a knowledge type, or adding an extra key here, + * is likewise rejected by `satisfies`. + */ +const EXPECTED_STRUCTURAL_TYPES = { + // code (5) + file: true, function: true, class: true, module: true, concept: true, + // non-code (8) + config: true, document: true, service: true, table: true, endpoint: true, + pipeline: true, schema: true, resource: true, + // domain (3) + domain: true, flow: true, step: true, + // design (6) — Figma graphs reuse the structural view in v1 + page: true, screen: true, component: true, componentSet: true, instance: true, token: true, +} satisfies Record, true>; + +const DESIGN_TYPES = [ + "page", + "screen", + "component", + "componentSet", + "instance", + "token", +] as const; + +describe("STRUCTURAL_VISIBLE_TYPES (drill-in / layer-detail visibility)", () => { + it('includes all 6 design node types so kind:"design" graphs render on drill-in', () => { + for (const t of DESIGN_TYPES) { + expect(STRUCTURAL_VISIBLE_TYPES.has(t)).toBe(true); + } + }); + + it("includes the core structural node types", () => { + for (const t of ["file", "function", "class", "service", "domain", "flow"]) { + expect(STRUCTURAL_VISIBLE_TYPES.has(t)).toBe(true); + } + }); + + it("excludes knowledge node types (they render in KnowledgeGraphView)", () => { + for (const t of KNOWLEDGE_TYPES) { + expect(STRUCTURAL_VISIBLE_TYPES.has(t)).toBe(false); + } + }); + + it("contains every non-knowledge core NodeType (regression guard)", () => { + // EXPECTED_STRUCTURAL_TYPES is compile-time exhaustive over + // Exclude, so a newly added design NodeType that + // someone forgot to list in STRUCTURAL_VISIBLE_TYPES trips this assertion. + for (const t of Object.keys(EXPECTED_STRUCTURAL_TYPES)) { + expect(STRUCTURAL_VISIBLE_TYPES.has(t)).toBe(true); + } + }); + + it("contains no node types beyond the expected non-knowledge set", () => { + expect(STRUCTURAL_VISIBLE_TYPES.size).toBe( + Object.keys(EXPECTED_STRUCTURAL_TYPES).length, + ); + }); +}); From 8762e4225e5ab82fe6792fc5ef907846ee00c2cf Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 8 Jul 2026 13:26:51 +0900 Subject: [PATCH 21/23] fix(dashboard): keep design node types in ALL_NODE_TYPES so JSON export doesn't drop them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExportMenu.exportJSON always runs filterNodes() with filters.nodeTypes, which is initialised from ALL_NODE_TYPES. The 6 design NodeTypes (page/screen/component/componentSet/instance/token) were missing from the dashboard's local NodeType union and ALL_NODE_TYPES, so exporting a freshly loaded kind:"design" (Figma) graph with no user filters stripped every design node and its edges from the JSON. Add the design types, plus a guard test that is compile-time exhaustive over the core NodeType union (satisfies Record) and asserts at runtime that ALL_NODE_TYPES covers them — so a future core NodeType can't be added without keeping it exportable. Addresses Codex review (P2) on PR #516. --- .../src/__tests__/allNodeTypes.test.ts | 56 +++++++++++++++++++ .../packages/dashboard/src/store.ts | 4 +- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 understand-anything-plugin/packages/dashboard/src/__tests__/allNodeTypes.test.ts diff --git a/understand-anything-plugin/packages/dashboard/src/__tests__/allNodeTypes.test.ts b/understand-anything-plugin/packages/dashboard/src/__tests__/allNodeTypes.test.ts new file mode 100644 index 000000000..78d4383ca --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/__tests__/allNodeTypes.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { ALL_NODE_TYPES } from "../store"; +import type { NodeType as CoreNodeType } from "@understand-anything/core/types"; + +/** + * Guard for the node-type FILTER / EXPORT path. + * + * `filters.nodeTypes` is initialised from ALL_NODE_TYPES, and + * ExportMenu.exportJSON always runs filterNodes() with that set. Any core + * NodeType missing from ALL_NODE_TYPES is therefore silently stripped from a + * freshly-loaded graph on export — which is exactly what dropped every + * kind:"design" (Figma) node and its edges before this fix (PR #516 review). + * + * EXPECTED_NODE_TYPES is compile-time exhaustive over the core NodeType union + * via `satisfies Record`: + * • Add a NodeType to core → this object is missing a key → the dashboard + * `build` (tsc -b) fails until it is listed here. + * • The runtime checks below then fail until it is ALSO added to + * ALL_NODE_TYPES. + * Together they make it impossible to add a core NodeType that export would + * silently drop. + */ +const EXPECTED_NODE_TYPES = { + // code (5) + file: true, function: true, class: true, module: true, concept: true, + // non-code (8) + config: true, document: true, service: true, table: true, endpoint: true, + pipeline: true, schema: true, resource: true, + // domain (3) + domain: true, flow: true, step: true, + // knowledge (5) + article: true, entity: true, topic: true, claim: true, source: true, + // design (6) — Figma graphs must remain exportable by default + page: true, screen: true, component: true, componentSet: true, instance: true, token: true, +} satisfies Record; + +const DESIGN_TYPES = ["page", "screen", "component", "componentSet", "instance", "token"] as const; + +describe("ALL_NODE_TYPES (filter / export default set)", () => { + it('includes all 6 design node types so kind:"design" graphs survive JSON export', () => { + for (const t of DESIGN_TYPES) { + expect(ALL_NODE_TYPES).toContain(t); + } + }); + + it("contains every core NodeType (regression guard)", () => { + for (const t of Object.keys(EXPECTED_NODE_TYPES)) { + expect(ALL_NODE_TYPES).toContain(t); + } + }); + + it("has no duplicates and no types beyond the core set", () => { + expect(new Set(ALL_NODE_TYPES).size).toBe(ALL_NODE_TYPES.length); + expect(ALL_NODE_TYPES.length).toBe(Object.keys(EXPECTED_NODE_TYPES).length); + }); +}); diff --git a/understand-anything-plugin/packages/dashboard/src/store.ts b/understand-anything-plugin/packages/dashboard/src/store.ts index b3b2a96c7..1214eed37 100644 --- a/understand-anything-plugin/packages/dashboard/src/store.ts +++ b/understand-anything-plugin/packages/dashboard/src/store.ts @@ -11,7 +11,7 @@ import type { ReactFlowInstance } from "@xyflow/react"; export type Persona = "non-technical" | "junior" | "experienced"; export type NavigationLevel = "overview" | "layer-detail"; -export type NodeType = "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource" | "domain" | "flow" | "step" | "article" | "entity" | "topic" | "claim" | "source"; +export type NodeType = "file" | "function" | "class" | "module" | "concept" | "config" | "document" | "service" | "table" | "endpoint" | "pipeline" | "schema" | "resource" | "domain" | "flow" | "step" | "article" | "entity" | "topic" | "claim" | "source" | "page" | "screen" | "component" | "componentSet" | "instance" | "token"; export type Complexity = "simple" | "moderate" | "complex"; export type EdgeCategory = "structural" | "behavioral" | "data-flow" | "dependencies" | "semantic" | "infrastructure" | "domain" | "knowledge"; export type ViewMode = "structural" | "domain" | "knowledge"; @@ -24,7 +24,7 @@ export interface FilterState { edgeCategories: Set; } -export const ALL_NODE_TYPES: NodeType[] = ["file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource", "domain", "flow", "step", "article", "entity", "topic", "claim", "source"]; +export const ALL_NODE_TYPES: NodeType[] = ["file", "function", "class", "module", "concept", "config", "document", "service", "table", "endpoint", "pipeline", "schema", "resource", "domain", "flow", "step", "article", "entity", "topic", "claim", "source", "page", "screen", "component", "componentSet", "instance", "token"]; export const ALL_COMPLEXITIES: Complexity[] = ["simple", "moderate", "complex"]; export const ALL_EDGE_CATEGORIES: EdgeCategory[] = ["structural", "behavioral", "data-flow", "dependencies", "semantic", "infrastructure", "domain", "knowledge"]; From 07c36f6d68c9fd7a5451852f8dce22b27e389227 Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 8 Jul 2026 13:28:46 +0900 Subject: [PATCH 22/23] fix(figma): carry token usage to the nearest structural ancestor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractTokens only emitted uses_token when the node that directly owns `styles` was itself a shallow structural node. Real screens/components apply fills/text styles on nested TEXT/RECTANGLE leaves, so consumerId was undefined for them and the walker silently dropped the usage — the design-system graph had token nodes but almost no consumer edges. Thread the nearest structural ancestor (screen/component/componentSet/ instance/page) down the walk and attribute a styled leaf's tokens to it. Add a regression test covering a screen whose styling lives on a nested TEXT layer. Addresses Codex review (P2) on PR #516. --- .../core/src/figma/__tests__/tokens.test.ts | 31 +++++++++++++++++++ .../packages/core/src/figma/parse/tokens.ts | 13 +++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts b/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts index 2c8fcf9fd..37835264b 100644 --- a/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts +++ b/understand-anything-plugin/packages/core/src/figma/__tests__/tokens.test.ts @@ -33,3 +33,34 @@ describe("extractTokens", () => { ])); }); }); + +describe("extractTokens — nested styled layers", () => { + // The common real-world case: a screen's visible styling lives on a nested + // TEXT/RECTANGLE leaf, so the style reference is on 11:0 — NOT on the screen + // frame (10:0) that is the structural node. + const nestedDoc: FigmaDocument = { + name: "MyApp", + document: { + id: "0:0", name: "Document", type: "DOCUMENT", children: [ + { id: "1:0", name: "Home", type: "CANVAS", children: [ + { id: "10:0", name: "Home Screen", type: "FRAME", children: [ + { id: "11:0", name: "Title", type: "TEXT", styles: { text: "T_KEY" }, children: [] }, + ] }, + ] }, + ], + }, + }; + const nestedStyles: FigmaStyles = { meta: { styles: [{ key: "T_KEY", name: "type/heading", style_type: "TEXT" }] } }; + const nestedStructural: GraphNode[] = [ + { id: "screen:10:0", type: "screen", name: "Home Screen", summary: "Home Screen", tags: ["screen"], complexity: "simple", figmaMeta: { fileKey: "ABC", nodeId: "10:0" } }, + ]; + + it("attributes nested-layer token usage to the nearest structural ancestor (screen)", () => { + const { nodes, edges } = extractTokens(nestedDoc, nestedStyles, nestedStructural, "ABC"); + const token = nodes.find((n) => n.type === "token"); + expect(token).toBeTruthy(); + expect(edges).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: "screen:10:0", target: token!.id, type: "uses_token" }), + ])); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts b/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts index 31d42539a..0f38c09f6 100644 --- a/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts +++ b/understand-anything-plugin/packages/core/src/figma/parse/tokens.ts @@ -39,8 +39,13 @@ export function extractTokens( } const usesSeen = new Set(); - function walk(n: FigmaNode) { - const consumerId = graphIdByFigmaId.get(n.id); + function walk(n: FigmaNode, nearestConsumerId: string | undefined) { + // Styles (fills/text/effect/grid) are usually applied to nested leaf + // layers (TEXT/RECTANGLE/…), not to the shallow structural node itself. + // Attribute a styled node's token usage to the nearest structural + // ancestor (screen/component/componentSet/instance/page) so real consumer + // relationships aren't dropped when the styled layer isn't itself a node. + const consumerId = graphIdByFigmaId.get(n.id) ?? nearestConsumerId; if (consumerId && n.styles) { for (const styleKey of Object.values(n.styles)) { const tokenId = tokenByStyleKey.get(styleKey); @@ -53,9 +58,9 @@ export function extractTokens( } } } - for (const c of n.children ?? []) walk(c); + for (const c of n.children ?? []) walk(c, consumerId); } - walk(doc.document); + walk(doc.document, undefined); return { nodes, edges }; } From c91a0c1bd59e6081522f04acfbb727a934dc8a1a Mon Sep 17 00:00:00 2001 From: gruming Date: Wed, 8 Jul 2026 13:32:55 +0900 Subject: [PATCH 23/23] fix(figma): refresh screen thumbnails on the UP_TO_DATE path Screen thumbnail URLs are pre-signed and expire after a few hours. The incremental guard exited on UP_TO_DATE *before* rendering images, so a re-run with no design edits left the dashboard sidebar showing expired/broken thumbnails unless the user knew to set UNDERSTAND_FIGMA_FORCE=1. Re-render screen thumbnails and patch knowledge-graph.json in place on the UP_TO_DATE path (best-effort; never fails the run), then still skip the expensive re-parse + LLM re-analysis. Extract the shared patching logic into core applyScreenThumbnails() (used by both scan paths) with unit tests. Addresses Codex review (P2) on PR #516. --- .../src/figma/__tests__/thumbnails.test.ts | 35 ++++++++++++++++++ .../packages/core/src/figma/index.ts | 1 + .../packages/core/src/figma/thumbnails.ts | 29 +++++++++++++++ .../skills/understand-figma/figma-scan.mjs | 37 ++++++++++++++++--- 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 understand-anything-plugin/packages/core/src/figma/__tests__/thumbnails.test.ts create mode 100644 understand-anything-plugin/packages/core/src/figma/thumbnails.ts diff --git a/understand-anything-plugin/packages/core/src/figma/__tests__/thumbnails.test.ts b/understand-anything-plugin/packages/core/src/figma/__tests__/thumbnails.test.ts new file mode 100644 index 000000000..66c2bd8c8 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/__tests__/thumbnails.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { applyScreenThumbnails } from "../thumbnails"; +import type { GraphNode } from "../../types"; + +function node(id: string, type: GraphNode["type"], nodeId: string): GraphNode { + return { + id, type, name: id, summary: id, tags: [type], complexity: "simple", + figmaMeta: { fileKey: "ABC", nodeId }, + }; +} + +describe("applyScreenThumbnails", () => { + it("sets thumbnailUrl only on screen nodes present in the images map", () => { + const nodes: GraphNode[] = [ + node("screen:10:0", "screen", "10:0"), + node("component:2:1", "component", "2:1"), // non-screen → ignored even if in map + node("screen:11:0", "screen", "11:0"), // screen but not in map → untouched + ]; + const updated = applyScreenThumbnails(nodes, { + "10:0": "https://figma/a.png", + "2:1": "https://figma/c.png", + }); + expect(updated).toBe(1); + expect(nodes[0].figmaMeta?.thumbnailUrl).toBe("https://figma/a.png"); + expect(nodes[1].figmaMeta?.thumbnailUrl).toBeUndefined(); + expect(nodes[2].figmaMeta?.thumbnailUrl).toBeUndefined(); + }); + + it("returns 0 and mutates nothing when no screens match", () => { + const nodes: GraphNode[] = [node("screen:9:9", "screen", "9:9")]; + const updated = applyScreenThumbnails(nodes, {}); + expect(updated).toBe(0); + expect(nodes[0].figmaMeta?.thumbnailUrl).toBeUndefined(); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/figma/index.ts b/understand-anything-plugin/packages/core/src/figma/index.ts index 042adaa24..ed3987952 100644 --- a/understand-anything-plugin/packages/core/src/figma/index.ts +++ b/understand-anything-plugin/packages/core/src/figma/index.ts @@ -2,4 +2,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 { applyScreenThumbnails } from "./thumbnails.js"; export { mergeDesignGraph, type DesignAnalysis } from "./merge.js"; diff --git a/understand-anything-plugin/packages/core/src/figma/thumbnails.ts b/understand-anything-plugin/packages/core/src/figma/thumbnails.ts new file mode 100644 index 000000000..00f93ce35 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/figma/thumbnails.ts @@ -0,0 +1,29 @@ +import type { GraphNode } from "../types.js"; + +/** + * Set `figmaMeta.thumbnailUrl` on screen nodes from a Figma image-render map + * (Figma nodeId → pre-signed image URL). Mutates matching nodes in place and + * returns how many were updated. + * + * Shared by figma-scan.mjs: the normal scan sets thumbnails on freshly parsed + * nodes, and the UP_TO_DATE path re-renders and refreshes the existing graph's + * thumbnails — the URLs are pre-signed and expire after a few hours, so a + * re-run must refresh them or the dashboard shows broken sidebar previews. + */ +export function applyScreenThumbnails( + nodes: GraphNode[], + images: Record, +): number { + let updated = 0; + for (const n of nodes) { + if (n.type !== "screen") continue; + const figmaId = n.figmaMeta?.nodeId; + if (!figmaId || !n.figmaMeta) continue; + const url = images[figmaId]; + if (url) { + n.figmaMeta.thumbnailUrl = url; + updated++; + } + } + return updated; +} diff --git a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs index b453f245b..ea95a65fb 100644 --- a/understand-anything-plugin/skills/understand-figma/figma-scan.mjs +++ b/understand-anything-plugin/skills/understand-figma/figma-scan.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; -import { parseFileKey, FigmaApiSource, parseDocument, extractTokens } from "@understand-anything/core/figma"; +import { parseFileKey, FigmaApiSource, parseDocument, extractTokens, applyScreenThumbnails } from "@understand-anything/core/figma"; const [, , projectRoot, urlOrKey] = process.argv; if (!projectRoot || !urlOrKey) { @@ -23,6 +23,11 @@ if (existsSync(metaPath)) { } } if (doc.version && prevVersion === doc.version && process.env.UNDERSTAND_FIGMA_FORCE !== "1") { + // Content is unchanged, but the stored screen thumbnail URLs are pre-signed + // and expire after a few hours. Refresh them in the existing graph so a later + // re-run doesn't leave the dashboard with broken sidebar thumbnails, then + // skip the expensive re-parse + LLM re-analysis. + await refreshThumbnailsInPlace(projectRoot, source); console.error("UP_TO_DATE"); process.exit(0); } @@ -39,10 +44,7 @@ const edges = [...structural.edges, ...tokens.edges]; const screens = structural.nodes.filter((n) => n.type === "screen"); try { const images = await source.renderImages(screens.map((n) => n.figmaMeta.nodeId)); - for (const s of screens) { - const url = images[s.figmaMeta.nodeId]; - if (url) s.figmaMeta.thumbnailUrl = url; - } + applyScreenThumbnails(structural.nodes, images); } catch { // thumbnails are optional — never fail the scan on image render } @@ -72,3 +74,28 @@ console.error( `${count("component")} components, ${count("componentSet")} sets, ` + `${count("instance")} instances, ${count("token")} tokens`, ); + +/** + * On the incremental UP_TO_DATE path we skip the full re-scan, but the screen + * thumbnail URLs already stored in knowledge-graph.json are pre-signed and + * expire after a few hours. Re-render them and patch the existing graph in + * place so the dashboard sidebar doesn't show broken images on a later re-run. + * Best-effort: never throw (thumbnails are optional). + */ +async function refreshThumbnailsInPlace(projectRoot, source) { + const graphPath = join(projectRoot, ".understand-anything", "knowledge-graph.json"); + if (!existsSync(graphPath)) return; + try { + const graph = JSON.parse(readFileSync(graphPath, "utf8")); + const screens = (graph.nodes ?? []).filter( + (n) => n.type === "screen" && n.figmaMeta?.nodeId, + ); + if (screens.length === 0) return; + const images = await source.renderImages(screens.map((n) => n.figmaMeta.nodeId)); + if (applyScreenThumbnails(graph.nodes, images) > 0) { + writeFileSync(graphPath, JSON.stringify(graph, null, 2)); + } + } catch { + // thumbnails are optional — never fail the up-to-date path on refresh + } +}