Day 04 · W1 · 無障礙線 · 難度 ★☆☆☆☆
本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01。
Day 3 講規則是怎麼長出來的,結尾我答應今天讓你「30 秒看到第一個 issue」。
一台 Windows 11、Python 3.13.2、全新虛擬環境,全程計時:
pip install a11y-moda 約 20 秒
a11y-moda lint demo/ 不到 1 秒
20 秒,從什麼都沒有到看見第一個 issue。 不用瀏覽器、不用 API key、不用設定檔。
比昨天預告的快 10 秒。預告是估的,實測是實測,兩個數字我都留著。
這篇的主軸是:檢測工具真正的門檻不在掃描,在安裝那一步。 所以安裝被切成兩條路線:只想檢查程式碼的人,不必為了它先裝一整顆瀏覽器。
八行,存成 demo/index.html:
<!DOCTYPE html>
<html>
<head><title>報名表單</title></head>
<body>
<img src="/logo.png">
<div onclick="submitForm()">送出</div>
<input type="text" placeholder="姓名">
<a href="/pricing">按這裡</a>
<p style="color:#999;background:#fff">小字說明</p>
</body>
</html>
我刻意埋了六個問題:圖片沒有 alt、div 綁 onclick 卻沒有鍵盤事件、<html> 沒有 lang、輸入框只有 placeholder 沒有 <label>、連結文字寫「按這裡」看不出要去哪、#999 配白底對比只有 2.85。
這六個都是接案現場真的會看到的東西,不是教科書例題。圖片沒有 alt 通常是後台上稿的人沒填;div 當按鈕用幾乎都是為了樣式好調;輸入框只留 placeholder 是因為設計稿上沒有標籤那一行。每一個背後都有一個當下很合理的理由,這也是它們特別難清的原因。
存好之後,就一行指令。
$ a11y-moda lint demo/
FAIL HM1110100C WCAG 1.1.1 L7 <img> 缺 alt 屬性
CAVEAT GN1210100E WCAG 2.1.1 L8 <div> 使用 onclick 但未提供鍵盤等效
FAIL HM1310100C WCAG 3.1.1 L2 <html> 缺 lang 屬性
FAIL GN1240100E WCAG 2.4.1 L6 未發現指向主要內容的 skip link
FAIL GN1240102E WCAG 2.4.1 L6 頁面缺 <main> landmark
四個 fail、一個 caveat。我埋的六個裡它抓到三個,另外兩條是白撿的:沒有 skip link、沒有 <main>,我根本沒想到要埋。
中間那條 caveat 值得看。狀態分三級:fail 是語法樹確認的違規,caveat 是看到形狀但靜態確認不了,info 只是提醒。那個 onclick 的 div 之所以是 caveat 而不是 fail,因為鍵盤事件可能綁在別的檔案、也可能是框架接管的。
它知道自己不知道,所以不硬判。 這個分級主要是給 AI agent 讀的:fail 直接改,caveat 停下來問人。要是全部都判 fail,agent 就會開始「修」那些其實沒壞的東西,那比不檢查更糟。
至於那兩條白撿的,原因很單純:它掃的是整份文件,不是我給的那張清單。我埋了什麼,它並不知道。
<label>、連結文字、對比,這三個為什麼沒響?問工具自己就好:
$ a11y-moda rules search "label"
| rule_id | WCAG | Level | Topic | Scope | Description |
| HM1130104C | 1.3.1 | A | forms | scan | 可見的表單控制元件均需有對應的標籤 |
| GN2240601E | 2.4.6 | AA | forms | scan | 提供描述性的標籤 |
| HM1240102C | 2.4.1 | A | nav | scan+lint | 以 <nav> 標籤將相關鏈結做分群 |
看 Scope 那一欄。scan+lint 表示兩個階段都跑得到,scan 表示只有瀏覽器裡才算得準。
<label> 的關聯可能是 React 元件在 runtime 組出來的;對比要拿到 computed style 才算得出實際顏色;連結文字要看它周圍的段落脈絡。這三件事在原始碼階段都只能猜。
比喻一下:lint 是看圖紙,scan 是到現場拿捲尺量。圖紙上少畫一道門,看圖就知道;牆刷完之後顏色跟旁邊差多少,只能到現場量。兩件事都要做,但先後順序不一樣,也不該綁在一起做。
數字是這樣:146 條規則裡,50 條 lint 跑得到,96 條要開瀏覽器。
這 50 條的價值在於它可以放進 pre-commit 或 pull request 檢查:不到一秒、不用網路、不用瀏覽器,錯的話當場擋下來。剩下 96 條適合排在部署後或每天跑一次,因為它要真的把頁面渲染出來。

兩條路線的分界不是功能多寡,是要不要為它裝下那 700 MB 的瀏覽器。想在 CI 裡加一道快速閘門的人,走上面那條就夠。
寫這段的時候我跑了 a11y-moda rules list --help,才發現說明文字還寫著「all 133 rules」,而實際回傳是 146。版本往前推的時候漏改了那個字串。本機已經修好,下一版跟著出。數字沒對上就是沒對上,先講在前面。
pip install "a11y-moda[scan]" 約 9 秒
playwright install chromium 幾秒(我這台快取裡有)
site-packages 從 36 MB 長到 144 MB。Chromium 本體不在裡面,另外算:428 MB,加上 headless shell 的 272 MB。
那幾秒不能當數字用,我這台之前裝過。第一次跑的人這步是分鐘級,看你的網路。
裝完之後:
a11y-moda scan https://www.light-design.com.tw --level AA
a11y-moda site https://www.light-design.com.tw --level AA \
--max-pages 5 --format html -o report.html
單頁約 3 秒。五頁約 7 秒,結果是 0 fail、34 info、2 caveat。
info 裡有一條我自己看漏很久:首頁的 h2「客戶怎麼說」下一個標題直接跳到 h6「來自各產業的真實回饋」,跳了四級。畫面上完全看不出來,因為 h6 被 CSS 調成剛好的大小。
但螢幕閱讀器的使用者常常靠「列出所有標題」來決定要不要往下讀,那份目錄在這裡就會斷一層 —— 讀到 h2 之後跳出一個 h6,中間三層是空的。這種問題完全沒有視覺線索,靠人眼審查很難穩定抓到,是機器最划算的那一類。
順帶說明,這五頁 0 fail 不代表全站乾淨。爬蟲從 sitemap 拿到的前五個網址不含 /blog,而那裡是有東西的。
HTML 報告固定產三種視圖,切換不重跑掃描。

依規則看:一條規則影響幾頁一目了然。這條 CS2141002E 五頁全中,代表它在共用元件裡,改一次收五頁。

依 WCAG 準則看:這張表可以直接對到自評表的欄位,送件時省掉一次人工歸類。
第三個視圖是依頁面分組,一頁一張表,那是分派工作用的,誰負責哪頁就看哪一段。
這三個視圖不是版面上的裝飾。同一份掃描結果,工程師想知道的是「改哪一個元件收最多」,所以要按規則聚合;送件的人想知道的是「1.4.10 這條到底過了沒」,所以要按 WCAG 準則對齊;專案負責人想知道的是「這頁歸誰」,所以要按網址切開。三個問題問的是同一批資料,但需要三種排序方式,任何一種當成唯一視圖,另外兩個人就得自己在腦袋裡重排一次。
報告不是給「所有人」看的,是給三個不同的人各看一次。
a11y-moda scan reports/report.html --allow-file --render --level AA
六個 fail、三個 info。
info 那顆黃色徽章對比只有 2.49(需要 4.5),全報告 13 處;整份沒有任何 :focus 或 :focus-visible 樣式;沒有 <main>、沒有 skip link、沒有 <nav>;還有七處字級用絕對單位 12.5px。
一個檢查無障礙的工具,產出的報告自己不合格。 這句話不好看,但它是真的。
比 fail 更值得講的是它「沒抓到」的那個。報告上方那排切換鈕(By rule / By WCAG guideline / By page)是純 <button> 加 CSS class,沒有 role="tab"、沒有 aria-selected。螢幕閱讀器讀不出「現在在第幾個頁籤、總共幾個」。
而我們是有這條規則的(GN1410200E,WCAG 4.1.2)。它沒響。
我一開始以為是規則根本不存在,準備把它寫成「待補清單」的一條,結果去翻規則檔才發現不但有,而且註解裡寫得很清楚:這條專門抓「看起來是頁籤、實作卻是一排獨立按鈕」的元件,還跟另一條管焦點順序的規則共用同一套偵測邏輯,一條從角色暴露的角度報、一條從焦點順序的角度報。也就是說,這個形狀是被預期到的,程式也寫好了,就是沒有對我們自己的報告生效。
原因在偵測邏輯裡的一個常數:
_MIN_SIBLINGS = 3
_MAX_LABEL_LEN = 16 # tab labels tend to be short
判定條件是「同一列有三個以上短標籤」才算頁籤群組。而那三顆鈕的字數是 7、17、7。By WCAG guideline 剛好 17 個字元,超過 16 被排除,剩下兩個不到三個的門檻,整組跳過。
把 16 改成 20 再掃一次,GN1410200E 跟 GN1240300E 同時響起來,caveat 從 0 變 2。差一個字元。
這跟 Day 2 那七條是同一種失敗:規則存在,但沒有射中。差別在於,Day 2 的原因是偵測邏輯想得不夠遠,這次的原因只是一個很久沒有人再看過的數字。
而那個數字寫死在原始碼裡。使用者拿到的是編譯好的行為,不是可以商量的參數 —— 你的網站頁籤標籤如果都是 18 個字,這條規則對你永遠不會響,而你不會知道。
那件事我留到 Day 7 講。
這次的修法有兩條路:把門檻做成可設定,或者不要只靠標籤長度判斷 —— 那三顆鈕其實都帶著 data-tab 屬性,這個訊號本身就夠強,不必再靠字數。我傾向後者,因為前者只是把猜測的責任丟給使用者。
至於報告自己那六個 fail,我沒有今天修。先讓它以現在的樣子被看見,比較誠實。
明天 Day 5:axe-core、pa11y、Lighthouse 我都跑過,為什麼還要再蓋一把。