著者: 著者: やまねこ タグ:
藍色の奥パネルと薄灰色の手前パネルが重なるアイソメトリック図。手前のパネルが優先され、重なり部分だけ暗くならない様子を表現したイラスト

axeもPlaywrightのスクリーンショット取得も、ダークモードのアクセシビリティテストはずっとパスしていました。classList.add('darkmode')を仕込んだテストコードは、何年も「正しく検査できている」ように見えていました。

ところが実際には、テストコードが再現していたのは実装のごく一部だけでした。CSSは正しいのに、ダーク側のコントラスト検査だけがずっと素通りしていたのです。原因はインラインstyleにありました。この記事では、自社ブログで実際に起きた発見と修正の記録を通じて、light-dark()時代のダークモードテストに潜む盲点を解説します。

ざっくりまとめ

  • class付与だけでダークモードを再現するテストは、実装がインラインstyleでcolor-schemeを切り替えている場合、検査漏れを起こします。
  • インラインstyleはCSSカスケードでクラスセレクタより常に優先されます。
  • 「見た目が暗くなる」ことと「light-dark()トークンが正しく解決されている」ことは別の現象です。
  • CSSは正しいのにcomputed styleが変わらないときは、まずインラインstyleを疑ってください。

何が起きていたのか — classは効くのにlight-dark()は動かない

自社ブログ(8091.info)のダークモードのアクセシビリティテストは、classList.add('darkmode')だけでダークモード状態を再現していました。この状態でページを見ると、.darkmodeクラスに依存するCSSルールは正しく暗くなります。ところが、light-dark()関数を使ったトークンだけが、常にlight側の値のまま解決されていました。

見た目のコントラストは一見変化しているのに、CSS変数レベルでは「クラスベースのルールだけが暗くなり、light-dark()トークンは暗くならない」というハイブリッドな描画になっていたわけです。この状態でaxeを実行すると、ダーク側のコントラスト検査は、実際にはlight側のまま解決された色を検査していました。ダーク側の検査が、実質的に無効化されていたことになります。

発覚したのは、2026年7月のリデザイン作業(和風のOKLCHカラートークンへの刷新、ダークモード実装の一本化)の最中でした。トークンの実装を見直している過程で、テストコードが再現しているダークモードの状態と、実際のコンポーネントが作り出すダークモードの状態が一致していないことに気づきました。このリポジトリの設計を扱った記事としては、AIエージェントのコンテキスト設計をまとめた記事もあります。

CSSカスケードの優先順位 — インラインstyleがクラスより勝つ理由

CSSには、同じ要素に複数のルールが競合したとき、どれを採用するかを決めるカスケードの規則があります。インラインstyleはクラスセレクタより詳細度が高く、!importantが絡まない限り優先されます。

MDNのCSS詳細度のページでは、インラインstyleがID・クラス・要素セレクタのどの組み合わせよりも優先されることが明記されています(参考: Specificity - CSS | MDN)。今回のケースに当てはめると、html要素にstyle="color-scheme: light"がインラインで付与されている状態では、CSS側で.darkmode { color-scheme: dark; }というルールを定義していても、インラインの指定が勝ちます。

light-dark()関数自体は、その時点のcolor-schemeの値に応じて、lightとdarkどちらの値を採用するかを決める関数です(参考: light-dark() - CSS | MDN)。つまりcolor-schemeの解決結果に、light-dark()の出力が直結します。color-schemeがインラインでlight固定になっていれば、CSS側でどれだけダークモード用のルールを整えても、light-dark()はlightの値しか返しません。

DarkModeコンポーネントの実装を読む — classListとstyle.colorSchemeのセット

問題の核心は、実装とテストコードの間で「ダークモードの再現方法」に食い違いがあったことです。

自社ブログで使っているaccessible-astro-componentsのDarkModeコンポーネントは、ダークモードの切り替え時にクラス操作とインラインstyleの両方を実行します。ダークモードを有効化するenableDarkMode関数は、root.classList.add('darkmode')に加えて、root.style.colorScheme = 'dark'をインラインで設定します。無効化するdisableDarkMode関数も同様に、root.classList.remove('darkmode')root.style.colorScheme = 'light'をセットで実行します(rootdocument.documentElement、つまりhtml要素です)。

実装は、クラスとインラインstyleの両方を常にセットで切り替える設計になっているわけです。一方、修正前のテストコードはclassList.add('darkmode')だけを実行していました。この非対称性が、検査漏れの直接の原因でした。

ここまでのポイント

インラインstyleはクラスより常に優先されます。実装がインラインでcolor-schemeを切り替えるなら、テストコードもそれを再現しない限り、実際の状態を検査したことにはなりません。ここからは、実際に何が起きていて、何を修正したのかを見ていきます。

テストコードは何を再現していたのか — 修正前のcontrast-check.js

修正前のコントラスト検査スクリプト(contrast-check.js)は、ダークモードを次のように再現していました。

if (mode === 'dark') {
  await page.evaluate(() => {
    document.documentElement.classList.add('darkmode')
  })
}

classList.add('darkmode')だけを実行し、style.colorSchemeの設定は行っていません。この状態でも.darkmodeクラスに依存する大半のCSSルールは正しく暗くなって見えるため、目視確認やスクリーンショット比較だけでは違和感に気づきにくいものでした。影響を受けるのはlight-dark()トークンを使った箇所に限られる、局所的な不具合だったのです。

「CSSは正しいのに、computed styleが変わらない」という現象に出会ったときは、ブラウザの開発者ツールでComputed styleパネルを確認するのが定石です。実際に採用されているルールがどこから来ているのかを見れば、インラインstyleが割り込んでいることに気づけます。

修正内容と確認できたこと

修正の方向性はシンプルです。テストコード側でも、classList操作とstyle.colorScheme設定の両方を行うようにしました。

if (mode === 'dark') {
  await page.evaluate(() => {
    document.documentElement.classList.add('darkmode')
    document.documentElement.style.colorScheme = 'dark'
  })
} else {
  await page.evaluate(() => {
    document.documentElement.classList.remove('darkmode')
    document.documentElement.style.colorScheme = 'light'
  })
}

同じタイミングで新設したスクリーンショット取得スクリプト(screenshot-baseline.js)も、最初からこの両方を設定する形で実装しました。修正前のバグを踏まえて書いたので、同じ盲点を持ち込まずに済んでいます。

この修正は2026年7月3日のコミットとして、リデザインPR(デザイントークンの和風OKLCHパレット統合と合わせた一連の変更)に含まれる形でマージしました。修正後は、npm run buildとVitestのテストスイート、axe-coreによるコントラスト検査(複数ページ×light/dark両モード)、Playwrightでの複数の画面幅×両テーマのスクリーンショット比較を行い、ダーク側の検査が実際に機能していることを確認しています。

自分の実装は大丈夫か — セルフチェックリスト

class付与だけのダークモード再現テストは、実装がインラインstyleでcolor-schemeを切り替えている場合、検査漏れを起こします。インラインstyleは常にクラスセレクタより優先されるからです。「見た目が暗い」ことと「light-dark()トークンが正しく解決されている」ことは、別の話だと考えてください。

これは、自動テストの価値を否定する話ではありません。テストコードが実装の状態遷移を正確に再現して初めて、検査は意味を持つという話です。似たような「信頼していたものが実は機能していなかった」という構造は、ローカルAIエージェントのリスクを検証した記事でも扱っています。

自分の実装を次の観点で見直してみてください。

  • ダークモード切り替えの実装は、class操作だけでしょうか。それともインラインstyleも併用しているでしょうか。
  • テストコード側は、実装が行っているすべての状態変更(classとインラインstyleの両方)を再現しているでしょうか。
  • light-dark()を使っている箇所が、ダークモードテスト時に実際に暗い値へ解決されているか、開発者ツールのComputed styleで確認したでしょうか。
  • 「見た目のスクリーンショット」だけでなく、「computed styleの値」でも検証しているでしょうか。

よくある質問

light-dark()が効かないときの原因は何ですか。

多くの場合、CSSカスケードの優先順位が原因です。インラインstyleでcolor-schemeが指定されていると、クラスベースのCSSルールより優先され、light-dark()の解決結果に影響します。

CSSは正しいのにcomputed styleが変わらないのはなぜですか。

インラインstyleなど、より詳細度の高いルールが別途適用されている可能性があります。開発者ツールのComputed styleパネルで、どのルールが実際に採用されているかを確認するのが定石です。

axe-coreはダークモードを正しく検査できますか。

axe-core自体はDOMの状態をそのまま検査します。テストコード側がダークモードの状態(classとインラインstyleを含む)を、実装と同じ方法で正しく再現していれば検査できます。再現が不完全だと、検査自体は正常に完了しても、実際には意味のある検査になっていません。

インラインstyleとCSSクラス、どちらが優先されますか。

インラインstyleが優先されます。CSSの詳細度の仕組み上、インラインstyleはクラスセレクタを含むセレクタベースのルールより、常に高い優先順位を持ちます。


Astro製の技術ブログの運用については、macOSでのローカルAI環境構築を扱った記事もあわせてご覧ください。