Claude Codeでリフローを検査する — 320px幅の横スクロールを洗い出して直す
WCAG 1.4.10のリフローとテキスト間隔を、Playwrightで幅320pxに絞って検査します。横スクロールの原因要素を抜き出し、Claude Codeの非対話実行で直させる手順です。
リフローの検査は、ブラウザーの幅を320pxにして、横スクロールが出る要素を数えるだけで始められます。400%ズームを手で繰り返す代わりに、Playwrightのスクリプトで幅320pxの画面を開き、はみ出した要素を一覧にします。その一覧をClaude Codeに渡せば、固定幅や折り返しなしの指定を直させられます。
手作業のズーム確認は、画面ごとに目で見るので漏れます。この記事は、検査を1本のスクリプトにして終了コードで合否を返し、Claude Codeの非対話実行で修正まで回す流れを組みます。
リフローの基準は320pxと256px
リフローとは、画面を拡大しても横方向のスクロールなしで読めることです。WCAG 2.2の達成基準1.4.10(レベルAA)は、情報や機能を失わず、2方向のスクロールを要求しないことを求めます。数値は縦に流れる内容なら幅320 CSSピクセル、横に流れる内容なら高さ256 CSSピクセルです。
検査の基準値
縦スクロールの内容
幅320px
CSSピクセル
横スクロールの内容
高さ256px
縦書きなど
400%ズームの起点
幅1280px
1280÷4=320
400%ズームという言い方は、この換算から来ています。幅1280pxのウィンドウを400%に拡大すると、レイアウトは幅320pxで組まれます。ズーム操作を再現するより、ビューポートを320pxに固定するほうが、自動化では確実です。
例外もあります。地図や図表のように、2次元のレイアウトが意味を持つ内容は、2方向のスクロールが認められます。注記では、動画、ゲーム、プレゼンテーション、データ表(個々のセルではなく表)、操作中にツールバーを見せ続ける画面が挙がっています。
テキスト間隔は別の基準
達成基準1.4.12(レベルAA)は、利用者が文字の間隔を上書きしても内容や機能が失われないことを求めます。上書きする値は次の4つで、ほかのスタイルは変えません。
| 項目 | 下限の値 |
|---|---|
| 行の高さ | 下限の値フォントサイズの1.5倍 |
| 段落の後ろの間隔 | 下限の値フォントサイズの2倍 |
| 文字の間隔 | 下限の値フォントサイズの0.12倍 |
| 単語の間隔 | 下限の値フォントサイズの0.16倍 |
注記には、コンテンツ側がこの値を使う必要はないとあります。求められているのは、利用者が上書きしたときに崩れないことです。幅320pxで間隔を広げると、折り返しが増えて、固定の高さや幅の箱から文字がはみ出しやすくなります。2つの基準を同じ画面幅で続けて測るのは、そのためです。
横スクロールの原因要素をPlaywrightで抜き出す
検査スクリプトは、幅320pxのコンテキストでページを開き、画面の右端を越えた要素を集めます。Playwrightのpage.setViewportSize()のドキュメントは、ビューポートを遷移の前に決めるよう書いています。複数のページを持つ場合はbrowser.newContext()で一括指定できるため、ここではコンテキストのviewportに320x800を渡します。
次のスクリプトは、リフローとテキスト間隔の2回を測って、JSONを標準出力に書きます。
npm install --save-dev playwright
npx playwright install chromiumimport { chromium } from "playwright";
const url = process.argv[2];
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 320, height: 800 },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "load" });
const measure = () => page.evaluate(() => {
const vw = document.documentElement.clientWidth;
const label = (el) =>
el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") +
[...el.classList].map((c) => "." + c).join("");
const inScroller = (el) => {
for (let p = el.parentElement; p; p = p.parentElement) {
const ox = getComputedStyle(p).overflowX;
if (ox === "auto" || ox === "scroll") return true;
}
return false;
};
const wide = new Set();
for (const el of document.body.querySelectorAll("*")) {
const r = el.getBoundingClientRect();
if (r.width > 0 && (r.right > vw + 1 || r.left < -1) &&
!inScroller(el)) wide.add(el);
}
const leaves = [...wide].filter(
(el) => ![...el.children].some((c) => wide.has(c)));
const clipped = [...document.body.querySelectorAll("*")].filter(
(el) => getComputedStyle(el).overflowX === "hidden" &&
el.scrollWidth > el.clientWidth + 1);
const info = (el) => ({
selector: label(el),
width: Math.round(el.getBoundingClientRect().width),
whiteSpace: getComputedStyle(el).whiteSpace,
text: (el.textContent || "").trim().slice(0, 30),
});
return {
viewport: vw,
pageScrollWidth: document.documentElement.scrollWidth,
wide: leaves.slice(0, 30).map(info),
clipped: clipped.slice(0, 30).map(info),
};
});
const base = await measure();
await page.addStyleTag({ content: `
* { line-height: 1.5 !important; letter-spacing: 0.12em !important;
word-spacing: 0.16em !important; }
p { margin-bottom: 2em !important; }
` });
const spacing = await measure();
await browser.close();
console.log(JSON.stringify({ url, base, spacing }, null, 2));
const bad = (m) => m.pageScrollWidth > m.viewport ||
m.wide.length > 0 || m.clipped.length > 0;
process.exit(bad(base) || bad(spacing) ? 1 : 0);はみ出した要素は、親も一緒に画面を越えます。そのためwideには、子にはみ出し要素を持たない末端だけを残しています。原因は末端の要素にあるからです。preやスクロール枠(overflow-x: auto)の内側は、枠の中で横スクロールするため数えません。
clippedは、横方向にはみ出した分がoverflow: hiddenで切り取られている要素です。ページ全体に横スクロールが出ないぶん見落としやすく、文字が読めなくなっていれば情報の欠落にあたります。
実行は、開発サーバーを起動してURLを渡すだけです。
node reflow-check.mjs http://localhost:3000/出力のbaseが通常の間隔、spacingが間隔を上書きした状態の結果です。pageScrollWidthがviewportより大きければ、ページ全体に横スクロールが出ています。1箇所でも該当があれば終了コードは1になり、CIで合否に使えます。
測る画面を決める
1ページだけ検査しても、コンポーネントの崩れは拾えません。トップ、記事本文、フォーム、表を含む画面など、レイアウトの型ごとに1つずつURLを選び、シェルのループで同じスクリプトを回します。画面ごとに結果をファイルへ出しておくと、あとでClaude Codeに渡しやすくなります。
直し方は原因別に決まる
抜き出した要素は、見つかった型ごとに直し方が決まります。
| 原因 | 見つかる値 | 直し方 |
|---|---|---|
| ピクセル固定の幅 | 見つかる値widthが320を超える | 直し方max-width: 100%を足す、またはwidthを割合にする |
| 折り返さない指定 | 見つかる値whiteSpaceがnowrap | 直し方white-space: normalに戻す |
| 長いURLや英単語 | 見つかる値折り返し位置がない | 直し方overflow-wrap: anywhereを指定する |
| 折り返さないflex行 | 見つかる値子が縮まない | 直し方flex-wrap: wrapを指定する |
| 幅いっぱいの画像・動画 | 見つかる値元サイズのまま | 直し方max-width: 100%とheight: auto |
overflow-wrap: anywhereは、折り返せる位置がない長い文字列を途中で分割します。MDNによると、この折り返し位置は最小内容幅(min-content)の計算にも数えられます。そのためflexやgridの子要素の最小幅が小さくなり、親の幅まで縮められるようになります。同じ効果を狙えるbreak-wordは、最小内容幅の計算に折り返し位置を数えません。
flexやgridの子要素は、min-widthがautoのとき、内容の最小幅より小さくなりません。長い文字列を含むカードが親から飛び出すのは、この仕様が原因のことがあります。子にmin-width: 0を指定すると、親の幅に合わせて縮められるようになります。
固定幅の例を、前後で並べます。
/* 直す前: 320px幅の画面からはみ出す */
.card { width: 480px; }
.tag { white-space: nowrap; }
.row { display: flex; }
/* 直した後 */
.card { width: 100%; max-width: 480px; }
.tag { white-space: normal; overflow-wrap: anywhere; }
.row { display: flex; flex-wrap: wrap; }width: 480pxをmax-width: 480pxに置き換えるだけでも直ります。ただwidth: 100%を併記すると、広い画面での見た目を保ったまま、狭い画面で親に追従します。
検査結果をClaude Codeに渡して直させる
スクリプトの出力は、wideとclippedに要素名と幅が並ぶJSONです。これをClaude Codeの非対話実行(-p)に渡して、該当するCSSを探して直させます。公式ドキュメントには、データをパイプで渡す書き方と、--allowedToolsでツールを事前承認する書き方があります。
node reflow-check.mjs http://localhost:3000/ > reflow.json
cat reflow.json | claude -p \
"320px幅の検査結果です。wideとclippedの要素を、CSSの修正で
解消してください。表・地図・コードブロックは枠内スクロールで
残し、それ以外の固定幅とnowrapを直します。" \
--allowedTools "Read,Edit"claude -pは成功すると終了コード0、失敗すると0以外で終わるため、スクリプトの中で分岐に使えます。v2.1.302以降のclaude -pは、権限モードを何も指定しないとManualモードで始まります。承認が必要な呼び出しは拒否されるので、読み取りと編集を--allowedToolsで許可しています。Bashは許可していません。検査の再実行は人かCIが担当し、Claude Codeの権限は最小に保てます。
何を直したかをスクリプトで受け取りたいときは、--output-format jsonと--json-schemaを組み合わせます。
cat reflow.json | claude -p "wideとclippedを直して、変更した
ファイルと見送った要素を報告してください" \
--allowedTools "Read,Edit" \
--output-format json \
--json-schema '{"type":"object","properties":{"changed":{"type":"array","items":{"type":"string"}},"skipped":{"type":"array","items":{"type":"string"}}},"required":["changed","skipped"]}' \
| jq '.structured_output'応答にスキーマに合う出力が得られなかったとき(依頼を断った場合など)は、structured_outputが付きません。後続の処理は、このフィールドがあることを確かめてから使います。
CLAUDE.mdに検査の約束を書く
毎回プロンプトに書くより、プロジェクトのCLAUDE.mdに置くほうが、画面を触るたびに検査が回ります。
## リフロー検査(幅320px)
- 画面のCSSを変えたら `node reflow-check.mjs <URL>` を実行する
- 終了コードが1のときは、出力の wide と clipped を直してから再実行する
- 固定幅は max-width に置き換え、nowrap は理由がなければ外す
- 表・地図・図・コードブロックは枠内スクロールで残してよい最後の1行は、例外の範囲を先に伝えるためのものです。書いておけば、基準の例外にあたる表や地図まで直そうとする作業を、最初から除けます。
落とし穴
直したつもりが、はみ出しを隠しただけになる
overflow: hiddenをページ全体や親要素に足すと、横スクロールは消えます。文字が切り取られただけで、基準が求める「情報を失わない」には反します。clippedの検査はこの隠し方を拾うためのものです。Claude Codeに指示するときも、overflow: hiddenで解決しないことを一言添えます。
テキスト間隔で崩れるのは固定高さの箱
幅320pxで通っても、spacingの結果でclippedが出ることがあります。典型は、widthをピクセルで固定してwhite-space: nowrapとoverflow: hiddenを指定したボタンやバッジです。文字間隔が0.12emになると、同じ文字数でも幅が足りなくなり、末尾が切り取られます。
heightをピクセルで固定した箱や、line-clampで行数を絞ったカードは、行の高さが1.5倍になると縦に収まらなくなります。ただしreflow-check.mjsのclippedは横方向の切り取りだけを見るので、縦のあふれは出力に現れません。spacingを適用した画面を目で確かめます。
直し方は、heightをmin-heightに置き換え、余白はpaddingで作ることです。行数を絞る表示は、隠れた文字が情報として必要かどうかで判断します。必要なら絞らず、不要なら装飾として残せます。
日本語だけのページでは、単語の間隔が見た目に出ないことがあります。1.4.12には、あるプロパティを使わない言語と文字体系では、存在するプロパティだけで適合できるという例外があります。検査スクリプトは4つとも上書きしていますが、結果の読み方は対象サイトの言語に合わせます。
直さなくてよいもの、別の検査に任せるもの
横スクロールが残っても、基準の例外にあたるものがあります。データ表と地図は、枠の中で横にスクロールする作りで構いません。スクリプトはoverflow-x: autoの内側を数えないので、表を枠で包んでおけば検査にも出なくなります。
2つの基準はレイアウトの崩れを測るもので、色のコントラストやラベルの欠落は測りません。そちらはaxe-coreなどの検査の役割です。PlaywrightとaxeをPRで回す構成はClaude CodeでPlaywrightとaxeのアクセシビリティ検査をPRで落とすに、セッション内で対話的に回す方法はClaude CodeのAxe Accessibilityプラグインでアクセシビリティ検査を回すにまとめています。
リフローの検査も、同じPlaywrightのテストに載せられます。pageScrollWidthがviewportを超えたら失敗とするだけで、PRごとに回帰を止められます。
まとめ
リフローは、幅320pxで横スクロールが出る要素を数える検査に落とせます。400%ズームは幅1280pxの起点と同じ配置になるため、ズーム操作の再現は要りません。スクリプトの出力をClaude Codeの非対話実行に渡し、読み取りと編集だけを許可して直させれば、検査と修正が分かれた形で回ります。
固定幅の置き換えとnowrapの解除で、多くの崩れが解消します。直らないものは、表や地図のように例外にあたるか、固定高さの箱のように設計を変える必要があるかのどちらかです。判断が分かれる箇所は、skippedとして人に返す形にしておくと、Claude Codeが隠し方で解決する事態を避けられます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。