😭

バリデーション地獄を可視化せよ:Claude Code × Figmaで「今どうなってるか」を見える化

に公開

はじめに

この記事を読んでくれてありがとうございます。
きっとあなたも同胞だと思います。

開発してると、こんな脳内カオスが始まること、ありますよね?

「っ…なっ…なんだって!?」
赤いエラーメッセージが炸裂する。相手はバリデーション・エラー。
こいつはコピー作成時だけ発動する、厄介な隠し能力持ちだ。

「くそっ…!このタイミングで!?」
俺は React Hook Form をにらむが、敵は容赦しない。
新規作成とコピー作成で効果が変わる“トリガー・トラップ”が発動!

「フハハハ!お前の理解はまだ甘い!」
「誰だっ!?」
――脳内デュエル、開幕。

「一人で戦うな」
背後から聞こえた声。振り向くと、先輩が立っていた。
手には Zod スキーマという伝説の武器。

だが敵はさらに強い。「isTimeSpecified」を操る策略、
そして現れた新たな敵――useEffect!
状態変化を監視し、意図しないタイミングでバリデーションを発動させる。

「なんで…コピーしただけで走るんだ…!?」
そのとき気づく。これは単なるバリデーションじゃない。
状態遷移という壮大な物語だ。

「何が正しいのかわからない…!どうすればいいんだ…!」

だいたい私はこんな感じで仕事してます。
いつもフォローありがとう、開発メンバーのみなさん。

そんな脳内カオスのやり取りをしながら、
「これ、今どういう状態なんだっけ……?」と
画面の前で固まることが最近よくありました。

複数人で開発してると、
実装と修正が積み重なって、
「今どうなってるか」を
誰も即答できない瞬間が出てきます。

それがこの記事を書くきっかけです。

👣 脳内で起きているカオスの正体

  • 実装は存在している
  • コードを追えば理由は分かる
  • でも 全体像として把握できない

特にフォームのバリデーションは、

  • 初期表示
  • フォーカス / ブラー
  • 入力変更
  • 明示的なトリガー

といった 状態とタイミングが絡み合う構造 になってるんだろうな、と思います。

その結果、

「これ、正しい挙動なのか?」
を毎回コードで確認する羽目になりがちです。

人によって追うコードが違うし、
見る場所も、前提にしている状態も違うのが原因かもしれません。

カオスが生まれるのは、
状態と遷移が“頭の中にしか存在していない” からだと思っています。

🎯 「今どうなっているか」を知る

というわけで、今回の記事の目的に戻ります。
やることはシンプルです。

  • 新しい設計を作りたいわけではない
  • バリデーションを直したいわけでもない

今の実装がどう動いているのかを、
誰でも同じ理解で見られるようにすること。

バリデーションをスムーズに開発するための
最初の一歩としての可視化 です。

🛠 全体の流れ(今回やったこと)

最初にやったことを全部並べます。

  1. Figma Plugin で「描画できる仕組み」を先に作る
  2. 最小構成の Plugin 実装
  3. 最小JSONで「本当に描画できるか」を確認
  4. 実際に Plugin を実行して表示を確認
  5. Claude Code でバリデーション抽出の準備
  6. まずは「特定ページのタイトル」だけを見る
  7. 抽出結果を Node で描画用JSONに整形
  8. Figma Plugin を動かして「バリデーション状態」を描画

いきなり全部やらない のがポイントです。

⚙️ Step 1:Figma Plugin で「描画できる仕組み」を先に作る

Figma Desktop App を使い、

  • プラグイン → 開発 → 新規プラグイン
  • 種類:Figma design
  • 実行方式:デフォルト
  • ローカルに保存

で空のプラグインを作ります。
作ると manifest.jsoncode.js ができているはずです。
今回はfigma-pluginというディレクトリで対応します

  • figma-plugin/wire-autogen/
    • code.js
    • manifest.json

ここを起点に、最終的には 次の構成になります。

  • figma-plugin/wire-autogen/
    • prompt.txt
    • validation-state.json
    • figma-diagram.json
    • ui.html
    • code.js
    • manifest.json

⚙️ Step 2:最小構成の Plugin 実装

code.js(描画ロジック)

figma.showUI(__html__, { visible: false });

const draw = async (data) => {
  await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });

  const nodes = {};

  data.frames.forEach((f) => {
    const frame = figma.createFrame();
    frame.resize(f.width, f.height);
    frame.x = f.x;
    frame.y = f.y;

    const text = figma.createText();
    text.characters = f.label;
    text.x = 16;
    text.y = 16;

    frame.appendChild(text);
    figma.currentPage.appendChild(frame);
    nodes[f.id] = frame;
  });

  data.lines.forEach((l) => {
    const from = nodes[l.from];
    const to = nodes[l.to];
    if (!from || !to) return;

    const line = figma.createLine();
    line.x = from.x + from.width / 2;
    line.y = from.y + from.height / 2;
    line.resize(to.x - from.x, to.y - from.y);
    figma.currentPage.appendChild(line);
  });
};

figma.ui.onmessage = async (msg) => {
  await draw(msg);
  figma.closePlugin();
};

ui.html

<!DOCTYPE html>
<html>
  <body>
    <script id="data" type="application/json">
      {
        "frames": [
          {
            "id": "A",
            "label": "四角A",
            "x": 0,
            "y": 0,
            "width": 200,
            "height": 100
          },
          {
            "id": "B",
            "label": "四角B",
            "x": 300,
            "y": 0,
            "width": 200,
            "height": 100
          }
        ],
        "lines": [{ "from": "A", "to": "B" }]
      }
    </script>
    <script>
      const data = JSON.parse(document.getElementById('data').textContent);
      parent.postMessage({ pluginMessage: data }, '*');
    </script>
  </body>
</html>

⚙️ Step 3:最小JSONで「本当に描画できるか」を確認する

  • Figma Plugin が起動できる
  • code.jsui.html が連携できる
  • JSON を渡して描画する仕組みが存在する

ここまでで、 土台 はできました。

「四角と線が、意図した位置に表示されるか?」

そのために用意したのが、
四角A・四角B・線1本 だけの最小JSONです。

すでに ui.html に書いた以下のデータが、それです。

  • frames:2つ
  • lines:1本
  • 状態・バリデーション・条件分岐:一切なし

⚙️ Step 4:実際に Plugin を実行して表示を確認する

Figma Desktop App で、

  • Plugins → Development → 作成したプラグインを実行

すると、

  • 四角A
  • 四角B
  • それをつなぐ線

がキャンバス上に表示されるはずです。

この時点で確認したいのはこれだけ。

  • プラグインが落ちずに動くか
  • JSON を渡して描画できているか

⚙️ Step 5:Claude Code でバリデーション抽出の準備

次は Claude Code に渡す内容(prompt.txt)を用意します。
具体的には「調査対象」と「出力フォーマット」を明示します。

念の為ですが、前提として以下の想定で進めます。

  • Claude Code は起動できている
  • 対象プロジェクトで実際に動いている
  • バリデーションロジックを解析できる状態にある

参考はこちら: 【2025/10最新版】ゼロから始めるClaude Code入門・ハンズオン

prompt.txt

【前提】
以下は、<対象ページ> における
<対象フィールド> のバリデーション挙動を
実際のコードから調査した内容である。
...(調査結果を要約して記載)...

【出力フォーマット(厳守)】
{
  "field": "<対象フィールド名>",
  "validation_rules": {
    "base": "<基本ルール(型、制約など)>",
    "required_when": [
      "<必須条件1>",
      "<必須条件2>"
    ],
    "error_messages": {
      "required": "<必須エラーメッセージ>",
      "other": "<その他のエラーメッセージ>"
    }
  },
  "states": [
    { "id": "状態ID", "label": "状態の説明" }
  ],
  "transitions": [
    {
      "from": "開始状態ID",
      "to": "終了状態ID",
      "trigger": "遷移のトリガー",
      "context": "遷移の詳細説明"
    }
  ]
}

⚙️ Step 6:まずは「特定ページのタイトル」だけを見る

いきなり全フォーム・全バリデーションを対象にすると、

  • 抽出に時間がかかる
  • 出力が多すぎて確認できない
  • 何が正しくて何が間違っているか分からない

という地獄が待っています。

なので、最初は割り切ります。

  • ページを1つに限定
  • フィールドは「タイトル」だけ
  • 状態遷移も最小限

これは実装をサボるためではなく、検証を速くするため です。

この段階で Claude Code から得られる出力が
validation-state.json です。

validation-state.json

※ Claude Code の実際の出力には validation_rules も含まれますが、社内仕様が含まれるため本文ではテンプレ化しています。

{
  "field": "<対象フィールド名>",
  "validation_rules": {
    "base": "<基本ルール(型、制約など)>",
    "required_when": ["<必須条件1>", "<必須条件2>"],
    "error_messages": {
      "required": "<必須エラーメッセージ>",
      "other": "<その他のエラーメッセージ>"
    }
  },
  "states": [
    { "id": "initial", "label": "初期状態 (未検証)" },
    { "id": "touched", "label": "touch済み (バリデーション実行中)" },
    { "id": "valid", "label": "バリデーション成功" },
    { "id": "invalid", "label": "バリデーション失敗" }
  ],
  "transitions": [
    {
      "from": "initial",
      "to": "touched",
      "trigger": "<onBlur等>",
      "context": "<初回>"
    },
    {
      "from": "touched",
      "to": "valid",
      "trigger": "<onChange等>",
      "context": "<成功>"
    },
    {
      "from": "touched",
      "to": "invalid",
      "trigger": "<onChange等>",
      "context": "<失敗>"
    }
  ]
}

⚙️ Step 7:抽出結果を Node で描画用JSONに整形する

Claude Code から得られるのは、

  • 状態
  • 条件
  • 遷移

といった 論理構造 です。

一方、Figma Plugin が欲しいのは、

  • frames(位置・サイズ・ラベル)
  • lines(from / to)

という 描画用データ です。

ここをつなぐために、Node スクリプト(または手動)で

  • 状態 → 四角
  • 遷移 → 線

に変換します。

ここでは 手動での変換ルール を決めています。

  • statesframes
    • x 座標は index * 320
  • transitionslines
    • from, to, label をそのまま使う

この変換結果が figma-diagram.json です。

⚙️ Step 8:Figma Plugin を動かして「バリデーション状態」を描画する

最後はとても単純です。

  • Step7で作ったJSONを
  • ui.html<script id="diagram-data"> にコピペして
    • 最小構成の id="data" から、完成版では diagram-data に変更します
  • Plugin を実行する

すると、

  • タイトルの状態
  • それぞれの遷移
  • 「今どうなっているか」

図として目に見える形 で出てきます。

🧩 最終的なコード

最小構成から拡張した完成版と画像を載せておきます。
参考になれば幸いです!

code.js
// code.js
figma.showUI(__html__, { visible: false });

const createDiagram = async (diagramData) => {
  // 念のためページを空にする(既存があっても問題ない)
  figma.currentPage.children.forEach((node) => node.remove());

  // フォント読み込み(Text作成に必須)
  await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });

  const frames = diagramData.frames;
  const lines = diagramData.lines;

  const layoutOverrides = {
    initial: { x: 0, y: 0 },
    touched: { x: 0, y: 180 },
    valid: { x: -280, y: 360 },
    invalid: { x: 280, y: 360 },
  };

  // 先にboundsを計算(背景を最初に作成するため)
  let minX = Infinity;
  let minY = Infinity;
  let maxX = -Infinity;
  let maxY = -Infinity;
  frames.forEach((frameData) => {
    const override = layoutOverrides[frameData.id];
    const x = override ? override.x : frameData.x;
    const y = override ? override.y : frameData.y;
    minX = Math.min(minX, x);
    minY = Math.min(minY, y);
    maxX = Math.max(maxX, x + frameData.width);
    maxY = Math.max(maxY, y + frameData.height);
  });

  // 背景を最初に作成(最背面になる)
  const padding = 80;
  const background = figma.createFrame();
  background.name = 'Background';
  background.resize(maxX - minX + padding * 2, maxY - minY + padding * 2);
  background.x = minX - padding;
  background.y = minY - padding;
  background.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
  background.strokes = [];
  figma.currentPage.appendChild(background);

  // Frameを作成
  const frameNodes = {};

  frames.forEach((frameData) => {
    const frame = figma.createFrame();
    frame.name = frameData.label;
    frame.resize(frameData.width, frameData.height);
    const override = layoutOverrides[frameData.id];
    frame.x = override ? override.x : frameData.x;
    frame.y = override ? override.y : frameData.y;

    // 状態ごとに色を変える
    if (frameData.id === 'initial') {
      frame.fills = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
    } else if (frameData.id === 'touched') {
      frame.fills = [{ type: 'SOLID', color: { r: 0.95, g: 0.85, b: 0.6 } }];
    } else if (frameData.id === 'valid') {
      frame.fills = [{ type: 'SOLID', color: { r: 0.7, g: 0.9, b: 0.7 } }];
    } else if (frameData.id === 'invalid') {
      frame.fills = [{ type: 'SOLID', color: { r: 0.95, g: 0.7, b: 0.7 } }];
    }

    frame.cornerRadius = 8;
    figma.currentPage.appendChild(frame);

    const text = figma.createText();
    text.characters = frameData.label;
    text.fontSize = 14;
    text.x = 16;
    text.y = 16;
    frame.appendChild(text);

    frameNodes[frameData.id] = frame;
  });

  // Lineを作成(双方向の線を考慮してオフセットを管理)
  const lineCounts = {}; // 双方向グループ用(線のoffset計算)
  const directedLineCounts = {}; // 同じ方向の線用(ラベルのY座標計算)

  lines.forEach((lineData) => {
    const fromNode = frameNodes[lineData.from];
    const toNode = frameNodes[lineData.to];

    if (!fromNode || !toNode) return;

    // 双方向の線を同じグループとして扱う(A-BとB-Aを同じキーに)
    const sortedKey = [lineData.from, lineData.to].sort().join('-');
    lineCounts[sortedKey] = (lineCounts[sortedKey] || 0) + 1;
    const lineIndex = lineCounts[sortedKey];
    const offset = (lineIndex - 1) * 50; // offsetを50に増加

    // 同じ方向の線の数を数える(ラベルのY座標をずらすため)
    const directedKey = `${lineData.from}-${lineData.to}`;
    directedLineCounts[directedKey] =
      (directedLineCounts[directedKey] || 0) + 1;
    const directedLineIndex = directedLineCounts[directedKey];

    // フレームの端から線を引く(中央ではなく)
    const fromCenterX = fromNode.x + fromNode.width / 2;
    const toCenterX = toNode.x + toNode.width / 2;
    const dy = toNode.y - fromNode.y;
    const isDownward = dy > 0;

    let startX, startY, endX, endY;

    if (isDownward) {
      // 下に向かう線: fromの下端からtoの上端へ
      startX = fromCenterX + offset;
      startY = fromNode.y + fromNode.height;
      endX = toCenterX + offset;
      endY = toNode.y;
    } else {
      // 上に向かう線: fromの上端からtoの下端へ
      startX = fromCenterX + offset;
      startY = fromNode.y;
      endX = toCenterX + offset;
      endY = toNode.y + toNode.height;
    }

    // Vectorを使って線を描画(より正確な制御が可能)
    const vector = figma.createVector();
    const pathData = `M ${startX} ${startY} L ${endX} ${endY}`;
    vector.vectorPaths = [{ windingRule: 'EVENODD', data: pathData }];
    vector.strokes = [{ type: 'SOLID', color: { r: 0.3, g: 0.3, b: 0.3 } }];
    vector.strokeWeight = 2;
    vector.strokeCap = 'ROUND';
    figma.currentPage.appendChild(vector);

    // ラベルを作成(線の方向に応じて位置を調整)
    if (lineData.label) {
      const label = figma.createText();
      label.characters = lineData.label;
      label.fontSize = 10;
      // ラベルの位置を線の中点に配置
      const midX = (startX + endX) / 2;
      const midY = (startY + endY) / 2;
      // 同じ方向の線が複数ある場合、Y座標をずらす
      const labelYOffset = (directedLineIndex - 1) * 16;
      // 下り線は左側、上り線は右側にラベルを配置
      if (isDownward) {
        label.x = midX - 120;
        label.y = midY - 8 + labelYOffset;
      } else {
        label.x = midX + 10;
        label.y = midY - 8 + labelYOffset;
      }
      label.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2 } }];
      figma.currentPage.appendChild(label);
    }
  });
};

figma.ui.onmessage = async (message) => {
  try {
    if (message && message.type === 'diagram-data') {
      await createDiagram(message.payload);
      figma.notify('バリデーション状態遷移図を生成しました');
      return;
    }

    if (message && message.type === 'diagram-error') {
      figma.notify(
        message.message || 'figma-diagram.jsonの読み込みに失敗しました',
      );
    }
  } catch (error) {
    console.error(error);
    figma.notify('処理中にエラーが発生しました');
  } finally {
    // どの経路でも確実に終了させる
    figma.closePlugin();
  }
};
ui.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>wire-autogen</title>
  </head>
  <body>
    <script id="diagram-data" type="application/json">
      {
        "frames": [
          {
            "id": "initial",
            "label": "初期状態 (undefined, 未検証)",
            "x": 0,
            "y": 0,
            "width": 240,
            "height": 120
          },
          {
            "id": "touched",
            "label": "touch済み (バリデーション実行中)",
            "x": 320,
            "y": 0,
            "width": 240,
            "height": 120
          },
          {
            "id": "valid",
            "label": "バリデーション成功 (エラーなし)",
            "x": 640,
            "y": 0,
            "width": 240,
            "height": 120
          },
          {
            "id": "invalid",
            "label": "バリデーション失敗 (エラー表示)",
            "x": 960,
            "y": 0,
            "width": 240,
            "height": 120
          }
        ],
        "lines": [
          {
            "from": "initial",
            "to": "touched",
            "label": "onBlur (初回)"
          },
          {
            "from": "initial",
            "to": "touched",
            "label": "triggerPostValidation()"
          },
          {
            "from": "touched",
            "to": "valid",
            "label": "バリデーション成功"
          },
          {
            "from": "touched",
            "to": "invalid",
            "label": "バリデーション失敗"
          },
          {
            "from": "valid",
            "to": "touched",
            "label": "onChange"
          },
          {
            "from": "invalid",
            "to": "touched",
            "label": "onChange"
          }
        ]
      }
    </script>
    <script>
      try {
        const text = document.getElementById('diagram-data').textContent;
        const data = JSON.parse(text);
        parent.postMessage(
          { pluginMessage: { type: 'diagram-data', payload: data } },
          '*',
        );
      } catch (error) {
        parent.postMessage(
          {
            pluginMessage: {
              type: 'diagram-error',
              message: (error && error.message) || 'JSON読み込みに失敗しました',
            },
          },
          '*',
        );
      }
    </script>
  </body>
</html>

✅ まとめ

カオスを感じたら、まず可視化でOK。
「まずやってみろ」の精神で、自分の理解も深められてよかったです。
まだ全体のバリデーションは可視化できていないので、引き続きやってみます。

この記事が、読んでくださったあなたや、あなたのチームの 「まずやってみろ」 になれば幸いです。

カンリーテックブログ

Discussion