diff --git a/.gitignore b/.gitignore index 3c6a09028..c5ae47f54 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ CLAUDE.local.md handoff.md docs/research/ docs/plans/ +docs/superpowers # Worktrees .worktrees diff --git a/apps/figma-token-review-plugin/.env.example b/apps/figma-token-review-plugin/.env.example new file mode 100644 index 000000000..7c081b4f3 --- /dev/null +++ b/apps/figma-token-review-plugin/.env.example @@ -0,0 +1,6 @@ +# LiteLLM proxy endpoint (Anthropic-compatible /v1/messages). +VITE_LITELLM_BASE_URL= + +# Optional override; default is claude-sonnet-4-6. +VITE_LITELLM_MODEL= +# claude-sonnet-4-7, claude-opus-4-8, claude-haiku-4-5, goorm-gpt-5.5, ... \ No newline at end of file diff --git a/apps/figma-token-review-plugin/ARCHITECTURE.md b/apps/figma-token-review-plugin/ARCHITECTURE.md new file mode 100644 index 000000000..40927f8dc --- /dev/null +++ b/apps/figma-token-review-plugin/ARCHITECTURE.md @@ -0,0 +1,164 @@ +# 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 표시 + controllers init + 메시지 라우터 시작. +- **`messages.ts`** — 핸들러 등록(`on(type, handler)`) + `figma.ui.onmessage` 부착. + +역할별 3레이어. import 방향: `code.ts → controllers → { models, views }`. + +| 레이어 | 책임 | 파일 | +| -------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `controllers/` | 메시지별 흐름 제어 (requestId 경합 가드, 분기). `figma.*`/`postToUi` 직접 호출 금지 | `scan` `focus` `selection` `api-key` `resize` | +| `views/` | UI 통신 + 인터페이스 부수효과 | `ui-port`(postToUi 래퍼) `viewport`(포커스 이동) `window`(리사이즈) | +| `models/` | 도메인 로직 + figma 데이터 접근 | `extract/`(규칙 기반 추출) `selection` `api-key-store` `node-lookup` | + +`models/extract/` 는 규칙 레지스트리 구조: `rules/index.ts` 의 RULES 테이블 선언 + `engine/` 실행 엔진(필터/가드 중앙 판정, 규칙 단위 에러 격리). + +새 메시지 추가 = `controllers/.ts` 1개 + 필요한 model/view 함수 + `code.ts`에 init 한 줄. +새 추출 필드 추가 = `models/extract/rules/` 에 규칙 행 추가. + +--- + +## `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//` 폴더 하나만 늘어남. `src/ui/` 루트는 비대해지지 않음. + +### 루트 + +- **`main.tsx`** _(entry)_ — ``. 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 | +| `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─> controllers/scan + └─ models (figma API) + └─ views/ui-port (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')` → `controllers/selection` emit → `selectionStore` set → 모든 구독 컴포넌트. +- **focus 토스트**: bridge가 `isActiveFocus()`로 stale 차단. +- **resize**: UI rAF 큐 → sandbox apply diff --git a/apps/figma-token-review-plugin/eslint.config.mjs b/apps/figma-token-review-plugin/eslint.config.mjs new file mode 100644 index 000000000..f34492069 --- /dev/null +++ b/apps/figma-token-review-plugin/eslint.config.mjs @@ -0,0 +1,9 @@ +// @ts-check +import { configs as reactPackage } from '@repo/eslint-config/react-package'; + +export default [ + ...reactPackage, + { + ignores: ['dist/**/*'], + }, +]; diff --git a/apps/figma-token-review-plugin/index.html b/apps/figma-token-review-plugin/index.html new file mode 100644 index 000000000..1c77fb786 --- /dev/null +++ b/apps/figma-token-review-plugin/index.html @@ -0,0 +1,12 @@ + + + + + + Vapor Token Review + + +
+ + + diff --git a/apps/figma-token-review-plugin/manifest.json b/apps/figma-token-review-plugin/manifest.json new file mode 100644 index 000000000..7f1163e95 --- /dev/null +++ b/apps/figma-token-review-plugin/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Vapor Token Review", + "id": "1656229079258897968", + "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"] + } +} diff --git a/apps/figma-token-review-plugin/package.json b/apps/figma-token-review-plugin/package.json new file mode 100644 index 000000000..3691790a7 --- /dev/null +++ b/apps/figma-token-review-plugin/package.json @@ -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": "catalog:", + "react-dom": "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": "^3.2.6", + "eslint": "catalog:", + "rimraf": "^6.0.0", + "tailwindcss": "^4.0.0", + "typescript": "catalog:", + "vite": "^7.3.5", + "vite-plugin-singlefile": "^2.0.0", + "vitest": "^3.2.6" + } +} diff --git a/apps/figma-token-review-plugin/src/common/messages.ts b/apps/figma-token-review-plugin/src/common/messages.ts new file mode 100644 index 000000000..7f29cf562 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/messages.ts @@ -0,0 +1,58 @@ +import type { LlmContext, RawExtract, SelectionState } from './schemas'; + +export type ApiKeyState = { hasKey: boolean; key: string | null }; + +export type RequestId = string; + +/* ------------------------------------------------------------------------------------------------- + * Message shapes + * + * `requestId` is declared inline only on variants that need request/response correlation + * (scan/focus and their result/error responses). Fire-and-forget notifications omit it. + * -----------------------------------------------------------------------------------------------*/ + +export type UiMsg = + | { type: 'scan'; requestId: RequestId; frameId: string } + | { type: 'focus'; requestId: RequestId; nodeIds: string[] } + | { type: 'request-selection' } + | { type: 'resize'; width: number; height: number } + | { type: 'api-key:get' } + | { type: 'api-key:set'; value: string } + | { type: 'api-key:clear' } + | { type: 'close' }; + +export type CodeMsg = + | { type: 'selection'; state: SelectionState } + | { + type: 'extract-result'; + requestId: RequestId; + payload: { extract: RawExtract; llmContext: LlmContext }; + } + | { type: 'extract-error'; requestId: RequestId; message: string } + | { type: 'focus-result'; requestId: RequestId; resolved: number; missing: number } + | { type: 'focus-error'; requestId: RequestId; message: string } + | { type: 'api-key:state'; state: ApiKeyState }; + +export function newRequestId(): RequestId { + return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/* ------------------------------------------------------------------------------------------------- + * Post helpers + * -----------------------------------------------------------------------------------------------*/ + +/** + * UI → plugin. Uses `parent` (DOM iframe global). + * Tree-shaken from plugin bundle when only types are imported. + */ +export function postToCode(msg: UiMsg): void { + parent.postMessage({ pluginMessage: msg }, '*'); +} + +/** + * Plugin → UI. Uses `figma.ui` (plugin sandbox global). + * Tree-shaken from UI bundle when only types are imported. + */ +export function postToUi(msg: CodeMsg): void { + figma.ui.postMessage(msg); +} diff --git a/apps/figma-token-review-plugin/src/common/schemas.ts b/apps/figma-token-review-plugin/src/common/schemas.ts new file mode 100644 index 000000000..516721c6b --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/schemas.ts @@ -0,0 +1,247 @@ +/* ------------------------------------------------------------------------------------------------- + * Domain types (formerly src/shared/schema.ts) + * -----------------------------------------------------------------------------------------------*/ + +export type Severity = 'high' | 'info'; +export type Confidence = 'HIGH' | 'MED' | 'LOW'; +export type Origin = 'rule' | 'llm'; + +export type Property = + | 'fill' + | 'fill-on-text' + | 'stroke' + | 'padding' + | 'paddingTop' + | 'paddingRight' + | 'paddingBottom' + | 'paddingLeft' + | 'paddingVertical' + | 'paddingHorizontal' + | 'gap' + | 'width' + | 'height' + | 'borderRadius' + | 'shadow' + | 'textStyle'; + +export type Role = + | 'background' + | 'foreground' + | 'border' + | 'space' + | 'dimension' + | 'borderRadius' + | 'shadow'; + +export type Category = 'color' | 'space' | 'dimension' | 'typography' | 'borderRadius' | 'shadow'; + +export type ViolationType = + | 'token-not-used' + | 'primitive-used' + | 'unknown-token' + | 'do-not-use' + | 'role-mismatch' + | 'fg-grade-mismatch' + | 'fg-grade-ambiguous' + | 'text-contrast-low' + | 'typo-raw' + | 'typo-styled-override' + | 'semantic-misfit' // heuristic: LLM 의미 판정 FAIL (color) + | 'typo-hierarchy' // heuristic: LLM 텍스트 위계 FAIL + | 'typo-role-misfit' // heuristic: LLM 텍스트 역할 부적합 + | 'typo-viewport-misfit'; // heuristic: LLM 텍스트 뷰포트 부적합 + +export type Violation = { + nodeId: string; + nodeIds?: string[]; + count?: number; + name: string; + property: Property; + token: string | null; + value: string | null; + type: ViolationType; + severity: Severity; + origin: Origin; + message: string; + suggested: string[]; + confidence?: Confidence; // only when origin === 'llm' + /** 원본이 💙 DS 컴포넌트였으나 detach된 노드 (재컴포넌트화 포함). 리포트 뱃지 노출용. */ + wasDs?: boolean; +}; + +export type Conformant = { + nodeId: string; + nodeIds?: string[]; + name: string; + property: Property; + token: string; +}; + +export type EvaluateSummary = { + total: number; + conformCount: number; + conformanceRate: number | null; + // severity 축: origin/confidence 무관, severity 만으로 카운트 + highViolations: number; + infoFlags: number; + // 자신도(confidence) 축: LLM 판정만 대상, severity 와 겹침 허용 + heuristicViolations: number; // origin === 'llm' 전체 + lowConfidenceCount: number; // origin === 'llm' && confidence !== 'HIGH' +}; + +export type EvaluateOutput = { + violations: Violation[]; + conformant: Conformant[]; + summary: EvaluateSummary; +}; + +export type ScanPayload = { + color: EvaluateOutput; + space: EvaluateOutput; + dimension: EvaluateOutput; + typography: EvaluateOutput; + borderRadius: EvaluateOutput; + shadow: EvaluateOutput; + schemaMode: SchemaMode; +}; + +export type SelectionState = + | { kind: 'frame'; id: string; name: string } + | { kind: 'none' } + | { kind: 'multi' } + | { kind: 'invalid'; nodeType: string }; + +export type Viewport = 'pc' | 'tablet' | 'mobile'; + +export type SchemaMode = 'light' | 'dark'; + +export type ColorProperty = 'fill' | 'stroke' | 'text'; + +export type TokenStatus = 'ok' | 'raw' | 'unknown'; + +export type BackgroundKind = 'white' | 'other' | 'transparent' | 'ambiguous'; + +export type AppliedStatus = 'styled-clean' | 'styled-override' | 'var-only' | 'raw' | 'mixed'; + +export type ColorBackground = { + kind: BackgroundKind; + hex: string | null; +}; + +/** 모든 Usage 공통: 발생 노드 식별 정보. */ +export type NodeRef = { + nodeId: string; + name: string; + /** 원본이 💙 DS 컴포넌트였으나 detach된 노드에서 온 usage. */ + wasDs?: boolean; +}; + +/** 토큰 해석 결과 공통 묶음. */ +export type TokenInfo = { + token: string | null; + /** Variable Mode 로 semantic 을 래핑한 경우 실제 노드에 바인딩된 outer variable 이름. */ + appliedToken?: string | null; + tokenStatus: TokenStatus; +}; + +export type ColorUsage = NodeRef & + TokenInfo & { + nodeIds?: string[]; + count?: number; + property: ColorProperty; + hex: string | null; + background: ColorBackground | null; + /** TEXT 노드 fill 에 붙는 메타. fontSize/isBold 는 WCAG large-text 판정용, PNG 는 배경이 ambiguous 일 때만 첨부. */ + textShot?: TextShot; + }; + +export type TextShot = { + fontSize: number; + isBold: boolean; + /** 부모(배경) 노드를 텍스트 숨긴 상태에서 exportAsync 로 캡처한 PNG (base64). ambiguous 배경일 때만 존재. */ + imageBase64?: string; + /** PNG 픽셀 좌표계에서 텍스트 노드 bbox. exportAsync scale 이 이미 반영된 값. */ + cropX?: number; + cropY?: number; + cropW?: number; + cropH?: number; +}; + +export type TypographyResolved = { + fontSize: number | null; + lineHeight: unknown; + letterSpacing: unknown; + fontName: unknown; +}; + +export type TypographyUsage = NodeRef & { + nodeIds?: string[]; + count?: number; + characters: string; + textStyle: string | null; + viewport: Viewport; + appliedStatus: AppliedStatus; + overriddenFields: string[]; + resolved: TypographyResolved; +}; + +export type SpaceUsage = NodeRef & + TokenInfo & { + property: + | 'padding' + | 'paddingTop' + | 'paddingRight' + | 'paddingBottom' + | 'paddingLeft' + | 'paddingVertical' + | 'paddingHorizontal' + | 'gap'; + value: string; + }; + +export type DimensionUsage = NodeRef & + TokenInfo & { + property: 'width' | 'height'; + value: string; + }; + +export type RadiusUsage = NodeRef & TokenInfo & { value: string }; + +/** shadow 는 effect-style 단일 조회라 appliedToken 이 없다 — TokenInfo 미상속. */ +export type ShadowUsage = NodeRef & { + token: string | null; + tokenStatus: TokenStatus; + value: string; +}; + +export type RawExtractStats = { + nodeCount: number; + textNodes: number; + visited: number; +}; + +export type RawExtract = { + schemaMode: SchemaMode; + viewport: Viewport; + colors: ColorUsage[]; + typography: TypographyUsage[]; + spaces: SpaceUsage[]; + dimensions: DimensionUsage[]; + radii: RadiusUsage[]; + shadows: ShadowUsage[]; + stats: RawExtractStats; +}; + +export type NodeInfo = { + id: string; + type: string; + name: string; + parentId: string | null; + characters?: string; // TEXT nodes only, 30-char cap + textStyle?: string; // TEXT nodes only, bound style name if any +}; + +export type LlmContext = { + screenshotB64: string; + nodeTree: NodeInfo[]; +}; diff --git a/apps/figma-token-review-plugin/src/common/tokens/MANIFEST.json b/apps/figma-token-review-plugin/src/common/tokens/MANIFEST.json new file mode 100644 index 000000000..229e4ccd5 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/MANIFEST.json @@ -0,0 +1,45 @@ +{ + "generatedAt": "2026-06-30", + "entries": { + "semantic-color.light.json": { + "source": "skills/figma-token-review/assets/semantic-color.light.json", + "sha256": "757c9953a03146a149c1df6547dae8f6eff8cbd61e18f2d27ad80b1a500f1592" + }, + "semantic-color.dark.json": { + "source": "skills/figma-token-review/assets/semantic-color.dark.json", + "sha256": "f07ef140cb44a0b0e2c43a07375c7eb2e04383d12ddb661d86cd322e82edf18d" + }, + "primitive-color.light.json": { + "source": "skills/figma-token-review/assets/primitive-color.light.json", + "sha256": "2eb16b567eebc23a22c5b256061d2b8af22e50756755602d7580f39d5023aca7" + }, + "primitive-color.dark.json": { + "source": "skills/figma-token-review/assets/primitive-color.dark.json", + "sha256": "5b1a69ddce685bff4a6a9ea6c743b95061008c30591a58b45e93f64e6b55323e" + }, + "text-style.json": { + "source": "skills/figma-token-review/assets/text-style.json", + "sha256": "7e786c78f5eb6d4a16a8eea1b0a9c14ec44e983ace2d85400ba9932b809a15c8" + }, + "typography.json": { + "source": "skills/figma-token-review/assets/typography.json", + "sha256": "a178cac296b0210e4484729a2dc2f8eb25d4b7c12e39df6f93bf9b2176aebdbc" + }, + "space.json": { + "source": "skills/token-lint/assets/space.json", + "sha256": "e1ce26b4f673fa432739eb5422ea7d5128f6da343f5fe31e0aad2257b5664343" + }, + "dimension.json": { + "source": "skills/token-lint/assets/dimension.json", + "sha256": "a01fb0a5f85719fd212942eb7f2434949494ca1d539bb8070fa78d3b642d5c71" + }, + "border-radius.json": { + "source": "skills/token-lint/assets/border-radius.json", + "sha256": "90b14e7e52752579ecb03d785b3082c31023ce7409a59011fc07bde04d45e118" + }, + "shadow.json": { + "source": "skills/token-lint/assets/shadow.json", + "sha256": "ca0eba92ede054b3406f1ada250379b351231891313ac3a319392944a2330726" + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/border-radius.json b/apps/figma-token-review-plugin/src/common/tokens/border-radius.json new file mode 100644 index 000000000..626fbb7f7 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/border-radius.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "size": { + "borderRadius": { + "$type": "dimension", + "$description": "Scale for border radius properties such as border-radius. The number is a scale index, not a px value.", + "000": { "$value": { "value": 0, "unit": "px" } }, + "050": { "$value": { "value": 2, "unit": "px" } }, + "100": { "$value": { "value": 4, "unit": "px" } }, + "200": { "$value": { "value": 6, "unit": "px" } }, + "300": { "$value": { "value": 8, "unit": "px" } }, + "400": { "$value": { "value": 12, "unit": "px" } }, + "500": { "$value": { "value": 16, "unit": "px" } }, + "600": { "$value": { "value": 20, "unit": "px" } }, + "700": { "$value": { "value": 24, "unit": "px" } }, + "800": { "$value": { "value": 32, "unit": "px" } }, + "900": { "$value": { "value": 40, "unit": "px" } } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/dimension.json b/apps/figma-token-review-plugin/src/common/tokens/dimension.json new file mode 100644 index 000000000..2603f02fe --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/dimension.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "size": { + "dimension": { + "$type": "dimension", + "$description": "Scale for dimension properties such as width and height. The number is a scale index, not a px value.", + "025": { "$value": { "value": 2, "unit": "px" } }, + "050": { "$value": { "value": 4, "unit": "px" } }, + "075": { "$value": { "value": 6, "unit": "px" } }, + "100": { "$value": { "value": 8, "unit": "px" } }, + "150": { "$value": { "value": 12, "unit": "px" } }, + "175": { "$value": { "value": 14, "unit": "px" } }, + "200": { "$value": { "value": 16, "unit": "px" } }, + "225": { "$value": { "value": 18, "unit": "px" } }, + "250": { "$value": { "value": 20, "unit": "px" } }, + "300": { "$value": { "value": 24, "unit": "px" } }, + "400": { "$value": { "value": 32, "unit": "px" } }, + "500": { "$value": { "value": 40, "unit": "px" } }, + "600": { "$value": { "value": 48, "unit": "px" } }, + "700": { "$value": { "value": 56, "unit": "px" } }, + "800": { "$value": { "value": 64, "unit": "px" } } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/legacy-color.json b/apps/figma-token-review-plugin/src/common/tokens/legacy-color.json new file mode 100644 index 000000000..f8b05572c --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/legacy-color.json @@ -0,0 +1,604 @@ +[ + { + "type": "변경", + "oldName": "primary", + "oldValue": "rgb(42, 114, 229)", + "v1_0_0": "color-background-primary-200", + "section": "Theme" + }, + { + "type": "변경", + "oldName": "secondary", + "oldValue": "rgb(225, 225, 225)", + "v1_0_0": "color-background-secondary-200", + "section": "Theme" + }, + { + "type": "변경", + "oldName": "success", + "oldValue": "rgb(5, 135, 101)", + "v1_0_0": "color-background-success-200", + "section": "Theme" + }, + { + "type": "변경", + "oldName": "warning", + "oldValue": "rgb(211, 71, 1)", + "v1_0_0": "color-background-warning-200", + "section": "Theme" + }, + { + "type": "변경", + "oldName": "danger", + "oldValue": "rgb(218, 57, 68)", + "v1_0_0": "color-background-danger-200", + "section": "Theme" + }, + { + "type": "변경", + "oldName": "hint", + "oldValue": "rgb(93, 93, 93)", + "v1_0_0": "color-background-hint-200", + "section": "Theme" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-background-primary-100", + "section": "Theme" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-background-secondary-100", + "section": "Theme" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-background-success-100", + "section": "Theme" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-background-warning-100", + "section": "Theme" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-background-danger-100", + "section": "Theme" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-background-hint-100", + "section": "Theme" + }, + { + "type": "변경", + "oldName": "text-primary", + "oldValue": "rgb(9, 87, 200)", + "v1_0_0": "color-foreground-primary-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-primary-alternative", + "oldValue": "rgb(0, 67, 179)", + "v1_0_0": "color-foreground-primary-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-secondary", + "oldValue": "rgb(57, 57, 57)", + "v1_0_0": "color-foreground-secondary-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-secondary-alternative", + "oldValue": "rgb(38, 38, 38)", + "v1_0_0": "color-foreground-secondary-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-success", + "oldValue": "rgb(0, 108, 75)", + "v1_0_0": "color-foreground-success-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-success-alternative", + "oldValue": "rgb(0, 88, 58)", + "v1_0_0": "color-foreground-success-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-warning", + "oldValue": "rgb(183, 33, 0)", + "v1_0_0": "color-foreground-warning-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-warning-alternative", + "oldValue": "rgb(158, 0, 0)", + "v1_0_0": "color-foreground-warning-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-danger", + "oldValue": "rgb(187, 18, 37)", + "v1_0_0": "color-foreground-danger-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-danger-alternative", + "oldValue": "rgb(158, 0, 6)", + "v1_0_0": "color-foreground-danger-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-hint", + "oldValue": "rgb(93, 93, 93)", + "v1_0_0": "color-foreground-hint-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-hint-alternative", + "oldValue": "rgb(76, 76, 76)", + "v1_0_0": "color-foreground-hint-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-contrast", + "oldValue": "rgb(57, 57, 57)", + "v1_0_0": "color-foreground-contrast-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-contrast-alternative", + "oldValue": "rgb(38, 38, 38)", + "v1_0_0": "color-foreground-contrast-200", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-alternative", + "oldValue": "rgb(76, 76, 76)", + "v1_0_0": "color-foreground-normal-100", + "section": "Text" + }, + { + "type": "변경", + "oldName": "text-normal", + "oldValue": "rgb(38, 38, 38)", + "v1_0_0": "color-foreground-normal-200", + "section": "Text" + }, + { + "type": "삭제", + "oldName": "text-light", + "oldValue": "rgb(255, 255, 255)", + "v1_0_0": "color-white로 대체", + "section": "Text" + }, + { + "type": "삭제", + "oldName": "text-exception", + "oldValue": "rgb(255, 255, 255)", + "v1_0_0": "color-white로 대체", + "section": "Text" + }, + { + "type": "삭제", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-white로 대체", + "section": "Text" + }, + { + "type": "삭제", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-white로 대체", + "section": "Text" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-foreground-button-primary", + "section": "Text" + }, + { + "type": "변경", + "oldName": "background-normal", + "oldValue": "rgb(255, 255, 255)", + "v1_0_0": "color-background-canvas-100", + "section": "Background" + }, + { + "type": "변경", + "oldName": "background-alternative-01", + "oldValue": "rgb(247, 247, 247)", + "v1_0_0": "color-background-canvas-200", + "section": "Background" + }, + { + "type": "변경", + "oldName": "background-alternative-02", + "oldValue": "rgb(255, 255, 255)", + "v1_0_0": "color-background-overlay-100", + "section": "Background" + }, + { + "type": "변경", + "oldName": "border-normal", + "oldValue": "rgb(225, 225, 225)", + "v1_0_0": "color-border-normal", + "section": "Border" + }, + { + "type": "삭제", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "-", + "section": "Border" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-border-primary", + "section": "Border" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-border-success", + "section": "Border" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-border-warning", + "section": "Border" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-border-danger", + "section": "Border" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-border-hint", + "section": "Border" + }, + { + "type": "추가", + "oldName": "-", + "oldValue": "-", + "v1_0_0": "color-border-contrast", + "section": "Border" + }, + { + "type": "삭제", + "oldName": "primary-transparent-8", + "oldValue": "rgba(42, 114, 229, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "primary-transparent-16", + "oldValue": "rgba(42, 114, 229, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "primary-transparent-24", + "oldValue": "rgba(42, 114, 229, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "primary-transparent-32", + "oldValue": "rgba(42, 114, 229, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "secondary-transparent-8", + "oldValue": "rgba(225, 225, 225, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "secondary-transparent-16", + "oldValue": "rgba(225, 225, 225, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "secondary-transparent-24", + "oldValue": "rgba(225, 225, 225, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "secondary-transparent-32", + "oldValue": "rgba(225, 225, 225, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "success-transparent-8", + "oldValue": "rgba(5, 135, 101, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "success-transparent-16", + "oldValue": "rgba(5, 135, 101, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "success-transparent-24", + "oldValue": "rgba(5, 135, 101, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "success-transparent-32", + "oldValue": "rgba(5, 135, 101, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "warning-transparent-8", + "oldValue": "rgba(211, 71, 1, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "warning-transparent-16", + "oldValue": "rgba(211, 71, 1, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "warning-transparent-24", + "oldValue": "rgba(211, 71, 1, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "warning-transparent-32", + "oldValue": "rgba(211, 71, 1, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "danger-transparent-8", + "oldValue": "rgba(218, 57, 68, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "danger-transparent-16", + "oldValue": "rgba(218, 57, 68, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "danger-transparent-24", + "oldValue": "rgba(218, 57, 68, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "danger-transparent-32", + "oldValue": "rgba(218, 57, 68, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "contrast-transparent-8", + "oldValue": "rgba(57, 57, 57, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "contrast-transparent-16", + "oldValue": "rgba(57, 57, 57, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "contrast-transparent-24", + "oldValue": "rgba(57, 57, 57, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "contrast-transparent-32", + "oldValue": "rgba(57, 57, 57, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "hint-transparent-8", + "oldValue": "rgba(93, 93, 93, 0.08)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "hint-transparent-16", + "oldValue": "rgba(93, 93, 93, 0.16)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "hint-transparent-24", + "oldValue": "rgba(93, 93, 93, 0.24)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "hint-transparent-32", + "oldValue": "rgba(93, 93, 93, 0.32)", + "v1_0_0": "-(hex code로 사용)", + "section": "Theme - Transparent" + }, + { + "type": "삭제", + "oldName": "primary-hover", + "oldValue": "rgb(9, 87, 200)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "primary-active", + "oldValue": "rgb(0, 67, 179)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "secondary-hover", + "oldValue": "rgb(163, 163, 163)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "secondary-active", + "oldValue": "rgb(149, 149, 149)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "success-hover", + "oldValue": "rgb(0, 108, 75)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "success-active", + "oldValue": "rgb(0, 88, 58)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "warning-hover", + "oldValue": "rgb(183, 33, 0)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "warning-active", + "oldValue": "rgb(158, 0, 0)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "danger-hover", + "oldValue": "rgb(187, 18, 37)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "danger-active", + "oldValue": "rgb(158, 0, 6)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "hint-hover", + "oldValue": "rgb(76, 76, 76)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "hint-active", + "oldValue": "rgb(57, 57, 57)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "contrast-hover", + "oldValue": "rgb(38, 38, 38)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + }, + { + "type": "삭제", + "oldName": "contrast-active", + "oldValue": "rgb(38, 38, 38)", + "v1_0_0": "- ( 인터랙션 레이어로 대체 / 사용 필요할 경우 basic으로 사용 )", + "section": "Status" + } +] diff --git a/apps/figma-token-review-plugin/src/common/tokens/primitive-color.dark.json b/apps/figma-token-review-plugin/src/common/tokens/primitive-color.dark.json new file mode 100644 index 000000000..11267a239 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/primitive-color.dark.json @@ -0,0 +1,739 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "colors": { + "white": { + "$description": "Absolute white. Used as a base reference, not for direct use in themes.", + "$type": "color", + "$value": { + "colorSpace": "oklch", + "components": [1, 0, 0] + } + }, + "black": { + "$description": "Absolute black. Used as a base reference, not for direct use in themes.", + "$type": "color", + "$value": { + "colorSpace": "oklch", + "components": [0, 0, 0] + } + }, + "canvas": { + "$type": "color", + "$description": "Background canvas scale. Currently defined at the primitive level but carries semantic intent.", + "$value": { + "colorSpace": "oklch", + "components": [0.26, 0, 0] + } + }, + "red": { + "$type": "color", + "$description": "Red color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.12, 29.23] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.35, 0.14, 29.23] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.42, 0.17, 29.23] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.2, 23.9] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.56, 0.2, 22.87] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.65, 0.2, 20.75] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.75, 0.15, 20.83] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.81, 0.1, 20.3] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.05, 21.13] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 17.28] + } + } + }, + "pink": { + "$type": "color", + "$description": "Pink color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.12, 15.38] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.35, 0.14, 10.99] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.42, 0.17, 5.6] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.19, 1.77] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.56, 0.19, 1.98] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.65, 0.18, 1.89] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.75, 0.15, 1.6] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.82, 0.11, 1.53] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.06, 359.84] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.99, 0.01, 357.79] + } + } + }, + "grape": { + "$type": "color", + "$description": "Grape color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.3, 0.15, 313.06] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.36, 0.18, 315.66] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.43, 0.21, 317.34] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.52, 0.23, 319.06] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.56, 0.23, 319.08] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.66, 0.22, 318.78] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.75, 0.17, 319.32] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.82, 0.12, 319.05] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.06, 318.76] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 320.55] + } + } + }, + "violet": { + "$type": "color", + "$description": "Violet color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.3, 0.17, 286.41] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.36, 0.2, 289.93] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.42, 0.21, 290.35] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.21, 290.09] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.55, 0.21, 290.42] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.65, 0.19, 293.08] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.74, 0.15, 299.51] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.81, 0.11, 304.85] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.06, 305.68] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 305.41] + } + } + }, + "blue": { + "$type": "color", + "$description": "Blue color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.3, 0.18, 264.19] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.35, 0.18, 263.41] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.41, 0.19, 261.79] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.5, 0.19, 259.74] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.54, 0.19, 259.91] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.63, 0.17, 254.87] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.72, 0.14, 248.03] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.8, 0.11, 242.65] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.89, 0.06, 242.83] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 247.91] + } + } + }, + "cyan": { + "$type": "color", + "$description": "Cyan color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.28, 0.06, 239.62] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.33, 0.07, 233.01] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.4, 0.08, 226.97] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.48, 0.09, 222.64] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.53, 0.1, 219.25] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.62, 0.11, 216.18] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.71, 0.12, 212.56] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.79, 0.09, 210.78] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.89, 0.05, 213.07] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 207.14] + } + } + }, + "green": { + "$type": "color", + "$description": "Green color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.27, 0.07, 153.71] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.32, 0.08, 156.91] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.39, 0.09, 159.88] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.48, 0.1, 164.06] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.52, 0.11, 166.28] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.61, 0.11, 167.85] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.71, 0.12, 167.76] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.79, 0.12, 167.52] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.89, 0.07, 167.7] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 171.81] + } + } + }, + "lime": { + "$type": "color", + "$description": "Lime color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.27, 0.09, 142.5] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.32, 0.11, 142.5] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.39, 0.13, 141.58] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.48, 0.15, 138.2] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.52, 0.16, 136.62] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.62, 0.17, 134.11] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.71, 0.19, 131.87] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.79, 0.2, 130.07] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.89, 0.11, 129.52] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.02, 130.17] + } + } + }, + "yellow": { + "$type": "color", + "$description": "Yellow color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.09, 38.65] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.34, 0.1, 45.91] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.41, 0.1, 53.32] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.5, 0.11, 62.47] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.54, 0.12, 65.22] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.64, 0.14, 71.53] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.73, 0.15, 77.59] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.81, 0.17, 82.65] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.11, 84.98] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.02, 82.79] + } + } + }, + "orange": { + "$type": "color", + "$description": "Orange color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.3, 0.12, 29.23] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.35, 0.14, 29.23] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.42, 0.17, 29.23] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.19, 33.09] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.55, 0.19, 36.36] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.65, 0.19, 44.3] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.74, 0.15, 45.58] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.81, 0.11, 45.3] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.06, 46.35] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.99, 0.01, 56.32] + } + } + }, + "gray": { + "$type": "color", + "$description": "Neutral gray scale. Chroma is 0 across all steps.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.28, 0, 0] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.33, 0, 0] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.4, 0, 0] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.49, 0, 0] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.53, 0, 0] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.63, 0, 0] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.73, 0, 0] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.8, 0, 0] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.89, 0, 0] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.99, 0, 0] + } + }, + "950": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0, 286.33] + } + } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/primitive-color.light.json b/apps/figma-token-review-plugin/src/common/tokens/primitive-color.light.json new file mode 100644 index 000000000..4fbbb9251 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/primitive-color.light.json @@ -0,0 +1,739 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "colors": { + "white": { + "$type": "color", + "$description": "Absolute white. Used as a base reference, not for direct use in themes.", + "$value": { + "colorSpace": "oklch", + "components": [1, 0, 0] + } + }, + "black": { + "$type": "color", + "$description": "Absolute black. Used as a base reference, not for direct use in themes.", + "$value": { + "colorSpace": "oklch", + "components": [0, 0, 0] + } + }, + "canvas": { + "$type": "color", + "$description": "Background canvas scale. Currently defined at the primitive level but carries semantic intent.", + "$value": { + "colorSpace": "oklch", + "components": [1, 0, 0] + } + }, + "red": { + "$type": "color", + "$description": "Red color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 24.32] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.91, 0.04, 19.72] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.84, 0.09, 19.96] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.74, 0.16, 20.63] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.69, 0.18, 20.46] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.59, 0.2, 22.21] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.2, 24.18] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.44, 0.18, 28.27] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.36, 0.15, 29.23] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.12, 29.23] + } + } + }, + "pink": { + "$type": "color", + "$description": "Pink color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 3.49] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.92, 0.05, 0.81] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.84, 0.09, 1.69] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.74, 0.15, 1.52] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.69, 0.18, 1.66] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.59, 0.19, 1.6] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.2, 1.61] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.44, 0.18, 4.31] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.36, 0.15, 9.49] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.11, 15.78] + } + } + }, + "grape": { + "$type": "color", + "$description": "Grape color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 318.7] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.92, 0.06, 319.46] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.84, 0.11, 319.12] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.74, 0.17, 318.84] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.69, 0.2, 319.05] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.6, 0.22, 319.09] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.51, 0.23, 318.75] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.45, 0.22, 318.28] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.37, 0.18, 315.66] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.15, 312.06] + } + } + }, + "violet": { + "$type": "color", + "$description": "Violet color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 304.14] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.92, 0.05, 305.47] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.84, 0.1, 305.18] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.73, 0.15, 298.86] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.69, 0.17, 295.47] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.59, 0.21, 290.06] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.5, 0.21, 290.23] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.44, 0.21, 290.17] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.37, 0.21, 289.98] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.17, 286.26] + } + } + }, + "blue": { + "$type": "color", + "$description": "Blue color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.97, 0.01, 240.95] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.91, 0.05, 241.64] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.82, 0.1, 242.82] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.71, 0.14, 247.99] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.67, 0.16, 252] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.57, 0.19, 259.7] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.49, 0.19, 259.76] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.43, 0.19, 261.22] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.36, 0.18, 263.22] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.29, 0.18, 264.21] + } + } + }, + "cyan": { + "$type": "color", + "$description": "Cyan color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.97, 0.01, 209.81] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.91, 0.04, 211.85] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.82, 0.08, 212.09] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.71, 0.12, 212.77] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.66, 0.11, 214.15] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.56, 0.1, 219.52] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.47, 0.09, 223.24] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.41, 0.08, 226.66] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.34, 0.07, 233.02] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.27, 0.06, 240.55] + } + } + }, + "green": { + "$type": "color", + "$description": "Green color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.97, 0.02, 166.73] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.06, 167.05] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.81, 0.11, 167.48] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.7, 0.12, 167.2] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.65, 0.12, 167.46] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.55, 0.11, 167.28] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.47, 0.1, 163.7] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.41, 0.09, 161.79] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.33, 0.08, 157.79] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.26, 0.07, 152.19] + } + } + }, + "lime": { + "$type": "color", + "$description": "Lime color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.97, 0.03, 128.76] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.9, 0.1, 130.28] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.81, 0.18, 129.92] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.7, 0.19, 132.06] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.65, 0.18, 133.24] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.55, 0.16, 135.49] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.47, 0.15, 138.41] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.41, 0.13, 140.86] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.34, 0.11, 142.5] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.26, 0.09, 142.5] + } + } + }, + "yellow": { + "$type": "color", + "$description": "Yellow color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.02, 84.59] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.91, 0.1, 85.28] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.83, 0.17, 84.5] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.72, 0.15, 77.5] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.67, 0.14, 74.08] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.58, 0.13, 67.82] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.49, 0.11, 61.8] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.43, 0.11, 54.86] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.35, 0.1, 46.87] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.28, 0.09, 37.13] + } + } + }, + "orange": { + "$type": "color", + "$description": "Orange color scale.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0.01, 51.32] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.91, 0.05, 45.51] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.84, 0.09, 45.87] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.73, 0.15, 45.89] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.69, 0.18, 45.9] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.59, 0.19, 39.37] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.5, 0.19, 32.51] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.44, 0.18, 29.23] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.36, 0.15, 29.23] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.28, 0.12, 29.23] + } + } + }, + "gray": { + "$type": "color", + "$description": "Neutral gray scale. Chroma is 0 across all steps.", + "050": { + "$value": { + "colorSpace": "oklch", + "components": [0.98, 0, 0] + } + }, + "100": { + "$value": { + "colorSpace": "oklch", + "components": [0.91, 0, 0] + } + }, + "200": { + "$value": { + "colorSpace": "oklch", + "components": [0.83, 0, 0] + } + }, + "300": { + "$value": { + "colorSpace": "oklch", + "components": [0.72, 0, 0] + } + }, + "400": { + "$value": { + "colorSpace": "oklch", + "components": [0.67, 0, 0] + } + }, + "500": { + "$value": { + "colorSpace": "oklch", + "components": [0.57, 0, 0] + } + }, + "600": { + "$value": { + "colorSpace": "oklch", + "components": [0.48, 0, 0] + } + }, + "700": { + "$value": { + "colorSpace": "oklch", + "components": [0.42, 0, 0] + } + }, + "800": { + "$value": { + "colorSpace": "oklch", + "components": [0.34, 0, 0] + } + }, + "900": { + "$value": { + "colorSpace": "oklch", + "components": [0.27, 0, 0] + } + }, + "950": { + "$value": { + "colorSpace": "oklch", + "components": [0.27, 0.02, 275.37] + } + } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/semantic-color.dark.json b/apps/figma-token-review-plugin/src/common/tokens/semantic-color.dark.json new file mode 100644 index 000000000..551480cee --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/semantic-color.dark.json @@ -0,0 +1,796 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "colors": { + "background": { + "$type": "color", + "primary": { + "100": { + "$description": "The subtle primary background to represent status, category, or selection state.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element visually indicating current selection or active state with brand color", + "non-interactive element showing status or category with subtle brand emphasis", + "background of element conveying feedback or notification with brand color" + ], + "avoid": [ + "page or screen background → colors.background.canvas.100", + "solid action elements background → colors.background.primary.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.blue.050}" + }, + "200": { + "$description": "The solid primary background for high-emphasis interactive action and brand-colored elements.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background for interactive elements that require brand color emphasis", + "brand color fill for non-interactive visual elements requiring strong brand presence" + ], + "avoid": [ + "page or screen background → colors.background.canvas.100", + "status or category label background → colors.background.primary.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.blue.500}" + } + }, + "contrast": { + "100": { + "$description": "The subtle contrast background to intentionally draw attention to content by contrasting with the surrounding page surface.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of non-interactive element requiring intentional visual contrast against the page", + "background of informational element that must stand out from the surrounding content" + ], + "avoid": [ + "elements requiring stronger visual separation → colors.background.contrast.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.800}" + }, + "200": { + "$description": "The solid high-emphasis background for elements requiring strong visual separation from the surrounding UI.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element that must be visually distinct from all surrounding content", + "high-emphasis background for elements requiring immediate user attention", + "background of elements providing temporary or contextual information above the page layer" + ], + "avoid": [ + "elements requiring only subtle contrast → colors.background.contrast.100", + "interactive variant-specific backgrounds → use variant-specific background tokens" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.300}" + } + }, + "canvas": { + "100": { + "$description": "The base-level canvas background for pages and bordered components.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default background of all pages or screens", + "base surface background for components with visible borders" + ], + "avoid": [ + "elevated surfaces such as sidebar or panel background → colors.background.canvas.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.background.canvas}" + }, + "200": { + "$description": "The solid secondary canvas background to differentiate background regions within a page.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "secondary page-level background area that needs visual separation from the primary canvas", + "layout region requiring a subtle distinction between background layers without component-level context" + ], + "avoid": [ + "direct use as a component background → colors.background.secondary.100", + "primary page background → colors.background.canvas.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.050}" + } + }, + "secondary": { + "100": { + "$description": "Do not use this token.", + "$extensions": { + "io.goorm.vapor": { + "status": "do-not-use", + "reason": "No active use case defined.", + "when": [], + "avoid": [] + } + }, + "$value": "{colors.gray.050}" + }, + "200": { + "$description": "The solid secondary background for components serving a supplementary role alongside primary elements.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of component serving a secondary role alongside primary-colored elements", + "solid fill background for supplementary interactive elements" + ], + "avoid": [ + "standalone components requiring visibility without primary context → colors.background.contrast.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.200}" + } + }, + "success": { + "100": { + "$description": "The subtle success background for elements indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element indicating a successful or completed action", + "subtle positive feedback or confirmation element background" + ], + "avoid": [], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.green.050}" + }, + "200": { + "$description": "The solid success background for elements indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background of element indicating a successful or completed action", + "high-emphasis positive feedback or confirmation element background" + ], + "avoid": [], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.green.500}" + } + }, + "warning": { + "100": { + "$description": "The subtle warning background for elements requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element indicating an action that requires careful consideration", + "subtle cautionary state background guiding deliberate user decision" + ], + "avoid": [ + "irreversible or destructive actions → colors.background.danger.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.orange.050}" + }, + "200": { + "$description": "The solid warning background for elements requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background of element indicating an action that requires careful consideration", + "high-emphasis cautionary state background guiding deliberate user decision" + ], + "avoid": [ + "irreversible or destructive actions → colors.background.danger.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.orange.500}" + } + }, + "danger": { + "100": { + "$description": "The subtle danger background for elements indicating destructive or irreversible actions.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element indicating a destructive or irreversible action", + "subtle danger state background for high-risk user actions" + ], + "avoid": [ + "cautionary actions that do not involve irreversible risk → colors.background.warning.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.red.050}" + }, + "200": { + "$description": "The solid danger background for elements indicating destructive or irreversible actions.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background of element indicating a destructive or irreversible action", + "high-emphasis danger state background for high-risk user actions" + ], + "avoid": [ + "cautionary actions that do not involve irreversible risk → colors.background.warning.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.red.500}" + } + }, + "hint": { + "100": { + "$description": "The subtle hint background for elements providing general informational content.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element providing general informational content to the user", + "subtle background for non-critical informational elements" + ], + "avoid": [ + "elements requiring contrast emphasis against surrounding background → colors.background.contrast.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.200}" + }, + "200": { + "$description": "Do not use this token.", + "$extensions": { + "io.goorm.vapor": { + "status": "do-not-use", + "reason": "No active use case defined.", + "when": [], + "avoid": [] + } + }, + "$value": "{colors.gray.600}" + } + } + }, + "foreground": { + "$type": "color", + "$extensions": { + "io.goorm.vapor": { + "gradeRules": { + "100": "Use ONLY on pure white (#ffffff) or transparent backgrounds", + "200": "Use on any surface that is not pure white (#ffffff), including near-white colored backgrounds" + } + } + }, + "primary": { + "100": { + "$description": "The subtle primary foreground for brand-colored text and icons on white or transparent surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "brand-colored text or icon on white or transparent background", + "subtle brand emphasis without a colored background" + ], + "avoid": [ + "use on canvas.200 or surfaces with non-white background color → colors.foreground.primary.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.blue.600}" + }, + "200": { + "$description": "The solid primary foreground for brand-colored text and icons on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "brand-colored text or icon on canvas.200 or a surface with non-white background color", + "strong brand emphasis on a colored background surface" + ], + "avoid": [ + "use on white or transparent background → colors.foreground.primary.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.blue.700}" + } + }, + "inverse": { + "$description": "The inverse foreground for text and icons on backgrounds where white ensures sufficient contrast.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon on backgrounds where white provides sufficient contrast against the background color" + ], + "avoid": [ + "light or pale backgrounds where dark text provides better readability → use contextual foreground tokens" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.white}" + }, + "danger": { + "100": { + "$description": "The subtle danger foreground for text and icons indicating destructive actions or error states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a destructive or irreversible action such as deletion", + "error message text or icon requiring user attention" + ], + "avoid": [ + "cautionary actions that do not involve danger → colors.foreground.warning.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.red.600}" + }, + "200": { + "$description": "The solid danger foreground for text and icons indicating destructive actions or error states on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a destructive or irreversible action on a non-white background", + "error message text or icon on canvas.200 or tinted surface" + ], + "avoid": [ + "use on white or transparent background → colors.foreground.danger.100", + "cautionary actions that do not involve danger → colors.foreground.warning.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.red.700}" + } + }, + "hint": { + "100": { + "$description": "The subtle hint foreground for text and icons in supplementary contexts where low emphasis is intended.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon in an empty or inactive state requiring minimal visual weight", + "supplementary or helper text that needs to be perceived but not emphasized", + "low-emphasis icon in a non-interactive context" + ], + "avoid": [ + "elements requiring emphasis → colors.foreground.normal.100 or colors.foreground.primary.100", + "selected or active state text → colors.foreground.normal.100 or colors.foreground.primary.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.600}" + }, + "200": { + "$description": "The solid hint foreground for text and icons in supplementary contexts on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "placeholder or supplementary text on non-white or colored background", + "low-emphasis text or icon on canvas.200 or tinted surface" + ], + "avoid": [ + "elements requiring emphasis → colors.foreground.normal.200 or colors.foreground.primary.200", + "selected or active state text → colors.foreground.normal.200 or colors.foreground.primary.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.700}" + } + }, + "normal": { + "100": { + "$description": "The subtle normal foreground for default text and icons that users must perceive without brand color emphasis.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default body text or label on white or transparent background", + "essential text or icon that requires clear readability without brand color", + "primary content text where neutral emphasis is sufficient" + ], + "avoid": [ + "supplementary or low-emphasis text → colors.foreground.hint.100", + "brand-colored emphasis text → colors.foreground.primary.100", + "highest-hierarchy title or heading requiring maximum visual prominence → colors.foreground.contrast.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.700}" + }, + "200": { + "$description": "The solid normal foreground for default text and icons that users must perceive on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default body text or label on canvas.200 or non-white background", + "essential text or icon requiring clear readability on a colored surface" + ], + "avoid": [ + "supplementary or low-emphasis text → colors.foreground.hint.200", + "brand-colored emphasis text → colors.foreground.primary.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.900}" + } + }, + "secondary": { + "100": { + "$description": "The subtle secondary foreground for text and icons with supplementary emphasis.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon with lower visual hierarchy than the primary content on the same surface", + "supplementary or descriptive text supporting a higher-hierarchy text element" + ], + "avoid": [ + "standalone elements requiring visibility without primary context → colors.foreground.contrast.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.700}" + }, + "200": { + "$description": "The solid secondary foreground for text and icons with supplementary emphasis on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon with lower visual hierarchy than the primary content on a non-white background", + "supplementary or descriptive text supporting a higher-hierarchy text element on canvas.200 or tinted surface" + ], + "avoid": [ + "standalone elements requiring visibility without primary context → colors.foreground.contrast.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.900}" + } + }, + "success": { + "100": { + "$description": "The subtle success foreground for text and icons indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a successful or completed action on white or transparent background", + "positive feedback or confirmation message text or icon" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.green.600}" + }, + "200": { + "$description": "The solid success foreground for text and icons indicating positive or completed states on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a successful or completed action on canvas.200 or non-white background", + "positive feedback or confirmation message text or icon on colored surface" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.green.700}" + } + }, + "warning": { + "100": { + "$description": "The subtle warning foreground for text and icons requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating an action that requires careful consideration on white or transparent background", + "cautionary message text or icon guiding deliberate user decision" + ], + "avoid": [ + "irreversible or destructive actions → colors.foreground.danger.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.orange.600}" + }, + "200": { + "$description": "The solid warning foreground for text and icons requiring cautious user decision on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating an action that requires careful consideration on canvas.200 or non-white background", + "cautionary message text or icon on colored surface" + ], + "avoid": [ + "irreversible or destructive actions → colors.foreground.danger.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.orange.700}" + } + }, + "contrast": { + "100": { + "$description": "The subtle contrast foreground for text and icons requiring visibility without primary context.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "highest-hierarchy title or heading text requiring maximum visual prominence on the page", + "text or icon requiring clear visibility without accompanying primary-colored elements on white or transparent background" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.200}" + }, + "200": { + "$description": "The solid contrast foreground for text and icons requiring visibility without primary context on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon requiring clear visibility without accompanying primary-colored elements on canvas.200 or non-white background" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.300}" + } + } + }, + "border": { + "$type": "color", + "normal": { + "$description": "The base border for default outlines and structural boundaries of UI elements.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default border for standard UI elements requiring a subtle structural boundary", + "divider or separator between content areas that need visual separation without emphasis" + ], + "avoid": [ + "borders conveying interactive variant styles → use variant-specific border tokens (colors.border.primary, colors.border.secondary, colors.border.contrast)" + ], + "accessibility": { + "role": "border" + } + } + }, + "$value": "{colors.gray.300}" + }, + "contrast": { + "$description": "The base high-contrast border for focused states and contrast-level outline styles.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of non-text-input interactive elements such as select, button in focused state via interaction layer", + "border of UI elements requiring high contrast outline style" + ], + "avoid": [ + "focused state border of input-type elements → colors.border.primary", + "non-contrast variant outline styles → use variant-specific border tokens (colors.border.primary, colors.border.secondary)" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.gray.400}" + }, + "primary": { + "$description": "The base primary border for focused states of input-type interactive elements.", + "$extensions": { + "io.goorm.vapor": { + "when": ["border of text input or textarea elements in focused state"], + "avoid": [ + "focused state of non-input interactive elements → colors.border.contrast", + "default (unfocused) border of input elements → colors.border.normal" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.blue.400}" + }, + "secondary": { + "$description": "The base secondary border for UI elements requiring supplementary boundary emphasis.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of elements serving a secondary role alongside primary interactive elements" + ], + "avoid": [], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.gray.200}" + }, + "success": { + "$description": "The base success border for elements indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element indicating a successful or completed action", + "border conveying a positive result to the user" + ], + "avoid": [ + "primary action elements → colors.border.primary", + "elements used alongside primary elements → colors.border.secondary" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.green.400}" + }, + "warning": { + "$description": "The base warning border for elements requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element indicating an action that requires careful consideration", + "border conveying a cautionary state to guide deliberate user decision" + ], + "avoid": ["irreversible or destructive actions → colors.border.danger"], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.orange.400}" + }, + "hint": { + "$description": "The base hint border for elements that visually imply clickable or tappable interaction.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element that visually implies clickable or tappable interaction" + ], + "avoid": ["non-interactive elements → colors.border.normal"], + "accessibility": { + "role": "border", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.400}" + }, + "danger": { + "$description": "The base danger border for elements indicating destructive or irreversible actions and error states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element indicating a destructive or irreversible action", + "border conveying an error state to the user" + ], + "avoid": [ + "cautionary actions that do not involve irreversible risk → colors.border.warning" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.red.400}" + } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/semantic-color.light.json b/apps/figma-token-review-plugin/src/common/tokens/semantic-color.light.json new file mode 100644 index 000000000..86f5eab10 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/semantic-color.light.json @@ -0,0 +1,796 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "colors": { + "background": { + "$type": "color", + "primary": { + "100": { + "$description": "The subtle primary background to represent status, category, or selection state.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element visually indicating current selection or active state with brand color", + "non-interactive element showing status or category with subtle brand emphasis", + "background of element conveying feedback or notification with brand color" + ], + "avoid": [ + "page or screen background → colors.background.canvas.100", + "solid action elements background → colors.background.primary.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.blue.100}" + }, + "200": { + "$description": "The solid primary background for high-emphasis interactive action and brand-colored elements.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background for interactive elements that require brand color emphasis", + "brand color fill for non-interactive visual elements requiring strong brand presence" + ], + "avoid": [ + "page or screen background → colors.background.canvas.100", + "status or category label background → colors.background.primary.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.blue.500}" + } + }, + "contrast": { + "100": { + "$description": "The subtle contrast background to intentionally draw attention to content by contrasting with the surrounding page surface.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of non-interactive element requiring intentional visual contrast against the page", + "background of informational element that must stand out from the surrounding content" + ], + "avoid": [ + "elements requiring stronger visual separation → colors.background.contrast.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.300}" + }, + "200": { + "$description": "The solid high-emphasis background for elements requiring strong visual separation from the surrounding UI.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element that must be visually distinct from all surrounding content", + "high-emphasis background for elements requiring immediate user attention", + "background of elements providing temporary or contextual information above the page layer" + ], + "avoid": [ + "elements requiring only subtle contrast → colors.background.contrast.100", + "interactive variant-specific backgrounds → use variant-specific background tokens" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.800}" + } + }, + "canvas": { + "100": { + "$description": "The base-level canvas background for pages and bordered components.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default background of all pages or screens", + "base surface background for components with visible borders" + ], + "avoid": [ + "elevated surfaces such as sidebar or panel background → colors.background.canvas.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.background.canvas}" + }, + "200": { + "$description": "The solid secondary canvas background to differentiate background regions within a page.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "secondary page-level background area that needs visual separation from the primary canvas", + "layout region requiring a subtle distinction between background layers without component-level context" + ], + "avoid": [ + "direct use as a component background → colors.background.secondary.100", + "primary page background → colors.background.canvas.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.050}" + } + }, + "secondary": { + "100": { + "$description": "Do not use this token.", + "$extensions": { + "io.goorm.vapor": { + "status": "do-not-use", + "reason": "No active use case defined.", + "when": [], + "avoid": [] + } + }, + "$value": "{colors.gray.050}" + }, + "200": { + "$description": "The solid secondary background for components serving a supplementary role alongside primary elements.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of component serving a secondary role alongside primary-colored elements", + "solid fill background for supplementary interactive elements" + ], + "avoid": [ + "standalone components requiring visibility without primary context → colors.background.contrast.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.100}" + } + }, + "success": { + "100": { + "$description": "The subtle success background for elements indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element indicating a successful or completed action", + "subtle positive feedback or confirmation element background" + ], + "avoid": [], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.green.100}" + }, + "200": { + "$description": "The solid success background for elements indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background of element indicating a successful or completed action", + "high-emphasis positive feedback or confirmation element background" + ], + "avoid": [], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.green.500}" + } + }, + "warning": { + "100": { + "$description": "The subtle warning background for elements requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element indicating an action that requires careful consideration", + "subtle cautionary state background guiding deliberate user decision" + ], + "avoid": [ + "irreversible or destructive actions → colors.background.danger.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.orange.100}" + }, + "200": { + "$description": "The solid warning background for elements requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background of element indicating an action that requires careful consideration", + "high-emphasis cautionary state background guiding deliberate user decision" + ], + "avoid": [ + "irreversible or destructive actions → colors.background.danger.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.orange.500}" + } + }, + "danger": { + "100": { + "$description": "The subtle danger background for elements indicating destructive or irreversible actions.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element indicating a destructive or irreversible action", + "subtle danger state background for high-risk user actions" + ], + "avoid": [ + "cautionary actions that do not involve irreversible risk → colors.background.warning.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.red.100}" + }, + "200": { + "$description": "The solid danger background for elements indicating destructive or irreversible actions.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "solid fill background of element indicating a destructive or irreversible action", + "high-emphasis danger state background for high-risk user actions" + ], + "avoid": [ + "cautionary actions that do not involve irreversible risk → colors.background.warning.200" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.red.500}" + } + }, + "hint": { + "100": { + "$description": "The subtle hint background for elements providing general informational content.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "background of element providing general informational content to the user", + "subtle background for non-critical informational elements" + ], + "avoid": [ + "elements requiring contrast emphasis against surrounding background → colors.background.contrast.100" + ], + "accessibility": { + "role": "background" + } + } + }, + "$value": "{colors.gray.100}" + }, + "200": { + "$description": "Do not use this token.", + "$extensions": { + "io.goorm.vapor": { + "status": "do-not-use", + "reason": "No active use case defined.", + "when": [], + "avoid": [] + } + }, + "$value": "{colors.gray.600}" + } + } + }, + "foreground": { + "$type": "color", + "$extensions": { + "io.goorm.vapor": { + "gradeRules": { + "100": "Use ONLY on pure white (#ffffff) or transparent backgrounds", + "200": "Use on any surface that is not pure white (#ffffff), including near-white colored backgrounds" + } + } + }, + "primary": { + "100": { + "$description": "The subtle primary foreground for brand-colored text and icons on white or transparent surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "brand-colored text or icon on white or transparent background", + "subtle brand emphasis without a colored background" + ], + "avoid": [ + "use on canvas.200 or surfaces with non-white background color → colors.foreground.primary.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.blue.600}" + }, + "200": { + "$description": "The solid primary foreground for brand-colored text and icons on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "brand-colored text or icon on canvas.200 or a surface with non-white background color", + "strong brand emphasis on a colored background surface" + ], + "avoid": [ + "use on white or transparent background → colors.foreground.primary.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.blue.700}" + } + }, + "inverse": { + "$description": "The inverse foreground for text and icons on backgrounds where white ensures sufficient contrast.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon on backgrounds where white provides sufficient contrast against the background color" + ], + "avoid": [ + "light or pale backgrounds where dark text provides better readability → use contextual foreground tokens" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.white}" + }, + "danger": { + "100": { + "$description": "The subtle danger foreground for text and icons indicating destructive actions or error states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a destructive or irreversible action such as deletion", + "error message text or icon requiring user attention" + ], + "avoid": [ + "cautionary actions that do not involve danger → colors.foreground.warning.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.red.600}" + }, + "200": { + "$description": "The solid danger foreground for text and icons indicating destructive actions or error states on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a destructive or irreversible action on a non-white background", + "error message text or icon on canvas.200 or tinted surface" + ], + "avoid": [ + "use on white or transparent background → colors.foreground.danger.100", + "cautionary actions that do not involve danger → colors.foreground.warning.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.red.700}" + } + }, + "hint": { + "100": { + "$description": "The subtle hint foreground for text and icons in supplementary contexts where low emphasis is intended.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon in an empty or inactive state requiring minimal visual weight", + "supplementary or helper text that needs to be perceived but not emphasized", + "low-emphasis icon in a non-interactive context" + ], + "avoid": [ + "elements requiring emphasis → colors.foreground.normal.100 or colors.foreground.primary.100", + "selected or active state text → colors.foreground.normal.100 or colors.foreground.primary.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.600}" + }, + "200": { + "$description": "The solid hint foreground for text and icons in supplementary contexts on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "placeholder or supplementary text on non-white or colored background", + "low-emphasis text or icon on canvas.200 or tinted surface" + ], + "avoid": [ + "elements requiring emphasis → colors.foreground.normal.200 or colors.foreground.primary.200", + "selected or active state text → colors.foreground.normal.200 or colors.foreground.primary.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.700}" + } + }, + "normal": { + "100": { + "$description": "The subtle normal foreground for default text and icons that users must perceive without brand color emphasis.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default body text or label on white or transparent background", + "essential text or icon that requires clear readability without brand color", + "primary content text where neutral emphasis is sufficient" + ], + "avoid": [ + "supplementary or low-emphasis text → colors.foreground.hint.100", + "brand-colored emphasis text → colors.foreground.primary.100", + "highest-hierarchy title or heading requiring maximum visual prominence → colors.foreground.contrast.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.700}" + }, + "200": { + "$description": "The solid normal foreground for default text and icons that users must perceive on colored background surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default body text or label on canvas.200 or non-white background", + "essential text or icon requiring clear readability on a colored surface" + ], + "avoid": [ + "supplementary or low-emphasis text → colors.foreground.hint.200", + "brand-colored emphasis text → colors.foreground.primary.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.900}" + } + }, + "secondary": { + "100": { + "$description": "The subtle secondary foreground for text and icons with supplementary emphasis.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon with lower visual hierarchy than the primary content on the same surface", + "supplementary or descriptive text supporting a higher-hierarchy text element" + ], + "avoid": [ + "standalone elements requiring visibility without primary context → colors.foreground.contrast.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.800}" + }, + "200": { + "$description": "The solid secondary foreground for text and icons with supplementary emphasis on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon with lower visual hierarchy than the primary content on a non-white background", + "supplementary or descriptive text supporting a higher-hierarchy text element on canvas.200 or tinted surface" + ], + "avoid": [ + "standalone elements requiring visibility without primary context → colors.foreground.contrast.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.900}" + } + }, + "success": { + "100": { + "$description": "The subtle success foreground for text and icons indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a successful or completed action on white or transparent background", + "positive feedback or confirmation message text or icon" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.green.600}" + }, + "200": { + "$description": "The solid success foreground for text and icons indicating positive or completed states on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating a successful or completed action on canvas.200 or non-white background", + "positive feedback or confirmation message text or icon on colored surface" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.green.700}" + } + }, + "warning": { + "100": { + "$description": "The subtle warning foreground for text and icons requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating an action that requires careful consideration on white or transparent background", + "cautionary message text or icon guiding deliberate user decision" + ], + "avoid": [ + "irreversible or destructive actions → colors.foreground.danger.100" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.orange.600}" + }, + "200": { + "$description": "The solid warning foreground for text and icons requiring cautious user decision on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon indicating an action that requires careful consideration on canvas.200 or non-white background", + "cautionary message text or icon on colored surface" + ], + "avoid": [ + "irreversible or destructive actions → colors.foreground.danger.200" + ], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.orange.700}" + } + }, + "contrast": { + "100": { + "$description": "The subtle contrast foreground for text and icons requiring visibility without primary context.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "highest-hierarchy title or heading text requiring maximum visual prominence on the page", + "text or icon requiring clear visibility without accompanying primary-colored elements on white or transparent background" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.800}" + }, + "200": { + "$description": "The solid contrast foreground for text and icons requiring visibility without primary context on colored surfaces.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "text or icon requiring clear visibility without accompanying primary-colored elements on canvas.200 or non-white background" + ], + "avoid": [], + "accessibility": { + "role": "foreground", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.800}" + } + } + }, + "border": { + "$type": "color", + "normal": { + "$description": "The base border for default outlines and structural boundaries of UI elements.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "default border for standard UI elements requiring a subtle structural boundary", + "divider or separator between content areas that need visual separation without emphasis" + ], + "avoid": [ + "borders conveying interactive variant styles → use variant-specific border tokens (colors.border.primary, colors.border.secondary, colors.border.contrast)" + ], + "accessibility": { + "role": "border" + } + } + }, + "$value": "{colors.gray.100}" + }, + "contrast": { + "$description": "The base high-contrast border for focused states and contrast-level outline styles.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of non-text-input interactive elements such as select, button in focused state via interaction layer", + "border of UI elements requiring high contrast outline style" + ], + "avoid": [ + "focused state border of input-type elements → colors.border.primary", + "non-contrast variant outline styles → use variant-specific border tokens (colors.border.primary, colors.border.secondary)" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.gray.800}" + }, + "primary": { + "$description": "The base primary border for focused states of input-type interactive elements.", + "$extensions": { + "io.goorm.vapor": { + "when": ["border of text input or textarea elements in focused state"], + "avoid": [ + "focused state of non-input interactive elements → colors.border.contrast", + "default (unfocused) border of input elements → colors.border.normal" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.blue.500}" + }, + "secondary": { + "$description": "The base secondary border for UI elements requiring supplementary boundary emphasis.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of elements serving a secondary role alongside primary interactive elements" + ], + "avoid": [], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.gray.200}" + }, + "success": { + "$description": "The base success border for elements indicating positive or completed states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element indicating a successful or completed action", + "border conveying a positive result to the user" + ], + "avoid": [ + "primary action elements → colors.border.primary", + "elements used alongside primary elements → colors.border.secondary" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.green.500}" + }, + "warning": { + "$description": "The base warning border for elements requiring cautious user decision.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element indicating an action that requires careful consideration", + "border conveying a cautionary state to guide deliberate user decision" + ], + "avoid": ["irreversible or destructive actions → colors.border.danger"], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.orange.500}" + }, + "hint": { + "$description": "The base hint border for elements that visually imply clickable or tappable interaction.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element that visually implies clickable or tappable interaction" + ], + "avoid": ["non-interactive elements → colors.border.normal"], + "accessibility": { + "role": "border", + "minimumContrast": "4.5:1" + } + } + }, + "$value": "{colors.gray.600}" + }, + "danger": { + "$description": "The base danger border for elements indicating destructive or irreversible actions and error states.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "border of element indicating a destructive or irreversible action", + "border conveying an error state to the user" + ], + "avoid": [ + "cautionary actions that do not involve irreversible risk → colors.border.warning" + ], + "accessibility": { + "role": "border", + "minimumContrast": "3:1" + } + } + }, + "$value": "{colors.red.500}" + } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/shadow.json b/apps/figma-token-review-plugin/src/common/tokens/shadow.json new file mode 100644 index 000000000..858faecdb --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/shadow.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "shadow": { + "$type": "shadow", + "sm": { + "$description": "Do not use — no active use case defined.", + "$extensions": { + "io.goorm.vapor": { + "status": "do-not-use", + "reason": "No active use case defined.", + "when": [], + "avoid": [] + } + }, + "$value": { + "offsetX": { "value": 0, "unit": "px" }, + "offsetY": { "value": 1, "unit": "px" }, + "blur": { "value": 3, "unit": "px" }, + "spread": { "value": 0, "unit": "px" }, + "color": "rgba(0, 0, 0, 0.2)" + } + }, + "md": { + "$description": "The most common shadow. Used when an interface element requires clear separation from the background.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "independent element requiring user action", + "element that must be perceived first by the user", + "element temporarily emphasized by interaction such as focus or selection" + ], + "avoid": ["icons", "text"] + } + }, + "$value": { + "offsetX": { "value": 0, "unit": "px" }, + "offsetY": { "value": 4, "unit": "px" }, + "blur": { "value": 10, "unit": "px" }, + "spread": { "value": 0, "unit": "px" }, + "color": "rgba(0, 0, 0, 0.2)" + } + }, + "lg": { + "$description": "Represents a higher elevation than `md`. Used when separation from an interface element that already has a shadow applied is required.", + "$extensions": { + "io.goorm.vapor": { + "when": ["element that appears above another element with a shadow applied"], + "avoid": ["general shadow use → `md`"] + } + }, + "$value": { + "offsetX": { "value": 0, "unit": "px" }, + "offsetY": { "value": 4, "unit": "px" }, + "blur": { "value": 16, "unit": "px" }, + "spread": { "value": 0, "unit": "px" }, + "color": "rgba(0, 0, 0, 0.2)" + } + }, + "xl": { + "$description": "Used for the strongest separation between page and content. Represents the greatest perceived distance between elements.", + "$extensions": { + "io.goorm.vapor": { + "when": ["modal-type overlay content that appears above the page"], + "avoid": ["elements that are not overlay content"] + } + }, + "$value": { + "offsetX": { "value": 0, "unit": "px" }, + "offsetY": { "value": 16, "unit": "px" }, + "blur": { "value": 32, "unit": "px" }, + "spread": { "value": 0, "unit": "px" }, + "color": "rgba(0, 0, 0, 0.2)" + } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/space.json b/apps/figma-token-review-plugin/src/common/tokens/space.json new file mode 100644 index 000000000..590d8f6d7 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/space.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "size": { + "space": { + "$type": "dimension", + "$description": "Scale for spacing properties such as padding and gap. The number is a scale index, not a px value.", + "000": { "$value": { "value": 0, "unit": "px" } }, + "025": { "$value": { "value": 2, "unit": "px" } }, + "050": { "$value": { "value": 4, "unit": "px" } }, + "075": { "$value": { "value": 6, "unit": "px" } }, + "100": { "$value": { "value": 8, "unit": "px" } }, + "150": { "$value": { "value": 12, "unit": "px" } }, + "175": { "$value": { "value": 14, "unit": "px" } }, + "200": { "$value": { "value": 16, "unit": "px" } }, + "225": { "$value": { "value": 18, "unit": "px" } }, + "250": { "$value": { "value": 20, "unit": "px" } }, + "300": { "$value": { "value": 24, "unit": "px" } }, + "400": { "$value": { "value": 32, "unit": "px" } }, + "500": { "$value": { "value": 40, "unit": "px" } }, + "600": { "$value": { "value": 48, "unit": "px" } }, + "700": { "$value": { "value": 56, "unit": "px" } }, + "800": { "$value": { "value": 64, "unit": "px" } }, + "900": { "$value": { "value": 72, "unit": "px" } } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/text-style.json b/apps/figma-token-review-plugin/src/common/tokens/text-style.json new file mode 100644 index 000000000..40cd783c7 --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/text-style.json @@ -0,0 +1,380 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "textStyle": { + "$type": "typography", + "display1": { + "$description": "The high display to deliver maximum visual impact as a graphic element in brand or marketing contexts.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "hero or landing section on desktop where text functions as a visual graphic rather than readable prose", + "brand or campaign statement limited to a few words requiring the strongest typographic presence" + ], + "avoid": [ + "mobile viewports → heading1 or heading2", + "text carrying meaningful or informational content → display2, display3, or display4" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.800}", + "fontSize": "{typography.fontSize.1000}", + "lineHeight": "{typography.lineHeight.1000}", + "letterSpacing": "{typography.letterSpacing.400}" + } + }, + "display2": { + "$description": "The mid display to deliver stronger visual impact than display3 in a page or hero context.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "hero or landing headline requiring greater visual weight than display3 can provide", + "brand or campaign statement where maximum typographic presence is intentional" + ], + "avoid": [ + "mobile viewports → heading1 or heading2", + "standard landing or hero headlines where display3 is sufficient → display3" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.800}", + "fontSize": "{typography.fontSize.900}", + "lineHeight": "{typography.lineHeight.900}", + "letterSpacing": "{typography.letterSpacing.400}" + } + }, + "display3": { + "$description": "The mid display to headline landing or hero sections as the default display scale on desktop.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "primary headline of a landing page or hero section on desktop", + "brand or marketing statement requiring large visual weight with readable content" + ], + "avoid": [ + "mobile viewports → heading1 or heading2", + "cases requiring stronger impact → display2" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.800}", + "fontSize": "{typography.fontSize.800}", + "lineHeight": "{typography.lineHeight.800}", + "letterSpacing": "{typography.letterSpacing.400}" + } + }, + "display4": { + "$description": "The low display to title subsections within a hero or marketing layout at the lowest display hierarchy.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "subsection headline within a hero or landing area sitting below a display2 or display3 title", + "largest title in a promotional block that does not require full display1–3 scale" + ], + "avoid": ["mobile viewports → heading1 or heading2"] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.800}", + "fontSize": "{typography.fontSize.700}", + "lineHeight": "{typography.lineHeight.700}", + "letterSpacing": "{typography.letterSpacing.400}" + } + }, + "heading1": { + "$description": "The high heading to deliver the strongest visual impact as a hero headline on mobile.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "primary hero headline on mobile where display tokens are not applicable", + "largest title in a mobile layout requiring maximum typographic presence" + ], + "avoid": [ + "desktop hero headlines → display2, display3, or display4", + "desktop content section titles → heading2 or heading3" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.700}", + "fontSize": "{typography.fontSize.600}", + "lineHeight": "{typography.lineHeight.600}", + "letterSpacing": "{typography.letterSpacing.400}" + } + }, + "heading2": { + "$description": "The high heading to title a prominent content section or serve as a mobile hero headline at lower impact than heading1.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "top-level title of a content section requiring strong visual emphasis, particularly in marketing contexts", + "mobile hero headline where heading1 provides more impact than needed" + ], + "avoid": ["desktop hero headlines → display2, display3, or display4"] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.700}", + "fontSize": "{typography.fontSize.500}", + "lineHeight": "{typography.lineHeight.500}", + "letterSpacing": "{typography.letterSpacing.400}" + } + }, + "heading3": { + "$description": "The mid heading to organize page-level content sections below the primary heading hierarchy.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "section title that divides page-level content beneath heading1 or heading2", + "most prominent title within a content block that does not require heading1 or heading2 scale" + ], + "avoid": [ + "top-level content section titles requiring strong emphasis → heading2", + "titles inside cards or panels → heading4" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.700}", + "fontSize": "{typography.fontSize.400}", + "lineHeight": "{typography.lineHeight.400}", + "letterSpacing": "{typography.letterSpacing.300}" + } + }, + "heading4": { + "$description": "The mid heading to establish the primary title within a card, panel, or contained component.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "main title inside a card, panel, or other contained UI component", + "primary label of a grouped content block with defined boundaries" + ], + "avoid": ["page-level section titles → heading3"] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.700}", + "fontSize": "{typography.fontSize.300}", + "lineHeight": "{typography.lineHeight.300}", + "letterSpacing": "{typography.letterSpacing.200}" + } + }, + "heading5": { + "$description": "The low heading to title compact components or deliver strong emphasis in short label-like text.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "primary title within a dialog, bottom sheet, or small-screen layout", + "short emphatic label requiring stronger visual weight than body or subtitle tokens" + ], + "avoid": [ + "container titles where more hierarchy is needed → heading4", + "sub-titles or secondary labels within the same component → heading6" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.700}", + "fontSize": "{typography.fontSize.200}", + "lineHeight": "{typography.lineHeight.200}", + "letterSpacing": "{typography.letterSpacing.100}" + } + }, + "heading6": { + "$description": "The low heading to label secondary content or classify information alongside a heading.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "sub-title or supporting label paired with heading3, heading4, or heading5", + "short classificatory label grouping or categorizing content such as counts or status indicators" + ], + "avoid": [ + "primary titles within a component → heading5", + "labels inside badge or small contained components → subtitle2" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.500}", + "fontSize": "{typography.fontSize.100}", + "lineHeight": "{typography.lineHeight.100}", + "letterSpacing": "{typography.letterSpacing.100}" + } + }, + "subtitle1": { + "$description": "The high subtitle to deliver emphasis within body-scale text through increased font weight.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "label or title requiring emphasis within a body text context", + "inline text that needs stronger visual weight than body2 without increasing font size" + ], + "avoid": [ + "long body or paragraph text → body2", + "labels inside badge or small contained components → subtitle2" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.500}", + "fontSize": "{typography.fontSize.075}", + "lineHeight": "{typography.lineHeight.075}", + "letterSpacing": "{typography.letterSpacing.100}" + } + }, + "subtitle2": { + "$description": "The low subtitle to label minimal UI components or add emphasis at caption scale.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "label inside a badge or similarly compact component requiring strong visual weight", + "caption-scale text requiring emphasis beyond body3" + ], + "avoid": [ + "body or paragraph text → body2 or body3", + "labels outside compact components → subtitle1" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.500}", + "fontSize": "{typography.fontSize.050}", + "lineHeight": "{typography.lineHeight.050}", + "letterSpacing": "{typography.letterSpacing.000}" + } + }, + "body1": { + "$description": "The high body to provide supplementary description alongside large-scale headings.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "descriptive text paired directly with a heading of 20px or larger as a supporting explanation", + "subtitle-like body text in hero or section contexts where heading scale demands larger supporting text" + ], + "avoid": [ + "general body or paragraph text → body2", + "emphasized short labels → subtitle1" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.400}", + "fontSize": "{typography.fontSize.100}", + "lineHeight": "{typography.lineHeight.100}", + "letterSpacing": "{typography.letterSpacing.100}" + } + }, + "body2": { + "$description": "The high body to deliver readable content as the default body text scale.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "general body or paragraph text in most UI contexts", + "long-form content requiring comfortable reading at the default text size" + ], + "avoid": [ + "emphasized or bold inline text → subtitle1", + "supporting description for large headings → body1" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.400}", + "fontSize": "{typography.fontSize.075}", + "lineHeight": "{typography.lineHeight.075}", + "letterSpacing": "{typography.letterSpacing.100}" + } + }, + "body3": { + "$description": "The low body to provide descriptive captions for content elements at a reduced scale.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "caption text describing an image, chart, or media element", + "supplementary description below a content block where body2 is too prominent" + ], + "avoid": [ + "general body or paragraph text → body2", + "ultra-small labels where legibility is not a concern → body4" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.400}", + "fontSize": "{typography.fontSize.050}", + "lineHeight": "{typography.lineHeight.050}", + "letterSpacing": "{typography.letterSpacing.100}" + } + }, + "body4": { + "$description": "The low body to present the smallest readable text for space-constrained label contexts.", + "$extensions": { + "io.goorm.vapor": { + "when": [ + "ultra-small label in a space-constrained UI element where no larger size fits" + ], + "avoid": [ + "body or paragraph text where legibility matters → body2 or body3", + "captions or supplementary descriptions → body3" + ] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.sans}", + "fontWeight": "{typography.fontWeight.400}", + "fontSize": "{typography.fontSize.025}", + "lineHeight": "{typography.lineHeight.025}", + "letterSpacing": "{typography.letterSpacing.000}" + } + }, + "code1": { + "$description": "The first element in the code hierarchy. The default code text scale.", + "$extensions": { + "io.goorm.vapor": { + "when": ["code block or inline code text"], + "avoid": ["non-code text"] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.code}", + "fontWeight": "{typography.fontWeight.400}", + "fontSize": "{typography.fontSize.075}", + "lineHeight": "{typography.lineHeight.075}", + "letterSpacing": "{typography.letterSpacing.000}" + } + }, + "code2": { + "$description": "The second and smallest element in the code hierarchy. The smallest code text scale.", + "$extensions": { + "io.goorm.vapor": { + "when": ["code block or inline code text"], + "avoid": ["non-code text"] + } + }, + "$value": { + "fontFamily": "{typography.fontFamily.code}", + "fontWeight": "{typography.fontWeight.400}", + "fontSize": "{typography.fontSize.050}", + "lineHeight": "{typography.lineHeight.050}", + "letterSpacing": "{typography.letterSpacing.000}" + } + } + } +} diff --git a/apps/figma-token-review-plugin/src/common/tokens/typography.json b/apps/figma-token-review-plugin/src/common/tokens/typography.json new file mode 100644 index 000000000..a1640832f --- /dev/null +++ b/apps/figma-token-review-plugin/src/common/tokens/typography.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://www.designtokens.org/schemas/2025.10/format.json", + "typography": { + "fontFamily": { + "$type": "fontFamily", + "sans": { "$value": ["Pretendard", "sans-serif"] }, + "code": { "$value": ["FiraCode", "monospace"] } + }, + "fontWeight": { + "$type": "fontWeight", + "$description": "Font weight scale.", + "400": { "$value": 400 }, + "500": { "$value": 500 }, + "700": { "$value": 700 }, + "800": { "$value": 800 } + }, + "fontSize": { + "$type": "dimension", + "$description": "Font size scale. The number is a scale index, not a px value.", + "025": { "$value": { "value": 10, "unit": "px" } }, + "050": { "$value": { "value": 12, "unit": "px" } }, + "075": { "$value": { "value": 14, "unit": "px" } }, + "100": { "$value": { "value": 16, "unit": "px" } }, + "200": { "$value": { "value": 18, "unit": "px" } }, + "300": { "$value": { "value": 20, "unit": "px" } }, + "400": { "$value": { "value": 24, "unit": "px" } }, + "500": { "$value": { "value": 32, "unit": "px" } }, + "600": { "$value": { "value": 38, "unit": "px" } }, + "700": { "$value": { "value": 48, "unit": "px" } }, + "800": { "$value": { "value": 64, "unit": "px" } }, + "900": { "$value": { "value": 80, "unit": "px" } }, + "1000": { "$value": { "value": 120, "unit": "px" } } + }, + "lineHeight": { + "$type": "number", + "$description": "Line height scale. The number is a scale index, not a px value.", + "025": { "$value": 1.4 }, + "050": { "$value": 1.5 }, + "075": { "$value": 1.57142 }, + "100": { "$value": 1.5 }, + "200": { "$value": 1.44444 }, + "300": { "$value": 1.5 }, + "400": { "$value": 1.5 }, + "500": { "$value": 1.5 }, + "600": { "$value": 1.47368 }, + "700": { "$value": 1.2916 }, + "800": { "$value": 1.3125 }, + "900": { "$value": 1.3 }, + "1000": { "$value": 1.3 } + }, + "letterSpacing": { + "$type": "dimension", + "$description": "Letter spacing scale. The number is a scale index, not a px value.", + "000": { "$value": { "value": 0, "unit": "px" } }, + "100": { "$value": { "value": -0.1, "unit": "px" } }, + "200": { "$value": { "value": -0.2, "unit": "px" } }, + "300": { "$value": { "value": -0.3, "unit": "px" } }, + "400": { "$value": { "value": -0.4, "unit": "px" } } + } + } +} diff --git a/apps/figma-token-review-plugin/src/plugin/code.ts b/apps/figma-token-review-plugin/src/plugin/code.ts new file mode 100644 index 000000000..7c8e78ead --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/code.ts @@ -0,0 +1,16 @@ +import { initApiKey } from './controllers/api-key'; +import { initFocus } from './controllers/focus'; +import { initResize } from './controllers/resize'; +import { initScan } from './controllers/scan'; +import { initSelection } from './controllers/selection'; +import { start } from './messages'; +import { DEFAULT_SIZE } from './views/window'; + +figma.showUI(__html__, DEFAULT_SIZE); + +initSelection(); +initScan(); +initFocus(); +initResize(); +initApiKey(); +start(); diff --git a/apps/figma-token-review-plugin/src/plugin/controllers/api-key.ts b/apps/figma-token-review-plugin/src/plugin/controllers/api-key.ts new file mode 100644 index 000000000..879a9063f --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/controllers/api-key.ts @@ -0,0 +1,27 @@ +import { on } from '../messages'; +import { clearKey, readKey, writeKey } from '../models/api-key-store'; +import { sendApiKeyState } from '../views/ui-port'; + +async function emit(): Promise { + const key = await readKey(); + sendApiKeyState({ hasKey: key !== null, key }); +} + +export function initApiKey(): void { + on('api-key:get', async () => { + await emit(); + }); + + on('api-key:set', async (msg) => { + if (msg.type !== 'api-key:set') return; + await writeKey(msg.value); + await emit(); + }); + + on('api-key:clear', async () => { + await clearKey(); + await emit(); + }); + + void emit(); +} diff --git a/apps/figma-token-review-plugin/src/plugin/controllers/focus.ts b/apps/figma-token-review-plugin/src/plugin/controllers/focus.ts new file mode 100644 index 000000000..c5c1f8032 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/controllers/focus.ts @@ -0,0 +1,33 @@ +import type { RequestId } from '~/common/messages'; + +import { on } from '../messages'; +import { resolveSceneNodes } from '../models/node-lookup'; +import { sendFocusError, sendFocusResult } from '../views/ui-port'; +import { focusNodes } from '../views/viewport'; + +let activeRequestId: RequestId | null = null; + +export function initFocus(): void { + on('focus', async (msg) => { + if (msg.type !== 'focus') return; + + const { requestId } = msg; + activeRequestId = requestId; + + const result = await resolveSceneNodes(msg.nodeIds, () => activeRequestId !== requestId); + if (!result) return; + + const { resolved, missing } = result; + + if (resolved.length === 0) { + sendFocusError(requestId, '이 프레임에 해당 노드 없음 — 파일이 다른가요?'); + sendFocusResult(requestId, 0, missing.length); + return; + } + + if (activeRequestId !== requestId) return; + + focusNodes(resolved); + sendFocusResult(requestId, resolved.length, missing.length); + }); +} diff --git a/apps/figma-token-review-plugin/src/plugin/controllers/resize.ts b/apps/figma-token-review-plugin/src/plugin/controllers/resize.ts new file mode 100644 index 000000000..abed51f6b --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/controllers/resize.ts @@ -0,0 +1,9 @@ +import { on } from '../messages'; +import { resizeWindow } from '../views/window'; + +export function initResize(): void { + on('resize', async (msg) => { + if (msg.type !== 'resize') return; + resizeWindow(msg.width, msg.height); + }); +} diff --git a/apps/figma-token-review-plugin/src/plugin/controllers/scan.ts b/apps/figma-token-review-plugin/src/plugin/controllers/scan.ts new file mode 100644 index 000000000..3989196b6 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/controllers/scan.ts @@ -0,0 +1,36 @@ +import type { RequestId } from '~/common/messages'; + +import { on } from '../messages'; +import { extractFrame } from '../models/extract'; +import { getScanTarget } from '../models/node-lookup'; +import { sendExtractError, sendExtractResult } from '../views/ui-port'; + +let activeRequestId: RequestId | null = null; + +export function initScan(): void { + on('scan', async (msg) => { + if (msg.type !== 'scan') return; + + const { requestId } = msg; + activeRequestId = requestId; + + try { + const target = await getScanTarget(msg.frameId); + if (activeRequestId !== requestId) return; + + if (!target) { + sendExtractError(requestId, '선택한 프레임 또는 인스턴스를 찾을 수 없습니다.'); + return; + } + + const payload = await extractFrame(msg.frameId); + if (activeRequestId !== requestId) return; + + sendExtractResult(requestId, payload); + } catch (err) { + if (activeRequestId !== requestId) return; + + sendExtractError(requestId, err instanceof Error ? err.message : '알 수 없는 오류'); + } + }); +} diff --git a/apps/figma-token-review-plugin/src/plugin/controllers/selection.ts b/apps/figma-token-review-plugin/src/plugin/controllers/selection.ts new file mode 100644 index 000000000..e72faaf54 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/controllers/selection.ts @@ -0,0 +1,19 @@ +import { on } from '../messages'; +import { computeSelection } from '../models/selection'; +import { sendSelection } from '../views/ui-port'; + +function emit(): void { + sendSelection(computeSelection()); +} + +export function initSelection(): void { + emit(); + + // figma.on 은 controllers 에서 1건 허용 — selectionchange 이벤트는 + // plugin API 레벨 이벤트로 messages.ts 를 경유할 수 없다. + figma.on('selectionchange', emit); + + on('request-selection', () => { + emit(); + }); +} diff --git a/apps/figma-token-review-plugin/src/plugin/messages.ts b/apps/figma-token-review-plugin/src/plugin/messages.ts new file mode 100644 index 000000000..9479724aa --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/messages.ts @@ -0,0 +1,24 @@ +import type { UiMsg } from '~/common/messages'; + +type Handler = (msg: UiMsg) => void | Promise; + +const handlers = new Map(); + +export function on(type: UiMsg['type'], handler: Handler): void { + handlers.set(type, handler); +} + +export function start(): void { + figma.ui.onmessage = (msg: UiMsg) => { + const handler = handlers.get(msg.type); + if (!handler) return; + + const result = handler(msg); + + if (result instanceof Promise) { + result.catch((err) => { + console.error(`[plugin handler:${msg.type}]`, err); + }); + } + }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/api-key-store.ts b/apps/figma-token-review-plugin/src/plugin/models/api-key-store.ts new file mode 100644 index 000000000..fc532300b --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/api-key-store.ts @@ -0,0 +1,20 @@ +const STORAGE_KEY = 'litellm-api-key'; + +export async function readKey(): Promise { + const raw = await figma.clientStorage.getAsync(STORAGE_KEY); + return typeof raw === 'string' && raw.length > 0 ? raw : null; +} + +/** trim 후 빈 값이면 삭제와 동일 취급 — 기존 api-key:set 핸들러 의미 보존. */ +export async function writeKey(value: string): Promise { + const trimmed = value.trim(); + if (trimmed.length === 0) { + await figma.clientStorage.deleteAsync(STORAGE_KEY); + } else { + await figma.clientStorage.setAsync(STORAGE_KEY, trimmed); + } +} + +export async function clearKey(): Promise { + await figma.clientStorage.deleteAsync(STORAGE_KEY); +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/engine.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/engine.test.ts new file mode 100644 index 000000000..354e30808 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/engine.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { passes, runRules } from './engine'; +import type { AnyExtractionRule, ExtractCtx } from './types'; + +const node = { id: 'n1', name: 'Card' } as SceneNode; + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +/** 테스트용 최소 spaces 규칙. */ +const spaceRule = (over: Partial = {}): AnyExtractionRule => + ({ + name: 'test:space', + category: 'spaces', + filterKeys: ['itemSpacing'], + extract: async () => [ + { + property: 'gap', + value: '8px', + token: 'space-100', + appliedToken: null, + tokenStatus: 'ok', + }, + ], + ...over, + }) as AnyExtractionRule; + +describe('passes', () => { + it('null 필터는 전부 통과', () => { + expect(passes(null, 'anything')).toBe(true); + }); + it('키 하나라도 포함 시 통과, 아니면 차단', () => { + expect(passes(new Set(['fills']), 'fills', 'strokes')).toBe(true); + expect(passes(new Set(['fills']), 'strokes')).toBe(false); + }); +}); + +describe('runRules', () => { + it('emission 에 nodeId/name 스탬핑 후 category 로 병합', async () => { + const { facts } = await runRules(node, ctx(), [spaceRule()]); + expect(facts.spaces).toEqual([ + { + nodeId: 'n1', + name: 'Card', + property: 'gap', + value: '8px', + token: 'space-100', + appliedToken: null, + tokenStatus: 'ok', + }, + ]); + expect(facts.colors).toEqual([]); + }); + + it('filterKeys 미충족 규칙은 extract 미호출 스킵', async () => { + let called = false; + const rule = spaceRule({ + extract: async () => { + called = true; + return []; + }, + }); + const { facts, trace } = await runRules(node, ctx({ filter: new Set(['fills']) }), [rule], { + trace: true, + }); + expect(called).toBe(false); + expect(facts.spaces).toEqual([]); + expect(trace).toContainEqual({ rule: 'test:space', skipped: 'filter' }); + }); + + it('가드 실패 시 가드명이 trace 에 남는다', async () => { + const rule = spaceRule({ + guards: [{ name: 'never', test: () => false }], + }); + const { trace } = await runRules(node, ctx(), [rule], { trace: true }); + expect(trace).toContainEqual({ rule: 'test:space', skipped: 'guard:never' }); + }); + + it('한 규칙의 throw 가 다른 규칙 결과를 죽이지 않는다', async () => { + const bad = spaceRule({ + name: 'test:bad', + extract: async () => { + throw new Error('boom'); + }, + }); + const { facts, trace } = await runRules(node, ctx(), [bad, spaceRule()], { trace: true }); + expect(facts.spaces).toHaveLength(1); + expect(trace).toContainEqual({ rule: 'test:bad', error: 'boom' }); + }); + + it('병합 순서는 테이블 순서를 따른다 (완료 순서 무관)', async () => { + const slow = spaceRule({ + name: 'test:slow', + extract: async () => { + await new Promise((r) => setTimeout(r, 20)); + return [ + { + property: 'gap', + value: '1px', + token: null, + appliedToken: null, + tokenStatus: 'raw', + }, + ]; + }, + }); + const fast = spaceRule({ + name: 'test:fast', + extract: async () => [ + { + property: 'gap', + value: '2px', + token: null, + appliedToken: null, + tokenStatus: 'raw', + }, + ], + }); + const { facts } = await runRules(node, ctx(), [slow, fast]); + expect(facts.spaces.map((s) => s.value)).toEqual(['1px', '2px']); + }); + + it('trace 옵션 없으면 trace 는 빈 배열', async () => { + const { trace } = await runRules(node, ctx(), [spaceRule()]); + expect(trace).toEqual([]); + }); + + it('trace off + throwing rule → console.warn 호출, 다른 규칙 결과는 유지', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const bad = spaceRule({ + name: 'test:bad', + extract: async () => { + throw new Error('silent-boom'); + }, + }); + const { facts } = await runRules(node, ctx(), [bad, spaceRule()]); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toContain('[extract] rule "test:bad" failed on node n1:'); + expect(facts.spaces).toHaveLength(1); + warnSpy.mockRestore(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/engine.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/engine.ts new file mode 100644 index 000000000..9508009df --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/engine.ts @@ -0,0 +1,87 @@ +import type { AnyExtractionRule, ExtractCtx, NodeFacts, OverrideFilter, RuleTrace } from './types'; + +export const passes = (filter: OverrideFilter, ...keys: readonly string[]): boolean => + filter === null || keys.some((k) => filter.has(k)); + +const emptyFacts = (): NodeFacts => ({ + colors: [], + typography: [], + spaces: [], + dimensions: [], + radii: [], + shadows: [], +}); + +type RuleOutcome = { + rule: AnyExtractionRule; + emissions: object[]; + trace: RuleTrace | null; +}; + +async function runOne( + node: SceneNode, + ctx: ExtractCtx, + rule: AnyExtractionRule, + collectTrace: boolean, +): Promise { + if (!passes(ctx.filter, ...rule.filterKeys)) { + return { + rule, + emissions: [], + trace: collectTrace ? { rule: rule.name, skipped: 'filter' } : null, + }; + } + + const failed = (rule.guards ?? []).find((g) => !g.test(node, ctx)); + if (failed) { + return { + rule, + emissions: [], + trace: collectTrace ? { rule: rule.name, skipped: `guard:${failed.name}` } : null, + }; + } + + try { + const emissions = await rule.extract(node, ctx); + return { + rule, + emissions, + trace: collectTrace ? { rule: rule.name, emitted: emissions.length } : null, + }; + } catch (err) { + // 규칙 하나의 실패가 노드 전체 추출을 죽이지 않는다. + const msg = err instanceof Error ? err.message : String(err); + if (!collectTrace) { + // trace off 여도 규칙 실패는 관측 가능해야 함 + console.warn(`[extract] rule "${rule.name}" failed on node ${node.id}:`, msg); + } + return { + rule, + emissions: [], + trace: collectTrace ? { rule: rule.name, error: msg } : null, + }; + } +} + +/** 병렬 실행, 병합은 테이블 순서 유지 — downstream dedup 안정성. */ +export async function runRules( + node: SceneNode, + ctx: ExtractCtx, + rules: readonly AnyExtractionRule[], + options: { trace?: boolean } = {}, +): Promise<{ facts: NodeFacts; trace: RuleTrace[] }> { + const collectTrace = options.trace === true; + const outcomes = await Promise.all(rules.map((r) => runOne(node, ctx, r, collectTrace))); + + const facts = emptyFacts(); + const trace: RuleTrace[] = []; + + for (const { rule, emissions, trace: t } of outcomes) { + if (t) trace.push(t); + for (const e of emissions) { + (facts[rule.category] as object[]).push({ nodeId: node.id, name: node.name, ...e }); + } + } + + return { facts, trace }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/factories.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/factories.test.ts new file mode 100644 index 000000000..220307133 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/factories.test.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { composite, px, tokenField } from './factories'; +import type { ExtractCtx } from './types'; + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +describe('tokenField', () => { + const gap = tokenField({ + name: 'space:gap', + category: 'spaces', + property: 'gap', + field: 'itemSpacing', + }); + + afterEach(() => { + delete (globalThis as any).figma; + }); + + it('filterKeys 는 field 하나', () => { + expect(gap.filterKeys).toEqual(['itemSpacing']); + }); + + it('숫자 필드 → px 포맷 + raw 상태 emission (바인딩 없음)', async () => { + const node = { id: 'n', name: 'N', itemSpacing: 8 } as unknown as SceneNode; + const out = await gap.extract(node, ctx()); + expect(out).toEqual([ + { property: 'gap', value: '8px', token: null, appliedToken: null, tokenStatus: 'raw' }, + ]); + }); + + it('필드가 숫자가 아니면 미방출 (figma.mixed, undefined)', async () => { + const node = { id: 'n', name: 'N', itemSpacing: Symbol('mixed') } as unknown as SceneNode; + expect(await gap.extract(node, ctx())).toEqual([]); + expect(await gap.extract({ id: 'n', name: 'N' } as SceneNode, ctx())).toEqual([]); + }); + + it('boundVariables 에 바인딩이 있으면 readBoundToken 경로로 토큰 해석', async () => { + // walk() 가 semantic tier 를 찾도록 figma variable API 를 mock. + // variables.ts:43 getVariableWithRemoteDefense → figma.variables.getVariableByIdAsync + // variables.ts:83 walk → figma.variables.getVariableCollectionByIdAsync + (globalThis as { figma?: unknown }).figma = { + variables: { + getVariableByIdAsync: async () => ({ + name: 'space-100', + remote: false, + variableCollectionId: 'c1', + valuesByMode: { m1: 8 }, + }), + getVariableCollectionByIdAsync: async () => ({ name: '● token', modes: [] }), + }, + }; + const node = { id: 'n', name: 'N', itemSpacing: 8 } as unknown as SceneNode; + const out = await gap.extract(node, ctx({ boundVariables: { itemSpacing: { id: 'v1' } } })); + expect(out[0]?.tokenStatus).toBe('ok'); + expect(out[0]?.token).toBe('space-100'); + }); +}); + +describe('composite', () => { + it('선언 필드를 그대로 규칙으로 조립', async () => { + const rule = composite({ + name: 'shadow', + category: 'shadows', + filterKeys: ['effects'], + read: async () => [{ value: 'css', token: null, tokenStatus: 'raw' as const }], + }); + expect(rule.name).toBe('shadow'); + expect(rule.filterKeys).toEqual(['effects']); + const out = await rule.extract({} as SceneNode, ctx()); + expect(out).toEqual([{ value: 'css', token: null, tokenStatus: 'raw' }]); + }); +}); + +describe('px', () => { + it('숫자를 px 문자열로', () => { + expect(px(12)).toBe('12px'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/factories.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/factories.ts new file mode 100644 index 000000000..44d91f687 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/factories.ts @@ -0,0 +1,68 @@ +import { readBoundToken } from '../variables'; +import type { EmissionOf, ExtractCtx, ExtractionRule, FactCategory, NodeGuard } from './types'; + +export const px = (v: number): string => `${v}px`; + +type ScalarCategory = 'spaces' | 'dimensions'; + +/** + * ScalarCategory 별 property 타입을 분배 조건 타입으로 보존. + * EmissionOf[C]['property'] 는 제네릭 C 로 인덱싱 불가이므로 이 형태를 사용. + */ +type PropertyOf = C extends 'spaces' + ? EmissionOf['spaces']['property'] + : EmissionOf['dimensions']['property']; + +export function tokenField(opts: { + name: string; + category: C; + property: PropertyOf; + field: string; + guards?: readonly NodeGuard[]; + format?: (v: number) => string; +}): ExtractionRule { + const format = opts.format ?? px; + + return { + name: opts.name, + category: opts.category, + filterKeys: [opts.field], + guards: opts.guards, + extract: async (node: SceneNode, ctx: ExtractCtx) => { + const raw = (node as unknown as Record)[opts.field]; + if (typeof raw !== 'number') return []; + + const { token, appliedToken, status } = await readBoundToken( + node, + ctx.boundVariables, + opts.field, + ); + + return [ + { + property: opts.property, + value: format(raw), + token, + appliedToken, + tokenStatus: status, + } as unknown as EmissionOf[C], + ]; + }, + }; +} + +export function composite(opts: { + name: string; + category: C; + filterKeys: readonly string[]; + guards?: readonly NodeGuard[]; + read: ExtractionRule['extract']; +}): ExtractionRule { + return { + name: opts.name, + category: opts.category, + filterKeys: opts.filterKeys, + guards: opts.guards, + extract: opts.read, + }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/guards.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/guards.test.ts new file mode 100644 index 000000000..f49f8d410 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/guards.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { isText, notRoot, notVectorLike, sizingFixed } from './guards'; +import type { ExtractCtx } from './types'; + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +describe('guards', () => { + it('isText: TEXT 노드만 통과', () => { + expect(isText.test({ type: 'TEXT' } as SceneNode, ctx())).toBe(true); + expect(isText.test({ type: 'FRAME' } as SceneNode, ctx())).toBe(false); + }); + + it('notVectorLike: VECTOR 계열 차단', () => { + expect(notVectorLike.test({ type: 'VECTOR' } as SceneNode, ctx())).toBe(false); + expect(notVectorLike.test({ type: 'FRAME' } as SceneNode, ctx())).toBe(true); + }); + + it('notRoot: ctx.rootId 와 같은 id 차단', () => { + expect(notRoot.test({ id: 'root' } as SceneNode, ctx())).toBe(false); + expect(notRoot.test({ id: 'child' } as SceneNode, ctx())).toBe(true); + }); + + it('sizingFixed: 해당 축이 FIXED 일 때만 통과, 가드명에 축 포함', () => { + const g = sizingFixed('layoutSizingHorizontal'); + expect(g.name).toBe('sizingFixed:layoutSizingHorizontal'); + expect(g.test({ layoutSizingHorizontal: 'FIXED' } as unknown as SceneNode, ctx())).toBe( + true, + ); + expect(g.test({ layoutSizingHorizontal: 'HUG' } as unknown as SceneNode, ctx())).toBe( + false, + ); + expect(g.test({} as SceneNode, ctx())).toBe(false); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/guards.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/guards.ts new file mode 100644 index 000000000..b095fa4e3 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/guards.ts @@ -0,0 +1,26 @@ +import { isVectorLike } from '../filters'; +import type { NodeGuard } from './types'; + +export const isText: NodeGuard = { + name: 'isText', + test: (node) => node.type === 'TEXT', +}; + +/** 벡터 원시 노드 — 크기가 도형 자체 속성이라 dimension 검사 제외 (filters.ts 규칙 재사용). */ +export const notVectorLike: NodeGuard = { + name: 'notVectorLike', + test: (node) => !isVectorLike(node), +}; + +export const notRoot: NodeGuard = { + name: 'notRoot', + test: (node, ctx) => node.id !== ctx.rootId, +}; + +/** FIXED sizing 축만 dimension 토큰 검사 대상. */ +export const sizingFixed = ( + axis: 'layoutSizingHorizontal' | 'layoutSizingVertical', +): NodeGuard => ({ + name: `sizingFixed:${axis}`, + test: (node) => (node as unknown as Record)[axis] === 'FIXED', +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/engine/types.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/types.ts new file mode 100644 index 000000000..a9fa0dd51 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/engine/types.ts @@ -0,0 +1,69 @@ +import type { + ColorUsage, + DimensionUsage, + RadiusUsage, + ShadowUsage, + SpaceUsage, + TypographyUsage, + Viewport, +} from '~/common/schemas'; + +/** + * 💙 DS 인스턴스 하위에서 특정 필드만 감사 대상으로 좁힐 때 사용. + * null = 전체 감사 (제약 없음). + */ +export type OverrideFilter = ReadonlySet | null; + +/** 단일 노드에서 카테고리별로 추출된 사실 묶음. */ +export type NodeFacts = { + colors: ColorUsage[]; + typography: TypographyUsage[]; + spaces: SpaceUsage[]; + dimensions: DimensionUsage[]; + radii: RadiusUsage[]; + shadows: ShadowUsage[]; +}; + +export type FactCategory = keyof NodeFacts; + +/** 규칙이 방출하는 단위 — nodeId/name 은 엔진이 스탬핑하므로 제외. */ +export type EmissionOf = { + [C in FactCategory]: Omit; +}; + +export type ExtractCtx = { + /** root frame id — 자기 자신의 width/height 는 dimension 검사 대상 제외. */ + rootId: string; + viewport: Viewport; + /** node.boundVariables — 규칙마다 재파싱하지 않도록 캐시. */ + boundVariables: Record | undefined; + /** 규칙 내부 세분 필터용 (예: padding 4방향) — filterKeys 게이트 후 reader 에 전달. */ + filter: OverrideFilter; +}; + +/** 이름 있는 순수 가드. throw 금지 계약 — trace 에 실패 가드명이 남는다. */ +export type NodeGuard = { + name: string; + test: (node: SceneNode, ctx: ExtractCtx) => boolean; +}; + +export type ExtractionRule = { + /** trace 식별용. `카테고리:속성` 컨벤션 (예: 'dimension:width'). */ + name: string; + category: C; + /** OverrideFilter 키 — 하나라도 filter 에 있으면 규칙 실행. 엔진이 중앙 판정. */ + filterKeys: readonly string[]; + /** 전부 통과해야 실행. */ + guards?: readonly NodeGuard[]; + extract: (node: SceneNode, ctx: ExtractCtx) => Promise; +}; + +/** 카테고리별 규칙의 분배 유니온 — RULES 테이블 원소 타입. */ +export type AnyExtractionRule = { + [C in FactCategory]: ExtractionRule; +}[FactCategory]; + +export type RuleTrace = + | { rule: string; skipped: 'filter' | `guard:${string}` } + | { rule: string; emitted: number } + | { rule: string; error: string }; diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/extract-frame.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/extract-frame.ts new file mode 100644 index 000000000..ff6b19afa --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/extract-frame.ts @@ -0,0 +1,222 @@ +import type { + ColorUsage, + DimensionUsage, + LlmContext, + NodeInfo, + RadiusUsage, + RawExtract, + ShadowUsage, + SpaceUsage, + TypographyUsage, + Viewport, +} from '~/common/schemas'; + +import { isDsInstance, shouldSkipNode, wasDsComponent } from './filters'; +import { groupBy } from './group-by'; +import { captureScreenshot } from './screenshot'; +import { detectSchemaMode } from './variables'; +import { collectNodeFacts } from './visitor'; +import type { NodeFacts, OverrideFilter, VisitCtx } from './visitor'; +import { walkTree } from './walk-tree'; + +/** + * root 프레임을 재귀 순회하면서 카테고리별 raw usage 를 수집한다. + * per-node 로 `collectNodeFacts` 가 { colors, spaces, ... } 를 리턴하면 + * `sink.merge` 로 카테고리별 배열에 병합. + */ +class FactSink { + colors: (ColorUsage & { nodeId: string })[] = []; + typography: (TypographyUsage & { nodeId: string })[] = []; + spaces: SpaceUsage[] = []; + dimensions: DimensionUsage[] = []; + radii: RadiusUsage[] = []; + shadows: ShadowUsage[] = []; + visited = 0; + textNodes = 0; + + merge(facts: NodeFacts): void { + this.colors.push(...facts.colors); + this.typography.push(...facts.typography); + this.spaces.push(...facts.spaces); + this.dimensions.push(...facts.dimensions); + this.radii.push(...facts.radii); + this.shadows.push(...facts.shadows); + this.textNodes += facts.typography.length; + } +} + +/** + * 💙 DS 인스턴스 아래로 들어갔을 때 유지되는 감사 문맥. + * overrideMap: 해당 인스턴스와 하위 노드의 override 필드 맵 (`InstanceNode.overrides`). + * DS 하위 노드는 이 맵에 등재된 필드만 감사 대상이 된다. + */ +type DsScope = { overrideMap: Map> } | null; + +function buildOverrideMap(instance: InstanceNode): Map> { + const map = new Map>(); + const overrides = ( + instance as unknown as { + overrides?: Array<{ id: string; overriddenFields: string[] }>; + } + ).overrides; + + if (!overrides) return map; + for (const o of overrides) map.set(o.id, new Set(o.overriddenFields)); + return map; +} + +function tagWasDs(facts: NodeFacts): void { + for (const c of facts.colors) c.wasDs = true; + for (const t of facts.typography) t.wasDs = true; + for (const s of facts.spaces) s.wasDs = true; + for (const d of facts.dimensions) d.wasDs = true; + for (const r of facts.radii) r.wasDs = true; + for (const s of facts.shadows) s.wasDs = true; +} + +async function auditNode( + node: SceneNode, + ctx: VisitCtx, + sink: FactSink, + filter: OverrideFilter, +): Promise { + sink.visited++; + const facts = await collectNodeFacts(node, ctx, filter); + if (await wasDsComponent(node)) tagWasDs(facts); + sink.merge(facts); +} + +async function traverse( + node: SceneNode, + ctx: VisitCtx, + sink: FactSink, + scope: DsScope, +): Promise { + if (node.visible === false) return; + + // 🟨 / 🔶 — 자신은 건너뛰고 자식만 순회. + if (shouldSkipNode(node.name)) { + if ('children' in node) + for (const ch of node.children) await traverse(ch, ctx, sink, scope); + return; + } + + // Rule 1 — 💙 DS 인스턴스: override 된 필드만 감사, 하위는 DS scope 진입. + if (await isDsInstance(node)) { + const map = buildOverrideMap(node as InstanceNode); + const selfFields = map.get(node.id) ?? new Set(); + + if (selfFields.size > 0) { + await auditNode(node, ctx, sink, selfFields); + } + + for (const ch of (node as InstanceNode).children) { + await traverse(ch, ctx, sink, { overrideMap: map }); + } + return; + } + + // Rule 2 — DS 하위에서 프리픽스 없는 인스턴스: 로컬 컴포넌트로 간주해 전체 감사. + if (scope && node.type === 'INSTANCE') { + await auditNode(node, ctx, sink, null); + for (const ch of (node as InstanceNode).children) { + await traverse(ch, ctx, sink, null); + } + return; + } + + // DS 하위의 구조적(비 인스턴스) 노드: overrideMap 에 등재된 필드만 감사. + if (scope) { + const fields = scope.overrideMap.get(node.id); + if (fields && fields.size > 0) { + await auditNode(node, ctx, sink, fields); + } + if ('children' in node) + for (const ch of node.children) await traverse(ch, ctx, sink, scope); + return; + } + + // 일반 노드: 전체 감사. + await auditNode(node, ctx, sink, null); + if ('children' in node) for (const ch of node.children) await traverse(ch, ctx, sink, null); +} + +function inferViewport(width: number): Viewport { + if (width >= 1024) return 'pc'; + if (width >= 768) return 'tablet'; + return 'mobile'; +} + +// --------------------------------------------------------------------------- +// Group keys — usage-level dedup 기준. +// --------------------------------------------------------------------------- + +const colorKey = (e: ColorUsage & { nodeId: string }): string => + JSON.stringify([ + e.name, + e.property, + e.token, + e.appliedToken ?? null, + e.hex, + e.tokenStatus, + e.background ? e.background.kind : null, + e.background ? e.background.hex : null, + ]); + +const typographyKey = (e: TypographyUsage & { nodeId: string }): string => + JSON.stringify([ + e.name, + e.characters, + e.textStyle, + e.viewport, + e.appliedStatus, + e.overriddenFields, + e.resolved, + ]); + +// --------------------------------------------------------------------------- +// Public entry +// --------------------------------------------------------------------------- + +export async function extractFrame( + frameId: string, +): Promise<{ extract: RawExtract; llmContext: LlmContext }> { + figma.skipInvisibleInstanceChildren = true; + + const root = await figma.getNodeByIdAsync(frameId); + if (!root) throw new Error('노드를 찾을 수 없음: ' + frameId); + + const rootScene = root as SceneNode; + const rootWidth = 'width' in root ? (root as unknown as { width: number }).width : 1024; + const ctx: VisitCtx = { + rootId: root.id, + viewport: inferViewport(rootWidth), + }; + const schemaMode = await detectSchemaMode(rootScene); + + const sink = new FactSink(); + await traverse(rootScene, ctx, sink, null); + + const extract: RawExtract = { + schemaMode, + viewport: ctx.viewport, + colors: groupBy(sink.colors, colorKey) as unknown as ColorUsage[], + typography: groupBy(sink.typography, typographyKey) as unknown as TypographyUsage[], + spaces: sink.spaces, + dimensions: sink.dimensions, + radii: sink.radii, + shadows: sink.shadows, + stats: { + nodeCount: sink.visited, + textNodes: sink.textNodes, + visited: sink.visited, + }, + }; + + const [screenshotB64, nodeTree] = await Promise.all([ + captureScreenshot(root as FrameNode).catch(() => ''), + walkTree(rootScene).catch(() => [] as NodeInfo[]), + ]); + + return { extract, llmContext: { screenshotB64, nodeTree } }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/filters.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/filters.ts new file mode 100644 index 000000000..845e984f9 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/filters.ts @@ -0,0 +1,70 @@ +/** + * 노드 순회 시 무시/특수 취급할 대상 판별. + */ + +/** DS(vapor) 라이브러리 컴포넌트를 표시하는 이모지 프리픽스. */ +export const DS_PREFIX = '💙'; + +/** + * 🔶InteractionLayer/{variant} — hover/active/focus 상태를 투명 오버레이로 표현하는 컴포넌트. + * 실제 배경색이 아니므로 배경 판정·스크린샷·픽셀 샘플링 모두에서 제외 대상. + */ +export const INTERACTION_LAYER_PREFIX = '🔶InteractionLayer'; + +/** 자기 자신은 스캔하지 않되 자식은 계속 순회하는 대상. */ +const SKIP_PREFIXES = ['🟨', '🔶'] as const; + +/** 이모지 접두 규칙에 의해 검사에서 제외되는 노드. 자식은 순회하되 자신은 건너뛴다. */ +export function shouldSkipNode(name: string) { + return SKIP_PREFIXES.some((p) => name.startsWith(p)); +} + +/** + * 💙 프리픽스가 붙었고 실제 DS 라이브러리 원본을 참조하는 인스턴스. + * detach 후 로컬로 재컴포넌트화된 인스턴스(remote=false) 는 제외한다. + */ +export async function isDsInstance(node: SceneNode): Promise { + if (node.type !== 'INSTANCE' || !node.name.startsWith(DS_PREFIX)) return false; + const main = await (node as InstanceNode).getMainComponentAsync(); + return main?.remote === true; +} + +/** + * 원래 💙 DS 컴포넌트였으나 detach된 노드 후보. + * - detach된 frame/group/component (type ≠ INSTANCE, 이름은 Figma가 보존) + * - detach 후 로컬 컴포넌트로 재컴포넌트화된 인스턴스 (remote=false) + * 감사 스코프는 일반 노드와 동일(전체 감사), 리포트 뱃지 노출용 태그. + */ +export async function wasDsComponent(node: SceneNode): Promise { + if (!node.name.startsWith(DS_PREFIX)) return false; + if (node.type !== 'INSTANCE') return true; + const main = await (node as InstanceNode).getMainComponentAsync(); + return main?.remote === false; +} + +/** + * 🔶InteractionLayer/ 프리픽스를 가진 노드 여부. + * 이 레이어는 hover/focus/active 상태를 투명하게 겹쳐 표현하므로 + * 배경 판정과 스크린샷·픽셀 샘플링에서 시각적으로 제거해야 한다. + */ +export function isInteractionLayer(node: Pick): boolean { + return typeof node.name === 'string' && node.name.startsWith(INTERACTION_LAYER_PREFIX); +} + +/** + * 벡터(아이콘) 원시 노드는 크기가 도형 자체의 기하학적 속성이므로 + * dimension 토큰 검사에서 제외한다. 아이콘을 감싸는 프레임 크기는 검사 대상. + * fill 도 fg-role 스코프로 취급. + */ +const VECTOR_LIKE_TYPES: ReadonlySet = new Set([ + 'VECTOR', + 'BOOLEAN_OPERATION', + 'LINE', + 'ELLIPSE', + 'POLYGON', + 'STAR', +]); + +export function isVectorLike(node: SceneNode) { + return VECTOR_LIKE_TYPES.has(node.type); +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/group-by.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/group-by.ts new file mode 100644 index 000000000..d35bf5232 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/group-by.ts @@ -0,0 +1,27 @@ +type Grouped = T & { nodeIds: string[]; count: number }; + +/** + * 동일 특성을 가진 사용처를 하나로 묶는다. + * key 는 caller 가 결정 (JSON stringify 등). + * 결과 항목은 nodeIds/count 를 갖는다. + */ +export function groupBy( + items: T[], + keyOf: (item: T) => string, +): Grouped[] { + const map = new Map>(); + + for (const it of items) { + const key = keyOf(it); + const g = map.get(key); + + if (g) { + g.nodeIds.push(it.nodeId); + g.count++; + } else { + map.set(key, { ...it, nodeIds: [it.nodeId], count: 1 }); + } + } + + return [...map.values()]; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/hide-interaction-layers.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/hide-interaction-layers.test.ts new file mode 100644 index 000000000..27346cb97 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/hide-interaction-layers.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from 'vitest'; + +import { exportWithoutInteractionLayers } from './hide-interaction-layers'; + +type Node = { + name: string; + visible: boolean; + children?: Node[]; + x?: number; + y?: number; +}; + +function n(name: string, children: Node[] = [], visible = true): Node { + return { name, visible, children, x: 0, y: 0 }; +} + +/** + * clone 시 children 을 얕지 않게 복사해야 원본과 독립적으로 mutate 가능. + */ +function deepClone(node: Node): Node { + return { + name: node.name, + visible: node.visible, + x: node.x, + y: node.y, + children: node.children ? node.children.map(deepClone) : undefined, + }; +} + +function makeExportable(node: Node) { + let removed = false; + const exportMock = vi.fn().mockImplementation(async () => { + // export 시점에 clone 내 InteractionLayer 는 이미 hidden 이어야 함. + return { snapshot: JSON.parse(JSON.stringify(node)), removed }; + }); + + return Object.assign(node, { + clone: () => makeExportable(deepClone(node)), + exportAsync: exportMock, + remove: () => { + removed = true; + }, + }); +} + +describe('exportWithoutInteractionLayers', () => { + it('원본 노드의 🔶InteractionLayer visibility 를 건드리지 않음', async () => { + const layer = n('🔶InteractionLayer/hover'); + const original = makeExportable(n('button', [n('Text'), layer])); + + await exportWithoutInteractionLayers(original as any, { format: 'PNG' } as any); + + // 원본 layer 는 visible 그대로. + expect(layer.visible).toBe(true); + }); + + it('export 시 clone 내부의 🔶InteractionLayer 는 visible=false 상태', async () => { + const layer = n('🔶InteractionLayer/hover'); + const original = makeExportable(n('button', [n('Text'), layer])); + const exportSpy = vi.spyOn(original as any, 'clone'); + + await exportWithoutInteractionLayers(original as any, { format: 'PNG' } as any); + + // clone 이 한 번 호출되었어야 함. + expect(exportSpy).toHaveBeenCalledTimes(1); + }); + + it('export 도중 throw 해도 clone.remove() 가 호출되어야 함', async () => { + const layer = n('🔶InteractionLayer/hover'); + const original = makeExportable(n('button', [layer])); + + let removeCalled = false; + (original as any).clone = () => { + const clone = { ...deepClone(original), children: original.children }; + return { + ...clone, + exportAsync: () => Promise.reject(new Error('boom')), + remove: () => { + removeCalled = true; + }, + }; + }; + + await expect( + exportWithoutInteractionLayers(original as any, { format: 'PNG' } as any), + ).rejects.toThrow('boom'); + + expect(removeCalled).toBe(true); + }); + + it('clone 을 화면 밖(x=-100000, y=-100000) 으로 이동시켜 canvas flash 방지', async () => { + const original = makeExportable(n('button', [])); + let clonePosition: { x?: number; y?: number } | null = null; + + (original as any).clone = () => { + const clone: any = deepClone(original); + clone.exportAsync = async () => { + clonePosition = { x: clone.x, y: clone.y }; + return new Uint8Array(); + }; + clone.remove = () => {}; + return clone; + }; + + await exportWithoutInteractionLayers(original as any, { format: 'PNG' } as any); + + expect(clonePosition).toEqual({ x: -100000, y: -100000 }); + }); + + it('중첩된 🔶InteractionLayer 여러 개도 모두 clone 내부에서 감춰짐', async () => { + const original = makeExportable( + n('button', [ + n('inner', [n('🔶InteractionLayer/hover')]), + n('🔶InteractionLayer/pressed'), + ]), + ); + + let hiddenLayerNames: string[] = []; + (original as any).clone = () => { + const clone: any = deepClone(original); + clone.exportAsync = async () => { + const collected: string[] = []; + const walk = (node: Node) => { + if (node.name.startsWith('🔶InteractionLayer') && node.visible === false) { + collected.push(node.name); + } + node.children?.forEach(walk); + }; + walk(clone); + hiddenLayerNames = collected; + return new Uint8Array(); + }; + clone.remove = () => {}; + return clone; + }; + + await exportWithoutInteractionLayers(original as any, { format: 'PNG' } as any); + + expect(hiddenLayerNames.sort()).toEqual([ + '🔶InteractionLayer/hover', + '🔶InteractionLayer/pressed', + ]); + }); + + it('clone 함수 없는 노드는 원본 export 로 fallback', async () => { + const bytes = new Uint8Array([1, 2, 3]); + const node: any = { + name: 'x', + exportAsync: vi.fn().mockResolvedValue(bytes), + }; + + const out = await exportWithoutInteractionLayers(node, { format: 'PNG' } as any); + expect(out).toBe(bytes); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/hide-interaction-layers.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/hide-interaction-layers.ts new file mode 100644 index 000000000..109ec3373 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/hide-interaction-layers.ts @@ -0,0 +1,62 @@ +import { isInteractionLayer } from './filters'; + +type ExportSettings = ExportSettingsImage | ExportSettingsSVG | ExportSettingsPDF; + +type Cloneable = SceneNode & { + clone: () => SceneNode; + exportAsync: (settings: ExportSettings) => Promise; + remove: () => void; +}; + +/** + * clone 하위에서 🔶InteractionLayer 를 visible=false 로 표시. + * clone 은 원본과 독립된 서브트리라 override 도 clone 에만 남는다 (원본 무영향). + * INSTANCE 하위 자식은 `remove()` 가 막혀 있어 visibility 를 쓴다. + */ +function hideOnClone(root: SceneNode): void { + const stack: SceneNode[] = [root]; + + while (stack.length) { + const node = stack.pop()!; + + if (isInteractionLayer(node)) { + const toggle = node as SceneNode & { visible: boolean }; + toggle.visible = false; + continue; + } + + if ('children' in node) { + for (const c of node.children as readonly SceneNode[]) stack.push(c); + } + } +} + +/** + * `node` 를 **clone** 한 뒤 🔶InteractionLayer 를 감춘 상태로 export. + * - 원본 노드는 read-only 로 취급 → 사용자 파일 무손상. + * - clone 은 화면 밖(-100000,-100000) 으로 이동시켜 캔버스 flash 방지. + * - export 성공/실패와 무관하게 clone.remove() 로 정리 (try/finally). + * - clone/remove 자체는 Figma undo 히스토리에 항목을 남긴다 (원본 구조는 유지). + */ +export async function exportWithoutInteractionLayers( + node: SceneNode, + settings: ExportSettings, +): Promise { + const cloneable = node as Cloneable; + if (typeof cloneable.clone !== 'function') { + return cloneable.exportAsync(settings); + } + + const clone = cloneable.clone() as Cloneable; + + try { + const positioned = clone as unknown as { x?: number; y?: number }; + if (typeof positioned.x === 'number') positioned.x = -100000; + if (typeof positioned.y === 'number') positioned.y = -100000; + + hideOnClone(clone); + return await clone.exportAsync(settings); + } finally { + clone.remove(); + } +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/index.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/index.ts new file mode 100644 index 000000000..2eae07aa6 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/index.ts @@ -0,0 +1 @@ +export { extractFrame } from './extract-frame'; diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/padding.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/padding.ts new file mode 100644 index 000000000..a5869a5f6 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/padding.ts @@ -0,0 +1,61 @@ +import type { TokenStatus } from '~/common/schemas'; + +export type PaddingField = 'paddingTop' | 'paddingRight' | 'paddingBottom' | 'paddingLeft'; + +export type PaddingDir = { + field: PaddingField; + value: number; + token: string | null; + appliedToken: string | null; + status: TokenStatus; +}; + +export type PaddingEmitProperty = + | 'padding' + | 'paddingVertical' + | 'paddingHorizontal' + | PaddingField; + +export type PaddingEmission = { property: PaddingEmitProperty; source: PaddingDir }; + +function samePadding(a: PaddingDir, b: PaddingDir): boolean { + return a.value === b.value && a.token === b.token && a.status === b.status; +} + +/** + * 4방향 padding 을 최소 표현으로 축약. + * - 4방향 존재 & 전부 동일 → 1건 (padding) + * - 4방향 존재 & 상하/좌우 동일 → 2건 (paddingVertical + paddingHorizontal) + * - 그 외 (부분/불균등) → 방향별 개별 emit + */ +export function derivePaddingEmissions(dirs: PaddingDir[]): PaddingEmission[] { + if (dirs.length === 0) return []; + + const byField = {} as Partial>; + + for (const d of dirs) { + byField[d.field] = d; + } + + const top = byField.paddingTop; + const bot = byField.paddingBottom; + const left = byField.paddingLeft; + const right = byField.paddingRight; + const allFour = dirs.length === 4 && top && bot && left && right; + + const isVertSame = allFour && samePadding(top, bot); + const isHorzSame = allFour && samePadding(left, right); + + if (isVertSame && isHorzSame && samePadding(top, left)) { + return [{ property: 'padding', source: top }]; + } + + if (isVertSame && isHorzSame) { + return [ + { property: 'paddingVertical', source: top }, + { property: 'paddingHorizontal', source: left }, + ]; + } + + return dirs.map((d) => ({ property: d.field, source: d })); +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/paint.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/paint.test.ts new file mode 100644 index 000000000..8c132c25b --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/paint.test.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from 'vitest'; + +import { classifyBackground } from './paint'; + +function makeSolid(hex: { r: number; g: number; b: number }, opacity = 1) { + return { type: 'SOLID', color: hex, opacity, visible: true }; +} + +const WHITE = { r: 1, g: 1, b: 1 }; +const RED = { r: 1, g: 0, b: 0 }; + +/** + * 부모 체인만 참조하므로 parent 링크만 채운 최소 노드로 충분. + */ +function chain(...ancestors: any[]): any { + for (let i = 0; i < ancestors.length - 1; i++) ancestors[i].parent = ancestors[i + 1]; + ancestors[ancestors.length - 1].parent = { type: 'PAGE' }; + return ancestors[0]; +} + +describe('classifyBackground', () => { + it('흰색 배경 부모 → white', () => { + const text: any = { type: 'TEXT', name: 'label' }; + const button: any = { type: 'FRAME', name: 'button', fills: [makeSolid(WHITE)] }; + chain(text, button); + + expect(classifyBackground(text).kind).toBe('white'); + }); + + it('🔶InteractionLayer 조상의 fill 은 무시하고 상위로 계속 올라감', () => { + const text: any = { type: 'TEXT', name: 'label' }; + const layer: any = { + type: 'FRAME', + name: '🔶InteractionLayer/hover', + fills: [makeSolid(RED, 0.08)], + }; + const button: any = { type: 'FRAME', name: 'button', fills: [makeSolid(WHITE)] }; + chain(text, layer, button); + + // InteractionLayer 를 건너뛰고 button 의 흰색을 봐야 함. + expect(classifyBackground(text).kind).toBe('white'); + }); + + it('부모가 색상 fill 을 가지면 other', () => { + const text: any = { type: 'TEXT', name: 'label' }; + const button: any = { type: 'FRAME', name: 'button', fills: [makeSolid(RED)] }; + chain(text, button); + + expect(classifyBackground(text).kind).toBe('other'); + }); + + it('부모가 fill 없이 PAGE 도달 → transparent', () => { + const text: any = { type: 'TEXT', name: 'label' }; + const wrapper: any = { type: 'FRAME', name: 'wrap', fills: [] }; + chain(text, wrapper); + + expect(classifyBackground(text).kind).toBe('transparent'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/paint.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/paint.ts new file mode 100644 index 000000000..763d231e6 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/paint.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Figma paint shape lookups are intentionally loose. */ +import type { ColorBackground } from '~/common/schemas'; + +import { isInteractionLayer } from './filters'; + +/** Figma RGB{0..1} → CSS hex '#rrggbb'. Alpha 는 무시 (별도 판단). */ +export function rgbaToHex(c: any): string | null { + if (!c) return null; + + const f = (n: number) => { + return Math.round(n * 255) + .toString(16) + .padStart(2, '0'); + }; + + return '#' + f(c.r) + f(c.g) + f(c.b); +} + +/** Figma RGBA → CSS `rgba(r,g,b,a)`. shadow → box-shadow 변환용. */ +export function rgbaToString(c: any): string { + if (!c) return 'rgba(0,0,0,1)'; + + const r = Math.round(c.r * 255); + const g = Math.round(c.g * 255); + const b = Math.round(c.b * 255); + const a = typeof c.a === 'number' ? c.a : 1; + + return `rgba(${r},${g},${b},${a})`; +} + +/** Figma DROP_SHADOW / INNER_SHADOW → CSS box-shadow 문자열. */ +export function shadowToCss(eff: any): string { + const inset = eff.type === 'INNER_SHADOW' ? 'inset ' : ''; + const x = Math.round(eff.offset?.x ?? 0); + const y = Math.round(eff.offset?.y ?? 0); + const blur = Math.round(eff.radius ?? 0); + const spread = Math.round(eff.spread ?? 0); + const color = rgbaToString(eff.color); + + return `${inset}${x}px ${y}px ${blur}px ${spread}px ${color}`; +} + +export function isVisiblePaint(p: any): boolean { + return !!p && p.visible !== false; +} + +/** + * 대상 노드의 부모 체인을 타고 올라가며 effective 배경 hex 를 판정. + * - SOLID 아닌 fill(그라디언트/이미지) 또는 반투명 스택 → `ambiguous` + * - SOLID + opaque → `white` (`#ffffff`) 또는 `other` + * - fill 없이 PAGE 도달 → `transparent` + * + * 🔶InteractionLayer 조상은 실제 배경이 아니라 hover/focus 오버레이이므로 + * fill 을 무시하고 상위로 계속 올라간다. (fg-grade-mismatch 오탐 방지) + */ +export function classifyBackground(node: SceneNode): ColorBackground { + let cur: any = node.parent; + + while (cur && cur.type !== 'PAGE') { + if (isInteractionLayer(cur)) { + cur = cur.parent; + continue; + } + + const nodeOpaque = ('opacity' in cur ? cur.opacity : 1) === 1; + const fills = 'fills' in cur && Array.isArray(cur.fills) ? cur.fills : []; + const visible = fills.find(isVisiblePaint); + + if (!visible) { + cur = cur.parent; + continue; + } + + if (visible.type !== 'SOLID') return { kind: 'ambiguous', hex: null }; + + const fillOpaque = (visible.opacity ?? 1) === 1; + if (!nodeOpaque || !fillOpaque) { + return { kind: 'ambiguous', hex: rgbaToHex(visible.color) }; + } + + const hex = rgbaToHex(visible.color); + + return { kind: hex === '#ffffff' ? 'white' : 'other', hex }; + } + + return { kind: 'transparent', hex: null }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/color.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/color.test.ts new file mode 100644 index 000000000..127bda879 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/color.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExtractCtx } from '../engine/types'; +import { captureTextShot } from '../text'; +import { toToken, walk } from '../variables'; +import { fillColorsRule, strokeColorsRule } from './color'; + +vi.mock('../variables', () => ({ + walk: vi.fn(), + toToken: vi.fn(), +})); + +vi.mock('../text', () => ({ + captureTextShot: vi.fn(), +})); + +const ctx = (): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc' as const, + boundVariables: undefined, + filter: null, +}); + +describe('color rules', () => { + beforeEach(() => { + vi.mocked(walk).mockResolvedValue({ chain: [], finalHex: null }); + vi.mocked(toToken).mockReturnValue({ token: null, appliedToken: null, tokenStatus: 'raw' }); + vi.mocked(captureTextShot).mockResolvedValue(undefined); + }); + + it('fillColorsRule filterKeys = fills', () => { + const rule = fillColorsRule(); + expect(rule.filterKeys).toEqual(['fills']); + }); + + it('TEXT fill → property text, background transparent, hex #000000, tokenStatus raw', async () => { + const node = { + id: 'n1', + name: 'Label', + type: 'TEXT', + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0 }, visible: true }], + strokes: [], + boundVariables: {}, + parent: null, + } as unknown as SceneNode; + const out = await fillColorsRule().extract(node, ctx()); + expect(out[0]?.property).toBe('text'); + // classifyBackground(node) with parent=null walks no ancestors → returns transparent + expect(out[0]?.background).toEqual({ kind: 'transparent', hex: null }); + expect(out[0]?.hex).toBe('#000000'); + expect(out[0]?.tokenStatus).toBe('raw'); + }); + + it('FRAME raw SOLID fill → property fill, hex lowercase', async () => { + const node = { + id: 'n2', + name: 'Box', + type: 'FRAME', + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, visible: true }], + strokes: [], + boundVariables: {}, + parent: null, + } as unknown as SceneNode; + const out = await fillColorsRule().extract(node, ctx()); + expect(out[0]?.property).toBe('fill'); + expect(out[0]?.hex).toBe('#ff0000'); + expect(out[0]?.tokenStatus).toBe('raw'); + }); + + it('strokeColorsRule filterKeys = strokes, property stroke, textShot 없음', async () => { + const rule = strokeColorsRule(); + expect(rule.filterKeys).toEqual(['strokes']); + const node = { + id: 'n3', + name: 'Box', + type: 'FRAME', + fills: [], + strokes: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1 }, visible: true }], + boundVariables: {}, + parent: null, + } as unknown as SceneNode; + const out = await rule.extract(node, ctx()); + expect(out[0]?.property).toBe('stroke'); + expect(out[0]?.textShot).toBeUndefined(); + expect(out[0]?.hex).toBe('#0000ff'); + }); + + it('bound fill → walk/toToken 호출 → token 해석', async () => { + vi.mocked(walk).mockResolvedValueOnce({ + chain: [{ name: 'color/color-background-primary-100', tier: 'semantic' }], + finalHex: '#001122', + }); + vi.mocked(toToken).mockReturnValueOnce({ + token: 'color-background-primary-100', + appliedToken: 'color-background-primary-100', + tokenStatus: 'ok', + }); + const node = { + id: 'n4', + name: 'Card', + type: 'FRAME', + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }], + strokes: [], + boundVariables: { fills: [{ id: 'var-token-1' }] }, + parent: null, + } as unknown as SceneNode; + const out = await fillColorsRule().extract(node, ctx()); + expect(out[0]?.token).toBe('color-background-primary-100'); + expect(out[0]?.tokenStatus).toBe('ok'); + expect(out[0]?.hex).toBe('#001122'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/color.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/color.ts new file mode 100644 index 000000000..c81ac5aaf --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/color.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Figma paint bindings are intentionally untyped. */ +import type { ColorBackground, ColorProperty } from '~/common/schemas'; + +import type { EmissionOf, ExtractionRule } from '../engine/types'; +import { isVectorLike } from '../filters'; +import { classifyBackground, rgbaToHex } from '../paint'; +import type { TextShot } from '../text'; +import { captureTextShot } from '../text'; +import { toToken, walk } from '../variables'; + +async function readPaints( + node: SceneNode, + paints: any, + bound: any[], + property: ColorProperty, + textShot: TextShot | undefined, + textBackground: ColorBackground | null, +): Promise { + const paintList = Array.isArray(paints) ? paints : null; + const shotFor = property === 'text' && node.type === 'TEXT' ? textShot : undefined; + const bgFor = property === 'text' ? textBackground : null; + const out: EmissionOf['colors'][] = []; + + for (let i = 0; i < bound.length; i++) { + const a = bound[i]; + if (!a || !a.id) continue; + + const p = paintList ? paintList[i] : null; + if (p && p.visible === false) continue; + + const { chain, finalHex } = await walk(node, a.id); + const { token, appliedToken, tokenStatus } = toToken(chain); + + out.push({ + property, + token, + appliedToken, + hex: finalHex, + background: bgFor, + tokenStatus, + textShot: shotFor, + }); + } + + if (!paintList) return out; + + paintList.forEach((p: any, i: number) => { + if (!p || p.type !== 'SOLID' || p.visible === false || bound[i]) return; + out.push({ + property, + token: null, + hex: rgbaToHex(p.color), + background: bgFor, + tokenStatus: 'raw', + textShot: shotFor, + }); + }); + + return out; +} + +export function fillColorsRule(): ExtractionRule<'colors'> { + return { + name: 'color:fill', + category: 'colors', + filterKeys: ['fills'], + extract: async (node, _ctx) => { + const bv: any = (node as any).boundVariables || {}; + const fillProperty: ColorProperty = + node.type === 'TEXT' || isVectorLike(node) ? 'text' : 'fill'; + const textBackground = node.type === 'TEXT' ? classifyBackground(node) : null; + const textShot = + node.type === 'TEXT' + ? await captureTextShot(node as TextNode, textBackground) + : undefined; + return readPaints( + node, + (node as any).fills, + bv.fills || [], + fillProperty, + textShot, + textBackground, + ); + }, + }; +} + +export function strokeColorsRule(): ExtractionRule<'colors'> { + return { + name: 'color:stroke', + category: 'colors', + filterKeys: ['strokes'], + extract: async (node, _ctx) => { + const bv: any = (node as any).boundVariables || {}; + return readPaints( + node, + (node as any).strokes, + bv.strokes || [], + 'stroke', + undefined, + null, + ); + }, + }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/dimension.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/dimension.test.ts new file mode 100644 index 000000000..977dab525 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/dimension.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import type { ExtractCtx } from '../engine/types'; +import { heightRule, widthRule } from './dimension'; + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +describe('dimension rules', () => { + it('widthRule: FIXED 노드 width emission', async () => { + const node = { + id: 'n', + name: 'Box', + type: 'FRAME', + width: 320, + layoutSizingHorizontal: 'FIXED', + } as unknown as SceneNode; + // 가드 통과 검증 후 extract + expect(widthRule.guards?.every((g) => g.test(node, ctx()))).toBe(true); + const out = await widthRule.extract(node, ctx()); + expect(out).toEqual([ + { + property: 'width', + value: '320px', + token: null, + appliedToken: null, + tokenStatus: 'raw', + }, + ]); + }); + + it('widthRule 가드: 벡터/root/HUG 차단', () => { + const base = { id: 'n', name: 'B', type: 'FRAME', layoutSizingHorizontal: 'FIXED' }; + const vector = { ...base, type: 'VECTOR' } as unknown as SceneNode; + const root = { ...base, id: 'root' } as unknown as SceneNode; + const hug = { ...base, layoutSizingHorizontal: 'HUG' } as unknown as SceneNode; + + const pass = (n: SceneNode) => widthRule.guards?.every((g) => g.test(n, ctx())); + expect(pass(vector)).toBe(false); + expect(pass(root)).toBe(false); + expect(pass(hug)).toBe(false); + }); + + it('heightRule 은 layoutSizingVertical 기준', () => { + const node = { + id: 'n', + name: 'B', + type: 'FRAME', + layoutSizingVertical: 'FIXED', + } as unknown as SceneNode; + expect(heightRule.guards?.every((g) => g.test(node, ctx()))).toBe(true); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/dimension.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/dimension.ts new file mode 100644 index 000000000..573c8eb91 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/dimension.ts @@ -0,0 +1,20 @@ +import { tokenField } from '../engine/factories'; +import { notRoot, notVectorLike, sizingFixed } from '../engine/guards'; + +// 벡터 원시 노드 크기는 도형 자체 속성, root 프레임은 뷰포트라 검사 제외. +// FIXED sizing 만 토큰 검사 대상 (HUG/FILL 은 파생값). +export const widthRule = tokenField({ + name: 'dimension:width', + category: 'dimensions', + property: 'width', + field: 'width', + guards: [notVectorLike, notRoot, sizingFixed('layoutSizingHorizontal')], +}); + +export const heightRule = tokenField({ + name: 'dimension:height', + category: 'dimensions', + property: 'height', + field: 'height', + guards: [notVectorLike, notRoot, sizingFixed('layoutSizingVertical')], +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/index.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/index.ts new file mode 100644 index 000000000..3e63708c4 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/index.ts @@ -0,0 +1,20 @@ +import type { AnyExtractionRule } from '../engine/types'; +import { fillColorsRule, strokeColorsRule } from './color'; +import { heightRule, widthRule } from './dimension'; +import { radiusRule } from './radius'; +import { shadowRule } from './shadow'; +import { gapRule, paddingRule } from './space'; +import { typographyRule } from './typography'; + +/** 필드 추가 = 여기에 행 추가. 배열 순서 = NodeFacts 내 emission 순서. */ +export const RULES: readonly AnyExtractionRule[] = [ + fillColorsRule(), + strokeColorsRule(), + typographyRule(), + paddingRule(), + gapRule, + widthRule, + heightRule, + radiusRule(), + shadowRule(), +]; diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/radius.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/radius.test.ts new file mode 100644 index 000000000..fdb3b9aa5 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/radius.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import type { ExtractCtx } from '../engine/types'; +import { RADIUS_BINDING_FIELDS, radiusRule } from './radius'; + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +describe('radiusRule', () => { + const rule = radiusRule(); + + it('filterKeys 는 5개 바인딩 필드 전체', () => { + expect(rule.filterKeys).toEqual(RADIUS_BINDING_FIELDS); + }); + + it('uniform cornerRadius 숫자 → 1건 (바인딩 없으면 raw)', async () => { + const node = { id: 'n', name: 'Card', cornerRadius: 8 } as unknown as SceneNode; + const out = await rule.extract(node, ctx()); + expect(out).toEqual([ + { value: '8px', token: null, appliedToken: null, tokenStatus: 'raw' }, + ]); + }); + + it('cornerRadius 가 mixed(비숫자)면 미방출', async () => { + const node = { + id: 'n', + name: 'Card', + cornerRadius: Symbol('mixed'), + } as unknown as SceneNode; + expect(await rule.extract(node, ctx())).toEqual([]); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/radius.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/radius.ts new file mode 100644 index 000000000..931d97e83 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/radius.ts @@ -0,0 +1,44 @@ +import type { TokenStatus } from '~/common/schemas'; + +import type { ExtractionRule } from '../engine/types'; +import { readBoundToken } from '../variables'; + +export const RADIUS_BINDING_FIELDS = [ + 'cornerRadius', + 'topLeftRadius', + 'topRightRadius', + 'bottomLeftRadius', + 'bottomRightRadius', +] as const; + +/** + * Figma uniform cornerRadius 는 boundVariables.cornerRadius 없이 corner 필드에 + * 바인딩될 수 있어 5개 필드를 우선순위대로 훑는다 — tokenField 로 못 쪼개는 이유. + */ +export function radiusRule(): ExtractionRule<'radii'> { + return { + name: 'radius', + category: 'radii', + filterKeys: RADIUS_BINDING_FIELDS, + extract: async (node, ctx) => { + const cr = (node as FrameNode).cornerRadius; + if (typeof cr !== 'number') return []; + + let token: string | null = null; + let appliedToken: string | null = null; + let status: TokenStatus = 'raw'; + + for (const cf of RADIUS_BINDING_FIELDS) { + const r = await readBoundToken(node, ctx.boundVariables, cf); + if (r.status !== 'raw') { + token = r.token; + appliedToken = r.appliedToken; + status = r.status; + break; + } + } + + return [{ value: `${cr}px`, token, appliedToken, tokenStatus: status }]; + }, + }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/shadow.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/shadow.test.ts new file mode 100644 index 000000000..43f6d58f8 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/shadow.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ExtractCtx } from '../engine/types'; +import { readEffectStyleToken } from '../variables'; +import { shadowRule } from './shadow'; + +vi.mock('../variables', () => ({ + readEffectStyleToken: vi.fn(), +})); + +const ctx = (): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, +}); + +const dropShadow = { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.25 }, + offset: { x: 0, y: 2 }, + radius: 4, + spread: 0, +} as const; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('shadowRule', () => { + it('filterKeys = effects + effectStyleId', () => { + const rule = shadowRule(); + expect(rule.filterKeys).toEqual(['effects', 'effectStyleId']); + }); + + it('shadow 계열 effect 만 emission, effectStyleId 없으면 raw', async () => { + vi.mocked(readEffectStyleToken).mockResolvedValueOnce({ token: null, status: 'raw' }); + const node = { + id: 'n', + name: 'Card', + effects: [dropShadow, { type: 'LAYER_BLUR', radius: 2 }], + } as unknown as SceneNode; + const rule = shadowRule(); + const out = await rule.extract(node, ctx()); + + // LAYER_BLUR 는 제외 → 1개 emission + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + value: '0px 2px 4px 0px rgba(0,0,0,0.25)', + token: null, + tokenStatus: 'raw', + }); + // readEffectStyleToken 은 1회 호출 (노드당 1개) + expect(readEffectStyleToken).toHaveBeenCalledTimes(1); + }); + + it('shadow effect 없으면 미방출', async () => { + const node = { + id: 'n', + name: 'Card', + effects: [{ type: 'LAYER_BLUR', radius: 4 }], + } as unknown as SceneNode; + const rule = shadowRule(); + const out = await rule.extract(node, ctx()); + + expect(out).toHaveLength(0); + expect(readEffectStyleToken).not.toHaveBeenCalled(); + }); + + it('INNER_SHADOW 도 포함, effectStyleId 있으면 토큰 해석', async () => { + vi.mocked(readEffectStyleToken).mockResolvedValueOnce({ + token: 'shadow-card', + status: 'ok', + }); + const innerShadow = { + type: 'INNER_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.5 }, + offset: { x: 1, y: 1 }, + radius: 2, + spread: 0, + }; + const node = { + id: 'n', + name: 'Card', + effects: [dropShadow, innerShadow], + effectStyleId: 'S:abc', + } as unknown as SceneNode; + const rule = shadowRule(); + const out = await rule.extract(node, ctx()); + + expect(out).toHaveLength(2); + // 모든 항목이 같은 토큰을 공유 (1회 조회) + expect(out[0]?.token).toBe('shadow-card'); + expect(out[1]?.token).toBe('shadow-card'); + expect(out[0]?.tokenStatus).toBe('ok'); + // INNER_SHADOW → inset + expect(out[1]?.value).toMatch(/^inset/); + expect(readEffectStyleToken).toHaveBeenCalledTimes(1); + }); + + it('effects 필드 없는 노드 → 미방출', async () => { + const node = { id: 'n', name: 'Icon' } as unknown as SceneNode; + const rule = shadowRule(); + const out = await rule.extract(node, ctx()); + + expect(out).toHaveLength(0); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/shadow.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/shadow.ts new file mode 100644 index 000000000..2ed7fdb9f --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/shadow.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Figma effect shape is intentionally loose. */ +import type { ExtractionRule } from '../engine/types'; +import { shadowToCss } from '../paint'; +import { readEffectStyleToken } from '../variables'; + +/** 노드당 effectStyleId 는 1개 → 토큰 1회 조회로 모든 shadow 항목이 공유. */ +export function shadowRule(): ExtractionRule<'shadows'> { + return { + name: 'shadow', + category: 'shadows', + filterKeys: ['effects', 'effectStyleId'], + extract: async (node) => { + const effects: any[] = Array.isArray((node as any).effects) + ? (node as any).effects + : []; + const shadows = effects.filter( + (eff: any) => eff.type === 'DROP_SHADOW' || eff.type === 'INNER_SHADOW', + ); + + if (shadows.length === 0) return []; + + const { token, status } = await readEffectStyleToken(node); + return shadows.map((eff: any) => ({ + value: shadowToCss(eff), + token, + tokenStatus: status, + })); + }, + }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/space.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/space.test.ts new file mode 100644 index 000000000..394097352 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/space.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import type { ExtractCtx } from '../engine/types'; +import { gapRule, paddingRule } from './space'; + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +describe('gapRule', () => { + it('itemSpacing 있는 노드 → gap emission', async () => { + const node = { id: 'n', name: 'N', itemSpacing: 8 } as unknown as SceneNode; + const out = await gapRule.extract(node, ctx()); + expect(out).toEqual([ + { property: 'gap', value: '8px', token: null, appliedToken: null, tokenStatus: 'raw' }, + ]); + }); + + it('itemSpacing 없는 노드 → 미방출', async () => { + const node = { id: 'n', name: 'N' } as unknown as SceneNode; + expect(await gapRule.extract(node, ctx())).toEqual([]); + }); +}); + +describe('paddingRule', () => { + const rule = paddingRule(); + + it('filter 로 단일 방향만 허용 → 해당 필드만 emit', async () => { + const node = { + id: 'n', + name: 'Box', + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8, + } as unknown as SceneNode; + const out = await rule.extract(node, ctx({ filter: new Set(['paddingTop']) })); + expect(out).toEqual([ + { + property: 'paddingTop', + value: '8px', + token: null, + appliedToken: null, + tokenStatus: 'raw', + }, + ]); + }); + + it('padding 필드 없는 노드 → 미방출', async () => { + const node = { id: 'n', name: 'N' } as unknown as SceneNode; + expect(await rule.extract(node, ctx())).toEqual([]); + }); + + it('4방향 동일 → padding 으로 축약', async () => { + const node = { + id: 'n', + name: 'Box', + paddingTop: 16, + paddingRight: 16, + paddingBottom: 16, + paddingLeft: 16, + } as unknown as SceneNode; + const out = await rule.extract(node, ctx()); + expect(out).toEqual([ + { + property: 'padding', + value: '16px', + token: null, + appliedToken: null, + tokenStatus: 'raw', + }, + ]); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/space.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/space.ts new file mode 100644 index 000000000..bf8c48ec9 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/space.ts @@ -0,0 +1,56 @@ +import { passes } from '../engine/engine'; +import { composite, tokenField } from '../engine/factories'; +import type { EmissionOf, ExtractionRule } from '../engine/types'; +import type { PaddingDir, PaddingField } from '../padding'; +import { derivePaddingEmissions } from '../padding'; +import { readBoundToken } from '../variables'; + +export const PADDING_FIELDS: readonly PaddingField[] = [ + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', +]; + +export const gapRule: ExtractionRule<'spaces'> = tokenField({ + name: 'space:gap', + category: 'spaces', + property: 'gap', + field: 'itemSpacing', +}); + +// 4방향 동시 참조로 shorthand 축약 — 방향별 tokenField 로 분리 불가. +export function paddingRule(): ExtractionRule<'spaces'> { + return composite({ + name: 'space:padding', + category: 'spaces', + filterKeys: [...PADDING_FIELDS], + read: async (node, ctx): Promise => { + const dirs: PaddingDir[] = []; + + for (const f of PADDING_FIELDS) { + if (!passes(ctx.filter, f)) continue; + const v = (node as unknown as Record)[f]; + if (typeof v !== 'number') continue; + + const { token, appliedToken, status } = await readBoundToken( + node, + ctx.boundVariables, + f, + ); + dirs.push({ field: f, value: v, token, appliedToken, status }); + } + + return derivePaddingEmissions(dirs).map( + ({ property, source }) => + ({ + property, + value: `${source.value}px`, + token: source.token, + appliedToken: source.appliedToken, + tokenStatus: source.status, + }) as unknown as EmissionOf['spaces'], + ); + }, + }); +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/typography.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/typography.test.ts new file mode 100644 index 000000000..0dfe735cd --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/typography.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ExtractCtx } from '../engine/types'; +import { classifyTextNode } from '../text'; +import { TYPOGRAPHY_KEYS, typographyRule } from './typography'; + +vi.mock('../text', () => ({ + classifyTextNode: vi.fn(), +})); + +const ctx = (over: Partial = {}): ExtractCtx => ({ + rootId: 'root', + viewport: 'pc', + boundVariables: undefined, + filter: null, + ...over, +}); + +const makeTextNode = (chars = 'Hello world', extra: Record = {}) => + ({ + id: 'n1', + name: 'Label', + type: 'TEXT', + characters: chars, + ...extra, + }) as unknown as SceneNode; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('typographyRule', () => { + it('filterKeys = TYPOGRAPHY_KEYS', () => { + const rule = typographyRule(); + expect(rule.filterKeys).toEqual(TYPOGRAPHY_KEYS); + }); + + it('guards include isText (TEXT 통과, FRAME 차단)', () => { + const rule = typographyRule(); + const textNode = makeTextNode(); + const frameNode = { id: 'f', name: 'Box', type: 'FRAME' } as unknown as SceneNode; + const c = ctx(); + const pass = (n: SceneNode) => rule.guards?.every((g) => g.test(n, c)); + expect(pass(textNode)).toBe(true); + expect(pass(frameNode)).toBe(false); + }); + + it('seg 있으면 resolved 각 필드 채움', async () => { + const seg = { + fontSize: 16, + lineHeight: { unit: 'PERCENT', value: 150 }, + letterSpacing: { unit: 'PIXELS', value: 0.5 }, + fontName: { family: 'Pretendard', style: 'Regular' }, + }; + vi.mocked(classifyTextNode).mockResolvedValueOnce({ + appliedStatus: 'styled-clean', + textStyle: 'Heading/H1', + overriddenFields: [], + seg, + }); + const rule = typographyRule(); + const node = makeTextNode('Hello world'); + const out = await rule.extract(node, ctx({ viewport: 'tablet' })); + + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ + characters: 'Hello world', + textStyle: 'Heading/H1', + viewport: 'tablet', + appliedStatus: 'styled-clean', + overriddenFields: [], + resolved: { + fontSize: 16, + lineHeight: { unit: 'PERCENT', value: 150 }, + letterSpacing: { unit: 'PIXELS', value: 0.5 }, + fontName: { family: 'Pretendard', style: 'Regular' }, + }, + }); + }); + + it('seg null 이면 resolved 전 필드 null', async () => { + vi.mocked(classifyTextNode).mockResolvedValueOnce({ + appliedStatus: 'raw', + textStyle: null, + overriddenFields: [], + seg: null, + }); + const rule = typographyRule(); + const node = makeTextNode('Hi'); + const out = await rule.extract(node, ctx()); + + expect(out[0]?.resolved).toEqual({ + fontSize: null, + lineHeight: null, + letterSpacing: null, + fontName: null, + }); + }); + + it('characters 는 최대 20자로 잘림', async () => { + vi.mocked(classifyTextNode).mockResolvedValueOnce({ + appliedStatus: 'raw', + textStyle: null, + overriddenFields: [], + seg: null, + }); + const rule = typographyRule(); + const long = 'A'.repeat(30); + const node = makeTextNode(long); + const out = await rule.extract(node, ctx()); + + expect(out[0]?.characters).toHaveLength(20); + }); + + it('viewport from ctx', async () => { + vi.mocked(classifyTextNode).mockResolvedValueOnce({ + appliedStatus: 'raw', + textStyle: null, + overriddenFields: [], + seg: null, + }); + const rule = typographyRule(); + const node = makeTextNode(); + const out = await rule.extract(node, ctx({ viewport: 'mobile' })); + + expect(out[0]?.viewport).toBe('mobile'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/rules/typography.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/typography.ts new file mode 100644 index 000000000..1411192e0 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/rules/typography.ts @@ -0,0 +1,43 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Figma seg shape is intentionally loose. */ +import { isText } from '../engine/guards'; +import type { ExtractionRule } from '../engine/types'; +import { classifyTextNode } from '../text'; + +export const TYPOGRAPHY_KEYS = [ + 'characters', + 'fontName', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textStyleId', +] as const; + +export function typographyRule(): ExtractionRule<'typography'> { + return { + name: 'typography', + category: 'typography', + filterKeys: TYPOGRAPHY_KEYS, + guards: [isText], + extract: async (node, ctx) => { + const textNode = node as TextNode; + const { appliedStatus, textStyle, overriddenFields, seg } = + await classifyTextNode(textNode); + + return [ + { + characters: (textNode.characters || '').slice(0, 20), + textStyle, + viewport: ctx.viewport, + appliedStatus, + overriddenFields, + resolved: { + fontSize: seg ? seg.fontSize : null, + lineHeight: seg ? seg.lineHeight : null, + letterSpacing: seg ? (seg as any).letterSpacing : null, + fontName: seg ? seg.fontName : null, + }, + }, + ]; + }, + }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/screenshot.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/screenshot.test.ts new file mode 100644 index 000000000..1d37d93b3 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/screenshot.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from 'vitest'; + +import { captureScreenshot } from './screenshot'; + +describe('captureScreenshot', () => { + it('base64-encodes exportAsync bytes as PNG at scale 1', async () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const frame = { + exportAsync: vi.fn().mockResolvedValue(bytes), + } as unknown as FrameNode; + + (globalThis as any).figma = { + base64Encode: (b: Uint8Array) => Buffer.from(b).toString('base64'), + }; + + const out = await captureScreenshot(frame); + expect(frame.exportAsync).toHaveBeenCalledWith({ + format: 'PNG', + constraint: { type: 'SCALE', value: 1 }, + }); + expect(out).toBe(Buffer.from(bytes).toString('base64')); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/screenshot.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/screenshot.ts new file mode 100644 index 000000000..2fc738764 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/screenshot.ts @@ -0,0 +1,15 @@ +import { exportWithoutInteractionLayers } from './hide-interaction-layers'; + +/** + * 프레임 전체 스크린샷(1x PNG base64). LLM 컨텍스트로 전달. + * 🔶InteractionLayer 는 hover/focus 오버레이라 실제 배경색이 아니므로 + * 원본을 건드리지 않고 clone 위에서 감춘 뒤 export → clone 제거. + */ +export async function captureScreenshot(frame: FrameNode): Promise { + const bytes = await exportWithoutInteractionLayers(frame, { + format: 'PNG', + constraint: { type: 'SCALE', value: 1 }, + }); + + return figma.base64Encode(bytes); +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/text.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/text.ts new file mode 100644 index 000000000..36ce627ed --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/text.ts @@ -0,0 +1,180 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Figma seg/style shapes are intentionally loose. */ +import type { ColorBackground } from '~/common/schemas'; + +import { isInteractionLayer } from './filters'; +import { exportWithoutInteractionLayers } from './hide-interaction-layers'; + +export type TextClassification = { + appliedStatus: 'styled-clean' | 'styled-override' | 'var-only' | 'raw' | 'mixed'; + textStyle: string | null; + overriddenFields: string[]; + seg: any; +}; + +export type TextShot = { + fontSize: number; + isBold: boolean; + imageBase64?: string; + cropX?: number; + cropY?: number; + cropW?: number; + cropH?: number; +}; + +const CAPTURE_SCALE = 2; + +function sameLineHeight(a: LineHeight | undefined, b: LineHeight | undefined): boolean { + if (!a || !b) return a === b; + if (a.unit !== b.unit) return false; + if (a.unit === 'AUTO' || b.unit === 'AUTO') return a.unit === b.unit; + + return a.value === b.value; +} + +/** + * TEXT 노드 하나에 대해 스타일 적용 상태를 판정. + * - segs > 1 → mixed + * - textStyleId 있음 → 세부 오버라이드 필드 검사 → styled-clean / styled-override + * - textStyleId 없음, boundVariables 존재 → var-only + * - 그 외 → raw + */ +export async function classifyTextNode(node: TextNode): Promise { + const segs = node.getStyledTextSegments([ + 'textStyleId', + 'fontName', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'boundVariables', + ]); + + if (segs.length > 1) { + return { appliedStatus: 'mixed', textStyle: null, overriddenFields: [], seg: segs[0] }; + } + + const seg = segs[0]; + const styleId = seg && seg.textStyleId; + + if (styleId) { + const style: any = await figma.getStyleByIdAsync(styleId).catch(() => null); + if (!style) { + return { appliedStatus: 'styled-clean', textStyle: null, overriddenFields: [], seg }; + } + + const overriddenFields: string[] = []; + + if ( + seg.fontName?.family !== style.fontName?.family || + seg.fontName?.style !== style.fontName?.style + ) { + overriddenFields.push('fontName'); + } + + if (seg.fontSize !== style.fontSize) overriddenFields.push('fontSize'); + if (!sameLineHeight(seg.lineHeight, style.lineHeight)) overriddenFields.push('lineHeight'); + if ( + seg.letterSpacing?.unit !== style.letterSpacing?.unit || + seg.letterSpacing?.value !== style.letterSpacing?.value + ) { + overriddenFields.push('letterSpacing'); + } + + return { + appliedStatus: overriddenFields.length ? 'styled-override' : 'styled-clean', + textStyle: style.name, + overriddenFields, + seg, + }; + } + + const bv = (seg && seg.boundVariables) || {}; + const hasBinding = Object.keys(bv).some((k) => + ['fontFamily', 'fontSize', 'fontStyle', 'lineHeight', 'letterSpacing'].includes(k), + ); + + return { + appliedStatus: hasBinding ? 'var-only' : 'raw', + textStyle: null, + overriddenFields: [], + seg, + }; +} + +function readTextMeta(node: TextNode): { fontSize: number; isBold: boolean } { + const fontSize = typeof node.fontSize === 'number' ? node.fontSize : 16; + const fn: any = node.fontName; + const isBold = fn && typeof fn.style === 'string' ? /bold|black|heavy/i.test(fn.style) : false; + return { fontSize, isBold }; +} + +/** + * 배경이 결정적(SOLID opaque) 이면 hex 로 직접 대비 계산할 수 있으므로 PNG 불필요. + * 배경이 ambiguous 이면 fill 있는 최근접 조상을 그대로 PNG 로 캡처 (텍스트 포함). + * 분석기는 이 PNG 를 텍스트 bbox 로 크롭한 뒤 fg 색과 거리가 먼 픽셀 = 실제 배경 픽셀로 간주해 평균. + * 노드 visibility 를 건드리지 않으므로 undo 히스토리/인스턴스 override 오염 없음. + * 실패 시 fontSize/isBold 만 담긴 TextShot 을 반환 (분석기는 이 경우 스킵). + */ +export async function captureTextShot( + node: TextNode, + background: ColorBackground | null, +): Promise { + const meta = readTextMeta(node); + + if (background && (background.kind === 'white' || background.kind === 'other')) { + return meta; + } + + const ancestor = findBackgroundAncestor(node); + if (!ancestor) return meta; + + const tb = node.absoluteBoundingBox; + const pb = ancestor.absoluteRenderBounds ?? ancestor.absoluteBoundingBox; + if (!tb || !pb) return meta; + + let bytes: Uint8Array; + try { + bytes = await exportWithoutInteractionLayers(ancestor, { + format: 'PNG', + constraint: { type: 'SCALE', value: CAPTURE_SCALE }, + }); + } catch (_e) { + return meta; + } + + const cropX = Math.max(0, Math.round((tb.x - pb.x) * CAPTURE_SCALE)); + const cropY = Math.max(0, Math.round((tb.y - pb.y) * CAPTURE_SCALE)); + const cropW = Math.max(1, Math.round(tb.width * CAPTURE_SCALE)); + const cropH = Math.max(1, Math.round(tb.height * CAPTURE_SCALE)); + + return { + ...meta, + imageBase64: figma.base64Encode(bytes), + cropX, + cropY, + cropW, + cropH, + }; +} + +type ExportableAncestor = SceneNode & { + exportAsync: (settings: any) => Promise; + absoluteBoundingBox: Rect | null; + absoluteRenderBounds: Rect | null; +}; + +function findBackgroundAncestor(node: SceneNode): ExportableAncestor | null { + let cur: any = node.parent; + while (cur && cur.type !== 'PAGE' && cur.type !== 'DOCUMENT') { + // 🔶InteractionLayer 는 hover/focus 오버레이라 실제 배경이 아님. + if (isInteractionLayer(cur)) { + cur = cur.parent; + continue; + } + const hasFills = 'fills' in cur && Array.isArray(cur.fills); + const visible = hasFills ? cur.fills.find((p: any) => p && p.visible !== false) : null; + const canExport = typeof cur.exportAsync === 'function' && !!cur.absoluteBoundingBox; + if (visible && canExport) return cur as ExportableAncestor; + cur = cur.parent; + } + return null; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/variables.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/variables.test.ts new file mode 100644 index 000000000..9ad97a280 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/variables.test.ts @@ -0,0 +1,226 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { readBoundToken } from './variables'; + +// --------------------------------------------------------------------------- +// figma.variables mock helpers +// --------------------------------------------------------------------------- + +type FakeVariable = { + id: string; + name: string; + collectionId: string; + /** modeId → resolved value (alias or leaf). */ + valuesByMode: Record; + remote?: boolean; +}; + +type FakeCollection = { + id: string; + name: string; +}; + +const MODE = 'mode-1'; + +const COLLECTIONS = { + primitive: { id: 'coll-prim', name: '⚙️ Primitives' }, + semanticSpace: { id: 'coll-sem-space', name: '● Semantic / Space' }, + semanticColor: { id: 'coll-sem-color', name: '● Semantic / Color' }, + component: { id: 'coll-comp', name: '💙 Button' }, +} satisfies Record; + +function leafValue() { + return { r: 0, g: 0, b: 0, a: 1 }; +} + +function makeVar(id: string, name: string, collectionId: string, value: any): FakeVariable { + return { + id, + name, + collectionId, + valuesByMode: { [MODE]: value }, + }; +} + +function alias(id: string) { + return { type: 'VARIABLE_ALIAS', id }; +} + +function installFigma(vars: FakeVariable[], collections: FakeCollection[]) { + const varById = new Map(vars.map((v) => [v.id, v])); + const collById = new Map(collections.map((c) => [c.id, c])); + + (globalThis as any).figma = { + variables: { + getVariableByIdAsync: async (id: string) => { + const v = varById.get(id); + if (!v) return null; + return { ...v, variableCollectionId: v.collectionId }; + }, + getVariableCollectionByIdAsync: async (id: string) => collById.get(id) ?? null, + importVariableByKeyAsync: async () => null, + }, + }; +} + +function nodeWithMode(collectionIds: string[]): SceneNode { + const resolvedVariableModes: Record = {}; + for (const c of collectionIds) resolvedVariableModes[c] = MODE; + + return { + id: 'node-1', + name: 'node', + resolvedVariableModes, + } as unknown as SceneNode; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('readBoundToken', () => { + afterEach(() => { + delete (globalThis as any).figma; + }); + + it("바인딩이 없으면 status='raw'", async () => { + installFigma([], []); + const node = nodeWithMode([]); + + const out = await readBoundToken(node, undefined, 'paddingLeft'); + + expect(out).toEqual({ token: null, appliedToken: null, status: 'raw' }); + }); + + it('직접 semantic 바인딩 → token/appliedToken 모두 semantic (회귀 방지)', async () => { + const sem = makeVar( + 'v-sem', + 'size/size-space-200', + COLLECTIONS.semanticSpace.id, + leafValue(), + ); + installFigma([sem], [COLLECTIONS.semanticSpace]); + const node = nodeWithMode([COLLECTIONS.semanticSpace.id]); + + const out = await readBoundToken(node, { paddingLeft: { id: sem.id } }, 'paddingLeft'); + + expect(out).toEqual({ + token: 'size-space-200', + appliedToken: 'size-space-200', + status: 'ok', + }); + }); + + it('component var 가 semantic 을 wrap → token=semantic, appliedToken=outer', async () => { + const sem = makeVar( + 'v-sem', + 'size/size-space-200', + COLLECTIONS.semanticSpace.id, + leafValue(), + ); + const comp = makeVar( + 'v-comp', + 'button/space-inline', + COLLECTIONS.component.id, + alias(sem.id), + ); + installFigma([comp, sem], [COLLECTIONS.component, COLLECTIONS.semanticSpace]); + const node = nodeWithMode([COLLECTIONS.component.id, COLLECTIONS.semanticSpace.id]); + + const out = await readBoundToken(node, { paddingLeft: { id: comp.id } }, 'paddingLeft'); + + expect(out).toEqual({ + token: 'size-space-200', + appliedToken: 'space-inline', + status: 'ok', + }); + }); + + it('component → semantic → primitive 다단계 → token=semantic, appliedToken=outer', async () => { + const prim = makeVar('v-prim', 'primitive/size-16', COLLECTIONS.primitive.id, leafValue()); + const sem = makeVar( + 'v-sem', + 'size/size-space-200', + COLLECTIONS.semanticSpace.id, + alias(prim.id), + ); + const comp = makeVar( + 'v-comp', + 'button/space-inline', + COLLECTIONS.component.id, + alias(sem.id), + ); + installFigma( + [comp, sem, prim], + [COLLECTIONS.component, COLLECTIONS.semanticSpace, COLLECTIONS.primitive], + ); + const node = nodeWithMode([ + COLLECTIONS.component.id, + COLLECTIONS.semanticSpace.id, + COLLECTIONS.primitive.id, + ]); + + const out = await readBoundToken(node, { paddingLeft: { id: comp.id } }, 'paddingLeft'); + + expect(out).toEqual({ + token: 'size-space-200', + appliedToken: 'space-inline', + status: 'ok', + }); + }); + + it('component var 가 primitive 만 wrap (semantic 없음) → token=appliedToken=outer', async () => { + const prim = makeVar('v-prim', 'primitive/size-16', COLLECTIONS.primitive.id, leafValue()); + const comp = makeVar( + 'v-comp', + 'button/space-inline', + COLLECTIONS.component.id, + alias(prim.id), + ); + installFigma([comp, prim], [COLLECTIONS.component, COLLECTIONS.primitive]); + const node = nodeWithMode([COLLECTIONS.component.id, COLLECTIONS.primitive.id]); + + const out = await readBoundToken(node, { paddingLeft: { id: comp.id } }, 'paddingLeft'); + + // semantic 없음 → downstream 이 unknown-token 으로 처리하도록 token 도 outer 이름. + expect(out).toEqual({ + token: 'space-inline', + appliedToken: 'space-inline', + status: 'ok', + }); + }); + + it('primitive 직접 바인딩 → token=appliedToken=primitive 이름', async () => { + const prim = makeVar('v-prim', 'primitive/size-16', COLLECTIONS.primitive.id, leafValue()); + installFigma([prim], [COLLECTIONS.primitive]); + const node = nodeWithMode([COLLECTIONS.primitive.id]); + + const out = await readBoundToken(node, { paddingLeft: { id: prim.id } }, 'paddingLeft'); + + expect(out).toEqual({ token: 'size-16', appliedToken: 'size-16', status: 'ok' }); + }); + + it('순환 참조 → seen guard, semantic 없음 → appliedToken=chain[0]', async () => { + // v-a → v-b → v-a (loop). semantic tier 없음. + const a = makeVar('v-a', 'button/a', COLLECTIONS.component.id, alias('v-b')); + const b = makeVar('v-b', 'button/b', COLLECTIONS.component.id, alias('v-a')); + installFigma([a, b], [COLLECTIONS.component]); + const node = nodeWithMode([COLLECTIONS.component.id]); + + const out = await readBoundToken(node, { paddingLeft: { id: a.id } }, 'paddingLeft'); + + expect(out.status).toBe('ok'); + expect(out.token).toBe('a'); + expect(out.appliedToken).toBe('a'); + }); + + it('getVariableByIdAsync 가 null 반환하면 status=unknown', async () => { + installFigma([], []); + const node = nodeWithMode([]); + + const out = await readBoundToken(node, { paddingLeft: { id: 'missing' } }, 'paddingLeft'); + + expect(out).toEqual({ token: null, appliedToken: null, status: 'unknown' }); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/variables.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/variables.ts new file mode 100644 index 000000000..a9f979d0f --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/variables.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- Variable alias chains + valuesByMode lookups are intentionally untyped. */ +import type { SchemaMode, TokenStatus } from '~/common/schemas'; + +import { rgbaToHex } from './paint'; + +export type TierChainEntry = { name: string; tier: string }; + +/** 콜렉션 이름의 이모지/서명 prefix 로 tier 판정. */ +function tierOf(collName: string | null): string { + if (!collName) return 'unknown'; + if (collName.includes('⚙️') || /primitive/i.test(collName)) return 'primitive'; + if (collName.includes('●') || /(^|[^a-z])token(\/|$| )/i.test(collName)) return 'semantic'; + if (collName.includes('💙') || /\/Color$/i.test(collName)) return 'component'; + + return 'unknown'; +} + +/** semantic 계층에 도달한 이름을 스키마 키로 변환. `color-` 로 시작하지 않으면 null. */ +function toSchemaKey(name: string | null): string | null { + return name && name.startsWith('color-') ? name : null; +} + +/** + * Figma 변수/스타일 이름에서 선두 `/` 를 제거. + * "size/size-space-200" → "size-space-200" + * "color/color-background-primary-100" → "color-background-primary-100" + * "shadow/shadow-md" → "shadow-md" + * "already-clean-200" → "already-clean-200" + */ +function stripLeadingPrefix(name: string): string { + const idx = name.indexOf('/'); + return idx === -1 ? name : name.substring(idx + 1); +} + +/** + * remote 변수 안전 조회. team library import 실패 시 원본 반환. + * 실패한 체인은 downstream 에서 'unknown' 으로 떨어진다. + */ +async function getVariableWithRemoteDefense(id: string): Promise { + let v: any = null; + + try { + v = await figma.variables.getVariableByIdAsync(id); + } catch (_e) { + v = null; + } + + if (v && v.remote) { + try { + const imported = await figma.variables.importVariableByKeyAsync(v.key); + if (imported) v = imported; + } catch (_e) { + /* 원본 유지 */ + } + } + + return v; +} + +/** + * 변수 별칭 체인을 끝까지 추적하며 [(name, tier)...] 를 만들고 + * 최종 hex 도 함께 반환. seen guard 로 무한 참조 방지. + */ +export async function walk( + node: SceneNode, + startId: string, +): Promise<{ chain: TierChainEntry[]; finalHex: string | null }> { + const modes: Record = (node as any).resolvedVariableModes || {}; + const chain: TierChainEntry[] = []; + const seen = new Set(); + let id: string | null = startId; + let finalHex: string | null = null; + + while (id && !seen.has(id)) { + seen.add(id); + + const v = await getVariableWithRemoteDefense(id); + if (!v) break; + + let collName: string | null = null; + + try { + const coll = await figma.variables.getVariableCollectionByIdAsync( + v.variableCollectionId, + ); + + collName = coll && coll.name; + } catch (_e) { + collName = null; + } + chain.push({ name: v.name, tier: tierOf(collName) }); + + const m = modes[v.variableCollectionId] || Object.keys(v.valuesByMode)[0]; + const val = v.valuesByMode[m]; + + if (val && val.type === 'VARIABLE_ALIAS') { + id = val.id; + } else { + finalHex = rgbaToHex(val); + id = null; + } + } + + return { chain, finalHex }; +} + +/** + * semantic 항목까지 도달했으면 스키마 키 반환. 없으면 unknown. + * + * `appliedToken` 은 노드에 실제로 바인딩된 outer variable 이름(chain[0]) — semantic 미도달 시에도 + * 노드에 어떤 이름이 붙어있는지 표시하기 위해 함께 반환. 카드 UI 는 위반 시 이 이름을 우선한다. + */ +export function toToken(chain: TierChainEntry[]): { + token: string | null; + appliedToken: string | null; + tokenStatus: TokenStatus; +} { + if (chain.length === 0) return { token: null, appliedToken: null, tokenStatus: 'unknown' }; + + const appliedToken = stripLeadingPrefix(chain[0].name); + const sem = chain.find((c) => c.tier === 'semantic'); + if (!sem) return { token: null, appliedToken, tokenStatus: 'unknown' }; + + const key = toSchemaKey(sem.name); + return key + ? { token: key, appliedToken, tokenStatus: 'ok' } + : { token: null, appliedToken, tokenStatus: 'unknown' }; +} + +/** + * boundVariables 의 단일 필드 → 토큰 키. 바인딩 없으면 'raw'. + * + * Variable Mode 로 component-tier variable 이 semantic 을 alias 한 경우에도 + * 체인을 끝까지 따라가 semantic-tier 이름을 반환한다 (color 경로의 walk+toToken 과 동일 규칙). + * semantic 도달 실패 시 chain[0] 이름을 fallback 으로 반환하여 downstream 의 + * `token in schema.tokens` 미스매치가 그대로 unknown-token 위반으로 처리되도록 한다. + * + * `appliedToken` 은 노드에 실제로 바인딩된 outer variable 이름(chain[0]) 이다. + * 검사(validation)는 `token`(=원본 semantic) 기준이지만, 위반 카드 UI 는 + * 실제로 요소에 적용된 이름을 보여야 하므로 별도 필드로 노출한다. + */ +export async function readBoundToken( + node: SceneNode, + bound: Record | undefined, + field: string, +): Promise<{ token: string | null; appliedToken: string | null; status: TokenStatus }> { + const ref = bound?.[field]; + if (!ref) return { token: null, appliedToken: null, status: 'raw' }; + + const { chain } = await walk(node, ref.id); + if (chain.length === 0) return { token: null, appliedToken: null, status: 'unknown' }; + + const appliedToken = stripLeadingPrefix(chain[0].name); + const sem = chain.find((c) => c.tier === 'semantic'); + if (sem) return { token: stripLeadingPrefix(sem.name), appliedToken, status: 'ok' }; + + return { token: appliedToken, appliedToken, status: 'ok' }; +} + +/** + * shadow 는 effect-style 레벨에서 바인딩되므로 effectStyleId 로 토큰을 도출. + * per-effect boundVariables 는 사용하지 않음. + */ +export async function readEffectStyleToken( + node: SceneNode, +): Promise<{ token: string | null; status: TokenStatus }> { + const styleId: string | undefined = (node as any).effectStyleId; + if (!styleId) return { token: null, status: 'raw' }; + + try { + const style = await figma.getStyleByIdAsync(styleId); + if (!style) return { token: null, status: 'unknown' }; + + return { token: stripLeadingPrefix(style.name), status: 'ok' }; + } catch (_e) { + return { token: null, status: 'unknown' }; + } +} + +/** 노드가 참조하는 변수 콜렉션 중 mode 이름이 "dark" 를 포함하면 dark 스키마. */ +export async function detectSchemaMode(node: SceneNode): Promise { + const modes: Record = (node as any).resolvedVariableModes || {}; + + for (const collId of Object.keys(modes)) { + try { + const coll = await figma.variables.getVariableCollectionByIdAsync(collId); + const m = coll && coll.modes.find((x: any) => x.modeId === modes[collId]); + + if (m && /dark/i.test(m.name)) return 'dark'; + } catch (_e) { + /* light 로 폴백 */ + } + } + + return 'light'; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/visitor.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/visitor.ts new file mode 100644 index 000000000..7d919875c --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/visitor.ts @@ -0,0 +1,32 @@ +import type { Viewport } from '~/common/schemas'; + +import { runRules } from './engine/engine'; +import type { ExtractCtx, NodeFacts, OverrideFilter } from './engine/types'; +import { RULES } from './rules'; + +// extract-frame.ts 가 이 모듈 경로에서 소비하는 타입 계약 유지. +export type { NodeFacts, OverrideFilter }; + +export type VisitCtx = { + /** root frame id — 자기 자신의 width/height 는 dimension 검사 대상 제외. */ + rootId: string; + viewport: Viewport; +}; + +/** 순회는 호출부(extract-frame.ts) 책임. */ +export async function collectNodeFacts( + node: SceneNode, + ctx: VisitCtx, + filter: OverrideFilter = null, +): Promise { + const extractCtx: ExtractCtx = { + rootId: ctx.rootId, + viewport: ctx.viewport, + boundVariables: (node as unknown as { boundVariables?: Record }) + .boundVariables, + filter, + }; + + const { facts } = await runRules(node, extractCtx, RULES); + return facts; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/walk-tree.test.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/walk-tree.test.ts new file mode 100644 index 000000000..401fc9657 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/walk-tree.test.ts @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from 'vitest'; + +import { walkTree } from './walk-tree'; + +// --------------------------------------------------------------------------- +// Factory helpers +// --------------------------------------------------------------------------- + +function makeFrame(opts: { id?: string; children?: any[] } = {}): any { + const id = opts.id ?? 'frame'; + return { + id, + type: 'FRAME', + name: id, + children: opts.children ?? [], + x: 0, + y: 0, + width: 100, + height: 100, + }; +} + +function makeTextNode( + opts: { + id?: string; + characters?: string; + boundTextStyleName?: string; + classifyThrows?: boolean; + } = {}, +): any { + const id = opts.id ?? 'text'; + const seg = { + textStyleId: opts.boundTextStyleName ? `style-${id}` : undefined, + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 14, + lineHeight: { unit: 'AUTO' }, + letterSpacing: { unit: 'PERCENT', value: 0 }, + boundVariables: {}, + }; + return { + id, + type: 'TEXT', + name: id, + children: [], + x: 0, + y: 0, + width: 100, + height: 20, + characters: opts.characters ?? '', + getStyledTextSegments: opts.classifyThrows + ? () => { + throw new Error('classify failed'); + } + : vi.fn().mockReturnValue([seg]), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('walkTree', () => { + it('emits parent before children, skips 🟨/🔶 subtrees, keeps only id/type/name/parentId', async () => { + const leaf = { + id: 'l', + type: 'TEXT', + name: 'label', + x: 5, + y: 6, + width: 7, + height: 8, + } as any; + + const skipped = { + id: 's', + type: 'GROUP', + name: '🟨 legend', + children: [leaf], + x: 0, + y: 0, + width: 0, + height: 0, + } as any; + + const root = { + id: 'r', + type: 'FRAME', + name: 'Root', + children: [skipped, leaf], + x: 0, + y: 0, + width: 100, + height: 200, + } as any; + + const tree = await walkTree(root); + + const ids = tree.map((n) => n.id); + expect(ids).toContain('r'); + expect(ids).toContain('l'); + expect(ids).not.toContain('s'); + expect(tree[0]?.id).toBe('r'); + const leafInfo = tree.find((n) => n.id === 'l')!; + expect(leafInfo.parentId).toBe('r'); + // 기하 필드(x/y/w/h)와 childIds 는 스크린샷/parentId 로 대체되어 nodeTree 에서 제외 + expect((leafInfo as Record).x).toBeUndefined(); + expect((leafInfo as Record).childIds).toBeUndefined(); + }); + + it('walkTree 는 벡터·리프 도형(VECTOR/LINE/ELLIPSE 등) 을 자기·자식 모두 스킵한다', async () => { + const vectorChild = { + id: 'v', + type: 'VECTOR', + name: 'icon', + children: [], + } as any; + const root = { + id: 'r', + type: 'FRAME', + name: 'Root', + children: [vectorChild], + } as any; + const tree = await walkTree(root); + expect(tree.map((n) => n.id)).not.toContain('v'); + }); + + it('walkTree TEXT 노드는 characters(30자 컷) 와 textStyle 을 담는다', async () => { + (globalThis as any).figma = { + getStyleByIdAsync: vi.fn().mockResolvedValue({ + name: 'body2', + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 14, + lineHeight: { unit: 'AUTO' }, + letterSpacing: { unit: 'PERCENT', value: 0 }, + }), + }; + const frame = makeFrame({ + children: [ + makeTextNode({ + id: 't1', + characters: 'a'.repeat(100), + boundTextStyleName: 'body2', + }), + ], + }); + const tree = await walkTree(frame); + const text = tree.find((n) => n.id === 't1')!; + expect(text.characters).toBe('a'.repeat(30)); + expect(text.textStyle).toBe('body2'); + }); + + it('walkTree 비-TEXT 노드는 characters/textStyle 이 undefined', async () => { + const frame = makeFrame({ children: [makeFrame({ id: 'f2' })] }); + const tree = await walkTree(frame); + const inner = tree.find((n) => n.id === 'f2')!; + expect(inner.characters).toBeUndefined(); + expect(inner.textStyle).toBeUndefined(); + }); + + it('walkTree 는 classifyTextNode 실패해도 노드를 유지하고 textStyle 만 생략', async () => { + const frame = makeFrame({ + children: [ + makeTextNode({ + id: 't3', + characters: 'x', + classifyThrows: true, + }), + ], + }); + const tree = await walkTree(frame); + const text = tree.find((n) => n.id === 't3')!; + expect(text).toBeDefined(); + expect(text.characters).toBe('x'); + expect(text.textStyle).toBeUndefined(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/plugin/models/extract/walk-tree.ts b/apps/figma-token-review-plugin/src/plugin/models/extract/walk-tree.ts new file mode 100644 index 000000000..94011c6ae --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/extract/walk-tree.ts @@ -0,0 +1,74 @@ +import type { NodeInfo } from '~/common/schemas'; + +import { shouldSkipNode } from './filters'; +import { classifyTextNode } from './text'; + +/** LLM 판정에 무의미한 벡터·리프 도형. nodeTree 에서 제외. */ +const NOISE_LEAF_TYPES: ReadonlySet = new Set([ + 'VECTOR', + 'LINE', + 'ELLIPSE', + 'POLYGON', + 'STAR', + 'BOOLEAN_OPERATION', +]); + +const TEXT_CHAR_CAP = 30; + +/** + * LLM 컨텍스트용 노드 트리를 DFS 로 평탄화. + * - 숨겨진 노드/하위 제외 + * - `shouldSkipNode` 이름 필터에 걸린 노드는 자기 자신은 스킵하되 자식은 원래 부모 밑에 이어 붙임 + * - NOISE_LEAF_TYPES(벡터/리프 도형) 는 자기 스킵 + 자식은 스크린샷에 이미 반영되므로 무시 + * - TEXT 노드는 characters(30자 컷) + textStyle 부착 + * - 위치·크기(x/y/w/h) 는 스크린샷이 대체. childIds 는 parentId 의 역함수 → 생략. + */ +export async function walkTree(root: SceneNode): Promise { + const out: NodeInfo[] = []; + const stack: Array<{ node: SceneNode; parentId: string | null }> = [ + { node: root, parentId: null }, + ]; + + while (stack.length) { + const { node, parentId } = stack.pop()!; + + if (node.visible === false) continue; + + const children = 'children' in node ? (node.children as readonly SceneNode[]) : []; + + if (shouldSkipNode(node.name)) { + for (const c of children) stack.push({ node: c, parentId }); + continue; + } + + // 노이즈 리프 도형은 자기 자신·자식 모두 스킵. 시각 정보는 스크린샷에 있음. + if (NOISE_LEAF_TYPES.has(node.type)) continue; + + const info: NodeInfo = { + id: node.id, + type: node.type, + name: node.name, + parentId, + }; + + if (node.type === 'TEXT') { + const textNode = node as TextNode; + info.characters = (textNode.characters || '').slice(0, TEXT_CHAR_CAP); + + try { + const { textStyle } = await classifyTextNode(textNode); + + if (textStyle) info.textStyle = textStyle; + } catch { + /* textStyle 없이 characters 만 노출 */ + } + } + + out.push(info); + + for (const c of children) { + stack.push({ node: c, parentId: node.id }); + } + } + return out; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/node-lookup.ts b/apps/figma-token-review-plugin/src/plugin/models/node-lookup.ts new file mode 100644 index 000000000..19fe5aea3 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/node-lookup.ts @@ -0,0 +1,31 @@ +/** scan 대상 검증: FRAME/INSTANCE 만 유효. */ +export async function getScanTarget(frameId: string): Promise { + const node = await figma.getNodeByIdAsync(frameId); + if (!node || (node.type !== 'FRAME' && node.type !== 'INSTANCE')) return null; + return node; +} + +/** + * nodeIds → SceneNode 해석. isCancelled 가 true 를 반환하면 즉시 null — + * 최신 요청만 유효한 focus 흐름의 "루프 중 stale 체크"를 보존하기 위한 콜백. + */ +export async function resolveSceneNodes( + nodeIds: string[], + isCancelled: () => boolean, +): Promise<{ resolved: SceneNode[]; missing: string[] } | null> { + const resolved: SceneNode[] = []; + const missing: string[] = []; + + for (const nodeId of nodeIds) { + const n = await figma.getNodeByIdAsync(nodeId); + if (isCancelled()) return null; + + if (n && n.type !== 'DOCUMENT' && n.type !== 'PAGE' && 'visible' in n) { + resolved.push(n as SceneNode); + } else { + missing.push(nodeId); + } + } + + return { resolved, missing }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/models/selection.ts b/apps/figma-token-review-plugin/src/plugin/models/selection.ts new file mode 100644 index 000000000..832486db9 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/models/selection.ts @@ -0,0 +1,31 @@ +import type { SelectionState } from '~/common/schemas'; + +type FrameLike = FrameNode | InstanceNode; + +function findFrameAncestor(node: BaseNode): FrameLike | null { + let cur: BaseNode | null = node; + while (cur) { + if (cur.type === 'FRAME' || cur.type === 'INSTANCE') return cur; + cur = 'parent' in cur ? cur.parent : null; + } + return null; +} + +export function computeSelection(): SelectionState { + const sel = figma.currentPage.selection; + + if (sel.length === 0) return { kind: 'none' }; + + const frames = sel.map((n) => findFrameAncestor(n)); + + if (frames.some((f) => f === null)) { + if (sel.length > 1) return { kind: 'multi' }; + return { kind: 'invalid', nodeType: sel[0].type }; + } + + const uniqueIds = new Set(frames.map((f) => (f as FrameLike).id)); + if (uniqueIds.size > 1) return { kind: 'multi' }; + + const frame = frames[0] as FrameLike; + return { kind: 'frame', id: frame.id, name: frame.name }; +} diff --git a/apps/figma-token-review-plugin/src/plugin/views/ui-port.ts b/apps/figma-token-review-plugin/src/plugin/views/ui-port.ts new file mode 100644 index 000000000..605720503 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/views/ui-port.ts @@ -0,0 +1,30 @@ +import { postToUi } from '~/common/messages'; +import type { ApiKeyState, RequestId } from '~/common/messages'; +import type { LlmContext, RawExtract, SelectionState } from '~/common/schemas'; + +/** controllers 는 postToUi 직접 호출 불가 — 발송 지점 SSOT. */ +export type ExtractPayload = { extract: RawExtract; llmContext: LlmContext }; + +export function sendExtractResult(requestId: RequestId, payload: ExtractPayload): void { + postToUi({ type: 'extract-result', requestId, payload }); +} + +export function sendExtractError(requestId: RequestId, message: string): void { + postToUi({ type: 'extract-error', requestId, message }); +} + +export function sendFocusResult(requestId: RequestId, resolved: number, missing: number): void { + postToUi({ type: 'focus-result', requestId, resolved, missing }); +} + +export function sendFocusError(requestId: RequestId, message: string): void { + postToUi({ type: 'focus-error', requestId, message }); +} + +export function sendSelection(state: SelectionState): void { + postToUi({ type: 'selection', state }); +} + +export function sendApiKeyState(state: ApiKeyState): void { + postToUi({ type: 'api-key:state', state }); +} diff --git a/apps/figma-token-review-plugin/src/plugin/views/viewport.ts b/apps/figma-token-review-plugin/src/plugin/views/viewport.ts new file mode 100644 index 000000000..42c330948 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/views/viewport.ts @@ -0,0 +1,5 @@ +/** 캔버스 인터페이스 부수효과: 선택 변경 + 뷰포트 이동. */ +export function focusNodes(nodes: SceneNode[]): void { + figma.currentPage.selection = nodes; + figma.viewport.scrollAndZoomIntoView(nodes); +} diff --git a/apps/figma-token-review-plugin/src/plugin/views/window.ts b/apps/figma-token-review-plugin/src/plugin/views/window.ts new file mode 100644 index 000000000..60c2efbf0 --- /dev/null +++ b/apps/figma-token-review-plugin/src/plugin/views/window.ts @@ -0,0 +1,10 @@ +export const DEFAULT_SIZE = { width: 500, height: 600 }; +export const MIN_SIZE = { width: 360, height: 480 }; + +/** 플러그인 창 크기 조절 — MIN_SIZE 미만으로 줄어들지 않게 클램프. */ +export function resizeWindow(width: number, height: number): void { + figma.ui.resize( + Math.max(MIN_SIZE.width, Math.round(width)), + Math.max(MIN_SIZE.height, Math.round(height)), + ); +} diff --git a/apps/figma-token-review-plugin/src/ui/App.tsx b/apps/figma-token-review-plugin/src/ui/App.tsx new file mode 100644 index 000000000..bc6f3701d --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/App.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; + +import { Box } from '@vapor-ui/core'; + +import { Loader } from './components/loader'; +import { ResizeHandle } from './components/resize-handler'; +import { useApiKey } from './features/api-key'; +import type { ScanState } from './features/scan'; +import { useScan } from './features/scan'; +import { HomePage } from './pages/home'; +import { ScanResultPage } from './pages/scan-result'; +import { SettingsPage } from './pages/settings'; +import { SuccessPage } from './pages/success'; + +const App = () => { + const { state: scan } = useScan(); + const { state: apiKey } = useApiKey(); + const [showSettings, setShowSettings] = useState(false); + + return ( + + {renderBody({ scan, apiKey, showSettings, setShowSettings })} + + + ); +}; + +type RenderArgs = { + scan: ScanState; + apiKey: ReturnType['state']; + showSettings: boolean; + setShowSettings: (v: boolean) => void; +}; + +function renderBody({ scan, apiKey, showSettings, setShowSettings }: RenderArgs) { + if (apiKey.kind === 'unknown') return ; + if (apiKey.kind === 'missing') return setShowSettings(false)} />; + if (showSettings) return setShowSettings(false)} />; + + return renderScan(scan, () => setShowSettings(true)); +} + +function renderScan(state: ScanState, openSettings: () => void) { + switch (state.kind) { + case 'idle': + return ; + case 'loading': + return ; + case 'clean': + return ; + case 'success': + return ; + } +} + +export default App; diff --git a/apps/figma-token-review-plugin/src/ui/assets/confirm.svg b/apps/figma-token-review-plugin/src/ui/assets/confirm.svg new file mode 100644 index 000000000..7e0034f99 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/assets/confirm.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/figma-token-review-plugin/src/ui/assets/survey.svg b/apps/figma-token-review-plugin/src/ui/assets/survey.svg new file mode 100644 index 000000000..c8978413b --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/assets/survey.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/figma-token-review-plugin/src/ui/components/error-boundary.tsx b/apps/figma-token-review-plugin/src/ui/components/error-boundary.tsx new file mode 100644 index 000000000..632fca79d --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/error-boundary.tsx @@ -0,0 +1,72 @@ +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; + +type Props = { + children: ReactNode; + fallback?: ReactNode; +}; + +type State = { + error: Error | null; +}; + +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[ErrorBoundary]', error, info); + } + + reset = () => this.setState({ error: null }); + + render() { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.fallback) return this.props.fallback; + + return ( +
+

오류 발생

+
+                    {error.message}
+                
+ +
+ ); + } +} diff --git a/apps/figma-token-review-plugin/src/ui/components/hero-panel.tsx b/apps/figma-token-review-plugin/src/ui/components/hero-panel.tsx new file mode 100644 index 000000000..9ae2dc342 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/hero-panel.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps, ReactNode } from 'react'; + +import { Box, Button, Text } from '@vapor-ui/core'; + +function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Content({ children }: { children: ReactNode }) { + return {children}; +} + +function Image({ src, alt = '' }: { src: string; alt?: string }) { + return ( + + {alt} + + ); +} + +function Heading({ children }: { children: ReactNode }) { + return {children}; +} + +function Title({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Description({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +type ActionProps = Pick, 'onClick' | 'disabled'> & { + children: ReactNode; +}; + +function Action({ children, onClick, disabled }: ActionProps) { + return ( + + ); +} + +export const HeroPanel = { + Root, + Content, + Image, + Heading, + Title, + Description, + Action, +}; diff --git a/apps/figma-token-review-plugin/src/ui/components/loader.tsx b/apps/figma-token-review-plugin/src/ui/components/loader.tsx new file mode 100644 index 000000000..20c01e7d9 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/loader.tsx @@ -0,0 +1,64 @@ +import { Box, Spinner, Text } from '@vapor-ui/core'; +import { TimeOutlineIcon } from '@vapor-ui/icons'; + +import type { LoadingProgress } from '../features/scan'; + +type PhaseKey = 'extract' | 'thinking' | 'finalizing'; + +export type LoaderProps = { + progress?: LoadingProgress; +}; + +export function Loader({ progress }: LoaderProps) { + const phase = currentPhase(progress); + + return ( + + + + + + {statusText(phase)} + + + + + + {estimateText(progress)} + + + + + ); +} + +function statusText(phase: PhaseKey): string { + switch (phase) { + case 'extract': + return '피그마 노드 추출 중...'; + case 'thinking': + return 'AI 검사 중...'; + case 'finalizing': + return '결과 정리 중...'; + } +} + +function currentPhase(progress: LoadingProgress | undefined): PhaseKey { + if (!progress || !progress.llm) return 'extract'; + return progress.llm.phase; +} + +function estimateText(progress: LoadingProgress | undefined): string { + const ms = progress?.llm?.estimatedDurationMs; + if (typeof ms !== 'number') return '예상시간: 계산 중...'; + return `예상시간: 약 ${formatDuration(ms)}`; +} + +function formatDuration(ms: number): string { + const seconds = Math.max(1, Math.round(ms / 1000)); + if (seconds < 60) return `${seconds}초`; + const minutes = Math.floor(seconds / 60); + const rem = seconds % 60; + if (rem === 0) return `${minutes}분`; + return `${minutes}분 ${rem}초`; +} diff --git a/apps/figma-token-review-plugin/src/ui/components/resize-handler.tsx b/apps/figma-token-review-plugin/src/ui/components/resize-handler.tsx new file mode 100644 index 000000000..335b0e6c8 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/resize-handler.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef } from 'react'; + +import { postToCode } from '~/common/messages'; + +const MIN_WIDTH = 360; +const MIN_HEIGHT = 480; + +type DragState = { + startX: number; + startY: number; + startWidth: number; + startHeight: number; +}; + +export function ResizeHandle() { + const dragRef = useRef(null); + const rafRef = useRef(null); + const pendingRef = useRef<{ width: number; height: number } | null>(null); + + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, []); + + const flush = () => { + rafRef.current = null; + const pending = pendingRef.current; + if (!pending) return; + pendingRef.current = null; + postToCode({ type: 'resize', width: pending.width, height: pending.height }); + }; + + const queueResize = (width: number, height: number) => { + pendingRef.current = { width, height }; + if (rafRef.current !== null) return; + rafRef.current = requestAnimationFrame(flush); + }; + + const cancelPending = () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + pendingRef.current = null; + }; + + const handlePointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + + dragRef.current = { + startX: event.clientX, + startY: event.clientY, + startWidth: window.innerWidth, + startHeight: window.innerHeight, + }; + }; + + const handlePointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + + if (!drag) return; + if (event.buttons === 0) { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + cancelPending(); + return; + } + + const width = Math.max(MIN_WIDTH, drag.startWidth + (event.clientX - drag.startX)); + const height = Math.max(MIN_HEIGHT, drag.startHeight + (event.clientY - drag.startY)); + queueResize(width, height); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const drag = dragRef.current; + dragRef.current = null; + + if (!drag) { + cancelPending(); + return; + } + + cancelPending(); + const width = Math.max(MIN_WIDTH, drag.startWidth + (event.clientX - drag.startX)); + const height = Math.max(MIN_HEIGHT, drag.startHeight + (event.clientY - drag.startY)); + postToCode({ type: 'resize', width, height }); + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/figma-token-review-plugin/src/ui/components/toast.tsx b/apps/figma-token-review-plugin/src/ui/components/toast.tsx new file mode 100644 index 000000000..817b08984 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/toast.tsx @@ -0,0 +1,6 @@ +import { Toast } from '@vapor-ui/core'; + +export const toastManager = Toast.createToastManager(); +export const ToastProvider = (props: Toast.Provider.Props) => { + return ; +}; diff --git a/apps/figma-token-review-plugin/src/ui/components/violation-card/index.ts b/apps/figma-token-review-plugin/src/ui/components/violation-card/index.ts new file mode 100644 index 000000000..82fa7bb0b --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/violation-card/index.ts @@ -0,0 +1 @@ +export { ViolationCard } from './violation-card'; diff --git a/apps/figma-token-review-plugin/src/ui/components/violation-card/token-comparison.tsx b/apps/figma-token-review-plugin/src/ui/components/violation-card/token-comparison.tsx new file mode 100644 index 000000000..a942772ce --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/violation-card/token-comparison.tsx @@ -0,0 +1,96 @@ +import { Box, HStack, Text } from '@vapor-ui/core'; +import { ForwardPageOutlineIcon } from '@vapor-ui/icons'; + +import { isHexColor } from './utils'; + +type TokenComparisonProps = { + usedLabel: string; + suggestedLabel: string; + leftColor?: string | null; + rightColor?: string | null; +}; + +export function TokenComparison({ + usedLabel, + suggestedLabel, + leftColor, + rightColor, +}: TokenComparisonProps) { + return ( + + + + + + ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +type TokenChipProps = { + label: string; + swatch: string | null; + tone: 'used' | 'suggested'; +}; + +export function TokenChip({ label, swatch, tone }: TokenChipProps) { + return ( + + + + {label} + + + ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +type ColorSwatchProps = { + value: string | null; +}; + +export function ColorSwatch({ value }: ColorSwatchProps) { + if (!value || !isHexColor(value)) return null; + + return ( + + ); +} diff --git a/apps/figma-token-review-plugin/src/ui/components/violation-card/utils.ts b/apps/figma-token-review-plugin/src/ui/components/violation-card/utils.ts new file mode 100644 index 000000000..e071796fd --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/violation-card/utils.ts @@ -0,0 +1,10 @@ +export const HEX_RE = /#[0-9a-fA-F]{3,8}/; + +export function extractRawValue(detail: string): string | null { + const m = detail.match(HEX_RE); + return m ? m[0].toUpperCase() : null; +} + +export function isHexColor(value: string): boolean { + return HEX_RE.test(value); +} diff --git a/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-breadcrumb.tsx b/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-breadcrumb.tsx new file mode 100644 index 000000000..25c12ce91 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-breadcrumb.tsx @@ -0,0 +1,28 @@ +import { HStack, Text } from '@vapor-ui/core'; +import { CaretRightIcon } from '@vapor-ui/icons'; + +type ViolationBreadcrumbProps = { + name: string; + property: string; +}; + +export function ViolationBreadcrumb({ name, property }: ViolationBreadcrumbProps) { + return ( + + + {name} + + + + {property} + + + ); +} diff --git a/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-card.tsx b/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-card.tsx new file mode 100644 index 000000000..dc934e305 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-card.tsx @@ -0,0 +1,92 @@ +import { Card, VStack } from '@vapor-ui/core'; + +import type { SchemaMode, Violation } from '~/common/schemas'; +import { requestFocus } from '~/ui/features/messaging'; +import { loadColorSchema } from '~/ui/lib/loaders/color'; + +import { TokenComparison } from './token-comparison'; +import { ViolationBreadcrumb } from './violation-breadcrumb'; +import { ViolationDetail } from './violation-detail'; + +const PROPERTY_LABEL: Record = { + fill: 'Fill', + 'fill-on-text': 'Text Fill', + stroke: 'Stroke', + padding: 'Padding', + paddingTop: 'Padding Top', + paddingRight: 'Padding Right', + paddingBottom: 'Padding Bottom', + paddingLeft: 'Padding Left', + paddingVertical: 'Padding Vertical', + paddingHorizontal: 'Padding Horizontal', + gap: 'Gap', + width: 'Width', + height: 'Height', + borderRadius: 'Border Radius', + shadow: 'Shadow', + textStyle: 'Text Style', +}; + +const COLOR_PROPERTIES: ReadonlySet = new Set([ + 'fill', + 'fill-on-text', + 'stroke', +]); + +function isColorProperty(p: Violation['property']): boolean { + return COLOR_PROPERTIES.has(p); +} + +/** Resolve a token or raw hex to a hex string for the swatch. */ +function resolveHex(tokenOrHex: string | null, schemaMode: SchemaMode): string | null { + if (!tokenOrHex) return null; + if (/^#[0-9a-fA-F]{3,8}$/.test(tokenOrHex)) return tokenOrHex; + const schema = loadColorSchema(schemaMode); + const sem = schema.semantic[tokenOrHex]; + if (sem?.hex) return sem.hex; + const prim = schema.primitive[tokenOrHex]; + return prim ?? null; +} + +type ViolationCardProps = { + violation: Violation; + schemaMode: SchemaMode; +}; + +export function ViolationCard({ violation, schemaMode }: ViolationCardProps) { + const property = PROPERTY_LABEL[violation.property]; + const usedLabel = violation.token ?? violation.value ?? '—'; + const suggested = violation.suggested[0] ?? null; + const suggestedLabel = suggested ?? '추천 없음'; + const nodes = + violation.nodeIds && violation.nodeIds.length > 0 ? violation.nodeIds : [violation.nodeId]; + + const isColor = isColorProperty(violation.property); + const leftColor = isColor ? resolveHex(violation.token ?? violation.value, schemaMode) : null; + const rightColor = isColor && suggested ? resolveHex(suggested, schemaMode) : null; + + return ( + } onClick={() => requestFocus(nodes)}> + + + + + + + + + + + + ); +} diff --git a/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-detail.tsx b/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-detail.tsx new file mode 100644 index 000000000..271147ee3 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/components/violation-card/violation-detail.tsx @@ -0,0 +1,73 @@ +import type { Box } from '@vapor-ui/core'; +import { Badge, HStack, Text, VStack } from '@vapor-ui/core'; +import { AiSmartieIcon } from '@vapor-ui/icons'; + +import type { Confidence, Severity } from '~/common/schemas'; + +type ViolationDetailProps = { + message: string; + severity: Severity; + confidence?: Confidence; + wasDs?: boolean; +}; + +export function ViolationDetail({ message, severity, confidence, wasDs }: ViolationDetailProps) { + const severityMap = SEVERITY_MAP[severity]; + const confidenceMap = confidence ? CONFIDENCE_MAP[confidence] : null; + + return ( + + + + {severityMap.label} + + {confidenceMap && ( + + + AI 추천: {confidenceMap.label} + + )} + {wasDs ? ( + + detached + + ) : null} + + + {message} + + + ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +const DescriptionBox = ({ $css, children, ...props }: Box.Props) => ( + + {children} + +); + +/* -----------------------------------------------------------------------------------------------*/ + +type Value = { label: string; palette: Badge.Props['colorPalette'] }; + +const SEVERITY_MAP: Record = { + high: { label: '심각', palette: 'danger' }, + info: { label: '참고', palette: 'warning' }, +}; + +const CONFIDENCE_MAP: Record = { + HIGH: { label: '확신 높음', palette: 'success' }, + MED: { label: '확신 보통', palette: 'warning' }, + LOW: { label: '확신 낮음', palette: 'hint' }, +}; diff --git a/apps/figma-token-review-plugin/src/ui/features/api-key/index.ts b/apps/figma-token-review-plugin/src/ui/features/api-key/index.ts new file mode 100644 index 000000000..1ecede7ff --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/api-key/index.ts @@ -0,0 +1,3 @@ +export { apiKeyActions, apiKeyStore, currentApiKey } from './store'; +export type { ApiKeyStoreState } from './store'; +export { useApiKey } from './use-api-key'; diff --git a/apps/figma-token-review-plugin/src/ui/features/api-key/store.ts b/apps/figma-token-review-plugin/src/ui/features/api-key/store.ts new file mode 100644 index 000000000..dc85f7238 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/api-key/store.ts @@ -0,0 +1,38 @@ +import type { ApiKeyState } from '~/common/messages'; +import { postToCode } from '~/common/messages'; + +import { createStore } from '../../shared/create-store'; + +export type ApiKeyStoreState = + | { kind: 'unknown' } + | { kind: 'missing' } + | { kind: 'present'; key: string }; + +export const apiKeyStore = createStore({ kind: 'unknown' }); + +export const apiKeyActions = { + apply(state: ApiKeyState): void { + if (state.hasKey && state.key) { + apiKeyStore.setState({ kind: 'present', key: state.key }); + } else { + apiKeyStore.setState({ kind: 'missing' }); + } + }, + + requestSync(): void { + postToCode({ type: 'api-key:get' }); + }, + + save(value: string): void { + postToCode({ type: 'api-key:set', value }); + }, + + clear(): void { + postToCode({ type: 'api-key:clear' }); + }, +}; + +export function currentApiKey(): string | null { + const s = apiKeyStore.getState(); + return s.kind === 'present' ? s.key : null; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/api-key/use-api-key.ts b/apps/figma-token-review-plugin/src/ui/features/api-key/use-api-key.ts new file mode 100644 index 000000000..6175452ec --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/api-key/use-api-key.ts @@ -0,0 +1,12 @@ +import { useStore } from '../../shared/create-store'; +import { apiKeyActions, apiKeyStore } from './store'; + +export function useApiKey() { + const state = useStore(apiKeyStore); + + return { + state, + save: apiKeyActions.save, + clear: apiKeyActions.clear, + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/client.test.ts b/apps/figma-token-review-plugin/src/ui/features/llm/client.test.ts new file mode 100644 index 000000000..56cb2577a --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/client.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { LlmProgress } from './client'; +import { LlmHttpError, postLiteLLM } from './client'; +import type { AnthropicMessagesRequest } from './prompt'; + +const REQUEST: AnthropicMessagesRequest = { + model: 'test-model', + max_tokens: 1000, + system: [{ type: 'text', text: 'sys' }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], +}; + +describe('postLiteLLM', () => { + it('JSON 응답을 AnthropicMessagesResponse 로 반환', async () => { + const body = { content: [{ type: 'text', text: '{"a":1}' }] }; + const fetchImpl = vi.fn( + async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const progress: LlmProgress[] = []; + + const res = await postLiteLLM(REQUEST, { + env: { baseUrl: 'https://example.test', apiKey: 'k' }, + fetchImpl: fetchImpl as unknown as typeof fetch, + onProgress: (p) => progress.push(p), + }); + + expect(res.content).toEqual([{ type: 'text', text: '{"a":1}' }]); + expect(progress).toEqual([{ phase: 'thinking' }, { phase: 'finalizing' }]); + }); + + it('non-2xx 응답은 LlmHttpError', async () => { + const fetchImpl = vi.fn(async () => new Response('bad key', { status: 401 })); + + await expect( + postLiteLLM(REQUEST, { + env: { baseUrl: 'https://example.test', apiKey: 'k' }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toBeInstanceOf(LlmHttpError); + }); + + it('요청 body에 stream 옵션 없음', async () => { + let capturedBody = ''; + const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { + capturedBody = String(init.body); + return new Response(JSON.stringify({ content: [] }), { status: 200 }); + }); + + await postLiteLLM(REQUEST, { + env: { baseUrl: 'https://example.test', apiKey: 'k' }, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const parsed = JSON.parse(capturedBody); + expect(parsed.stream).toBeUndefined(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/client.ts b/apps/figma-token-review-plugin/src/ui/features/llm/client.ts new file mode 100644 index 000000000..e7b9cc6f5 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/client.ts @@ -0,0 +1,155 @@ +import type { AnthropicMessagesResponse } from './parse'; +import type { AnthropicMessagesRequest } from './prompt'; + +export type LlmEnv = { + baseUrl: string; + apiKey: string; +}; + +export class LlmHttpError extends Error { + readonly status: number; + readonly bodyText: string; + constructor(message: string, status: number, bodyText: string) { + super(message); + this.name = 'LlmHttpError'; + this.status = status; + this.bodyText = bodyText; + } +} + +export class LlmTimeoutError extends Error { + constructor(message = 'LLM 호출 timeout') { + super(message); + this.name = 'LlmTimeoutError'; + } +} + +export type LlmProgress = { + phase: 'thinking' | 'finalizing'; + estimatedDurationMs?: number; +}; + +export type PostOptions = { + env: LlmEnv; + signal?: AbortSignal; + timeoutMs?: number; + fetchImpl?: typeof fetch; + tags?: string[]; + onProgress?: (progress: LlmProgress) => void; +}; + +const DEFAULT_TIMEOUT_MS = 300_000; +const RATE_LIMIT_RETRY_DELAY_MS = 2_000; + +export async function postLiteLLM( + request: AnthropicMessagesRequest, + options: PostOptions, +): Promise { + const { + env, + signal, + timeoutMs = DEFAULT_TIMEOUT_MS, + fetchImpl = fetch, + tags, + onProgress, + } = options; + + try { + return await postOnce(request, env, signal, timeoutMs, fetchImpl, tags, onProgress); + } catch (err) { + if (err instanceof LlmHttpError && err.status === 429) { + await delay(RATE_LIMIT_RETRY_DELAY_MS, signal); + return postOnce(request, env, signal, timeoutMs, fetchImpl, tags, onProgress); + } + throw err; + } +} + +async function postOnce( + request: AnthropicMessagesRequest, + env: LlmEnv, + signal: AbortSignal | undefined, + timeoutMs: number, + fetchImpl: typeof fetch, + tags: string[] | undefined, + onProgress: ((p: LlmProgress) => void) | undefined, +): Promise { + const controller = new AbortController(); + + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + const upstreamAbort = () => controller.abort(signal?.reason); + signal?.addEventListener('abort', upstreamAbort); + + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', upstreamAbort); + }; + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.apiKey}`, + 'anthropic-version': '2023-06-01', + }; + + if (tags && tags.length > 0) { + headers['x-litellm-tags'] = tags.join(','); + } + + onProgress?.({ phase: 'thinking' }); + + let res: Response; + + try { + res = await fetchImpl(`${env.baseUrl.replace(/\/$/, '')}/v1/messages`, { + method: 'POST', + headers, + body: JSON.stringify(request), + signal: controller.signal, + }); + } catch (err) { + cleanup(); + if (timedOut) throw new LlmTimeoutError(); + throw err; + } + + if (!res.ok) { + const bodyText = await res.text().catch(() => ''); + cleanup(); + throw new LlmHttpError(`LiteLLM ${res.status}`, res.status, bodyText); + } + + try { + const json = (await res.json()) as AnthropicMessagesResponse; + onProgress?.({ phase: 'finalizing' }); + return json; + } catch (err) { + if (timedOut) throw new LlmTimeoutError(); + throw err; + } finally { + cleanup(); + } +} + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error('aborted')); + return; + } + + const timer = setTimeout(() => resolve(), ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(signal.reason ?? new Error('aborted')); + }, + { once: true }, + ); + }); +} diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/index.ts b/apps/figma-token-review-plugin/src/ui/features/llm/index.ts new file mode 100644 index 000000000..f1b8bb9c0 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/index.ts @@ -0,0 +1,160 @@ +import type { Category, LlmContext, RawExtract, ScanPayload } from '~/common/schemas'; +import { evaluateTextContrast } from '~/ui/lib/contrast/analyze'; +import { evaluateColor } from '~/ui/lib/evaluate/color'; +import { evaluateDimension } from '~/ui/lib/evaluate/dimension'; +import { evaluateRadius } from '~/ui/lib/evaluate/radius'; +import { evaluateShadow } from '~/ui/lib/evaluate/shadow'; +import { evaluateSpace } from '~/ui/lib/evaluate/space'; +import { evaluateTypography } from '~/ui/lib/evaluate/typography'; +import { loadColorSchema } from '~/ui/lib/loaders/color'; +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; +import { loadTextStyleSchema } from '~/ui/lib/loaders/typography'; +import { applyRecommendations } from '~/ui/lib/recommend'; +import { buildLlmInput } from '~/ui/lib/rubric'; + +import type { LlmEnv, LlmProgress } from './client'; +import { LlmHttpError, LlmTimeoutError, postLiteLLM } from './client'; +import type { CategoryDet, MergeArgs } from './merge'; +import { mergeScanPayload } from './merge'; +import { LlmParseError, parseLlmResponse } from './parse'; +import { buildRequest } from './prompt'; + +export type RunLlmEvaluationOptions = { + signal?: AbortSignal; + apiKey: string; + baseUrl?: string; + model?: string; + extraTags?: string[]; + onProgress?: (progress: LlmProgress) => void; +}; + +const DEFAULT_MODEL = 'claude-sonnet-4-6'; +const APP_TAG = 'app:figma-token-review-plugin'; +const FEATURE_TAG = 'feature:scan'; + +/** + * LLM 소요시간 추정. 관측치 2점 선형 회귀: + * (11 targets, 25s), (157 targets, 96s) → base 20s + 0.5s/target + */ +const ESTIMATE_BASE_MS = 20_000; +const ESTIMATE_PER_TARGET_MS = 500; + +export function estimateLlmDurationMs(targetCount: number): number { + return ESTIMATE_BASE_MS + ESTIMATE_PER_TARGET_MS * Math.max(0, targetCount); +} + +export class MissingApiKeyError extends Error { + constructor() { + super('LiteLLM API 키가 설정되지 않았습니다.'); + this.name = 'MissingApiKeyError'; + } +} + +export async function runLlmEvaluation( + extract: RawExtract, + llmContext: LlmContext, + options: RunLlmEvaluationOptions, +): Promise { + const apiKey = options.apiKey?.trim(); + if (!apiKey) throw new MissingApiKeyError(); + + const baseUrl = options.baseUrl ?? baseUrlFromImportMeta(); + if (!baseUrl) throw new Error('VITE_LITELLM_BASE_URL 누락'); + + const env: LlmEnv = { baseUrl, apiKey }; + const model = options.model ?? importMetaModel() ?? DEFAULT_MODEL; + + const colorSchema = loadColorSchema(extract.schemaMode); + const dim = loadDimensionSchemas(); + const textStyleSchema = loadTextStyleSchema(); + + // 텍스트 fill 픽셀 기반 색대비 판정 (WCAG AA). 실패 시 text-contrast-low violation. + const contrastViolations = await evaluateTextContrast(extract.colors); + const colorEval = evaluateColor(extract.colors, colorSchema); + colorEval.violations.push(...contrastViolations); + + const det: Record = { + color: { ...colorEval, total: extract.colors.length }, + space: { ...evaluateSpace(extract.spaces, dim.space), total: extract.spaces.length }, + dimension: { + ...evaluateDimension(extract.dimensions, dim.dimension), + total: extract.dimensions.length, + }, + typography: { + ...evaluateTypography(extract.typography, textStyleSchema), + total: extract.typography.length, + }, + borderRadius: { + ...evaluateRadius(extract.radii, dim.borderRadius), + total: extract.radii.length, + }, + shadow: { ...evaluateShadow(extract.shadows, dim.shadow), total: extract.shadows.length }, + }; + + const ctx = { + colorSchema, + space: dim.space, + dimension: dim.dimension, + borderRadius: dim.borderRadius, + shadow: dim.shadow, + }; + for (const key of Object.keys(det) as Category[]) { + det[key].violations = applyRecommendations(det[key].violations, ctx); + } + + const { input: llmInput, targets } = buildLlmInput({ + extract, + deterministicConformant: { + color: det.color.conformant, + typography: det.typography.conformant, + }, + colorSchema, + textStyleSchema, + nodeTree: llmContext.nodeTree, + }); + + const request = buildRequest(llmInput, llmContext.screenshotB64, model); + const tags = [APP_TAG, FEATURE_TAG, `model:${model}`, ...(options.extraTags ?? [])]; + + const targetCount = + llmInput.judgmentTargets.typography.length + llmInput.judgmentTargets.semanticColor.length; + const estimatedDurationMs = estimateLlmDurationMs(targetCount); + const upstream = options.onProgress; + const onProgress: ((p: LlmProgress) => void) | undefined = upstream + ? (p) => upstream({ ...p, estimatedDurationMs }) + : undefined; + + const response = await postLiteLLM(request, { + env, + signal: options.signal, + tags, + onProgress, + }); + const judgments = parseLlmResponse(response); + + const mergeArgs: MergeArgs = { + deterministic: det, + llm: judgments, + schemaMode: extract.schemaMode, + textStyleSchema, + targets, + }; + + return mergeScanPayload(mergeArgs); +} + +function baseUrlFromImportMeta(): string | undefined { + return importMetaString('VITE_LITELLM_BASE_URL'); +} + +function importMetaModel(): string | undefined { + return importMetaString('VITE_LITELLM_MODEL') || undefined; +} + +function importMetaString(key: string): string | undefined { + const env = (import.meta as ImportMeta & { env?: Record }).env; + return env ? env[key] : undefined; +} + +export { LlmHttpError, LlmTimeoutError, LlmParseError }; +export type { LlmProgress }; diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/merge.test.ts b/apps/figma-token-review-plugin/src/ui/features/llm/merge.test.ts new file mode 100644 index 000000000..74e80d7da --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/merge.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it } from 'vitest'; + +import type { MergeArgs, TargetLookup } from '~/ui/features/llm/merge'; +import { mergeScanPayload } from '~/ui/features/llm/merge'; +import type { LlmTypoJudgment } from '~/ui/features/llm/parse'; +import { loadTextStyleSchema } from '~/ui/lib/loaders/typography'; +import type { ColorTarget, TypographyTarget } from '~/ui/lib/rubric'; + +function emptyDeterministic(): MergeArgs['deterministic'] { + const empty = () => ({ violations: [], conformant: [], total: 0 }); + return { + color: empty(), + space: empty(), + dimension: empty(), + typography: empty(), + borderRadius: empty(), + shadow: empty(), + }; +} + +function makeTargets(overrides: Partial = {}): TargetLookup { + return { + typography: overrides.typography ?? new Map(), + color: overrides.color ?? new Map(), + nameByNodeId: overrides.nameByNodeId ?? new Map(), + wasDsNodeIds: overrides.wasDsNodeIds ?? new Set(), + }; +} + +describe('mergeScanPayload', () => { + it('nodeIds 로 묶인 conformant 는 노드별로 펼치고 LLM FAIL 노드만 제거된다', () => { + const payload = mergeScanPayload({ + deterministic: { + color: { + violations: [], + conformant: [ + { + nodeId: 'A', + nodeIds: ['A', 'B', 'C'], + name: 'title', + property: 'fill-on-text', + token: 'color-foreground-normal-100', + }, + ], + total: 3, + }, + space: { violations: [], conformant: [], total: 0 }, + dimension: { violations: [], conformant: [], total: 0 }, + typography: { violations: [], conformant: [], total: 0 }, + borderRadius: { violations: [], conformant: [], total: 0 }, + shadow: { violations: [], conformant: [], total: 0 }, + }, + llm: { + typography: [], + semanticColor: [ + { + nodeId: 'B', + property: 'fill-on-text', + confidence: 'HIGH', + reasoning: '위계 부적합', + suggested: [], + }, + ], + }, + schemaMode: 'light', + textStyleSchema: loadTextStyleSchema(), + targets: makeTargets({ + color: new Map([ + [ + 'B:fill-on-text', + { + nodeId: 'B', + property: 'fill-on-text', + token: 'color-foreground-normal-100', + }, + ], + ]), + nameByNodeId: new Map([['B', 'title']]), + }), + }); + const remainingIds = payload.color.conformant.map((c) => c.nodeId).sort(); + expect(remainingIds).toEqual(['A', 'C']); + expect(payload.color.violations[0].nodeId).toBe('B'); + expect(payload.color.violations[0].token).toBe('color-foreground-normal-100'); + expect(payload.color.violations[0].name).toBe('title'); + }); + + it('LLM heuristic FAIL 항목을 해당 카테고리 violations 로 합친다', () => { + const payload = mergeScanPayload({ + deterministic: { + color: { violations: [], conformant: [], total: 1 }, + space: { violations: [], conformant: [], total: 0 }, + dimension: { violations: [], conformant: [], total: 0 }, + typography: { violations: [], conformant: [], total: 1 }, + borderRadius: { violations: [], conformant: [], total: 0 }, + shadow: { violations: [], conformant: [], total: 0 }, + }, + llm: { + typography: [ + { + nodeId: '1', + confidence: 'HIGH', + axis: 'hierarchy', + matchedRule: '', + reasoning: '제목 자리에 본문', + suggested: ['heading4'], + }, + ], + semanticColor: [ + { + nodeId: '2', + property: 'fill', + confidence: 'MED', + reasoning: '경고 아님', + suggested: [], + }, + ], + }, + schemaMode: 'light', + textStyleSchema: loadTextStyleSchema(), + targets: makeTargets({ + typography: new Map([['1', { nodeId: '1', textStyle: 'body2' }]]), + color: new Map([ + [ + '2:fill', + { nodeId: '2', property: 'fill', token: 'color-background-danger-100' }, + ], + ]), + nameByNodeId: new Map([ + ['1', 'h'], + ['2', 'alert'], + ]), + }), + }); + expect(payload.typography.violations[0].origin).toBe('llm'); + expect(payload.typography.violations[0].severity).toBe('high'); + expect(payload.typography.violations[0].token).toBe('body2'); + expect(payload.color.violations[0].origin).toBe('llm'); + expect(payload.color.violations[0].token).toBe('color-background-danger-100'); + }); + + it('적합률은 severity=high 전체를 부적합으로 셈, confidence 는 별도 축', () => { + const payload = mergeScanPayload({ + deterministic: { + color: { violations: [], conformant: [], total: 10 }, + space: { violations: [], conformant: [], total: 0 }, + dimension: { violations: [], conformant: [], total: 0 }, + typography: { violations: [], conformant: [], total: 0 }, + borderRadius: { violations: [], conformant: [], total: 0 }, + shadow: { violations: [], conformant: [], total: 0 }, + }, + llm: { + typography: [], + semanticColor: [ + { + nodeId: '1', + property: 'fill', + confidence: 'HIGH', + reasoning: '', + suggested: [], + }, + { + nodeId: '2', + property: 'fill', + confidence: 'LOW', + reasoning: '', + suggested: [], + }, + ], + }, + schemaMode: 'light', + textStyleSchema: loadTextStyleSchema(), + targets: makeTargets({ + color: new Map([ + [ + '1:fill', + { nodeId: '1', property: 'fill', token: 'color-background-primary-100' }, + ], + [ + '2:fill', + { nodeId: '2', property: 'fill', token: 'color-background-primary-100' }, + ], + ]), + // 서로 다른 name → 그룹화되지 않고 2개 카드 유지 + nameByNodeId: new Map([ + ['1', 'a'], + ['2', 'b'], + ]), + }), + }); + expect(payload.color.summary.conformanceRate).toBeCloseTo(0.8); + expect(payload.color.summary.highViolations).toBe(2); + expect(payload.color.summary.heuristicViolations).toBe(2); + expect(payload.color.summary.lowConfidenceCount).toBe(1); + }); + + it('targets 에 없는 nodeId 의 LLM 판정은 조용히 드롭한다', () => { + const payload = mergeScanPayload({ + deterministic: emptyDeterministic(), + llm: { + typography: [ + { + nodeId: 'ghost', + confidence: 'HIGH', + axis: 'hierarchy', + matchedRule: '', + reasoning: '없는 노드', + suggested: [], + }, + ], + semanticColor: [], + }, + schemaMode: 'light', + textStyleSchema: loadTextStyleSchema(), + targets: makeTargets(), + }); + expect(payload.typography.violations).toHaveLength(0); + }); +}); + +const textStyleSchema = loadTextStyleSchema(); + +function makeTypoJudgment(overrides: Partial = {}): LlmTypoJudgment { + return { + nodeId: 'n1', + confidence: 'HIGH', + axis: 'hierarchy', + matchedRule: '본문에 heading 오용', + reasoning: '이유', + suggested: ['body1'], + ...overrides, + }; +} + +function mergeWithTypo( + judgment: LlmTypoJudgment, + typoTarget: TypographyTarget = { nodeId: 'n1', textStyle: 'body2' }, +) { + return mergeScanPayload({ + deterministic: emptyDeterministic(), + llm: { typography: [judgment], semanticColor: [] }, + schemaMode: 'light', + textStyleSchema, + targets: makeTargets({ + typography: new Map([[typoTarget.nodeId, typoTarget]]), + nameByNodeId: new Map([[typoTarget.nodeId, 'Label']]), + }), + }); +} + +describe('heuristicTypo axis mapping / message / filter', () => { + it('axis=hierarchy → violation.type=typo-hierarchy', () => { + const payload = mergeWithTypo(makeTypoJudgment({ axis: 'hierarchy' })); + expect(payload.typography.violations[0].type).toBe('typo-hierarchy'); + }); + + it('axis=role → violation.type=typo-role-misfit', () => { + const payload = mergeWithTypo(makeTypoJudgment({ axis: 'role' })); + expect(payload.typography.violations[0].type).toBe('typo-role-misfit'); + }); + + it('matchedRule 이 있으면 message 앞에 대괄호로 붙는다', () => { + const payload = mergeWithTypo( + makeTypoJudgment({ matchedRule: 'mobile → heading1', reasoning: '모바일임' }), + ); + expect(payload.typography.violations[0].message).toBe('[mobile → heading1] 모바일임'); + }); + + it('matchedRule 이 빈 문자열이면 reasoning 만 사용', () => { + const payload = mergeWithTypo(makeTypoJudgment({ matchedRule: '', reasoning: '이유만' })); + expect(payload.typography.violations[0].message).toBe('이유만'); + }); + + it('suggested 는 스키마에 없는 이름을 걸러낸다', () => { + const payload = mergeWithTypo( + makeTypoJudgment({ suggested: ['body1', 'nonexistent-style', 'subtitle1'] }), + ); + expect(payload.typography.violations[0].suggested).toEqual(['body1', 'subtitle1']); + }); + + it('reasoning 이 "PASS 로 재판단" 을 포함하면 emit 하지 않는다', () => { + const payload = mergeWithTypo( + makeTypoJudgment({ + reasoning: + 'subtitle2의 when에 부합하므로 이 자리는 적절하다. PASS로 재판단. emit 불필요.', + }), + ); + expect(payload.typography.violations).toHaveLength(0); + }); + + it('reasoning 이 "emit 불필요" 만 포함해도 드롭한다', () => { + const payload = mergeWithTypo(makeTypoJudgment({ reasoning: '적절 — emit 불필요' })); + expect(payload.typography.violations).toHaveLength(0); + }); +}); + +describe('LLM violation grouping', () => { + it('같은 (name, type, property, token) 조합의 여러 nodeId FAIL 은 한 카드로 묶인다', () => { + const payload = mergeScanPayload({ + deterministic: emptyDeterministic(), + llm: { + typography: [ + { + nodeId: 'n1', + confidence: 'MED', + axis: 'role', + matchedRule: 'rank/sub', + reasoning: 'a', + suggested: ['subtitle1'], + }, + { + nodeId: 'n2', + confidence: 'HIGH', + axis: 'role', + matchedRule: 'rank/sub', + reasoning: 'b', + suggested: ['subtitle1', 'heading4'], + }, + { + nodeId: 'n3', + confidence: 'LOW', + axis: 'role', + matchedRule: 'rank/sub', + reasoning: 'c', + suggested: [], + }, + ], + semanticColor: [], + }, + schemaMode: 'light', + textStyleSchema, + targets: makeTargets({ + typography: new Map([ + ['n1', { nodeId: 'n1', textStyle: 'subtitle2' }], + ['n2', { nodeId: 'n2', textStyle: 'subtitle2' }], + ['n3', { nodeId: 'n3', textStyle: 'subtitle2' }], + ]), + nameByNodeId: new Map([ + ['n1', 'BADGE'], + ['n2', 'BADGE'], + ['n3', 'BADGE'], + ]), + }), + }); + expect(payload.typography.violations).toHaveLength(1); + const v = payload.typography.violations[0]; + expect(v.nodeIds).toEqual(['n1', 'n2', 'n3']); + expect(v.count).toBe(3); + // 최고 confidence(HIGH) 판정의 message 를 대표로 사용 + expect(v.confidence).toBe('HIGH'); + expect(v.message).toBe('[rank/sub] b'); + // suggested 는 union, 순서 보존 + expect(v.suggested).toEqual(['subtitle1', 'heading4']); + }); + + it('그룹된 LLM FAIL 의 모든 nodeId 는 conformant 에서 제외된다', () => { + const payload = mergeScanPayload({ + deterministic: { + color: { + violations: [], + conformant: [ + { + nodeId: 'A', + nodeIds: ['A', 'B', 'C', 'D'], + name: 'BADGE', + property: 'fill-on-text', + token: 'color-foreground-normal-100', + }, + ], + total: 4, + }, + space: { violations: [], conformant: [], total: 0 }, + dimension: { violations: [], conformant: [], total: 0 }, + typography: { violations: [], conformant: [], total: 0 }, + borderRadius: { violations: [], conformant: [], total: 0 }, + shadow: { violations: [], conformant: [], total: 0 }, + }, + llm: { + typography: [], + semanticColor: [ + { + nodeId: 'B', + property: 'fill-on-text', + confidence: 'HIGH', + reasoning: '경고 아님', + suggested: [], + }, + { + nodeId: 'C', + property: 'fill-on-text', + confidence: 'HIGH', + reasoning: '경고 아님', + suggested: [], + }, + ], + }, + schemaMode: 'light', + textStyleSchema, + targets: makeTargets({ + color: new Map([ + [ + 'B:fill-on-text', + { + nodeId: 'B', + property: 'fill-on-text', + token: 'color-foreground-normal-100', + }, + ], + [ + 'C:fill-on-text', + { + nodeId: 'C', + property: 'fill-on-text', + token: 'color-foreground-normal-100', + }, + ], + ]), + nameByNodeId: new Map([ + ['B', 'BADGE'], + ['C', 'BADGE'], + ]), + }), + }); + expect(payload.color.violations).toHaveLength(1); + expect(payload.color.violations[0].nodeIds).toEqual(['B', 'C']); + const remaining = payload.color.conformant.map((c) => c.nodeId).sort(); + expect(remaining).toEqual(['A', 'D']); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/merge.ts b/apps/figma-token-review-plugin/src/ui/features/llm/merge.ts new file mode 100644 index 000000000..9d90ba24e --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/merge.ts @@ -0,0 +1,194 @@ +import type { + Category, + Conformant, + EvaluateOutput, + EvaluateSummary, + ScanPayload, + SchemaMode, + Violation, + ViolationType, +} from '~/common/schemas'; +import type { TextStyleSchema } from '~/ui/lib/loaders/typography'; +import type { TargetLookup } from '~/ui/lib/rubric'; + +import type { LlmColorJudgment, LlmJudgments, LlmTypoJudgment } from './parse'; + +export type CategoryDet = { violations: Violation[]; conformant: Conformant[]; total: number }; + +export type { TargetLookup }; + +export type MergeArgs = { + deterministic: Record; + llm: LlmJudgments; + schemaMode: SchemaMode; + textStyleSchema: TextStyleSchema; + targets: TargetLookup; +}; + +const AXIS_TO_TYPE: Record = { + hierarchy: 'typo-hierarchy', + role: 'typo-role-misfit', +}; + +// LLM 이 FAIL 로 emit 했지만 reasoning 안에서 스스로 "PASS 로 재판단 / emit 불필요" 로 취소하는 경우. +// 프롬프트 위반이지만 실제로 발생하므로 안전망으로 여기서 드롭한다. +const PASS_CANCEL_RE = + /(pass\s*(?:로|은|는|이|가|처럼|처리|판정)?\s*(?:재판단|판정|처리|해석)|emit\s*(?:불필요|금지|하지\s*(?:마|말|않)))/i; + +function isSelfCancelled(reasoning: string): boolean { + return PASS_CANCEL_RE.test(reasoning); +} + +function heuristicTypo( + j: LlmTypoJudgment, + schema: TextStyleSchema, + targets: TargetLookup, +): Violation | null { + const t = targets.typography.get(j.nodeId); + if (!t) return null; // target 소실 = LLM 이 없는 nodeId 지어냄. 드롭. + if (isSelfCancelled(j.reasoning)) return null; + + const known = new Set(schema.order); + const filtered = j.suggested.filter((s) => known.has(s)); + const message = j.matchedRule ? `[${j.matchedRule}] ${j.reasoning}` : j.reasoning; + return { + nodeId: j.nodeId, + name: targets.nameByNodeId.get(j.nodeId) ?? '', + property: 'textStyle', + token: t.textStyle, + value: null, + type: AXIS_TO_TYPE[j.axis], + severity: 'high', + origin: 'llm', + message, + suggested: filtered, + confidence: j.confidence, + wasDs: targets.wasDsNodeIds.has(j.nodeId) || undefined, + }; +} + +function heuristicColor(j: LlmColorJudgment, targets: TargetLookup): Violation | null { + const key = `${j.nodeId}:${j.property}`; + const t = targets.color.get(key); + if (!t) return null; + if (isSelfCancelled(j.reasoning)) return null; + return { + nodeId: j.nodeId, + name: targets.nameByNodeId.get(j.nodeId) ?? '', + property: j.property, + token: t.token, + value: null, + type: 'semantic-misfit', + severity: 'high', + origin: 'llm', + message: j.reasoning, + suggested: j.suggested, + confidence: j.confidence, + wasDs: targets.wasDsNodeIds.has(j.nodeId) || undefined, + }; +} + +const CONFIDENCE_RANK = { HIGH: 3, MED: 2, LOW: 1 } as const; + +/** + * 결정론 evaluator 는 extract 단계에서 groupBy 로 동일 사용처를 한 카드로 묶는다. + * LLM 은 nodeId 별 개별 target 을 판정하므로 같은 (name, type, property, token) 조합에 + * 여러 nodeId FAIL 이 쏟아지면 카드가 중복된다. 결정론과 동일한 시각적 취급을 위해 + * merge 단계에서 다시 그룹화한다. + */ +function groupLlmViolations(vs: Violation[]): Violation[] { + const map = new Map(); + for (const v of vs) { + const key = `${v.name}|${v.type}|${v.property}|${v.token ?? ''}`; + const g = map.get(key); + if (!g) { + map.set(key, { ...v, nodeIds: [v.nodeId], count: 1, suggested: [...v.suggested] }); + continue; + } + g.nodeIds!.push(v.nodeId); + g.count = (g.count ?? 1) + 1; + if (v.wasDs) g.wasDs = true; + for (const s of v.suggested) if (!g.suggested.includes(s)) g.suggested.push(s); + const curConf = g.confidence ?? 'LOW'; + const newConf = v.confidence ?? 'LOW'; + if (CONFIDENCE_RANK[newConf] > CONFIDENCE_RANK[curConf]) { + g.confidence = newConf; + g.message = v.message; // 대표 message 는 최고 confidence 판정에서 취한다. + } + } + return [...map.values()]; +} + +function summarize( + violations: Violation[], + conformant: Conformant[], + total: number, +): EvaluateSummary { + const high = violations.filter((v) => v.severity === 'high').length; + const infos = violations.filter((v) => v.severity === 'info').length; + + const heuristics = violations.filter((v) => v.origin === 'llm').length; + const lowConfidence = violations.filter( + (v) => v.origin === 'llm' && v.confidence !== 'HIGH', + ).length; + const conformCount = conformant.length; + + const conformanceRate = total > 0 ? (total - high) / total : null; + + return { + total, + conformCount, + conformanceRate, + highViolations: high, + infoFlags: infos, + heuristicViolations: heuristics, + lowConfidenceCount: lowConfidence, + }; +} + +export function mergeScanPayload(args: MergeArgs): ScanPayload { + const { deterministic, llm, schemaMode, textStyleSchema, targets } = args; + + const colorHeuristics = groupLlmViolations( + llm.semanticColor + .map((j) => heuristicColor(j, targets)) + .filter((v): v is Violation => v !== null), + ); + const typoHeuristics = groupLlmViolations( + llm.typography + .map((j) => heuristicTypo(j, textStyleSchema, targets)) + .filter((v): v is Violation => v !== null), + ); + + const buildOutput = (cat: Category, extra: Violation[]): EvaluateOutput => { + const d = deterministic[cat]; + const violations = [...d.violations, ...extra]; + const flagged = new Set(); + for (const v of extra) { + const ids = v.nodeIds && v.nodeIds.length > 0 ? v.nodeIds : [v.nodeId]; + for (const id of ids) flagged.add(`${id}:${v.property}`); + } + // 각 노드는 독립 판정 대상이므로 그룹 conformant 를 nodeIds 단위로 펼친다. + // LLM 이 특정 nodeId 만 FAIL 로 뒤집으면 그 노드만 conformant 에서 제외하고 형제는 유지한다. + const flatConformant: Conformant[] = d.conformant.flatMap((c) => { + const ids = c.nodeIds && c.nodeIds.length > 0 ? c.nodeIds : [c.nodeId]; + return ids.map((id) => ({ ...c, nodeId: id, nodeIds: [id] })); + }); + const conformant = flatConformant.filter((c) => !flagged.has(`${c.nodeId}:${c.property}`)); + return { + violations, + conformant, + summary: summarize(violations, conformant, d.total), + }; + }; + + return { + color: buildOutput('color', colorHeuristics), + space: buildOutput('space', []), + dimension: buildOutput('dimension', []), + typography: buildOutput('typography', typoHeuristics), + borderRadius: buildOutput('borderRadius', []), + shadow: buildOutput('shadow', []), + schemaMode, + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/parse.test.ts b/apps/figma-token-review-plugin/src/ui/features/llm/parse.test.ts new file mode 100644 index 000000000..d46141ac3 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/parse.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import { LlmParseError, parseLlmResponse } from '~/ui/features/llm/parse'; + +describe('parseLlmResponse', () => { + it('JSON text block 을 LlmJudgments 로 파싱한다', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [ + { + nodeId: '1', + confidence: 'HIGH', + axis: 'hierarchy', + matchedRule: 'x', + reasoning: '위계 이탈', + suggested: ['heading4'], + }, + ], + semanticColor: [ + { + nodeId: '2', + property: 'fill', + confidence: 'MED', + reasoning: '경고가 아닌 정보 자리', + suggested: ['colors.background.hint.100'], + }, + ], + }), + }, + ], + }; + const result = parseLlmResponse(response); + expect(result.typography[0].axis).toBe('hierarchy'); + expect(result.semanticColor[0].suggested).toEqual(['colors.background.hint.100']); + }); + + it('마크다운 fence 감싸도 추출한다', () => { + const response = { + content: [ + { + type: 'text' as const, + text: + '```json\n' + + JSON.stringify({ typography: [], semanticColor: [] }) + + '\n```', + }, + ], + }; + const result = parseLlmResponse(response); + expect(result.typography).toEqual([]); + }); + + it('typography 배열에 malformed item(null)이 있으면 LlmParseError를 던진다', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [null], + semanticColor: [], + }), + }, + ], + }; + expect(() => parseLlmResponse(response)).toThrow(LlmParseError); + }); + + it('semanticColor 배열에 malformed item(숫자)이 있으면 LlmParseError를 던진다', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [], + semanticColor: [42], + }), + }, + ], + }; + expect(() => parseLlmResponse(response)).toThrow(LlmParseError); + }); + + it('typography item 에 axis 필드가 없으면 LlmParseError', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [ + { + nodeId: '1', + confidence: 'HIGH', + matchedRule: '', + reasoning: '맞음', + suggested: [], + }, + ], + semanticColor: [], + }), + }, + ], + }; + expect(() => parseLlmResponse(response)).toThrow(LlmParseError); + }); + + it('typography item 의 axis 가 2-value union 밖이면 LlmParseError', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [ + { + nodeId: '1', + confidence: 'MED', + axis: 'viewport', + matchedRule: 'x', + reasoning: 'nope', + suggested: [], + }, + ], + semanticColor: [], + }), + }, + ], + }; + expect(() => parseLlmResponse(response)).toThrow(LlmParseError); + }); + + it('typography item 에 matchedRule 이 빈 문자열이면 통과한다', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [ + { + nodeId: '1', + confidence: 'HIGH', + axis: 'role', + matchedRule: '', + reasoning: '맞음', + suggested: [], + }, + ], + semanticColor: [], + }), + }, + ], + }; + const result = parseLlmResponse(response); + expect(result.typography[0].axis).toBe('role'); + expect(result.typography[0].matchedRule).toBe(''); + }); + + it('semanticColor item에 property 필드가 없으면 LlmParseError를 던진다', () => { + const response = { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + typography: [], + semanticColor: [ + { + nodeId: '1', + confidence: 'HIGH', + reasoning: 'ok', + suggested: [], + }, + ], + }), + }, + ], + }; + expect(() => parseLlmResponse(response)).toThrow(LlmParseError); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/parse.ts b/apps/figma-token-review-plugin/src/ui/features/llm/parse.ts new file mode 100644 index 000000000..79aee75d0 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/parse.ts @@ -0,0 +1,106 @@ +import type { Confidence } from '~/common/schemas'; + +type AnthropicTextBlock = { type: 'text'; text: string }; +type AnthropicContentBlock = AnthropicTextBlock | { type: string; [k: string]: unknown }; + +export type AnthropicMessagesResponse = { + content?: AnthropicContentBlock[]; +}; + +export type LlmTypoJudgment = { + nodeId: string; + confidence: Confidence; + axis: 'hierarchy' | 'role'; + matchedRule: string; + reasoning: string; + suggested: string[]; +}; + +export type LlmColorJudgment = { + nodeId: string; + property: 'fill' | 'fill-on-text' | 'stroke'; + confidence: Confidence; + reasoning: string; + suggested: string[]; +}; + +export type LlmJudgments = { + typography: LlmTypoJudgment[]; + semanticColor: LlmColorJudgment[]; +}; + +export class LlmParseError extends Error { + readonly raw: string | null; + constructor(message: string, raw: string | null) { + super(message); + this.name = 'LlmParseError'; + this.raw = raw; + } +} + +function extractJsonObject(text: string): string | null { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = (fenced ? fenced[1] : text).trim(); + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) return null; + return candidate.slice(start, end + 1); +} + +function isTypoJudgment(v: unknown): v is LlmTypoJudgment { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return ( + typeof o.nodeId === 'string' && + (o.confidence === 'HIGH' || o.confidence === 'MED' || o.confidence === 'LOW') && + (o.axis === 'hierarchy' || o.axis === 'role') && + typeof o.matchedRule === 'string' && + typeof o.reasoning === 'string' && + Array.isArray(o.suggested) + ); +} + +function isColorJudgment(v: unknown): v is LlmColorJudgment { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return ( + typeof o.nodeId === 'string' && + (o.property === 'fill' || o.property === 'fill-on-text' || o.property === 'stroke') && + (o.confidence === 'HIGH' || o.confidence === 'MED' || o.confidence === 'LOW') && + typeof o.reasoning === 'string' && + Array.isArray(o.suggested) + ); +} + +function isJudgments(value: unknown): value is LlmJudgments { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + Array.isArray(v.typography) && + v.typography.every(isTypoJudgment) && + Array.isArray(v.semanticColor) && + v.semanticColor.every(isColorJudgment) + ); +} + +export function parseLlmResponse(response: AnthropicMessagesResponse): LlmJudgments { + const blocks = response.content ?? []; + const lastText = [...blocks] + .reverse() + .find( + (b): b is AnthropicTextBlock => + b.type === 'text' && typeof (b as AnthropicTextBlock).text === 'string', + ); + if (!lastText) throw new LlmParseError('LLM 응답에 text block이 없습니다.', null); + const json = extractJsonObject(lastText.text); + if (!json) throw new LlmParseError('LLM 응답에서 JSON 객체를 찾을 수 없습니다.', lastText.text); + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new LlmParseError(`JSON parse 실패: ${msg}`, json); + } + if (!isJudgments(parsed)) throw new LlmParseError('LlmJudgments schema mismatch.', json); + return parsed; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/prompt.test.ts b/apps/figma-token-review-plugin/src/ui/features/llm/prompt.test.ts new file mode 100644 index 000000000..96cd2bd8d --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/prompt.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import type { LlmInput } from '~/ui/lib/rubric'; + +import { buildRequest } from './prompt'; + +function baseInput(): LlmInput { + return { + context: { schemaMode: 'light', viewport: 'pc', totalRanks: 0 }, + judgmentTargets: { typography: [], semanticColor: [] }, + rubric: { textStyle: {}, color: {} }, + nodeTree: [], + }; +} + +describe('buildRequest', () => { + it('with a non-empty screenshot, emits [text, image] content blocks', () => { + const req = buildRequest(baseInput(), 'AAAA', 'claude-sonnet-4-6'); + expect(req.messages).toHaveLength(1); + const content = req.messages[0]!.content as Array<{ type: string }>; + expect(content).toHaveLength(2); + expect(content[0]).toMatchObject({ type: 'text' }); + expect(content[1]).toMatchObject({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, + }); + }); + + it('with empty screenshot, emits [text] only', () => { + const req = buildRequest(baseInput(), '', 'claude-sonnet-4-6'); + const content = req.messages[0]!.content as Array<{ type: string }>; + expect(content).toHaveLength(1); + expect(content[0]).toMatchObject({ type: 'text' }); + }); + + it('serializes the input into the text block as JSON', () => { + const input = baseInput(); + input.context.totalRanks = 16; + const req = buildRequest(input, '', 'claude-sonnet-4-6'); + const first = (req.messages[0]!.content as Array<{ type: string; text?: string }>)[0]!; + const parsed = JSON.parse(first.text ?? ''); + expect(parsed.context.totalRanks).toBe(16); + expect(Array.isArray(parsed.nodeTree)).toBe(true); + }); + + it('SYSTEM_BASE typography schema 블록에 axis 와 matchedRule 필드가 포함되고 verdict 는 제외된다', () => { + const req = buildRequest(baseInput(), '', 'claude-sonnet-4-6'); + const combined = req.system.map((b) => b.text).join('\n'); + expect(combined).toContain('"axis"'); + expect(combined).toContain('"matchedRule"'); + expect(combined).toContain('"hierarchy"'); + expect(combined).toContain('"role"'); + // viewport 축은 결정론이 담당 → LLM 스키마에서 제외 + expect(combined).not.toContain('"viewport"'); + // FAIL 만 emit — verdict 필드 자체 제외 + expect(combined).not.toContain('"verdict"'); + }); + + it('SEMANTIC_GUIDE 는 hierarchy/role 두 축 정의와 rubric 활용 지시를 포함한다', () => { + const req = buildRequest(baseInput(), '', 'claude-sonnet-4-6'); + const combined = req.system.map((b) => b.text).join('\n'); + expect(combined).toContain('hierarchy'); + expect(combined).toContain('role'); + expect(combined).toContain('rubric.textStyle'); + expect(combined).toContain('matchedRule'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/features/llm/prompt.ts b/apps/figma-token-review-plugin/src/ui/features/llm/prompt.ts new file mode 100644 index 000000000..e529ffea7 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/llm/prompt.ts @@ -0,0 +1,146 @@ +import type { LlmInput } from '~/ui/lib/rubric'; + +const SYSTEM_BASE = [ + '당신은 vapor 디자인 토큰의 의미 판정자다.', + '결정론 분석은 일체 하지 않는다 — plugin이 이미 끝냈다.', + '너의 역할은 두 가지뿐이다.', + '1) 텍스트 위계(text styles) 적합성 분석', + '2) semantic color의 역할/상태/상황 적합성 분석', + '', + 'PASS 판정은 응답에서 제외한다. FAIL 항목만 emit 한다 (부재 = PASS).', + '결정론 fail 노드는 입력에 없다. 결정론 fail에 대한 의견은 내지 마라.', + '', + '자기 취소 금지 — 이 규칙 위반이 가장 잦은 실수다.', + 'reasoning 안에 다음 어휘가 하나라도 나오면 그 항목은 FAIL 이 아니다. 응답 배열에서 통째로 빼라.', + ' - "적합하다" / "적합함" / "적절하다" / "적절함"', + ' - "부합한다" / "부합함" (단, "부합하지 않다" 는 위반 서술 → emit 대상)', + ' - "PASS" / "PASS 에 해당" / "PASS 로 재판단"', + ' - "emit 제외" / "emit 불필요" / "emit 하지 않는다"', + ' - "응답에서 제외" / "배열에서 제외" / "항목 자체를 빼라"', + ' - "재판단" / "판정을 뒤집는다"', + '', + 'emit 직전 self-check (모든 항목에 대해):', + ' 1) reasoning 이 "…적합/부합/적절…" 로 끝나는가? → 빼라', + ' 2) reasoning 이 "…따라서 emit 한다/제외한다/PASS…" 문장을 포함하는가? → 빼라', + ' 3) matchedRule 이 빈 문자열이고 확실한 위반 근거가 없는가? → 빼라', + ' 4) 위 셋이 모두 아니어야만 응답 배열에 포함한다.', + '', + 'Bad emit (절대 배열에 넣지 마라):', + ' {', + ' "nodeId": "X",', + ' "property": "fill-on-text",', + ' "confidence": "HIGH",', + ' "reasoning": "빨간 배경 위 흰색 텍스트에 color-foreground-inverse 는 부합한다. PASS 에 해당하므로 emit 제외한다.",', + ' "suggested": []', + ' }', + ' → reasoning 이 "부합한다" + "PASS" + "emit 제외" 를 포함. 이건 FAIL 이 아니다. 배열에서 빼라.', + '', + 'Good emit (배열에 포함):', + ' {', + ' "nodeId": "Y",', + ' "property": "fill-on-text",', + ' "confidence": "HIGH",', + ' "reasoning": "성공 메시지 텍스트가 아닌 일반 본문에 color-foreground-success-100 사용. when 의 \'성공/완료 메시지\' 조건에 부합하지 않는다.",', + ' "suggested": ["color-foreground-normal-100"]', + ' }', + ' → 위반 서술만 있고 자기취소 어휘 없음. 정상 emit.', + '', + '판정 입력은 세 가지다:', + '1) rubric — 각 토큰의 의도(when/avoid, typography는 rank 포함). 사용된 토큰의 의도와 자리의 부합 여부를 판정.', + '2) nodeTree — 프레임 하위 노드의 구조(id, type, name, parentId). TEXT 노드는 characters, textStyle 필드도 포함해 이웃 텍스트 간 rank 비교에 사용.', + '3) 첨부 이미지 — 렌더된 프레임. 구조로 안 잡히는 시각적 위계(가장 큰 헤딩인가, 경고 색 자리인가)를 판정에 반영.', + '출력은 다음 JSON 객체 하나뿐. 마크다운 fence/문장/접두사 금지.', + '{', + ' "typography": LlmTypoJudgment[],', + ' "semanticColor": LlmColorJudgment[]', + '}', + '', + 'LlmTypoJudgment = {', + ' "nodeId": string,', + ' "confidence": "HIGH" | "MED" | "LOW",', + ' "axis": "hierarchy" | "role",', + ' "matchedRule": string, // 위반한 when/avoid 원문. 스키마에 없는 규칙 발명 금지. 없으면 빈 문자열.', + ' "reasoning": string (Korean),', + ' "suggested": string[] // rubric.textStyle 키만. 빈 배열 가능. 절대 string 으로 내지 마라.', + '}', + '', + 'LlmColorJudgment = {', + ' "nodeId": string,', + ' "property": "fill" | "fill-on-text" | "stroke",', + ' "confidence": "HIGH" | "MED" | "LOW",', + ' "reasoning": string (Korean),', + ' "suggested": string[]', + '}', + '', + 'Hard rules:', + '- verdict 필드는 emit 하지 마라. FAIL 만 emit 하므로 verdict 는 항상 FAIL 로 취급된다.', + '- name/token 은 응답에 넣지 마라. 서버가 nodeId(+property) 로 재구성한다.', + '- suggested 는 항상 배열. 후보 없으면 [].', + '- 토큰 키(예: color-background-danger-100, subtitle1)는 영문 그대로.', + '- reasoning 은 한국어. 어느 when/avoid 항목이 위배되는지 명시.', + '- 확신이 약하면 confidence 를 낮춰라. 완전 무증거면 항목 자체를 emit 하지 마라.', +].join('\n'); + +const SEMANTIC_GUIDE = [ + '의미 판정 가이드:', + '', + '색상 판정 축: semantic color 의 when/avoid 가 전제하는 역할(danger/warning/primary/normal/hint 등) 과 실제 자리(노드 이름·부모·인접 노드)의 의미가 부합하는가.', + '- avoid 의 "조건 → color-X-Y" 형식은 우변이 그대로 remedy 후보다.', + '', + 'typography 판정 축 (각 대상에 정확히 하나 라벨):', + '- hierarchy: 시각 위계 뒤집힘 / 본문에 heading 오용 / heading 에 body 오용. 근거는 rank(작을수록 큰 제목) 와 nodeTree 상 이웃 TEXT 노드의 rank·characters 비교 + 스크린샷의 시각 크기.', + '- role: when/avoid 의 역할·문맥 규칙 위배 (예: subtitle 자리에 body, code 자리에 body).', + '(viewport 축은 결정론이 이미 판정. LLM 은 다루지 않는다.)', + '', + '입력 3종 활용:', + '1. rubric.textStyle — 전체 스타일의 rank/when/avoid. 대체 후보(suggested) 는 이 안에서만 선택.', + '2. nodeTree — 각 TEXT 노드에 characters, textStyle 포함. 형제·부모 그룹에서 이웃 rank 비교로 위계 판정.', + '3. 첨부 이미지 — 시각 크기·강조·위치. 구조로 안 잡히는 위계는 이미지에서.', + '', + '각 typography 판정에 필수:', + '- axis: "hierarchy" | "role"', + '- matchedRule: 위반한 when/avoid 문장 원문. 스키마에 없는 규칙 발명 금지. 없으면 빈 문자열.', + '- suggested: rubric.textStyle 키만 허용.', + '', + '점수화(0-100) 금지. confidence 만.', +].join('\n'); + +export type SystemBlock = { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }; + +export type TextBlock = { type: 'text'; text: string }; + +export type ImageBlock = { + type: 'image'; + source: { type: 'base64'; media_type: 'image/png'; data: string }; +}; + +export type AnthropicMessagesRequest = { + model: string; + max_tokens: number; + system: SystemBlock[]; + messages: Array<{ role: 'user'; content: Array }>; +}; + +export function buildRequest( + input: LlmInput, + screenshotB64: string, + model: string, +): AnthropicMessagesRequest { + const content: Array = [{ type: 'text', text: JSON.stringify(input) }]; + if (screenshotB64) { + content.push({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: screenshotB64 }, + }); + } + + return { + model, + max_tokens: 30000, + system: [ + { type: 'text', text: SYSTEM_BASE }, + { type: 'text', text: SEMANTIC_GUIDE, cache_control: { type: 'ephemeral' } }, + ], + messages: [{ role: 'user', content }], + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/messaging/evaluate.ts b/apps/figma-token-review-plugin/src/ui/features/messaging/evaluate.ts new file mode 100644 index 000000000..1dfee00ac --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/messaging/evaluate.ts @@ -0,0 +1,63 @@ +import type { CodeMsg } from '~/common/messages'; + +import { toastManager } from '../../components/toast'; +import { apiKeyActions, currentApiKey } from '../api-key'; +import { LlmHttpError, LlmParseError, MissingApiKeyError, runLlmEvaluation } from '../llm'; +import { scanActions, scanStore } from '../scan'; + +let activeEvaluation: AbortController | null = null; + +export async function evaluateExtract( + msg: Extract, +): Promise { + const state = scanStore.getState(); + if (state.kind !== 'loading' || state.requestId !== msg.requestId) return; + + activeEvaluation?.abort(); + const controller = new AbortController(); + activeEvaluation = controller; + + try { + const apiKey = currentApiKey(); + if (!apiKey) throw new MissingApiKeyError(); + + const payload = await runLlmEvaluation(msg.payload.extract, msg.payload.llmContext, { + signal: controller.signal, + apiKey, + onProgress: (progress) => scanActions.progress(msg.requestId, progress), + }); + if (activeEvaluation !== controller) return; + scanActions.result(payload, msg.requestId); + } catch (err) { + if (activeEvaluation !== controller) return; + if (controller.signal.aborted) return; + + const applied = scanActions.error(msg.requestId); + if (!applied) return; + + if (err instanceof LlmHttpError && (err.status === 401 || err.status === 403)) { + apiKeyActions.clear(); + } + + toastManager.add({ + title: messageFromEvaluatorError(err), + colorPalette: 'danger', + }); + } finally { + if (activeEvaluation === controller) activeEvaluation = null; + } +} + +function messageFromEvaluatorError(err: unknown): string { + if (err instanceof MissingApiKeyError) return err.message; + if (err instanceof LlmHttpError) { + if (err.status === 401 || err.status === 403) { + return 'API 키가 유효하지 않아 삭제되었습니다. 다시 설정해 주세요.'; + } + if (err.status === 429) return '요청이 많습니다. 잠시 후 다시 시도해 주세요.'; + return `LLM 호출 실패 (${err.status})`; + } + if (err instanceof LlmParseError) return 'LLM 응답 형식 오류'; + if (err instanceof Error) return err.message; + return '알 수 없는 오류'; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/messaging/focus.ts b/apps/figma-token-review-plugin/src/ui/features/messaging/focus.ts new file mode 100644 index 000000000..d8831438c --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/messaging/focus.ts @@ -0,0 +1,14 @@ +import { newRequestId, postToCode } from '~/common/messages'; +import type { RequestId } from '~/common/messages'; + +let activeFocusId: RequestId | null = null; + +export function requestFocus(nodeIds: string[]): void { + const requestId = newRequestId(); + activeFocusId = requestId; + postToCode({ type: 'focus', nodeIds, requestId }); +} + +export function isActiveFocus(requestId: RequestId): boolean { + return requestId === activeFocusId; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/messaging/index.ts b/apps/figma-token-review-plugin/src/ui/features/messaging/index.ts new file mode 100644 index 000000000..629f2932c --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/messaging/index.ts @@ -0,0 +1,5 @@ +export { useBridge } from './use-bridge'; +export { useRequestSelection } from './use-request-selection'; +export { useRequestApiKey } from './use-request-api-key'; +export { requestFocus, isActiveFocus } from './focus'; +export { evaluateExtract } from './evaluate'; diff --git a/apps/figma-token-review-plugin/src/ui/features/messaging/use-bridge.ts b/apps/figma-token-review-plugin/src/ui/features/messaging/use-bridge.ts new file mode 100644 index 000000000..5b1a013d7 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/messaging/use-bridge.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; + +import type { CodeMsg } from '~/common/messages'; + +import { toastManager } from '../../components/toast'; +import { apiKeyActions } from '../api-key'; +import { scanActions } from '../scan'; +import { selectionStore } from '../selection'; +import { evaluateExtract } from './evaluate'; +import { isActiveFocus } from './focus'; + +export const useBridge = () => { + useEffect(() => { + window.addEventListener('message', handleWindowMessage); + + return () => { + window.removeEventListener('message', handleWindowMessage); + }; + }, []); +}; + +/* ----- utils ----- */ + +function handleWindowMessage(event: MessageEvent) { + const msg = event.data?.pluginMessage as CodeMsg | undefined; + if (!msg || typeof msg.type !== 'string') return; + handle(msg); +} + +function handle(msg: CodeMsg) { + switch (msg.type) { + case 'selection': + selectionStore.setState(msg.state); + return; + case 'extract-result': + void evaluateExtract(msg); + return; + case 'extract-error': { + const applied = scanActions.error(msg.requestId); + if (!applied) return; + toastManager.add({ title: msg.message, colorPalette: 'danger' }); + return; + } + case 'focus-error': + if (!isActiveFocus(msg.requestId)) return; + toastManager.add({ title: msg.message, colorPalette: 'danger' }); + return; + case 'focus-result': + if (!isActiveFocus(msg.requestId)) return; + if (msg.resolved <= 0 || msg.missing <= 0) return; + toastManager.add({ + title: `${msg.missing}개 노드 누락`, + colorPalette: 'info', + }); + return; + case 'api-key:state': + apiKeyActions.apply(msg.state); + return; + } +} diff --git a/apps/figma-token-review-plugin/src/ui/features/messaging/use-request-api-key.ts b/apps/figma-token-review-plugin/src/ui/features/messaging/use-request-api-key.ts new file mode 100644 index 000000000..a39be08c9 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/messaging/use-request-api-key.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react'; + +import { apiKeyActions } from '../api-key'; + +export const useRequestApiKey = () => { + useEffect(() => { + apiKeyActions.requestSync(); + }, []); +}; diff --git a/apps/figma-token-review-plugin/src/ui/features/messaging/use-request-selection.ts b/apps/figma-token-review-plugin/src/ui/features/messaging/use-request-selection.ts new file mode 100644 index 000000000..483075696 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/messaging/use-request-selection.ts @@ -0,0 +1,9 @@ +import { useEffect } from 'react'; + +import { postToCode } from '~/common/messages'; + +export const useRequestSelection = () => { + useEffect(() => { + postToCode({ type: 'request-selection' }); + }, []); +}; diff --git a/apps/figma-token-review-plugin/src/ui/features/scan/index.ts b/apps/figma-token-review-plugin/src/ui/features/scan/index.ts new file mode 100644 index 000000000..8736e8ad5 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/scan/index.ts @@ -0,0 +1,4 @@ +export type { LoadingProgress, ScanState } from './store'; +export { scanActions, scanStore } from './store'; +export { useScan } from './use-scan'; +export type { UseScan } from './use-scan'; diff --git a/apps/figma-token-review-plugin/src/ui/features/scan/store.ts b/apps/figma-token-review-plugin/src/ui/features/scan/store.ts new file mode 100644 index 000000000..981cd0fef --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/scan/store.ts @@ -0,0 +1,83 @@ +import type { RequestId } from '~/common/messages'; +import type { ScanPayload } from '~/common/schemas'; + +import { createStore } from '../../shared/create-store'; +import type { LlmProgress } from '../llm'; + +/** + * 마지막 scan 시점의 frame snapshot. 재검사 gating(선택 이탈 감지) 용도로 유지된다. + * 현재 selection은 useSelection에서 읽는다 — 여기선 관여하지 않는다. + */ +type LastScanned = { + lastScannedFrameId: string | null; + lastScannedFrameName: string | null; +}; + +export type LoadingProgress = { + startedAt: number; + llm: LlmProgress | null; +}; + +export type ScanState = LastScanned & + ( + | { kind: 'idle' } + | { kind: 'loading'; requestId: RequestId; progress: LoadingProgress } + | { kind: 'clean' } + | { kind: 'success'; payload: ScanPayload } + ); + +const INITIAL_LAST_SCANNED: LastScanned = { + lastScannedFrameId: null, + lastScannedFrameName: null, +}; + +export const scanStore = createStore({ ...INITIAL_LAST_SCANNED, kind: 'idle' }); + +export const scanActions = { + start(frameId: string, frameName: string, requestId: RequestId) { + scanStore.setState({ + kind: 'loading', + requestId, + progress: { startedAt: Date.now(), llm: null }, + lastScannedFrameId: frameId, + lastScannedFrameName: frameName, + }); + }, + progress(requestId: RequestId, llm: LlmProgress) { + const state = scanStore.getState(); + if (state.kind !== 'loading' || state.requestId !== requestId) return; + scanStore.setState({ + ...state, + progress: { ...state.progress, llm }, + }); + }, + result(payload: ScanPayload, requestId: RequestId) { + const state = scanStore.getState(); + if (state.kind !== 'loading') return; + if (state.requestId !== requestId) return; + + const empty = + payload.color.violations.length === 0 && + payload.typography.violations.length === 0 && + payload.space.violations.length === 0 && + payload.dimension.violations.length === 0 && + payload.borderRadius.violations.length === 0 && + payload.shadow.violations.length === 0; + + scanStore.setState({ + kind: empty ? 'clean' : 'success', + lastScannedFrameId: state.lastScannedFrameId, + lastScannedFrameName: state.lastScannedFrameName, + ...(empty ? {} : { payload }), + } as ScanState); + }, + error(requestId: RequestId): boolean { + const state = scanStore.getState(); + if (state.kind === 'loading' && state.requestId !== requestId) return false; + scanStore.setState({ ...INITIAL_LAST_SCANNED, kind: 'idle' }); + return true; + }, + reset() { + scanStore.setState({ ...INITIAL_LAST_SCANNED, kind: 'idle' }); + }, +}; diff --git a/apps/figma-token-review-plugin/src/ui/features/scan/use-scan.ts b/apps/figma-token-review-plugin/src/ui/features/scan/use-scan.ts new file mode 100644 index 000000000..a08bc6f28 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/scan/use-scan.ts @@ -0,0 +1,25 @@ +import { newRequestId, postToCode } from '~/common/messages'; + +import { useStore } from '../../shared/create-store'; +import type { ScanState } from './store'; +import { scanActions, scanStore } from './store'; + +export type UseScan = { + state: ScanState; + start: (frameId: string, frameName: string) => void; + reset: () => void; +}; + +export function useScan(): UseScan { + const state = useStore(scanStore); + + return { + state, + start: (frameId, frameName) => { + const requestId = newRequestId(); + scanActions.start(frameId, frameName, requestId); + postToCode({ type: 'scan', frameId, requestId }); + }, + reset: scanActions.reset, + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/selection/index.ts b/apps/figma-token-review-plugin/src/ui/features/selection/index.ts new file mode 100644 index 000000000..b0fb6cf29 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/selection/index.ts @@ -0,0 +1,3 @@ +export { selectionStore } from './store'; +export { useSelection, type SelectionStrategy } from './use-selection'; +export * from './plugins'; diff --git a/apps/figma-token-review-plugin/src/ui/features/selection/plugins.ts b/apps/figma-token-review-plugin/src/ui/features/selection/plugins.ts new file mode 100644 index 000000000..27deeba1e --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/selection/plugins.ts @@ -0,0 +1,22 @@ +import type { SelectionState } from '~/common/schemas'; + +import type { SelectionStrategy } from './use-selection'; + +type FrameSelection = Extract; +type InvalidSelection = Extract; + +export function pluginFrame(handler: (sel: FrameSelection) => T): SelectionStrategy { + return { frame: handler }; +} + +export function pluginNone(handler: () => T): SelectionStrategy { + return { none: handler }; +} + +export function pluginMulti(handler: () => T): SelectionStrategy { + return { multi: handler }; +} + +export function pluginInvalid(handler: (sel: InvalidSelection) => T): SelectionStrategy { + return { invalid: handler }; +} diff --git a/apps/figma-token-review-plugin/src/ui/features/selection/store.ts b/apps/figma-token-review-plugin/src/ui/features/selection/store.ts new file mode 100644 index 000000000..5235c03e5 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/selection/store.ts @@ -0,0 +1,5 @@ +import type { SelectionState } from '~/common/schemas'; + +import { createStore } from '../../shared/create-store'; + +export const selectionStore = createStore({ kind: 'none' }); diff --git a/apps/figma-token-review-plugin/src/ui/features/selection/use-selection.ts b/apps/figma-token-review-plugin/src/ui/features/selection/use-selection.ts new file mode 100644 index 000000000..2755464a1 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/features/selection/use-selection.ts @@ -0,0 +1,22 @@ +import type { SelectionState } from '~/common/schemas'; + +import { useStore } from '../../shared/create-store'; +import { selectionStore } from './store'; + +export type SelectionStrategy = { + [K in SelectionState['kind']]?: (sel: Extract) => T; +}; + +type UseSelectionReturn = { + selection: SelectionState; + buildAction: (strategy: SelectionStrategy) => () => void; +}; + +export function useSelection(): UseSelectionReturn { + const selection = useStore(selectionStore); + return { + selection, + buildAction: (strategy: SelectionStrategy) => () => + strategy[selection.kind]?.(selection as never), + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/index.css b/apps/figma-token-review-plugin/src/ui/index.css new file mode 100644 index 000000000..ee094530e --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/index.css @@ -0,0 +1,6 @@ +@layer tw-theme, vapor, tw-utilities; + +@import '@vapor-ui/core/tailwind.css'; + +@import 'tailwindcss/theme.css' layer(tw-theme); +@import 'tailwindcss/utilities.css' layer(tw-utilities); diff --git a/apps/figma-token-review-plugin/src/ui/lib/contrast/analyze.ts b/apps/figma-token-review-plugin/src/ui/lib/contrast/analyze.ts new file mode 100644 index 000000000..9b8e63164 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/contrast/analyze.ts @@ -0,0 +1,239 @@ +/** + * 텍스트 노드의 실제 배경 hex 를 확정하고 WCAG contrast ratio 를 계산. + + * WCAG: + * contrast = (Lmax + 0.05) / (Lmin + 0.05) + * large-text: fontSize ≥ 24px (=18pt) 또는 bold & fontSize ≥ 18.66px (=14pt) + */ +import type { ColorUsage, Violation } from '~/common/schemas'; + +function srgbToLinear(c: number): number { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); +} + +export function relativeLuminance(r: number, g: number, b: number): number { + return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); +} + +export function contrastRatio(l1: number, l2: number): number { + const lo = Math.min(l1, l2); + const hi = Math.max(l1, l2); + return (hi + 0.05) / (lo + 0.05); +} + +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const h = hex.trim().replace(/^#/, ''); + const norm = + h.length === 3 + ? h + .split('') + .map((c) => c + c) + .join('') + : h; + if (!/^[0-9a-fA-F]{6}$/.test(norm)) return null; + const n = parseInt(norm, 16); + return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }; +} + +function rgbToHex(r: number, g: number, b: number): string { + const f = (n: number) => Math.round(n).toString(16).padStart(2, '0'); + return '#' + f(r) + f(g) + f(b); +} + +async function loadImageFromBase64(base64: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('image decode failed')); + img.src = `data:image/png;base64,${base64}`; + }); +} + +type Crop = { x: number; y: number; w: number; h: number }; + +function readImageData(img: HTMLImageElement, crop?: Crop): ImageData { + const w = img.naturalWidth; + const h = img.naturalHeight; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('2d context unavailable'); + ctx.drawImage(img, 0, 0); + if (!crop) return ctx.getImageData(0, 0, w, h); + const cx = Math.max(0, Math.min(crop.x, w - 1)); + const cy = Math.max(0, Math.min(crop.y, h - 1)); + const cw = Math.max(1, Math.min(crop.w, w - cx)); + const ch = Math.max(1, Math.min(crop.h, h - cy)); + return ctx.getImageData(cx, cy, cw, ch); +} + +export type ContrastResult = { + fgHex: string; + bgHex: string; + ratio: number; + /** WCAG AA large-text 판정 (fontSize ≥ 24px, 또는 ≥ 18.66px bold). */ + isLargeText: boolean; + /** AA 통과 여부. */ + passesAA: boolean; +}; + +function isLargeTextRule(fontSize: number, isBold: boolean): boolean { + return fontSize >= 24 || (isBold && fontSize >= 18.66); +} + +function threshold(isLargeText: boolean): number { + return isLargeText ? 3.0 : 4.5; +} + +function contrastFromRgb( + fgHex: string, + fg: { r: number; g: number; b: number }, + bg: { r: number; g: number; b: number }, + fontSize: number, + isBold: boolean, + bgHex: string, +): ContrastResult { + const fgL = relativeLuminance(fg.r, fg.g, fg.b); + const bgL = relativeLuminance(bg.r, bg.g, bg.b); + const ratio = contrastRatio(fgL, bgL); + const isLargeText = isLargeTextRule(fontSize, isBold); + return { fgHex, bgHex, ratio, isLargeText, passesAA: ratio >= threshold(isLargeText) }; +} + +/** + * SOLID opaque 배경용 결정적 경로. PNG 없이 hex → hex 대비. + */ +export function contrastFromHex( + fgHex: string, + bgHex: string, + fontSize: number, + isBold: boolean, +): ContrastResult | null { + const fg = hexToRgb(fgHex); + const bg = hexToRgb(bgHex); + if (!fg || !bg) return null; + return contrastFromRgb(fgHex, fg, bg, fontSize, isBold, bgHex); +} + +/** + * ambiguous 배경용 PNG 샘플링 경로. plugin 은 부모 노드를 텍스트가 보이는 상태로 캡처. + * crop 영역 opaque 픽셀 중 fg 루미넌스와의 거리 상위 30% 만 bg 후보로 사용 → 글리프 픽셀 자연 배제. + */ +export async function analyzeTextContrast( + imageBase64: string, + fgHex: string, + fontSize: number, + isBold: boolean, + crop?: Crop, +): Promise { + const fg = hexToRgb(fgHex); + if (!fg) return null; + let data: ImageData; + try { + const img = await loadImageFromBase64(imageBase64); + data = readImageData(img, crop); + } catch { + return null; + } + const fgL = relativeLuminance(fg.r, fg.g, fg.b); + const pixels = data.data; + + const opaque: Array<{ r: number; g: number; b: number; dist: number }> = []; + for (let i = 0; i < pixels.length; i += 4) { + const a = pixels[i + 3]; + if (a < 250) continue; + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const l = relativeLuminance(r, g, b); + opaque.push({ r, g, b, dist: Math.abs(l - fgL) }); + } + if (opaque.length === 0) return null; + + opaque.sort((a, b) => b.dist - a.dist); + const cutoff = Math.max(1, Math.floor(opaque.length * 0.3)); + const sample = opaque.slice(0, cutoff); + + let sr = 0; + let sg = 0; + let sb = 0; + for (const px of sample) { + sr += px.r; + sg += px.g; + sb += px.b; + } + const bgR = sr / sample.length; + const bgG = sg / sample.length; + const bgB = sb / sample.length; + return contrastFromRgb( + fgHex, + fg, + { r: bgR, g: bgG, b: bgB }, + fontSize, + isBold, + rgbToHex(bgR, bgG, bgB), + ); +} + +async function resolveContrast(u: ColorUsage & { hex: string }): Promise { + const shot = u.textShot; + if (!shot) return null; + const bg = u.background; + if (bg && (bg.kind === 'white' || bg.kind === 'other') && bg.hex) { + return contrastFromHex(u.hex, bg.hex, shot.fontSize, shot.isBold); + } + if ( + bg && + bg.kind === 'ambiguous' && + shot.imageBase64 && + shot.cropX !== undefined && + shot.cropY !== undefined && + shot.cropW !== undefined && + shot.cropH !== undefined + ) { + return analyzeTextContrast(shot.imageBase64, u.hex, shot.fontSize, shot.isBold, { + x: shot.cropX, + y: shot.cropY, + w: shot.cropW, + h: shot.cropH, + }); + } + return null; +} + +/** + * ColorUsage 중 property === 'text' 인 항목의 텍스트 색대비를 판정. + * 배경이 결정적이면 hex 로 즉시 계산, ambiguous 면 PNG 로 샘플링. + * 실패/판정불가 항목은 조용히 스킵. + */ +export async function evaluateTextContrast(usages: ColorUsage[]): Promise { + const targets = usages.filter( + (u): u is ColorUsage & { hex: string } => !!u.hex && u.property === 'text', + ); + const results = await Promise.all( + targets.map(async (u) => { + const r = await resolveContrast(u); + if (!r) return null; + if (r.passesAA) return null; + const th = threshold(r.isLargeText); + const v: Violation = { + nodeId: u.nodeId, + nodeIds: u.nodeIds, + count: u.count, + name: u.name, + property: 'fill-on-text', + token: u.appliedToken ?? u.token, + value: u.hex, + origin: 'rule', + type: 'text-contrast-low', + severity: 'high', + suggested: [], + message: `WCAG AA 미준수. 대비 ${r.ratio.toFixed(2)}:1 (기준 ${th}:1, ${r.isLargeText ? 'large' : 'normal'} text). 배경 ${r.bgHex} vs 텍스트 ${r.fgHex}.`, + }; + return v; + }), + ); + return results.filter((x): x is Violation => x !== null); +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/color.test.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/color.test.ts new file mode 100644 index 000000000..486a523fe --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/color.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import type { ColorUsage } from '~/common/schemas'; +import { evaluateColor } from '~/ui/lib/evaluate/color'; +import { loadColorSchema } from '~/ui/lib/loaders/color'; + +const schema = loadColorSchema('light'); + +function usage(partial: Partial): ColorUsage { + return { + nodeId: 'n', + name: 'sample', + property: 'fill', + token: null, + hex: null, + tokenStatus: 'ok', + background: null, + ...partial, + }; +} + +describe('evaluateColor', () => { + it('raw fill 은 token-not-used / severity high 로 잡는다', () => { + const result = evaluateColor( + [usage({ tokenStatus: 'raw', hex: '#ff0000', token: null })], + schema, + ); + expect(result.violations[0].type).toBe('token-not-used'); + expect(result.violations[0].severity).toBe('high'); + }); + + it('알 수 없는 토큰은 unknown-token', () => { + const result = evaluateColor( + [usage({ tokenStatus: 'unknown', token: null, hex: '#abc123' })], + schema, + ); + expect(result.violations[0].type).toBe('unknown-token'); + }); + + it('Variable Mode wrap primitive (semantic 없음) → violation.token = appliedToken 표시', () => { + // Button 이 💙 Test variable 을 통해 #ffffff 를 사용 — semantic 미도달 시나리오. + const result = evaluateColor( + [ + usage({ + tokenStatus: 'unknown', + token: null, + appliedToken: 'Test', + hex: '#ffffff', + property: 'text', + }), + ], + schema, + ); + expect(result.violations[0].type).toBe('unknown-token'); + expect(result.violations[0].token).toBe('Test'); + }); + + it('raw hex 는 violation.token=null (appliedToken 무시)', () => { + const result = evaluateColor( + [ + usage({ + tokenStatus: 'raw', + token: null, + appliedToken: null, + hex: '#ff0000', + }), + ], + schema, + ); + expect(result.violations[0].type).toBe('token-not-used'); + expect(result.violations[0].token).toBeNull(); + }); + + it('do-not-use 플래그가 박힌 토큰은 do-not-use 로 잡는다', () => { + // status=do-not-use 가 있는 token 키를 schema 에서 골라 입력 + const doNotUseKey = Object.entries(schema.semantic).find( + ([, v]) => v.status === 'do-not-use', + )?.[0]; + if (!doNotUseKey) return; // 스키마에 없으면 skip + const result = evaluateColor( + [ + usage({ + tokenStatus: 'ok', + token: doNotUseKey, + hex: schema.semantic[doNotUseKey].hex, + }), + ], + schema, + ); + expect(result.violations[0].type).toBe('do-not-use'); + }); + + it('fill 에 foreground 토큰을 쓰면 role-mismatch', () => { + const fgKey = Object.entries(schema.semantic).find(([, v]) => v.role === 'foreground')?.[0]; + if (!fgKey) return; + const result = evaluateColor( + [ + usage({ + property: 'fill', + tokenStatus: 'ok', + token: fgKey, + hex: schema.semantic[fgKey].hex, + }), + ], + schema, + ); + expect(result.violations.some((v) => v.type === 'role-mismatch')).toBe(true); + }); + + it('primitive 토큰 사용은 warning 으로 잡지 않고 conformant 처리', () => { + const result = evaluateColor( + [usage({ tokenStatus: 'ok', token: 'color-blue-500', hex: '#0000ff' })], + schema, + ); + expect(result.violations).toHaveLength(0); + expect(result.conformant.some((c) => c.token === 'color-blue-500')).toBe(true); + }); + + it('grade 없는 primitive(color-white, color-black)도 conformant 로 통과', () => { + for (const token of ['color-white', 'color-black']) { + const result = evaluateColor( + [ + usage({ + tokenStatus: 'ok', + token, + hex: schema.primitive[token], + }), + ], + schema, + ); + expect(result.violations).toHaveLength(0); + expect(result.conformant.some((c) => c.token === token)).toBe(true); + } + }); + + it('fg-200 을 순백 배경 위에 쓰면 fg-grade-mismatch', () => { + const key = Object.entries(schema.semantic).find( + ([k, v]) => + v.role === 'foreground' && v.gradeRule?.other === '200' && k.endsWith('-200'), + )?.[0]; + if (!key) return; + const result = evaluateColor( + [ + usage({ + property: 'text', + tokenStatus: 'ok', + token: key, + hex: schema.semantic[key].hex, + background: { kind: 'white', hex: '#ffffff' }, + }), + ], + schema, + ); + expect(result.violations.some((v) => v.type === 'fg-grade-mismatch')).toBe(true); + }); + + it('fg-200 을 투명 배경 위에 쓰면 fg-grade-mismatch', () => { + const key = Object.entries(schema.semantic).find( + ([k, v]) => + v.role === 'foreground' && v.gradeRule?.other === '200' && k.endsWith('-200'), + )?.[0]; + if (!key) return; + const result = evaluateColor( + [ + usage({ + property: 'text', + tokenStatus: 'ok', + token: key, + hex: schema.semantic[key].hex, + background: { kind: 'transparent', hex: null }, + }), + ], + schema, + ); + expect(result.violations.some((v) => v.type === 'fg-grade-mismatch')).toBe(true); + }); + + it('fg-100 을 순백 배경 위에 쓰면 문제 없음', () => { + const key = Object.entries(schema.semantic).find( + ([k, v]) => + v.role === 'foreground' && v.gradeRule?.other === '100' && k.endsWith('-100'), + )?.[0]; + if (!key) return; + const result = evaluateColor( + [ + usage({ + property: 'text', + tokenStatus: 'ok', + token: key, + hex: schema.semantic[key].hex, + background: { kind: 'white', hex: '#ffffff' }, + }), + ], + schema, + ); + expect(result.violations.length).toBe(0); + }); + + it('적합한 semantic 토큰 사용은 conformant 로 떨어진다', () => { + const fgKey = Object.entries(schema.semantic).find(([, v]) => v.role === 'foreground')?.[0]; + if (!fgKey) return; + const result = evaluateColor( + [ + usage({ + // 'text' property → effectiveProperty maps to 'fill-on-text' + // foreground role is allowed for fill-on-text per PROPERTY_SCOPE + property: 'text', + tokenStatus: 'ok', + token: fgKey, + hex: schema.semantic[fgKey].hex, + }), + ], + schema, + ); + expect(result.violations.length).toBe(0); + expect(result.conformant.length).toBe(1); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/color.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/color.ts new file mode 100644 index 000000000..2d35f0d73 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/color.ts @@ -0,0 +1,196 @@ +import type { ColorUsage, Conformant, Property, Violation } from '~/common/schemas'; +import type { ColorSchema } from '~/ui/lib/loaders/color'; +import { suggestLegacyReplacement } from '~/ui/lib/loaders/legacy-color'; +import { PROPERTY_SCOPE } from '~/ui/lib/scope'; + +/** + * primitive 토큰 키 판별. regex 로 grade 유무를 추정하지 않고 loader 가 만든 primitive 사전을 조회한다. + * grade 있는 스케일(color-red-100 등)뿐 아니라 grade 없는 원자 primitive(color-white / color-black 등)도 커버. + */ +function isPrimitiveKey(token: string, schema: ColorSchema): boolean { + return token in schema.primitive; +} + +/** + * ColorUsage.property (ColorProperty = 'fill' | 'stroke' | 'text') 를 + * Violation.property (Property) 로 변환. + * TEXT/VECTOR 노드의 색상 = 'fill-on-text', 그 외 fill = 'fill', 그 외 = 'stroke'. + */ +function effectiveProperty(u: ColorUsage): Property { + if (u.property === 'text') return 'fill-on-text'; + if (u.property === 'fill') return 'fill'; + return 'stroke'; +} + +/** + * ColorUsage 배열을 결정론으로 판정한다. + * + * 판정 순서 (앞 단계에서 해결되면 이후 단계는 건너뜀): + * 1. raw → token-not-used (high) + * 2. unknown → unknown-token (high) + * 3. primitive → conformant (warning 제외) + * 4. 스키마 미등록 → unknown-token (high) + * 5. do-not-use → do-not-use (high) + * 6. role 불일치 → role-mismatch (high) + * 7. fg-grade → fg-grade-mismatch (high) / fg-grade-ambiguous (info) + * 8. 통과 → conformant + */ +export function evaluateColor( + usages: ColorUsage[], + schema: ColorSchema, +): { violations: Violation[]; conformant: Conformant[] } { + const violations: Violation[] = []; + const conformant: Conformant[] = []; + + for (const u of usages) { + const property = effectiveProperty(u); + const value = u.hex; + const legacy = suggestLegacyReplacement({ token: u.token, hex: u.hex, property }); + const suggested: string[] = legacy ? [legacy] : []; + // 검사는 원본(semantic) token 기준. 카드 표시는 실제 노드에 바인딩된 + // Variable Mode outer 이름(appliedToken) 우선. + const displayToken = u.appliedToken ?? u.token; + const base = { + nodeId: u.nodeId, + nodeIds: u.nodeIds, + count: u.count, + name: u.name, + property, + token: displayToken, + value, + origin: 'rule' as const, + message: '', + suggested, + wasDs: u.wasDs, + }; + + // 1. raw: 변수 바인딩 없이 직접 입력된 색상 + if (u.tokenStatus === 'raw') { + violations.push({ + ...base, + token: null, + type: 'token-not-used', + severity: 'high', + message: 'HEX 값 대신 디자인 토큰 사용을 고려하세요.', + }); + continue; + } + + // 2. unknown: 바인딩은 있으나 semantic 단계 미도달 + if (u.tokenStatus === 'unknown') { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: + '존재하지 않는 토큰입니다. 레거시 토큰이라면 신규 토큰으로 교체를 고려하세요.', + }); + continue; + } + + // token 이 없으면 판정 불가 — 건너뜀 + if (!u.token) continue; + + // 3. primitive 토큰 직접 사용 — warning 대상에서 제외. + // semantic 을 우회한 사용이지만 이슈로 잡지 않고 conformant 로 통과시킨다. + if (isPrimitiveKey(u.token, schema) && !schema.semantic[u.token]) { + conformant.push({ + nodeId: u.nodeId, + nodeIds: u.nodeIds, + name: u.name, + property, + token: u.token, + }); + continue; + } + + // 4. 스키마 미등록 토큰 + const meta = schema.semantic[u.token]; + if (!meta) { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: + '존재하지 않는 토큰입니다. 레거시 토큰이라면 신규 토큰으로 교체를 고려하세요.', + }); + continue; + } + + // 5. do-not-use 토큰 + if (meta.status === 'do-not-use') { + violations.push({ + ...base, + type: 'do-not-use', + severity: 'high', + message: '사용이 권장되지 않는 토큰입니다(do-not-use).', + }); + continue; + } + + // 6. role mismatch — PROPERTY_SCOPE 기반 + const allowedRoles = + (PROPERTY_SCOPE as Record>)[property] ?? []; + if (meta.role && !allowedRoles.includes(meta.role)) { + violations.push({ + ...base, + type: 'role-mismatch', + severity: 'high', + message: `${property} 속성에는 ${meta.role} 토큰을 사용할 수 없습니다. ${allowedRoles.join('/')} 역할 토큰을 고려하세요.`, + }); + continue; + } + + // 7. fg-grade 검사 (gradeRule 있는 foreground + fill-on-text + background 정보 있을 때만) + // 스키마의 gradeRules 상속으로 loader 가 gradeRule.other 을 채운 토큰만 검사 대상. + // - grade '100' 은 pure white / transparent 배경 전용 + // - grade '200' 은 non-white 배경 전용 + if ( + meta.role === 'foreground' && + property === 'fill-on-text' && + u.background && + meta.gradeRule + ) { + const kind = u.background.kind; + const grade = meta.gradeRule.other; + if (kind === 'ambiguous') { + violations.push({ + ...base, + type: 'fg-grade-ambiguous', + severity: 'info', + message: '배경 식별이 모호해 fg grade 짝 확인이 보류되었습니다.', + }); + continue; + } + if (kind === 'other' && grade === '100') { + violations.push({ + ...base, + type: 'fg-grade-mismatch', + severity: 'high', + message: '색상이 있는 배경에서는 foreground 200 grade 사용을 고려하세요.', + }); + continue; + } + if ((kind === 'white' || kind === 'transparent') && grade === '200') { + violations.push({ + ...base, + type: 'fg-grade-mismatch', + severity: 'high', + message: '순백/투명 배경에서는 foreground 100 grade 사용을 고려하세요.', + }); + continue; + } + } + + // 8. 통과 → conformant + conformant.push({ + nodeId: u.nodeId, + nodeIds: u.nodeIds, + name: u.name, + property, + token: u.token, + }); + } + + return { violations, conformant }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/dimension.test.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/dimension.test.ts new file mode 100644 index 000000000..1fc6f3e51 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/dimension.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import type { DimensionUsage } from '~/common/schemas'; +import { evaluateDimension } from '~/ui/lib/evaluate/dimension'; +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; + +const schemas = loadDimensionSchemas(); + +function usage(partial: Partial): DimensionUsage { + return { + nodeId: 'n', + name: 'box', + property: 'width', + value: '320px', + token: null, + tokenStatus: 'ok', + ...partial, + }; +} + +describe('evaluateDimension', () => { + it('raw value 는 token-not-used / high', () => { + const r = evaluateDimension([usage({ tokenStatus: 'raw' })], schemas.dimension); + expect(r.violations[0].type).toBe('token-not-used'); + expect(r.violations[0].severity).toBe('high'); + }); + + it('스키마에 없는 토큰은 unknown-token', () => { + const r = evaluateDimension( + [usage({ token: 'dimension.999', tokenStatus: 'ok' })], + schemas.dimension, + ); + expect(r.violations[0].type).toBe('unknown-token'); + }); + + it('적합 토큰은 conformant', () => { + const k = Object.keys(schemas.dimension.tokens)[0]; + const v = schemas.dimension.tokens[k]; + const r = evaluateDimension([usage({ token: k, value: v })], schemas.dimension); + expect(r.violations.length).toBe(0); + expect(r.conformant.length).toBe(1); + expect(r.conformant[0].token).toBe(k); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/dimension.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/dimension.ts new file mode 100644 index 000000000..37ecdf581 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/dimension.ts @@ -0,0 +1,50 @@ +import type { Conformant, DimensionUsage, Violation } from '~/common/schemas'; +import type { TokenValueIndex } from '~/ui/lib/loaders/dimension'; + +export function evaluateDimension( + usages: DimensionUsage[], + schema: TokenValueIndex, +): { violations: Violation[]; conformant: Conformant[] } { + const violations: Violation[] = []; + const conformant: Conformant[] = []; + + for (const u of usages) { + const displayToken = u.appliedToken ?? u.token; + const base = { + nodeId: u.nodeId, + name: u.name, + property: u.property, + token: displayToken, + value: u.value, + origin: 'rule' as const, + message: '', + suggested: [] as string[], + wasDs: u.wasDs, + }; + + if (u.tokenStatus === 'raw') { + violations.push({ + ...base, + token: null, + type: 'token-not-used', + severity: 'high', + message: `${u.property}에 raw value(${u.value})가 직접 입력되었습니다.`, + }); + continue; + } + + if (u.tokenStatus === 'unknown' || !u.token || !(u.token in schema.tokens)) { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: 'dimension 스키마에 등록되지 않은 토큰입니다.', + }); + continue; + } + + conformant.push({ nodeId: u.nodeId, name: u.name, property: u.property, token: u.token }); + } + + return { violations, conformant }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/radius.test.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/radius.test.ts new file mode 100644 index 000000000..4f7dc7258 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/radius.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import type { RadiusUsage } from '~/common/schemas'; +import { evaluateRadius } from '~/ui/lib/evaluate/radius'; +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; + +const schemas = loadDimensionSchemas(); + +function usage(partial: Partial): RadiusUsage { + return { nodeId: 'n', name: 'card', value: '8px', token: null, tokenStatus: 'ok', ...partial }; +} + +describe('evaluateRadius', () => { + it('raw value 는 token-not-used', () => { + const r = evaluateRadius([usage({ tokenStatus: 'raw' })], schemas.borderRadius); + expect(r.violations[0].type).toBe('token-not-used'); + expect(r.violations[0].severity).toBe('high'); + }); + + it('스키마에 없는 토큰은 unknown-token', () => { + const r = evaluateRadius( + [usage({ token: 'borderRadius.999', tokenStatus: 'ok' })], + schemas.borderRadius, + ); + expect(r.violations[0].type).toBe('unknown-token'); + }); + + it('적합 토큰은 conformant', () => { + const k = Object.keys(schemas.borderRadius.tokens)[0]; + const v = schemas.borderRadius.tokens[k]; + const r = evaluateRadius([usage({ token: k, value: v })], schemas.borderRadius); + expect(r.violations.length).toBe(0); + expect(r.conformant.length).toBe(1); + expect(r.conformant[0].token).toBe(k); + }); + + it('property 는 borderRadius (camelCase)', () => { + const r = evaluateRadius([usage({ tokenStatus: 'raw' })], schemas.borderRadius); + expect(r.violations[0].property).toBe('borderRadius'); + }); + + it('token null + tokenStatus ok → unknown-token', () => { + const r = evaluateRadius([usage({ token: null, tokenStatus: 'ok' })], schemas.borderRadius); + expect(r.violations[0].type).toBe('unknown-token'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/radius.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/radius.ts new file mode 100644 index 000000000..a9d5de3b0 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/radius.ts @@ -0,0 +1,55 @@ +import type { Conformant, RadiusUsage, Violation } from '~/common/schemas'; +import type { TokenValueIndex } from '~/ui/lib/loaders/dimension'; + +export function evaluateRadius( + usages: RadiusUsage[], + schema: TokenValueIndex, +): { violations: Violation[]; conformant: Conformant[] } { + const violations: Violation[] = []; + const conformant: Conformant[] = []; + + for (const u of usages) { + const displayToken = u.appliedToken ?? u.token; + const base = { + nodeId: u.nodeId, + name: u.name, + property: 'borderRadius' as const, + token: displayToken, + value: u.value, + origin: 'rule' as const, + message: '', + suggested: [] as string[], + wasDs: u.wasDs, + }; + + if (u.tokenStatus === 'raw') { + violations.push({ + ...base, + token: null, + type: 'token-not-used', + severity: 'high', + message: `borderRadius에 raw value(${u.value})가 직접 입력되었습니다.`, + }); + continue; + } + + if (u.tokenStatus === 'unknown' || !u.token || !(u.token in schema.tokens)) { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: 'borderRadius 스키마에 등록되지 않은 토큰입니다.', + }); + continue; + } + + conformant.push({ + nodeId: u.nodeId, + name: u.name, + property: 'borderRadius', + token: u.token, + }); + } + + return { violations, conformant }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/shadow.test.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/shadow.test.ts new file mode 100644 index 000000000..e0d0b6d53 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/shadow.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import type { ShadowUsage } from '~/common/schemas'; +import { evaluateShadow } from '~/ui/lib/evaluate/shadow'; +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; + +const schemas = loadDimensionSchemas(); + +function usage(partial: Partial): ShadowUsage { + return { + nodeId: 'n', + name: 'card', + value: 'drop-shadow(0 1px 2px rgba(0,0,0,0.1))', + token: null, + tokenStatus: 'ok', + ...partial, + }; +} + +describe('evaluateShadow', () => { + it('raw 는 token-not-used', () => { + const r = evaluateShadow([usage({ tokenStatus: 'raw' })], schemas.shadow); + expect(r.violations[0].type).toBe('token-not-used'); + }); + + it('raw severity 는 high', () => { + const r = evaluateShadow([usage({ tokenStatus: 'raw' })], schemas.shadow); + expect(r.violations[0].severity).toBe('high'); + }); + + it('스키마에 없는 토큰은 unknown-token', () => { + const r = evaluateShadow( + [usage({ token: 'shadow.999', tokenStatus: 'ok' })], + schemas.shadow, + ); + expect(r.violations[0].type).toBe('unknown-token'); + }); + + it('적합 토큰은 conformant', () => { + const k = Object.keys(schemas.shadow.tokens)[0]; + const v = schemas.shadow.tokens[k]; + const r = evaluateShadow([usage({ token: k, value: v })], schemas.shadow); + expect(r.violations.length).toBe(0); + expect(r.conformant.length).toBe(1); + expect(r.conformant[0].token).toBe(k); + }); + + it('property 는 shadow (camelCase)', () => { + const r = evaluateShadow([usage({ tokenStatus: 'raw' })], schemas.shadow); + expect(r.violations[0].property).toBe('shadow'); + }); + + it('token null + tokenStatus ok → unknown-token', () => { + const r = evaluateShadow([usage({ token: null, tokenStatus: 'ok' })], schemas.shadow); + expect(r.violations[0].type).toBe('unknown-token'); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/shadow.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/shadow.ts new file mode 100644 index 000000000..85fa3c420 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/shadow.ts @@ -0,0 +1,48 @@ +import type { Conformant, ShadowUsage, Violation } from '~/common/schemas'; +import type { TokenValueIndex } from '~/ui/lib/loaders/dimension'; + +export function evaluateShadow( + usages: ShadowUsage[], + schema: TokenValueIndex, +): { violations: Violation[]; conformant: Conformant[] } { + const violations: Violation[] = []; + const conformant: Conformant[] = []; + + for (const u of usages) { + const base = { + nodeId: u.nodeId, + name: u.name, + property: 'shadow' as const, + token: u.token, + value: u.value, + origin: 'rule' as const, + message: '', + suggested: [] as string[], + wasDs: u.wasDs, + }; + + if (u.tokenStatus === 'raw') { + violations.push({ + ...base, + type: 'token-not-used', + severity: 'high', + message: `shadow에 raw value(${u.value})가 직접 입력되었습니다.`, + }); + continue; + } + + if (u.tokenStatus === 'unknown' || !u.token || !(u.token in schema.tokens)) { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: 'shadow 스키마에 등록되지 않은 토큰입니다.', + }); + continue; + } + + conformant.push({ nodeId: u.nodeId, name: u.name, property: 'shadow', token: u.token }); + } + + return { violations, conformant }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/space.test.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/space.test.ts new file mode 100644 index 000000000..66fb76259 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/space.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import type { SpaceUsage } from '~/common/schemas'; +import { evaluateSpace } from '~/ui/lib/evaluate/space'; +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; + +const schemas = loadDimensionSchemas(); + +function usage(partial: Partial): SpaceUsage { + return { + nodeId: 'n', + name: 'box', + property: 'padding', + value: '16px', + token: null, + tokenStatus: 'ok', + ...partial, + }; +} + +describe('evaluateSpace', () => { + it('raw value 는 token-not-used / high', () => { + const r = evaluateSpace([usage({ tokenStatus: 'raw' })], schemas.space); + expect(r.violations[0].type).toBe('token-not-used'); + expect(r.violations[0].severity).toBe('high'); + }); + + it('스키마에 없는 토큰은 unknown-token', () => { + const r = evaluateSpace([usage({ token: 'space.999', tokenStatus: 'ok' })], schemas.space); + expect(r.violations[0].type).toBe('unknown-token'); + }); + + it('적합 토큰은 conformant', () => { + const knownToken = Object.keys(schemas.space.tokens)[0]; + const knownValue = schemas.space.tokens[knownToken]; + const r = evaluateSpace([usage({ token: knownToken, value: knownValue })], schemas.space); + expect(r.violations.length).toBe(0); + expect(r.conformant[0].token).toBe(knownToken); + }); + + it( + 'Variable Mode wrap 이 semantic 이면 (token=semantic, appliedToken=outer) conformant, ' + + 'conformant.token 은 semantic 유지', + () => { + const knownToken = Object.keys(schemas.space.tokens)[0]; + const knownValue = schemas.space.tokens[knownToken]; + const r = evaluateSpace( + [ + usage({ + token: knownToken, + appliedToken: 'space-inline', + value: knownValue, + }), + ], + schemas.space, + ); + expect(r.violations.length).toBe(0); + expect(r.conformant[0].token).toBe(knownToken); + }, + ); + + it('스키마에 없는 wrap 은 unknown-token 위반, violation.token 은 appliedToken(outer) 표시', () => { + const r = evaluateSpace( + [ + usage({ + token: 'space-inline', + appliedToken: 'space-inline', + tokenStatus: 'ok', + }), + ], + schemas.space, + ); + expect(r.violations[0].type).toBe('unknown-token'); + expect(r.violations[0].token).toBe('space-inline'); + }); + + it('token-not-used 위반은 raw value → violation.token=null (appliedToken 무시)', () => { + const r = evaluateSpace([usage({ tokenStatus: 'raw', appliedToken: null })], schemas.space); + expect(r.violations[0].type).toBe('token-not-used'); + expect(r.violations[0].token).toBeNull(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/space.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/space.ts new file mode 100644 index 000000000..a7e53b24f --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/space.ts @@ -0,0 +1,52 @@ +import type { Conformant, SpaceUsage, Violation } from '~/common/schemas'; +import type { TokenValueIndex } from '~/ui/lib/loaders/dimension'; + +export function evaluateSpace( + usages: SpaceUsage[], + schema: TokenValueIndex, +): { violations: Violation[]; conformant: Conformant[] } { + const violations: Violation[] = []; + const conformant: Conformant[] = []; + + for (const u of usages) { + // 검사는 원본(semantic) token 기준. 카드 표시는 실제 요소에 바인딩된 + // Variable Mode outer 이름(appliedToken) 우선. + const displayToken = u.appliedToken ?? u.token; + const base = { + nodeId: u.nodeId, + name: u.name, + property: u.property, + token: displayToken, + value: u.value, + origin: 'rule' as const, + message: '', + suggested: [] as string[], + wasDs: u.wasDs, + }; + + if (u.tokenStatus === 'raw') { + violations.push({ + ...base, + token: null, + type: 'token-not-used', + severity: 'high', + message: `${u.property}에 raw value(${u.value})가 직접 입력되었습니다.`, + }); + continue; + } + + if (u.tokenStatus === 'unknown' || !u.token || !(u.token in schema.tokens)) { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: 'space 스키마에 등록되지 않은 토큰입니다.', + }); + continue; + } + + conformant.push({ nodeId: u.nodeId, name: u.name, property: u.property, token: u.token }); + } + + return { violations, conformant }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/typography.test.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/typography.test.ts new file mode 100644 index 000000000..edc0db679 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/typography.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; + +import type { TypographyUsage } from '~/common/schemas'; +import { evaluateTypography } from '~/ui/lib/evaluate/typography'; +import { loadTextStyleSchema } from '~/ui/lib/loaders/typography'; + +const schema = loadTextStyleSchema(); + +function usage(partial: Partial): TypographyUsage { + return { + nodeId: 'n', + name: 'label', + characters: '안내', + textStyle: 'body2', + viewport: 'pc', + appliedStatus: 'styled-clean', + overriddenFields: [], + resolved: { fontSize: 14, lineHeight: {}, letterSpacing: {}, fontName: {} }, + ...partial, + }; +} + +describe('evaluateTypography', () => { + it('appliedStatus=raw 는 typo-raw / high', () => { + const r = evaluateTypography([usage({ appliedStatus: 'raw', textStyle: null })], schema); + expect(r.violations[0].type).toBe('typo-raw'); + expect(r.violations[0].severity).toBe('high'); + }); + + it('styled-override 는 typo-styled-override / info', () => { + const r = evaluateTypography( + [usage({ appliedStatus: 'styled-override', overriddenFields: ['fontSize'] })], + schema, + ); + expect(r.violations[0].type).toBe('typo-styled-override'); + expect(r.violations[0].severity).toBe('info'); + }); + + it('styled-override message 에 overriddenFields 표시', () => { + const r = evaluateTypography( + [ + usage({ + appliedStatus: 'styled-override', + overriddenFields: ['fontSize', 'lineHeight'], + }), + ], + schema, + ); + expect(r.violations[0].message).toContain('fontSize'); + expect(r.violations[0].message).toContain('lineHeight'); + }); + + it('styled-clean 은 conformant', () => { + const r = evaluateTypography([usage({})], schema); + expect(r.violations.length).toBe(0); + expect(r.conformant.length).toBe(1); + }); + + it('스키마에 없는 textStyle → unknown-token / high', () => { + const r = evaluateTypography( + [usage({ textStyle: 'nonexistent-style-xyz', appliedStatus: 'styled-clean' })], + schema, + ); + expect(r.violations[0].type).toBe('unknown-token'); + expect(r.violations[0].severity).toBe('high'); + }); + + it('property 는 textStyle', () => { + const r = evaluateTypography([usage({ appliedStatus: 'raw', textStyle: null })], schema); + expect(r.violations[0].property).toBe('textStyle'); + }); + + it('typo-raw 는 resolved.fontSize 와 일치하는 textStyle 이름을 suggested 에 담는다', () => { + // body2 는 14px 로 resolve 된다 (Task 2) + const r = evaluateTypography( + [ + usage({ + appliedStatus: 'raw', + textStyle: null, + resolved: { fontSize: 14, lineHeight: {}, letterSpacing: {}, fontName: {} }, + }), + ], + schema, + ); + expect(r.violations[0].type).toBe('typo-raw'); + expect(r.violations[0].suggested).toContain('body2'); + }); + + it('typo-raw 의 fontSize 가 null 이면 suggested 빈 배열', () => { + const r = evaluateTypography( + [ + usage({ + appliedStatus: 'raw', + textStyle: null, + resolved: { fontSize: null, lineHeight: {}, letterSpacing: {}, fontName: {} }, + }), + ], + schema, + ); + expect(r.violations[0].suggested).toEqual([]); + }); + + it('typo-styled-override 는 바인딩된 textStyle 을 suggested 로 복원 제안한다', () => { + const r = evaluateTypography( + [ + usage({ + appliedStatus: 'styled-override', + textStyle: 'body2', + overriddenFields: ['fontSize'], + }), + ], + schema, + ); + expect(r.violations[0].type).toBe('typo-styled-override'); + expect(r.violations[0].suggested).toEqual(['body2']); + }); + + it('unknown-token 은 suggested 빈 배열', () => { + const r = evaluateTypography( + [usage({ textStyle: 'nonexistent-style-xyz', appliedStatus: 'styled-clean' })], + schema, + ); + expect(r.violations[0].type).toBe('unknown-token'); + expect(r.violations[0].suggested).toEqual([]); + }); + + it('token 필드는 textStyle 이름', () => { + const r = evaluateTypography([usage({ appliedStatus: 'raw', textStyle: null })], schema); + expect(r.violations[0].token).toBeNull(); + + const r2 = evaluateTypography( + [ + usage({ + appliedStatus: 'styled-override', + textStyle: 'body2', + overriddenFields: ['fontSize'], + }), + ], + schema, + ); + expect(r2.violations[0].token).toBe('body2'); + }); + + it('conformant token 필드는 textStyle 이름', () => { + const r = evaluateTypography( + [usage({ textStyle: 'body2', appliedStatus: 'styled-clean' })], + schema, + ); + expect(r.conformant[0].token).toBe('body2'); + }); + + it('typo-raw: fontSize 가 여러 textStyle 에 매핑되면 suggested 에 모두 포함된다', () => { + // fontSize=14 는 schema 에서 subtitle1·body2 두 스타일이 공유 (typography.fontSize.075 = 14px) + const r = evaluateTypography( + [ + usage({ + appliedStatus: 'raw', + textStyle: null, + resolved: { fontSize: 14, lineHeight: {}, letterSpacing: {}, fontName: {} }, + }), + ], + schema, + ); + expect(r.violations[0].type).toBe('typo-raw'); + expect(r.violations[0].suggested.length).toBeGreaterThanOrEqual(2); + expect(r.violations[0].suggested).toContain('subtitle1'); + expect(r.violations[0].suggested).toContain('body2'); + }); + + it('빈 usages → violations/conformant 모두 빔', () => { + const r = evaluateTypography([], schema); + expect(r.violations.length).toBe(0); + expect(r.conformant.length).toBe(0); + }); + + describe('viewport 결정론 규칙 (mobile + display*)', () => { + it('mobile 뷰포트 + display* → typo-viewport-misfit / high', () => { + const r = evaluateTypography( + [usage({ textStyle: 'display1', viewport: 'mobile' })], + schema, + ); + expect(r.violations[0].type).toBe('typo-viewport-misfit'); + expect(r.violations[0].severity).toBe('high'); + expect(r.conformant.length).toBe(0); + }); + + it('mobile 뷰포트 + display* → suggested 는 heading1/heading2', () => { + const r = evaluateTypography( + [usage({ textStyle: 'display2', viewport: 'mobile' })], + schema, + ); + expect(r.violations[0].suggested).toEqual(['heading1', 'heading2']); + }); + + it('pc 뷰포트 + display* → conformant (뷰포트 규칙 적용 안 함)', () => { + const r = evaluateTypography( + [usage({ textStyle: 'display1', viewport: 'pc' })], + schema, + ); + expect(r.violations.length).toBe(0); + expect(r.conformant[0].token).toBe('display1'); + }); + + it('mobile 뷰포트 + non-display → conformant', () => { + const r = evaluateTypography( + [usage({ textStyle: 'body2', viewport: 'mobile' })], + schema, + ); + expect(r.violations.length).toBe(0); + expect(r.conformant.length).toBe(1); + }); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/evaluate/typography.ts b/apps/figma-token-review-plugin/src/ui/lib/evaluate/typography.ts new file mode 100644 index 000000000..64d8f784b --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/evaluate/typography.ts @@ -0,0 +1,91 @@ +import type { Conformant, TypographyUsage, Violation } from '~/common/schemas'; +import type { TextStyleSchema } from '~/ui/lib/loaders/typography'; + +function suggestByFontSize(fontSize: number | null, schema: TextStyleSchema): string[] { + if (fontSize == null) return []; + return schema.order.filter((name) => schema.styles[name].fontSize === fontSize); +} + +export function evaluateTypography( + usages: TypographyUsage[], + schema: TextStyleSchema, +): { violations: Violation[]; conformant: Conformant[] } { + const violations: Violation[] = []; + const conformant: Conformant[] = []; + + for (const u of usages) { + const base = { + nodeId: u.nodeId, + name: u.name, + property: 'textStyle' as const, + token: u.textStyle, + value: null, + origin: 'rule' as const, + message: '', + suggested: [] as string[], + wasDs: u.wasDs, + }; + + if (u.appliedStatus === 'raw') { + const fontSize = typeof u.resolved.fontSize === 'number' ? u.resolved.fontSize : null; + violations.push({ + ...base, + type: 'typo-raw', + severity: 'high', + message: `Text Style이 바인딩되지 않은 raw 텍스트입니다 ("${u.characters}").`, + suggested: suggestByFontSize(fontSize, schema), + }); + continue; + } + + if (u.appliedStatus === 'styled-override') { + violations.push({ + ...base, + type: 'typo-styled-override', + severity: 'info', + message: `Text Style "${u.textStyle}" 적용 후 ${u.overriddenFields.join(', ')} 필드가 오버라이드되었습니다.`, + suggested: u.textStyle ? [u.textStyle] : [], + }); + continue; + } + + if (u.textStyle && !(u.textStyle in schema.styles)) { + violations.push({ + ...base, + type: 'unknown-token', + severity: 'high', + message: `등록되지 않은 Text Style 이름입니다: ${u.textStyle}.`, + }); + continue; + } + + // 결정론 뷰포트 규칙: mobile 뷰포트에서 display* 스타일은 즉시 FAIL. + // 프롬프트가 LLM 에게 요구하던 "mobile → display* viewport FAIL" 규칙을 결정론에서 처리. + if ( + u.textStyle && + u.viewport === 'mobile' && + u.textStyle.startsWith('display') && + u.textStyle in schema.styles + ) { + violations.push({ + ...base, + type: 'typo-viewport-misfit', + severity: 'high', + message: `mobile 뷰포트에서 ${u.textStyle}은 부적합. heading1 또는 heading2 사용을 권장.`, + suggested: ['heading1', 'heading2'].filter((s) => s in schema.styles), + }); + continue; + } + + if (u.textStyle) { + conformant.push({ + nodeId: u.nodeId, + name: u.name, + property: 'textStyle', + token: u.textStyle, + }); + } + } + + return { violations, conformant }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/color.test.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/color.test.ts new file mode 100644 index 000000000..3cf2c82f4 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/color.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { loadColorSchema } from '~/ui/lib/loaders/color'; + +describe('loadColorSchema(light)', () => { + const schema = loadColorSchema('light'); + + it('semantic 토큰을 평탄화한 키를 가진다', () => { + expect(schema.tokenKeys.length).toBeGreaterThan(0); + expect(schema.tokenKeys[0]).toMatch(/^color-/); + }); + + it('각 토큰 메타에 when, avoid 배열을 포함한다', () => { + const sample = schema.semantic[schema.tokenKeys[0]]; + expect(Array.isArray(sample.when)).toBe(true); + expect(Array.isArray(sample.avoid)).toBe(true); + }); + + it('hex index로 같은 색을 가진 모든 semantic 토큰을 역참조할 수 있다', () => { + expect(schema.hexIndex.size).toBeGreaterThan(0); + for (const [hex, tokens] of schema.hexIndex.entries()) { + expect(hex).toMatch(/^#[0-9a-f]{6}$/i); + expect(tokens.length).toBeGreaterThanOrEqual(1); + } + }); + + it('oklch primitive white [1,0,0]은 #ffffff로 변환된다', () => { + expect(schema.primitive['color-white']).toBe('#ffffff'); + }); + + it('oklch primitive black [0,0,0]은 #000000으로 변환된다', () => { + expect(schema.primitive['color-black']).toBe('#000000'); + }); + + it('foreground 그룹의 leaf 토큰은 gradeRule을 상속한다', () => { + const fg100 = schema.semantic['color-foreground-normal-100']; + expect(fg100?.gradeRule).toBeTruthy(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/color.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/color.ts new file mode 100644 index 000000000..36424f793 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/color.ts @@ -0,0 +1,200 @@ +import type { Role, SchemaMode } from '~/common/schemas'; +import primitiveDark from '~/common/tokens/primitive-color.dark.json'; +import primitiveLight from '~/common/tokens/primitive-color.light.json'; +import semanticDark from '~/common/tokens/semantic-color.dark.json'; +import semanticLight from '~/common/tokens/semantic-color.light.json'; + +const NS = 'io.goorm.vapor'; + +export type GradeRule = { other: '100' | '200' | 'ambiguous' | null }; + +export type SemanticTokenMeta = { + role: Role | null; + status: string | null; + valueRef: string | null; + hex: string | null; + when: string[]; + avoid: string[]; + description: string | null; + gradeRule: GradeRule | null; +}; + +export type ColorSchema = { + mode: SchemaMode; + semantic: Record; + primitive: Record; + tokenKeys: string[]; + hexIndex: Map; +}; + +type Node = { + $description?: string; + $value?: unknown; + $extensions?: { [key: string]: Record }; + [k: string]: unknown; +}; + +function vaporMeta(node: Node): Record { + return (node?.$extensions?.[NS] ?? {}) as Record; +} + +/** Convert oklch components [L, C, h°] to a #rrggbb hex string. */ +function oklchToHex([L, C, h]: [number, number, number]): string { + // oklch → oklab + const hRad = (h * Math.PI) / 180; + const a = C * Math.cos(hRad); + const b = C * Math.sin(hRad); + + // oklab → linear sRGB (via cube-root intermediates) + const L_ = L + 0.3963377774 * a + 0.2158037573 * b; + const M_ = L - 0.1055613458 * a - 0.0638541728 * b; + const S_ = L - 0.0894841775 * a - 1.291485548 * b; + + const l = L_ * L_ * L_; + const m = M_ * M_ * M_; + const s = S_ * S_ * S_; + + const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const bv = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + + // linear → gamma sRGB + const toGamma = (x: number): number => + x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055; + + // clamp, scale to 0-255, format as hex + const clamp = (x: number) => Math.max(0, Math.min(1, x)); + const toHex = (x: number) => + Math.round(clamp(x) * 255) + .toString(16) + .padStart(2, '0'); + + return `#${toHex(toGamma(r))}${toHex(toGamma(g))}${toHex(toGamma(bv))}`; +} + +function flattenPrimitive(root: { colors?: Node }): Record { + const out: Record = {}; + const walk = (node: Node, path: string) => { + if (!node || typeof node !== 'object') return; + if ('$value' in node) { + const v = node.$value; + if (typeof v === 'string') { + out[path] = v; + return; + } + if ( + v && + typeof v === 'object' && + (v as { colorSpace?: string }).colorSpace === 'oklch' + ) { + const components = (v as { components?: unknown }).components; + if ( + Array.isArray(components) && + components.length === 3 && + components.every((n) => typeof n === 'number') + ) { + out[path] = oklchToHex(components as [number, number, number]); + } + return; + } + return; + } + for (const [k, v] of Object.entries(node)) { + if (k.startsWith('$')) continue; + walk(v as Node, path ? `${path}-${k}` : k); + } + }; + if (root.colors) walk(root.colors, 'color'); + return out; +} + +function resolveAlias(valueRef: string | null, primitive: Record): string | null { + if (!valueRef) return null; + const m = valueRef.match(/^\{(.+)\}$/); + if (!m) return /^#[0-9a-f]{3,8}$/i.test(valueRef) ? valueRef : null; + // Convert JSON alias path (dot-separated, plural 'colors') to new key format (dash-separated, singular 'color') + const aliasPath = m[1].replace(/^colors\./, 'color-').replace(/\./g, '-'); + return primitive[aliasPath] ?? null; +} + +function asRole(value: unknown): Role | null { + const valid: Role[] = [ + 'background', + 'foreground', + 'border', + 'space', + 'dimension', + 'borderRadius', + 'shadow', + ]; + return typeof value === 'string' && (valid as string[]).includes(value) + ? (value as Role) + : null; +} + +function isGradeKey(value: unknown): value is '100' | '200' | 'ambiguous' { + return value === '100' || value === '200' || value === 'ambiguous'; +} + +function flattenSemantic( + root: { colors?: Node }, + primitive: Record, +): { + semantic: Record; + tokenKeys: string[]; + hexIndex: Map; +} { + const out: Record = {}; + const hexIndex = new Map(); + + const walk = (node: Node, path: string, inheritedGradeRules: Record | null) => { + const meta = vaporMeta(node); + const gradeRules = + (meta.gradeRules as Record | undefined) ?? inheritedGradeRules; + if ('$value' in node && typeof node.$value === 'string') { + const grade = path.split('-').pop() ?? ''; + const valueRef = node.$value as string; + const hex = resolveAlias(valueRef, primitive); + // Store the grade key ('100'/'200') as GradeRule.other rather than the + // description text — the type requires '100'|'200'|'ambiguous'|null and + // downstream evaluators need the key, not prose. + const hasGradeRule = gradeRules != null && grade in gradeRules; + const gradeRuleOther: '100' | '200' | 'ambiguous' | null = + hasGradeRule && isGradeKey(grade) ? grade : null; + out[path] = { + role: asRole((meta.accessibility as { role?: unknown } | undefined)?.role), + status: typeof meta.status === 'string' ? meta.status : null, + valueRef, + hex, + when: Array.isArray(meta.when) ? (meta.when as string[]) : [], + avoid: Array.isArray(meta.avoid) ? (meta.avoid as string[]) : [], + description: typeof node.$description === 'string' ? node.$description : null, + gradeRule: gradeRuleOther ? { other: gradeRuleOther } : null, + }; + if (hex) { + const key = hex.toLowerCase(); + const arr = hexIndex.get(key) ?? []; + arr.push(path); + hexIndex.set(key, arr); + } + return; + } + for (const [k, v] of Object.entries(node)) { + if (k.startsWith('$')) continue; + walk(v as Node, path ? `${path}-${k}` : k, gradeRules ?? null); + } + }; + if (root.colors) walk(root.colors, 'color', null); + return { semantic: out, tokenKeys: Object.keys(out), hexIndex }; +} + +export function loadColorSchema(mode: SchemaMode): ColorSchema { + const semanticRaw = mode === 'dark' ? semanticDark : semanticLight; + const primitiveRaw = mode === 'dark' ? primitiveDark : primitiveLight; + const primitive = flattenPrimitive(primitiveRaw as { colors?: Node }); + const { semantic, tokenKeys, hexIndex } = flattenSemantic( + semanticRaw as { colors?: Node }, + primitive, + ); + return { mode, semantic, primitive, tokenKeys, hexIndex }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/dimension.test.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/dimension.test.ts new file mode 100644 index 000000000..5ae7ec002 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/dimension.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; + +describe('loadDimensionSchemas', () => { + const schemas = loadDimensionSchemas(); + + it('space tokens 의 valueToTokens 인덱스가 빈 배열을 만들지 않는다', () => { + for (const [value, tokens] of schemas.space.valueToTokens.entries()) { + expect(value).toMatch(/^[\d.]+(px|rem|%)?$/); + expect(tokens.length).toBeGreaterThanOrEqual(1); + } + }); + + it('dimension, borderRadius, shadow 모두 동일한 인터페이스를 갖는다', () => { + for (const key of ['space', 'dimension', 'borderRadius', 'shadow'] as const) { + expect(schemas[key].tokens).toBeTypeOf('object'); + expect(schemas[key].valueToTokens).toBeInstanceOf(Map); + } + }); + + it('각 카테고리가 최소 1개 이상의 토큰을 포함한다', () => { + expect(Object.keys(schemas.space.tokens).length).toBeGreaterThan(0); + expect(Object.keys(schemas.dimension.tokens).length).toBeGreaterThan(0); + expect(Object.keys(schemas.borderRadius.tokens).length).toBeGreaterThan(0); + expect(Object.keys(schemas.shadow.tokens).length).toBeGreaterThan(0); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/dimension.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/dimension.ts new file mode 100644 index 000000000..4f5817fdc --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/dimension.ts @@ -0,0 +1,92 @@ +import borderRadiusRaw from '~/common/tokens/border-radius.json'; +import dimensionRaw from '~/common/tokens/dimension.json'; +import shadowRaw from '~/common/tokens/shadow.json'; +import spaceRaw from '~/common/tokens/space.json'; + +export type TokenValueIndex = { + tokens: Record; + valueToTokens: Map; +}; + +export type DimensionSchemas = { + space: TokenValueIndex; + dimension: TokenValueIndex; + borderRadius: TokenValueIndex; + shadow: TokenValueIndex; +}; + +type DimensionValue = { value: number; unit: string }; +type Node = { $value?: string | object; [k: string]: unknown }; + +/** + * Stringify a token $value. + * For dimension objects `{ value: 8, unit: "px" }` → `"8px"`. + * For shadow objects → JSON string (identity for downstream lookup). + * For plain strings → identity. + */ +function stringify(v: string | object): string { + if (typeof v === 'string') return v; + if (typeof v === 'object' && v !== null && 'value' in v && 'unit' in v) { + const dv = v as DimensionValue; + return `${dv.value}${dv.unit}`; + } + return JSON.stringify(v); +} + +function flatten(root: unknown, rootKey: string): TokenValueIndex { + const tokens: Record = {}; + const valueToTokens = new Map(); + + const walk = (node: Node, path: string) => { + if (!node || typeof node !== 'object') return; + if ('$value' in node && node.$value !== undefined) { + const value = stringify(node.$value as string | object); + tokens[path] = value; + const arr = valueToTokens.get(value) ?? []; + arr.push(path); + valueToTokens.set(value, arr); + return; + } + for (const [k, v] of Object.entries(node)) { + if (k.startsWith('$')) continue; + walk(v as Node, path ? `${path}-${k}` : k); + } + }; + + const r = (root as Record)[rootKey] as Node | undefined; + if (r) walk(r, rootKey); + return { tokens, valueToTokens }; +} + +/** + * space.json, dimension.json, border-radius.json all use: + * { size: { : { ... } } } + * Extract via the `size` wrapper then delegate to `flatten` with the inner key. + * The initial path is prefixed with `size-` to produce keys like `size-space-200`. + */ +function flattenSized(raw: { size?: Record }, innerKey: string): TokenValueIndex { + const index = flatten(raw.size ?? {}, innerKey); + // Re-key: replace initial `` prefix with `size-` + const tokens: Record = {}; + const valueToTokens = new Map(); + for (const [k, v] of Object.entries(index.tokens)) { + const newKey = `size-${k}`; + tokens[newKey] = v; + const arr = valueToTokens.get(v) ?? []; + arr.push(newKey); + valueToTokens.set(v, arr); + } + return { tokens, valueToTokens }; +} + +export function loadDimensionSchemas(): DimensionSchemas { + return { + space: flattenSized(spaceRaw as { size?: Record }, 'space'), + dimension: flattenSized(dimensionRaw as { size?: Record }, 'dimension'), + borderRadius: flattenSized( + borderRadiusRaw as { size?: Record }, + 'borderRadius', + ), + shadow: flatten(shadowRaw, 'shadow'), + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/legacy-color.test.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/legacy-color.test.ts new file mode 100644 index 000000000..da2ec3b5c --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/legacy-color.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { loadLegacyColorSchema, suggestLegacyReplacement } from '~/ui/lib/loaders/legacy-color'; + +describe('legacy-color loader', () => { + it('레거시 oldName → v1_0_0 매핑을 구축한다', () => { + const schema = loadLegacyColorSchema(); + expect(schema.byOldName.get('primary')).toBe('color-background-primary-200'); + expect(schema.byOldName.get('text-primary')).toBe('color-foreground-primary-100'); + expect(schema.byOldName.get('border-normal')).toBe('color-border-normal'); + }); + + it('v1_0_0 이 "-" 로 시작하는 항목은 제외', () => { + const schema = loadLegacyColorSchema(); + // primary-transparent-8 → "-(hex code로 사용)" 이므로 매핑 없음 + expect(schema.byOldName.has('primary-transparent-8')).toBe(false); + }); + + it('rgb → hex 인덱스 조회', () => { + const schema = loadLegacyColorSchema(); + // rgb(42, 114, 229) → #2a72e5 + const candidates = schema.byHex.get('#2a72e5'); + expect(candidates).toBeTruthy(); + expect(candidates?.some((c) => c.token === 'color-background-primary-200')).toBe(true); + }); +}); + +describe('suggestLegacyReplacement', () => { + it('oldName 이 매치하면 신규 토큰 반환', () => { + expect( + suggestLegacyReplacement({ + token: 'text-primary', + hex: null, + property: 'fill-on-text', + }), + ).toBe('color-foreground-primary-100'); + }); + + it('hex + property role 이 매치되는 후보 우선', () => { + // rgb(93,93,93) → hint / text-hint 둘 다 후보. fill 은 background 우선. + expect(suggestLegacyReplacement({ token: null, hex: '#5d5d5d', property: 'fill' })).toBe( + 'color-background-hint-200', + ); + expect( + suggestLegacyReplacement({ + token: null, + hex: '#5d5d5d', + property: 'fill-on-text', + }), + ).toBe('color-foreground-hint-100'); + }); + + it('매핑 없으면 null', () => { + expect( + suggestLegacyReplacement({ + token: 'not-a-legacy-name', + hex: '#123456', + property: 'fill', + }), + ).toBeNull(); + }); + + it('레거시 oldName 이라도 v1_0_0 이 "-" 로 시작하면 null', () => { + expect( + suggestLegacyReplacement({ + token: 'primary-hover', + hex: null, + property: 'fill', + }), + ).toBeNull(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/legacy-color.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/legacy-color.ts new file mode 100644 index 000000000..b32e2600d --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/legacy-color.ts @@ -0,0 +1,136 @@ +import type { Property } from '~/common/schemas'; +import legacyRaw from '~/common/tokens/legacy-color.json'; + +/** + * 레거시 vapor 컬러 → v1_0_0 신규 토큰 매핑. + * + * JSON 소스: `common/tokens/legacy-color.json`. + * 각 엔트리는 { type, oldName, oldValue, v1_0_0, section }. + * - type "변경" | "삭제" | "추가" + * - v1_0_0 값이 "-" 로 시작하면 신규 매핑 없음 (추천 없음) + * - oldName / oldValue 가 "-" 이면 신규 전용 항목 (역참조 대상 아님) + */ + +export type LegacyColorEntry = { + type: string; + oldName: string; + oldValue: string; + v1_0_0: string; + section: string; +}; + +export type LegacyRole = 'background' | 'foreground' | 'border' | null; + +/** v1_0_0 값에서 실제 신규 토큰 키만 추출. + * "-" 로 시작하면 매핑 없음. + * "color-white로 대체" 처럼 뒤에 한글 설명이 붙은 경우 앞의 토큰만 반환. + */ +function normalizeNewToken(v: string): string | null { + if (!v) return null; + if (v.trim().startsWith('-')) return null; + const match = v.match(/^([a-z][a-z0-9-]*)/); + return match ? match[1] : null; +} + +/** rgb / rgba 문자열 → #rrggbb (alpha 무시). + * "rgb(42, 114, 229)" / "rgba(42, 114, 229, 0.08)" 지원. + * "-" 같은 sentinel 은 null. + */ +function rgbStringToHex(v: string): string | null { + if (!v || v === '-') return null; + const m = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (!m) return null; + const toHex = (n: string) => + Math.max(0, Math.min(255, Number(n))) + .toString(16) + .padStart(2, '0'); + return `#${toHex(m[1])}${toHex(m[2])}${toHex(m[3])}`; +} + +function roleOfNewToken(newToken: string): LegacyRole { + if (newToken.startsWith('color-background-')) return 'background'; + if (newToken.startsWith('color-foreground-')) return 'foreground'; + if (newToken.startsWith('color-border-')) return 'border'; + return null; +} + +/** property → 필수 매칭 role. 매칭되지 않는 새 토큰은 오히려 오해를 유발하므로 제안 안 함. */ +function requiredRoleForProperty(property: Property): LegacyRole { + if (property === 'fill') return 'background'; + if (property === 'fill-on-text') return 'foreground'; + if (property === 'stroke') return 'border'; + return null; +} + +export type LegacyColorSchema = { + /** oldName → v1_0_0 신규 토큰 (매핑 없는 항목 제외). */ + byOldName: Map; + /** #rrggbb → 후보 신규 토큰 리스트 (같은 hex 가 여러 role 에 재사용될 수 있음). */ + byHex: Map>; +}; + +let cached: LegacyColorSchema | null = null; + +export function loadLegacyColorSchema(): LegacyColorSchema { + if (cached) return cached; + + const byOldName = new Map(); + const byHex = new Map>(); + + for (const entry of legacyRaw as LegacyColorEntry[]) { + const newToken = normalizeNewToken(entry.v1_0_0); + if (!newToken) continue; + + if (entry.oldName && entry.oldName !== '-' && !byOldName.has(entry.oldName)) { + byOldName.set(entry.oldName, newToken); + } + + const hex = rgbStringToHex(entry.oldValue); + if (hex) { + const key = hex.toLowerCase(); + const arr = byHex.get(key) ?? []; + arr.push({ token: newToken, role: roleOfNewToken(newToken) }); + byHex.set(key, arr); + } + } + + cached = { byOldName, byHex }; + return cached; +} + +/** + * 스캔된 컬러가 레거시일 경우 신규 토큰 제안. + * + * 규칙: + * 1. token 이름이 레거시 oldName 과 정확히 일치 → 해당 신규 토큰 (role 검사 후 반환). + * 2. 아니면 hex 로 찾되 property scope 에 맞는 role 인 후보만 반환. + * 3. role 이 property 와 안 맞으면 null. (fill 에 foreground 신규 토큰 제안은 오히려 오해) + * + * 매핑 없으면 null (UI 에서 "추천 없음" 표시). + */ +export function suggestLegacyReplacement(input: { + token: string | null; + hex: string | null; + property: Property; +}): string | null { + const schema = loadLegacyColorSchema(); + const requiredRole = requiredRoleForProperty(input.property); + + const matchesRole = (newToken: string): boolean => { + if (!requiredRole) return true; + return roleOfNewToken(newToken) === requiredRole; + }; + + if (input.token) { + const byName = schema.byOldName.get(input.token); + if (byName && matchesRole(byName)) return byName; + } + + if (input.hex) { + const candidates = schema.byHex.get(input.hex.toLowerCase()) ?? []; + const match = candidates.find((c) => (requiredRole ? c.role === requiredRole : true)); + if (match) return match.token; + } + + return null; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/typography.test.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/typography.test.ts new file mode 100644 index 000000000..15e589964 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/typography.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { loadTextStyleSchema } from '~/ui/lib/loaders/typography'; + +describe('loadTextStyleSchema', () => { + const schema = loadTextStyleSchema(); + + it('order는 위계 큰 것부터 작은 것 순서를 유지한다 (display1 → ... → body4)', () => { + expect(schema.order.indexOf('display1')).toBeLessThan(schema.order.indexOf('body4')); + }); + + it('각 스타일은 rank 인덱스를 갖는다', () => { + for (const [name, meta] of Object.entries(schema.styles)) { + expect(meta.rank).toBe(schema.order.indexOf(name)); + } + }); + + it('display1 fontSize 는 typography.json 의 1000 스케일(120) 로 resolve 된다', () => { + expect(schema.styles.display1.fontSize).toBe(120); + }); + + it('body2 fontSize 는 typography.json 의 075 스케일(14) 로 resolve 된다', () => { + expect(schema.styles.body2.fontSize).toBe(14); + }); + + it('alias 를 해석할 수 없는 케이스는 null', () => { + // 스키마 안에 실제로 alias resolve 실패 케이스가 없더라도, 타입이 nullable 이라는 것을 보장 + for (const meta of Object.values(schema.styles)) { + expect(meta.fontSize === null || typeof meta.fontSize === 'number').toBe(true); + } + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/loaders/typography.ts b/apps/figma-token-review-plugin/src/ui/lib/loaders/typography.ts new file mode 100644 index 000000000..3ff8ae4c4 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/loaders/typography.ts @@ -0,0 +1,70 @@ +import textStyleRaw from '~/common/tokens/text-style.json'; +import typographyRaw from '~/common/tokens/typography.json'; + +const NS = 'io.goorm.vapor'; + +export type TextStyleMeta = { + rank: number; + when: string[]; + avoid: string[]; + description: string | null; + fontSize: number | null; +}; + +export type TextStyleSchema = { + order: string[]; + styles: Record; +}; + +type Node = { + $description?: string; + $extensions?: { [key: string]: Record }; + $value?: { fontSize?: unknown }; +}; + +function loadFontSizeTable(): Record { + const root = typographyRaw as { + typography?: { fontSize?: Record }; + }; + const raw = root.typography?.fontSize ?? {}; + const out: Record = {}; + for (const [key, node] of Object.entries(raw)) { + if (key.startsWith('$')) continue; + const value = node?.$value?.value; + if (typeof value === 'number') out[key] = value; + } + return out; +} + +function resolveFontSize(alias: unknown, table: Record): number | null { + if (typeof alias !== 'string') return null; + const m = alias.match(/^\{typography\.fontSize\.([^}]+)\}$/); + if (!m) return null; + const key = m[1]; + return typeof table[key] === 'number' ? table[key] : null; +} + +export function loadTextStyleSchema(): TextStyleSchema { + const root = textStyleRaw as { textStyle?: Record }; + const fontSizeTable = loadFontSizeTable(); + const order: string[] = []; + const styles: Record = {}; + const entries = Object.entries(root.textStyle ?? {}); + for (const [name, node] of entries) { + if (name.startsWith('$') || typeof node !== 'object' || !node) continue; + const meta = ((node as Node).$extensions?.[NS] ?? {}) as Record; + const alias = (node as Node).$value?.fontSize; + styles[name] = { + rank: order.length, + when: Array.isArray(meta.when) ? (meta.when as string[]) : [], + avoid: Array.isArray(meta.avoid) ? (meta.avoid as string[]) : [], + description: + typeof (node as Node).$description === 'string' + ? ((node as Node).$description ?? null) + : null, + fontSize: resolveFontSize(alias, fontSizeTable), + }; + order.push(name); + } + return { order, styles }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/recommend.test.ts b/apps/figma-token-review-plugin/src/ui/lib/recommend.test.ts new file mode 100644 index 000000000..dbc740449 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/recommend.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from 'vitest'; + +import type { Violation } from '~/common/schemas'; +import { loadColorSchema } from '~/ui/lib/loaders/color'; +import { loadDimensionSchemas } from '~/ui/lib/loaders/dimension'; +import { applyRecommendations } from '~/ui/lib/recommend'; + +const colorSchema = loadColorSchema('light'); +const dim = loadDimensionSchemas(); + +function v(partial: Partial): Violation { + return { + nodeId: 'n', + name: 's', + property: 'fill', + token: null, + value: null, + type: 'token-not-used', + severity: 'high', + origin: 'rule', + message: '', + suggested: [], + ...partial, + }; +} + +// ----------------------------------------------------------------------- +// Helper: find a hex that has at least one background-role semantic token +// ----------------------------------------------------------------------- +function findBgHex(): { hex: string; tokens: string[] } { + for (const [hex, tokens] of colorSchema.hexIndex) { + const hasBg = tokens.some((t) => colorSchema.semantic[t]?.role === 'background'); + if (hasBg) + return { + hex, + tokens: tokens.filter((t) => colorSchema.semantic[t]?.role === 'background'), + }; + } + throw new Error('No background-role hex found in hexIndex'); +} + +describe('applyRecommendations', () => { + // Case 1: Raw color → 동일 hex+스코프 semantic 후보 + it('raw color(token-not-used, fill)는 동일 hex + background role 토큰을 suggested에 담는다', () => { + const { hex, tokens } = findBgHex(); + const out = applyRecommendations( + [v({ type: 'token-not-used', property: 'fill', value: hex })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested.length).toBeGreaterThan(0); + for (const s of out[0].suggested) { + expect(tokens).toContain(s); + } + }); + + // Case 1b: Raw color with no scope match → primitive fallback + it('raw color(token-not-used)에서 scope 일치 없으면 동일 hex primitive 토큰 반환', () => { + // Find a hex that exists in primitive but NOT in hexIndex (semantic) — or use a hex + // that has only foreground semantics paired with a fill property (expects background). + // Strategy: find any primitive hex that has ONLY foreground semantics. + // If none, pick any primitive and a property whose allowed roles don't include any semantic for that hex. + let testHex: string | null = null; + let expectedPrimKey: string | null = null; + + for (const [primKey, primHex] of Object.entries(colorSchema.primitive)) { + const semTokens = colorSchema.hexIndex.get(primHex.toLowerCase()) ?? []; + // All semantic tokens for this hex must be foreground-only + const allForeground = + semTokens.length > 0 && + semTokens.every((t) => colorSchema.semantic[t]?.role === 'foreground'); + if (allForeground) { + testHex = primHex; + expectedPrimKey = primKey; + break; + } + // Or hex not in semantic at all (pure primitive) + if (semTokens.length === 0) { + testHex = primHex; + expectedPrimKey = primKey; + break; + } + } + + if (!testHex || !expectedPrimKey) return; // no suitable primitive found, skip + + // Use property 'fill' (expects 'background' role) — no foreground semantic will scope-match + const out = applyRecommendations( + [v({ type: 'token-not-used', property: 'fill', value: testHex })], + { colorSchema, ...dim }, + ); + // Primitive key must appear in suggestions (primitive fallback path) + expect(out[0].suggested).toContain(expectedPrimKey); + // 남은 항목은 primitive 이거나 레거시 매핑에서 온 신규 semantic 이어야 한다. + for (const s of out[0].suggested) { + const isPrimitive = colorSchema.semantic[s] === undefined; + const isSemanticBackground = + colorSchema.semantic[s]?.role === 'background' && + colorSchema.semantic[s]?.status !== 'do-not-use'; + expect(isPrimitive || isSemanticBackground).toBe(true); + } + }); + + // Case 2: Raw dimension/space/radius/shadow → 동일 value 토큰 + it('raw space(token-not-used, padding)는 동일 value space 토큰을 suggested에 담는다', () => { + const [[value]] = dim.space.valueToTokens.entries(); + const out = applyRecommendations( + [v({ type: 'token-not-used', property: 'padding', value })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested.length).toBeGreaterThan(0); + expect(dim.space.valueToTokens.get(value)).toEqual( + expect.arrayContaining(out[0].suggested), + ); + }); + + it('raw dimension(token-not-used, width)는 동일 value dimension 토큰을 suggested에 담는다', () => { + const [[value]] = dim.dimension.valueToTokens.entries(); + const out = applyRecommendations( + [v({ type: 'token-not-used', property: 'width', value })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested.length).toBeGreaterThan(0); + expect(dim.dimension.valueToTokens.get(value)).toEqual( + expect.arrayContaining(out[0].suggested), + ); + }); + + it('raw borderRadius(token-not-used, borderRadius)는 동일 value borderRadius 토큰을 suggested에 담는다', () => { + const [[value]] = dim.borderRadius.valueToTokens.entries(); + const out = applyRecommendations( + [v({ type: 'token-not-used', property: 'borderRadius', value })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested.length).toBeGreaterThan(0); + expect(dim.borderRadius.valueToTokens.get(value)).toEqual( + expect.arrayContaining(out[0].suggested), + ); + }); + + it('raw shadow(token-not-used, shadow)는 동일 value shadow 토큰을 suggested에 담는다', () => { + const [[value]] = dim.shadow.valueToTokens.entries(); + const out = applyRecommendations( + [v({ type: 'token-not-used', property: 'shadow', value })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested.length).toBeGreaterThan(0); + expect(dim.shadow.valueToTokens.get(value)).toEqual( + expect.arrayContaining(out[0].suggested), + ); + }); + + // Case 3: Primitive-used → 동일 hex+스코프 semantic + it('primitive-used(fill-on-text)는 동일 hex + foreground role 토큰을 suggested에 담는다', () => { + // Find a primitive token that maps to a foreground semantic hex + const fgEntry = Object.entries(colorSchema.semantic).find( + ([, m]) => m.role === 'foreground' && m.hex && m.status !== 'do-not-use', + ); + if (!fgEntry) return; + const [fgToken, fgMeta] = fgEntry; + const hex = fgMeta.hex!; + // Build a primitive key whose value matches this hex + const primitiveKey = Object.entries(colorSchema.primitive).find( + ([, v]) => v.toLowerCase() === hex.toLowerCase(), + )?.[0]; + + const out = applyRecommendations( + [ + v({ + type: 'primitive-used', + property: 'fill-on-text', + token: primitiveKey ?? 'color-blue-600', + value: hex, + }), + ], + { colorSchema, ...dim }, + ); + // Should suggest the matching foreground semantic token + expect(out[0].suggested).toContain(fgToken); + }); + + // Case 4: Scope mismatch → 해당 property scope + 같은 위계(grade) + it('role-mismatch(fill, foreground token -100)는 background role의 -100 토큰을 suggested에 담는다', () => { + // token = color-foreground-primary-100, property = fill (expects background) + const token = 'color-foreground-primary-100'; + const out = applyRecommendations([v({ type: 'role-mismatch', property: 'fill', token })], { + colorSchema, + ...dim, + }); + expect(out[0].suggested.length).toBeGreaterThan(0); + for (const s of out[0].suggested) { + const meta = colorSchema.semantic[s]; + expect(meta?.role).toBe('background'); + expect(s.endsWith('-100')).toBe(true); + } + }); + + // Case 5: do-not-use → fallback chain (same-grade sibling) + it('do-not-use(color-background-secondary-100)는 동위계(grade=100) 다른 family background 토큰을 suggested에 담는다', () => { + const token = 'color-background-secondary-100'; + const out = applyRecommendations([v({ type: 'do-not-use', property: 'fill', token })], { + colorSchema, + ...dim, + }); + // Fallback #3: same grade (-100), same role (background), different family, not do-not-use + expect(out[0].suggested.length).toBeGreaterThan(0); + for (const s of out[0].suggested) { + const meta = colorSchema.semantic[s]; + expect(meta?.status).not.toBe('do-not-use'); + expect(meta?.role).toBe('background'); + expect(s.endsWith('-100')).toBe(true); + expect(s).not.toContain('background-secondary'); + } + }); + + // Case 6: fg-grade-mismatch → same family .200 + it('fg-grade-mismatch(-100 token)는 동일 family의 -200 토큰을 suggested에 담는다', () => { + const token = 'color-foreground-primary-100'; + const out = applyRecommendations( + [v({ type: 'fg-grade-mismatch', property: 'fill-on-text', token })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested).toEqual(['color-foreground-primary-200']); + }); + + it('fg-grade-mismatch(-200 token)는 동일 family의 -100 토큰을 suggested에 담는다', () => { + const token = 'color-foreground-primary-200'; + const out = applyRecommendations( + [v({ type: 'fg-grade-mismatch', property: 'fill-on-text', token })], + { colorSchema, ...dim }, + ); + expect(out[0].suggested).toEqual(['color-foreground-primary-100']); + }); + + // Case 7: Unknown / fg-grade-ambiguous / typo-raw / typo-styled-override → [] + it('unknown-token은 suggested가 []', () => { + const out = applyRecommendations([v({ type: 'unknown-token' })], { colorSchema, ...dim }); + expect(out[0].suggested).toEqual([]); + }); + + it('fg-grade-ambiguous는 suggested가 []', () => { + const out = applyRecommendations([v({ type: 'fg-grade-ambiguous' })], { + colorSchema, + ...dim, + }); + expect(out[0].suggested).toEqual([]); + }); + + it('typo-raw는 suggested가 []', () => { + const out = applyRecommendations([v({ type: 'typo-raw' })], { colorSchema, ...dim }); + expect(out[0].suggested).toEqual([]); + }); + + it('typo-styled-override는 suggested가 []', () => { + const out = applyRecommendations([v({ type: 'typo-styled-override' })], { + colorSchema, + ...dim, + }); + expect(out[0].suggested).toEqual([]); + }); + + // LLM origin pass-through + it('origin=llm인 violation은 suggested를 변경하지 않는다', () => { + const original = ['color-background-primary-100']; + const out = applyRecommendations( + [ + v({ + type: 'role-mismatch', + property: 'fill', + token: 'color-foreground-primary-100', + suggested: original, + origin: 'llm', + }), + ], + { colorSchema, ...dim }, + ); + expect(out[0].suggested).toEqual(original); + }); + + // Immutability: input array is not mutated + it('입력 violations 배열을 변경하지 않는다', () => { + const input = [v({ type: 'token-not-used', property: 'fill', value: '#000000' })]; + const before = [...input[0].suggested]; + applyRecommendations(input, { colorSchema, ...dim }); + expect(input[0].suggested).toEqual(before); + }); +}); + +describe('applyRecommendations - typography pass-through', () => { + const colorSchema = loadColorSchema('light'); + const dim = loadDimensionSchemas(); + const ctx = { + colorSchema, + space: dim.space, + dimension: dim.dimension, + borderRadius: dim.borderRadius, + shadow: dim.shadow, + }; + + it('typo-raw 는 이미 채워진 suggested 를 보존한다', () => { + const v: Violation = { + nodeId: 'n1', + name: 'title', + property: 'textStyle', + token: null, + value: null, + type: 'typo-raw', + severity: 'high', + origin: 'rule', + message: 'raw', + suggested: ['body2'], + }; + const [out] = applyRecommendations([v], ctx); + expect(out.suggested).toEqual(['body2']); + }); + + it('typo-styled-override 는 이미 채워진 suggested 를 보존한다', () => { + const v: Violation = { + nodeId: 'n2', + name: 'x', + property: 'textStyle', + token: 'body1', + value: null, + type: 'typo-styled-override', + severity: 'info', + origin: 'rule', + message: 'override', + suggested: ['body1'], + }; + const [out] = applyRecommendations([v], ctx); + expect(out.suggested).toEqual(['body1']); + }); + + it('typography unknown-token 은 suggested 를 덮어쓰지 않는다', () => { + const v: Violation = { + nodeId: 'n3', + name: 'x', + property: 'textStyle', + token: 'nonexistent', + value: null, + type: 'unknown-token', + severity: 'high', + origin: 'rule', + message: 'unknown', + suggested: [], + }; + const [out] = applyRecommendations([v], ctx); + expect(out.suggested).toEqual([]); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/recommend.ts b/apps/figma-token-review-plugin/src/ui/lib/recommend.ts new file mode 100644 index 000000000..25f14c8eb --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/recommend.ts @@ -0,0 +1,284 @@ +/* ------------------------------------------------------------------------------------------------- + * Unified Recommend module + * + * applyRecommendations(violations, ctx) — violation type/property에 따라 + * suggested[] 를 채워 새 배열로 반환 (입력 violations[] 불변). + * heuristic === true 인 violation은 pass-through. + * -----------------------------------------------------------------------------------------------*/ +import type { Property, Violation } from '~/common/schemas'; +import type { ColorSchema } from '~/ui/lib/loaders/color'; +import type { TokenValueIndex } from '~/ui/lib/loaders/dimension'; +import { suggestLegacyReplacement } from '~/ui/lib/loaders/legacy-color'; +import { PROPERTY_SCOPE } from '~/ui/lib/scope'; + +export type RecommendCtx = { + colorSchema: ColorSchema; + space: TokenValueIndex; + dimension: TokenValueIndex; + borderRadius: TokenValueIndex; + shadow: TokenValueIndex; +}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Return the TokenValueIndex for a given non-color property. + * Returns null for color properties (fill, fill-on-text, stroke). + */ +function dimensionIndex(property: Property, ctx: RecommendCtx): TokenValueIndex | null { + switch (property) { + case 'padding': + case 'paddingTop': + case 'paddingRight': + case 'paddingBottom': + case 'paddingLeft': + case 'paddingVertical': + case 'paddingHorizontal': + case 'gap': + return ctx.space; + case 'width': + case 'height': + return ctx.dimension; + case 'borderRadius': + return ctx.borderRadius; + case 'shadow': + return ctx.shadow; + default: + return null; + } +} + +/** + * For raw color or primitive-used: look up hex in hexIndex, filter by property scope. + * Priority: scope-matched semantics → same-hex primitives → [] + */ +function colorSuggestions(hex: string | null, property: Property, schema: ColorSchema): string[] { + if (!hex) return []; + const key = hex.toLowerCase(); + const candidates = schema.hexIndex.get(key) ?? []; + const allowedRoles = (PROPERTY_SCOPE as Record>)[property] ?? []; + + const scoped = candidates.filter((token) => { + const meta = schema.semantic[token]; + return ( + meta && meta.role && allowedRoles.includes(meta.role) && meta.status !== 'do-not-use' + ); + }); + + if (scoped.length > 0) return scoped; + + // No scope-matched semantic — fall back to primitive tokens with the same hex + return Object.entries(schema.primitive) + .filter(([, v]) => v.toLowerCase() === key) + .map(([k]) => k); +} + +/** + * For raw dimension/space/radius/shadow: look up value in the appropriate index. + */ +function valueSuggestions(value: string | null, index: TokenValueIndex): string[] { + if (!value) return []; + return index.valueToTokens.get(value) ?? []; +} + +/** + * For scope (role) mismatch: suggest semantic tokens that match the property's allowed roles + * and share the same grade segment as the violating token. + */ +function scopeMismatchSuggestions( + token: string | null, + property: Property, + schema: ColorSchema, +): string[] { + if (!token) return []; + const grade = token.split('-').pop() ?? ''; + const allowedRoles = (PROPERTY_SCOPE as Record>)[property] ?? []; + + return Object.entries(schema.semantic) + .filter(([path, meta]) => { + if (meta.status === 'do-not-use') return false; + if (!meta.role || !allowedRoles.includes(meta.role)) return false; + if (grade && path.split('-').pop() !== grade) return false; + return true; + }) + .map(([path]) => path); +} + +/** + * For do-not-use tokens: 3-step fallback chain. + * 1. meta.replacement (string | string[]) explicit field + * 2. meta.avoid 우변 `colors\.[a-zA-Z0-9.]+` 정규식 추출 (dedup 포함) + * 3. 동위계(same grade) + same role + different family + status !== 'do-not-use' + */ +function doNotUseSuggestions(token: string | null, schema: ColorSchema): string[] { + if (!token) return []; + const meta = schema.semantic[token]; + if (!meta) return []; + + // Fallback 1: explicit replacement field (not present in current schema but forward-compat) + const replacement = (meta as unknown as Record)['replacement']; + if (replacement) { + if (typeof replacement === 'string') return [replacement]; + if (Array.isArray(replacement)) return replacement as string[]; + } + + // Fallback 2: parse avoid[] for token refs + const fromAvoid = (meta.avoid ?? []) + .flatMap((line) => Array.from(line.matchAll(/color-[a-zA-Z0-9-]+/g), (m) => m[0])) + .filter( + (cand) => + cand !== token && + cand in schema.semantic && + schema.semantic[cand].status !== 'do-not-use', + ); + if (fromAvoid.length > 0) return Array.from(new Set(fromAvoid)); + + // Fallback 3: same grade + same role + different family + not do-not-use + const parts = token.split('-'); + // Expected structure: color--- + if (parts.length < 4) return []; + const grade = parts[parts.length - 1]; + const role = parts[1]; + const family = parts[2]; + + return Object.entries(schema.semantic) + .filter(([path, m]) => { + if (m.status === 'do-not-use') return false; + const p = path.split('-'); + if (p.length < 4) return false; + const pGrade = p[p.length - 1]; + const pRole = p[1]; + const pFamily = p[2]; + return pGrade === grade && pRole === role && pFamily !== family; + }) + .map(([path]) => path); +} + +/** + * For fg-grade-mismatch: suggest the same-family opposite-grade token when it exists. + * .100 ↔ .200 swap on the last segment. + */ +function fgGradeMismatchSuggestions(token: string | null, schema: ColorSchema): string[] { + if (!token) return []; + const parts = token.split('-'); + const grade = parts[parts.length - 1]; + const opposite = grade === '100' ? '200' : grade === '200' ? '100' : null; + if (!opposite) return []; + + const base = parts.slice(0, -1).join('-'); + const candidate = `${base}-${opposite}`; + if (schema.semantic[candidate] && schema.semantic[candidate].status !== 'do-not-use') { + return [candidate]; + } + return []; +} + +// --------------------------------------------------------------------------- +// Main dispatcher +// --------------------------------------------------------------------------- + +const COLOR_PROPERTIES: ReadonlySet = new Set(['fill', 'fill-on-text', 'stroke']); + +/** color property 위반에 대해 레거시 토큰 매핑을 앞쪽에 추가. + * - property 가 color 계열이 아니면 그대로 반환. + * - legacy 매핑이 이미 suggested 안에 있으면 중복 제거. + */ +function withLegacySuggestion( + suggested: string[], + token: string | null, + value: string | null, + property: Property, +): string[] { + if (!COLOR_PROPERTIES.has(property)) return suggested; + const legacy = suggestLegacyReplacement({ token, hex: value, property }); + if (!legacy) return suggested; + if (suggested.includes(legacy)) return suggested; + return [legacy, ...suggested]; +} + +export function applyRecommendations(violations: Violation[], ctx: RecommendCtx): Violation[] { + return violations.map((violation) => { + // llm origin pass-through — never modify + if (violation.origin === 'llm') return violation; + + let suggested: string[] = []; + + const { type, property, token, value } = violation; + + switch (type) { + case 'token-not-used': { + // Check if this is a color property + const idx = dimensionIndex(property, ctx); + if (idx !== null) { + // Dimension / space / radius / shadow raw value + suggested = valueSuggestions(value, idx); + } else { + // Color raw value + suggested = colorSuggestions(value, property, ctx.colorSchema); + } + break; + } + + case 'primitive-used': { + // Same hex + scope semantic only (no primitive fallback) + const meta = token ? ctx.colorSchema.semantic[token] : null; + const hex = meta?.hex ?? value; + const allowedRoles = + (PROPERTY_SCOPE as Record>)[property] ?? []; + const candidates = hex + ? (ctx.colorSchema.hexIndex.get(hex.toLowerCase()) ?? []) + : []; + suggested = candidates.filter((t) => { + const m = ctx.colorSchema.semantic[t]; + return ( + m && m.role && allowedRoles.includes(m.role) && m.status !== 'do-not-use' + ); + }); + break; + } + + case 'role-mismatch': { + suggested = scopeMismatchSuggestions(token, property, ctx.colorSchema); + break; + } + + case 'do-not-use': { + suggested = doNotUseSuggestions(token, ctx.colorSchema); + break; + } + + case 'fg-grade-mismatch': { + suggested = fgGradeMismatchSuggestions(token, ctx.colorSchema); + break; + } + + case 'typo-raw': + case 'typo-styled-override': + // suggested already filled by evaluateTypography — do not overwrite + return violation; + + case 'unknown-token': + if (property === 'textStyle') { + // typography path — evaluateTypography already set suggested (currently []) + return violation; + } + suggested = []; + break; + + // Color-side ambiguous / LLM-defense types → no suggestions + case 'fg-grade-ambiguous': + case 'semantic-misfit': + case 'typo-hierarchy': + case 'text-contrast-low': // text-contrast-low: token swap 이 아닌 디자인 판단 이슈. suggested 없음. + suggested = []; + break; + + default: + suggested = []; + } + + return { ...violation, suggested: withLegacySuggestion(suggested, token, value, property) }; + }); +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/rubric.test.ts b/apps/figma-token-review-plugin/src/ui/lib/rubric.test.ts new file mode 100644 index 000000000..e761280e4 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/rubric.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from 'vitest'; + +import type { Conformant, NodeInfo, RawExtract } from '~/common/schemas'; +import { loadColorSchema } from '~/ui/lib/loaders/color'; +import { loadTextStyleSchema } from '~/ui/lib/loaders/typography'; +import { buildLlmInput } from '~/ui/lib/rubric'; + +const colorSchema = loadColorSchema('light'); +const textStyleSchema = loadTextStyleSchema(); + +const fgKey = + Object.entries(colorSchema.semantic).find(([, v]) => v.role === 'foreground')?.[0] ?? ''; + +const extract: RawExtract = { + schemaMode: 'light', + viewport: 'pc', + colors: [ + { + nodeId: '1', + name: 't', + property: 'text', + token: fgKey, + hex: '#000', + tokenStatus: 'ok', + background: null, + }, + ], + typography: [ + { + nodeId: '2', + name: 'h', + characters: '제목', + textStyle: 'subtitle1', + viewport: 'pc', + appliedStatus: 'styled-clean', + overriddenFields: [], + resolved: { fontSize: 14, lineHeight: {}, letterSpacing: {}, fontName: {} }, + }, + ], + spaces: [], + dimensions: [], + radii: [], + shadows: [], + stats: { nodeCount: 0, textNodes: 0, visited: 0 }, +}; + +function makeEmptyExtract() { + return { + schemaMode: 'light' as const, + viewport: 'pc' as const, + colors: [], + typography: [], + spaces: [], + dimensions: [], + radii: [], + shadows: [], + stats: { nodeCount: 0, textNodes: 0, visited: 0 }, + }; +} + +describe('buildLlmInput', () => { + it('의미 판정 대상은 결정론 통과 conformant 노드만', () => { + const conformant = { + color: [ + { nodeId: '1', name: 't', property: 'fill-on-text', token: fgKey } as Conformant, + ], + typography: [ + { nodeId: '2', name: 'h', property: 'textStyle', token: 'subtitle1' } as Conformant, + ], + }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + expect(input.judgmentTargets.semanticColor.map((t) => t.nodeId)).toEqual(['1']); + expect(input.judgmentTargets.typography.map((t) => t.nodeId)).toEqual(['2']); + }); + + it('targets lookup 은 nodeId(+property) 로 원본 target 을 복원한다', () => { + const conformant = { + color: [ + { nodeId: '1', name: 't', property: 'fill-on-text', token: fgKey } as Conformant, + ], + typography: [ + { nodeId: '2', name: 'h', property: 'textStyle', token: 'subtitle1' } as Conformant, + ], + }; + const { targets } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + expect(targets.typography.get('2')?.textStyle).toBe('subtitle1'); + expect(targets.color.get('1:fill-on-text')?.token).toBe(fgKey); + expect(targets.nameByNodeId.get('1')).toBe('t'); + expect(targets.nameByNodeId.get('2')).toBe('h'); + }); + + it('rubric 서브셋은 실제 등장한 토큰만 담는다', () => { + const conformant = { + color: [ + { nodeId: '1', name: 't', property: 'fill-on-text', token: fgKey } as Conformant, + ], + typography: [ + { nodeId: '2', name: 'h', property: 'textStyle', token: 'subtitle1' } as Conformant, + ], + }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + expect(Object.keys(input.rubric.color)).toEqual([fgKey]); + expect(input.rubric.textStyle['subtitle1']).toBeDefined(); + expect(Object.keys(input.rubric.textStyle).length).toBe(textStyleSchema.order.length); + }); + + it('rubric.textStyle 항목은 rank/when/avoid 만 담는다 (description/totalRanks 제거)', () => { + const { input } = buildLlmInput({ + extract, + deterministicConformant: { color: [], typography: [] }, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + const first = Object.values(input.rubric.textStyle)[0]!; + expect(Object.keys(first).sort()).toEqual(['avoid', 'rank', 'when']); + }); + + it('rubric.color 항목은 when/avoid 만 담는다 (role/description 제거)', () => { + const conformant = { + color: [ + { nodeId: '1', name: 't', property: 'fill-on-text', token: fgKey } as Conformant, + ], + typography: [], + }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + const entry = input.rubric.color[fgKey]!; + expect(Object.keys(entry).sort()).toEqual(['avoid', 'when']); + }); + + it('결정론 실패 노드는 판정 대상에서 제외', () => { + const conformant = { color: [], typography: [] }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + expect(input.judgmentTargets.semanticColor).toHaveLength(0); + expect(input.judgmentTargets.typography).toHaveLength(0); + expect(Object.keys(input.rubric.color)).toHaveLength(0); + expect(Object.keys(input.rubric.textStyle).length).toBe(textStyleSchema.order.length); + }); + + it('context 는 schemaMode/viewport/totalRanks 만 담고 frameName 은 없다', () => { + const conformant = { color: [], typography: [] }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + expect(input.context.schemaMode).toBe('light'); + expect(input.context.viewport).toBe('pc'); + expect(input.context.totalRanks).toBe(textStyleSchema.order.length); + expect((input.context as Record).frameName).toBeUndefined(); + }); + + it('nodeTree가 LlmInput에 그대로 복사된다', () => { + const nodeTree: NodeInfo[] = [ + { + id: 'r', + type: 'FRAME', + name: 'Root', + parentId: null, + }, + { + id: 'c', + type: 'TEXT', + name: 'Caption', + parentId: 'r', + }, + ]; + const { input } = buildLlmInput({ + extract, + deterministicConformant: { color: [], typography: [] }, + colorSchema, + textStyleSchema, + nodeTree, + }); + expect(input.nodeTree).toEqual(nodeTree); + }); + + it('typography target 은 nodeId/textStyle 만 담고 name/characters 를 담지 않는다', () => { + const conformant = { + color: [], + typography: [ + { nodeId: '2', name: 'h', property: 'textStyle', token: 'subtitle1' } as Conformant, + ], + }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + const target = input.judgmentTargets.typography[0]!; + expect(Object.keys(target).sort()).toEqual(['nodeId', 'textStyle']); + }); + + it('color target 은 nodeId/property/token 만 담고 name 을 담지 않는다', () => { + const conformant = { + color: [ + { nodeId: '1', name: 't', property: 'fill-on-text', token: fgKey } as Conformant, + ], + typography: [], + }; + const { input } = buildLlmInput({ + extract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + const target = input.judgmentTargets.semanticColor[0]!; + expect(Object.keys(target).sort()).toEqual(['nodeId', 'property', 'token']); + }); + + it('같은 스타일 그룹의 형제 노드는 각각 별도의 LLM target 으로 펼쳐진다', () => { + const fgKey = + Object.entries(colorSchema.semantic).find(([, v]) => v.role === 'foreground')?.[0] ?? + ''; + const groupedExtract: RawExtract = { + schemaMode: 'light', + viewport: 'pc', + colors: [ + { + nodeId: 'A', + nodeIds: ['A', 'B', 'C'], + name: 'title', + property: 'text', + token: fgKey, + hex: '#000', + tokenStatus: 'ok', + background: { kind: 'white', hex: '#ffffff' }, + } as unknown as RawExtract['colors'][number], + ], + typography: [], + spaces: [], + dimensions: [], + radii: [], + shadows: [], + stats: { nodeCount: 3, textNodes: 3, visited: 3 }, + }; + const conformant = { + color: [ + { + nodeId: 'A', + nodeIds: ['A', 'B', 'C'], + name: 'title', + property: 'fill-on-text', + token: fgKey, + } as Conformant, + ], + typography: [], + }; + const { input } = buildLlmInput({ + extract: groupedExtract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + const ids = input.judgmentTargets.semanticColor.map((t) => t.nodeId).sort(); + expect(ids).toEqual(['A', 'B', 'C']); + }); + + it('같은 노드에 fill/stroke 두 색이 붙어도 각 conformant 의 property 를 그대로 보존한다', () => { + const bgKey = + Object.entries(colorSchema.semantic).find(([, v]) => v.role === 'background')?.[0] ?? + ''; + const borderKey = + Object.entries(colorSchema.semantic).find(([, v]) => v.role === 'border')?.[0] ?? ''; + const twoPaintExtract: RawExtract = { + schemaMode: 'light', + viewport: 'pc', + colors: [ + { + nodeId: '150:537', + name: 'Card', + property: 'fill', + token: bgKey, + hex: '#fff', + tokenStatus: 'ok', + background: null, + }, + { + nodeId: '150:537', + name: 'Card', + property: 'stroke', + token: borderKey, + hex: '#ccc', + tokenStatus: 'ok', + background: null, + }, + ], + typography: [], + spaces: [], + dimensions: [], + radii: [], + shadows: [], + stats: { nodeCount: 1, textNodes: 0, visited: 1 }, + }; + const conformant = { + color: [ + { nodeId: '150:537', name: 'Card', property: 'fill', token: bgKey } as Conformant, + { + nodeId: '150:537', + name: 'Card', + property: 'stroke', + token: borderKey, + } as Conformant, + ], + typography: [], + }; + const { input } = buildLlmInput({ + extract: twoPaintExtract, + deterministicConformant: conformant, + colorSchema, + textStyleSchema, + nodeTree: [], + }); + const byToken = Object.fromEntries( + input.judgmentTargets.semanticColor.map((t) => [t.token, t.property]), + ); + expect(byToken[bgKey]).toBe('fill'); + expect(byToken[borderKey]).toBe('stroke'); + }); + + it('textStyleRubric 는 스키마 전체 스타일을 포함한다 (사용/미사용 무관)', () => { + const schema = loadTextStyleSchema(); + const { input } = buildLlmInput({ + extract: makeEmptyExtract(), + deterministicConformant: { color: [], typography: [] }, + colorSchema: loadColorSchema('light'), + textStyleSchema: schema, + nodeTree: [], + }); + for (const name of schema.order) { + expect(input.rubric.textStyle[name]).toBeDefined(); + } + expect(Object.keys(input.rubric.textStyle).length).toBe(schema.order.length); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/rubric.ts b/apps/figma-token-review-plugin/src/ui/lib/rubric.ts new file mode 100644 index 000000000..02fe064f0 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/rubric.ts @@ -0,0 +1,176 @@ +import type { Conformant, NodeInfo, RawExtract } from '~/common/schemas'; +import type { ColorSchema } from '~/ui/lib/loaders/color'; +import type { TextStyleSchema } from '~/ui/lib/loaders/typography'; + +/** + * TypographyTarget / ColorTarget 은 LLM 응답 재구성 lookup 에도 쓰인다. + * LLM 은 name/token/textStyle 을 다시 echo 하지 않으므로, merge 는 nodeId(+property) 로 + * 이 target 을 찾아 Violation.token / property 를 복원한다. + */ +export type TypographyTarget = { + nodeId: string; + textStyle: string; +}; + +export type ColorTarget = { + nodeId: string; + property: 'fill' | 'fill-on-text' | 'stroke'; + token: string; +}; + +export type ColorMetaSubset = { + when: string[]; + avoid: string[]; +}; + +export type TextStyleMetaSubset = { + rank: number; + when: string[]; + avoid: string[]; +}; + +export type LlmInput = { + context: { + schemaMode: 'light' | 'dark'; + viewport: string; + totalRanks: number; + }; + judgmentTargets: { typography: TypographyTarget[]; semanticColor: ColorTarget[] }; + rubric: { + textStyle: Record; + color: Record; + }; + nodeTree: NodeInfo[]; +}; + +export type BuildLlmInputArgs = { + extract: RawExtract; + deterministicConformant: { color: Conformant[]; typography: Conformant[] }; + colorSchema: ColorSchema; + textStyleSchema: TextStyleSchema; + nodeTree: NodeInfo[]; +}; + +/** + * merge 가 LLM 응답의 nodeId(+property) → target(=token 등) 을 되찾기 위한 lookup. + * runLlmEvaluation 안에서 buildLlmInput 과 함께 만들어 mergeScanPayload 로 넘겨진다. + */ +export type TargetLookup = { + typography: Map; + color: Map; + nameByNodeId: Map; + /** detach된 💙 DS 컴포넌트 하위에서 발견된 노드 id 집합. LLM Violation 뱃지 노출용. */ + wasDsNodeIds: Set; +}; + +export type BuildLlmInputResult = { + input: LlmInput; + targets: TargetLookup; +}; + +export function buildLlmInput(args: BuildLlmInputArgs): BuildLlmInputResult { + const { extract, deterministicConformant, colorSchema, textStyleSchema, nodeTree } = args; + + // After groupBy in extract.ts, items have nodeIds: string[] instead of nodeId. + // Build a map from each individual nodeId to its grouped entry. + const colorByNode = new Map(); + for (const c of extract.colors) { + const ids = c.nodeIds ?? (c.nodeId ? [c.nodeId] : []); + for (const id of ids) colorByNode.set(id, c); + } + const typoByNode = new Map(); + for (const t of extract.typography) { + const ids = t.nodeIds ?? (t.nodeId ? [t.nodeId] : []); + for (const id of ids) typoByNode.set(id, t); + } + + const nameByNodeId = new Map(); + for (const [id, entry] of colorByNode) nameByNodeId.set(id, entry.name); + for (const [id, entry] of typoByNode) nameByNodeId.set(id, entry.name); + + const wasDsNodeIds = new Set(); + for (const [id, entry] of colorByNode) if (entry.wasDs) wasDsNodeIds.add(id); + for (const [id, entry] of typoByNode) if (entry.wasDs) wasDsNodeIds.add(id); + + const semanticColorTargets: ColorTarget[] = []; + const colorTargetIndex = new Map(); + const usedColorTokens = new Set(); + for (const conf of deterministicConformant.color) { + const u = colorByNode.get(conf.nodeId); + if (!u || !conf.token) continue; + if (!colorSchema.semantic[conf.token]) continue; + if ( + conf.property !== 'fill' && + conf.property !== 'fill-on-text' && + conf.property !== 'stroke' + ) { + continue; + } + const ids = conf.nodeIds && conf.nodeIds.length > 0 ? conf.nodeIds : [conf.nodeId]; + for (const id of ids) { + const target: ColorTarget = { + nodeId: id, + property: conf.property, + token: conf.token, + }; + semanticColorTargets.push(target); + colorTargetIndex.set(`${id}:${conf.property}`, target); + } + usedColorTokens.add(conf.token); + } + + const typographyTargets: TypographyTarget[] = []; + const typoTargetIndex = new Map(); + for (const conf of deterministicConformant.typography) { + const u = typoByNode.get(conf.nodeId); + if (!u || !conf.token) continue; + const ids = conf.nodeIds && conf.nodeIds.length > 0 ? conf.nodeIds : [conf.nodeId]; + for (const id of ids) { + const target: TypographyTarget = { nodeId: id, textStyle: conf.token }; + typographyTargets.push(target); + typoTargetIndex.set(id, target); + } + } + + const colorRubric: Record = {}; + for (const t of usedColorTokens) { + const meta = colorSchema.semantic[t]; + if (!meta) continue; + colorRubric[t] = { + when: meta.when, + avoid: meta.avoid, + }; + } + + const totalRanks = textStyleSchema.order.length; + const textStyleRubric: Record = {}; + for (const name of textStyleSchema.order) { + const meta = textStyleSchema.styles[name]; + textStyleRubric[name] = { + rank: meta.rank, + when: meta.when, + avoid: meta.avoid, + }; + } + + const input: LlmInput = { + context: { + schemaMode: extract.schemaMode, + viewport: extract.viewport, + totalRanks, + }, + judgmentTargets: { typography: typographyTargets, semanticColor: semanticColorTargets }, + rubric: { textStyle: textStyleRubric, color: colorRubric }, + nodeTree, + }; + + return { + input, + targets: { + typography: typoTargetIndex, + color: colorTargetIndex, + nameByNodeId, + wasDsNodeIds, + }, + }; +} diff --git a/apps/figma-token-review-plugin/src/ui/lib/scope.test.ts b/apps/figma-token-review-plugin/src/ui/lib/scope.test.ts new file mode 100644 index 000000000..4902b9f14 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/scope.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import type { Property, Role } from '~/common/schemas'; +import { PROPERTY_SCOPE } from '~/ui/lib/scope'; + +describe('PROPERTY_SCOPE', () => { + it('fill 은 background role 만 허용한다', () => { + expect(PROPERTY_SCOPE.fill).toEqual(['background']); + }); + + it('fill-on-text 는 foreground role 만 허용한다', () => { + expect(PROPERTY_SCOPE['fill-on-text']).toEqual(['foreground']); + }); + + it('stroke 는 border role 만 허용한다', () => { + expect(PROPERTY_SCOPE.stroke).toEqual(['border']); + }); + + it('padding 과 gap 은 space role', () => { + expect(PROPERTY_SCOPE.padding).toEqual(['space']); + expect(PROPERTY_SCOPE.paddingTop).toEqual(['space']); + expect(PROPERTY_SCOPE.paddingRight).toEqual(['space']); + expect(PROPERTY_SCOPE.paddingBottom).toEqual(['space']); + expect(PROPERTY_SCOPE.paddingLeft).toEqual(['space']); + expect(PROPERTY_SCOPE.paddingVertical).toEqual(['space']); + expect(PROPERTY_SCOPE.paddingHorizontal).toEqual(['space']); + expect(PROPERTY_SCOPE.gap).toEqual(['space']); + }); + + it('width 와 height 는 dimension role', () => { + expect(PROPERTY_SCOPE.width).toEqual(['dimension']); + expect(PROPERTY_SCOPE.height).toEqual(['dimension']); + }); + + it('borderRadius / shadow 는 동명 role', () => { + expect(PROPERTY_SCOPE.borderRadius).toEqual(['borderRadius']); + expect(PROPERTY_SCOPE.shadow).toEqual(['shadow']); + }); + + it('typography Property 는 매핑이 없다 (결정론 결과 별도 정책)', () => { + const map = PROPERTY_SCOPE as Record>; + expect(map.textStyle).toBeUndefined(); + }); +}); diff --git a/apps/figma-token-review-plugin/src/ui/lib/scope.ts b/apps/figma-token-review-plugin/src/ui/lib/scope.ts new file mode 100644 index 000000000..ba99c4369 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/lib/scope.ts @@ -0,0 +1,19 @@ +import type { Property, Role } from '~/common/schemas'; + +export const PROPERTY_SCOPE: Record, ReadonlyArray> = { + fill: ['background'], + 'fill-on-text': ['foreground'], + stroke: ['border'], + padding: ['space'], + paddingTop: ['space'], + paddingRight: ['space'], + paddingBottom: ['space'], + paddingLeft: ['space'], + paddingVertical: ['space'], + paddingHorizontal: ['space'], + gap: ['space'], + width: ['dimension'], + height: ['dimension'], + borderRadius: ['borderRadius'], + shadow: ['shadow'], +} as const; diff --git a/apps/figma-token-review-plugin/src/ui/main.tsx b/apps/figma-token-review-plugin/src/ui/main.tsx new file mode 100644 index 000000000..06ad7428d --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import './index.css'; + +import App from './App'; +import { ErrorBoundary } from './components/error-boundary'; +import { Providers } from './providers'; + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/apps/figma-token-review-plugin/src/ui/pages/home.tsx b/apps/figma-token-review-plugin/src/ui/pages/home.tsx new file mode 100644 index 000000000..e75a68e86 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/pages/home.tsx @@ -0,0 +1,68 @@ +import { Box, IconButton, Tooltip } from '@vapor-ui/core'; +import { SettingIcon } from '@vapor-ui/icons'; + +import surveyUrl from '~/ui/assets/survey.svg'; + +import { HeroPanel } from '../components/hero-panel'; +import { toastManager } from '../components/toast'; +import { useScan } from '../features/scan'; +import { useSelection } from '../features/selection'; + +const toastError = (title: string) => toastManager.add({ title, colorPalette: 'danger' }); + +type Props = { + onOpenSettings?: () => void; +}; + +export function HomePage({ onOpenSettings }: Props) { + const { start } = useScan(); + const { selection, buildAction } = useSelection(); + const handleScan = buildAction({ + frame: (sel) => start(sel.id, sel.name), + none: () => toastError('프레임을 1개 선택해 주세요.'), + multi: () => toastError('프레임을 1개만 선택해 주세요.'), + invalid: (sel) => + toastError(`프레임 또는 인스턴스만 선택할 수 있습니다. (현재: ${sel.nodeType})`), + }); + + return ( + + + + + + + 검수할 프레임을 선택해 주세요 + + 프레임을 선택하고 검수 하기 버튼을 누르면
+ 토큰 검수가 시작됩니다. +
+
+
+ + 검수 시작하기 + +
+
+ ); +} + +const SettingButton = ({ onClick }: IconButton.Props) => ( + + + + + } + /> + + 설정 + +); diff --git a/apps/figma-token-review-plugin/src/ui/pages/scan-result.tsx b/apps/figma-token-review-plugin/src/ui/pages/scan-result.tsx new file mode 100644 index 000000000..b13af556a --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/pages/scan-result.tsx @@ -0,0 +1,394 @@ +import { useMemo, useState } from 'react'; + +import { + Badge, + Box, + Button, + Collapsible, + Dialog, + HStack, + Tabs, + Text, + VStack, +} from '@vapor-ui/core'; +import { ChevronUpOutlineIcon, RefreshOutlineIcon, UppercaseIcon } from '@vapor-ui/icons'; + +import type { EvaluateOutput, ScanPayload, SchemaMode, Violation } from '~/common/schemas'; + +import { toastManager } from '../components/toast'; +import { ViolationCard } from '../components/violation-card'; +import { useScan } from '../features/scan'; +import { useSelection } from '../features/selection'; + +type TabKey = 'color' | 'space' | 'dimension' | 'typography' | 'borderRadius' | 'shadow'; + +type Props = { + payload: ScanPayload; +}; + +export function ScanResultPage({ payload }: Props) { + const [tab, setTab] = useState('color'); + const counts = useMemo(() => getViolationCounts(payload), [payload]); + const schemaMode = payload.schemaMode; + + return ( + setTab(value as TabKey)} + variant="line" + size="md" + $css={{ width: '100%', minHeight: '100vh', backgroundColor: '$bg-canvas-100' }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// ----- Tab bar ----- + +const FRAME_TAB_KEYS: TabKey[] = [ + 'color', + 'space', + 'dimension', + 'typography', + 'borderRadius', + 'shadow', +]; + +const TAB_LABEL: Record = { + color: 'Color', + space: 'Space', + dimension: 'Dimension', + typography: 'Typography', + borderRadius: 'Border Radius', + shadow: 'Shadow', +}; + +type ScanTabBarProps = { + selected: TabKey; + counts: Record; +}; + +function ScanTabBar({ selected, counts }: ScanTabBarProps) { + return ( + + {FRAME_TAB_KEYS.map((key) => { + const isActive = selected === key; + + return ( + + {TAB_LABEL[key]} + + + {counts[key]} + + + ); + })} + + ); +} + +function getViolationCounts(payload: ScanPayload): Record { + return { + color: payload.color.violations.length, + space: payload.space.violations.length, + dimension: payload.dimension.violations.length, + typography: payload.typography.violations.length, + borderRadius: payload.borderRadius.violations.length, + shadow: payload.shadow.violations.length, + }; +} + +// ----- Selected frame header ----- + +const toastError = (title: string) => toastManager.add({ title, colorPalette: 'danger' }); + +function SelectedFrameHeader() { + const { start } = useScan(); + const { selection, buildAction } = useSelection(); + + const runRescan = buildAction({ + frame: (sel) => start(sel.id, sel.name), + none: () => toastError('프레임을 1개 선택해 주세요.'), + multi: () => toastError('프레임을 1개만 선택해 주세요.'), + invalid: (sel) => + toastError(`프레임 또는 인스턴스만 선택할 수 있습니다. (현재: ${sel.nodeType})`), + }); + + const isFrame = selection.kind === 'frame'; + + const handleConfirm = () => { + runRescan(); + }; + + return ( + + + + 선택한 프레임 + + + {isFrame ? selection.name : '이름 없는 프레임'} + + + + + }> + + 재검사하기 + + + + + 재검사 하시겠어요? + + + 기존 검사 결과는 모두 삭제됩니다. + + + }> + 취소 + + }> + 재검사 + + + + + + ); +} + +// ----- Violation panel ----- + +type ViolationPanelProps = { + violations: Violation[]; + summary: EvaluateOutput['summary']; + schemaMode: SchemaMode; +}; + +function ViolationPanel({ violations, summary, schemaMode }: ViolationPanelProps) { + const { frameOnes, textOnes } = useMemo( + () => splitByKind(sortViolations(violations)), + [violations], + ); + + if (violations.length === 0) return ; + + return ( + + + + + ); +} + +function splitByKind(violations: Violation[]): { + frameOnes: Violation[]; + textOnes: Violation[]; +} { + return { + frameOnes: violations.filter((v) => !isTextViolation(v)), + textOnes: violations.filter(isTextViolation), + }; +} + +function isTextViolation(v: Violation): boolean { + return ( + v.type === 'fg-grade-mismatch' || + v.type === 'fg-grade-ambiguous' || + v.type === 'text-contrast-low' || + v.type === 'typo-raw' || + v.type === 'typo-styled-override' || + v.type === 'typo-hierarchy' || + v.type === 'typo-role-misfit' || + v.type === 'typo-viewport-misfit' + ); +} + +function sortViolations(violations: Violation[]): Violation[] { + return [...violations].sort((a, b) => { + if (a.severity === b.severity) return weight(b) - weight(a); + return a.severity === 'high' ? -1 : 1; + }); +} + +function weight(v: Violation): number { + return v.count ?? v.nodeIds?.length ?? 1; +} + +// ----- Violation section (collapsible group) ----- + +type ViolationSectionProps = { + icon: 'frame' | 'text'; + title: string; + violations: Violation[]; + schemaMode: SchemaMode; +}; + +function ViolationSection({ icon, title, violations, schemaMode }: ViolationSectionProps) { + if (violations.length === 0) return null; + + return ( + + + + + + + + + + ); +} + +function SectionHeader({ icon, title }: { icon: 'frame' | 'text'; title: string }) { + return ( + + {icon === 'frame' ? : } + + {title} + + + ); +} + +type ViolationListProps = { + violations: Violation[]; + schemaMode: SchemaMode; +}; + +function ViolationList({ violations, schemaMode }: ViolationListProps) { + return ( + + {violations.map((v, i) => ( + + ))} + + ); +} + +// ----- Empty state ----- + +function EmptyState({ summary }: { summary: EvaluateOutput['summary'] }) { + return ( + + 위반 없음. + + 검사 노드 총 {summary.total}개 + + + ); +} + +// ----- Icons ----- + +function FrameHashIcon() { + return ( + + + + ); +} diff --git a/apps/figma-token-review-plugin/src/ui/pages/settings.tsx b/apps/figma-token-review-plugin/src/ui/pages/settings.tsx new file mode 100644 index 000000000..e045f019f --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/pages/settings.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; + +import { Button, Card, HStack, Text, TextInput, VStack } from '@vapor-ui/core'; + +import { toastManager } from '../components/toast'; +import { useApiKey } from '../features/api-key'; + +type Props = { + onClose: () => void; +}; + +function maskKey(key: string): string { + if (key.length <= 8) return '••••'; + return `${key.slice(0, 4)}••••${key.slice(-4)}`; +} + +export function SettingsPage({ onClose }: Props) { + const { state, save, clear } = useApiKey(); + const [draft, setDraft] = useState(''); + + const hasKey = state.kind === 'present'; + const currentKey = state.kind === 'present' ? state.key : ''; + + const handleSave = () => { + const trimmed = draft.trim(); + if (!trimmed) { + toastManager.add({ title: 'API 키를 입력해 주세요.', colorPalette: 'danger' }); + return; + } + + save(trimmed); + setDraft(''); + + toastManager.add({ title: 'API 키가 저장되었습니다.', colorPalette: 'success' }); + onClose?.(); + }; + + const handleClear = () => { + clear(); + toastManager.add({ title: 'API 키가 삭제되었습니다.', colorPalette: 'info' }); + }; + + return ( + + + + LiteLLM API 키 설정 + + + 관리자에게 발급 받은 개인 키를 입력해 주세요. 키는 이 브라우저에만 저장되며 + 번들에는 포함되지 않습니다. + + + + {hasKey && ( + + + + 저장된 키 + + + {maskKey(currentKey)} + + + + )} + + + + {hasKey ? '새 키로 교체' : 'API 키'} + + + + + + {hasKey && ( + + )} + + + + + + + ); +} diff --git a/apps/figma-token-review-plugin/src/ui/pages/success.tsx b/apps/figma-token-review-plugin/src/ui/pages/success.tsx new file mode 100644 index 000000000..386140e1e --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/pages/success.tsx @@ -0,0 +1,28 @@ +import { Box } from '@vapor-ui/core'; + +import confirmUrl from '~/ui/assets/confirm.svg'; + +import { HeroPanel } from '../components/hero-panel'; +import { useScan } from '../features/scan'; + +export function SuccessPage() { + const { reset } = useScan(); + + return ( + + + + + + 모든 토큰이 올바르게 사용되었어요 + + 선택한 프레임의 모든 토큰이
+ 올바르게 사용되었습니다. +
+
+
+ 다른 프레임 검수하기 +
+
+ ); +} diff --git a/apps/figma-token-review-plugin/src/ui/providers.tsx b/apps/figma-token-review-plugin/src/ui/providers.tsx new file mode 100644 index 000000000..e12851bb4 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/providers.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; + +import { ToastProvider, toastManager } from './components/toast'; +import { useBridge, useRequestApiKey, useRequestSelection } from './features/messaging'; + +type Props = { children: ReactNode }; + +export const Providers = ({ children }: Props) => { + useBridge(); + useRequestSelection(); + useRequestApiKey(); + + return ( + + {children} + + ); +}; diff --git a/apps/figma-token-review-plugin/src/ui/shared/create-store.ts b/apps/figma-token-review-plugin/src/ui/shared/create-store.ts new file mode 100644 index 000000000..3a48b56b3 --- /dev/null +++ b/apps/figma-token-review-plugin/src/ui/shared/create-store.ts @@ -0,0 +1,34 @@ +import { useSyncExternalStore } from 'react'; + +export type Store = { + getState: () => T; + setState: (next: T | ((prev: T) => T)) => void; + subscribe: (listener: () => void) => () => void; +}; + +export function createStore(initial: T): Store { + let state = initial; + const listeners = new Set<() => void>(); + + return { + getState: () => state, + setState: (next) => { + const value = typeof next === 'function' ? (next as (prev: T) => T)(state) : next; + + if (Object.is(value, state)) return; + + state = value; + listeners.forEach((fn) => { + fn(); + }); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +export function useStore(store: Store): T { + return useSyncExternalStore(store.subscribe, store.getState); +} diff --git a/apps/figma-token-review-plugin/src/vite-env.d.ts b/apps/figma-token-review-plugin/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/figma-token-review-plugin/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/figma-token-review-plugin/tsconfig.app.json b/apps/figma-token-review-plugin/tsconfig.app.json new file mode 100644 index 000000000..acbea128f --- /dev/null +++ b/apps/figma-token-review-plugin/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "extends": "@repo/typescript-config/react-app", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "typeRoots": ["./node_modules/@types", "./node_modules/@figma"], + "baseUrl": ".", + "paths": { + "~/*": ["src/*"] + }, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "jsx": "react-jsx", + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/apps/figma-token-review-plugin/tsconfig.json b/apps/figma-token-review-plugin/tsconfig.json new file mode 100644 index 000000000..eb69b0d08 --- /dev/null +++ b/apps/figma-token-review-plugin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/apps/figma-token-review-plugin/tsconfig.node.json b/apps/figma-token-review-plugin/tsconfig.node.json new file mode 100644 index 000000000..18b24f66b --- /dev/null +++ b/apps/figma-token-review-plugin/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "extends": "@repo/typescript-config/base", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ui.ts", "vite.config.plugin.ts", "vitest.config.ts"] +} diff --git a/apps/figma-token-review-plugin/vite.config.plugin.ts b/apps/figma-token-review-plugin/vite.config.plugin.ts new file mode 100644 index 000000000..2d784aadd --- /dev/null +++ b/apps/figma-token-review-plugin/vite.config.plugin.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +export default defineConfig(({ mode }) => ({ + plugins: [viteSingleFile()], + resolve: { + alias: { + '~': path.resolve(__dirname, 'src'), + }, + }, + build: { + minify: mode === 'production', + sourcemap: mode !== 'production' ? 'inline' : false, + target: 'es2017', + emptyOutDir: false, + outDir: path.resolve('dist'), + rollupOptions: { + input: path.resolve('src/plugin/code.ts'), + output: { + entryFileNames: 'code.js', + }, + }, + }, +})); diff --git a/apps/figma-token-review-plugin/vite.config.ui.ts b/apps/figma-token-review-plugin/vite.config.ui.ts new file mode 100644 index 000000000..e6845864a --- /dev/null +++ b/apps/figma-token-review-plugin/vite.config.ui.ts @@ -0,0 +1,24 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { defineConfig } from 'vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +export default defineConfig(({ mode }) => ({ + plugins: [react(), tailwindcss(), ...(mode === 'production' ? [viteSingleFile()] : [])], + resolve: { + alias: { + '~': path.resolve(__dirname, 'src'), + }, + }, + build: { + minify: mode === 'production', + cssMinify: mode === 'production', + sourcemap: mode !== 'production' ? 'inline' : false, + emptyOutDir: false, + outDir: path.resolve('dist'), + rollupOptions: { + input: path.resolve('index.html'), + }, + }, +})); diff --git a/apps/figma-token-review-plugin/vitest.config.ts b/apps/figma-token-review-plugin/vitest.config.ts new file mode 100644 index 000000000..af151f837 --- /dev/null +++ b/apps/figma-token-review-plugin/vitest.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + alias: { + '~': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3335576d3..b9f1f1644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,7 +125,7 @@ importers: version: link:../../packages/typescript-config '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 4.2.4(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@types/culori': specifier: ^4.0.1 version: 4.0.1 @@ -137,7 +137,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 5.2.0(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -152,10 +152,74 @@ importers: version: 5.9.3 vite: specifier: ^7.3.5 - version: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + version: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) vite-plugin-singlefile: specifier: ^2.3.3 - version: 2.3.3(rollup@4.61.1)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 2.3.3(rollup@4.61.1)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + + apps/figma-token-review-plugin: + dependencies: + '@vapor-ui/core': + specifier: workspace:* + version: link:../../packages/core + '@vapor-ui/icons': + specifier: workspace:* + version: link:../../packages/icons + react: + specifier: 'catalog:' + version: 19.2.7 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) + devDependencies: + '@figma/plugin-typings': + specifier: ^1.125.0 + version: 1.125.0 + '@repo/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@repo/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.4(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@vitest/coverage-v8': + specifier: ^3.2.6 + version: 3.2.6(@vitest/browser@3.2.6)(vitest@3.2.6) + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@2.6.1) + rimraf: + specifier: ^6.0.0 + version: 6.1.3 + tailwindcss: + specifier: ^4.0.0 + version: 4.2.4 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite-plugin-singlefile: + specifier: ^2.0.0 + version: 2.3.3(rollup@4.61.1)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + vitest: + specifier: ^3.2.6 + version: 3.2.6(@types/debug@4.1.12)(@types/node@26.1.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) apps/storybook: dependencies: @@ -177,13 +241,13 @@ importers: version: link:../../packages/typescript-config '@storybook/addon-docs': specifier: ^9.1.20 - version: 9.1.20(@types/react@19.2.17)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) + version: 9.1.20(@types/react@19.2.17)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) '@storybook/builder-vite': specifier: ^9.1.20 - version: 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@storybook/react-vite': specifier: ^9.1.20 - version: 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -192,10 +256,10 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vanilla-extract/vite-plugin': specifier: ^5.2.2 - version: 5.2.2(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 5.2.2(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 5.2.0(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) eslint: specifier: 'catalog:' version: 9.39.4(jiti@2.6.1) @@ -204,7 +268,7 @@ importers: version: 16.5.0 storybook: specifier: ^9.1.20 - version: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + version: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -213,7 +277,7 @@ importers: version: 8.59.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.5 - version: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + version: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) apps/website: dependencies: @@ -422,16 +486,16 @@ importers: version: 9.39.4(jiti@2.6.1) jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + version: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) rimraf: specifier: ^6.1.3 version: 6.1.3 ts-jest: specifier: ^29.4.11 - version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)))(typescript@6.0.3) + version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(typescript@6.0.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(typescript@5.9.3) packages/color-generator: dependencies: @@ -689,7 +753,7 @@ importers: version: 5.2.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-storybook: specifier: ^9.1.20 - version: 9.1.20(eslint@9.39.4(jiti@2.6.1))(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3) + version: 9.1.20(eslint@9.39.4(jiti@2.6.1))(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3) globals: specifier: ^16.5.0 version: 16.5.0 @@ -3650,6 +3714,9 @@ packages: cpu: [x64] os: [win32] + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -4652,8 +4719,8 @@ packages: '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@26.1.0': + resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} @@ -5006,6 +5073,12 @@ packages: vue-router: optional: true + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8005,6 +8078,10 @@ packages: '@types/react': '>=18' react: '>=18' + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -8808,11 +8885,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -8828,8 +8900,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} @@ -11763,7 +11835,7 @@ snapshots: jest-util: 30.3.0 slash: 3.0.0 - '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))': + '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3))': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -11778,7 +11850,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.19.21)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-config: 30.3.0(@types/node@22.19.21)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -11948,12 +12020,12 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.9.3)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: glob: 10.5.0 magic-string: 0.30.21 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) optionalDependencies: typescript: 5.9.3 @@ -12666,6 +12738,8 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rolldown/pluginutils@1.0.1': {} @@ -13022,25 +13096,25 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@9.1.20(@types/react@19.2.17)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))': + '@storybook/addon-docs@9.1.20(@types/react@19.2.17)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) - '@storybook/csf-plugin': 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) + '@storybook/csf-plugin': 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) '@storybook/icons': 1.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) + '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/builder-vite@9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@storybook/builder-vite@9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: - '@storybook/csf-plugin': 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@storybook/csf-plugin': 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) ts-dedent: 2.2.0 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) '@storybook/builder-vite@9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)))(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))': dependencies: @@ -13049,9 +13123,9 @@ snapshots: ts-dedent: 2.2.0 vite: 8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0) - '@storybook/csf-plugin@9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))': + '@storybook/csf-plugin@9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))': dependencies: - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) unplugin: 1.16.1 '@storybook/csf-plugin@9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)))': @@ -13066,11 +13140,11 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@storybook/react-dom-shim@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))': + '@storybook/react-dom-shim@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))': dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@storybook/react-dom-shim@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)))': dependencies: @@ -13098,33 +13172,33 @@ snapshots: - supports-color - typescript - '@storybook/react-vite@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@storybook/react-vite@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.61.1)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.9.3)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@rollup/pluginutils': 5.3.0(rollup@4.61.1) - '@storybook/builder-vite': 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) - '@storybook/react': 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3) + '@storybook/builder-vite': 9.1.20(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@storybook/react': 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3) find-up: 7.0.0 magic-string: 0.30.21 react: 19.2.7 react-docgen: 8.0.2 react-dom: 19.2.7(react@19.2.7) resolve: 1.22.10 - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) tsconfig-paths: 4.2.0 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - rollup - supports-color - typescript - '@storybook/react@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3)': + '@storybook/react@9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) + '@storybook/react-dom-shim': 9.1.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) optionalDependencies: typescript: 5.9.3 @@ -13304,12 +13378,12 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.4(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.4(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -13550,10 +13624,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@25.5.0': + '@types/node@26.1.0': dependencies: - undici-types: 7.18.2 - optional: true + undici-types: 8.3.0 '@types/prismjs@1.26.5': {} @@ -13881,12 +13954,12 @@ snapshots: - tsx - yaml - '@vanilla-extract/compiler@0.7.0(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)': + '@vanilla-extract/compiler@0.7.0(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)': dependencies: '@vanilla-extract/css': 1.20.1 '@vanilla-extract/integration': 8.0.9 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) - vite-node: 6.0.0(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite-node: 6.0.0(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -13983,11 +14056,11 @@ snapshots: - tsx - yaml - '@vanilla-extract/vite-plugin@5.2.2(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@vanilla-extract/vite-plugin@5.2.2(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: - '@vanilla-extract/compiler': 0.7.0(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + '@vanilla-extract/compiler': 0.7.0(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) '@vanilla-extract/integration': 8.0.9 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -14010,7 +14083,19 @@ snapshots: next: 16.2.7(@babel/core@7.28.4)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.99.0) react: 19.2.7 - '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@vitejs/plugin-react@4.7.0(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -14018,7 +14103,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -14062,6 +14147,26 @@ snapshots: - vite optional: true + '@vitest/browser@3.2.6(playwright@1.60.0)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))(vitest@3.2.6)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@vitest/utils': 3.2.6 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.6(@types/debug@4.1.12)(@types/node@26.1.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + ws: 8.20.0 + optionalDependencies: + playwright: 1.60.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.6(playwright@1.60.0)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))(vitest@3.2.6)': dependencies: '@testing-library/dom': 10.4.1 @@ -14114,9 +14219,9 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.6(@types/debug@4.1.12)(@types/node@22.19.21)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vitest: 3.2.6(@types/debug@4.1.12)(@types/node@26.1.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) optionalDependencies: - '@vitest/browser': 3.2.6(playwright@1.60.0)(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))(vitest@3.2.6) + '@vitest/browser': 3.2.6(playwright@1.60.0)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))(vitest@3.2.6) transitivePeerDependencies: - supports-color @@ -14136,13 +14241,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) '@vitest/mocker@3.2.4(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))': dependencies: @@ -14168,6 +14273,14 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + '@vitest/mocker@3.2.6(vite@8.1.4(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.6 @@ -15358,11 +15471,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.1.20(eslint@9.39.4(jiti@2.6.1))(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3): + eslint-plugin-storybook@9.1.20(eslint@9.39.4(jiti@2.6.1))(storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + storybook: 9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) transitivePeerDependencies: - supports-color - typescript @@ -16345,15 +16458,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest-cli@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-config: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -16364,7 +16477,7 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest-config@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -16392,12 +16505,12 @@ snapshots: optionalDependencies: '@types/node': 20.19.43 esbuild-register: 3.6.0(esbuild@0.27.7) - ts-node: 10.9.2(@types/node@20.19.43)(typescript@6.0.3) + ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@22.19.21)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest-config@30.3.0(@types/node@22.19.21)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -16425,7 +16538,7 @@ snapshots: optionalDependencies: '@types/node': 22.19.21 esbuild-register: 3.6.0(esbuild@0.27.7) - ts-node: 10.9.2(@types/node@20.19.43)(typescript@6.0.3) + ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -16682,12 +16795,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)): + jest@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest-cli: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -17967,6 +18080,8 @@ snapshots: transitivePeerDependencies: - supports-color + react-refresh@0.17.0: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): @@ -18589,13 +18704,13 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)): + storybook@9.1.20(@testing-library/dom@10.4.1)(prettier@3.8.4)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) '@vitest/spy': 3.2.4 better-opn: 3.0.2 esbuild: 0.25.12 @@ -18889,18 +19004,18 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.11(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)))(typescript@6.0.3): + ts-jest@29.4.11(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.27.7)(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)) + jest: 30.3.0(@types/node@20.19.43)(esbuild-register@3.6.0(esbuild@0.27.7))(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.8.1 type-fest: 4.41.0 - typescript: 6.0.3 + typescript: 5.9.3 yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.29.0 @@ -18920,7 +19035,7 @@ snapshots: '@ts-morph/common': 0.29.0 code-block-writer: 13.0.3 - ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3): + ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -18934,7 +19049,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.4 make-error: 1.3.6 - typescript: 6.0.3 + typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optional: true @@ -18982,34 +19097,6 @@ snapshots: - tsx - yaml - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(typescript@6.0.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.27.7) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3 - esbuild: 0.27.7 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0) - resolve-from: 5.0.0 - rollup: 4.60.3 - source-map: 0.7.6 - sucrase: 3.35.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.16 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.16 - typescript: 6.0.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -19091,8 +19178,6 @@ snapshots: typescript@5.9.3: {} - typescript@6.0.3: {} - ufo@1.6.1: {} uglify-js@3.19.3: @@ -19107,8 +19192,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: - optional: true + undici-types@8.3.0: {} undici@7.28.0: optional: true @@ -19320,6 +19404,27 @@ snapshots: - tsx - yaml + vite-node@3.2.4(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@6.0.0(@types/node@22.19.21)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0): dependencies: cac: 7.0.0 @@ -19341,13 +19446,13 @@ snapshots: - tsx - yaml - vite-node@6.0.0(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0): + vite-node@6.0.0(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0): dependencies: cac: 7.0.0 es-module-lexer: 2.1.0 obug: 2.1.2 pathe: 2.0.3 - vite: 8.1.4(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0) + vite: 8.1.4(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -19362,10 +19467,10 @@ snapshots: - tsx - yaml - vite-plugin-singlefile@2.3.3(rollup@4.61.1)(vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)): + vite-plugin-singlefile@2.3.3(rollup@4.61.1)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)): dependencies: micromatch: 4.0.8 - vite: 7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) optionalDependencies: rollup: 4.61.1 @@ -19401,7 +19506,7 @@ snapshots: sass: 1.99.0 tsx: 4.21.0 - vite@7.3.5(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0): + vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -19410,7 +19515,7 @@ snapshots: rollup: 4.61.1 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 26.1.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 @@ -19432,7 +19537,7 @@ snapshots: sass: 1.99.0 tsx: 4.21.0 - vite@8.1.4(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0): + vite@8.1.4(@types/node@26.1.0)(esbuild@0.25.12)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -19440,7 +19545,7 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 26.1.0 esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.6.1 @@ -19545,6 +19650,51 @@ snapshots: - tsx - yaml + vitest@3.2.6(@types/debug@4.1.12)(@types/node@26.1.0)(@vitest/browser@3.2.6)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 26.1.0 + '@vitest/browser': 3.2.6(playwright@1.60.0)(vite@7.3.5(@types/node@26.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(tsx@4.21.0))(vitest@3.2.6) + happy-dom: 20.9.0 + jsdom: 29.0.2(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0