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 the TokenMap into our DTCG-based JSON (one group per tier·type, references as {…} aliases, dark mode via a $extensions.tostada.dark key, 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. A FigmaSyncPanel on 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 DesignToken map. Three tiers today — primitive, semantic, shadcn. The spec renames shadcncomponent conceptually; the codebase-wide rename is a separate task, so this code reads tier === 'shadcn' and emits the group name "Component".
  • references is string | { family, size }. Always narrow with typeof t.references === 'string' first (see CLAUDE.md). The { family, size } form is only on semantic typography tokens.
  • Component-tier (shadcn) tokens carry value: '' and a references string (e.g. primarybg-accent). Their reference is their only meaning — if it can't resolve, drop and report, never emit "" (spec §7, §8).
  • Token id prefixes drive $type inference: color-* → color, radius-*/font-size-* → dimension, font-family-* → fontFamily; typography facets weight/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.ts follows the same discipline (the spec places it in src/lib/ next to docs.ts; keep it pure regardless of folder).
  • Existing plugin (figma-plugin/code.js) is legacy: its applyTokens consumes a Figma-internal payload (variableCollections/variableModes/…), not DTCG, and applyComponents builds image frames. Both are replaced. The plugin is plain ES5-style JS (var/function) with a global figmano import/export (Figma runs it as a classic script, unbundled).
  • Test reach: Vitest only collects src/**/*.{test,spec}.{ts,tsx}. The plugin lives outside src/, so its test must live under src/ and require() the plugin file. To make that possible the plugin exposes its functions via a module.exports guard and guards its top-level figma.showUI call — 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.ts

  • Create: src/lib/figmaTokens.test.ts

  • Step 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: Commitfeat(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 to figmaTokens.ts:
    /** 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
    }
    
    In 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: Commitfeat(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:
    function 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
    }
    
    Set node.$extensions = darkExtension(tokens, t) only when defined (don't assign undefined — 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: Commitfeat(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:
    /** 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
      }
    }
    
    Add the pass (skip tokens whose family/size primitive is missing — mirror emitTypographyVars returning 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: Commitfeat(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 that inferType handles 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 should dropped.push it. 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: Committest(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:
    if (typeof figma !== 'undefined' && figma.showUI) {
      figma.showUI(__html__, { width: 480, height: 560, title: 'Tostada' })
      figma.ui.onmessage = onMessage
    }
    
    Replace the legacy applyTokens/applyComponents with onMessage + buildWritePlan + applyDtcg (next tasks). Add buildWritePlan:
    var 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
    }
    
    At the bottom of code.js, export for tests (no-op in Figma):
    if (typeof module !== 'undefined' && module.exports) {
      module.exports = { buildWritePlan: buildWritePlan, hexToRgba: hexToRgba, applyDtcg: applyDtcg }
    }
    
    (hexToRgba/applyDtcg land in B2/B3 — add them to the export then.)
  • Step 4: Run the test — verify it passes.
  • Step 5: Commitfeat(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:
    function 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 }
    }
    
    Add hexToRgba to the module.exports object.
  • Step 4: Run — verify it passes.
  • Step 5: Commitfeat(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 figma mock:
    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 applyDtcg in code.js:
    function 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 }
    }
    
    Add applyDtcg to module.exports.
  • Step 4: Run — verify it passes.
  • Step 5: Commitfeat(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 onMessage in code.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.html to a single DTCG flow: a textarea, a "Paste from clipboard" button, an "Apply" button, an inline error on JSON.parse failure, and a result line. On Apply: parent.postMessage({ pluginMessage: { type: 'apply-dtcg', dtcg: parsed } }, '*'). On done, show created / skipped. Remove the legacy tokens/components tabbed UI. (Mirror the existing markup/styles in the current ui.html for 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: Commitfeat(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: Commitfeat(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 a catch that falls back to downloadBlob (reuse the helper pattern from DeveloperPage), "Export DTCG" → always downloads <slug>-tokens.figma.json, empty-state disables both and renders "No tokens to sync yet." Match the inline-style idiom of DeveloperPage for visual consistency. Use the §6 ASCII layout as the structure.
  • Step 4: Run — verify it passes.
  • Step 5: Commitfeat(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: Commitfeat(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): with VITE_FIGMA_SYNC enabled, the page renders the "Send to Figma plugin" button; with it off, it does not. Mock isFigmaSyncEnabled via vi.mock('@/lib/featureFlags', ...).
  • Step 2: Run — verify it fails.
  • Step 3: Implement — import FigmaSyncPanel and isFigmaSyncEnabled; render {isFigmaSyncEnabled() && <FigmaSyncPanel />} near the export button in the header.
  • Step 4: Run — verify it passes.
  • Step 5: Commitfeat(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:

  1. Happy path: click Send to Figma plugin → assert the clipboard (use Playwright's page.evaluate to read a clipboard stub, or assert the download via Export DTCG instead) contains valid DTCG with the expected collection keys.
  2. Empty token map → both actions disabled + "No tokens to sync yet".
  3. 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 (needs libnspr4/libnss3, see CLAUDE.md) — verify they pass.
  • Step 3: Committest(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.md entry.
  • Step 3: Update CLAUDE.md — note src/lib/figmaTokens.ts (pure DTCG serializer) and the rewritten figma-plugin/ Tostada Variables plugin (DTCG interpreter), plus the figmaSync flag.
  • Step 4: Mark the Figma-sync milestone in REBUILD_PLAN.md.
  • Step 5: Commitdocs(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_URL placeholder 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_URL in FigmaSyncPanel.tsx with the real URL; update the docs link. Commit — chore(figma): wire published plugin Community URL.
  • Step 5: Flip VITE_FIGMA_SYNC on 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; $type inference → 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_URL is 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? }, plugin buildWritePlan(dtcg) → PlanEntry[], applyDtcg(dtcg) → { created, skipped }, hexToRgba(hex) → {r,g,b,a}, isFigmaSyncEnabled(env?) → boolean are used consistently across tasks.

Open risks to watch during build

  • Non-hex default colors. A5 forces this into the open: if any DEFAULT_TOKENS color isn't hex, either extend hexToRgba (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 × multiplier into 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.