今天要解的問題

拿 2026Q3 SEO 健檢那篇去跑 PageSpeed Insights(手機版),最佳做法和 SEO 都是 100,無障礙卻卡在 95,就差那 5 分。扣分的是兩個稽核項目:

  • Background and foreground colors do not have a sufficient contrast ratio(文字對比不足)
  • Elements with role="none" or role="presentation" have conflicts(role 衝突)

Lighthouse 無障礙四個頁面從 95 升到 100,以及兩組顏色的前後對比

這兩項雖然都不會讓網站壞掉。我網站的配色是米色底配暖色的墨色,而這在灰褐色的次要文字(日期、頁尾、月曆上的數字)會非常淡。在保留我的配色下,只有稍微調整一點,讓我的分數可以過關。

技術背景

對比度怎麼算

WCAG 的對比度是兩個顏色「相對亮度」的比值:(L1 + 0.05) / (L2 + 0.05),L1 是較亮的那個,範圍從 1:1 到 21:1(WCAG 2.1 contrast ratio 定義)。AA 等級要求一般大小的文字至少 4.5:1,大字(18pt 以上,或 14pt 粗體)至少 3:1(SC 1.4.3)。

這裡要注意的是,對比度是一組文字色和底色的配對,不是一個顏色本身的屬性。同一個灰褐色,放在白一點的底色上會過,放在黃一點的底色上就不過。我寫了一支小 Python 照這個公式算,後面所有的比值都是它算出來的,跟 Lighthouse 報的數字一致(例如 #7A6A4F 配 #F2EBDB,Lighthouse 報 4.41,自己算 4.42,差在四捨五入)。

role=“presentation” 跟 alt=""

<img alt=""> 是「這張圖是裝飾,螢幕閱讀器請跳過」的寫法,瀏覽器會把它當成 role="presentation"。但如果同一個元素又帶了 aria-label 這種全域 ARIA 屬性,瀏覽器要忽略 presentation 角色、把它當成一般的圖片(WAI-ARIA 1.2:Presentational Roles Conflict Resolution),於是兩個訊號互相打架。Lighthouse 對這個元素的說明是:

nav.nav > div.logo > a > img
Fix all of the following:
  Element does not have global ARIA attribute

意思是:要嘛別當裝飾,要嘛拿掉全域 ARIA 屬性。

想法與取捨

先在本機重跑,拿到確切的元素

但 PageSpeed 那次只留下稽核項目的名稱,看不到是哪些元素,而且只測了一頁。所以我先在本機 build 整站,用 Lighthouse CLI 對四個頁面各跑一次:首頁、/blogs/、每日筆記的月曆系列頁,和那篇 SEO 健檢文章。

npx -y [email protected] "http://localhost:8176/series/kiwi-daily/" \
  --only-categories=accessibility --form-factor=mobile \
  --chrome-flags="--headless=new" --output=json --output-path=series.json --quiet

結果四頁分別是 95、95、95、96,失敗的元素整理起來只有三種:

元素前景
背景
對比
次要文字#7A6A4F
#F2EBDB
4.41
程式碼註解#6E738D
#1C1D21
3.6
header logorole 衝突—

第一列的「次要文字」包括頁尾、首頁的「Latest」、月曆的日期與副標、文章底下上一篇/下一篇的標題。看起來元素很多,其實是同一組顏色配對。

改 token,不改元素

修對比有兩條路:在那幾個元素上各自換成比較深的顏色,或直接把次要文字的顏色 token 調深。失敗的五種元素用的都是同一個 token --ink-soft-warm,底色也都是同一個 #F2EBDB,若一個一個改就要動五個 selector,改 token 則一次全部蓋到。加上這個網站的顏色本來就規定只從 custom.css 開頭的 token 來,所以就改 token。

但 token 一改,整站的次要文字都會跟著變,包括原本就過的地方(文章頁的日期、卡片上的 meta)。所以新顏色要越接近原色越好,小到看起來還是同一個色系。

挑哪個灰褐色

從原色 #7A6A4F 往深的方向、保持同樣色相,算了幾個候選:

候選列表頁底色 #F2EBDB邊框色 #E8E2D5
#7A6A4F(原色)4.424.07
#77674C4.624.25
#7565494.764.38
#7363484.904.51
#7161465.054.65

只看失敗的那個底色,#77674C 就過了。但這次的問題就是出在一個沒想到的底色上(見後面「踩到的坑」),所以我多看了網站裡最暗的淺色:邊框色 #E8E2D5,若哪天次要文字放到這個顏色的區塊上也要能過。#736348 是第一個在邊框色上也過 4.5 的,就選它。它在文章頁底色 #FAF7F0 上是 5.44、卡片的 #FFFDF8 上是 5.73。

程式碼註解

程式碼區塊的配色是 PaperMod 內建的 Chroma 樣式,註解色 #6E738D 在深色底色 #1C1D21 上只有 3.6。主題是 git submodule,所以在自己的 custom.css 覆寫。候選算了三個:#8B90A8 5.33、#9399B2 5.97、#A5ADCB 7.57。目標是只把註解拉過門檻、其他語法色不動,所以選三個裡最接近原色、還保留藍灰色調的 #8B90A8。

深色模式

這個網站設成 defaultTheme: light、也關掉了切換按鈕,訪客其實看不到深色模式。但 CSS 裡兩套 token 都還在,所以我還是測了:在頁面上手動加 body.dark,用 axe-core 跑同樣的規則。新改的兩個顏色在深色下都沒問題(深色的次要文字是另一個 token,在各深色底色上 5.03 到 7.23;程式碼區塊在兩種模式都是同一個深色底色,所以註解色共用)。

深色模式下其實還有別的對比問題,例如文章底下的標籤是 #B8A77F 配 #F2EBDB,只有 1.99。這些改之前就存在,原因出在 token 那段 selector 裡的 .post-single:not(.dark):文章區塊本身沒有 .dark class,所以在深色頁面裡又被套回淺色 token。要修就等於讓整套深色模式能用,是另一件事,這次先不動。

實作

次要文字 token

改的只有淺色那一組裡的兩行。--secondary 是 PaperMod 自己用的名字(頁尾、列表的 meta 讀的是它),--ink-soft-warm 是我們自己 partial 用的名字,兩個要一起改:

:root,
.list:not(.dark),
.post-single:not(.dark),
body:not(.dark) {
    /* PaperMod-named variables → warm cream tokens */
    --theme: #FAF7F0;            /* paper-cream */
    /* ... */
    --secondary: #736348;        /* ink-soft-warm */
    /* ... */
    --ink-soft-warm: #736348;
    /* ... */
    --code-inline-bg-warm: #F2EBDB;
    --code-block-bg: #1C1D21;
    /* Code blocks are dark in both modes, so dark mode reuses this. */
    --code-comment: #8B90A8;
}

--code-comment 是新的 token,只在淺色那組定義一次。深色那組(body.dark)沒有重新宣告它,CSS 變數會從 :root 繼承下去,所以深色模式拿到的是同一個值。

覆寫 Chroma 的註解色

Chroma 把不同種類的註解拆成好幾個 class,主題的樣式表裡用 #6e738d 的有九個:一般註解、多行註解、單行註解、前處理指令、docstring、heredoc 等等。全部指到新 token:

/* Chroma's comment / docstring colour (#6e738d) is 3.6:1 on the code
   block; lift it to AA without changing the rest of the palette. */
.chroma .c, .chroma .ch, .chroma .cm, .chroma .c1, .chroma .cs,
.chroma .cp, .chroma .cpf, .chroma .sd, .chroma .sh {
    color: var(--code-comment);
}

主題的 CSS 和我們的 custom.css 會被串成同一個檔,custom.css 排在後面,同樣的 selector 權重,後面的贏,所以不用加 !important。

SEO 健檢文章裡的 YAML 程式碼區塊,改前與改後的註解顏色

logo 的 aria-label

header 的 logo 是一張圖加上網站名稱文字,一起包在連到首頁的 <a> 裡。圖本身是裝飾,連結的名稱來自後面的文字「kiwi-walk」,所以 alt="" 是對的,多出來的是 aria-label="logo":

<a href="{{ "" | absLangURL }}" accesskey="h" title="{{ $label_text }} (Alt + H)">
    {{- /* ... */}}
    <img src="{{ $img.Permalink }}" alt=""
        width="{{- site.Params.label.iconHeight | default "30" -}}" height="{{- site.Params.label.iconHeight | default "30" -}}">
    {{- /* ... */}}
    {{- $label_text -}}
</a>

拿掉之後,螢幕閱讀器讀這個連結時還是「kiwi-walk」,只是不會多讀一個「logo」。這個 header 是從 PaperMod 複製出來改的,aria-label="logo" 是原本主題就有的寫法,兩個分支(處理過的圖片、外部網址的圖片)各一處,兩處都拿掉。

踩到的坑

對比明明是過的

我原本以為 token 本身就沒問題:--ink-soft-warm 配網站的底色 --paper-cream(#FAF7F0)是 4.90,深色那組 7.23,兩個都過。照這樣算根本沒有對比問題,所以只能猜是疊在別的底色上的文字,要重跑才知道是哪些。

結果 Lighthouse 報的底色是 #F2EBDB,不是 #FAF7F0。這個顏色在 token 裡是「行內程式碼的底色」,照理不會出現在頁尾後面。打開首頁直接看 computed style:

body                  background: rgb(242, 235, 219)   color: rgb(42, 37, 32)
footer.footer > span  background: transparent           color: rgb(122, 106, 79)

才發現 body 的底色真的是 #F2EBDB。往 PaperMod 的原始碼找,在 theme-vars.css:

.list {
    background: var(--code-bg);
}

列表頁(首頁、/blogs/、系列頁)的 body 帶 .list class,PaperMod 把它的底色換成 --code-bg。我們的 token 把 --code-bg 指到 #F2EBDB,這導致列表頁的底色其實一直是它,比文章頁的 #FAF7F0 深一點、黃一點。文章頁上被點名的是「上一篇/下一篇」的標題,Lighthouse 量到的底色也是 #F2EBDB。

所以「次要文字配網站底色」這組是過的,失敗的是「次要文字配列表頁底色」,而後者沒有寫在任何一個 token 的名字裡。這也是上面挑顏色時多看一個邊框色的原因。

小結

四個頁面的 Lighthouse 無障礙分數都從 95、96 升到 100,在 preview 上重跑也一樣。改動只有三處:

  1. 次要文字的 token 從 #7A6A4F 調深到 #736348
  2. 新增一個程式碼註解色 token 並覆寫 Chroma 的九個註解 class
  3. 拿掉 logo 上多餘的 aria-label。

每日筆記月曆在手機寬度下改前與改後,日期數字的顏色只深了一點

肉眼要特別拉出來看,但若不仔細看,根本很難注意到。灰褐色還是那個灰褐色,只是剛好跨過 4.5:1。