-
Notifications
You must be signed in to change notification settings - Fork 11
feat: create Figma Token Review Plugin #625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 20 commits
afa1c46
77e9bc3
8520483
fe4a82b
230b289
9082df0
273f58f
0ef7037
b650273
b705240
4463849
4eae349
adead8f
5cd0c8e
9bab073
4a1488a
8d7704a
2337136
c90ee7a
7423fc9
b2e2576
961ed27
f2ed7a1
3908d77
1d2d579
42c3249
61b8780
1fd7edd
34f81f7
f7ddc9e
01fc728
11eb801
c9a8bff
ec8557f
cdc8699
7d11260
de1bc58
61e5aeb
1144f88
e0b533d
fd9df7b
cfd4f34
be79382
89e23cb
3861c7f
1418e0f
db77565
0b0ffd6
5584acf
56cff5e
f26c16e
240934e
305c082
d1f2041
094416a
d10e0e6
3f206e7
7a2528e
9b5fd4d
68ffdce
41830c8
e2dab7d
a217bf5
76c687c
e9b928e
7b0dbfc
245e9fc
cb20f2a
ad6af34
71e190d
4f482b8
2038054
5dc8c8b
e053aa4
06ed463
ea96c5b
34f0abc
aedbceb
59d5104
1546199
631007e
268af30
ab82215
509354f
9cd7c11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,7 @@ CLAUDE.local.md | |
| handoff.md | ||
| docs/research/ | ||
| docs/plans/ | ||
| docs/superpowers | ||
|
|
||
| # Worktrees | ||
| .worktrees | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # LiteLLM proxy endpoint (Anthropic-compatible /v1/messages). | ||
| VITE_LITELLM_BASE_URL=https://litellm.internal.goorm.io | ||
|
|
||
| # LiteLLM API key. | ||
| VITE_LITELLM_API_KEY= | ||
|
|
||
| # Optional override; default is claude-sonnet-4-6. | ||
| VITE_LITELLM_MODEL=claude-sonnet-4-6 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| .env.local | ||
| .env.*.local | ||
| dist/ | ||
| node_modules/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| # Figma Token Review Plugin — 모듈 구조 | ||
|
|
||
| ``` | ||
| src/ | ||
| ├── common/ # 두 사이드가 공유하는 wire 계약·도메인 스키마 | ||
| ├── plugin/ # Figma 샌드박스 (figma API 접근, DOM 없음) | ||
| └── ui/ # React iframe UI (feature-folder 기반) | ||
| ``` | ||
|
|
||
| - `plugin/`: 1단(결정론) RawExtract 추출. LLM 호출 없음. | ||
| - `ui/features/llm/`: 2단 LLM 판정. RawExtract → ScanPayload. | ||
| - 두 사이드는 `figma.ui.postMessage` / `window.message`로만 통신. 모든 wire 형식은 `common/`이 정의. | ||
|
|
||
| --- | ||
|
|
||
| ## `src/common/` — 공유 계약 | ||
|
|
||
| - **`schemas.ts`** — 도메인 타입 (`Violation`, `EvaluateOutput`, `ScanPayload`, `SelectionState`, `RawExtract`). | ||
| - **`messages.ts`** — wire envelope (`UiEnvelope`, `CodeEnvelope`), `RequestId`, `postToCode`, `newRequestId`. request/response 상관 인프라(핸들러가 응답에 동일 id를 echo → stale 응답 차단). | ||
|
|
||
| --- | ||
|
|
||
| ## `src/plugin/` — Figma 샌드박스 | ||
|
|
||
| `figma.*` API만 접근. DOM/window 없음. Vite가 `code.ts`를 entry로 단일 번들 생성. | ||
|
|
||
| - **`code.ts`** _(entry)_ — UI 표시 + 크기 복원 + 핸들러 init + 메시지 라우터 시작. | ||
| - **`messages.ts`** — `postToUi` 래퍼 + 핸들러 등록(`on(type, handler)`) + `figma.ui.onmessage` 부착. 응답에 requestId echo. | ||
| - **`handlers/`** — 메시지 종류별 핸들러. | ||
|
|
||
| | 파일 | 책임 | | ||
| | -------------- | ---------------------------------------------------------------------- | | ||
| | `selection.ts` | 초기 emit + `selectionchange` 구독 + `request-selection` 응답 | | ||
| | `scan.ts` | in-flight scan cancel, `extract.ts` 호출, 결과 echo | | ||
| | `extract.ts` | 프레임 추출 (`extractFrame`) | | ||
| | `focus.ts` | 노드 id 리스트 resolve, viewport 이동, missing 카운트 응답 | | ||
| | `resize.ts` | `figma.ui.resize` 적용. `commit===true`일 때만 `clientStorage` persist | | ||
|
|
||
| 새 메시지 추가 = `handlers/<name>.ts` 1개 + `code.ts`에 init 한 줄. | ||
|
|
||
| --- | ||
|
|
||
| ## `src/ui/` — React iframe UI | ||
|
|
||
| `createRoot` 단일 진입점. React 19, Vite single-file 번들. 폴더 분류는 **layer가 아니라 feature** 단위. | ||
|
|
||
| ``` | ||
| ui/ | ||
| ├── App.tsx providers.tsx main.tsx index.css | ||
| ├── assets/ # svg 등 정적 리소스 | ||
| ├── shared/ # cross-feature primitive (store factory 등) | ||
| ├── features/ # 기능 단위 모듈 | ||
| │ ├── messaging/ # plugin ↔ UI 와이어 통신 전담 | ||
| │ ├── llm/ # LLM 호출 (2단 평가) | ||
| │ ├── scan/ # 스캔 상태 머신 + 훅 | ||
| │ └── selection/ # 샌드박스 selection 상태 + 훅 | ||
| ├── components/ # 순수 재사용 UI | ||
| │ └── violation-card/ | ||
| └── pages/ # 상태별 화면 | ||
| ``` | ||
|
|
||
| 새 기능 추가 = `features/<name>/` 폴더 하나만 늘어남. `src/ui/` 루트는 비대해지지 않음. | ||
|
|
||
| ### 루트 | ||
|
|
||
| - **`main.tsx`** _(entry)_ — `<StrictMode> → <ErrorBoundary> → <Providers> → <App>`. Render 트리만 조립. | ||
| - **`App.tsx`** — scan 상태 머신 switch 라우터. props drill 없음, store에서 직접 읽음. | ||
| - **`providers.tsx`** — `Toast.Provider` 래퍼 + `useBridge` + `useRequestSelection` 마운트. | ||
|
|
||
| ### `shared/` | ||
|
|
||
| | 파일 | 책임 | | ||
| | ----------------- | ----------------------------------------------------------------------- | | ||
| | `create-store.ts` | 제네릭 store 팩토리 + `useStore` 훅. `Object.is` 동일 참조 시 알림 skip | | ||
|
|
||
| ### `features/messaging/` — 와이어 통신 | ||
|
|
||
| | 파일 | 책임 | | ||
| | ---------------------- | -------------------------------------------------------------------------------------------------------------------- | | ||
| | `bridge.ts` | `window.message` 단일 listener + `handle(msg)` 라우터. requestId mismatch drop. `useBridge` 훅 export | | ||
| | `evaluate.ts` | `extract-result` 메시지 받아 `runLlmEvaluation` 호출 후 scan store에 반영. AbortController로 in-flight evaluate 취소 | | ||
| | `focus.ts` | `requestFocus(nodeIds)` + `isActiveFocus()`. stale focus 토스트 차단 | | ||
| | `request-selection.ts` | 초기 selection 요청 effect | | ||
| | `index.ts` | barrel | | ||
|
|
||
| ### `features/llm/` — LLM 평가 (2단) | ||
|
|
||
| | 파일 | 책임 | | ||
| | ----------- | ----------------------------------------------------------------------------------- | | ||
| | `client.ts` | LiteLLM HTTP 호출. 60s timeout, 429 1회 재시도. `LlmHttpError`/`LlmTimeoutError` | | ||
| | `parse.ts` | Anthropic content blocks → `ScanPayload` JSON parse + schema guard. `LlmParseError` | | ||
| | `prompt.ts` | `SYSTEM_PROMPT` + `buildRequest()` | | ||
| | `index.ts` | `runLlmEvaluation()` 공개 + env/model 해석 | | ||
|
|
||
| 외부에서는 `runLlmEvaluation(extract, opts)` 한 함수만 사용. UI는 `messaging/evaluate.ts`만 의존. | ||
|
|
||
| ### `features/scan/` — 스캔 상태 머신 | ||
|
|
||
| | 파일 | 책임 | | ||
| | ------------- | ---------------------------------------------------------------------------------------------------- | | ||
| | `store.ts` | `idle/loading/clean/success` 머신 + `scanActions`. loading에 requestId 포함, action 단에서 race 가드 | | ||
| | `use-scan.ts` | scan state + `start(frameId, name)` (requestId 생성 + dispatch + postToCode) + `reset` | | ||
| | `index.ts` | barrel | | ||
|
|
||
| ### `features/selection/` — 셀렉션 상태 | ||
|
|
||
| | 파일 | 책임 | | ||
| | ------------------ | --------------------------- | | ||
| | `store.ts` | 샌드박스 selection 슬라이스 | | ||
| | `use-selection.ts` | selection store 구독 | | ||
| | `index.ts` | barrel | | ||
|
|
||
| ### `pages/` | ||
|
|
||
| | 파일 | 책임 | | ||
| | ----------------- | ---------------------------------------------------------------------------------- | | ||
| | `home.tsx` | idle 화면. selection 종류에 따라 토스트/스캔 트리거 | | ||
| | `scan-result.tsx` | success 화면. Tabs(color/typography) + Violation 섹션. counts/sort/split `useMemo` | | ||
| | `success.tsx` | clean 화면 (위반 0) | | ||
|
|
||
| ### `components/` | ||
|
|
||
| | 파일 | 책임 | | ||
| | -------------------- | --------------------------------------------------------------------------------- | | ||
| | `error-boundary.tsx` | 렌더 에러 catch + fallback UI + reset 버튼 | | ||
| | `resize-handler.tsx` | rAF throttle drag. pointermove → 큐 → next frame flush, pointerup만 `commit:true` | | ||
| | `toast.tsx` | `toastManager` 싱글톤 + ToastProvider | | ||
| | `hero-panel.tsx` | home/success 공통 패널 슬롯 | | ||
| | `loader.tsx` | loading 스피너 | | ||
|
|
||
| #### `components/violation-card/` | ||
|
|
||
| | 파일 | 책임 | | ||
| | -------------------------- | -------------------------------------------- | | ||
| | `violation-card.tsx` | 카드 컨테이너. 클릭 시 `requestFocus(nodes)` | | ||
| | `violation-breadcrumb.tsx` | 카드 상단 경로 표시 | | ||
| | `token-comparison.tsx` | used → suggested 토큰 chip + color swatch | | ||
| | `violation-detail.tsx` | detail 텍스트 + AI 추천 뱃지 | | ||
| | `utils.ts` | `isHexColor` 등 헬퍼 | | ||
| | `index.ts` | barrel | | ||
|
|
||
| --- | ||
|
|
||
| ## 데이터 흐름 | ||
|
|
||
| ``` | ||
| [사용자 액션] | ||
| └─> features/scan.use-scan.start ─postToCode─> plugin/messages ─dispatch─> handlers/scan | ||
| └─ figma API | ||
| └─ postToUi(requestId) | ||
| <── window.message ── features/messaging/bridge (단일 listener) | ||
| └─ handle() ─ scan/selection store action (requestId 매칭 후 set) | ||
| └─ useSyncExternalStore notify | ||
| └─ React re-render | ||
| ``` | ||
|
|
||
| 특별 경로 | ||
|
|
||
| - **extract-result → LLM 평가**: `features/messaging/bridge.handle` → `evaluate.evaluateExtract` → `features/llm.runLlmEvaluation` → `scanActions.result`. | ||
| - **selection 변화**: plugin `figma.on('selectionchange')` → `handlers/selection` emit → `selectionStore` set → 모든 구독 컴포넌트. | ||
| - **focus 토스트**: bridge가 `isActiveFocus()`로 stale 차단. | ||
| - **resize**: UI rAF 큐 → pointermove 시 `commit` 없음 → sandbox apply only / pointerup commit → persist. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| // @ts-check | ||
| import { configs as reactPackage } from '@repo/eslint-config/react-package'; | ||
|
|
||
| export default [ | ||
| ...reactPackage, | ||
| { | ||
| ignores: ['dist/**/*'], | ||
| }, | ||
| ]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| <!doctype html> | ||
| <html lang="ko"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Vapor Token Review</title> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="/src/ui/main.tsx"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "name": "Vapor Token Review", | ||
| "id": "1542812611331843954", | ||
| "api": "1.0.0", | ||
| "main": "code.js", | ||
| "ui": "index.html", | ||
| "capabilities": [], | ||
| "enableProposedApi": false, | ||
| "documentAccess": "dynamic-page", | ||
| "editorType": ["figma"], | ||
| "networkAccess": { | ||
| "allowedDomains": ["https://litellm.internal.goorm.io"] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| { | ||
| "name": "figma-token-review-plugin", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "rimraf dist && tsc -b && vite build --config vite.config.ui.ts && vite build --config vite.config.plugin.ts && cp manifest.json dist/", | ||
| "build:plugin": "vite build --config vite.config.plugin.ts", | ||
| "build:ui": "vite build --config vite.config.ui.ts", | ||
| "clean": "rimraf dist node_modules .turbo", | ||
| "dev": "vite build --watch --config vite.config.ui.ts & vite build --watch --config vite.config.plugin.ts", | ||
| "lint": "eslint ./src", | ||
| "predev:figma": "mkdir -p dist && cp manifest.json dist/", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest", | ||
| "typecheck": "tsc -b --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@vapor-ui/core": "workspace:*", | ||
| "@vapor-ui/icons": "workspace:*", | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 패키지 전체적인 라이브러리 버전 일치를 위해 catalog로 추가하는 것이 좋을 듯 합니다.! |
||
| }, | ||
| "devDependencies": { | ||
| "@figma/plugin-typings": "^1.125.0", | ||
| "@repo/eslint-config": "workspace:*", | ||
| "@repo/typescript-config": "workspace:*", | ||
| "@tailwindcss/vite": "^4.0.0", | ||
| "@types/node": "^26.1.0", | ||
| "@types/react": "^19.0.0", | ||
| "@types/react-dom": "^19.0.0", | ||
| "@vitejs/plugin-react": "^4.3.0", | ||
| "@vitest/coverage-v8": "^4.1.10", | ||
| "eslint": "catalog:", | ||
| "rimraf": "^6.0.0", | ||
| "tailwindcss": "^4.0.0", | ||
| "typescript": "catalog:", | ||
| "vite": "^6.0.0", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 혹시 vite 6버전으로 하신 이유가 있으실까요? 최신 버전인 8.* 버전 혹은 다른 vite를 사용하는 앱의 버전과 일치시키면 좋을 것 같습니다.! |
||
| "vite-plugin-singlefile": "^2.0.0", | ||
| "vitest": "^4.1.10" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||||||
| import type { LlmContext, RawExtract, SelectionState } from './schemas'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export type CodeMsg = | ||||||||||||||||||||
| | { type: 'selection'; state: SelectionState } | ||||||||||||||||||||
| | { type: 'extract-result'; payload: { extract: RawExtract; llmContext: LlmContext } } | ||||||||||||||||||||
| | { type: 'extract-error'; message: string } | ||||||||||||||||||||
| | { type: 'focus-result'; resolved: number; missing: number } | ||||||||||||||||||||
| | { type: 'focus-error'; message: string }; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export type UiMsg = | ||||||||||||||||||||
| | { type: 'request-selection' } | ||||||||||||||||||||
| | { type: 'scan'; frameId: string } | ||||||||||||||||||||
| | { type: 'focus'; nodeIds: string[] } | ||||||||||||||||||||
| | { type: 'resize'; width: number; height: number; commit?: boolean }; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /* ------------------------------------------------------------------------------------------------- | ||||||||||||||||||||
| * Envelope + RequestId (formerly src/shared/protocol.ts) | ||||||||||||||||||||
| * -----------------------------------------------------------------------------------------------*/ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export type RequestId = string; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export type Envelope<T> = T & { requestId?: RequestId }; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기 타입이 scan, focus와 같이 requestId가 반드시 필요한 메시지에도 requestID가 optional로 들어가고 있는 것 같습니다.
Suggested change
이렇게 msg에서 바로 타입을 명확하게 정의하는 것도 좋아보입니다.! |
||||||||||||||||||||
|
|
||||||||||||||||||||
| export type UiEnvelope = Envelope<UiMsg>; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export type CodeEnvelope = Envelope<CodeMsg>; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export function newRequestId(): RequestId { | ||||||||||||||||||||
| return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /* ------------------------------------------------------------------------------------------------- | ||||||||||||||||||||
| * Post helpers | ||||||||||||||||||||
| * -----------------------------------------------------------------------------------------------*/ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
| * UI → plugin direction. Uses `parent` (DOM iframe global). | ||||||||||||||||||||
| * Tree-shaken from plugin bundle when only types are imported. | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| export function postToCode(msg: UiMsg | UiEnvelope): void { | ||||||||||||||||||||
| parent.postMessage({ pluginMessage: msg }, '*'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
| * Plugin → UI direction. Uses `figma.ui` (plugin sandbox global). | ||||||||||||||||||||
| * Tree-shaken from UI bundle when only types are imported. | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| export function postToUi(msg: CodeEnvelope, requestId?: RequestId): void { | ||||||||||||||||||||
| figma.ui.postMessage(requestId ? { ...msg, requestId } : msg); | ||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기 resize.ts에서 commit에 대한 정보를 넘겨주지 않아서 항상 commit = false. 그리고 초기에도 항상 기본 사이즈로 plugin이 열리고 있어서 여기 내용 수정이 필요해 보입니다.!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아 이건 제거한 부분인데 아키텍처 문서에 반영이 안되어 있었네요! 수정했습니다!