Figmaのデザイントークンを整理する | 生hex 3,977箇所を変数化し、コントラスト不合格をゼロにするまで

ChromeのClaudeにFigmaコネクタを追加した。繋いだ先にあったのは、病院内で薬剤を運ぶ搬送ロボットのUIデザイン一式だった。アプリ、タブレット、PCダッシュボード、スタイルガイド。合計で約5,000ノードある。

このファイルには変数(デザイントークン)が既に整備されていた。プリミティブ29個、セマンティックトークン20個、Light/Nightの2モード。設計としてはかなり整っている。

にもかかわらず、画面はそれを1つも使っていなかった。全部が生のhex値で組まれていた。

そこから、色を変数に繋ぎ直し、重複した色を潰し、コントラストの不足を直すまでの記録を残す。途中で自分の書いたスクリプトが45ノードを壊した話も含めて、全量で書く。

Figmaコネクタが読み取ったもの

コネクタを繋いで、対象フレームのURLを渡すと、get_design_context がリファレンスコードとスクリーンショットとメタデータを返してくる。返ってきたのは390×1048のアプリ画面だった。

返却されたコードは React + Tailwind の形をしている。ただしこれは「参照用」であって、そのまま使うものではない。重要なのは、この出力から実際に使われている色が全て生のhexで書かれていると分かることだった。

<div className="bg-[#f8f9fa] content-stretch flex flex-col items-start relative size-full" data-node-id="12580:28" data-name="Pharmacy Robot App">
  <div className="bg-white content-stretch drop-shadow-[0px_2px_4px_rgba(0,0,0,0.05)] flex flex-col gap-[16px] items-start px-[20px] py-[16px] relative shrink-0 w-full" data-node-id="12580:29" data-name="Header">
    <p className="[word-break:break-word] font-['Noto_Sans_JP:Bold'] font-bold leading-[24px] relative shrink-0 text-[#1a1a1a] text-[20px] whitespace-nowrap" data-node-id="12580:31">
      薬剤搬送ロボット
    </p>

bg-[#f8f9fa]、text-[#1a1a1a]。変数を参照していれば、ここには変数名が出る。出ていないということは、デザイン側で色が直接指定されているということだった。

この時点で「変数が無い」と判断したが、これは誤りだった。次の節でそれが分かる。

変数は存在していた。画面が使っていなかっただけだった

ファイル側の状態を実測するスクリプトを書いた。何かを変更する前に、まず現物を読む。

const collections = await figma.variables.getLocalVariableCollectionsAsync();
const colInfo = collections.map(c => ({
  name: c.name,
  id: c.id,
  varCount: c.variableIds.length,
  modes: c.modes.map(m => m.name)
}));

const pages = figma.root.children.map(p => ({ id: p.id, name: p.name }));

const node = await figma.getNodeByIdAsync("12580:28");
const nodeInfo = node ? {
  name: node.name,
  type: node.type,
  width: node.width,
  height: node.height,
  pageName: (() => { let n = node; while (n && n.type !== 'PAGE') n = n.parent; return n ? n.name : null; })()
} : null;

const localStyles = {
  paint: (await figma.getLocalPaintStylesAsync()).length,
  text: (await figma.getLocalTextStylesAsync()).length,
  effect: (await figma.getLocalEffectStylesAsync()).length
};

return { collections: colInfo, pages, nodeInfo, localStyles };

返ってきた実測値。

{
  "collections": [
    {"name":"Primitives(プリミティブ)","id":"VariableCollectionId:645:51019","varCount":29,"modes":["Mode 1"]},
    {"name":"Tokens(トークン)","id":"VariableCollectionId:1941:108641","varCount":20,"modes":["Light Mode","Night Mode"]},
    {"name":"html.to.design","id":"VariableCollectionId:5378:709","varCount":319,"modes":["Mode 1"]}
  ],
  "pages": [
    {"id":"0:1","name":"Pixels to Motors"},
    {"id":"12580:25","name":"テスト"},
    {"id":"1394:101746","name":"ハコブくん4コママンガ"},
    {"id":"4462:4","name":"サムネイル保管庫"}
  ],
  "nodeInfo": {"name":"Pharmacy Robot App","type":"FRAME","width":390,"height":1048,"pageName":"テスト"},
  "localStyles": {"paint":82,"text":146,"effect":0}
}

Primitives が29個、Tokens が20個。Tokensには Light Mode と Night Mode の2モードがある。中身を読むと、Tokens側は全てプリミティブへのエイリアスで構成されていた。

Surface/Default   → Light: Neutral/Off White  / Night: Neutral/Gray Dark
Surface/Container → Light: Neutral/White      / Night: Neutral/Black
Text/Primary      → Light: Neutral/Black      / Night: Neutral/White
Text/Secondary    → Light: Neutral/Gray Dark  / Night: Neutral/Gray Pale
Border/Default    → Light: Neutral/Gray Pale  / Night: Neutral/Gray Dark
Action/Primary/Bg → Light: Primary/Blue       / Night: Primary/Light Blue
Status/Success/Fg → Light: Success/Success    / Night: Success/Success Light

素の色を持つ層(プリミティブ)と、意味を持つ層(トークン)が分かれていて、後者が前者を参照する。教科書通りの2層構造だった。

つまり問題は「トークンが無い」ではなく「画面がトークンを使っていない」だった。作るのではなく、繋ぐ作業になる。

先にコントラストを測ると、4色が基準に届いていなかった

繋ぐ前に、そのまま繋いでいいのかを確認した。色をコントラスト比で測ると、4箇所がWCAG AA基準(通常テキストで4.5:1)に届いていない。

箇所

色

背景

比率

判定

「75%」

#4CAF50

白

2.78:1

不合格

受け取り完了ボタン

白

#4CAF50

2.78:1

不合格

ボトムナビ非アクティブ

#9E9E9E

白

2.68:1

不合格

「配送中」バッジ

#E65100

#FFF3E0

3.46:1

不合格

薬剤を運ぶロボットのUIなので、状態が読めないことは実務上まずい。ここは繋ぐ前に色そのものを直す。

暗い側の色を2つ追加し、既存のセマンティックトークンの参照先を差し替えた。

const hex = (s) => ({
  r: parseInt(s.slice(1,3),16)/255,
  g: parseInt(s.slice(3,5),16)/255,
  b: parseInt(s.slice(5,7),16)/255
});

const prim = await figma.variables.getVariableCollectionByIdAsync("VariableCollectionId:645:51019");
const tok  = await figma.variables.getVariableCollectionByIdAsync("VariableCollectionId:1941:108641");
const primMode = prim.modes[0].modeId;
const light = tok.modes.find(m => m.name === "Light Mode").modeId;
const night = tok.modes.find(m => m.name === "Night Mode").modeId;

const lookup = {};
for (const cid of [prim.variableIds, tok.variableIds].flat()) {
  const v = await figma.variables.getVariableByIdAsync(cid);
  lookup[v.name] = v;
}

const created = [];

const successDark = figma.variables.createVariable("Success/Success Dark", prim, "COLOR");
successDark.scopes = ["ALL_SCOPES"];
successDark.setValueForMode(primMode, hex("#2E7D32"));
created.push(successDark.id);

const deliveryDark = figma.variables.createVariable("Delivery/Delivery Dark", prim, "COLOR");
deliveryDark.scopes = ["ALL_SCOPES"];
deliveryDark.setValueForMode(primMode, hex("#BF360C"));
created.push(deliveryDark.id);

lookup["Status/Success/Fg"].setValueForMode(light, { type: "VARIABLE_ALIAS", id: successDark.id });
lookup["Status/Delivery/Fg"].setValueForMode(light, { type: "VARIABLE_ALIAS", id: deliveryDark.id });

const actSuccessBg = figma.variables.createVariable("Action/Success/Bg", tok, "COLOR");
actSuccessBg.scopes = ["FRAME_FILL", "SHAPE_FILL"];
actSuccessBg.setValueForMode(light, { type: "VARIABLE_ALIAS", id: successDark.id });
actSuccessBg.setValueForMode(night, { type: "VARIABLE_ALIAS", id: lookup["Success/Success"].id });
created.push(actSuccessBg.id);

const actSuccessFg = figma.variables.createVariable("Action/Success/Fg", tok, "COLOR");
actSuccessFg.scopes = ["TEXT_FILL"];
actSuccessFg.setValueForMode(light, { type: "VARIABLE_ALIAS", id: lookup["Neutral/White"].id });
actSuccessFg.setValueForMode(night, { type: "VARIABLE_ALIAS", id: lookup["Neutral/Black"].id });
created.push(actSuccessFg.id);

const navActive = figma.variables.createVariable("Nav/Active", tok, "COLOR");
navActive.scopes = ["TEXT_FILL", "SHAPE_FILL"];
navActive.setValueForMode(light, { type: "VARIABLE_ALIAS", id: lookup["Primary/Dark"].id });
navActive.setValueForMode(night, { type: "VARIABLE_ALIAS", id: lookup["Primary/Light Blue"].id });
created.push(navActive.id);

const navInactive = figma.variables.createVariable("Nav/Inactive", tok, "COLOR");
navInactive.scopes = ["TEXT_FILL", "SHAPE_FILL"];
navInactive.setValueForMode(light, { type: "VARIABLE_ALIAS", id: lookup["Neutral/Gray Medium"].id });
navInactive.setValueForMode(night, { type: "VARIABLE_ALIAS", id: lookup["Neutral/Gray Light"].id });
created.push(navInactive.id);

return {
  createdVariableIds: created,
  repointed: ["Status/Success/Fg", "Status/Delivery/Fg"],
  primitiveCount: prim.variableIds.length,
  tokenCount: tok.variableIds.length
};
{
  "createdVariableIds": ["VariableID:12582:2","VariableID:12582:3","VariableID:12582:4","VariableID:12582:5","VariableID:12582:6","VariableID:12582:7"],
  "repointed": ["Status/Success/Fg","Status/Delivery/Fg"],
  "primitiveCount": 31,
  "tokenCount": 24
}

変更後の比率。

箇所

変更

比率

「75%」

#4CAF50 → #2E7D32

2.78 → 5.13:1

受け取り完了ボタン

背景を #2E7D32 へ

2.78 → 5.13:1

ナビ非アクティブ

#9E9E9E → #757575

2.68 → 4.61:1

「配送中」バッジ

#E65100 → #BF360C

3.46 → 5.11:1

「Noto Sans JP SemiBold」は存在しなかった

テキストの色を変えようとしたとき、スクリプトが止まった。

Error: The font "Noto Sans JP SemiBold" could not be loaded.
Available styles for "Noto Sans JP": ["Black","Bold","DemiLight","Light","Medium","Regular","Thin"]

ファイルは SemiBold を参照しているが、その字形は環境に存在しない。フォールバックで表示されていただけだった。Figmaは欠落フォントのテキストノードを編集できないので、文字を変える前にフォントを実在するスタイルへ差し替える必要がある。

ページ内を数えると96ノードが該当した。

const page = await figma.getNodeByIdAsync("12580:25");
await figma.setCurrentPageAsync(page);

const texts = page.findAllWithCriteria({ types: ["TEXT"] });

const styleCount = {};
const semiBoldNodes = [];

for (const t of texts) {
  let segs;
  try { segs = t.getStyledTextSegments(["fontName"]); } catch (e) { continue; }
  let hit = false;
  for (const s of segs) {
    const key = `${s.fontName.family} ${s.fontName.style}`;
    styleCount[key] = (styleCount[key] || 0) + 1;
    if (s.fontName.style === "SemiBold") hit = true;
  }
  if (hit) semiBoldNodes.push(t.id);
}

return {
  totalTextNodes: texts.length,
  styleUsage: styleCount,
  semiBoldNodeCount: semiBoldNodes.length,
  sampleIds: semiBoldNodes.slice(0, 5)
};
{
  "totalTextNodes": 321,
  "styleUsage": {
    "Noto Sans JP Bold": 79,
    "Noto Sans JP Medium": 50,
    "Noto Sans JP SemiBold": 96,
    "Noto Sans JP Regular": 96
  },
  "semiBoldNodeCount": 96,
  "sampleIds": ["12580:49","12580:58","12580:71","12580:92","12580:99"]
}

96ノードを Bold に置換した。置換後の構成。

{
  "changedCount": 16,
  "errors": [],
  "finalStyleUsage": {
    "Noto Sans JP Bold": 175,
    "Noto Sans JP Medium": 50,
    "Noto Sans JP Regular": 96
  }
}

Bold が 79 から 175 に増え、SemiBold がゼロになった。ただしこれで見出し=Bold、小見出し=SemiBold、本文=Regular という3段階のうち中間が消えた。ウェイトの階層が2段階になったぶん、画面のメリハリは弱くなっている。フォントが実在しない以上、避けられない副作用だった。

hex完全一致で機械的に繋ぐと、見た目を変えずに生hexがゼロになる

ここからが本題の接続作業になる。方針は単純で、生のhex値と完全に一致する変数があれば、そこへ繋ぐ。値が同じなので見た目は1ピクセルも変わらない。変わるのは構造だけになる。

ただし繋ぎ先の選び方には条件がある。同じ #FFFFFF でも、テキストなら Text/Inverse、面なら Surface/Container を選ぶべきで、これは変数の scopes(その変数がどのプロパティに使えるか)で判定できる。

const page = await figma.getNodeByIdAsync("12580:25");
await figma.setCurrentPageAsync(page);

const toHex = (c) => '#' + [c.r,c.g,c.b].map(v => Math.round(v*255).toString(16).padStart(2,'0')).join('').toUpperCase();

const prim = await figma.variables.getVariableCollectionByIdAsync("VariableCollectionId:645:51019");
const tok  = await figma.variables.getVariableCollectionByIdAsync("VariableCollectionId:1941:108641");
const primMode = prim.modes[0].modeId;
const light = tok.modes.find(m => m.name === "Light Mode").modeId;

const primHex = {};
const cand = [];
for (const vid of prim.variableIds) {
  const v = await figma.variables.getVariableByIdAsync(vid);
  const val = v.valuesByMode[primMode];
  if (val && typeof val.r === "number") {
    primHex[v.id] = toHex(val);
    cand.push({ v, name: v.name, hex: toHex(val), scopes: v.scopes, layer: 0 });
  }
}
for (const vid of tok.variableIds) {
  const v = await figma.variables.getVariableByIdAsync(vid);
  const val = v.valuesByMode[light];
  const h = val && val.type === "VARIABLE_ALIAS" ? primHex[val.id] : (val && typeof val.r === "number" ? toHex(val) : null);
  if (h) cand.push({ v, name: v.name, hex: h, scopes: v.scopes, layer: 1 });
}

const score = (c, prop, isText) => {
  const s = c.scopes;
  const all = s.includes("ALL_SCOPES");
  if (prop === "strokes") {
    if (!all && !s.includes("STROKE_COLOR")) return -1;
  } else if (isText) {
    if (!all && !s.includes("TEXT_FILL")) return -1;
  } else {
    if (!all && !s.includes("FRAME_FILL") && !s.includes("SHAPE_FILL")) return -1;
  }
  let n = c.layer * 10 + (all ? 0 : 4);
  if (prop === "strokes" && c.name.startsWith("Border/")) n += 3;
  if (prop === "fills" && isText && (c.name.startsWith("Text/") || c.name.endsWith("/Fg"))) n += 3;
  if (prop === "fills" && !isText && (c.name.startsWith("Surface/") || c.name.endsWith("/Bg"))) n += 3;
  return n;
};

const cache = {};
const pick = (hex, prop, isText) => {
  const k = hex + prop + isText;
  if (cache[k] !== undefined) return cache[k];
  let best = null, bs = -1;
  for (const c of cand) {
    if (c.hex !== hex) continue;
    const s = score(c, prop, isText);
    if (s > bs) { bs = s; best = c; }
  }
  cache[k] = best;
  return best;
};

const tally = {};
let touched = 0, remaining = 0, unmatched = 0;
const errors = [];

for (const n of page.findAll(() => true)) {
  if (n.type === "SECTION") continue;
  let needs = false;
  for (const prop of ["fills","strokes"]) {
    const ps = n[prop];
    if (!Array.isArray(ps)) continue;
    for (const p of ps) {
      if (p.type === "SOLID" && p.visible !== false && p.color && !(p.boundVariables && p.boundVariables.color)) needs = true;
    }
  }
  if (!needs) continue;
  if (touched >= 120) { remaining++; continue; }

  try {
    for (const prop of ["fills","strokes"]) {
      const ps = n[prop];
      if (!Array.isArray(ps) || !ps.length) continue;
      const copy = JSON.parse(JSON.stringify(ps));
      let dirty = false;
      for (let i = 0; i < copy.length; i++) {
        const p = copy[i];
        if (p.type !== "SOLID" || p.visible === false || !p.color) continue;
        if (p.boundVariables && p.boundVariables.color) continue;
        const c = pick(toHex(p.color), prop, n.type === "TEXT");
        if (!c) { unmatched++; continue; }
        copy[i] = figma.variables.setBoundVariableForPaint(p, "color", c.v);
        tally[c.name] = (tally[c.name] || 0) + 1;
        dirty = true;
      }
      if (dirty) n[prop] = copy;
    }
    touched++;
  } catch (e) { errors.push(`${n.id}: ${e.message}`); }
}

return { touchedNodes: touched, remainingNodes: remaining, unmatched, distinctTokensUsed: Object.keys(tally).length, errors: errors.slice(0,5) };

layer * 10 でトークン層をプリミティブ層より優先し、スコープが具体的なものに +4、名前の役割が一致するものに +3 を足す。この重み付けで、同じ値を持つ複数の変数から適切な1つが選ばれる。

3バッチに分けて実行した結果。

{"touchedNodes":120,"remainingNodes":857,"unmatched":0,"distinctTokensUsed":20,"errors":[]}
{"touchedNodes":500,"remainingNodes":357,"unmatched":0,"errors":[]}
{"touchedPaintSets":376,"unmatched":0,"rawPaintsRemaining":0,"rawSample":[],
 "tally":{"Surface/Container":61,"Surface/Overlay":13,"Neutral/Gray Light":21,"Action/Primary/Bg":10,"Text/Inverse":6,"Text/Primary":79,"Neutral/Gray Pale":13,"Nav/Inactive":21,"Delivery/Delivery":5,"Primary/Blue Deep":23,"Action/Secondary/Bg":3,"Action/Secondary/Fg":3,"Border/Strong":33,"Border/Default":11,"Surface/Default":4,"Neutral/Gray Slate":27,"Text/Secondary":7,"Nav/Active":3,"Background/Background3":1,"Neutral/Gray Faint":8,"Neutral/Gray Lighter":16,"Warning/Warning":4,"Status/Warning/Fg":1,"Primary/Blue":3},
 "errors":[]}

最終確認。

{
  "boundPaints": 1170,
  "rawPaints": 0,
  "coverage": "100%",
  "distinctVariables": 38,
  "top": [["Text/Primary",175],["Surface/Container",170],["Nav/Inactive",105],["Border/Strong",95],["Neutral/Gray Medium",65],["Primary/Blue Deep",57],["Status/Success/Fg",51],["Neutral/Gray Light",51],["Neutral/Gray Slate",48],["Border/Default",41],["Surface/Overlay",34],["Neutral/Gray Lighter",33]]
}

生hexがゼロになった。ここまでは順調だった。

同じ「配送中」が4つの変数に割れていた

機械的な一致で繋ぐと、見た目は保たれる。しかし意味は揃わない。

「配送中」というタグの文字色が、画面によって別の変数を参照している状態が見つかった。ある画面では Status/Delivery/Fg(#BF360C)、別の画面では Delivery/Delivery(#E65100)。色まで違う。

原因ははっきりしていた。先に Status/Delivery/Fg を #BF360C に変えていたので、生の #E65100 だったテキストは完全一致せず、プリミティブの Delivery/Delivery に落ちていた。hex一致という基準を使う以上、避けられない副作用だった。

同じ役割が別の変数を使っている箇所を探す監査を書いた。

const page = await figma.getNodeByIdAsync("12580:25");
await figma.setCurrentPageAsync(page);

const nameCache = {};
const getName = async (id) => {
  if (!nameCache[id]) {
    const v = await figma.variables.getVariableByIdAsync(id);
    nameCache[id] = v ? v.name : id;
  }
  return nameCache[id];
};

const boundOf = async (n, prop) => {
  const ps = n[prop];
  if (!Array.isArray(ps) || !ps.length) return null;
  const p = ps[0];
  if (p.type !== "SOLID" || !p.boundVariables || !p.boundVariables.color) return null;
  return await getName(p.boundVariables.color.id);
};

const byLabel = {};
for (const t of page.findAllWithCriteria({ types: ["TEXT"] })) {
  const key = (t.characters || "").trim();
  if (!key || key.length > 12) continue;
  const v = await boundOf(t, "fills");
  if (!v) continue;
  if (!byLabel[key]) byLabel[key] = {};
  byLabel[key][v] = (byLabel[key][v] || 0) + 1;
}

const byFrame = {};
for (const f of page.findAllWithCriteria({ types: ["FRAME"] })) {
  if (!/Badge|Status|Button|Tab|Chip/i.test(f.name)) continue;
  const v = await boundOf(f, "fills");
  if (!v) continue;
  if (!byFrame[f.name]) byFrame[f.name] = {};
  byFrame[f.name][v] = (byFrame[f.name][v] || 0) + 1;
}

const conflicts = (obj) => Object.entries(obj)
  .filter(([, m]) => Object.keys(m).length > 1)
  .map(([k, m]) => ({ key: k, variables: m }));

const tok = await figma.variables.getVariableCollectionByIdAsync("VariableCollectionId:1941:108641");
const tokenNames = new Set();
for (const vid of tok.variableIds) tokenNames.add((await figma.variables.getVariableByIdAsync(vid)).name);

let toToken = 0, toPrimitive = 0;
for (const n of page.findAll(() => true)) {
  if (n.type === "SECTION") continue;
  for (const prop of ["fills","strokes"]) {
    const ps = n[prop]; if (!Array.isArray(ps)) continue;
    for (const p of ps) {
      if (p.type !== "SOLID" || !p.boundVariables || !p.boundVariables.color) continue;
      const nm = await getName(p.boundVariables.color.id);
      if (tokenNames.has(nm)) toToken++; else toPrimitive++;
    }
  }
}

return {
  labelConflicts: conflicts(byLabel),
  frameConflicts: conflicts(byFrame),
  layerUsage: { semanticToken: toToken, primitiveDirect: toPrimitive }
};

結果の一部。

{
  "labelConflicts": [
    {"key":"配送中","variables":{"Text/Primary":2,"Status/Delivery/Fg":1,"Nav/Inactive":3,"Delivery/Delivery":3,"Status/Success/Fg":1}},
    {"key":"キャンセル","variables":{"Text/Secondary":2,"Nav/Inactive":2,"Warning/Warning Dark":1}},
    {"key":"完了","variables":{"Nav/Inactive":3,"Status/Success/Fg":5}},
    {"key":"ホーム","variables":{"Nav/Active":1,"Neutral/Gray Light":3,"Action/Secondary/Fg":1}}
  ],
  "frameConflicts": [
    {"key":"Status Badge","variables":{"Status/Delivery/Bg":2,"Action/Secondary/Bg":1}}
  ],
  "layerUsage": {"semanticToken":776,"primitiveDirect":394}
}

1,170のうち394がプリミティブ直参照だった。セマンティック層を飛ばしているので、意味が表現できていない状態になる。

ただし監査結果を読むときに注意が要る。「配送中」が4つの変数に割れていると出るが、実際に確認すると3つは正しい使い分けだった。

文脈

変数

役割

セクション見出しの「配送中」

Text/Primary

ただの見出し

バッジの「配送中」

Status/Delivery/Fg

状態表示

統計の項目名「配送中」

Nav/Inactive

数値に付く補助ラベル

同じ言葉でも役割が違えば色が違うのは正しい。機械的な一致検査は「割れている」としか言えないので、1件ずつ何を指しているか見る必要があった。実際に誤りだったのは、ロボット一覧の「配送中」が1号機は緑、2号機はグレーになっていた箇所だけだった。

フレーム名の部分一致で45ノードを壊した

ここで自分のスクリプトが事故を起こした。

「待機中」というステータスを青からグレーに移す作業で、対象を絞る条件をこう書いた。

for (const f of root.findAll(n => n.type === "FRAME" && /badge|chip|status/i.test(n.name))) {
  const ts = f.findAllWithCriteria({ types: ["TEXT"] });
  if (!ts.length) continue;
  const hit = ts.some(t => PENDING.test(t.characters || ""));
  if (!hit) continue;
  if (bind(f, "fills", byName["Status/Pending/Bg"])) badges++;
  for (const t of ts) if (bind(t, "fills", byName["Status/Pending/Fg"])) texts++;
}

/badge|chip|status/i がフレーム名に部分一致するものを対象にしている。ここに Delivery Status Screen という画面全体のフレームが該当した。その中に「待機中」というテキストが1つでもあれば、findAllWithCriteria が拾う画面内の全テキストが Pending トークンで塗り潰される。

被害は45ノード。「詳細を見る」ボタン、統計の数値、ナビゲーションのラベル、配送IDまで巻き込まれた。

さらに悪いことに、気づいてロールバックしたときに一律 Text/Primary へ戻してしまった。この時点で元の色情報が失われた。「75%」が緑だったのか、「配送ID」がグレーだったのかが、ノードからは読めなくなった。

{
  "pendingRollback": [
    {"text":"配送状況","to":"Text/Primary"},
    {"text":"配送中","to":"Text/Primary"},
    {"text":"2","to":"Text/Inverse"},
    {"text":"配送ID: #D2024","to":"Text/Primary"},
    {"text":"3階 302号室","to":"Text/Primary"},
    {"text":"到着予定: 14:25 ","to":"Text/Primary"},
    {"text":"出発完了","to":"Text/Primary"},
    {"text":"75%","to":"Text/Primary"},
    {"text":"追跡","to":"Text/Primary"},
    {"text":"詳細を見る","to":"Text/Inverse"}
  ],
  "textTokenMisuse": 7
}

救いは、この画面と同一内容のコピーが別ページに存在したことだった。そちらは無傷だったので、参照元として使えた。

正しいトークン割り当てを、テキスト内容・フォントサイズ・親フレーム名の3つをキーにして抽出する。

const page = await figma.getNodeByIdAsync("12580:25");
await figma.setCurrentPageAsync(page);
const nameCache = {};
const getName = async (id) => { if(!nameCache[id]){const v=await figma.variables.getVariableByIdAsync(id); nameCache[id]=v?v.name:id;} return nameCache[id]; };

const ref = {};
for (const t of page.findAllWithCriteria({ types: ["TEXT"] })) {
  const ps = t.fills;
  if (!Array.isArray(ps) || !ps.length) continue;
  const q = ps[ps.length-1];
  if (q.type !== "SOLID" || !q.boundVariables || !q.boundVariables.color) continue;
  const v = await getName(q.boundVariables.color.id);
  const key = `${(t.characters||"").trim()}|${Math.round(t.fontSize)}|${t.parent ? t.parent.name : ""}`;
  if (!ref[key]) ref[key] = {};
  ref[key][v] = (ref[key][v] || 0) + 1;
}
const map = {};
for (const [k, m] of Object.entries(ref)) {
  map[k] = Object.entries(m).sort((a,b)=>b[1]-a[1])[0][0];
}
return { entries: Object.keys(map).length, map };

282件の対応表が取れた。抜粋。

配送中|13|Status Badge          → Status/Delivery/Fg
75%|16|Progress Text            → Status/Success/Fg
配送ID: #D2024-0415|14|Delivery Info → Nav/Inactive
詳細を見る|14|Details Button      → Text/Inverse
待機中|13|Status Badge           → Status/Pending/Fg
完了: 今日 13:45|12|Time Stamp    → Text/Muted

これを壊れたページに適用して130箇所を復元した。

{
  "applied": 130,
  "hits": {"Text/Primary":52,"Text/Muted":30,"Status/Delivery/Fg":8,"Status/Success/Fg":13,"Text/Secondary":3,"Text/Inverse":10,"Status/Warning/Fg":3,"Action/Secondary/Fg":10,"Status/Pending/Fg":1}
}

参照先が無ければ手作業になっていた。一括処理の対象を「フレーム名の部分一致」で絞ったのが原因なので、以降はテキスト内容と構造で絞るように変えた。

重複と衝突は別の問題だった

作業が一巡した時点で、トークンは35個になっていた。「多すぎないか」を判断するために、同じ値に解決されるトークンを機械的に洗い出した。

const sig = {};
const tokens = [];
for (const vid of tok.variableIds) {
  const v = await figma.variables.getVariableByIdAsync(vid);
  const l = res(v.valuesByMode[light]), n = res(v.valuesByMode[night]);
  tokens.push({ name: v.name, light: l, night: n });
  const k = l + "/" + n;
  (sig[k] = sig[k] || []).push(v.name);
}
const dupes = Object.entries(sig).filter(([,a]) => a.length > 1)
  .map(([k,a]) => ({ value: k, tokens: a }));
{
  "duplicateValues": [
    {"value":"#FFFFFF/#1A1A1A","tokens":["Surface/Container","Action/Primary/Fg","Text/Inverse","Action/Success/Fg","Icon/Inverse"]},
    {"value":"#1A1A1A/#FFFFFF","tokens":["Text/Primary","Icon/Strong"]},
    {"value":"#E3F2FD/#0277BD","tokens":["Action/Secondary/Bg","Status/Pending/Bg"]},
    {"value":"#2E7D32/#81C784","tokens":["Status/Success/Fg","Status/Success/Solid"]},
    {"value":"#E53935/#FFEBEE","tokens":["Status/Warning/Fg","Status/Warning/Solid"]},
    {"value":"#BF360C/#FFF3E0","tokens":["Status/Delivery/Fg","Status/Delivery/Solid"]},
    {"value":"#1565C0/#4FC3F7","tokens":["Nav/Active","Status/Pending/Fg"]}
  ]
}

35個のトークンが実質25色しか表現していない。だが、この重複には残すべきものと潰すべきものがあった。

Text/Inverse と Surface/Container はどちらも白だが、軸が違う(文字か面か)。将来テーマを増やしたときに別々に動く。これは設計として正しい重複で、潰すと表現力が落ちる。

一方 Icon/Strong は Text/Primary と完全に同じだった。このデザインではアイコンと文字が常に同色なので、アイコン用の軸を分けた判断自体が過剰だった。これは潰すべき重複になる。

そして本当の問題は、重複ではなく衝突のほうにあった。

色

意味1

意味2

#1565C0

Nav/Active(現在地)

Status/Pending/Fg(待機中)

#E3F2FD

Action/Secondary/Bg(押せる)

Status/Pending/Bg(待機中)

青が「押せる」「現在地」「待機中」の3役を兼ねていた。画面を見た人は、青いものが操作対象なのか状態表示なのか区別できない。

これはトークンを減らして解決する問題ではない。逆に分けるべき箇所だった。「待機中」を青から外してグレー系に移し、青は操作と現在地に残した。

同じ構造の問題は青の3色にもあった。

色

用途

白文字とのコントラスト

#2196F3

Action/Primary/Bg(「詳細を見る」ボタン)

3.12:1

#1976D2

混入色 57箇所

4.60:1

#1565C0

Nav/Active

5.75:1

一番明るい #2196F3 が一番使われていて、しかも一番読みにくい。#1565C0 に統合したことで3色が1色になり、ボタンのコントラストも同時に直った。

整理の結果、トークンは35個から30個に減り、色は19色になった。トークンは増えて色は減る。意味の種類を細かく定義するほど、同じ色を使い回せるようになる。

残りのページに同じ処理をかけると、35色が17色になった

テストページで確立した手順を、本体のページにある4つのセクション(スタイルガイド、タブレットUI、アプリUI、PCダッシュボード)へ適用した。合計約4,700ノード、生hex 2,951箇所。

事前調査で、既に変数が入っている900箇所が全てプリミティブ直参照だと分かった。セマンティック層を経由していないので、トークンの値を変えても反映されない状態になっていた。

パレットに無い色も7つ出てきた。

{
  "distinctRawColors": 35,
  "totalRawPaints": 2951,
  "exactMatches": 28,
  "needsDecision": [
    {"hex":"#757575","count":454,"match":"Neutral/Gray Slate #6B7280","dist":15},
    {"hex":"#FF9800","count":56,"match":"Accent/Amber #FFB300","dist":27},
    {"hex":"#000000","count":6,"match":"Neutral/Black #1A1A1A","dist":45},
    {"hex":"#EDEDED","count":4,"match":"Neutral/Gray Faint #F0F0F0","dist":5},
    {"hex":"#7B1FA2","count":4,"match":"Neutral/Gray Slate #6B7280","dist":91},
    {"hex":"#F57C00","count":4,"match":"Delivery/Delivery #E65100","dist":46},
    {"hex":"#F3E5F5","count":2,"match":"Neutral/Gray Faint #F0F0F0","dist":12}
  ]
}

#FF9800 の正体を調べると、PCダッシュボードのステータスドット、タイムラインアイコン、「配送中」テキストに使われていた。つまり #E65100 と同じ意味を、別の色で表現していた箇所だった。白背景でのコントラストは 2.16:1 で、テキストとしては極めて読みにくい。

紫の #7B1FA2 はもっと分かりやすかった。タブレットUIの「Card 5」に使われていたが、同じカードの別コピーでは橙(#FFF3E0)だった。意図的な色分けではなく、コピー間で崩れた残骸だった。橙に揃えることで紫が消えた。

用途別に振り分けるマッピングを書いた。

const roleMap = (hex, prop, isText) => {
  const T = isText && prop === "fills";
  const solidFill = prop === "fills" && !isText;
  switch (hex) {
    case "#FF9800": case "#F57C00": case "#E65100": case "#BF360C": case "#7B1FA2":
      return "Status/Delivery/Fg";
    case "#FFF3E0": case "#F3E5F5": return "Status/Delivery/Bg";
    case "#4CAF50": case "#2E7D32": case "#81C784": return "Status/Success/Fg";
    case "#E8F5E9": return "Status/Success/Bg";
    case "#E53935": case "#C62828": return "Status/Warning/Fg";
    case "#FFEBEE": return "Status/Warning/Bg";
    case "#2196F3": case "#1976D2": case "#1565C0": case "#0277BD": case "#03A9F4":
    case "#4FC3F7":
      return solidFill ? "Action/Primary/Bg" : "Action/Secondary/Fg";
    case "#E3F2FD": case "#E1F5FE": case "#BBDEFB": case "#90CAF9": case "#B3E5FC":
    case "#BDE8FB":
      return "Action/Secondary/Bg";
    case "#FFFFFF": return solidFill ? "Surface/Container" : "Text/Inverse";
    case "#1A1A1A": case "#000000": return "Text/Primary";
    case "#424242": return "Text/Secondary";
    case "#757575": case "#6B6B6B": case "#6B7280": case "#9E9E9E":
      return T ? "Text/Muted" : "Icon/Default";
    case "#BDBDBD": return "Icon/Subtle";
    case "#E0E0E0": case "#F0F0F0": case "#EDEDED": return "Border/Default";
    case "#F5F5F5": return "Surface/Overlay";
    case "#F8F9FA": return "Surface/Default";
    case "#FFB300": case "#FFB74D": return "Rating/Active";
  }
  return null;
};

3バッチで全ノードを処理した結果。

{"touched":700,"remaining":3002,"unmapped":{"#4FC3F7":5,"#BBDEFB":1,"#90CAF9":1,"#B3E5FC":1}}
{"touched":1600,"remaining":1410,"unmapped":{"#BDE8FB":4}}
{"touched":1414,"remaining":0,"unmapped":{}}

最終状態。

{
  "boundPaints": 3843,
  "rawPaints": 0,
  "textChecked": 1314,
  "distinctColors": 17,
  "palette": [["#6B6B6B",917],["#FFFFFF",621],["#1565C0",508],["#1A1A1A",506],["#2E7D32",313],["#E0E0E0",201],["#BF360C",159],["#424242",131],["#F5F5F5",104],["#BDBDBD",80],["#F8F9FA",80],["#C62828",75],["#E3F2FD",63],["#E8F5E9",38],["#FFF3E0",31],["#FFB300",9],["#FFEBEE",7]]
}

35色が17色になった。

コントラストは、1色を変えるだけで33件直った

全テキストのコントラストを測る監査を書いた。各テキストノードから親をたどって最初に不透明な塗りを持つ祖先を探し、その色を背景とみなして比率を出す。

const lum = (c) => {
  const f = (v) => v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4);
  return 0.2126*f(c.r) + 0.7152*f(c.g) + 0.0722*f(c.b);
};
const ratio = (a,b) => { const L1=lum(a), L2=lum(b); const hi=Math.max(L1,L2), lo=Math.min(L1,L2); return (hi+0.05)/(lo+0.05); };
const solidFill = (n) => {
  const ps = n.fills;
  if (!Array.isArray(ps)) return null;
  for (let i = ps.length-1; i >= 0; i--) if (ps[i].type === "SOLID" && ps[i].visible !== false && (ps[i].opacity ?? 1) > 0.9) return ps[i].color;
  return null;
};

const fails = []; let checked = 0;
for (const t of page.findAllWithCriteria({ types: ["TEXT"] })) {
  const fg = solidFill(t);
  if (!fg) continue;
  let p = t.parent, bg = null;
  while (p && p.type !== "PAGE") { const c = solidFill(p); if (c) { bg = c; break; } p = p.parent; }
  if (!bg) continue;
  const r = ratio(fg, bg);
  const bold = /Bold|Black|Medium/i.test(t.fontName && t.fontName.style ? t.fontName.style : "");
  const large = t.fontSize >= 24 || (t.fontSize >= 18.66 && bold);
  const need = large ? 3.0 : 4.5;
  checked++;
  if (r < need) fails.push({ text: (t.characters||"").slice(0,14), size: t.fontSize, fg: toHex(fg), bg: toHex(bg), ratio: Math.round(r*100)/100, need });
}

321件を測って33件が不合格だった。全て 4.03〜4.37:1 の僅差で、原因は2つに集約された。

{
  "textNodesChecked": 321,
  "failures": [
    {"text":"2分前","size":11,"fg":"#757575","bg":"#E3F2FD","ratio":4.03,"need":4.5},
    {"text":"完了","size":13,"fg":"#757575","bg":"#F5F5F5","ratio":4.23,"need":4.5},
    {"text":"キャンセル","size":12,"fg":"#E53935","bg":"#FFEBEE","ratio":3.7,"need":4.5},
    {"text":"配送ID: #D2024-0","size":13,"fg":"#757575","bg":"#F5F5F5","ratio":4.23,"need":4.5},
    {"text":"ご利用ありがとうございました","size":14,"fg":"#757575","bg":"#F8F9FA","ratio":4.37,"need":4.5},
    {"text":"ロボット2号機が配送中です","size":14,"fg":"#757575","bg":"#FFF3E0","ratio":4.2,"need":4.5},
    {"text":"中止","size":16,"fg":"#E53935","bg":"#FFFFFF","ratio":4.23,"need":4.5}
  ]
}

#757575 は白背景では 4.61:1 で合格していた。ところが #F5F5F5 や #E3F2FD のような淡い色の上に乗ると 4.03 まで落ちる。カード内や通知内で使われているので、実際には多くが不合格だった。白でだけ測っていたら見逃していた。

修正はトークンの参照先を変えるだけで済んだ。

byName["Neutral/Gray Medium"].setValueForMode(primMode, { r: 0x6B/255, g: 0x6B/255, b: 0x6B/255 });
byName["Status/Warning/Fg"].setValueForMode(light, A("Warning/Warning Dark"));
byName["Border/Strong"].setValueForMode(light, A("Neutral/Gray Medium"));

Neutral/Gray Medium を #757575 から #6B6B6B に1段階暗くしただけで、Text/Muted・Nav/Inactive・Icon/Default が一斉に改善し、342箇所が直った。

{"textNodesChecked":321,"failures":[],"boundPaints":1170,"rawPaints":0,"distinctColors":19}

本体ページ側も同様に測り、1,314件中0件不合格になった。残った5件はフロアマップのラベルで、監査が親をたどる仕組みのため、下に敷かれた兄弟図形を見ていない誤検知だった。実際の背景を調べると 5.13〜5.75:1 で合格していた。

[
  {"text":"エレベーター","size":24,"actualBackdrop":{"node":"Vector","hex":"#6B6B6B","ratio":5.33}},
  {"text":"1","size":14,"actualBackdrop":{"node":"Vector","hex":"#1565C0","ratio":5.75}},
  {"text":"2","size":14,"actualBackdrop":{"node":"Vector","hex":"#BF360C","ratio":5.6}},
  {"text":"3","size":14,"actualBackdrop":{"node":"Vector","hex":"#2E7D32","ratio":5.13}}
]

押せるものと状態表示が同じ色をしていた

最後に見つかったのは、色の数ではなく役割の見分けがつかないという問題だった。

「稼働中」というステータスバッジと、「管理者」「更新」というボタンが、どちらも薄い青地に青文字だった。同じ配色なので、見ただけではどちらが押せるのか分からない。

該当する要素を洗い出して分類した。

const CONTROL = /button|btn|chip|tab$|tab |action|filter|refresh|toggle|switch|link|user|account|menu item|pagination|selector/i;
const STATUS  = /badge|status|label|state|pill/i;

const rows = [];
for (const secId of ["1185:84984","1329:97699","101:2995"]) {
  const root = await figma.getNodeByIdAsync(secId);
  for (const f of root.findAll(n => n.type === "FRAME")) {
    const bg = await varOf(f, "fills");
    if (bg !== "Action/Secondary/Bg") continue;
    const t = f.findAllWithCriteria({ types: ["TEXT"] })[0];
    if (!t) continue;
    const fg = await varOf(t, "fills");
    const isControl = CONTROL.test(f.name);
    const isStatus  = STATUS.test(f.name) && !isControl;
    rows.push({
      section: root.name.replace("薬剤搬送ロボット-",""),
      id: f.id, node: f.name, text: (t.characters||"").slice(0,10),
      size: `${Math.round(f.width)}x${Math.round(f.height)}`,
      fg, kind: isControl ? "操作" : isStatus ? "状態" : "不明"
    });
  }
}
{"total":32,"byKind":{"不明":11,"状態":8,"操作":13}}

32件が同じ薄青ピル構成で、うち13件が操作(管理者×4、更新、割当×2、追跡、エクスポート、詳細×4)、8件が状態表示だった。残り11件は幅200px超の情報パネルで、ピルではないので対象外とした。

操作側だけを白文字+黒地に移した。新しくトークンを2つ追加している。

mk("Action/Neutral/Bg", ["FRAME_FILL","SHAPE_FILL"],               "Neutral/Black", "Neutral/White");
mk("Action/Neutral/Fg", ["TEXT_FILL","STROKE_COLOR","SHAPE_FILL"], "Neutral/White", "Neutral/Black");

コントラストは 17.4:1 になった。状態表示は薄青のまま残したので、色で役割が分かれる。

結果

2ページ・5区画に対して行った作業の集計。

指標

作業前

作業後

生hexのペイント

3,977

0

変数接続

1,044

5,013

使用色(テストページ)

26

19

使用色(本体4区画)

35

17

プリミティブ

29

36

セマンティックトークン

20

32

AA不合格のテキスト

多数

0(1,635件検証)

作業を通して分かったことが3つある。

hex完全一致は安全だが、意味は揃わない。 値が同じ変数に繋ぐ限り見た目は変わらないので、事故のリスクはほぼ無い。ただし「同じ役割が同じ変数を使う」状態には自動的にはならない。役割で揃えるには、ノードの構造や文脈を見て1件ずつ判断するしかなかった。

コントラストは白背景だけで測ると見逃す。 #757575 は白の上では合格していた。淡い背景の上に乗った瞬間に落ちる。実際に使われている組み合わせで測って初めて33件が出てきた。

色を減らす作業は、トークンを増やす作業だった。 重複したトークンは潰すべきだが、1つの色が複数の意味を兼ねている「衝突」は、分けなければ解決しない。最終的にトークンは20個から32個に増え、色は35色から17色に減った。意味の種類を細かく定義するほど、同じ色を使い回せるようになる。

そして、一括処理の対象を絞る条件は慎重に書く必要がある。/badge|chip|status/i という4文字の正規表現が Delivery Status Screen に一致して45ノードを壊した。同一内容のコピーが別ページに残っていたから復元できたが、無ければ手作業になっていた。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。