📖 はじめに — a11y は「良心」ではなくリリースブロッカー
アクセシビリティ(a11y)対応は「余裕があればやる」枠に押し込まれがちだ。CTS-EC では WCAG 準拠をリリースブロッカー級と位置づけ、他のフロントエンド整理から切り離した専用ステアリングで先行させた。性質が違うからだ — DRY/SSoT のリファクタと WCAG 準拠 + CI ゲート新設を 1 つのステアリングに混ぜると、レビューの軸がぶれる。
そして本章は、第I部でおそらく一番「実機でしか分からなかった」話を含む。先に結論を言う。
a11y テストは全部通っていた。しかし検査していたのは、スタイルが当たっていない DOM だった。
第5章で Presentational を Storybook 検収可能にした、その Storybook を品質ゲートに変える過程で起きたことを、時系列で記録する。
🎨 是正 1: コントラスト不足 — 「全部直す」を選ばない勇気
最初の負債は本文テキストの text-gray-400(コントラスト比 AA 不足)。是正方針の比較はこうだ。
- 却下案: Tailwind のセマンティックトークン化(427 箇所置換 + 設計)。正論だが工数が大きくスコープが膨張する。しかも Tailwind v4 は CSS-first で
tailwind.config自体が存在せず、トークン化の下地もない - 採用案: 本文・ラベル用途のみ
text-gray-500/600へ置換。この 2 色は既に 495 件の使用実績があり、新色の追加なしで済む
装飾・disabled・placeholder 用途の text-gray-400 は正当なので、例外管理が要る。ここに小さな発明がある — 許可リストを「ファイル名:行番号」で持つと行がずれた瞬間に陳腐化するため、該当行に専用マーカーコメントを付ける方式にした。grep ゲート(AT-27)はマーカーの無い本文用途だけを BLOCKING にする。例外の理由がコードのその場に書いてあるので、レビューでも一目で分かる。
⌨️ 是正 2〜3: zoneless Angular の罠と、role を「付けない」判断
aria-invalid — signal でないものを host binding に書くと沈黙する
フォームエラーの aria-invalid / aria-describedby 付与では、zoneless Angular 特有の罠を踏んだ。NgControl の invalid / touched は signal ではないため、素直に host binding へ書くと zoneless では変更検知が走らず、属性が更新されない。公式パターンどおり control.events(ControlEvent ストリーム)を toSignal 化し、computed で再評価させる形で解決した。
もう 1 つ、自分たちの過去のゲートに縛られる場面があった。directive のファイルを aria-invalid.directive.ts と命名しようとすると、第5章で敷いた AT-8(.directive.ts 旧命名の無条件 BLOCKING)に自分が弾かれる。正しくは aria-invalid.ts。機械ゲートは後から来る自分にも平等に効く — これはレールが機能している証拠でもある。
適用戦略も実利的だ。全 page の生 input に directive を配って回るのではなく、共通部品(searchable-select / password-input)への直接付与を主軸にして利用箇所を一括カバーし、残存は後述の axe ゲートに検出させる。
data-table の行キーボード操作 — role="button" を付けない
クリック可能なテーブル行のキーボード対応では、<tr> に role="button" を付ける案を捨てた。table semantics(row role)が壊れ、かえって axe 警告の懸念があるからだ。採用したのは role なし + tabindex + keydown.enter/space + focus-visible リング。「a11y 対応 = ARIA を足すこと」ではなく、足さない方が正しい場面を axe の判定で確定させる進め方をした。
🔬 是正 4: test-storybook ゲート — 事実調査が計画を 2 回書き換えた
本丸は「全 story に axe 検査を走らせ、CI でブロックする」ゲートの新設だ。ここで当初計画が事実調査で 2 回訂正された。この訂正プロセス自体が本章の読みどころだと思う。
- 「addon-a11y は未導入」→ 誤り。実コードを確認すると
@storybook/addon-a11yは導入済みで、preview.tsに color-contrast などの設定も既存だった。追加すべきはテストランナーだけ - 「axe-playwright + test-runner hook が必要」→ 古い。これは Storybook 8 時代のレシピで、公式 README をバージョン一致で確認すると、SB9+ では
@storybook/test-runnerが addon-a11y を out-of-the-box でサポート。preview.tsにparameters: { a11y: { test: 'error' } }を書くだけで全 story が BLOCKING 検査になり、axe-playwright は「アンインストールしてよい」と明記されていた
結果、追加パッケージは @storybook/test-runner 1 つだけ。Web 記事の「定番レシピ」を写していたら、不要な依存と不要な hook コードを抱えていた。ツールの世代が変わったら、レシピではなく公式 README をバージョン指定で読み直す — 地味だが、これだけで依存も脆弱性表面も減る。
💣 実機で発覚 — 静的 Storybook が「無スタイル」でビルドされていた
ゲートを組み、docker(CI と同じ Playwright イメージ)で実機検証したところ、奇妙な失敗が出た。theme-toggle の 24px サイズ測定で play テストが落ち、label 系の誤検出が出て、逆に color-contrast は何も言わない。
原因を追うと、ビルド設定の欠落だった。dev 用の storybook ターゲットには styles(Tailwind CSS の取り込み)があるのに、CI が使う build-storybook ターゲットには styles 指定が欠けていた。つまり静的ビルドされた Storybook は Tailwind 不在の無スタイル DOM で、axe も play テストもそれを検査していたのだ。
これが冒頭の「テストは緑、でも検査対象が違った」の正体である。ローカルの dev Storybook では正しく見えるので、人間の目視では気づけない。docker でCI と同一条件の実機検証をしたから発覚した。「テストが通っている」と「検査すべきものを検査している」は別の命題であり、後者はパイプラインの実機確認でしか担保できない。
📊 styled 再検査の衝撃 — 52 件の違反と、ratchet で受ける判断
styles を足して正確な検査になった途端、color-contrast 違反 52 件 / 15 スイートが露出した。red-500(コントラスト比 3.64)、green-600(3.1)、tint 背景上のグレーなど — つまり text-gray-400 どころではなく、デザインシステムのセマンティックカラー全般が AA を満たしていないことが定量で判明した。
ここでの意思決定が、実践編の「型」そのものだ。
- 案 B(52 件を即修正): カラーパレット改修であり、別ワークストリーム規模 → 却下
- 採用(案 A): 15 スイートを
a11y: { test: 'todo' }(警告・非 fail)の許可リストに載せ、52 件を技術的負債として台帳に起票。ゲートは新規違反のみ BLOCKING で即日稼働
「見つけた負債を全部いま返す」でも「見なかったことにする」でもなく、既存は数を固定して追跡可能な負債に変換し、増分だけを機械で止める。第7章のカバレッジ ratchet と同じ構造で、ゲート導入を負債完済と切り離すからこそ、ゲートが今日から効く。
結末 — 起票された 52 件は、翌日に完済された
「台帳に起票」で話を切ると先送りに見えるので、結末まで書いておく。この負債の寿命は 1 日だった。翌日、本番前の負債一括解消ステアリングが台帳からこれを取り出し、根本解消している。
- error / success / warning のセマンティックカラートークンを、AA を満たすことを実測した値でテーマに定義(ダークモードは自動スワップ)
- 直書きパレット 52 箇所をトークン参照に全置換
todo許可リストを空にし、ゲートを全スイート BLOCKING(error)へ昇格。置換後の実機検査は両モードで color-contrast 0 件、かつ todo→error の切替で元の 52 件が正しく検出されること(= ゲートの検出能力そのもの)も確かめてある
つまり案 B を「別ワークストリーム規模」と却下した判断は、やらない宣言ではなく分離の宣言だった。実際に別ステアリングとして翌日実行され、台帳起票 → 完済 → ゲート昇格まで 2 日で一巡している。台帳は負債の墓場ではなく、返済のキューとして機能した — 第3章の「根本解消できないものは、できない理由ごと台帳に載せて追跡する」の追跡が、実際に回収まで届いた実例である。
🧾 まとめ
- a11y は専用ステアリングで先行。是正は「全面改修」でなく本文用途のみ + マーカーコメント許可リストの最小確実路線
- zoneless Angular では NgControl の状態を
toSignal化しないと aria 属性が沈黙する。roleは付けない方が正しい場面がある - SB9+ の test-runner は addon-a11y を内蔵サポート — 古いレシピでなく公式 README をバージョン一致で読む
- 最大の教訓:
build-storybookの styles 欠落で、axe は無スタイル DOM を検査していた。「テスト緑 ≠ 検査できている」。実機(CI と同一 docker)での検証が唯一の裏取り - 露出した 52 件は todo 許可リスト + 負債起票で「管理された負債」に変換し、新規違反だけを BLOCKING — そして翌日、セマンティックトークン化で完済し、ゲートを全スイート BLOCKING へ昇格(台帳は先送りでなく返済キュー)
この「検査そのものの真正性を疑う」姿勢は、第II部のレビュー漏れ機械クローズ(AI レビューの証跡検証)とまっすぐ繋がっている。検証の仕組みは、検証自体が機能しているかをもう一段外から確かめて初めて完成する。
📚 シリーズ記事(ステアリング駆動開発・実践編)
序章
第I部: 技術的負債ドメイン別の実録(総点検ガイド + 8 ドメイン・全 9 回)
- リファクタリング総点検ガイド — 7 つの観点と進め方
- DDD/SOLID/BC 編 — god class 一掃と境界の機械ガード
- SAGA 編 — 新アーキテクチャ挑戦と再発ゲート
- Atomic Design リファクタ編 — 47 page 新規移植
- Storybook × a11y 実機編(本記事)
- テスト品質・網羅性編
- Python ジョブ群編
- パフォーマンス編
- セキュリティ編
第II部: 検証エンジン(クロスファミリー検証・全 6 回)
- 総論 — マルチ LLM の 2 系統と見取り図
- 裏取り編 — +18.1pt 論文の検証
- 設計編 — finder/verifier 分業と逆順禁止
- TDD×ハーネス編 — テスト保護と三層ゲート
- 実装編 — Claude Code の中から Codex を動かす
- モデル戦略編 — ティア割当と「買うか組むか」
終章
- クロスファミリー検証の最終型 —「正しく作る」から「自分で正しさを測り直す」へ — 検証エンジンに「計測」の層をはめ、自己修正ループを閉じる
番外編
- CI テスト 29 分 → 5.6 分 — 検証エンジンの平時運用実録 — 実測が有力仮説を殺し、退行前より速くなった 1 日
- GPT-5.6 sol 切替の当日実録 — もう一つのデフォルト追従 — CLI 更新が黙って替える finder と、当日中の再計測
- 遊休 90% の枠に仕事を振る — Codex 上流調査と verifier バッチ化 — 逆順禁止の境界を ADR で確定し、読む仕事を遊休枠へ移した 1 日
- ハーネスが単一プロジェクトを卒業する — プラグイン化と二層配布 — 4 プロジェクト展開への切り出しと、version + SHA ピンによる非強制追従
- 69分で5リリース — Opus 5 当日対応が暴いた共通ハーネスの死角 — 新モデル対応を起点に、配布・文書・CI の回帰を二消費者で検出した 69 分
関連記事
- Atomic Design リファクタ編 — Storybook 検収を可能にした構造改革
- Claude Code + Claude Design 連携ワークフロー — デザイン検収の周辺フロー