Day 10 · W2 · 無障礙線 · 難度 ★★★☆☆
本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01。
白底黑字穩過 4.5:1,這件事沒有人會算錯。
那漸層背景上的白字呢?背景一邊深一邊淺,公式要吃哪一個顏色?
我把自家站幾頁重掃一次,想找個現成的漸層來算。找到了,但工具給我的不是數字:
CAVEAT GN2140300E WCAG 1.4.3
對比無法量測(gradient bg on ancestor)— 共 8 處需人工 review。
報告上沒印數字,但那批元素的原始量測我撈出來看了:對比值是 15.66。AA 的門檻是 4.5。
它算出一個超標三倍的數字,然後拒絕說它通過。
這不是 bug。那個 15.66 是拿一個畫面上根本不存在的背景色算出來的。
一句話主軸:對比度的公式十行就寫得完,難的是決定「哪一個顏色算背景」。
WCAG 的對比度定義只有兩步。先把每個顏色換算成相對亮度,再把兩個亮度套進一個比值公式。
def _channel(c): # c 是 0-255 的單通道
s = c / 255.0
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
def relative_luminance(rgb):
r, g, b = rgb
return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
def contrast_ratio(fg, bg):
l1, l2 = relative_luminance(fg), relative_luminance(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
這就是全部了。綠色權重 0.7152、藍色只有 0.0722,因為人眼對綠最敏感、對藍最鈍。
拿 Day 4 那個八行測試檔驗算:#999 配白底,contrast_ratio((153,153,153), (255,255,255)) 得到 2.85。跟那天報告上印的數字一樣。
門檻有三個:一般文字 4.5、大尺寸文字 3.0、AAA 是 7.0。
還有一件事讓這條規則跟其他規則不一樣:它非開瀏覽器不可。
原始碼裡寫的是 class="text-muted",那串字不是顏色。真正的前景色要疊完幾層樣式表、解完 CSS 變數、套完媒體查詢才會定案。Day 9 講語法樹看不到的三樣東西,第一樣就是這個。所以對比度只有 scan 版沒有 lint 版,不是懶得寫,是資訊不在那裡。
到這裡為止,這條規則像小學數學。麻煩的是 bg 那個參數要填什麼。
我做了一個四行的測試檔,四段白字或灰字,四種不同的背景:
<style>
.solid { color: #767676; background: #ffffff; }
.gradient { color: #ffffff; background: linear-gradient(90deg, #0a47b0, #926bff); }
.alpha { color: #ffffff; background: #3b3b3b; }
.alpha p { background: rgba(255, 255, 255, .85); }
.image { color: #ffffff; background: url(bg.jpg) center/cover; }
</style>
跑 a11y-moda scan ... --render,拿到的原始量測長這樣:
section.solid ratio= 4.54 bg=(255,255,255) unmeasurable=False
section.gradient ratio= 1.00 bg=(255,255,255) unmeasurable=True gradient bg on ancestor
p ratio= 1.00 bg=(255,255,255) unmeasurable=False
section.image ratio= 1.00 bg=(255,255,255) unmeasurable=True image bg on ancestor
四行裡有三行是 1.00。而那三個 1.00 是三件完全不同的事。
第一行的 4.54 是真的量出來的:純色背景,bg 有唯一解,剛好壓在 4.5 上面過關。
後面三行的 bg 全是 (255,255,255)。那不是量到的白色,是找不到背景色時的預設值。取背景色的邏輯是從元素往上走,遇到第一個不透明的底色就停;漸層跟背景圖都掛在 background-image 上,background-color 是透明的,於是它一路走到頁面最外層,回傳白。
前景剛好也是白,所以 1.00。這個數字不是量測結果,是走到底的副產品。

四段文字、三個 1.00,只有第一行的數字是真的量出來的。
所以判定不能只看數字,要看旁邊那個 unmeasurable 旗標。有旗標的走 caveat,沒旗標的才拿去跟門檻比。
順帶一提,報告上那句 caveat 訊息其實藏了一個小問題:它只印第一個遇到的原因,數量卻是全部加總。上面那個檔案裡,「共 2 處」一處是漸層、一處是背景圖,兩種完全不同的無解,被同一行字蓋掉了。
回到測試檔那個漸層。linear-gradient(90deg, #0a47b0, #926bff) 上面放白字,兩端各算一次:
白字 on #0a47b0(左端) 8.26 過
白字 on #926bff(右端) 3.64 不過
同一行字、同一個顏色,左邊合格右邊不合格。
再往下算一步更有意思。沿著這條漸層插值,對比值從 8.26 一路掉到 3.64,在 76% 的位置穿過 4.5。也就是說這行字前面四分之三合格,最後四分之一不合格。
那公式該吃哪個顏色?取最差的那端最保守,但整段文字如果只有結尾兩個字踩線,報 fail 就過度了;取平均值最好看,也最沒有意義,因為沒有任何一個像素長那樣;取文字實際覆蓋範圍的加權最準,但那要先知道每個字落在漸層的哪個位置,等於要做一次排版模擬。
三種都有道理,也都能被質疑。這種時候工具最誠實的答案是「我量不到,請人看」。
拿量身高比喻:受測者站在斜坡上,尺是準的,量出來的數字也是真的,但那個數字不代表他多高。這時候該說的是「請站到平地再量」,不是硬報一個。
測試檔第三段是半透明疊層:深灰底 #3b3b3b,上面壓一層 85% 不透明的白,白色文字寫在那層上面。
工具給的是 1.00,判 fail。
我自己手算了一次。85% 的白疊在 #3b3b3b 上,合成出來是 #e2e2e2,白字對它的對比是 1.30。
兩個數字都遠低於 4.5,判定沒錯。但工具的數字是錯的。
原因在取背景色那段:它拿到 rgba(255,255,255,0.85),把 alpha 丟掉,直接當成純白。前景色的 alpha 有做合成,背景色的沒有。
工具算的 白字 on rgb(255,255,255) = 1.00
實際畫面 白字 on rgba(255,255,255,.85) 疊在 #3b3b3b 上 = 1.30
這次沒出事,因為兩邊都不及格。但把那層白改成 30% 不透明,實際背景會變成偏深的灰,白字其實有 5 以上的對比、應該過關,工具卻還是會拿純白去算,然後報一個假的 fail。
這條還沒修。 修法不難,取背景色時要把每一層半透明底色記下來,由下往上合成,而不是遇到第一層就停。真正麻煩的是回歸驗證:這個改動會動到每一頁的對比數字,得先跑完自家站的前後對照才敢動。
Day 2 說過工具報全綠但人工回來 9 條,Day 4 說過它掃自己會抓到自己。這是第三次,而這次是為了寫這篇文章才發現的。
回到開頭那八處 caveat。那是自家站一張卡片,樣式長這樣:
.solutionCard {
background-color: transparent;
background-image: linear-gradient(135deg,
rgba(10, 71, 176, .04),
rgba(146, 107, 255, .04));
}
兩個品牌色,各只有 4% 不透明度,斜著疊在頁面底色 #fcfdfe 上。視覺上幾乎看不出來是漸層。
工具的取色邏輯往上走,看到 background-color: transparent 就跳過,一路走到 body 拿到 #fcfdfe,用它算出 15.66。同時另一段邏輯偵測到祖先有 linear-gradient,掛上 caveat。
實際答案是多少?把兩個端點各自跟底色合成再算:
| 拿什麼當背景 | 卡片標題 rgb(32,34,37) | 卡片內文 rgb(69,74,78) |
|---|---|---|
| 工具用的 body 底色 | 15.66 | 8.80 |
| 漸層起點合成後 | 14.68 | 8.25 |
| 漸層終點合成後 | 14.99 | 8.43 |
全部過關,而且過很多。 這八個 caveat 是虛驚。
我一度想加一條例外:alpha 低於某個值就當它不存在。想了一下沒做。門檻要訂在哪?4% 顯然無害,40% 顯然有害,中間那條線畫在哪裡都是我拍板的,而 Day 7 才剛講完六個魔術數字都是我自己訂的。再多一個沒有依據的數字,不如留著 caveat 讓人看一眼。

工具取到的背景色不存在於畫面上,但真實答案的三個版本都過關。這八個 caveat 是虛驚。
一般文字 4.5、大尺寸文字 3.0。這個「大」的定義比多數人以為的嚴格:
def is_large_text(font_size_px, font_weight):
if font_size_px >= 24.0: # 18pt
return True
if font_size_px >= 18.66 and font_weight >= 700: # 14pt bold
return True
return False
實際套進去看幾組:
24px weight 400 大字 門檻 3.0
20px weight 700 大字 門檻 3.0
18px weight 700 一般 門檻 4.5 ← 差 0.66px
18px weight 600 一般 門檻 4.5 ← semibold 不算 bold
16px weight 400 一般 門檻 4.5
上一節那張卡片的標題是 18px、weight 600。看起來又大又粗,在 WCAG 的定義裡是一般文字,要 4.5 不是 3.0。差 0.66px、差一級字重,門檻就差了 50%。
設計系統裡最容易踩到的就是這格:把次要文字定成 18px / semibold / #767676,設計稿上看起來完全沒問題,實際上要用 4.5 的標準去驗,而 #767676 配白底剛好是 4.54,只剩 0.04 的餘裕。底色稍微換一個色階就掉下去。
明天 Day 11:我的工具把整個部落格當成訊息日誌,43 頁裡報了 32 頁。兇手是三個字母。