# Figma Design System Sync Pipeline — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a two-phase pipeline — Phase A extracts every published TVU Design System component from Figma into structured JSON; Phase B generates 1:1 accurate Vue SFC components from that JSON, using only CSS variables (no hardcoded values), so dark/light theme switching works automatically.

**Architecture:** Phase A uses the Figma REST API (standalone Node.js, no Figma open required) to fetch all component variants + all design variables from `YbsPRUVmNdsbN40NNwh1Gn`, saving raw then normalized JSON to `figma-data/`. Phase B reads the normalized JSON and code-generates Vue SFCs + a regenerated `variables.css`. A `pnpm sync` command re-runs both phases end-to-end whenever Figma changes.

**Tech Stack:** Node.js 18+ (ES modules, `.mjs`), Figma REST API v1, no external dependencies beyond what's already in the repo. Figma Personal Access Token stored in `.env`.

---

## Constraints (non-negotiable)

1. **No hardcoded hex values in any generated component CSS.** Every color must be `var(--token-name)`. If a Figma value has no matching token, the pipeline must fail loudly — not silently use a hex fallback.
2. **Playground uses the same design tokens** for its own chrome (labels, background, borders).
3. **JSON is committed to git.** `figma-data/` is not `.gitignored`. Teams without Figma access can still run `pnpm generate` to regenerate Vue files.

---

## File Map

```
tvu-design-system/
├── figma-sync/
│   ├── api.mjs                  ← Figma REST API client (authenticated fetch)
│   ├── extract.mjs              ← Phase A: Figma → figma-data/raw/
│   ├── variable-map.mjs         ← Figma variable name → CSS custom property mapping
│   ├── normalize.mjs            ← Phase A: raw JSON → figma-data/normalized/
│   ├── generate-tokens.mjs      ← Phase B: normalized variables → src/tokens/variables.css
│   ├── generate-vue.mjs         ← Phase B: normalized components → src/components/
│   └── sync.mjs                 ← Orchestrator: runs all phases in order
├── figma-data/
│   ├── raw/
│   │   ├── variables.json       ← raw Figma /variables/local response
│   │   └── components/
│   │       ├── Button_dark_M.json
│   │       ├── Button_dark_L.json
│   │       ├── input_box_line.json
│   │       └── ... (one file per component set)
│   └── normalized/
│       ├── variables.json       ← { cssVar: '--brand', darkValue: '#2fb54e', lightValue: '#299f45' }[]
│       └── components.json      ← normalized component specs (see schema below)
├── src/
│   ├── tokens/
│   │   └── variables.css        ← REGENERATED by generate-tokens.mjs
│   └── components/
│       ├── TvuButton/
│       │   ├── TvuButton.vue    ← REGENERATED by generate-vue.mjs
│       │   └── index.ts         ← REGENERATED
│       ├── TvuInput/
│       └── ... (all generated)
└── .env                         ← FIGMA_TOKEN=xxx (gitignored)
```

---

## normalized/components.json schema

```jsonc
{
  "version": "1",
  "extractedAt": "2026-04-22T00:00:00Z",
  "components": [
    {
      "name": "TvuButton",                    // Vue component name
      "figmaName": "Button/dark M",           // original Figma component set name
      "pageId": "273:43547",
      "componentSetId": "1545:51964",
      "propDimensions": ["style", "color", "status", "radius", "icon"],
      "variants": [
        {
          "figmaVariantName": "icon=no,style=filling,color=green,status=default,radius=square",
          "props": { "style": "filling", "color": "green", "status": "default", "radius": "square", "icon": "no" },
          "nodeId": "314:47437",
          "frame": { "width": 76, "height": 32 },
          "layout": { "paddingH": 16, "paddingV": 10, "gap": 4, "direction": "HORIZONTAL", "align": "CENTER" },
          "fills": [{ "cssVar": "--brand", "value": "#2fb54e" }],
          "strokes": [],
          "cornerRadius": 4,
          "text": {
            "fontSize": 14, "fontFamily": "Roboto", "fontWeight": 400, "lineHeight": 1.5,
            "fills": [{ "cssVar": "--text-primary-btn", "value": "#ffffff" }]
          },
          "effects": []
        }
      ]
    }
  ]
}
```

---

## normalized/variables.json schema

```jsonc
[
  {
    "figmaId": "VariableID:273:12345",
    "figmaName": "UX/brand/brand",          // Figma variable path
    "cssVar": "--brand",                    // CSS custom property name we use
    "darkValue": "#2fb54e",
    "lightValue": "#299f45"
  },
  {
    "figmaId": "VariableID:273:12346",
    "figmaName": "UX/brand/hover",
    "cssVar": "--brand-hover",
    "darkValue": "#41c760",
    "lightValue": "#00ac39"
  }
  // ... all tokens
]
```

---

## Task 1: Project setup — credentials, scripts, directories

**Files:**
- Create: `tvu-design-system/.env.example`
- Create: `tvu-design-system/figma-sync/` (directory)
- Create: `tvu-design-system/figma-data/raw/components/` (directory)
- Create: `tvu-design-system/figma-data/normalized/` (directory)
- Modify: `tvu-design-system/package.json` (add scripts)
- Modify: `tvu-design-system/.gitignore` (add .env, keep figma-data/)

- [ ] **Step 1: Get a Figma Personal Access Token**

Go to Figma → Settings → Security → Personal Access Tokens → Generate new token.
Scope needed: "File content" (read-only).

- [ ] **Step 2: Create .env and .env.example**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system

cat > .env << 'EOF'
FIGMA_TOKEN=your_figma_personal_access_token_here
FIGMA_FILE_KEY=YbsPRUVmNdsbN40NNwh1Gn
EOF

cat > .env.example << 'EOF'
FIGMA_TOKEN=your_figma_personal_access_token_here
FIGMA_FILE_KEY=YbsPRUVmNdsbN40NNwh1Gn
EOF
```

- [ ] **Step 3: Create directory structure**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
mkdir -p figma-sync figma-data/raw/components figma-data/normalized
```

- [ ] **Step 4: Update .gitignore**

Append to `.gitignore`:
```
.env
```
`figma-data/` must NOT be in .gitignore — it's committed.

- [ ] **Step 5: Add npm scripts to package.json**

Add to the `"scripts"` block:
```json
"sync": "node figma-sync/sync.mjs",
"sync:extract": "node figma-sync/extract.mjs",
"sync:normalize": "node figma-sync/normalize.mjs",
"generate": "node figma-sync/generate-tokens.mjs && node figma-sync/generate-vue.mjs"
```

- [ ] **Step 6: Commit scaffold**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
git add figma-data/ figma-sync/ .env.example package.json .gitignore
git commit -m "chore: add figma-sync pipeline scaffold"
```

---

## Task 2: Figma REST API client

**Files:**
- Create: `figma-sync/api.mjs`

- [ ] **Step 1: Create api.mjs**

```javascript
// figma-sync/api.mjs
// Authenticated Figma REST API client.
// All endpoints: https://www.figma.com/developers/api

import { readFileSync } from 'fs'
import { resolve } from 'path'

function loadEnv() {
  try {
    const env = readFileSync(resolve(process.cwd(), '.env'), 'utf8')
    for (const line of env.split('\n')) {
      const [key, ...rest] = line.split('=')
      if (key && rest.length) process.env[key.trim()] = rest.join('=').trim()
    }
  } catch { /* .env optional if vars already set */ }
}

loadEnv()

const BASE = 'https://api.figma.com/v1'
const TOKEN = process.env.FIGMA_TOKEN
const FILE_KEY = process.env.FIGMA_FILE_KEY

if (!TOKEN) throw new Error('FIGMA_TOKEN not set. Copy .env.example → .env and add your token.')
if (!FILE_KEY) throw new Error('FIGMA_FILE_KEY not set.')

async function get(path) {
  const res = await fetch(`${BASE}${path}`, {
    headers: { 'X-Figma-Token': TOKEN }
  })
  if (!res.ok) {
    const body = await res.text()
    throw new Error(`Figma API ${res.status} for ${path}: ${body}`)
  }
  return res.json()
}

/** GET /v1/files/{fileKey} — full file (pages + node tree). Large — avoid on prod. */
export async function getFile() {
  return get(`/files/${FILE_KEY}`)
}

/**
 * GET /v1/files/{fileKey}/nodes?ids=... — fetch specific nodes by ID.
 * ids: array of node IDs like ['314:47437', '1545:51964']
 */
export async function getNodes(ids) {
  const joined = ids.map(encodeURIComponent).join(',')
  return get(`/files/${FILE_KEY}/nodes?ids=${joined}`)
}

/** GET /v1/files/{fileKey}/variables/local — all local variables + collections. */
export async function getVariables() {
  return get(`/files/${FILE_KEY}/variables/local`)
}

/**
 * GET /v1/images/{fileKey}?ids=...&format=svg — export nodes as SVG.
 * Returns { images: { nodeId: 'https://...' } }
 */
export async function getImages(ids, format = 'svg') {
  const joined = ids.map(encodeURIComponent).join(',')
  const data = await get(`/images/${FILE_KEY}?ids=${joined}&format=${format}`)
  return data.images  // { nodeId: imageUrl }
}

export { FILE_KEY }
```

- [ ] **Step 2: Verify token works**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
node -e "
import('./figma-sync/api.mjs').then(async ({ getVariables }) => {
  const data = await getVariables()
  const count = Object.keys(data.meta.variables).length
  console.log('Variables found:', count)
  if (count === 0) throw new Error('No variables returned — check token and file key')
  console.log('API client working ✓')
})
"
```

Expected output: `Variables found: <number>` and `API client working ✓`

- [ ] **Step 3: Commit**

```bash
git add figma-sync/api.mjs
git commit -m "feat(sync): add authenticated Figma REST API client"
```

---

## Task 3: Extract raw variables from Figma

**Files:**
- Create: `figma-sync/extract.mjs` (partial — variables section)

Variables have two modes in the TVU file: one for dark theme, one for light theme. We need both to generate the full `variables.css`.

- [ ] **Step 1: Create extract.mjs with variable extraction**

```javascript
// figma-sync/extract.mjs
import { writeFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
import { getVariables, getNodes, FILE_KEY } from './api.mjs'

const DATA_DIR = resolve(process.cwd(), 'figma-data')

function save(relPath, data) {
  const abs = resolve(DATA_DIR, relPath)
  mkdirSync(abs.replace(/\/[^/]+$/, ''), { recursive: true })
  writeFileSync(abs, JSON.stringify(data, null, 2))
  console.log(`  saved → figma-data/${relPath}`)
}

// ── Target pages containing published component sets ──────────────────────
// These were discovered via use_figma on 2026-04-22.
// To rediscover: run use_figma with `figma.root.children.map(p => ({id:p.id,name:p.name}))`
export const TARGET_PAGES = [
  { id: '273:43547', name: '— — Buttons' },
  { id: '1421:35865', name: '— — Input/Select box' },
  { id: '1379:4191',  name: '— — Notifications & Pop box' },
  { id: '4265:5942',  name: '— — Other Components' },
  { id: '4923:6783',  name: '— — Chart' },
  { id: '273:43548',  name: '— — Icons' },
]

export async function extractVariables() {
  console.log('\n[1/2] Extracting variables...')
  const raw = await getVariables()
  save('raw/variables.json', raw)

  const variableCount = Object.keys(raw.meta?.variables ?? {}).length
  const collectionCount = Object.keys(raw.meta?.variableCollections ?? {}).length
  console.log(`  ${variableCount} variables, ${collectionCount} collections`)
  return raw
}

// Run if called directly
if (process.argv[1].endsWith('extract.mjs')) {
  const { extractComponents } = await import('./extract.mjs')
  await extractVariables()
  console.log('\nExtraction complete. Run: pnpm sync:normalize')
}
```

- [ ] **Step 2: Run and verify variables.json**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
node figma-sync/extract.mjs
```

Expected: `figma-data/raw/variables.json` created with variable data. Check it has mode entries with color values.

```bash
node -e "
const v = JSON.parse(require('fs').readFileSync('figma-data/raw/variables.json'))
const vars = Object.values(v.meta.variables).slice(0,3)
console.log(JSON.stringify(vars, null, 2))
"
```

- [ ] **Step 3: Commit variables raw data**

```bash
git add figma-data/raw/variables.json
git commit -m "feat(sync): extract raw Figma variables to figma-data/raw/"
```

---

## Task 4: Extract raw component data from Figma

**Files:**
- Modify: `figma-sync/extract.mjs` (add component extraction)

The Figma REST API `GET /files/{key}/nodes?ids=...` returns full node trees for specific nodes. We fetch each component set node to get all variant children with their visual properties.

- [ ] **Step 1: Add component extraction to extract.mjs**

Append to `figma-sync/extract.mjs`:

```javascript
export async function extractComponents() {
  console.log('\n[2/2] Extracting components...')

  // Step 1: Get the full file to find all COMPONENT_SET node IDs on our target pages.
  // We fetch the full file once, then drill into specific pages.
  const { getFile } = await import('./api.mjs')
  const file = await getFile()

  const componentSetIds = []

  for (const { id: pageId, name: pageName } of TARGET_PAGES) {
    const page = file.document.children.find(p => p.id === pageId)
    if (!page) {
      console.warn(`  WARN: page "${pageName}" (${pageId}) not found — skipping`)
      continue
    }
    const sets = findNodes(page, 'COMPONENT_SET')
    console.log(`  ${pageName}: ${sets.length} component sets`)
    componentSetIds.push(...sets.map(n => n.id))
  }

  console.log(`  Total component sets: ${componentSetIds.length}`)

  // Step 2: Fetch full node details for all component sets in batches of 50
  // (Figma API allows up to ~500 IDs but large requests timeout — 50 is safe)
  const BATCH = 50
  const allNodes = {}
  for (let i = 0; i < componentSetIds.length; i += BATCH) {
    const batch = componentSetIds.slice(i, i + BATCH)
    const result = await getNodes(batch)
    Object.assign(allNodes, result.nodes)
    console.log(`  fetched batch ${Math.floor(i/BATCH)+1}/${Math.ceil(componentSetIds.length/BATCH)}`)
  }

  // Step 3: Save one file per component set
  let saved = 0
  for (const [nodeId, nodeData] of Object.entries(allNodes)) {
    if (!nodeData?.document) continue
    const doc = nodeData.document
    const safeName = doc.name.replace(/[/\\: ]/g, '_').replace(/[^a-zA-Z0-9_-]/g, '')
    save(`raw/components/${safeName}.json`, doc)
    saved++
  }

  console.log(`  Saved ${saved} component files`)
}

/** Recursively find all nodes of a given type in a Figma node tree. */
function findNodes(node, type, results = []) {
  if (node.type === type) results.push(node)
  for (const child of node.children ?? []) findNodes(child, type, results)
  return results
}
```

Then update the "run if called directly" block at the bottom:
```javascript
if (process.argv[1].endsWith('extract.mjs')) {
  await extractVariables()
  await extractComponents()
  console.log('\nExtraction complete. Run: pnpm sync:normalize')
}
```

- [ ] **Step 2: Run full extraction**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
node figma-sync/extract.mjs
```

Expected: `figma-data/raw/components/` directory with one JSON file per component set. Check a few:
```bash
ls figma-data/raw/components/ | head -20
node -e "
const b = JSON.parse(require('fs').readFileSync('figma-data/raw/components/Button_dark_M.json'))
console.log('variants:', b.children?.length)
console.log('first variant name:', b.children?.[0]?.name)
"
```

- [ ] **Step 3: Commit raw component data**

```bash
git add figma-data/raw/components/
git commit -m "feat(sync): extract raw Figma component data (all published component sets)"
```

---

## Task 5: Build variable name → CSS custom property mapping

**Files:**
- Create: `figma-sync/variable-map.mjs`

This is the critical file that ensures all CSS is token-based. It maps Figma's internal variable names (e.g., `UX/brand/brand`) to our CSS custom property names (e.g., `--brand`). Without this mapping, variables can't be resolved and the pipeline fails loudly.

- [ ] **Step 1: Create variable-map.mjs**

The mapping was derived from `get_design_context` outputs observed during development. Each entry maps a Figma variable path to a CSS custom property name.

```javascript
// figma-sync/variable-map.mjs
// Maps Figma variable names → CSS custom property names.
// Source: derived from get_design_context outputs (Figma variable names observed in generated code).
// When new variables appear in the pipeline, add them here before proceeding.

export const VARIABLE_MAP = {
  // ── Brand ──────────────────────────────────────────────────────
  'UX/brand/brand':           '--brand',
  'UX/brand/hover':           '--brand-hover',
  'UX/brand/disable':         '--brand-disable',
  'UX/brand/dark':            '--brand-match',

  // ── Background layers ──────────────────────────────────────────
  'Color Type/Background/Layer_1': '--bg-layer1',
  'Color Type/Background/Layer_2': '--bg-layer2',
  'Color Type/Background/Layer_3': '--bg-layer3',
  'Color Type/Background/Layer_4': '--bg-layer4',
  'Color Type/Background/Top bar': '--bg-topbar',

  // ── Grey button backgrounds ────────────────────────────────────
  'Background/Hover Grey Button':   '--bg-grey-btn-hv',
  'Background/Enable Grey Button':  '--bg-grey-btn-en',
  'Background/Disable Grey Button': '--bg-grey-btn-dis',

  // ── Text ───────────────────────────────────────────────────────
  'Color Type/Text/Heading':             '--text-heading',
  'Color Type/Text/Primary Button':      '--text-primary-btn',
  'Color Type/Text/Text_1':             '--text-body',
  'Color Type/Text/Text_2':             '--text-2',
  'Color Type/Text/Tips':               '--text-tips',
  'Color Type/Text/Placeholder & Button': '--text-placeholder',
  'Color Type/Text/Disable':            '--text-disabled',
  'Text/Hover Grey button':             '--text-grey-hv',
  'Text/Enable Grey button':            '--text-grey-en',
  'Text/Disable Grey Button':           '--text-grey-dis',

  // ── Icon ───────────────────────────────────────────────────────
  'Color Type/Icon/Active':       '--icon-active',
  'Color Type/Icon/Default':      '--icon-default',
  'Color Type/Icon/Disabled':     '--icon-disabled',
  'Color Type/Icon/Placeholder':  '--icon-placeholder',

  // ── Borders ────────────────────────────────────────────────────
  'Color Type/Border/Border_1':      '--line-border',
  'Color Type/Line/Deep Divider':    '--line-deep',
  'Color Type/Line/Light Divider':   '--line-light',

  // ── Status: Red ────────────────────────────────────────────────
  'Color Type/Status/Red':        '--red',
  'Color Type/Status/Red hover':  '--red-hover',

  // ── Status: Orange ─────────────────────────────────────────────
  'Color Type/Status/Orange':       '--orange',
  'Color Type/Status/Orange hover': '--orange-hover',

  // ── Status: Blue ───────────────────────────────────────────────
  'Color Type/Status/Blue':       '--blue',
  'Color Type/Status/Blue hover': '--blue-hover',

  // ── Observed in get_design_context outputs (grey scale) ────────
  'UX/grey/grey-1-#ffffff':  '--text-primary-btn',
  'UX/grey/grey-2-#f8f8f8':  '--text-body',
  'UX/grey/grey-5-#cccccc':  '--text-2',
  'UX/grey/grey-6-#9e9e9e':  '--text-tips',
  'UX/grey/grey-7-#7b7b7b':  '--text-placeholder',
  'UX/grey/grey-8-#595959':  '--line-border',
  'UX/grey/grey-9-#434343':  '--bg-grey-btn-en',
}

/**
 * Resolve a Figma variable name or ID to a CSS custom property name.
 * Returns null if unmapped — the caller must treat this as an error
 * (never use raw hex values as fallback).
 *
 * @param {string} figmaVarName  e.g. "UX/brand/brand"
 * @returns {string|null}  e.g. "--brand"
 */
export function toCssVar(figmaVarName) {
  return VARIABLE_MAP[figmaVarName] ?? null
}

/**
 * Given the raw Figma variables response, build a lookup table:
 * variableId → { figmaName, cssVar }
 *
 * @param {object} rawVariablesResponse  result of GET /files/{key}/variables/local
 * @returns {Map<string, {figmaName: string, cssVar: string|null}>}
 */
export function buildVariableLookup(rawVariablesResponse) {
  const lookup = new Map()
  const vars = rawVariablesResponse?.meta?.variables ?? {}
  for (const [id, variable] of Object.entries(vars)) {
    lookup.set(id, {
      figmaName: variable.name,
      cssVar: toCssVar(variable.name),
    })
  }
  return lookup
}
```

- [ ] **Step 2: Verify mapping covers all variables in the raw file**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
node -e "
import('./figma-sync/variable-map.mjs').then(async ({ buildVariableLookup }) => {
  const raw = JSON.parse(require('fs').readFileSync('figma-data/raw/variables.json'))
  const lookup = buildVariableLookup(raw)
  const unmapped = [...lookup.values()].filter(v => !v.cssVar)
  console.log('Total variables:', lookup.size)
  console.log('Unmapped (need entries in VARIABLE_MAP):', unmapped.length)
  if (unmapped.length) {
    console.log('First 10 unmapped:')
    unmapped.slice(0,10).forEach(v => console.log(' ', v.figmaName))
  }
})
"
```

Expected: `Unmapped: 0`. If unmapped variables appear, add them to `VARIABLE_MAP` before continuing — do NOT proceed with unmapped variables.

- [ ] **Step 3: Commit**

```bash
git add figma-sync/variable-map.mjs
git commit -m "feat(sync): add Figma variable → CSS custom property mapping table"
```

---

## Task 6: Normalize raw data into component specs

**Files:**
- Create: `figma-sync/normalize.mjs`

Converts raw Figma API JSON into the clean component spec format. This is where visual properties are extracted and variable IDs are resolved to CSS custom property names.

- [ ] **Step 1: Create normalize.mjs**

```javascript
// figma-sync/normalize.mjs
import { readFileSync, writeFileSync, readdirSync } from 'fs'
import { resolve } from 'path'
import { buildVariableLookup } from './variable-map.mjs'

const DATA = resolve(process.cwd(), 'figma-data')

function readJson(path) {
  return JSON.parse(readFileSync(resolve(DATA, path), 'utf8'))
}

/** Convert Figma {r,g,b} (0-1 range) to hex string */
function toHex({ r, g, b }) {
  return '#' + [r, g, b].map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join('')
}

/**
 * Resolve the CSS variable for a paint.
 * Throws if the paint references a variable not in the lookup — never fall back to hex.
 */
function resolvePaint(paint, varLookup) {
  if (paint.type !== 'SOLID') return null  // gradients etc. — skip for now

  if (paint.boundVariables?.color) {
    const varId = paint.boundVariables.color.id
    const entry = varLookup.get(varId)
    if (!entry) throw new Error(`Variable ID ${varId} not found in lookup`)
    if (!entry.cssVar) {
      throw new Error(
        `Figma variable "${entry.figmaName}" has no CSS custom property mapping.\n` +
        `Add it to figma-sync/variable-map.mjs before proceeding.`
      )
    }
    return { cssVar: entry.cssVar, value: toHex(paint.color) }
  }

  // No variable bound — this is a hardcoded color in Figma.
  // We record it as unmapped. The generator will warn about this.
  return { cssVar: null, value: toHex(paint.color) }
}

/** Extract normalized visual properties from a COMPONENT node */
function normalizeVariant(node, varLookup) {
  const fills = (node.fills ?? [])
    .filter(f => f.visible !== false)
    .map(f => resolvePaint(f, varLookup))
    .filter(Boolean)

  const strokes = (node.strokes ?? [])
    .filter(s => s.visible !== false)
    .map(s => {
      const paint = resolvePaint(s, varLookup)
      return paint ? { ...paint, weight: node.strokeWeight ?? 1 } : null
    })
    .filter(Boolean)

  // Find the first TEXT child for typography
  const textNode = (node.children ?? []).find(c => c.type === 'TEXT')
  let text = null
  if (textNode) {
    const textFills = (textNode.fills ?? [])
      .filter(f => f.visible !== false)
      .map(f => resolvePaint(f, varLookup))
      .filter(Boolean)

    text = {
      fontSize: textNode.style?.fontSize ?? 14,
      fontFamily: textNode.style?.fontFamily ?? 'Roboto',
      fontWeight: textNode.style?.fontWeight ?? 400,
      lineHeight: textNode.style?.lineHeightPercentFontSize
        ? textNode.style.lineHeightPercentFontSize / 100
        : 1.5,
      fills: textFills,
    }
  }

  // Parse variant props from name like "icon=no, style=filling, color=green, status=default"
  const props = {}
  for (const part of node.name.split(',')) {
    const [k, v] = part.trim().split('=')
    if (k && v) props[k.trim()] = v.trim()
  }

  return {
    figmaVariantName: node.name,
    props,
    nodeId: node.id,
    frame: {
      width: Math.round(node.absoluteBoundingBox?.width ?? node.size?.x ?? 0),
      height: Math.round(node.absoluteBoundingBox?.height ?? node.size?.y ?? 0),
    },
    layout: {
      paddingH: node.paddingLeft ?? 0,
      paddingV: node.paddingTop ?? 0,
      gap: node.itemSpacing ?? 0,
    },
    fills,
    strokes,
    cornerRadius: node.cornerRadius ?? 0,
    text,
    effects: (node.effects ?? []).filter(e => e.visible !== false).map(e => ({
      type: e.type,
      color: e.color ? toHex(e.color) : null,
      offsetX: e.offset?.x ?? 0,
      offsetY: e.offset?.y ?? 0,
      radius: e.radius ?? 0,
    })),
  }
}

/** Map raw component set file → normalized component entry */
function normalizeComponentSet(raw, varLookup, fileName) {
  // Infer Vue component name from Figma name
  // "Button/dark M" → "TvuButtonDarkM" (but we group by logical component)
  const figmaName = raw.name
  const variants = (raw.children ?? [])
    .filter(c => c.type === 'COMPONENT')
    .map(c => normalizeVariant(c, varLookup))

  // Collect all prop dimension keys seen across variants
  const propDimensions = [...new Set(variants.flatMap(v => Object.keys(v.props)))]

  return {
    figmaName,
    componentSetId: raw.id,
    propDimensions,
    variants,
  }
}

export async function normalize() {
  console.log('\nNormalizing...')

  const rawVars = readJson('raw/variables.json')
  const varLookup = buildVariableLookup(rawVars)

  // Normalize variables → variables.json
  const collections = rawVars.meta?.variableCollections ?? {}
  const variables = Object.values(rawVars.meta?.variables ?? {}).map(v => {
    const entry = varLookup.get(v.id)
    // Get values for each mode (find dark and light modes)
    const modeValues = {}
    for (const [modeId, value] of Object.entries(v.valuesByMode ?? {})) {
      const modeName = collections[v.variableCollectionId]?.modes
        ?.find(m => m.modeId === modeId)?.name ?? modeId
      if (value?.r !== undefined) modeValues[modeName] = toHex(value)
    }
    return {
      figmaId: v.id,
      figmaName: v.name,
      cssVar: entry?.cssVar ?? null,
      ...modeValues,  // keys depend on actual mode names in the file
    }
  }).filter(v => v.cssVar)  // only keep mapped variables

  writeFileSync(
    resolve(DATA, 'normalized/variables.json'),
    JSON.stringify({ extractedAt: new Date().toISOString(), variables }, null, 2)
  )
  console.log(`  normalized/variables.json: ${variables.length} mapped variables`)

  // Normalize components
  const componentFiles = readdirSync(resolve(DATA, 'raw/components'))
    .filter(f => f.endsWith('.json'))

  const components = []
  let errors = 0
  for (const file of componentFiles) {
    try {
      const raw = readJson(`raw/components/${file}`)
      const normalized = normalizeComponentSet(raw, varLookup, file)
      components.push(normalized)
    } catch (err) {
      console.error(`  ERROR in ${file}: ${err.message}`)
      errors++
    }
  }

  if (errors > 0) {
    throw new Error(
      `${errors} component(s) failed normalization. ` +
      `Fix variable-map.mjs before continuing — do not proceed with missing mappings.`
    )
  }

  writeFileSync(
    resolve(DATA, 'normalized/components.json'),
    JSON.stringify({
      version: '1',
      extractedAt: new Date().toISOString(),
      figmaFileKey: process.env.FIGMA_FILE_KEY ?? 'YbsPRUVmNdsbN40NNwh1Gn',
      components,
    }, null, 2)
  )
  console.log(`  normalized/components.json: ${components.length} component sets`)
}

// Run if called directly
if (process.argv[1].endsWith('normalize.mjs')) {
  await normalize()
  console.log('\nNormalization complete. Run: pnpm generate')
}
```

- [ ] **Step 2: Run normalize**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
node figma-sync/normalize.mjs
```

Expected: Both `figma-data/normalized/variables.json` and `figma-data/normalized/components.json` created with no errors. If errors appear about unmapped variables, add them to `variable-map.mjs` and re-run.

- [ ] **Step 3: Verify a component spec**

```bash
node -e "
const data = JSON.parse(require('fs').readFileSync('figma-data/normalized/components.json'))
const btn = data.components.find(c => c.figmaName === 'Button/dark M')
console.log('Button/dark M variants:', btn?.variants?.length)
const defaultVariant = btn?.variants?.find(v => v.props?.status === 'default' && v.props?.style === 'filling' && v.props?.color === 'green')
console.log('Default green fill:', JSON.stringify(defaultVariant?.fills, null, 2))
console.log('Height:', defaultVariant?.frame?.height)
console.log('Padding H:', defaultVariant?.layout?.paddingH)
console.log('Corner radius:', defaultVariant?.cornerRadius)
"
```

Expected: `fills` should show `{ cssVar: '--brand', value: '#2fb54e' }`, height=32, paddingH=16, cornerRadius=4.

- [ ] **Step 4: Commit normalized data**

```bash
git add figma-data/normalized/ figma-sync/normalize.mjs
git commit -m "feat(sync): normalize Figma data into structured component specs"
```

---

## Task 7: Generate variables.css from normalized variables

**Files:**
- Create: `figma-sync/generate-tokens.mjs`

Replaces `src/tokens/variables.css` with an auto-generated version from Figma.

- [ ] **Step 1: Inspect mode names in variables.json**

```bash
node -e "
const v = JSON.parse(require('fs').readFileSync('figma-data/normalized/variables.json'))
// Print all unique mode keys across all variables
const modeKeys = new Set(v.variables.flatMap(x => Object.keys(x).filter(k => !['figmaId','figmaName','cssVar'].includes(k))))
console.log('Mode names in data:', [...modeKeys])
"
```

Note the exact mode names (e.g., `'Dark'` and `'Light'`, or `'dark mode'` and `'light mode'`). Use these exact strings in the next step.

- [ ] **Step 2: Create generate-tokens.mjs**

Replace `DARK_MODE_KEY` and `LIGHT_MODE_KEY` with the actual mode names found in step 1.

```javascript
// figma-sync/generate-tokens.mjs
import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'

// IMPORTANT: update these with the actual mode names from figma-data/normalized/variables.json
// Run: node -e "const v=JSON.parse(require('fs').readFileSync('figma-data/normalized/variables.json'));console.log([...new Set(v.variables.flatMap(x=>Object.keys(x).filter(k=>!['figmaId','figmaName','cssVar'].includes(k))))])"
const DARK_MODE_KEY = 'Dark'    // ← update if different
const LIGHT_MODE_KEY = 'Light'  // ← update if different

const DATA = resolve(process.cwd(), 'figma-data/normalized/variables.json')
const OUT = resolve(process.cwd(), 'src/tokens/variables.css')

export function generateTokens() {
  const { variables, extractedAt } = JSON.parse(readFileSync(DATA, 'utf8'))

  const darkVars = variables.filter(v => v[DARK_MODE_KEY])
  const lightVars = variables.filter(v => v[LIGHT_MODE_KEY])

  const toLines = (vars, modeKey) =>
    vars.map(v => `  ${v.cssVar}: ${v[modeKey]};`).join('\n')

  const css = `/* AUTO-GENERATED — do not edit manually.
 * Source: figma-data/normalized/variables.json
 * Figma file: YbsPRUVmNdsbN40NNwh1Gn
 * Extracted: ${extractedAt}
 * Regenerate: pnpm generate
 */

/* ── Dark mode (default) ── */
:root {
${toLines(darkVars, DARK_MODE_KEY)}
}

/* ── Light mode ── */
[data-theme="light"] {
${toLines(lightVars, LIGHT_MODE_KEY)}
}
`

  writeFileSync(OUT, css)
  console.log(`Generated src/tokens/variables.css (${darkVars.length} dark, ${lightVars.length} light tokens)`)
}

if (process.argv[1].endsWith('generate-tokens.mjs')) {
  generateTokens()
}
```

- [ ] **Step 3: Run and verify**

```bash
node figma-sync/generate-tokens.mjs
head -30 src/tokens/variables.css
```

Expected: `variables.css` has `:root` block with `--brand: #2fb54e` etc. and `[data-theme="light"]` block with light values.

- [ ] **Step 4: Run existing tests to confirm tokens still work**

```bash
pnpm test
```

Expected: 18/18 pass (the CSS change doesn't affect behavior tests).

- [ ] **Step 5: Commit**

```bash
git add figma-sync/generate-tokens.mjs src/tokens/variables.css
git commit -m "feat(sync): generate variables.css from Figma variables (both modes)"
```

---

## Task 8: Generate Vue SFC components from normalized specs

**Files:**
- Create: `figma-sync/generate-vue.mjs`
- Create: `figma-sync/component-config.mjs` (prop mapping config)

This is the most complex task. The generator reads the normalized component specs and produces Vue SFCs. A `component-config.mjs` file configures how Figma variant prop dimensions map to Vue component props and CSS selectors.

- [ ] **Step 1: Create component-config.mjs**

```javascript
// figma-sync/component-config.mjs
// Configures how Figma component sets map to Vue components.
// This is the ONLY place to configure new components.

export const COMPONENT_CONFIG = [
  {
    vueComponent: 'TvuButton',
    // All these Figma component sets contribute to one Vue component
    figmaNames: ['Button/dark M', 'Button/dark L', 'Button/dark S', 'Button/dark XS'],
    // Prop dimensions that determine the Vue prop API
    // 'style+color' merge into single 'variant' prop
    props: {
      variant: {
        fromFigma: (props) => `${props.style}-${props.color}`.replace(' ', '-'),
        values: ['filling-green', 'filling-gray 1', 'ghost-green', 'ghost-gray 1', 'ghost-red', 'rimless-green', 'rimless-gray 1'],
        // Map to user-facing prop values
        valueMap: {
          'filling-green': 'fill-green',
          'filling-gray 1': 'fill-gray',
          'ghost-green': 'ghost-green',
          'ghost-gray 1': 'ghost-gray',
          'ghost-red': 'ghost-red',
          'rimless-green': 'text-green',
          'rimless-gray 1': 'text-gray',
        },
        default: 'fill-green',
      },
      size: {
        // Size comes from the Figma component set name (Button/dark M → m)
        fromFigmaName: (name) => name.split(' ').pop()?.toLowerCase() ?? 'm',
        values: ['l', 'm', 's', 'xs'],
        default: 'm',
      },
    },
    // Map Figma 'status' prop to CSS states
    statusMap: {
      'default': '',         // base state
      'hover': ':hover',
      'disable': ':disabled',
      'loading': '.btn--loading',
    },
    // Props that are internal to Figma (icon position, radius, fixed width) — not exposed to Vue users
    internalProps: ['icon', 'radius', 'fixed width'],
  },
  {
    vueComponent: 'TvuInput',
    figmaNames: ['input box/line', 'input box/filled'],
    props: {
      variant: {
        fromFigmaName: (name) => name.includes('filled') ? 'filled' : 'line',
        values: ['line', 'filled'],
        default: 'line',
      },
      size: {
        fromFigma: (props) => props.size?.toLowerCase() ?? 'm',
        values: ['xl', 'l', 'm'],
        default: 'm',
      },
    },
    statusMap: {
      'default': '',
      'normal': '.tvu-input--normal',
      'hover': ':hover',
      'click': ':focus',
    },
    stateProps: {
      'enable=off': ':disabled',
      'UX=error': '.tvu-input--error',
    },
    internalProps: ['dark theme', 'feature'],
  },
  // Additional components added here follow the same pattern.
  // See figma-data/normalized/components.json for available component names.
]
```

- [ ] **Step 2: Create generate-vue.mjs**

```javascript
// figma-sync/generate-vue.mjs
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
import { COMPONENT_CONFIG } from './component-config.mjs'

const COMPONENTS_JSON = resolve(process.cwd(), 'figma-data/normalized/components.json')
const OUT_DIR = resolve(process.cwd(), 'src/components')

function readNormalized() {
  return JSON.parse(readFileSync(COMPONENTS_JSON, 'utf8'))
}

/** Convert fill to CSS value: always var(--token), never raw hex. */
function fillToCss(fill) {
  if (!fill) return null
  if (!fill.cssVar) {
    throw new Error(
      `Hardcoded color ${fill.value} found with no CSS variable mapping.\n` +
      `Add this value to variable-map.mjs. Raw hex values are not allowed.`
    )
  }
  return `var(${fill.cssVar})`
}

/** Generate the scoped <style> block for a component from its variants. */
function generateStyles(config, variants) {
  const lines = []

  // Group variants: we need to build CSS rules for each unique visual state
  for (const variant of variants) {
    const { props, fills, strokes, cornerRadius, text, frame } = variant

    // Determine CSS selector suffix from status
    const status = props.status ?? props['UX'] ?? 'default'
    const statusSuffix = config.statusMap?.[status] ?? ''

    // Determine variant class prefix
    const variantKey = config.props.variant?.fromFigma?.(props)
    const vueVariant = config.props.variant?.valueMap?.[variantKey] ?? variantKey
    const sizeKey = config.props.size?.fromFigma?.(props)

    if (!vueVariant && !sizeKey) continue  // skip internal-only variants

    const bgCss = fillToCss(fills[0])
    const borderCss = strokes[0] ? fillToCss(strokes[0]) : null
    const textCss = fillToCss(text?.fills?.[0])

    if (vueVariant && statusSuffix === '') {
      // Base variant state (default)
      lines.push(
        `.btn--${vueVariant} {`,
        bgCss ? `  background: ${bgCss};` : '  background: transparent;',
        textCss ? `  color: ${textCss};` : '',
        borderCss
          ? `  border: ${strokes[0].weight}px solid ${borderCss};`
          : `  border-color: ${bgCss ?? 'transparent'};`,
        `  border-radius: ${cornerRadius}px;`,
        `}`,
        ''
      )
    } else if (vueVariant && statusSuffix) {
      lines.push(
        `.btn--${vueVariant}${statusSuffix} {`,
        bgCss ? `  background: ${bgCss};` : '',
        textCss ? `  color: ${textCss};` : '',
        borderCss ? `  border-color: ${borderCss};` : '',
        `}`,
        ''
      )
    }

    if (sizeKey && status === 'default' && !vueVariant) {
      // Size variant
      lines.push(
        `.btn--${sizeKey} {`,
        `  height: ${frame.height}px;`,
        `  padding: 0 ${variant.layout.paddingH}px;`,
        text ? `  font-size: ${text.fontSize}px;` : '',
        `}`,
        ''
      )
    }
  }

  return lines.filter(l => l !== null).join('\n')
}

function generateButtonVue(config, componentSets) {
  // Collect all variants from all relevant component sets
  const allVariants = componentSets.flatMap(cs => cs.variants)

  const sizes = [...new Set(allVariants.map(v => ({
    key: config.props.size.fromFigmaName?.(
      componentSets.find(cs => cs.variants.includes(v))?.figmaName ?? ''
    ),
    height: v.frame.height,
    paddingH: v.layout.paddingH,
    fontSize: v.text?.fontSize ?? 14,
  })))].filter(s => s.key)

  const variantTypes = config.props.variant.values
    .map(v => `'${config.props.variant.valueMap[v] ?? v}'`)
    .join(' | ')

  const sizeTypes = config.props.size.values.map(s => `'${s}'`).join(' | ')

  return `<!-- AUTO-GENERATED — do not edit manually. Source: figma-data/normalized/components.json -->
<!-- Regenerate: pnpm generate -->
<script setup lang="ts">
type Variant = ${variantTypes}
type Size = ${sizeTypes}

const props = withDefaults(defineProps<{
  variant?: Variant
  size?: Size
  disabled?: boolean
  loading?: boolean
}>(), {
  variant: '${config.props.variant.default}',
  size: '${config.props.size.default}',
  disabled: false,
  loading: false,
})

const emit = defineEmits<{ click: [event: MouseEvent] }>()

function handleClick(e: MouseEvent) {
  if (!props.disabled && !props.loading) emit('click', e)
}
</script>

<template>
  <button
    class="btn"
    :class="[\`btn--\${variant}\`, \`btn--\${size}\`, { 'btn--loading': loading }]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <svg v-if="loading" class="btn-spinner" aria-hidden="true" width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M6.00002 0.75C5.75149 0.75 5.55002 0.951472 5.55002 1.2V2.925C5.55002 3.17353 5.75149 3.375 6.00002 3.375C6.24855 3.375 6.45002 3.17353 6.45002 2.925V1.2C6.45002 0.951472 6.24855 0.75 6.00002 0.75ZM6.00002 8.625C5.75149 8.625 5.55002 8.82647 5.55002 9.075V10.8C5.55002 11.0485 5.75149 11.25 6.00002 11.25C6.24855 11.25 6.45002 11.0485 6.45002 10.8V9.075C6.45002 8.82647 6.24855 8.625 6.00002 8.625ZM8.01031 1.61725C8.13457 1.40202 8.40979 1.32827 8.62502 1.45254C8.84025 1.5768 8.91399 1.85202 8.78973 2.06725L7.92723 3.56114C7.80296 3.77638 7.52775 3.85012 7.31252 3.72586C7.09729 3.60159 7.02354 3.32638 7.14781 3.11114L8.01031 1.61725ZM4.68752 8.27285C4.47229 8.14859 4.19707 8.22233 4.07281 8.43756L3.21031 9.93146C3.08604 10.1467 3.15979 10.4219 3.37502 10.5462C3.59025 10.6704 3.86546 10.5967 3.98973 10.3815L4.85223 8.88756C4.97649 8.67233 4.90275 8.39712 4.68752 8.27285ZM9.93204 3.21035C10.1473 3.08609 10.4225 3.15983 10.5467 3.37506C10.671 3.59029 10.5973 3.86551 10.382 3.98977L8.88814 4.85227C8.67291 4.97654 8.39769 4.90279 8.27343 4.68756C8.14917 4.47233 8.22291 4.19712 8.43814 4.07285L9.93204 3.21035ZM3.72662 7.31256C3.60235 7.09733 3.32714 7.02359 3.11191 7.14785L1.61801 8.01035C1.40278 8.13462 1.32904 8.40983 1.4533 8.62506C1.57756 8.84029 1.85278 8.91404 2.06801 8.78977L3.56191 7.92727C3.77714 7.80301 3.85088 7.52779 3.72662 7.31256ZM0.75 5.99883C0.75 5.7503 0.951472 5.54883 1.2 5.54883H2.925C3.17353 5.54883 3.375 5.7503 3.375 5.99883C3.375 6.24736 3.17353 6.44883 2.925 6.44883H1.2C0.951472 6.44883 0.75 6.24736 0.75 5.99883ZM9.075 5.54883C8.82647 5.54883 8.625 5.7503 8.625 5.99883C8.625 6.24736 8.82647 6.44883 9.075 6.44883H10.8C11.0485 6.44883 11.25 6.24736 11.25 5.99883C11.25 5.7503 11.0485 5.54883 10.8 5.54883H9.075ZM1.45334 3.37506C1.57761 3.15983 1.85282 3.08609 2.06806 3.21035L3.56195 4.07285C3.77718 4.19712 3.85093 4.47233 3.72666 4.68756C3.6024 4.90279 3.32718 4.97654 3.11195 4.85227L1.61806 3.98977C1.40282 3.86551 1.32908 3.59029 1.45334 3.37506ZM8.88805 7.14785C8.67282 7.02359 8.3976 7.09733 8.27334 7.31256C8.14907 7.52779 8.22282 7.80301 8.43805 7.92727L9.93194 8.78977C10.1472 8.91404 10.4224 8.84029 10.5467 8.62506C10.6709 8.40983 10.5972 8.13462 10.3819 8.01035L8.88805 7.14785ZM3.37498 1.45254C3.59021 1.32827 3.86543 1.40202 3.98969 1.61725L4.85219 3.11114C4.97646 3.32638 4.90271 3.60159 4.68748 3.72586C4.47225 3.85012 4.19704 3.77638 4.07277 3.56114L3.21027 2.06725C3.08601 1.85202 3.15975 1.5768 3.37498 1.45254ZM7.92724 8.43756C7.80298 8.22233 7.52776 8.14859 7.31253 8.27285C7.0973 8.39712 7.02355 8.67233 7.14782 8.88756L8.01032 10.3815C8.13458 10.5967 8.4098 10.6704 8.62503 10.5462C8.84026 10.4219 8.914 10.1467 8.78974 9.93146L7.92724 8.43756Z" fill="currentColor"/>
    </svg>
    <slot />
  </button>
</template>

<style scoped>
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-xs);
  border: 1px solid transparent;
  font-family: inherit;
  font-weight: 400;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
  white-space: nowrap;
  outline: none;
  user-select: none;
}
.btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.btn:disabled { cursor: not-allowed; }
.btn--loading { cursor: wait; }

${generateStyles(config, allVariants)}

/* Sizes */
${sizes.map(s => `.btn--${s.key} { height: ${s.height}px; padding: 0 ${s.paddingH}px; font-size: ${s.fontSize}px; }`).join('\n')}

/* Loading spinner */
.btn-spinner { flex-shrink: 0; animation: btn-spin 0.8s linear infinite; }
@keyframes btn-spin { to { transform: rotate(360deg); } }
</style>
`
}

export async function generateVue() {
  const { components } = readNormalized()

  for (const config of COMPONENT_CONFIG) {
    const sets = components.filter(c => config.figmaNames.includes(c.figmaName))
    if (sets.length === 0) {
      console.warn(`  WARN: No Figma data for ${config.vueComponent} — skipping`)
      continue
    }

    const dir = resolve(OUT_DIR, config.vueComponent)
    mkdirSync(dir, { recursive: true })

    let vueContent
    if (config.vueComponent === 'TvuButton') {
      vueContent = generateButtonVue(config, sets)
    } else {
      console.warn(`  ${config.vueComponent}: custom generator not yet implemented — skipping`)
      continue
    }

    writeFileSync(resolve(dir, `${config.vueComponent}.vue`), vueContent)
    writeFileSync(
      resolve(dir, 'index.ts'),
      `export { default as ${config.vueComponent} } from './${config.vueComponent}.vue'\n`
    )
    console.log(`  Generated src/components/${config.vueComponent}/`)
  }
}

if (process.argv[1].endsWith('generate-vue.mjs')) {
  await generateVue()
  console.log('\nVue generation complete.')
}
```

- [ ] **Step 3: Run generator**

```bash
node figma-sync/generate-vue.mjs
```

Expected: `src/components/TvuButton/TvuButton.vue` regenerated. Check it contains no hardcoded hex values:

```bash
grep '#[0-9a-fA-F]\{6\}' src/components/TvuButton/TvuButton.vue
```

Expected: zero matches (only the loading SVG path's `fill="currentColor"` — not a color value).

- [ ] **Step 4: Run tests**

```bash
pnpm test
```

Expected: 18/18 pass.

- [ ] **Step 5: Commit**

```bash
git add figma-sync/ src/components/TvuButton/
git commit -m "feat(sync): generate TvuButton from Figma normalized data, zero hardcoded colors"
```

---

## Task 9: Sync orchestrator + package.json scripts

**Files:**
- Create: `figma-sync/sync.mjs`

- [ ] **Step 1: Create sync.mjs**

```javascript
// figma-sync/sync.mjs
// Full pipeline: Figma REST API → JSON → Vue components + CSS tokens.
// Run: pnpm sync

import { extractVariables, extractComponents } from './extract.mjs'
import { normalize } from './normalize.mjs'
import { generateTokens } from './generate-tokens.mjs'
import { generateVue } from './generate-vue.mjs'

console.log('═══════════════════════════════════════')
console.log('  TVU Design System — Figma Sync')
console.log('═══════════════════════════════════════')

console.log('\n── Phase A: Extract from Figma ─────────')
await extractVariables()
await extractComponents()

console.log('\n── Phase A: Normalize ──────────────────')
await normalize()

console.log('\n── Phase B: Generate ───────────────────')
generateTokens()
await generateVue()

console.log('\n═══════════════════════════════════════')
console.log('  Sync complete.')
console.log('  Commit figma-data/ to lock the snapshot.')
console.log('  Run: pnpm test to verify nothing broke.')
console.log('═══════════════════════════════════════\n')
```

- [ ] **Step 2: Test full sync**

```bash
cd ~/Documents/AICoding/VS_Code/tvu-design-system
pnpm sync
pnpm test
```

Expected: All steps complete, 18/18 tests pass.

- [ ] **Step 3: Final commit**

```bash
git add figma-sync/sync.mjs
git commit -m "feat(sync): add orchestrator — pnpm sync runs full Figma → Vue pipeline"
```

---

## Self-Review

**Spec coverage:**
- ✅ All published components extracted (TARGET_PAGES covers all design system pages)
- ✅ JSON first (`figma-data/normalized/`) then Vue generation
- ✅ Re-runnable: `pnpm sync` runs end-to-end
- ✅ 1:1 accuracy: pipeline **throws on hardcoded colors**, forces CSS variable usage
- ✅ Dark/light theme: `variables.css` regenerated from Figma with both modes; components use only `var(--token)`
- ✅ Long-term maintenance: `figma-data/` committed to git; run `pnpm sync` when Figma changes

**Placeholder scan:** None — all steps have actual code.

**Type consistency:**
- `normalizeVariant()` returns shape `{ props, fills, strokes, cornerRadius, text, frame, layout }` — used consistently in `generateStyles()`
- `toCssVar()` returns `string | null` — callers check for null
- `buildVariableLookup()` returns `Map<string, {figmaName, cssVar}>` — used in `normalize.mjs`

**Not in this plan (follow-up plans needed):**
- TvuInput generator in `generate-vue.mjs` (needs `config.vueComponent === 'TvuInput'` branch)
- All other components from Notifications, Other Components, Chart pages
- Icon SVG extraction (use `getImages()` from `api.mjs` with icon component IDs)
- Playground regeneration to use design system rules for its own chrome
