Claude Media
Claude CodeでWCAG 2.2のターゲットサイズとフォーカス隠れをPlaywrightで測る

Claude CodeでWCAG 2.2のターゲットサイズとフォーカス隠れをPlaywrightで測る

WCAG 2.2で加わった2.5.8(24px)と2.4.11(フォーカスが隠れない)を、Playwrightのスクリプトで測る手順です。結果をClaude Codeに読ませて直させる流れも組みます。

WCAG 2.2で加わったAAの基準のうち、画面を測れば判定できるものが2つあります。2.5.8のターゲットサイズ(最小)と、2.4.11のフォーカスが隠されない(最小)です。前者はポインターで押す対象が24×24 CSSピクセル以上あるか、後者はTabで着いた要素が固定ヘッダーなどに完全には隠れていないかを問います。

どちらも、ボタンの大きさと「いまフォーカスがある要素がどこに描かれているか」という数値だけで判定の大半が済みます。Playwrightで測るスクリプトを書き、結果をClaude Codeに渡して直させる構成にすれば、2.1までの検査では見えなかった違反をPRで拾えます。

2.5.8と2.4.11は何を満たせば合格か

測る前に、基準の文面を押さえます。W3Cの仕様書(WCAG 2.2)にある文言は次のとおりです。

基準レベル要求
2.5.8ターゲットサイズ(最小)レベルAA要求ポインター入力の対象は24×24 CSSピクセル以上。ただし例外あり
2.4.11フォーカスが隠されない(最小)レベルAA要求フォーカスを受けた部品が、作成者の置いたコンテンツに完全には隠れない
2.4.12フォーカスが隠されない(拡張)レベルAAA要求フォーカスを受けた部品のどの部分も隠れない

2.5.8には5つの例外があります。スクリプトで判定できるのは最初の1つだけで、残りは人が見ます。

  • 間隔: 24px未満の対象でも、その外接矩形の中心に直径24pxの円を置いたとき、円が他の対象や、他の小さな対象の円と交わらなければ合格です
  • 同等: 同じページに、同じ機能を果たす基準適合の別の部品がある
  • インライン: 文中にある、または行の高さに大きさが制約される対象
  • ユーザーエージェント制御: 大きさをブラウザーが決め、作成者が変えていない
  • 本質的: その表示が情報の伝達に不可欠、または法的に求められる

2.4.11は文面が「完全には隠れない」です。一部でも見えていれば2.4.11は満たされ、全体が見えることまで求めるのは2.4.12(AAA)です。また注として、ユーザーが開いたコンテンツがフォーカス要素を覆う場合、フォーカスを動かさずに見える状態へ戻せるなら、隠れたとは数えないとされています。

数字の境目は覚えやすい形です。24pxはAAの最低線で、44pxは2.5.5(AAA)の水準になります。2.5.5とのレベルの食い違いは、accessibility-reviewスキルの解説で扱っています。

axe-coreのtarget-sizeと自前スクリプトの分担

axe-coreには target-size という規則があります。規則一覧では、タグは wcag22aa と wcag258、結果は「failure」と「needs review」の2種です。つまりaxeは2.5.8を検査でき、確信が持てないものを要確認に回します。既存の構成なら、withTags() に wcag22aa を足すだけで走ります。足し方はPlaywrightとaxeのCI記事にあります。

では自前で測る意味はどこにあるのか。次の3点です。

  • 規則一覧で wcag22aa タグを持つ規則は target-size の1つだけです。2.4.11に対応する規則は載っていないので、フォーカスの隠れは別の方法で測ります
  • axeの結果は「規則IDとセレクタ」で、どの要素がどの隣と近すぎるかまでは短く出ません。自前なら、違反の相手まで一行に出せます
  • 測り方が見えているので、例外の扱い(インライン、既知の許容)を自分のプロジェクトに合わせて変えられます

おすすめの分担は、axeを広く浅い網として残し、自前の2本を「数値で説明できる補助」として足す形です。axeの needs review と自前の結果が食い違ったら、その要素を人が見ます。

ターゲットサイズを測る(2.5.8)

測る対象は、クリックやタップで押す要素です。a[href]・button・フォーム部品・role で押せることを示した要素・tabindex で操作対象にした要素を拾います。大きさは、ブラウザーが計算した矩形で見ます。

Playwrightには locator.boundingBox() があり、要素の矩形をメインフレームのビューポート基準で返します(見えない要素は null)。ここでは全要素の矩形と近接関係をまとめて計算したいので、ページの中で getBoundingClientRect() を一括で呼びます。

間隔の例外は、小さい対象Aについて次の順で判定します。

  1. 他の対象Bが24px以上なら、Aの中心からBの矩形までの距離が12px未満で「近すぎる」
  2. Bも24px未満なら、2つの中心間の距離が24px未満で「近すぎる」
  3. 親子関係にある要素どうしは数えない
// tests/a11y/wcag22/target-size.spec.ts
import { test, expect } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
 
const PAGES = ['/', '/pricing', '/login'];
 
for (const path of PAGES) {
  test(`2.5.8 target size: ${path}`, async ({ page }) => {
    await page.goto(path);
    await page.getByRole('main').waitFor();
 
    const result = await page.evaluate(() => {
      const SELECTOR = [
        'a[href]', 'button', 'input:not([type=hidden])', 'select', 'textarea',
        'summary', '[role=button]', '[role=link]', '[role=checkbox]',
        '[role=tab]', '[role=menuitem]', '[tabindex]:not([tabindex="-1"])',
      ].join(',');
      const MIN = 24;
 
      const name = (el: Element) => {
        const id = el.id ? `#${el.id}` : '';
        const cls = typeof el.className === 'string' && el.className.trim()
          ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.')
          : '';
        return el.tagName.toLowerCase() + id + cls;
      };
 
      const items = [...document.querySelectorAll(SELECTOR)].flatMap((el) => {
        const r = el.getBoundingClientRect();
        const cs = getComputedStyle(el);
        if (r.width === 0 || r.height === 0 || cs.visibility === 'hidden') return [];
        const hasSiblingText = [...(el.parentNode?.childNodes ?? [])]
          .some((n) => n.nodeType === 3 && (n.textContent ?? '').trim() !== '');
        return [{ el, r, sel: name(el), inline: cs.display === 'inline' && hasSiblingText }];
      });
 
      const small = (i: (typeof items)[number]) => i.r.width < MIN || i.r.height < MIN;
      const center = (i: (typeof items)[number]) => ({
        x: i.r.x + i.r.width / 2,
        y: i.r.y + i.r.height / 2,
      });
 
      const failures: unknown[] = [];
      const inlineReview: unknown[] = [];
      let passedBySpacing = 0;
 
      for (const s of items.filter(small)) {
        const c = center(s);
        const crowdedBy = items
          .filter((o) => o.el !== s.el && !o.el.contains(s.el) && !s.el.contains(o.el))
          .filter((o) => {
            if (small(o)) {
              const oc = center(o);
              return Math.hypot(c.x - oc.x, c.y - oc.y) < MIN;
            }
            const dx = Math.max(o.r.left - c.x, 0, c.x - o.r.right);
            const dy = Math.max(o.r.top - c.y, 0, c.y - o.r.bottom);
            return Math.hypot(dx, dy) < MIN / 2;
          })
          .map((o) => o.sel);
 
        const row = {
          sel: s.sel,
          size: `${s.r.width.toFixed(1)}x${s.r.height.toFixed(1)}`,
          crowdedBy: crowdedBy.slice(0, 3),
        };
        if (crowdedBy.length === 0) passedBySpacing++;
        else if (s.inline) inlineReview.push(row);
        else failures.push(row);
      }
      return { total: items.length, passedBySpacing, failures, inlineReview };
    });
 
    mkdirSync('a11y-report', { recursive: true });
    writeFileSync(
      `a11y-report/target-size-${path.replace(/\W/g, '_')}.json`,
      JSON.stringify(result, null, 2),
    );
    expect(result.failures, '24px未満で、間隔の例外も成り立たない対象').toEqual([]);
  });
}

出力は、違反が出たときだけ人間とClaudeが読める短さになります。次のような形です(要素名と数値はページ次第で変わる例です)。

{
  "total": 41,
  "passedBySpacing": 3,
  "failures": [
    { "sel": "button.icon-close", "size": "16.0x16.0", "crowdedBy": ["button.icon-edit"] }
  ],
  "inlineReview": []
}

読み方は単純です。failures は、小さく、しかも隣と近すぎる対象です。passedBySpacing は、小さいが周囲に十分な余白があって間隔の例外で通っている数で、増減の目安になります。inlineReview は文中のリンクで、インライン例外に当たる可能性が高いので落とさず、目で見ます。

Tabで辿ってフォーカスの隠れを測る(2.4.11)

こちらは「フォーカスを当てた直後の画面」を測ります。静的に要素を数えても、固定ヘッダーとの重なりは分かりません。ブラウザーはTabで着いた要素をスクロールして見える位置へ運びますが、運んだ先が固定ヘッダーの裏なら、要素は隠れます。

手順は、Tabを押し、スクロールが落ち着くのを待ち、フォーカス中の要素の矩形を縦横5点ずつ(計25点)サンプルして、各点の最上面の要素が自分自身か子孫かを調べます。最上面の要素を引く document.elementFromPoint() は、ビューポート座標を取り、pointer-events: none の要素を無視します。ビューポートの外の点は null になるので、画面外も「見えていない」側に数えます。

// tests/a11y/wcag22/focus-not-obscured.spec.ts
import { test, expect } from '@playwright/test';
import { mkdirSync, writeFileSync } from 'node:fs';
 
const PAGES = ['/', '/pricing', '/login'];
const VIEWPORTS = [
  { name: 'desktop', width: 1280, height: 720 },
  { name: 'mobile', width: 390, height: 844 },
];
 
for (const vp of VIEWPORTS) {
  for (const path of PAGES) {
    test(`2.4.11 focus not obscured: ${vp.name} ${path}`, async ({ page }) => {
      await page.setViewportSize({ width: vp.width, height: vp.height });
      await page.goto(path);
      await page.getByRole('main').waitFor();
      await page.evaluate(() => { (window as any).__seen = new Set<Element>(); });
 
      const hidden: unknown[] = [];
      const partial: unknown[] = [];
 
      for (let i = 0; i < 300; i++) {
        await page.keyboard.press('Tab');
        // スクロールの完了を2フレーム待つ
        await page.evaluate(() => new Promise<void>((resolve) =>
          requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
 
        const r = await page.evaluate(() => {
          const a = document.activeElement;
          const seen = (window as any).__seen as Set<Element>;
          if (!a || a === document.body || seen.has(a)) return { done: true as const };
          seen.add(a);
 
          const box = a.getBoundingClientRect();
          const fr = [0.1, 0.3, 0.5, 0.7, 0.9];
          let visible = 0;
          let cover = '';
          for (const fy of fr) for (const fx of fr) {
            const x = box.left + box.width * fx;
            const y = box.top + box.height * fy;
            const inside = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight;
            const hit = inside ? document.elementFromPoint(x, y) : null;
            if (hit && a.contains(hit)) visible++;
            else if (hit && !cover) cover = hit.tagName.toLowerCase() + (hit.id ? `#${hit.id}` : '');
          }
          const id = a.id ? `#${a.id}` : '';
          return { done: false as const, sel: a.tagName.toLowerCase() + id, visible, cover };
        });
 
        if (r.done) break;
        if (r.visible === 0) hidden.push(r);
        else if (r.visible < 25) partial.push(r);
      }
 
      mkdirSync('a11y-report', { recursive: true });
      writeFileSync(
        `a11y-report/focus-${vp.name}-${path.replace(/\W/g, '_')}.json`,
        JSON.stringify({ hidden, partial }, null, 2),
      );
      expect(hidden, 'フォーカスを受けたのに全体が隠れた要素').toEqual([]);
    });
  }
}

落とすのは hidden(25点すべてが隠れた)だけです。partial は一部が隠れた状態で、2.4.11の文面では適合ですが、2.4.12(AAA)では不適合になります。レポートには出し、PRを止めない設計にしました。

固定ヘッダーが原因なら、cover に header などの名前が出ます。モバイル幅で出やすいのは、ヘッダーが2段になったり、画面下の固定バーが加わったりして、覆う面積が増えるからです。ビューポートを2つ回す理由はそこにあります。

結果をClaude Codeに読ませて直させる

測定が短いJSONを吐くので、直す側は claude -p で足ります。非対話モードの -p は、--allowedTools で承認なしに使えるツールを指定できます。許可したのは、結果の読み取り、ソースの編集、テストの再実行だけです。Bash(npx playwright test *) の末尾の * は前方一致で、空白を落とすと別のコマンドにも一致するため、空白を入れておきます。

#!/usr/bin/env bash
# scripts/fix-wcag22.sh
for round in 1 2 3; do
  if npx playwright test tests/a11y/wcag22 --reporter=line; then
    echo "WCAG 2.2の2検査に合格"; exit 0
  fi
  claude -p "tests/a11y/wcag22 が落ちています。a11y-report/*.json を読み、
次の方針で修正して。
- 2.5.8: failures の要素を、min-width/min-height 24px 以上にするか、
  隣との間隔を空ける。見た目が変わる場合は変更点を要約に書く
- 2.4.11: hidden の要素が隠れる原因を CSS で直す。
  対象のテストや期待値は変更しない
- 修正後に npx playwright test tests/a11y/wcag22 で再確認する" \
    --allowedTools "Read,Edit,Bash(npx playwright test *)" \
    --output-format json | jq -r '.result'
done
exit 1

--output-format json にすると応答はJSONで返り、jq -r '.result' で本文だけを取り出せます。同じJSONには total_cost_usd も入るので、ラウンドごとの費用の推定にも使えます(ただし推定値で、請求額とずれる場合があります)。3ラウンドで止めているのは、直らない違反で無限に回さないためです。

実行環境ごとの注意点が1つあります。-p の実行は、プロジェクトの .claude/settings.json のhooksを読み、.mcp.json のサーバーにも接続します。CIで使うなら --bare を付けて、ホストの設定やCLAUDE.mdの自動読み込みを避けます。--bare ではOAuthの認証情報を読まないため、ANTHROPIC_API_KEY を環境変数で渡します。プロジェクト固有の指示は、プロンプトか --append-system-prompt-file に入れます。

普段のローカル実行ではCLAUDE.mdが効くので、毎回のプロンプトに書かない規約はそちらへ寄せます。

## アクセシビリティ(WCAG 2.2)
 
- クリック対象は24×24 CSSピクセル以上。小さいアイコンボタンは
  padding で当たり判定を広げ、見た目のアイコンは変えない
- 固定ヘッダーやバナーを足すときは、同じ高さを scroll-padding-top に入れる
- tests/a11y/wcag22 の期待値(24、25点)を緩めて通さない

直し方の定番2つ

測定で出る違反の大半は、次の2つで片付きます。

ターゲットサイズ。見た目を変えずに当たり判定だけ広げるのが、デザインとの衝突が少ない直し方です。

前と後

アイコンボタンの当たり判定

BEFORE

16pxのアイコンがそのままボタン

幅と高さがアイコンの16pxのまま。隣のボタンと8px間隔で並び、間隔の例外も成り立たない。

AFTER

padding で24px以上に

padding を足して幅・高さを24px以上にする。アイコンの描画サイズは16pxのままで、見た目はほぼ変わらない。

.icon-button {
  min-width: 24px;
  min-height: 24px;
  padding: 4px; /* 16pxのアイコン + 両側4px = 24px */
}

隣との間隔を空ける方法もあります。ただし、24px円どうしが交わらない距離を確保する必要があり、狭いツールバーでは大きくするほうが早くなります。

フォーカスの隠れ。固定ヘッダーの高さを scroll-padding-top に入れます。このプロパティは、スクロールポートの中で「要素を見せるときの有効な領域」の上端を内側へずらし、固定ツールバーなどに覆われた領域を除外するものです。

html {
  scroll-padding-top: 72px; /* 固定ヘッダーの高さ */
}

ヘッダーの高さがメディアクエリで変わるなら、同じ場所で値も切り替えます。高さを変数で持てば、ヘッダー側とずれる事故が減ります。

落とし穴

見た目を直しても、測定の期待値を緩めると意味がなくなる

Claudeに「テストを通して」とだけ頼むと、24を20に変える、検査対象のページから問題の要素を外す、といった近道が候補になりえます。プロンプトとCLAUDE.mdに「期待値と対象ページは変えない」と書き、PRの差分でテストファイル自体が変わっていないかを確認します。ベースラインの書き換えを止めるフックの組み方は、axe記事のフックの節にあります。

機械で判定できない部分と、誤検知の出どころ

このスクリプトは、2つの基準の全部を判定するものではありません。結果を読むときの限界を挙げます。

  • 例外の判断: 同等・ユーザーエージェント制御・本質的の3つは判定していません。ブラウザー標準のチェックボックスのように、作成者が大きさを変えていない部品は、failures に出ても例外に当たる場合があります
  • インライン判定は粗い: 文中のリンクの判定は、「兄弟にテキストノードがある display: inline の要素」という簡易な条件です。リストの中のリンクのように、文の外にあるものは failures に出ます
  • 覆い隠すものの種類: elementFromPoint() は pointer-events: none の要素を無視します。操作を通す透明な固定バナーは、見た目では隠れていても測定上は隠れていない扱いになります
  • ユーザーが開いたもの: ドロップダウンなど、利用者が開いたコンテンツがフォーカス要素を覆う場合の扱いは、2.4.11の注にあるとおり、動かさずに戻せるかで決まります。ここは人が判断します
  • ページ全体の状態: ログインが要る画面は、認証済みの状態を使い回す設定が前提です。手順はstorageStateの記事にあります
  • キーボードで届かない要素: Tabで到達できない要素は、そもそもこの測定の対象外です。到達順や操作可否は、キーボード操作の監査記事の手順で別に確認します

数字の誤差にも注意します。WCAGの24pxはCSSピクセルで数えるので、ズームしても判定の数値は変わりません。ただしズームでビューポートが狭くなるとレイアウトが組み替わり、ボタンの並びや固定ヘッダーの高さが変わることがあります。拡大時の表示は手で確かめます。

使い分けと運用の置き場所

毎回のPRで回すなら、axeの wcag22aa タグと、この2本の自前検査を同じジョブに置くのが現実的です。実行時間は、対象ページ数×ビューポート数に比例するので、全画面を毎回回すより、主要な導線とデザインシステムのコンポーネント一覧(Storybookなど)に絞ります。

役割の整理は次のとおりです。

手段向く用途見えないもの
axeの target-size向く用途2.5.8の広い網。PRの合否見えないもの2.4.11、例外の最終判断
自前のターゲットサイズ測定向く用途違反の相手まで特定、独自の許容見えないもの同等・本質的などの例外
自前のフォーカス隠れ測定向く用途2.4.11の固定ヘッダー被り見えないものユーザーが開いた領域の扱い
手動確認向く用途例外の判断、実機での操作見えないもの数が多いと追えない

WCAG 2.1 AAの監査を一通り回す場合は、accessibility-reviewスキルから入ると、基準の全体像を先に掴めます。この記事の2本は、そこに載らない2.2の追加基準を数値で埋める位置づけです。

まとめ

2.5.8は「24px未満の対象が、隣と近すぎないか」、2.4.11は「Tabで着いた要素の全体が隠れていないか」を数えれば、機械で判定できる範囲が決まります。前者は矩形と中心間の距離、後者はTab後の elementFromPoint() のサンプルで足ります。

Claude Codeに渡すのは、違反の要素と相手だけを書いた短いJSONです。直させるときの最大の注意は、期待値や対象ページを動かして通さないことです。例外の判断が必要な項目は、最後まで人の目に残ります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. w3.org/TR/WCAG22
  2. code.claude.com/docs/en/headless
  3. playwright.dev/docs/api/class-locator
  4. github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md
  5. developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint
  6. developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/scroll-padding-top
この記事を共有:XはてブLinkedIn