Tokens → Figma Sync Implementation Plan
Goal: Export Tostada's token map as a DTCG file that a rewritten first-party "Tostada Variables" Figma plugin reads to create Figma Variable collections, light/dark modes, and aliases on any plan. Architecture: A pure serializer (
src/lib/figmaTokens.ts) turns theTokenMapinto our DTCG-based JSON (one group per tier·type, references as{…}aliases, dark mode via a$extensions.tostada.darkkey, typography facets expanded with unit conversion). The Figma plugin (figma-plugin/code.js) is rewritten into a thin interpreter that parses that DTCG and drives the Figma plugin API. AFigmaSyncPanelon the Developer page copies the DTCG to the clipboard (download fallback) and links to the published plugin. Tech Stack: TypeScript, Vite, Vitest (src/**/*.{test,spec}.{ts,tsx}), Zustand store, plain-JS Figma Plugin API.
Source of truth
This plan implements specs/approved/tokens-figma-sync.md. If anything here contradicts the spec, the spec wins — fix the spec first, then this plan. Scope is the plugin flow only; the direct REST push, OAuth/PAT, generic-importer compatibility, and composite Text Styles are explicitly deferred (spec §3, §5 Deferred).
Background the engineer needs
- Token model (src/tokens/types.ts): one
DesignTokenmap. Three tiers today —primitive,semantic,shadcn. The spec renamesshadcn→componentconceptually; the codebase-wide rename is a separate task, so this code readstier === 'shadcn'and emits the group name"Component". referencesisstring | { family, size }. Always narrow withtypeof t.references === 'string'first (see CLAUDE.md). The{ family, size }form is only on semantic typography tokens.- Component-tier (
shadcn) tokens carryvalue: ''and areferencesstring (e.g.primary→bg-accent). Their reference is their only meaning — if it can't resolve, drop and report, never emit""(spec §7, §8). - Token id prefixes drive
$typeinference:color-*→ color,radius-*/font-size-*→ dimension,font-family-*→ fontFamily; typography facetsweight/line-height/letter-spacing→ number. - Sample values:
radius-base=0.5rem,font-size-md=1rem,font-family-body="'Inter', sans-serif",color-zinc-500=#71717a. Colors are hex. - Pure-module rule: the existing export generators in src/export/ take plain data → string with no React/
window/useStore.figmaTokens.tsfollows the same discipline (the spec places it insrc/lib/next todocs.ts; keep it pure regardless of folder). - Existing plugin (figma-plugin/code.js) is legacy: its
applyTokensconsumes a Figma-internal payload (variableCollections/variableModes/…), not DTCG, andapplyComponentsbuilds image frames. Both are replaced. The plugin is plain ES5-style JS (var/function) with a globalfigma— noimport/export(Figma runs it as a classic script, unbundled). - Test reach: Vitest only collects
src/**/*.{test,spec}.{ts,tsx}. The plugin lives outsidesrc/, so its test must live undersrc/andrequire()the plugin file. To make that possible the plugin exposes its functions via amodule.exportsguard and guards its top-levelfigma.showUIcall — neither affects the Figma runtime.
File structure
| File | Create/Modify | Responsibility |
|---|---|---|
src/lib/figmaTokens.ts |
Create | Pure. tokenMapToDtcg(tokens) → { doc, dropped }. All $type inference, tier·type grouping, alias emission, dark $extensions, typography facet expansion, unit conversion. |
src/lib/figmaTokens.test.ts |
Create | Exhaustive unit tests for the serializer. |
figma-plugin/code.js |
Rewrite | Thin interpreter: parse DTCG → create/reuse collections, modes, variables; resolve {…} aliases; set values per mode; update in place. Pure helpers (hex→rgba, dimension→number, path parse, alias-plan building) exported for test. |
figma-plugin/ui.html |
Rewrite | Single DTCG paste box + "Paste from clipboard" + Apply; inline error on bad JSON; result counts. |
figma-plugin/manifest.json |
Modify | Keep name "Tostada Variables"; ensure main/ui correct (no change expected). |
src/lib/figmaPlugin.test.ts |
Create | require()s figma-plugin/code.js, runs its pure helpers + its apply logic against a hand-rolled figma mock. |
src/components/pages/developer/FigmaSyncPanel.tsx |
Create | The §6 export panel: "Send to Figma plugin" (clipboard + download fallback), "Export DTCG", install deep link, run/import disclosure, empty-state. |
src/components/pages/developer/FigmaSyncPanel.test.tsx |
Create | Component tests for the panel. |
src/components/pages/developer/DeveloperPage.tsx |
Modify | Render <FigmaSyncPanel /> in the header area. |
src/lib/featureFlags.ts |
Create | figmaSync flag read from import.meta.env. |
docs/integration/sync-to-figma.md |
Create | User-facing "Sync to Figma" page. |
docs/integration/_meta.yml, docs/changelog.md, CLAUDE.md, REBUILD_PLAN.md |
Modify | Doc impact (spec §11). |
e2e/figma-sync.spec.ts |
Create | Playwright scenarios (spec §10 E2E). |
Phase A — DTCG serializer (src/lib/figmaTokens.ts)
Pure, TDD, no Figma. This is the heart of the feature and the most testable part.
Task A1: Types + $type inference + primitive color/dimension/font grouping
Files:
Create:
src/lib/figmaTokens.tsCreate:
src/lib/figmaTokens.test.tsStep 1: Write the failing test. In
figmaTokens.test.ts:import { describe, it, expect } from 'vitest' import { tokenMapToDtcg } from '@/lib/figmaTokens' import type { TokenMap, DesignToken } from '@/tokens/types' function map(...tokens: DesignToken[]): TokenMap { return Object.fromEntries(tokens.map((t) => [t.id, t])) } const prim = (id: string, value: string, extra: Partial<DesignToken> = {}): DesignToken => ({ id, name: id, tier: 'primitive', value, ...extra }) describe('tokenMapToDtcg — primitives', () => { it('infers $type and buckets primitives by tier·type', () => { const { doc } = tokenMapToDtcg(map( prim('color-zinc-500', '#71717a'), prim('radius-base', '0.5rem'), prim('font-family-body', "'Inter', sans-serif"), )) expect(doc.Primitive.Color['color-zinc-500']).toEqual({ $type: 'color', $value: '#71717a' }) expect(doc.Primitive.Dimension['radius-base']).toEqual({ $type: 'dimension', $value: 8 }) expect(doc.Primitive.Font['font-family-body']).toEqual({ $type: 'fontFamily', $value: "'Inter', sans-serif" }) }) })Step 2: Run
npm run test -- figmaTokens— verify it fails (module doesn't exist).Step 3: Write minimal implementation. In
figmaTokens.ts:import type { TokenMap, DesignToken } from '@/tokens/types' export type DtcgType = 'color' | 'dimension' | 'fontFamily' | 'number' export interface DtcgToken { $type: DtcgType $value: string | number $extensions?: { tostada: { dark: string | number } } } /** doc[tier][type][tokenId] — or a nested group for typography facets. */ export type DtcgNode = DtcgToken | { [k: string]: DtcgNode } export type DtcgDoc = Record<string, Record<string, Record<string, DtcgNode>>> export interface DroppedRef { id: string; reason: string } export interface DtcgResult { doc: DtcgDoc; dropped: DroppedRef[] } const TIER_GROUP: Record<DesignToken['tier'], string> = { primitive: 'Primitive', semantic: 'Semantic', shadcn: 'Component', // renamed conceptually; code rename tracked separately } /** rem → px (×16); px/unitless → number; returns null if unparseable. */ function dimToPx(value: string): number | null { const m = /^(-?[\d.]+)(rem|px)?$/.exec(value.trim()) if (!m) return null const n = parseFloat(m[1]) return m[2] === 'rem' ? n * 16 : n } function inferType(id: string): DtcgType | null { if (id.startsWith('color-')) return 'color' if (id.startsWith('radius-') || id.startsWith('font-size-')) return 'dimension' if (id.startsWith('font-family-')) return 'fontFamily' return null } /** Which type-bucket a primitive id lives in. */ function primitiveBucket(type: DtcgType): string { if (type === 'color') return 'Color' if (type === 'dimension') return 'Dimension' return 'Font' // fontFamily } export function tokenMapToDtcg(tokens: TokenMap): DtcgResult { const doc: DtcgDoc = {} const dropped: DroppedRef[] = [] const put = (tier: string, type: string, id: string, node: DtcgNode) => { ;(doc[tier] ??= {})[type] ??= {} doc[tier][type][id] = node } for (const t of Object.values(tokens)) { if (t.tier !== 'primitive') continue const type = inferType(t.id) if (!type) continue const bucket = primitiveBucket(type) const $value = type === 'dimension' ? dimToPx(t.value) : t.value if ($value == null) { dropped.push({ id: t.id, reason: 'unparseable dimension' }); continue } put(TIER_GROUP.primitive, bucket, t.id, { $type: type, $value }) } return { doc, dropped } }Step 4: Run
npm run test -- figmaTokens— verify it passes.Step 5: Commit —
feat(figma): DTCG serializer — primitive color/dimension/font buckets.
Task A2: Semantic & component aliases ({…} references, never flattened)
Files: Modify src/lib/figmaTokens.ts, src/lib/figmaTokens.test.ts
- Step 1: Write the failing test.
const sem = (id: string, references: string, extra: Partial<DesignToken> = {}): DesignToken => ({ id, name: id, tier: 'semantic', value: '', references, ...extra }) const comp = (id: string, references: string): DesignToken => ({ id, name: id, tier: 'shadcn', value: '', references }) describe('tokenMapToDtcg — aliases', () => { it('emits cross-collection aliases for semantic and component tokens', () => { const { doc } = tokenMapToDtcg(map( prim('color-blue-500', '#1b19ff'), sem('bg-accent', 'color-blue-500'), comp('primary', 'bg-accent'), )) expect(doc.Semantic.Color['bg-accent'].$value).toBe('{Primitive.Color.color-blue-500}') expect(doc.Component.Color['primary'].$value).toBe('{Semantic.Color.bg-accent}') }) }) - Step 2: Run the test — verify it fails (semantic/component tokens not yet emitted).
- Step 3: Implement. Add an index of
id → {tier,type}location so aliases can be addressed, then a second pass for non-primitive, non-typography tokens. Add tofigmaTokens.ts:
In/** Path of a token's location, e.g. "Primitive.Color.color-blue-500". */ function locate(tokens: TokenMap, id: string): string | null { const t = tokens[id] if (!t) return null if (t.tier === 'primitive') { const type = inferType(id) return type ? `Primitive.${primitiveBucket(type)}.${id}` : null } // semantic / component colours live in <Tier>.Color (typography handled separately) if (typeof t.references === 'string') return `${TIER_GROUP[t.tier]}.Color.${id}` return null }tokenMapToDtcg, after the primitive pass add:for (const t of Object.values(tokens)) { if (t.tier === 'primitive') continue if (typeof t.references !== 'string') continue // typography handled in A4 const targetPath = locate(tokens, t.references) if (!targetPath) { // component tokens are alias-only — drop & report; semantic falls back to raw value if (t.tier === 'shadcn' || !t.value) { dropped.push({ id: t.id, reason: 'unresolved reference' }); continue } put(TIER_GROUP[t.tier], 'Color', t.id, { $type: 'color', $value: t.value }) continue } put(TIER_GROUP[t.tier], 'Color', t.id, { $type: 'color', $value: `{${targetPath}}` }) } - Step 4: Run the test — verify it passes.
- Step 5: Commit —
feat(figma): cross-collection alias emission for semantic/component tokens.
Task A3: Light/dark via $extensions.tostada.dark
Files: Modify src/lib/figmaTokens.ts, src/lib/figmaTokens.test.ts
- Step 1: Write the failing test.
describe('tokenMapToDtcg — dark mode', () => { it('carries darkReferences as a dark alias and darkValue as a dark raw value', () => { const { doc } = tokenMapToDtcg(map( prim('color-blue-400', '#5957ff'), prim('color-blue-500', '#1b19ff'), sem('bg-accent', 'color-blue-500', { darkReferences: 'color-blue-400' }), sem('fg-note', 'color-blue-500', { darkValue: '#abcdef' }), )) expect(doc.Semantic.Color['bg-accent'].$extensions) .toEqual({ tostada: { dark: '{Primitive.Color.color-blue-400}' } }) expect(doc.Semantic.Color['fg-note'].$extensions) .toEqual({ tostada: { dark: '#abcdef' } }) }) it('omits $extensions when a token has no dark value', () => { const { doc } = tokenMapToDtcg(map(prim('color-x', '#fff'), sem('fg-error', 'color-x'))) expect(doc.Semantic.Color['fg-error'].$extensions).toBeUndefined() }) }) - Step 2: Run the test — verify it fails.
- Step 3: Implement. In the semantic/component loop, after computing the light node, attach dark:
Setfunction darkExtension(tokens: TokenMap, t: DesignToken): DtcgToken['$extensions'] | undefined { if (t.darkReferences) { const p = locate(tokens, t.darkReferences) if (p) return { tostada: { dark: `{${p}}` } } } if (t.darkValue) return { tostada: { dark: t.darkValue } } return undefined }node.$extensions = darkExtension(tokens, t)only when defined (don't assignundefined— keep the key absent so the A3 "omits" test passes; e.g.const ext = darkExtension(...); if (ext) node.$extensions = ext). - Step 4: Run the test — verify it passes.
- Step 5: Commit —
feat(figma): light/dark via tostada $extensions mode key.
Task A4: Typography facet expansion + unit conversion
Files: Modify src/lib/figmaTokens.ts, src/lib/figmaTokens.test.ts
Reference the existing facet model in src/tokens/typographyVars.ts: a typography token references { family, size } and carries optional fontWeight, lineHeight, letterSpacing. Per spec §7: family→alias to Primitive·Font, size→alias to Primitive·Dimension, weight/line-height/letter-spacing→numbers; line-height px = sizePx × multiplier; letter-spacing em/%→px; textTransform/textDecoration NOT emitted.
- Step 1: Write the failing test.
const typo = (id: string, family: string, size: string, extra: Partial<DesignToken> = {}): DesignToken => ({ id, name: id, tier: 'semantic', value: '', references: { family, size }, ...extra }) describe('tokenMapToDtcg — typography facets', () => { it('expands a typography token into aliased family/size + numeric facets', () => { const { doc } = tokenMapToDtcg(map( prim('font-family-heading', "'Inter', sans-serif"), prim('font-size-2xl', '1.953125rem'), // ~31.25px typo('typography-heading', 'font-family-heading', 'font-size-2xl', { fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.02em' }), )) const g = doc.Semantic.Typography['typography-heading'] as Record<string, DtcgToken> expect(g.family).toEqual({ $type: 'fontFamily', $value: '{Primitive.Font.font-family-heading}' }) expect(g.size).toEqual({ $type: 'dimension', $value: '{Primitive.Dimension.font-size-2xl}' }) expect(g.weight).toEqual({ $type: 'number', $value: 600 }) expect(g['line-height'].$value).toBeCloseTo(37.5, 2) // 31.25 × 1.2 expect(g['letter-spacing'].$value).toBeCloseTo(-0.625, 3) // -0.02em × 31.25px }) }) - Step 2: Run the test — verify it fails.
- Step 3: Implement. Add conversion helpers and a typography pass:
Add the pass (skip tokens whose family/size primitive is missing — mirror/** letter-spacing → px, given the resolved font size in px. */ function letterSpacingToPx(value: string, sizePx: number): number | null { const m = /^(-?[\d.]+)(em|rem|px|%)?$/.exec(value.trim()) if (!m) return null const n = parseFloat(m[1]) switch (m[2]) { case 'em': return n * sizePx case '%': return (n / 100) * sizePx case 'rem': return n * 16 default: return n // px / unitless } } /** line-height multiplier or unit → px, given the resolved font size in px. */ function lineHeightToPx(lh: string | number, sizePx: number): number | null { if (typeof lh === 'number') return sizePx * lh const m = /^([\d.]+)(rem|px|%)?$/.exec(lh.trim()) if (!m) return null const n = parseFloat(m[1]) switch (m[2]) { case 'px': return n case 'rem': return n * 16 case '%': return (n / 100) * sizePx default: return sizePx * n // unitless multiplier } }emitTypographyVarsreturning empty):for (const t of Object.values(tokens)) { if (t.tier !== 'semantic' || typeof t.references === 'string' || !t.references) continue const fam = tokens[t.references.family] const size = tokens[t.references.size] if (!fam || !size) { dropped.push({ id: t.id, reason: 'missing typography family/size' }); continue } const sizePx = dimToPx(size.value) if (sizePx == null) { dropped.push({ id: t.id, reason: 'unparseable typography size' }); continue } const facets: Record<string, DtcgToken> = { family: { $type: 'fontFamily', $value: `{Primitive.Font.${fam.id}}` }, size: { $type: 'dimension', $value: `{Primitive.Dimension.${size.id}}` }, } if (t.fontWeight != null) facets.weight = { $type: 'number', $value: t.fontWeight } if (t.lineHeight != null) { const px = lineHeightToPx(t.lineHeight, sizePx) if (px != null) facets['line-height'] = { $type: 'number', $value: px } } if (t.letterSpacing) { const px = letterSpacingToPx(t.letterSpacing, sizePx) if (px != null) facets['letter-spacing'] = { $type: 'number', $value: px } } ;(doc.Semantic ??= {}).Typography ??= {} doc.Semantic.Typography[t.id] = facets } - Step 4: Run the test — verify it passes.
- Step 5: Commit —
feat(figma): typography facet expansion + unit conversion.
Task A5: Dropped-ref reporting & full-default smoke test
Files: Modify src/lib/figmaTokens.test.ts
- Step 1: Write the failing test.
import { DEFAULT_TOKENS } from '@/tokens/defaults' const defaults = Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])) describe('tokenMapToDtcg — reporting & defaults', () => { it('drops (not "") a component token whose reference is missing', () => { const { doc, dropped } = tokenMapToDtcg(map(comp('orphan', 'does-not-exist'))) expect(doc.Component?.Color?.['orphan']).toBeUndefined() expect(dropped).toContainEqual({ id: 'orphan', reason: 'unresolved reference' }) }) it('serializes the full default token set without throwing and emits all four collections', () => { const { doc } = tokenMapToDtcg(defaults) expect(Object.keys(doc.Primitive)).toEqual(expect.arrayContaining(['Color', 'Dimension', 'Font'])) expect(doc.Semantic.Color).toBeDefined() expect(doc.Semantic.Typography).toBeDefined() expect(doc.Component.Color).toBeDefined() }) }) - Step 2: Run — verify it fails or surfaces real gaps (e.g. a default color in
hsl()/rgb()form thatinferTypehandles but the plugin later can't). If a default value isn't hex, note it: the plugin's color parser (Task B2) must cover that form, or the serializer shoulddropped.pushit. Adjust the serializer to drop non-hex colors with reason'non-hex color'and assert they're reported, rather than emitting a value the plugin can't parse. - Step 3: Implement any fix the smoke test surfaces (most likely none beyond non-hex handling).
- Step 4: Run the test — verify it passes.
- Step 5: Commit —
test(figma): dropped-ref reporting + full-defaults smoke test.
Phase B — Rewrite the Figma plugin (figma-plugin/)
The plugin becomes a thin interpreter of the DTCG. Pure helpers are exported via a module.exports guard so src/lib/figmaPlugin.test.ts can require() and test them; the figma-mutating apply runs against a hand-rolled mock.
Task B1: Make the plugin testable + DTCG flattening to a write-plan
Files: Modify figma-plugin/code.js; Create src/lib/figmaPlugin.test.ts
The interpreter's pure core is buildWritePlan(dtcg): walk the DTCG into an ordered, resolved plan the apply step executes blindly. A plan entry:
{ collection: 'Semantic · Color', modes: ['Light','Dark'],
variables: [ { name, type:'COLOR'|'FLOAT'|'STRING',
values: { Light: {kind:'value'|'alias', ...}, Dark: {...} } } ] }
- Step 1: Write the failing test.
src/lib/figmaPlugin.test.ts:import { describe, it, expect } from 'vitest' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const plugin = require('../../figma-plugin/code.js') // exported via module.exports guard describe('plugin buildWritePlan', () => { it('flattens a DTCG doc into per-collection variable write plans', () => { const dtcg = { Primitive: { Color: { 'color-blue-500': { $type: 'color', $value: '#1b19ff' } } }, Semantic: { Color: { 'bg-accent': { $type: 'color', $value: '{Primitive.Color.color-blue-500}', $extensions: { tostada: { dark: '#5957ff' } } } } }, } const plan = plugin.buildWritePlan(dtcg) const prim = plan.find((c) => c.collection === 'Primitive · Color') const sem = plan.find((c) => c.collection === 'Semantic · Color') expect(prim.modes).toEqual(['Light']) expect(sem.modes).toEqual(['Light', 'Dark']) expect(sem.variables[0]).toMatchObject({ name: 'bg-accent', type: 'COLOR', values: { Light: { kind: 'alias', path: 'Primitive.Color.color-blue-500' }, Dark: { kind: 'value', value: '#5957ff' } }, }) }) }) - Step 2: Run
npm run test -- figmaPlugin— verify it fails. - Step 3: Implement. At the top of
code.js, guard the UI launch:
Replace the legacyif (typeof figma !== 'undefined' && figma.showUI) { figma.showUI(__html__, { width: 480, height: 560, title: 'Tostada' }) figma.ui.onmessage = onMessage }applyTokens/applyComponentswithonMessage+buildWritePlan+applyDtcg(next tasks). AddbuildWritePlan:
At the bottom ofvar COLLECTION_SEP = ' · ' function typeToResolved(t) { if (t === 'color') return 'COLOR' if (t === 'dimension' || t === 'number') return 'FLOAT' return 'STRING' // fontFamily } function isToken(node) { return node && node.$type !== undefined } function valueEntry(raw) { if (typeof raw === 'string' && raw.charAt(0) === '{' && raw.charAt(raw.length - 1) === '}') return { kind: 'alias', path: raw.slice(1, -1) } return { kind: 'value', value: raw } } // Walk one collection's token group (handles 1 level of nesting = typography facets). function collectVariables(group, prefix, out) { for (var key in group) { if (!Object.prototype.hasOwnProperty.call(group, key)) continue var node = group[key] var name = prefix ? prefix + '/' + key : key if (isToken(node)) { var values = { Light: valueEntry(node.$value) } var dark = node.$extensions && node.$extensions.tostada && node.$extensions.tostada.dark if (dark !== undefined) values.Dark = valueEntry(dark) out.push({ name: name, type: typeToResolved(node.$type), values: values }) } else { collectVariables(node, name, out) // nested group (typography facets) } } } function buildWritePlan(dtcg) { var plan = [] for (var tier in dtcg) { for (var type in dtcg[tier]) { var vars = [] collectVariables(dtcg[tier][type], '', vars) var hasDark = vars.some(function (v) { return v.values.Dark !== undefined }) plan.push({ collection: tier + COLLECTION_SEP + type, modes: hasDark ? ['Light', 'Dark'] : ['Light'], variables: vars }) } } return plan }code.js, export for tests (no-op in Figma):
(if (typeof module !== 'undefined' && module.exports) { module.exports = { buildWritePlan: buildWritePlan, hexToRgba: hexToRgba, applyDtcg: applyDtcg } }hexToRgba/applyDtcgland in B2/B3 — add them to the export then.) - Step 4: Run the test — verify it passes.
- Step 5: Commit —
feat(figma-plugin): DTCG → write-plan flattening + testable module.
Task B2: Color/dimension value conversion (hexToRgba)
Files: Modify figma-plugin/code.js, src/lib/figmaPlugin.test.ts
- Step 1: Write the failing test.
describe('plugin hexToRgba', () => { it('parses #rrggbb and #rrggbbaa into 0..1 channels', () => { expect(plugin.hexToRgba('#1b19ff')).toEqual({ r: 27/255, g: 25/255, b: 255/255, a: 1 }) expect(plugin.hexToRgba('#00000080').a).toBeCloseTo(128/255, 3) }) it('supports #rgb shorthand', () => { expect(plugin.hexToRgba('#fff')).toEqual({ r: 1, g: 1, b: 1, a: 1 }) }) }) - Step 2: Run — verify it fails.
- Step 3: Implement in
code.js:
Addfunction hexToRgba(hex) { var h = hex.replace('#', '') if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2] var r = parseInt(h.substring(0, 2), 16) / 255 var g = parseInt(h.substring(2, 4), 16) / 255 var b = parseInt(h.substring(4, 6), 16) / 255 var a = h.length >= 8 ? parseInt(h.substring(6, 8), 16) / 255 : 1 return { r: r, g: g, b: b, a: a } }hexToRgbato themodule.exportsobject. - Step 4: Run — verify it passes.
- Step 5: Commit —
feat(figma-plugin): hex → Figma RGBA conversion.
Task B3: applyDtcg — create/reuse collections, modes, variables, set values & aliases
Files: Modify figma-plugin/code.js, src/lib/figmaPlugin.test.ts
Two-pass apply: pass 1 creates every variable (so aliases can resolve to any collection); pass 2 sets values per mode, resolving {path} to the variable created for that path's leaf id. Match existing collections/variables by name → update in place (spec §7 "update in place", Flow 2).
- Step 1: Write the failing test with a minimal
figmamock:function mockFigma() { const collections: any[] = [] const variables: any[] = [] let ids = 0 return { _collections: collections, _variables: variables, variables: { getLocalVariableCollections: () => collections, getLocalVariables: () => variables, getVariableCollectionById: (id: string) => collections.find((c) => c.id === id) ?? null, getVariableById: (id: string) => variables.find((v) => v.id === id) ?? null, createVariableCollection: (name: string) => { const c: any = { id: 'c' + ids++, name, modes: [{ modeId: 'm' + ids++, name: 'Mode 1' }], renameMode(mid: string, n: string) { this.modes.find((m: any) => m.modeId === mid).name = n }, addMode(n: string) { const mid = 'm' + ids++; this.modes.push({ modeId: mid, name: n }); return mid } } collections.push(c); return c }, createVariable: (name: string, colId: string, type: string) => { const v: any = { id: 'v' + ids++, name, variableCollectionId: colId, resolvedType: type, valuesByMode: {} as Record<string, any>, setValueForMode(mid: string, val: any) { this.valuesByMode[mid] = val } } variables.push(v); return v }, createVariableAlias: (v: any) => ({ type: 'VARIABLE_ALIAS', id: v.id }), }, } } describe('plugin applyDtcg', () => { it('creates collections/modes/variables and wires a cross-collection alias', () => { const figma: any = mockFigma(); (globalThis as any).figma = figma const dtcg = { Primitive: { Color: { 'color-blue-500': { $type: 'color', $value: '#1b19ff' } } }, Semantic: { Color: { 'bg-accent': { $type: 'color', $value: '{Primitive.Color.color-blue-500}', $extensions: { tostada: { dark: '#5957ff' } } } } }, } const res = plugin.applyDtcg(dtcg) expect(res.created).toBe(2) const sem = figma._collections.find((c: any) => c.name === 'Semantic · Color') expect(sem.modes.map((m: any) => m.name)).toEqual(['Light', 'Dark']) const accent = figma._variables.find((v: any) => v.name === 'bg-accent') const light = sem.modes.find((m: any) => m.name === 'Light').modeId expect(accent.valuesByMode[light]).toEqual({ type: 'VARIABLE_ALIAS', id: figma._variables.find((v: any) => v.name === 'color-blue-500').id }) }) it('updates in place on re-run (no duplicates)', () => { const figma: any = mockFigma(); (globalThis as any).figma = figma const dtcg = { Primitive: { Color: { 'color-x': { $type: 'color', $value: '#ffffff' } } } } plugin.applyDtcg(dtcg); plugin.applyDtcg(dtcg) expect(figma._variables.filter((v: any) => v.name === 'color-x')).toHaveLength(1) }) }) - Step 2: Run — verify it fails.
- Step 3: Implement
applyDtcgincode.js:
Addfunction ensureCollection(name, modeNames) { var existing = figma.variables.getLocalVariableCollections().filter(function (c) { return c.name === name })[0] var col = existing || figma.variables.createVariableCollection(name) // first mode → modeNames[0] col.renameMode(col.modes[0].modeId, modeNames[0]) var modeIds = {}; modeIds[modeNames[0]] = col.modes[0].modeId for (var i = 1; i < modeNames.length; i++) { var found = col.modes.filter(function (m) { return m.name === modeNames[i] })[0] modeIds[modeNames[i]] = found ? found.modeId : col.addMode(modeNames[i]) } return { col: col, modeIds: modeIds } } function leafId(path) { var parts = path.split('.'); return parts[parts.length - 1] } function applyDtcg(dtcg) { var plan = buildWritePlan(dtcg) var created = 0, skipped = 0 var byName = {} // variable name (leaf id) → figma variable var planByCollection = {} // Pass 1 — collections, modes, variables for (var p = 0; p < plan.length; p++) { var entry = plan[p] var ensured = ensureCollection(entry.collection, entry.modes) planByCollection[entry.collection] = { ensured: ensured, entry: entry } for (var v = 0; v < entry.variables.length; v++) { var spec = entry.variables[v] var colId = ensured.col.id var existingVar = figma.variables.getLocalVariables().filter(function (lv) { return lv.variableCollectionId === colId && lv.name === spec.name })[0] var fvar = existingVar || figma.variables.createVariable(spec.name, colId, spec.type) if (!existingVar) created++ byName[leafId(spec.name)] = fvar } } // Pass 2 — values & aliases for (var cn in planByCollection) { var pc = planByCollection[cn] for (var vi = 0; vi < pc.entry.variables.length; vi++) { var s = pc.entry.variables[vi] var fv = byName[leafId(s.name)] for (var mi = 0; mi < pc.entry.modes.length; mi++) { var modeName = pc.entry.modes[mi] var ve = s.values[modeName] || s.values.Light // Dark inherits Light when absent var modeId = pc.ensured.modeIds[modeName] if (ve.kind === 'alias') { var target = byName[leafId(ve.path)] if (!target) { skipped++; continue } fv.setValueForMode(modeId, figma.variables.createVariableAlias(target)) } else { fv.setValueForMode(modeId, s.type === 'COLOR' ? hexToRgba(ve.value) : ve.value) } } } } return { created: created, skipped: skipped } }applyDtcgtomodule.exports. - Step 4: Run — verify it passes.
- Step 5: Commit —
feat(figma-plugin): applyDtcg — collections, modes, variables, aliases, update-in-place.
Task B4: Wire the message handler + rewrite ui.html
Files: Modify figma-plugin/code.js, figma-plugin/ui.html
- Step 1: Implement
onMessageincode.js(no unit test — thin glue, covered by manual QA):function onMessage(msg) { if (msg.type === 'apply-dtcg') { try { figma.ui.postMessage(Object.assign({ type: 'done' }, applyDtcg(msg.dtcg))) } catch (e) { figma.ui.postMessage({ type: 'error', message: String(e) }) } } else if (msg.type === 'close') { figma.closePlugin() } } - Step 2: Rewrite
ui.htmlto a single DTCG flow: a textarea, a "Paste from clipboard" button, an "Apply" button, an inline error onJSON.parsefailure, and a result line. On Apply:parent.postMessage({ pluginMessage: { type: 'apply-dtcg', dtcg: parsed } }, '*'). Ondone, showcreated / skipped. Remove the legacy tokens/components tabbed UI. (Mirror the existing markup/styles in the currentui.htmlfor consistency.) - Step 3: Manual sanity — load the plugin in Figma (Plugins → Development → Import plugin from manifest), paste a small DTCG by hand, confirm it builds a collection. (Full QA in Phase E.)
- Step 4: Commit —
feat(figma-plugin): single DTCG paste UI + apply-dtcg handler; drop legacy token/component UI.
Phase C — Export panel (FigmaSyncPanel) + wiring
Task C1: figmaSync feature flag
Files: Create src/lib/featureFlags.ts
- Step 1: Write the failing test
src/lib/featureFlags.test.ts:import { describe, it, expect } from 'vitest' import { isFigmaSyncEnabled } from '@/lib/featureFlags' describe('isFigmaSyncEnabled', () => { it('is false unless VITE_FIGMA_SYNC === "true"', () => { expect(isFigmaSyncEnabled({ VITE_FIGMA_SYNC: 'false' })).toBe(false) expect(isFigmaSyncEnabled({ VITE_FIGMA_SYNC: 'true' })).toBe(true) expect(isFigmaSyncEnabled({})).toBe(false) }) }) - Step 2: Run — verify it fails.
- Step 3: Implement.
export function isFigmaSyncEnabled(env: Record<string, string | undefined> = import.meta.env): boolean { return env.VITE_FIGMA_SYNC === 'true' } - Step 4: Run — verify it passes.
- Step 5: Commit —
feat(figma): figmaSync feature flag (default off).
Task C2: FigmaSyncPanel — actions, empty-state, clipboard + download
Files: Create src/components/pages/developer/FigmaSyncPanel.tsx, FigmaSyncPanel.test.tsx
Constants: PLUGIN_COMMUNITY_URL (placeholder until Phase E publishes; mark with a // TODO(publish) and a tracking note). The panel reads useStore.getState().tokens, serializes with tokenMapToDtcg, and JSON.stringify(doc, null, 2).
- Step 1: Write the failing test.
import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { FigmaSyncPanel } from './FigmaSyncPanel' import { useStore } from '@/store' import { DEFAULT_TOKENS } from '@/tokens/defaults' beforeEach(() => { useStore.setState({ tokens: Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])) } as any) Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }) }) describe('FigmaSyncPanel', () => { it('copies the DTCG to the clipboard on "Send to Figma plugin"', async () => { render(<FigmaSyncPanel />) fireEvent.click(screen.getByRole('button', { name: /send to figma plugin/i })) expect(navigator.clipboard.writeText).toHaveBeenCalledOnce() const json = (navigator.clipboard.writeText as any).mock.calls[0][0] expect(JSON.parse(json).Primitive.Color).toBeDefined() }) it('disables both actions and shows the empty copy when there are no tokens', () => { useStore.setState({ tokens: {} } as any) render(<FigmaSyncPanel />) expect(screen.getByText(/no tokens to sync yet/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /send to figma plugin/i })).toBeDisabled() }) }) - Step 2: Run — verify it fails.
- Step 3: Implement
FigmaSyncPanel.tsx: build the DTCG, "Send to Figma plugin" →navigator.clipboard.writeText(json)with acatchthat falls back todownloadBlob(reuse the helper pattern fromDeveloperPage), "Export DTCG" → always downloads<slug>-tokens.figma.json, empty-state disables both and renders "No tokens to sync yet." Match the inline-style idiom ofDeveloperPagefor visual consistency. Use the §6 ASCII layout as the structure. - Step 4: Run — verify it passes.
- Step 5: Commit —
feat(figma): FigmaSyncPanel — clipboard send + DTCG download + empty-state.
Task C3: Install deep-link + run/import disclosure
Files: Modify src/components/pages/developer/FigmaSyncPanel.tsx, FigmaSyncPanel.test.tsx
- Step 1: Write the failing test.
it('links to the plugin Community page and discloses run/import steps', () => { useStore.setState({ tokens: Object.fromEntries(DEFAULT_TOKENS.map((t) => [t.id, t])) } as any) render(<FigmaSyncPanel />) const link = screen.getByRole('link', { name: /install tostada variables/i }) expect(link).toHaveAttribute('href', expect.stringContaining('figma.com/community')) fireEvent.click(screen.getByRole('button', { name: /how to run/i })) expect(screen.getByText(/paste from clipboard/i)).toBeInTheDocument() }) - Step 2: Run — verify it fails.
- Step 3: Implement the install
<a href={PLUGIN_COMMUNITY_URL} target="_blank" rel="noreferrer">and a<details>/disclosure listing the steps from spec §4 Flow 1 (install once, run plugin, Paste from clipboard, Apply). - Step 4: Run — verify it passes.
- Step 5: Commit —
feat(figma): install deep-link + run/import disclosure.
Task C4: Render the panel on the Developer page behind the flag
Files: Modify src/components/pages/developer/DeveloperPage.tsx
- Step 1: Write the failing test (
DeveloperPage.test.tsx, or extend an existing one): withVITE_FIGMA_SYNCenabled, the page renders the "Send to Figma plugin" button; with it off, it does not. MockisFigmaSyncEnabledviavi.mock('@/lib/featureFlags', ...). - Step 2: Run — verify it fails.
- Step 3: Implement — import
FigmaSyncPanelandisFigmaSyncEnabled; render{isFigmaSyncEnabled() && <FigmaSyncPanel />}near the export button in the header. - Step 4: Run — verify it passes.
- Step 5: Commit —
feat(figma): surface FigmaSyncPanel on the Developer page (flagged).
Phase D — E2E + docs
Task D1: Playwright scenarios (spec §10 E2E)
Files: Create e2e/figma-sync.spec.ts
Enable the flag for the E2E run (set VITE_FIGMA_SYNC=true for the Playwright web server, or stub via the existing e2e setup). Scenarios:
- Happy path: click Send to Figma plugin → assert the clipboard (use Playwright's
page.evaluateto read a clipboard stub, or assert the download via Export DTCG instead) contains valid DTCG with the expected collection keys. - Empty token map → both actions disabled + "No tokens to sync yet".
- Install link points at the plugin's Community URL; the run/import disclosure expands.
- Step 1: Write the three scenarios following the patterns in e2e/.
- Step 2: Run
npm run e2e -- figma-sync(needslibnspr4/libnss3, see CLAUDE.md) — verify they pass. - Step 3: Commit —
test(figma): e2e — send/export/empty/install.
Task D2: Documentation (spec §11)
Files: Create docs/integration/sync-to-figma.md; Modify docs/integration/_meta.yml, docs/changelog.md, CLAUDE.md, REBUILD_PLAN.md
- Step 1: Write
docs/integration/sync-to-figma.md— what it does, the DTCG file, install the Tostada Variables plugin, run it, light/dark + aliases, the "any plan" note, and the deferred REST push. Add it to_meta.yml. - Step 2: Add a
docs/changelog.mdentry. - Step 3: Update
CLAUDE.md— notesrc/lib/figmaTokens.ts(pure DTCG serializer) and the rewrittenfigma-plugin/Tostada Variables plugin (DTCG interpreter), plus thefigmaSyncflag. - Step 4: Mark the Figma-sync milestone in
REBUILD_PLAN.md. - Step 5: Commit —
docs(figma): sync-to-figma integration page + CLAUDE/changelog/rebuild updates.
Phase E — Publish the plugin (manual, not TDD)
This phase is operational, not code. It unblocks the real
PLUGIN_COMMUNITY_URLplaceholder from Task C2.
- Step 1: In Figma, import the plugin from
figma-plugin/manifest.json(Plugins → Development → Import plugin from manifest). - Step 2: Run the full manual QA from spec §10: real token export → Paste from clipboard → Apply; verify collections, names, values, dark mode, and typography facets match Tostada; re-run after editing a token and confirm in-place updates with no duplicates.
- Step 3: Publish the plugin to the Figma Community; capture the public URL.
- Step 4: Replace
PLUGIN_COMMUNITY_URLinFigmaSyncPanel.tsxwith the real URL; update the docs link. Commit —chore(figma): wire published plugin Community URL. - Step 5: Flip
VITE_FIGMA_SYNCon in the target env once QA passes (spec §13 rollout).
Self-review checklist (done while writing)
- Spec coverage: DTCG format → A1–A4; one-collection-per-tier·type → A1/A2 + B1; aliases → A2 + B3; light/dark
$extensions→ A3 + B1/B3; typography facets + unit conversion → A4; component empty-value drop + reporting → A2/A5;$typeinference → A1; plugin rewrite parsing DTCG on any plan → B1–B4; update-in-place (Flow 2) → B3; export panel (Send/Export/empty/install/disclosure) → C2/C3; surface on Developer page → C4; feature flag + rollback → C1/C4; tests (unit/E2E/manual) → A*/B*/D1/E2; docs → D2; publish → E. Deferred items (REST/OAuth/generic-import/Text Styles) are intentionally absent. - Placeholder scan:
PLUGIN_COMMUNITY_URLis the one intentional placeholder, resolved in Phase E and flagged at its introduction (C2). No "TBD"/"handle edge cases"/"write tests for the above". - Type consistency:
tokenMapToDtcg(tokens) → { doc, dropped },DtcgToken { $type, $value, $extensions? }, pluginbuildWritePlan(dtcg) → PlanEntry[],applyDtcg(dtcg) → { created, skipped },hexToRgba(hex) → {r,g,b,a},isFigmaSyncEnabled(env?) → booleanare used consistently across tasks.
Open risks to watch during build
- Non-hex default colors. A5 forces this into the open: if any
DEFAULT_TOKENScolor isn't hex, either extendhexToRgba(B2) or drop+report in the serializer. Decide when the smoke test surfaces it; don't emit a value the plugin can't parse. - Figma line-height fidelity. Spec §9.6 accepts that baking
size × multiplierinto px means rescaling text in Figma won't reproportion line-height. This is intended — don't "fix" it. - Clipboard in tests/E2E. jsdom and Playwright both need a clipboard stub; the Export DTCG download path is the more robust assertion target if clipboard mocking proves flaky.