import RelatedArticles from "@/components/RelatedArticles";
壁紙アプリの設定画面を更新してリリースした数日後、VoiceOver を使っている方から「保存ボタンが『ボタン』としか読み上げられず、何のボタンか分からない」というお便りをいただきました。アイコンだけの丸いボタンに accessibilityLabel を付け忘れていたのです。一画面なら目視で気づけますが、私自身、個人開発で複数のアプリと Web を並行して触っていると、リリースのたびに全画面を読み上げ確認する運用はまず続きません。
そこで今回は、axe-core でアクセシビリティ違反を機械的に検出し、その結果を Antigravity のエージェントに「直す対象」として渡し、検出からトリアージ、修正、再検証までを一つのループとして CI に組み込む方法をまとめます。ツールに判定させ、エージェントに修正させ、CI で歯止めをかける、という分業が要点になります。対象は主に Web フロントエンド(Dolice の Lab サイト群のような Next.js アプリ)を想定していますが、考え方はモバイルの自動アクセシビリティ検査にもそのまま移せます。
手動のアクセシビリティ点検が続かない理由
アクセシビリティの不具合は、機能バグのように画面が真っ白になって気づくものではありません。見た目には普通に動いています。だからこそ静かに溜まっていきます。コントラスト比が 4.5:1 をわずかに下回ったボタン、ラベルのない入力欄、フォーカスが飛ばない自作ダイアログ。どれも単体では小さく、目視レビューの最後にはたいてい力尽きます。
私が以前やってしまったのは、「リリース前にまとめて直す」という運用でした。結果として、直すべき項目が数百件に膨らみ、心理的に手が出せなくなりました。負債は溜めてから返すより、増やさない仕組みに寄せた方が、はるかに現実的だと感じています。axe-core を CI に置く目的は、まさにこの「増やさない」を機械に肩代わりさせることにあります。
ただ、検出するだけでは足りません。違反一覧を渡されても、レビューする側には「で、どう直すの」という負荷が残ります。ここに Antigravity のエージェントを噛ませ、違反の種類ごとに修正パッチの下書きまで作らせると、レビューが一気に前へ進みます。
ループ全体像 — 誰が検出し、誰が直し、誰が止めるか
このループは役割を最初に固定しておくと迷いません。三者の分担はこうなります。
axe-core(検出役) : レンダリング後の DOM を走査し、WCAG のどのルールに違反しているかを機械可読な JSON で返します。判定は決定的で、揺れません。
Antigravity エージェント(修正役) : 違反 JSON と該当箇所のソースを読み、修正の下書きを作ります。説明と提案が役目で、合否の判定はさせません。
GitHub Actions の差分ゲート(歯止め役) : ベースラインと比べて「新しく増えた違反」があれば PR を止めます。既存の負債は別キューに回し、新規だけを確実にせき止めます。
検出と歯止めを決定的なツールに任せ、非決定的な部分(修正の発想)だけをエージェントに寄せるのが、CI の信頼性を保つコツです。エージェントに合否まで委ねると、同じ PR でも実行ごとに結果が変わり、ゲートとして機能しなくなります。
Step 1: axe-core を Playwright に組み込んで違反を機械可読に出す
最初に、違反を JSON ファイルとして吐き出すところを作ります。@axe-core/playwright を使うと、実際にレンダリングされた状態のページを走査できます。静的解析では拾えない、動的に差し込まれた要素まで見られるのが利点です。
// tests/a11y/scan.spec.ts
// 主要ページを axe-core で走査し、違反を results/ に JSON 出力する
import { test } from "@playwright/test" ;
import AxeBuilder from "@axe-core/playwright" ;
import { mkdirSync, writeFileSync } from "node:fs" ;
// 検査対象。サイトの導線上で重要なページから始めるのが現実的
const PAGES = [ "/" , "/articles" , "/membership" , "/support" ];
test. describe ( "accessibility scan" , () => {
for ( const path of PAGES ) {
test ( `scan ${ path }` , async ({ page }) => {
await page. goto (path, { waitUntil: "networkidle" });
const results = await new AxeBuilder ({ page })
// WCAG 2.2 の A / AA に絞る。AAA まで入れるとノイズが急増する
. withTags ([ "wcag2a" , "wcag2aa" , "wcag21aa" , "wcag22aa" ])
. analyze ();
mkdirSync ( "results" , { recursive: true });
const safe = path. replace ( / \W + / g , "_" ) || "root" ;
writeFileSync (
`results/${ safe }.json` ,
JSON . stringify (results.violations, null , 2 ),
);
});
}
});
ここで waitUntil: "networkidle" を入れているのは理由があります。クライアント側で遅れて差し込まれるモーダルやトーストを待たずに走査すると、本来あるはずの違反を見落とします。私はこれを忘れて「ローカルでは出ないのに本番運用で読み上げが崩れる」という現象に半日溶かしました。
この時点では、合否を出さず JSON を貯めるだけにとどめます。判定は後段の差分ゲートに集約します。
Step 2: 違反 JSON を「エージェントが直せるタスク」に整形する
axe-core の生の出力は情報が多く、そのままエージェントに渡すと焦点がぼやけます。そこで、影響度(impact)でフィルタし、ファイルと行に紐づく修正タスクの形へ整形します。最初のうちは critical と serious だけに絞ることを強く推奨します。私の Lab サイトでは、全件だと 240 件あった指摘が、この 2 段階に絞ると 38 件まで減りました。指摘のうち約 84% を後回しにできた計算で、ようやく手がつけられる量になりました。
// scripts/build-fix-tasks.mjs
// results/*.json を読み、critical/serious の違反だけを修正タスクへ整形する
import { readdirSync, readFileSync, writeFileSync } from "node:fs" ;
const ALLOW = new Set ([ "critical" , "serious" ]);
const tasks = [];
for ( const file of readdirSync ( "results" )) {
const violations = JSON . parse ( readFileSync ( `results/${ file }` , "utf8" ));
for ( const v of violations) {
if ( ! ALLOW . has (v.impact)) continue ;
for ( const node of v.nodes) {
tasks. push ({
rule: v.id, // 例: "button-name"
impact: v.impact,
help: v.help, // 人間向けの一文説明
selector: node.target. join ( " " ), // 該当要素の CSS セレクタ
html: node.html. slice ( 0 , 200 ), // 修正の手がかりになる断片
fixHint: node.failureSummary, // axe が示す直し方の要約
});
}
}
}
// ルール単位でまとめると、エージェントが「同種をまとめて直す」判断をしやすい
writeFileSync ( "results/fix-tasks.json" , JSON . stringify (tasks, null , 2 ));
console. log ( `fixable tasks: ${ tasks . length }` );
failureSummary には axe-core 自身が「どう直すべきか」の要約を入れてくれます。これを残しておくと、エージェントが見当違いの修正をする確率がはっきり下がります。セレクタと HTML 断片も併せて渡すのが、後段の精度を決める分かれ目になります。
Step 3: エージェントの修正範囲を制約して直させる
ここが一番の勘所です。Antigravity のエージェントは放っておくと「良かれと思って」ARIA 属性を増やします。けれど、不要な role や aria-label はかえって支援技術の読み上げを壊します。実際、私は role="button" を付けた要素にネイティブの <button> を入れ子にされ、二重に読み上げられる回帰を一度作りました。
そこで、リポジトリ直下の AGENTS.md に修正範囲の制約を明文化しておきます。エージェントは作業前にこれを読み込みます。
# AGENTS.md — アクセシビリティ修正タスクの制約
## 対象
- `results/fix-tasks.json` に列挙された違反のみを直す
- 1 PR で扱うのは同一 rule(例: button-name)に限定する
## 直し方の優先順位
1. まずネイティブ要素・属性で解決する(例: アイコンボタンには可視ラベルか aria-label)
2. ネイティブで足りないときだけ ARIA を最小限で足す
3. role の追加・上書きは禁止。必要なら人間にエスカレーションする
## 禁止事項
- 見た目(レイアウト・色・余白)を変更しない
- color-contrast 違反はコードで勝手に色を変えず、課題として報告するに留める
- セレクタに一致しない要素には触れない
color-contrast を「直さずに報告する」に倒しているのには意図があります。色はデザインの意思決定であり、機械が勝手に明度を上げると、ブランドの一貫性が崩れます。私の運用では、コントラスト違反はエージェントの修正対象から外し、デザイン判断が要る課題として別途まとめるようにしています。アプリ側でも同じ方針で、App Store のスクリーンショットと並べて見比べながら人が決めています。
エージェントへの指示は、タスクファイルを起点にした短い一文で十分です。「results/fix-tasks.json のうち rule が button-name のものだけを、AGENTS.md の制約に従って直し、各修正に理由を一行添えてください」のように、範囲を毎回明示します。
Step 4: 「新規違反だけ」を止める差分ゲートを組む
既存の負債が数十件残っている状態で「違反ゼロでないと止める」を導入すると、最初の PR から全部赤になり、誰も見なくなります。現実的なのは、main のベースラインと比較して、新しく増えた違反があるときだけ止める差分ゲートです。
# .github/workflows/a11y.yml
name : accessibility-gate
on : pull_request
jobs :
a11y :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : 20
- run : npm ci
- run : npx playwright install --with-deps chromium
# PR ブランチを走査
- run : npx playwright test tests/a11y
- run : node scripts/build-fix-tasks.mjs
# ベースライン(main で生成済みの件数)と比較して新規増分だけを判定
- name : Compare against baseline
run : |
CURRENT=$(node -e "console.log(require('./results/fix-tasks.json').length)")
BASE=$(cat .a11y-baseline 2>/dev/null || echo 0)
echo "baseline=$BASE current=$CURRENT"
if [ "$CURRENT" -gt "$BASE" ]; then
echo "::error::新規のアクセシビリティ違反が $((CURRENT - BASE)) 件増えています"
exit 1
fi
.a11y-baseline には、現時点の critical/serious の件数(この例では 38)を記録しておきます。負債を一件返すたびにこの数字を下げ、ラチェットのように後戻りを防ぎます。私はこの仕組みを入れてから、週次で新規違反 0 件を維持できるようになりました。増やさないことを先に固定し、既存分はエージェントの修正 PR でゆっくり減らしていく、という二段構えです。
差分ゲートの結果と fix-tasks.json を PR コメントに貼るところまで自動化すると、レビューの初動が速くなります。コメントの組み立て方は Antigravity × GitHub で Pull Request レビューを AI 自動化する実践ガイド の方式がそのまま流用できます。
運用して分かったつまずきどころ
1. 動的コンテンツを待たずに走査して見落とす
Step 1 で触れた networkidle を入れても、ユーザー操作後にしか出ない要素(開いたメニューの中身など)は拾えません。重要な対話要素は、page.click() で開いた状態を作ってから別途走査するのが確実です。
2. エージェントが ARIA を盛りすぎる
制約を AGENTS.md に書いても、複雑な違反だと過剰修正に振れます。修正 PR のレビューでは「足した属性が本当に必要か」を必ず一つずつ確認しています。ネイティブ要素で足りるなら ARIA は引き算するのが基本です。
3. ベースラインの更新を忘れて負債が固定化する
違反を直したのに .a11y-baseline を下げ忘れると、せっかく減らした分の枠が再び埋まるまで気づけません。修正 PR のチェックリストに「ベースライン更新」を入れておくのをおすすめします。
4. color-contrast を機械に任せて見た目が崩れる
前述の通り、色はコードではなくデザインで決めます。axe-core の指摘は貴重ですが、修正の主語を人間に残しておかないと、ブランドカラーが少しずつ濁っていきます。
5. 全ページを一度に対象にして CI が重くなる
最初から全ページを走査すると Playwright の実行時間が伸び、PR ごとの待ち時間がストレスになります。導線上の主要ページから始め、安定してから広げるのが現実的です。
まず今週末にできる最小の一歩
ここまで四段階で組んできましたが、最初から全部を入れる必要はありません。私のおすすめは、Step 1 の「JSON を吐くだけのスキャン」を主要 4 ページに対して立て、件数を 2 週間眺めることです。
その間に、自分のサイトでどのルール違反が多いのか、どの画面が崩れやすいのかが見えてきます。傾向がつかめたところで Step 2 以降のフィルタとゲートを足していけば、過剰設計にならずに済みます。アクセシビリティは一度で完璧にするものではなく、増やさない仕組みを先に置いて、少しずつ返していくものだと考えています。同じ課題に向き合っている方の、最初のひと押しになれば幸いです。
なお、CI パイプラインそのものの組み立てに不慣れな場合は、Antigravity × GitHub Actions 高度な CI/CD パイプライン構築ガイド を下地にすると、本記事のワークフローを無理なく組み込めます。