自動検査を書いておくと安心できる。安心できるぶん、検査が見ていない領域が生まれる。

スマートフォン幅(390px)のレイアウト崩れを検出するために、横スクロールが出ていないかを調べる検査を入れていた。定番の書き方だ。

const overflows = await page.evaluate(() => {
  const el = document.documentElement;
  return el.scrollWidth > el.clientWidth;
});
expect(overflows).toBe(false);

これが全ページで緑だった。それなのに実機で開いたら、HTTPステータス一覧のツールで「5xx」のセグメントが枠の外に切れていて、押せなくなっていた。

なぜ検査に映らないのか

原因は親要素に付いていた overflow: hidden だった。

scrollWidth > clientWidth は「文書がはみ出して横に伸びているか」を見る検査だ。子要素が親をはみ出したとき、通常はそのはみ出しが上位に伝播して、最終的に documentElement の scrollWidth が広がる。だから検出できる。

ところが途中の要素に overflow: hidden があると、そこではみ出しが打ち切られる。子は確かにはみ出しているのだが、親がそれを切り取ってしまうので、文書全体の幅は変わらない。documentElement から見れば何も起きていない。

つまりこういうことだ。

状態見た目scrollWidth > clientWidth
はみ出し + overflow: visible横スクロールが出るtrue(検出できる)
はみ出し + overflow: hidden要素が切れて消えるfalse(検出できない)
はみ出しなし正常false

見た目としては後者のほうが悪い。 横スクロールなら、利用者はスクロールすれば辿り着ける。切り取られたら、そこにボタンがあること自体が分からない。それなのに検査は沈黙する。

overflow: hidden は、装飾のはみ出しを抑えるためによく書く。角丸の内側をきれいに見せる、アニメーションの飛び出しを抑える、といった目的だ。それ自体は悪くない。ただ副作用としてレイアウトのバグを隠す。

切り取られた要素を直接探す

対処は「はみ出しの結果」ではなく「はみ出している要素そのもの」を探すことだ。

const clipped = await page.evaluate(() => {
  const out = [];
  for (const el of document.querySelectorAll("*")) {
    const cs = getComputedStyle(el);
    const hidden = cs.overflowX === "hidden" || cs.overflowX === "clip";
    if (hidden && el.scrollWidth > el.clientWidth) {
      out.push({
        tag: el.tagName.toLowerCase(),
        cls: el.className && String(el.className).slice(0, 60),
        scrollWidth: el.scrollWidth,
        clientWidth: el.clientWidth,
      });
    }
  }
  return out;
});

各要素について「overflowXが隠す設定で、かつ中身が自分より広い」を調べる。これなら文書の幅に関係なく、切り取りが起きている箇所を直接列挙できる。

意図的に overflow: hidden で切っているカルーセルのような部品は当然引っかかるので、そこは除外リストを持つことになる。ただ、引っかかったものを1つずつ見て「これは意図的か」を判断する工程自体に価値がある。うちで引っかかったのは前述の1件で、それはバグだった。

もうひとつ、押せるかどうかを直接測る検査も足した。

const box = await page.locator("#target").boundingBox();
const vw = page.viewportSize().width;
// 画面の外に出ていないか
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(vw);

要素が画面の外にいるなら、boundingBox() の x が負になるか、右端が画面幅を超える。実際、アラビア語(RTL)のページでヘッダのボタンが right = -101 の位置にいて、まったく押せなくなっていたことがあった。「はみ出し」ではなく「画面外への脱出」なので、これも横スクロール検査には映らない。

実際に何が見つかったか

この検査を入れて390pxと1100pxの両方を回したら、それまでの自動テストを何度もすり抜けてきたものがまとめて出てきた。

何がどうなっていたかなぜ落ちなかったか
HTTPステータスの分類セグメント「5xx」が枠外に切れて押せない親が overflow: hidden で横スクロールが出ない
電池・モース硬度の選択中セグメント白地に白文字(1.05:1)でどれを選んでいるか読めない色は検査対象外だった
金魚すくいの金魚鉢の文字2.44:1 で読めない同上
コピーボタン類21〜30px(WCAG 2.5.8 の24px未満)同上

「白地に白文字」の原因は、CSSの詳細度だった。

Astroのスコープ付きCSSは属性セレクタのぶん詳細度が上がる。 .bg-seg label { background } と書いたものが、共有CSSの .lc-seg label:has(input:checked) { background } に勝ってしまう。背景だけ自分の指定で塗り替えられ、文字色は共有側の「選択中は白文字」が残る。結果として白地に白文字になった。

教訓としては、共有部品の見た目を上書きするときは、その部品が持っている :checked や :hover の状態も自分で書き直す。片方だけ上書きすると、状態が変わったときに組み合わせが壊れる。

測るときの注意: 半透明を合成する

コントラスト比を機械的に測るなら、半透明の背景を合成してから比較しないと嘘の指摘が大量に出る。

rgba(100, 116, 139, .14) のような薄いグレーの下地を、合成せずに「濃い色」として扱うと、その上の文字が軒並みコントラスト違反として報告される。実際には下地がほぼ透明なので、見た目には何の問題もない。

指摘が数百件出たときは、まず測定側を疑う。実装がそこまで一斉に壊れていることは、経験上あまりない。

点検の手順

最終的にこの順番で回すようにした。

  1. 実ブラウザ(Playwright)で 1100px と 390px の両方を開く。ヘルプのモーダルや <details> は開いた状態にする(閉じている要素は測れない)
  2. 全要素の文字色と背景を、半透明を合成したうえで比較し、WCAG AA(4.5:1、大きい文字は3:1)を割るものを列挙
  3. button と select の実寸が24px未満のものを列挙
  4. overflow が hidden/clip かつ scrollWidth > clientWidth の要素を列挙
  5. 直したら同じ計測を回し直して、指摘がゼロになったことを確認
  6. そのうえで画面を目で見る

6が要る。スクリーンショットは以前から撮っていたのに、見ていなかった。撮るだけでは意味がなくて、白地に白文字は人間が見れば一瞬で分かる。逆に、コントラスト比やタップ領域の寸法のように測れるものは測ったほうが確実で、目で見ても気づけない。役割が違う。

まとめ

  • documentElement.scrollWidth > clientWidth の横スクロール検査は、overflow: hidden の内側のはみ出しを見逃す
  • しかも見た目としては、横スクロールより切り取られるほうが悪い。利用者は要素の存在に気づけない
  • 「overflowが隠す設定 かつ scrollWidth > clientWidth の要素」を直接列挙する検査を足す
  • 画面外への脱出は boundingBox() の座標で測る(RTLで実際に起きた)
  • Astroのスコープ付きCSSは詳細度が上がる。共有部品を上書きするなら :checked / :hover も書き直す
  • コントラストを測るときは半透明を合成する。指摘が多すぎるときは測定側を疑う
  • 測れるものは測り、そのうえで必ず目で見る

自動検査は「書いた範囲」しか守ってくれない。そして守られていない範囲は、検査が緑であるぶんだけ見えにくくなる。