Skip to content

Re-sync Figma (metadata + values)

Updated

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.md for AI-agent navigation
  • VALUES (Step 1.5 below) → drives tokens/v4/*.css for runtime CSS

Each can run independently. A typical resync runs both.

  1. DOL Design System V2 file open in Figma Desktop
  2. figma-console-mcp Desktop Bridge plugin active (Plugins → Development → Figma Desktop Bridge)
  3. Verify: mcp__figma-console__figma_get_status with probe: true returns success: true
// 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:

Terminal window
# 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.json

Step 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:

Terminal window
# 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.json

After cache is fresh, diff vs current tokens/v4/*.css:

Mode → file mapping (must be exact for accurate diff):

CollectionFigma modeLocal file
Base Theme ColorLight / Darkcolor-base.css / color-base-dark.css
Raw ColorGeneral / KIDcolor-raw.css / color-raw-kid.css
TypographyDesktop / Tablet / Mobiletypography-{desktop,tablet,mobile}.css
Typography ScaleDefault / KIDtypography-scale.css / typography-scale-kid.css
LayoutGeneral / Tablet / Mobilelayout.css / layout-tablet.css / layout-mobile.css
Border RadiusGeneralradius-semantic.css
Base RadiusMode 1radius-base.css
Base SizeMode 1sizing-base.css
Component TokenMode 1component.css
StatisticMode 1statistic.css
General Component SizingMode 1component-sizing.css
General Color SystemDefaultcolor-semantic.css

Critical guardrails when applying (token-units gate, 7th pre-commit, will catch violations):

  • Figma FLOAT type values are unit-less. CSS border-radius: 12 is INVALID → falls back to 0. Always append px when writing to tokens/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.md Phase 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, glob
from 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.5b
def 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_file
for 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 refsHard 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 reposPlan 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.

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.

Terminal window
# 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-commit

If variable-registry gate fails (count drift), regen:

Terminal window
python3 design-guideline/figma-ai/tools/extract-variable-registry.py
// 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:

Terminal window
jq -r '.[0].text' <MCP-OUTPUT-PATH> | jq '.result' > DOL-DS-token/design-guideline/figma-ai/cache/component-inventory.json

Step 3 - Regenerate registries (R18 split architecture)

Section titled “Step 3 - Regenerate registries (R18 split architecture)”
Terminal window
cd "<workspace-root>"
python3 DOL-DS-token/design-guideline/figma-ai/tools/extract-figma-metadata.py

Expected (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 rows

The generator:

  • Emits a slim component-registry.md index (page list + 3-key preview per page)
  • Emits one components/<slug>.md detail file per DS page (kebab-case slug from page name)
  • Emits components/_index.json consumed by tools/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)
Terminal window
# Idempotence check - running twice shouldn't change output
python3 DOL-DS-token/design-guideline/figma-ai/tools/extract-figma-metadata.py --check
# Should print: "OK: both registries match cache"
Terminal window
git add DOL-DS-token/design-guideline/figma-ai/component-registry.md
git add DOL-DS-token/design-guideline/figma-ai/components/ # 44 detail files + _index.json
git add DOL-DS-token/design-guideline/figma-ai/variable-id-map.md
# cache/ is gitignored - do NOT commit raw JSON dumps
git commit -m "chore(figma-ai): resync component + variable registries from live DS"
SymptomCauseFix
figma_execute times out at 32sToo many nodes being walkedIncrease timeout to 60s or narrow exclude list
"Component set has existing errors" thrownSome component set has malformed componentPropertyDefinitionsAlready handled by try/catch; check errors[] in output
Registry counts drop significantlyNew exclude pattern accidentally filtering valid pagesReview EXCLUDE list in generator + extraction snippet
--check fails after fresh extractDate in frontmatter differsExpected - --check normalizes date before comparing
Same fileKey but different componentsDS team renamed/deleted componentsExpected; commit the new registry
  • 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