iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

前端不寫 Python,照樣 ship 一把網頁無障礙 CLI系列 第 10

Day 10:漸層背景上的白字,公式要吃哪一個顏色

  • 分享至 

  • xImage
  •  

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 那個參數要填什麼。

四段文字,工具吐了三個 1.00

我做了一個四行的測試檔,四段白字或灰字,四種不同的背景:

<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。這個數字不是量測結果,是走到底的副產品。

四種背景下對比度量測結果的對照圖。第一列純色背景,工具算出 4.54,這是唯一真正量到的數字;第二列漸層背景,工具算出 1.00 並標記無法量測,實際上漸層兩端分別是 8.26 與 3.64;第三列半透明疊層,工具算出 1.00 判定失敗,實際合成後應為 1.30;第四列背景圖,工具算出 1.00 並標記無法量測,實際值無解。圖中標示只有第一列的數字是量測值,其餘三個 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 說過它掃自己會抓到自己。這是第三次,而這次是為了寫這篇文章才發現的。

那個 15.66 到底錯在哪

回到開頭那八處 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 讓人看一眼。

自家站卡片的實際情況說明圖。左邊是樣式:背景色為透明,背景圖是一道 135 度漸層,由 4% 不透明的藍色漸變到 4% 不透明的紫色。中間是工具的取色路徑,從文字元素往上走,遇到透明背景就跳過,一路走到頁面底色取得接近白色的值。右邊是三組對比數值對照,用工具取到的底色算出標題 15.66、內文 8.80,用漸層起點合成後算出 14.68 與 8.25,用漸層終點合成後算出 14.99 與 8.43,三組全部遠高於 4.5 的門檻

工具取到的背景色不存在於畫面上,但真實答案的三個版本都過關。這八個 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 的餘裕。底色稍微換一個色階就掉下去。

今天的重點

  • 公式十行就寫得完,門檻 4.5 / 3.0 / 7.0,難的從來不是算,是決定拿哪個顏色當背景
  • 量不到的時候不要給數字。四段文字三個 1.00,其中兩個是預設值不是量測值,要靠旗標區分
  • 漸層沒有單一答案。測試檔那條在 76% 的位置穿過 4.5,同一行字前四分之三合格
  • 背景色的 alpha 我漏掉了,1.00 應該是 1.30。這次判定沒受影響,但改成 30% 不透明就會誤報
  • 18px semibold 不是大字。差 0.66px 跟一級字重,門檻從 3.0 跳到 4.5

明天 Day 11:我的工具把整個部落格當成訊息日誌,43 頁裡報了 32 頁。兇手是三個字母。


上一篇
Day 09:同一條規則我寫了兩次,因為 lint 跟瀏覽器看到的不是同一個東西
下一篇
Day 11:我的工具把整個部落格當成訊息日誌
系列文
前端不寫 Python,照樣 ship 一把網頁無障礙 CLI12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言