iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

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>

我刻意埋了六個問題:圖片沒有 altdivonclick 卻沒有鍵盤事件、<html> 沒有 lang、輸入框只有 placeholder 沒有 <label>、連結文字寫「按這裡」看不出要去哪、#999 配白底對比只有 2.85。

這六個都是接案現場真的會看到的東西,不是教科書例題。圖片沒有 alt 通常是後台上稿的人沒填;div 當按鈕用幾乎都是為了樣式好調;輸入框只留 placeholder 是因為設計稿上沒有標籤那一行。每一個背後都有一個當下很合理的理由,這也是它們特別難清的原因。

存好之後,就一行指令。

lint 不到一秒,回我五條

$ 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 只是提醒。那個 onclickdiv 之所以是 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 條適合排在部署後或每天跑一次,因為它要真的把頁面渲染出來。

兩條安裝路線的對照圖:輕量路線只裝 a11y-moda 本體,約 20 秒裝完、佔用 36 MB,可執行 lint 與 rules 兩個指令,涵蓋 146 條規則中的 50 條;完整路線額外安裝 scan 選配與 Chromium 瀏覽器,再多幾秒與約 700 MB 磁碟,才能執行 scan 與 site,涵蓋全部 146 條規則

兩條路線的分界不是功能多寡,是要不要為它裝下那 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 報告固定產三種視圖,切換不重跑掃描。

報告的 By rule 視圖:頁面上方是四張統計卡,顯示掃描 5 頁、0 個 fail、34 個 info、2 個 caveat;下方是可展開的規則清單,第一條 CS2141002E 對應 WCAG 1.4.10,標示影響 5 個頁面,展開後列出所有受影響的網址

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

報告的 By WCAG guideline 視圖:表格依 WCAG 成功準則分列,第一列 1.4.10 等級 2,對應三條規則、影響 5 頁、0 個 fail、14 個 info;下方依序是 1.4.11、2.4.1、3.2.4 各準則的統計

依 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 再掃一次,GN1410200EGN1240300E 同時響起來,caveat 從 0 變 2。差一個字元。

這跟 Day 2 那七條是同一種失敗:規則存在,但沒有射中。差別在於,Day 2 的原因是偵測邏輯想得不夠遠,這次的原因只是一個很久沒有人再看過的數字。

而那個數字寫死在原始碼裡。使用者拿到的是編譯好的行為,不是可以商量的參數 —— 你的網站頁籤標籤如果都是 18 個字,這條規則對你永遠不會響,而你不會知道。

那件事我留到 Day 7 講。

這次的修法有兩條路:把門檻做成可設定,或者不要只靠標籤長度判斷 —— 那三顆鈕其實都帶著 data-tab 屬性,這個訊號本身就夠強,不必再靠字數。我傾向後者,因為前者只是把猜測的責任丟給使用者。

至於報告自己那六個 fail,我沒有今天修。先讓它以現在的樣子被看見,比較誠實。

今天的重點

  • 20 秒從空環境到第一個 issue,不需要瀏覽器、不需要 API key
  • 146 條規則裡 50 條不用瀏覽器,剩下 96 條要 computed style 或 runtime DOM 才算得準
  • 三種視圖對應三種讀者:排修復順序、填自評表、分派負責人
  • 工具掃自己的報告:6 個 fail,外加一個因為門檻寫死而漏掉的頁籤問題
  • 閾值寫在程式碼裡、使用者改不動 —— 這是後面要談「為什麼要開源」的起點

明天 Day 5:axe-core、pa11y、Lighthouse 我都跑過,為什麼還要再蓋一把。


上一篇
Day 03:一個晚上,AAA 自評覆蓋率從 12/20 跑到 20/20
系列文
前端不寫 Python,照樣 ship 一把網頁無障礙 CLI4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言