Re-sync Figma (metadata + values)
When to run:
- DS team publishes new components or variables
- Component keys / variant properties change
- Variable collections or modes change
- Token values change (color hex, sizing px, alias remap) - Step 1.5 below
- Monthly maintenance cadence
📋 Two parallel pipelines, both covered here:
- METADATA (Steps 1, 2, 3 below) → drives
variable-id-map.md+component-registry.mdfor AI-agent navigation- VALUES (Step 1.5 below) → drives
tokens/v4/*.cssfor runtime CSSEach can run independently. A typical resync runs both.
Preconditions
Section titled “Preconditions”- DOL Design System V2 file open in Figma Desktop
- figma-console-mcp Desktop Bridge plugin active (Plugins → Development → Figma Desktop Bridge)
- Verify:
mcp__figma-console__figma_get_statuswithprobe: truereturnssuccess: true
Step 1 - Extract variable metadata
Section titled “Step 1 - Extract variable metadata”// Run via figma_execute (paste snippet into MCP tool call)async function extractVariableMetadata() { const collections = await figma.variables.getLocalVariableCollectionsAsync(); const variables = await figma.variables.getLocalVariablesAsync(); return { fileName: figma.root.name, fileKey: figma.fileKey, extractedAt: new Date().toISOString(), collectionCount: collections.length, variableCount: variables.length, collections: collections.map(c => ({ id: c.id, key: c.key, name: c.name, modes: c.modes.map(m => ({ modeId: m.modeId, name: m.name })), defaultModeId: c.defaultModeId, variableCount: c.variableIds.length, remote: c.remote, hiddenFromPublishing: c.hiddenFromPublishing, })), variables: variables.map(v => ({ id: v.id, key: v.key, name: v.name, collectionId: v.variableCollectionId, resolvedType: v.resolvedType, remote: v.remote, hiddenFromPublishing: v.hiddenFromPublishing, scopes: v.scopes, })), };}return await extractVariableMetadata();Expected output: 12 collections / ~2,960 variables. Result will exceed inline token limit → MCP auto-saves to temp file. Save path into cache/variable-metadata.json:
# After MCP returns "Output too large" saved path:jq -r '.[0].text' <MCP-OUTPUT-PATH> | jq '.result' > DOL-DS-token/design-guideline/figma-ai/cache/variable-metadata.jsonStep 1.5 - Extract variable VALUES (added 2026-04-27)
Section titled “Step 1.5 - Extract variable VALUES (added 2026-04-27)”Step 1 grabs metadata only (IDs, names, scopes). For VALUES (hex, px, alias resolution) - the data that drives tokens/v4/*.css and downstream Wiki/Studio/Playground CSS - run THIS snippet. Skip if you only need metadata refresh.
// figma_execute snippet — extracts ALL variable values per mode, with// recursive alias resolution + hex/px formatting. Tested 2026-04-27;// produces ~750KB output (auto-saved to file).async function extractAllValues() { const collections = await figma.variables.getLocalVariableCollectionsAsync(); const variables = await figma.variables.getLocalVariablesAsync(); const varById = new Map(variables.map(v => [v.id, v])); const collById = new Map(collections.map(c => [c.id, c]));
function resolveValue(value, modeId, depth) { if (depth > 10) return { _error: 'CIRCULAR' }; if (value && typeof value === 'object' && value.type === 'VARIABLE_ALIAS') { const aliased = varById.get(value.id); if (!aliased) return { _error: 'BROKEN_ALIAS', target: value.id }; const aliasColl = collById.get(aliased.variableCollectionId); const aliasModeId = aliased.valuesByMode[modeId] !== undefined ? modeId : aliasColl.defaultModeId; return resolveValue(aliased.valuesByMode[aliasModeId], aliasModeId, depth + 1); } return value; }
function fmt(value) { if (value && typeof value === 'object' && '_error' in value) return value; if (value && typeof value === 'object' && 'r' in value) { const to255 = x => Math.max(0, Math.min(255, Math.round(x * 255))); const hex = n => n.toString(16).padStart(2, '0'); const a = value.a !== undefined ? value.a : 1; if (a < 0.999) return `#${hex(to255(value.r))}${hex(to255(value.g))}${hex(to255(value.b))}${hex(to255(a))}`; return `#${hex(to255(value.r))}${hex(to255(value.g))}${hex(to255(value.b))}`; } return value; }
return { fileKey: figma.fileKey, fileName: figma.root.name, extractedAt: new Date().toISOString(), collectionCount: collections.length, variableCount: variables.length, collections: collections.map(c => { const collVars = variables.filter(v => v.variableCollectionId === c.id); return { id: c.id, key: c.key, name: c.name, modes: c.modes.map(m => ({ id: m.modeId, name: m.name })), defaultModeId: c.defaultModeId, variableCount: collVars.length, variables: collVars.map(v => { const valsByMode = {}; for (const mode of c.modes) { const raw = v.valuesByMode[mode.modeId]; if (raw === undefined) continue; valsByMode[mode.name] = fmt(resolveValue(raw, mode.modeId, 0)); if (raw && typeof raw === 'object' && raw.type === 'VARIABLE_ALIAS') { valsByMode[mode.name + '__alias'] = varById.get(raw.id)?.name || raw.id; } } return { id: v.id, key: v.key, name: v.name, type: v.resolvedType, scopes: v.scopes, valuesByMode: valsByMode }; }) }; }) };}return await extractAllValues();Expected output: 12 collections / ~2,960 variables, ~750KB. Save to cache/variable-values.json:
# After MCP returns "Output too large" saved path:jq -r '.[0].text' <MCP-OUTPUT-PATH> | jq '.result' > DOL-DS-token/design-guideline/figma-ai/cache/variable-values.jsonDiff + apply (Step 1.5b)
Section titled “Diff + apply (Step 1.5b)”After cache is fresh, diff vs current tokens/v4/*.css:
Mode → file mapping (must be exact for accurate diff):
| Collection | Figma mode | Local file |
|---|---|---|
| Base Theme Color | Light / Dark | color-base.css / color-base-dark.css |
| Raw Color | General / KID | color-raw.css / color-raw-kid.css |
| Typography | Desktop / Tablet / Mobile | typography-{desktop,tablet,mobile}.css |
| Typography Scale | Default / KID | typography-scale.css / typography-scale-kid.css |
| Layout | General / Tablet / Mobile | layout.css / layout-tablet.css / layout-mobile.css |
| Border Radius | General | radius-semantic.css |
| Base Radius | Mode 1 | radius-base.css |
| Base Size | Mode 1 | sizing-base.css |
| Component Token | Mode 1 | component.css |
| Statistic | Mode 1 | statistic.css |
| General Component Sizing | Mode 1 | component-sizing.css |
| General Color System | Default | color-semantic.css |
Critical guardrails when applying (token-units gate, 7th pre-commit, will catch violations):
- Figma
FLOATtype values are unit-less. CSSborder-radius: 12is INVALID → falls back to 0. Always appendpxwhen writing totokens/v4/*.css(except where the property accepts unit-less per CSS spec -opacity-*,font-weight-*,z-index-*,*-multiplier,*-ratio). - Variable names in Figma use
/and may contain(parens)and underscores. Sanitize to CSS form:/→-,_→-,(legacy)→-legacy-, lowercase. Multi-dash collapse. - For aliases: write as
var(--<sanitized-target-name>).
Reference apply-script: see commit history for tokens/v4/* 2026-04-27 sync (entry-point: cache/SYNC-REPORT-2026-04-27.md). The script + diff logic ran inline as a one-shot Python; consider promoting to tools/sync-figma-values.mjs if this becomes monthly cadence.
Orphan cleanup (Step 1.5b.2 - CRITICAL - added after 2026-04-27 incident)
Section titled “Orphan cleanup (Step 1.5b.2 - CRITICAL - added after 2026-04-27 incident)”⚠ Step 1.5b above only handles ADDITIVE updates (changes + new entries). It does NOT remove tokens that no longer exist in Figma. Skipping this step = old tokens persist in Studio + consumers, polluting the catalog. The 2026-04-27 sync hit this exact gap - see
cache/SYNC-REPORT-2026-04-27.mdPhase 2.
Detect orphans - local CSS tokens that have no corresponding Figma source:
# After Step 1.5a value extraction, run orphan-detect (in DS-Token root):import json, re, os, globfrom collections import defaultdict
FIGMA = json.load(open('design-guideline/figma-ai/cache/variable-values.json'))['result']
def sanitize(name): n = name.lower().replace('/', '-').replace('_', '-') n = re.sub(r'\s*\(([^)]*)\)\s*', r'-\1-', n) return re.sub(r'-{2,}', '-', n).strip('-')
# Use the SAME mode→file map from Step 1.5bdef file_for(coll, mode): ... # see Step 1.5b table
figma_names_by_file = defaultdict(set)for coll in FIGMA['collections']: for var in coll['variables']: for mode_name in var['valuesByMode'].keys(): if mode_name.endswith('__alias'): continue f = file_for(coll['name'], mode_name) if f: figma_names_by_file[f].add(sanitize(var['name']))
# Walk local; flag any --name not in figma_names_by_filefor path in sorted(glob.glob('tokens/v4/*.css')): fn = os.path.basename(path) if fn not in figma_names_by_file: continue figma_set = figma_names_by_file[fn] with open(path) as f: for ln, line in enumerate(f, 1): m = re.match(r'^\s*--([a-z0-9-]+)\s*:', line) if m and m.group(1).replace('_', '-') not in figma_set: print(f' {fn}:{ln} --{m.group(1)} ORPHAN')Decision matrix for each orphan:
Consumer refs (grep var(--<token>) Wiki+Playground+EduDoc) | Action |
|---|---|
| 0 refs | Hard delete from local CSS (no breakage risk) |
1+ refs in raw color file (color-raw*.css) | Likely safe to delete (raw rarely consumed directly); verify each |
1+ refs in semantic file (color-base*.css, color-semantic.css) | Convert to deprecated alias pointing to nearest valid token + add entry to deprecations.json |
| Many (10+) refs across multiple repos | Plan codemod migration as separate task; meanwhile keep as deprecated alias |
File-aware mapping bug (avoid the mistake from 2026-04-27): when aliasing semantic orphans, file-prefix the alias target - color-base.css (Light) → var(--light-...); color-base-dark.css (Dark) → var(--dark-...). A single global mapping applied to both files breaks one mode.
Mark as deprecated (deprecations.json at DS-Token root):
{ "<token-name>": { "deprecated": true, "replacement": "<replacement-token-name>", "reason": "Figma sync YYYY-MM-DD removed this token. Aliased for back-compat.", "deprecatedAt": "YYYY-MM-DD" }}This flag propagates to dist/tokens-data.json → Studio’s “Show deprecated” filter surfaces them with a badge.
Verify cleanup: re-run the orphan detector with deprecated tokens whitelisted; expect 0 remaining.
Sync report (mandatory after Step 1.5b)
Section titled “Sync report (mandatory after Step 1.5b)”Write cache/SYNC-REPORT-<YYYY-MM-DD>.md documenting:
- Source extraction timestamp + Figma file key + variable count
- Categorized changes (alias rebaselines, hex shifts, additions, broken aliases)
- Files touched + propagation chain run
- Gates passed (must include
token-units,variable-registry) - Open follow-ups (broken aliases for DS team, consumer migration candidates)
Reports ARE committed (see cache/.gitignore !SYNC-REPORT-*.md exception) - they form the human-readable history of token evolution.
Propagation after apply (Step 1.5c)
Section titled “Propagation after apply (Step 1.5c)”# From DS-Token root:npm run build # regen tokens-data.json + Tailwind preset
# From workspace root:node scripts/tokens-propagate.mjs --skip-build # → Wiki tokens.generated.css + Studio tokens.css
# Verify all 7 gates (anti-patterns + variable-registry + figma-metadata# + organizer-metadata + components-manifest + ui-registry + token-units):cd DOL-DS-token && bash .githooks/pre-commitIf variable-registry gate fails (count drift), regen:
python3 design-guideline/figma-ai/tools/extract-variable-registry.pyStep 2 - Extract component inventory
Section titled “Step 2 - Extract component inventory”// Same figma_execute pattern. Wrapped in try/catch for robustness.await figma.loadAllPagesAsync();
const EXCLUDE = [ /^---/, /^🧱 UI Base/, /^\s+Color$/, /^\s+Typography$/, /^\s+Effects \/ Shadow/, /^\s+Border radius/, /^\s+Spacing/, /^\s+Token/, /^\s+Sizing/, /^\s+Screen \/ Responsive/, /^\s+Layout/, /Old.*Không xài trong tương lai/, /Legacy/i, /Icons sample/, /^Playground/, /^Welcome/, /^AI pattern/, /^DOL PRO\/MAX/, /^Stuff/, /^Wireframe Kit/, /Discord Server/, /^Social$/, /^Emoji/, /🌠 Thumbnail/, /🧵 Design Organize/,];let seenOld = false;const excluded = new Set();for (const page of figma.root.children) { if (/---Old \(Không xài trong tương lai\)/.test(page.name)) seenOld = true; if (seenOld && !/^Legacy$/.test(page.name) && page.name !== '---Old (Không xài trong tương lai)') excluded.add(page.id); if (EXCLUDE.some(re => re.test(page.name))) excluded.add(page.id);}
const included = figma.root.children.filter(p => !excluded.has(p.id));const components = [], componentSets = [], errors = [];
function iconCategoryFromName(name) { const parts = String(name || '').split('/').map(s => s.trim()).filter(Boolean); if (/^Components$/i.test(parts[0] || '') && /^Icons?$/i.test(parts[1] || '')) return parts[2] || null; if (/^Icon filled$/i.test(parts[0] || '') || /^Icon Filled$/i.test(parts[0] || '')) return parts[1] || null; if (/^Icon 1\.5$/i.test(parts[0] || '')) { if (/^Components$/i.test(parts[1] || '') && /^Icons?$/i.test(parts[2] || '')) return parts[3] || null; return parts[1] || null; } return null;}
function walk(node, pageName, depth = 0, sectionName = null, parentPath = []) { if (depth > 3) return; const currentSection = node.type === 'SECTION' ? node.name : sectionName; const currentPath = parentPath.concat(node.name); if (node.type === 'COMPONENT_SET') { let props = []; try { const pd = node.componentPropertyDefinitions || {}; props = Object.entries(pd).map(([k, v]) => ({ name: k, type: v.type, defaultValue: v.defaultValue, options: v.variantOptions || null, })); } catch (e) { errors.push({ id: node.id, name: node.name, error: String(e).substring(0, 150) }); } componentSets.push({ id: node.id, key: node.key, name: node.name, pageName, sectionName: currentSection, parentPath: parentPath.join('/'), iconCategory: iconCategoryFromName(node.name), variantCount: node.children.length, properties: props }); return; } if (node.type === 'COMPONENT') { const parentIsSet = node.parent && node.parent.type === 'COMPONENT_SET'; if (!parentIsSet) components.push({ id: node.id, key: node.key, name: node.name, pageName, sectionName: currentSection, parentPath: parentPath.join('/'), iconCategory: iconCategoryFromName(node.name) }); return; } if ('children' in node) for (const child of node.children) walk(child, pageName, depth + 1, currentSection, currentPath);}
for (const page of included) for (const child of page.children) walk(child, page.name, 0);
return { fileName: figma.root.name, fileKey: figma.fileKey, extractedAt: new Date().toISOString(), includedPageCount: included.length, standaloneCount: components.length, componentSetCount: componentSets.length, errorCount: errors.length, components, componentSets, errors,};Expected output: ~560-590 component sets / ~2,600-8,000 standalone components (varies by how deep DS has decorative sub-components). Save to cache/component-inventory.json:
jq -r '.[0].text' <MCP-OUTPUT-PATH> | jq '.result' > DOL-DS-token/design-guideline/figma-ai/cache/component-inventory.jsonStep 3 - Regenerate registries (R18 split architecture)
Section titled “Step 3 - Regenerate registries (R18 split architecture)”cd "<workspace-root>"python3 DOL-DS-token/design-guideline/figma-ai/tools/extract-figma-metadata.pyExpected (post-R18 split):
✓ component-registry.md (slim INDEX): ~465 lines · 44 files indexed✓ components/ : 44 detail files · ~3185 total items✓ components/_index.json: machine catalog for lookup.py✓ variable-id-map.md: ~720 rowsThe generator:
- Emits a slim
component-registry.mdindex (page list + 3-key preview per page) - Emits one
components/<slug>.mddetail file per DS page (kebab-case slug from page name) - Emits
components/_index.jsonconsumed bytools/lookup.py - Auto-deletes orphan files in
components/if a page was removed - Splits Icons + Emoji pages BY PREFIX (icon-1-5.md, icon-filled.md, emoji-3d.md)
Step 4 - Verify
Section titled “Step 4 - Verify”# Idempotence check - running twice shouldn't change outputpython3 DOL-DS-token/design-guideline/figma-ai/tools/extract-figma-metadata.py --check# Should print: "OK: both registries match cache"Step 5 - Commit
Section titled “Step 5 - Commit”git add DOL-DS-token/design-guideline/figma-ai/component-registry.mdgit add DOL-DS-token/design-guideline/figma-ai/components/ # 44 detail files + _index.jsongit add DOL-DS-token/design-guideline/figma-ai/variable-id-map.md# cache/ is gitignored - do NOT commit raw JSON dumpsgit commit -m "chore(figma-ai): resync component + variable registries from live DS"Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
figma_execute times out at 32s | Too many nodes being walked | Increase timeout to 60s or narrow exclude list |
"Component set has existing errors" thrown | Some component set has malformed componentPropertyDefinitions | Already handled by try/catch; check errors[] in output |
| Registry counts drop significantly | New exclude pattern accidentally filtering valid pages | Review EXCLUDE list in generator + extraction snippet |
--check fails after fresh extract | Date in frontmatter differs | Expected - --check normalizes date before comparing |
Same fileKey but different components | DS team renamed/deleted components | Expected; commit the new registry |
Related
Section titled “Related”extract-figma-metadata.py- generator this runbook drives../component-registry.md+../variable-id-map.md- auto-generated outputs../workflows.md- Workflow 1-5 (consumers of these registries)../../ai-entry/FIGMA-AI.md- router links to both registries