🎨

Figmaデザインと実装を CLI で“テキストだけ”自動比較する話(「,」と「、」も逃さない)

に公開

クロスマート テックアドベントカレンダーの 12/12 の記事です 🎄

はじめに

Figma のデザインどおりに実装したつもりなのに、あとからこう言われること、ありませんか?

  • 「ここ、パーセンテージ 14.6% じゃなくて 18% になってますね」
  • 「デザインは , だけど、実装は ですよ」
  • 「Figma の規約文とサイトの規約文、どこかが違う気がする…けど目視だと見つからない」

ボタンラベルやヘルプテキストならまだマシですが、利用規約みたいな長文ページになると一気に話が変わります。

数千文字あるテキストを、Figma とブラウザを行き来しながら「一字一句」レビューするのは、正直かなりつらいし、ヒューマンエラーも避けづらいですよね。


この記事は、Figma を唯一の正解にしている Web プロダクトを前提にしています。
その中で、利用規約やプライバシーポリシーのような「長文の静的ページ」を担当しているフロントエンド/デザイナー向けの内容です。

npx @uimatch/cli compare 一発で /terms ページの文言を Figma と 1 文字単位で突き合わせる」 までの流れを伝え切るのがゴールです。


今回の記事では、そんな「長文ページの文言ズレ問題」を題材に、Figma と実装を機械的に突き合わせる方法について書きました。

具体的には:

  • 題材ページ:http://localhost:5173/terms(利用規約ページ)
  • 実装ファイル:TermsPage.tsx
  • Figma ノード:<fileKey>:<nodeId>

この 3 つを対応付けて、「Figma の規約テキスト」と「実装済み /terms ページのテキスト」が本当に同じかどうかを自動で検査していきます。

※この記事で扱っている利用規約の本文は、kiyaku.jp さんが公開している
「汎用的な利用規約のひな型」をベースに、一部文言を調整したものです。

使うツール

ここで使うのが uiMatch という CLI ツールです。

uiMatch は Figma API と Playwright を使って、

  • Figma 側:指定したフレームのスクリーンショットやテキスト
  • 実装側:指定した URL + CSS セレクタ(今回は #terms-root)のスクリーンショットやテキスト

を取り出し、両者を比較してレポートを出してくれます。

uiMatch 自体はレイアウトやカラーなどのビジュアル差分も見られますが、この記事ではそこはいったん脇に置きます。

フォーカスするのは 「テキストが完全に一致しているか?」 です。

確認すること

  • Figma どおりに書いたときに「完全一致」と判定できるか
  • 14.6% → 18% のような数値変更をちゃんと検出できるか
  • , のような句読点の違いまで拾ってくれるか

といった挙動を、実際に uiMatch を動かしながら確認していきます。

最終的なゴール

利用規約のような長文ページでも、

  • Figma のデザインをその世界線での正解にして
  • /terms ページのテキストを npx @uimatch/cli compare 一発で比較し
  • 「1 文字ズレたら CI で落とす」くらいの厳しさでチェックできる

という状態を作ることです。


ちゃんと仕組みを追う前に「まずは 1 回動かしてみたい」という人向けに、次のセクションに 5 分で動かせる最小構成 も用意しています。

以降のセクションでは、実際に uiMatch で /terms ページを比較する手順と、そのときのレポートの読み方を見ていきます。

簡単に触ってみる

このセクションでは、細かいオプションの意味を理解する前に/terms ページを 1 ページだけ題材にして npx @uimatch/cli compare を 1 回通すところまでをゴールにしています。

「Figma のアクセストークンさえあれば、ここまで 5 分でたどり着ける」を目指しています。

前提条件

  • Node.js: v20.19 以上または v22.12 以上(v21 系は非対応)
  • uiMatch: v0.1.1(2025/12/12 時点での最新版)
  • Playwright: v1.49.0 以上(uiMatch の peer dependency として自動インストールされます)

  1. Figma で、利用規約ページのフレームを 1 つ決める

    まず、ブラウザで対象フレームを開いたときの URL を確認します。

    例:

    https://www.figma.com/design/ABCD1234EFGH5678/terms?type=design&node-id=1-90&mode=design
    
    • ABCD1234EFGH5678 …… /design/ の直後にある英数字(これが fileKey
    • 1-90 …………………… クエリパラメータ node-id= の後ろの文字列(これが nodeId

    uiMatch には、<fileKey>:<nodeId> 形式の文字列を渡します。

    上の例だと、最終的に渡す値は:

    ABCD1234EFGH5678:1-90
    

    この記事では、この文字列を CI 用の Secret FIGMA_TERMS_NODE に入れて使います。

  2. フロントエンド側で、利用規約ページを用意

    • <article id="terms-paper"> など、「本文だけを入れるコンテナ」を 1 個作っておきます。
  3. 開発サーバを起動

    • この記事では http://localhost:5173/terms でアクセスできる前提で書いています。
  4. Figma のアクセストークンをワンライナーで渡す

    uiMatch は FIGMA_ACCESS_TOKEN という環境変数からトークンを読む前提になっているので、この記事ではまずはシンプルに「コマンドの先頭で一時的に設定する」形を使います。
    実際のプロダクトでは、.env などにトークンを置いて、dotenvdotenv-cli などで環境変数に流し込む形にする方が安全だと思います。
    (現状の uiMatch CLI が .env を自動で読むわけではないので、そこはプロジェクト側のラッパーで吸収する想定です。)

    FIGMA_ACCESS_TOKEN="figd_..." \
      npx @uimatch/cli compare \
        figma=<fileKey>:<nodeId> \
        story=http://localhost:5173/terms \
        selector="#terms-root" \
        text=true \
        textMode=self \
        textMatch=ratio \
        textMinRatio=1.0 \
        textGate=true \
        profile=page/text-doc \
        size=pad \
        contentBasis=element \
        areaGapCritical=1.0 \
        outDir=./uimatch-reports/terms-text-check
    

成功の確認

このコマンドが通ると、./uimatch-reports/terms-text-check 以下に figma.png / impl.png / diff.png / report.json が生成されます。

まずは report.json を開いて、textMatch.equaltrue になっていれば成功です。

uiMatch で利用規約ページのテキストを比較する

このセクションでは、実際に /terms ページを 1 ページだけ題材にして、npx @uimatch/cli compare ... textMode=self textMatch=ratio「テキストがどこまで一致しているか」 を見るところまでを追いかけます。

まずは、実際に /terms のテキストを Figma と突き合わせたときのコマンドから載せます。

FIGMA_ACCESS_TOKEN=... \
npx @uimatch/cli compare \
  figma=<fileKey>:<nodeId> \
  story=http://localhost:5173/terms \
  selector="#terms-root" \
  text=true \
  textMode=self \
  textMatch=ratio \
  textMinRatio=1.0 \
  textGate=true \
  profile=page/text-doc \
  size=pad \
  contentBasis=element \
  areaGapCritical=1.0 \
  outDir=./uimatch-reports/terms-text-check

ここからは、このコマンドの「テキスト比較に関係する引数」と、その結果として report.json にどういう情報が出てくるかを見ていきます。


今回の比較設定(テキストまわりだけざっくり)

npx @uimatch/cli compare のオプションはたくさんありますが、自分が長文ページのテキスト比較を試していたときに、結局よく触っていたのはこのあたりです。

  • figma=<fileKey>:<nodeId>
    比較対象となる Figma フレーム。<fileKey>:<nodeId> 形式で、今回は利用規約ページ用のフレームを 1 つだけ指定しています。

  • story=http://localhost:5173/terms
    実装側の URL。Playwright がここにアクセスして、DOM とスクリーンショットを取ります。

  • selector="#terms-root"(あるいは #terms-paper
    比較対象とする DOM のルート要素。
    利用規約の本文だけを 1 つのコンテナにまとめておき、そのコンテナを selector にします。

  • text=true
    テキスト比較を有効化するスイッチ。false の場合はピクセル差分だけが評価されます。

  • textMode=self
    長文ページのテキスト比較で一番効いているスイッチです。
    「どの範囲のテキストを 1 本として扱うか」の指定で、self にすると、指定した selector の内側に最終的に見えているテキストを 1 本の長い文字列として扱います。
    利用規約のような長文ページでは、Figma 側が 1 つの Text レイヤー、DOM 側が複数のタグ(h1 / p / li など)に分かれていることが多いので、子孫要素単位で比較するより「コンテナごと 1 対 1 で比べる」方が安定しやすいです。

  • textMatch=ratio
    テキストの比較方法です。Figma 側と実装側のテキストを

    • Unicode の NFKC 正規化(全角/半角のゆらぎをならす)
    • 余分な空白の圧縮・前後の空白トリム
    • 大文字/小文字の吸収(デフォルトは case-insensitive)

    などで一度正規化したうえで、「どれくらい似ているか」を 0.0〜1.0 のスコアにしたものが ratio です。
    内部的にはトークンの重なり具合や文字位置の近さを見ながら類似度を計算していて、

    • 1.0 … 完全一致
    • 0.99 前後 … 句読点や数文字だけ違う
    • 0.9 前後 … 文の骨格は同じだが、単語レベルでそこそこ違う

    くらいのイメージになります。

    詳細なスコア計算ロジック自体はライブラリ内部の実装に依存していて、将来のバージョンで変わる可能性はありますが、
    「1.0 に近いほどテキストが近い」「0.9 台は軽微な差分」といった相対的な指標として使う想定です。

  • textMinRatio=1.0
    「どこまで一致していれば OK とみなすか」の閾値。
    1.0 にしているので、「1 文字でも違ったら NG」というかなり厳しめの設定になっています。
    まずは 1.0 にしておいて、必要になったら 0.99 などに下げる、くらいの運用イメージです。

  • textGate=true
    テキスト比較の結果を優先して exit code を決めるスイッチです。
    ここでいう Gate は「この比較結果を PASS とみなすか FAIL とみなすかを決める判定ロジック」 のイメージです。

    textMatch.equal === true かつ ratio >= textMinRatio であれば、
    レイアウトやスタイルの差分で page/text-doc の Gate が FAIL でも、
    コマンド自体は exit code 0(成功)になります。
    逆に、テキストがズレていれば exit code 1 になるので、
    CI で「文言が違ったら PR を落とす」用途に使えます。

レイアウト寄りの設定(profile, size, contentBasis, areaGapCritical など)は今回の主役ではないので、
「テキストだけちゃんと一致しているかを見たい」という目的なら、ひとまず text* 系だけ意識しておけば十分です。


3 つのテストケースでどう違いが出るか

同じコマンドを使って、テキストだけを少しずつ変えながら 3 パターン試しています。

ケース 1:完全一致(ベースライン)

→ Figma のテキストをそのままコピペした状態

まずは、Figma のテキストをそのままコピペした状態。

"textMatch": {
  "equal": true,
  "ratio": 1.0,
  "details": {}
}
  • equal: true
    → 「テキストは完全に一致している」と判定。
  • ratio: 1.0
    → 類似度 100%。1 文字もズレていない状態。
  • details
    → 差分がないので空オブジェクト。

このときでも、レイアウトやスタイルの差分が原因で、全体の Quality Gate 自体は FAIL になります。
ただし textGate=true にしておけば、**テキストが完全一致している限り、コマンド自体は成功扱い(exit code 0)**になります。

文言チェックだけをしたいときは、

  • ログやレポートではビジュアルの差分も確認しつつ
  • CI の成否は textMatchtextGate に任せる

という切り分けができる、という使い方です。

ケース 2:数値を変えた場合(14.6% → 18%)

→ 本文中の 1 カ所だけ数値を変えた

次に、本文中の 1 カ所だけ数値を変えてみます。

- ユーザーは年14.6%の割合による遅延損害金を支払うものとします。
+ ユーザーは年18%の割合による遅延損害金を支払うものとします。

この状態で同じコマンドを回したときの textMatch はこんな感じになります。

"textMatch": {
  "equal": false,
  "ratio": 0.9735...,
  "details": {
    "missing": [
      "ユーザーが利用料金の支払を遅滞した場合には,ユーザーは年14.6%の割合による遅延損害金を支払うものとします。"
    ],
    "extra": [
      "ユーザーが利用料金の支払を遅滞した場合には,ユーザーは年18%の割合による遅延損害金を支払うものとします。"
    ]
  }
}

ざっくり読み解くと:

  • equal: false
    → どこかしらテキストが違うので「不一致」と判定。
  • ratio ≒ 0.97
    → ほとんど同じだけど、数文字だけ違うので 1.0 から少しだけ下がっている。
  • details.missing
    → Figma 側にあって実装側には無くなった文(14.6% の文)が入る。
  • details.extra
    → 実装側だけに存在する文(18% の文)が入る。

つまり、「ほぼ一緒だけど、この一文が 14.6% → 18% に変わっているね」というのが、JSON から分かります。

ケース 3:句読点だけ変えた場合(,

→ 数値は戻して、句読点だけをいじった

最後に、数値は戻した上で、句読点だけをいじってみます。

# Figma 側(半角カンマ)
- この利用規約(以下,「本規約」といいます。)は,株式会社サンプル商事...

# 実装側(全角読点)
+ この利用規約(以下、「本規約」といいます。)は、株式会社サンプル商事...

このケースの textMatch はこうなりました。

"textMatch": {
  "equal": false,
  "ratio": 0.9930...,
  "details": {
    "missing": [
      "この利用規約(以下,「本規約」といいます。)は,株式会社サンプル商事..."
    ],
    "extra": [
      "この利用規約(以下、「本規約」といいます。)は、株式会社サンプル商事..."
    ]
  }
}
  • ratio ≒ 0.99
    → 数値変更よりもさらに微妙な差なので、スコアの落ち方も小さい。
  • missing 側に「半角カンマ版の文章」
  • extra 側に「全角読点版の文章」

がそのまま入ります。
「Figma と完全一致させるならどこを直せばいいのか?」を、この missing / extra を見ることで確認できます。

現状の uiMatch では missing / extra に「その行まるごと」が入るだけなので、"どの文字が違うのか" はエディタの diff や目 grep にまだ頼っています。

このあたりは、いずれもう少し「差分だけが一目で分かるビューア」やレポート形式を用意したい気持ちはあるものの、今のところは「文言レビューの“当たり”をつけるレーダー」くらいの位置付けとなっています。


テキストの問題だけを見たいときに見る場所

compare を 1 回まわすと、出力ディレクトリには毎回こんなファイルが溜まります。

  • figma.png … Figma 側のスクリーンショット
  • impl.png … 実装側(ブラウザ側)のスクリーンショット
  • diff.png … 2 枚の差分をハイライトした画像
  • report.json … すべてのスコアと差分情報をまとめた JSON
Figma側 実装側+diff
Figma版 実装版
diff

レイアウトや色のズレまで含めて確認したいときは画像を眺めるのが早いんですが、
「とりあえず文言がズレているかどうかだけ知りたい」 というときは、report.jsontextMatch ブロックだけを見れば十分です。

  • テキストが完全一致していれば
    textMatch.equal: true, ratio: 1.0, details: {}

  • どこかに差分があれば
    equal: false になり、ratio が少し下がり、missing / extra に該当する文が並ぶ

というルールで揃っているので、機械的にも扱いやすい構造になっています。

report.json には、他にも dfs(Design Fidelity Score)や pixelDiffRatio など、
レイアウトや色も含めた「見た目全体のスコア」も入っています。

ただ、今回の記事では 文言チェックに集中したい ので、
DFS などのビジュアル系スコアは「参考情報」くらいの扱いにしていて、
実際の判定は textMatch.equaldetails.missing / extra だけを見ています。

全体の Quality Gate がレイアウト差分で FAIL になっていても、

  • テキストの判定 → textMatch.equal
  • 「どの文がおかしいか」 → textMatch.details

だけを見れば、長文ページでも文言チェックに集中できる、という使い方です。


ここまの内容で、

  • Figma の規約テキストと
  • /terms ページのテキストを

npx @uimatch/cli compare ... textMode=self textMatch=ratio で 1 本比較して、
「1 文字でもズレたら落とす」といったチェックを回すところまで見てきました。

この先(セレクタ設計や、複数ページに広げるときの考え方、LLM 連携)は、
「/terms 以外のページにも広げたい」「1→1.1 の運用まで設計したい」人向けの内容です。

セレクタ設計と、もう一歩先の運用

ここから先は 応用編 です。

「どこを selector にすると安定するか」と、「/terms 以外のページにも広げるとしたらどう設計するか」を、実務寄りにまとめておきます。

/terms の 1 ページだけ試せれば十分、という人は、必要になったときにここだけ読み返してもらえれば OK です。

テキスト比較そのものは textMode=selftextMatch=ratio の組み合わせで少なくとも自分の案件では安定して動いているので、このセクションでは

  • そもそも「どの要素をセレクトして比べるか」という設計の話
  • 複数コンポーネントを uimatch suite でまとめて回す設計案

の 2 つをまとめて整理しておきます。


セレクタ設計のパターン

今回の前提は、「できるだけテキストだけに集中して比較したい」です。
なので、レイアウトやアイコン、動的コンテンツをごちゃっと含むラッパーではなく、

静的なテキストだけを含むコンテナを 1 つ決めて、そこを selector にする

という方針でセレクタを決めています。

1. 利用規約ページ全体をチェックしたい場合

利用規約のような長文ページでは、ページ全体をそのまま比較対象にすると、

  • ヘッダー / フッター
  • ログインユーザー名
  • 「最終更新日」みたいな動的テキスト

まで巻き込んでしまい、テキスト比較がかなり不安定になります。

自分は最初、body 全体を selector にしてしまって、毎回ヘッダーの動的なログイン状態で textMatch が落ちる状態になり、「そりゃそうだ…」となりました。

そのときの textMatch は、ざっくりこんな感じです。

"textMatch": {
  "equal": false,
  "ratio": 0.82,
  "details": {
    "missing": ["最終更新日: 2024-12-01 ..."],
    "extra": ["最終更新日: 2024-12-12 ..."]
  }
}

固定テキスト以外まで巻き込んでしまうと、テストを増やすたびにこういった差分が増えてしまうので、
「静的なテキストだけを入れたコンテナ」を selector にする方針を取っています。

最初の数日は、ログイン状態で毎日テストが落ちていて、「これは設計を見直すサインだな」と観念しました。

だからこそ、固定テキストだけのコンテナを作るのがおすすめです。

そこで、実装側ではこんな感じで「紙面用コンテナ」を 1 枚切り出しておきます。

export const TermsPage = () => {
  return (
    <main
      id="terms-root"
      className="min-h-screen flex justify-center bg-slate-50"
    >
      <article
        id="terms-paper"
        className="w-[1033px] bg-white px-8 pt-8 pb-16 text-slate-800"
      >
        {/* ここに h1 / 各条文のテキストだけを入れる */}
      </article>
    </main>
  );
};

ここでのセレクタの候補はだいたい次の 2 つです。

  • ページ全体をざっくり見るとき: selector="#terms-root"
  • 利用規約本文だけをガチで見るとき: selector="#terms-paper"

今回の「Figma とテキストを 1 行単位で突き合わせたい」という目的だと、
#terms-paper のような「静的テキスト専用のコンテナ」を selector にする方が安定します。

2. Storybook コンポーネントをチェックしたい場合

Storybook 側では、Canvas 全体をセレクトしてしまうと、ツールバーやアドオンの UI まで含まれてしまいます。
そこで、#storybook-root を起点に、欲しい要素だけをセレクトするパターンを使うことができます。

例としてはこんな感じです。

  • プライマリボタン 1 個だけを比べたいとき

    selector="#storybook-root button"
    
  • カードコンポーネントの中身を比べたいとき

    selector="#storybook-root .card"
    

ここでも考え方は同じで、

  • Storybook の枠やアドオンは selector の外に出す
  • コンポーネントの「中身のテキスト」を含む一番外側の要素だけを selector にする

というのをルールにしておくと、テストが増えても迷いづらくなります。

3. data-testid を使うパターンについて

data-testid を selector に使うこともできます。例えば:

selector="[data-testid='primary-button']"

これは「DOM の構造やクラス名が多少変わってもテストが壊れにくい」という意味ではかなり強力です。

ただし運用面では、

  • 本番 DOM にテスト用属性を残したくないポリシーの会社もある
  • SEO / セキュリティ観点で属性を増やしたくないケースもある

といった話も聞きます。

この記事では「こういう書き方もできる」という紹介にとどめていて、
今回の基本路線としてはやはり、

  • ルートに id を振る(例: terms-root, terms-paper
  • 中身は BEM やユーティリティクラスでざっくりセレクトする

くらいの、素直な CSS セレクタを使う方針にしています。

4. まとめ:セレクタ設計の実質的な指針

実務的には、次の 2 ステップで考えるとシンプルです。

  1. 「固定テキストだけ」を入れるコンテナを 1 個用意する

    • 利用規約本文用の <article id="terms-paper">
    • Storybook のコンポーネント root
    • 動的なユーザー名・日付などはその外側(または別コンテナ)に逃がす
  2. uiMatch の selector には、そのコンテナだけを指定する

    • ページ全体ではなく「紙面」「カード本体」「ボタン本体」に絞る
    • そうすると textMode=self でのテキスト比較がかなり安定する

「テキストを綺麗に比べたいなら、まずテキストだけの箱を作る」という設計を先にやっておく、というイメージです。


複数ページをまとめて回すためのコマンド(uimatch suite)も用意はしているのですが、
執筆時点の v0.1.1 ではまだ挙動を改善中なので、
この記事では「まずは 1 ページを compare でしっかり見る」ラインまでに絞ります。


おまけ:CI への導入イメージ(最小構成)

ここまでで、textGate=true を使うと「ビジュアル差分があってもテキストが合っていれば OK」にできることが確認できました。
これを GitHub Actions などの CI に組み込む場合、最小構成としては次のようなステップになります。

# .github/workflows/terms-text-gate.yml の一部イメージ
- name: Run uiMatch text gate for /terms
  env:
    FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }}
    # FIGMA_TERMS_NODE は「<fileKey>:<nodeId>」形式の文字列を入れておく Secret。
    # 例:
    #   Figma URL:
    #     https://www.figma.com/design/ABCD1234EFGH5678/terms?type=design&node-id=1-90&mode=design
    #   → FIGMA_TERMS_NODE:
    #     ABCD1234EFGH5678:1-90
    FIGMA_TERMS_NODE: ${{ secrets.FIGMA_TERMS_NODE }}
  run: |
    npx @uimatch/cli compare \
      figma=$FIGMA_TERMS_NODE \
      story=http://localhost:4173/terms \
      selector="#terms-root" \
      text=true \
      textMode=self \
      textMatch=ratio \
      textMinRatio=1.0 \
      textGate=true \
      profile=page/text-doc \
      size=pad \
      contentBasis=element \
      areaGapCritical=1.0 \
      outDir=./uimatch-reports/terms-text-check

この job は「プレビューサーバが立っている(localhost:4173 などにアクセスできる)」前提です。

「とりあえずテキストがズレたら落とす」ゲートなら、この 1 ステップを足せば実現できるはずです。

まとめ

最後に、今回試行錯誤する中で大事だったことをまとめます。

  1. 長文ページのテキスト比較は textMode=self + textMatch=ratio が安定しやすい

    利用規約のような長文ページでは、Figma 側が 1 つの Text レイヤーで、DOM 側は複数タグに分かれていることが多いです。
    そのため、「コンテナごと 1 対 1 で比べる」方が ratio が素直に出やすい、という感触でした。

  2. report.jsontextMatch ブロックだけ見れば文言チェックに集中できる

    レイアウトやスタイルの差分で Quality Gate が FAIL になっていても、textMatch.equaldetails.missing / extra を見るだけで「どの文が Figma と違うか」を機械的に追いかけられます。


利用規約のような長文ページでも、Figma をその世界線での正解にして、npx @uimatch/cli compare 一発でテキストの一致・不一致を機械的にチェックできる、というのが今回の記事でたどり着きたかったところです。
自分もまだ、デザインと実装のズレをどう埋めていくかを試行錯誤している途中ですが、同じように長文の文言チェックで消耗している人の肩の荷が、この記事で少しでも軽くなったらうれしいです。

Discussion