---
name: tokens
description: Audits whether the colors on a Figma frame or page are bound to design system tokens, and maps and binds hardcoded colors to the right token. Derives token-to-hex from bindings already in the file so every mapping has evidence, grades the results, and auto-proposes only the safe ones. Lint mode (check only) and bind mode are separate. Which library and which token groups come from the design_system section of figma-conventions.yaml; on auto it detects the library the file is connected to. Triggers - "/fig:tokens", "check the token bindings", "find unbound colors", "토큰 바인딩 검수", "하드코딩 색상 검사", "디자인시스템 연동 점검".
allowed-tools: AskUserQuestion, mcp__plugin_figma_figma__use_figma, mcp__plugin_figma_figma__search_design_system, mcp__plugin_figma_figma__get_screenshot, mcp__plugin_figma_figma__get_metadata, mcp__plugin_figma_figma__whoami
---

# fig:tokens — audit and repair design system token bindings

Checks whether the colors inside a frame are actually bound to design system variables, and connects unbound hardcoded colors to a token through **a mapping that has evidence behind it**. Theming, rebranding, and consistency only hold if every color goes through a token, so this runs as the last check before handoff.

**Prerequisites**: always load the `figma:figma-use` skill before calling `use_figma`.

**Seat check before the first write** — call `whoami` once. Where every plan it lists carries `seat: View`, stop before any `use_figma` write and say so: the reading half of this skill runs on a View seat, the writing half needs an Edit seat on the file's plan, and no retry changes that. Where the seats are mixed, go ahead — and if the first write comes back as a permission error, report the seat table and stop rather than retrying.

## When to invoke

- "check whether this frame's colors are all bound", "find colors that aren't on a variable"
- "convert the hardcoded hex values to tokens"
- The token check right before handoff, once the design is done

## When NOT to invoke

- Frame naming and section tidying → `/fig:prep`
- Structure and flow rule violations → `/fig:lint`
- Flow arrows → `/fig:arrows`
- Creating the tokens (variables) themselves → `figma:figma-generate-library`
- Designing screens → `figma:figma-generate-design`

## Inputs

- `figma_url` (required): the page or frame to audit
- `mode` (optional): lint mode (check only) / bind. Omitted, it checks and proposes a mapping table

The rules come from `figma-conventions.yaml` — read the `design_system` section (library, token groups, thresholds) with `resolve-config.py --js <fileKey>`.

## Why this needs its own skill — tool constraints

- **`get_metadata` cannot see colors.** Metadata carries node id, type, coordinates, and size, with no fill, stroke, or effect color. On a large frame it also blows the token limit and fails wholesale. So **all color inspection and binding walks nodes directly through a `use_figma` script.**
- **`search_design_system` does not return a token's hex.** It returns the name, key, scope, and owning library. The actual color has to be worked out separately (see below).

## Where token values come from — auto-detection plus reverse-derivation (the core)

A mapping is only as trustworthy as your knowledge of "what color is this token, really". Two routes run together.

1. **Detect the connected library**: query the token groups in `design_system.token_prefixes` with `search_design_system` to get names and keys. `design_system.library` decides the target, and on `auto` it detects the library the file is connected to. **Never hardcode a library name in this document** — it differs per file. When `token_prefixes` is empty, use whatever prefixes appear in the reverse-derived map (route 2) as candidates.
2. **Reverse-derive token-to-hex from existing bindings (the most reliable)**: walk the colors in the same file that are *already bound to a variable* and build a `{token name → actual hex}` map. A bound paint carries both the token id in `boundVariables.color` and the last resolved color in `paint.color`. That shows exactly what color this file actually renders that token as, which makes it the primary evidence for any mapping.

> **Caution — a token's real value may differ from the hex you were given.** "Change this grey to `fill/primary`" can still mean `fill/primary` is slightly different from that grey (which is fine if tokenizing is the goal). Whenever the color changes, **the preview must say "this token is #AAA, so the screen becomes #BBB".**

## Properties checked

fill · stroke · text color (a TEXT node's fills) · effect color (an effect's color) are all checked. Different properties match different token scopes — a background fill against `FRAME_FILL`/`SHAPE_FILL`, a border against `STROKE`, text against `TEXT_FILL`. **Only map a property to a token whose scope fits it** — never bind a text color to a border token.

## Excluded (untouched in both audit and binding)

| Target | Why |
|---|---|
| Colors **inside a component instance** | They are instance overrides, fragile, and really a separate issue to be tokenized on the source component in the library. The instance node's *own* color (an override) is in scope; its *children* are not |
| Working scaffolding | Nodes matching the scaffolding name conventions in `naming` (`arrow_delimiter`, `label_prefix`, `state_chain_prefix`) and anything painted with `section_style` or `placeholder_style` — those are `/fig:arrows` and `/fig:prep` output, not design to tokenize |
| User-designated protected areas | Archives, templates, and the like |

Excluded items that turn up are **reported, never fixed** — colors inside instances in particular get grouped separately as "a component repair issue".

## Grading — only auto-propose what is safe

Each unbound color is compared against the nearest token of the same property and graded.

| Grade | Basis | Handling |
|---|---|---|
| 1 — exact or near match | Identical to the token's hex, or within `design_system.match_threshold_channel` per channel | Binding effectively does not change the color → **auto-propose the mapping** |
| 2 — color shifts | Visibly different from the nearest token (off-palette) | Snapping changes the color → **report only; the user decides** |
| 3 — no matching token | Far from every token, or a brand or special color | Binding not advised, report only |

The threshold is a baseline, not a verdict — for borderline colors, show the delta so the user can judge.

## Lint mode — check only (no writes)

For "just check" requests, or the survey step before repair. Read-only, report only:

- Unbound colors in scope **aggregated by hex** (property, count, representative locations), classified as inside-instance / scaffolding / directly editable
- Directly editable colors get the grade table plus a recommended token and delta
- Nothing is changed; proceeding requires a separate go into the procedure below

## Procedure

### 1. Build the token dictionary

- Get names and keys for the configured token groups via `search_design_system`
- Walk the target frames and build the **existing binding → {token name, actual hex}** reverse map
- Merge both into a `{token name, key, scope, hex}` dictionary — the evidence table behind every mapping

### 2. Scan for unbound colors (read-only)

- Walk the target frames and aggregate **SOLID and unbound** colors from fills, strokes, text, and effects, by hex
- Classify each node as inside-instance / scaffolding / directly editable (see the snippet below)
- For the directly editable ones, compare against the dictionary to derive grade, recommended token, and delta → the mapping table

### 3. Settle the mapping (user's choice)

- Present grade 1 as a table. **When one hex matches two tokens with different meanings equally** (white matching both a static white and a background token, say), do not pick — present the difference in meaning and let the user choose
- Grades 2 and 3 are reported only; the user decides whether to proceed
- Any mapping that changes the color says so explicitly

### 4. Bind (preview → go, split into steps)

- Show the settled mapping as a **preview** (targets, hex → token, counts, exclusions) and get a **go**
- With a lot to do, **split by frame or by color** — never write it all at once
- Import tokens with `importVariableByKeyAsync`, and bind only unbound paints whose hex matches exactly

### 5. Verify (required)

- **Re-scan after binding** and confirm "unbound remainder among the targeted, directly editable colors = 0"
- The count from the aggregation step and the number actually bound may differ — the traversal boundary for nodes inside instances can shift between calls, changing the classification (inferred). **Prove nothing was missed by the re-scan reaching zero**, and report the discrepancy honestly
- Report remaining inside-instance colors and grades 2–3 separately, as out of scope for this pass

## Implementation snippets

Color to hex, and the inside-instance test:

```js
function hex(c){const f=x=>Math.round(x*255).toString(16).padStart(2,"0").toUpperCase();return "#"+f(c.r)+f(c.g)+f(c.b);}
function insideInstance(n){let p=n.parent;while(p){if(p.type==="INSTANCE")return true;p=p.parent;}return false;}
```

Reverse-deriving token to hex from existing bindings:

```js
const map={}; // varId -> {name, hexes:{}, count}
for(const id of ROOT_IDS){
  const root=await figma.getNodeByIdAsync(id);
  for(const n of [root,...root.findAll(()=>true)]){
    for(const key of ["fills","strokes"]){
      const arr=n[key]; if(!Array.isArray(arr)) continue;
      for(const p of arr){
        if(p.type==="SOLID"&&p.visible!==false&&p.boundVariables&&p.boundVariables.color){
          const vid=p.boundVariables.color.id;
          if(!map[vid]){const v=await figma.variables.getVariableByIdAsync(vid);map[vid]={name:v?v.name:"?",hexes:{},count:0};}
          map[vid].count++; const h=hex(p.color); map[vid].hexes[h]=(map[vid].hexes[h]||0)+1;
        }
      }
    }
  }
}
return Object.values(map).map(e=>({name:e.name,count:e.count,hex:Object.keys(e.hexes).join(",")}));
```

Aggregating unbound colors (editable versus inside-instance):

```js
const agg={};
for(const id of ROOT_IDS){
  const root=await figma.getNodeByIdAsync(id);
  for(const n of [root,...root.findAll(()=>true)]){
    const ii=insideInstance(n);
    for(const key of ["fills","strokes"]){
      const arr=n[key]; if(!Array.isArray(arr)) continue;
      for(const p of arr){
        if(p.type!=="SOLID"||p.visible===false) continue;
        if(p.boundVariables&&p.boundVariables.color) continue;
        const k=key+"|"+hex(p.color);
        if(!agg[k]) agg[k]={prop:key,hex:hex(p.color),editable:0,inInstance:0,examples:[]};
        agg[k][ii?"inInstance":"editable"]++;
        if(agg[k].examples.length<4) agg[k].examples.push((ii?"⟨inst⟩":"")+n.name+" ["+n.type+"]");
      }
    }
  }
}
return Object.values(agg).sort((a,b)=>b.editable-a.editable);
```

> Text colors arrive in `fills` above, on TEXT nodes. Effect colors are checked the same way through `n.effects[].color`, and bound through the effect-specific API (check the `setBoundVariableForEffect` family in the d.ts).

Binding (exact hex match only, reassigning a new paint array):

```js
const V={ tokenA: await figma.variables.importVariableByKeyAsync("KEY_A"), /* ... */ };
const fillMap={"#FFFFFF":V.tokenA, /* hex: variable */ };
const strokeMap={/* ... */};
const counts={};
const root=await figma.getNodeByIdAsync(FRAME_ID);
for(const n of [root,...root.findAll(()=>true)]){
  if(insideInstance(n)) continue;                 // skip inside instances
  for(const [key,mapObj] of [["fills",fillMap],["strokes",strokeMap]]){
    const arr=n[key]; if(!Array.isArray(arr)||!arr.length) continue;
    let changed=false;
    const next=arr.map(p=>{
      if(p.type==="SOLID"&&p.visible!==false&&!(p.boundVariables&&p.boundVariables.color)){
        const v=mapObj[hex(p.color)];
        if(v){changed=true;counts[v.name]=(counts[v.name]||0)+1;return figma.variables.setBoundVariableForPaint(p,"color",v);}
      }
      return p;                                    // fills is read-only — build a new array with map
    });
    if(changed) n[key]=next;
  }
}
return {frame:FRAME_ID, counts};
```

Verification (confirm zero remaining):

```js
let editableLeft=0;
for(const id of ROOT_IDS){
  const root=await figma.getNodeByIdAsync(id);
  for(const n of [root,...root.findAll(()=>true)]){
    if(insideInstance(n)) continue;
    for(const key of ["fills","strokes"]){
      const arr=n[key]; if(!Array.isArray(arr)) continue;
      for(const p of arr){
        if(p.type==="SOLID"&&p.visible!==false&&!(p.boundVariables&&p.boundVariables.color)&&TARGET_HEXES.includes(hex(p.color))) editableLeft++;
      }
    }
  }
}
return {editableLeft};   // must be 0 to be done
```

## Constraints

- **Preview → go before writing.** Settling the mapping and running the binding are separate; binding is split by frame or by color
- **Never touch colors inside instances or scaffolding** — report only
- Only map where the property and the token scope agree (text color to a text token, and so on)
- Any mapping that changes the color says so in the preview — never assume the color is unchanged from the token's name
- Keep each call small, verifying between steps with a re-scan or a screenshot
- Never write "done" without verification — prove zero remaining with a re-scan

## Notes

- The primary evidence for a mapping is always **what value this file actually renders that token as** (the reverse-derived map). Never infer from how a token's name sounds
- "Lots of unbound colors" does not automatically mean somebody forgot — off-palette colors may simply have found their way in, so grades 2 and 3 go back to design judgement
- A mass of hardcoded colors inside instances is not this skill's scope; raise it separately as **a component library repair task**
