CSS無障礙WCAGforced-colorsUSWDS設計系統開源

用漸層畫的焦點框,在 Windows 高對比下會整個消失:NASA HDS Core 的四行 CSS 修法

· 30 分鐘閱讀
目錄
  1. 這個 bug 不是我找到的
  2. 強制色彩模式到底改掉了什麼?
  3. 為什麼同一個檔案裡的兩個焦點框,一個活著一個消失?
  4. 修法:四行 CSS,動 mixin 不動元件
  5. 怎麼驗證?陽性對照比多寫幾個斷言有用
  6. 我沒有加測試,而且在 PR 裡寫明了
  7. 維護者補的範圍:「完全沒有」和「有但太淡」是兩條不同的 WCAG
  8. 這個 repo 沒有 AI 揭露政策,我也沒有揭露
  9. 現在的狀態:已合併,尚未發版
  10. 帶走的東西

在 CSS 的強制色彩模式(forced-colors,Windows 11 的設定裡叫「對比佈景主題」)底下,background-image 只要不是 url() 形式的值就會被強制算成 none,所以用 repeating-linear-gradient 疊出來的焦點框會整片消失;同一條規則如果又寫了 outline: none,鍵盤使用者就真的什麼都看不到。NASA 的 Horizon Design System(HDS Core)就是這個狀況:文字連結、麵包屑連結、引言出處連結和無樣式按鈕,在高對比佈景主題下按 Tab 沒有任何焦點指示。修法是在 mixin 裡補一個 @media (forced-colors: active) 區塊把 outline 還回去,這筆改動是 nasa/hds-core PR #185,正式程式碼四行,2026 年 9 月 13 日由維護者合併。

HDS Core 是 NASA Horizon Design System 的 CSS 實作,GitHub 上的描述是「Documentation for NASA's Horizon Design System (HDS), with a Sass/CSS theme layer for the U.S. Web Design System (USWDS)」,也就是疊在美國聯邦網頁設計系統 USWDS 之上的一層主題。README 寫明它是給「standalone NASA websites, applications and platforms」用的,並且明確把其他人指向別處:「Interagency, non-.gov, and other non-NASA-branded sites should use USWDS instead.」專案目前是 pre-1.0(最新版 v0.10.0),README 自己標註類別名稱在小版本之間可能改變,程式碼以 CC0 1.0 釋出。它不是聯邦網站通用的東西,這一點我在自己的頁面上寫錯過一次,後來改掉了。

這個 bug 不是我找到的

回報者是 stevenpelletier90,2026 年 8 月 3 日開的 issue #176,比我的 PR 早九天。他不是丟一句「連結沒有焦點框」就走,他把機制查清楚了:引用 MDN 的 forced-colors 頁面、附上逐元素的結果表、指出圖示因為用 mask-image: url(...) 所以活著、指出按鈕另有 src/scss/base/_focus.scss:23-30 這條規則接住,而那條規則涵蓋 button、input、select、textarea、iframe、[tabindex][contenteditable],就是沒有 a。他最後補一句「This is in HDS, not USWDS.」,連責任範圍都先切好了。

他的環境是 Windows 11 Home build 26200、真的開著 Aquatic 對比佈景主題、Chrome 150.0.7871.187、HDS Core 0.9.0。有一個容易誤讀的細節:他說「六個 palette 全中」,那是 HDS 自己的六個色盤(white、light、midtone、dark、blue、black),不是六種 Windows 佈景主題。Windows 那邊他只用了一個主題。

我做的是後半段:在 mixin 層面修、量出前後差異、加一個陽性對照確認我量到的是真的。

強制色彩模式到底改掉了什麼?

強制色彩模式不是「把畫面變成黑白」,它是使用者要求瀏覽器改用一組受限的系統色重畫頁面,而規則分成兩種。

一種是丟掉。CSS Color Adjust Module Level 1 的說法是「Background-image computes to none unless the original value contains a url() function」,MDN 的說法是「background-image is forced to none for values that are not url-based」。漸層不含 url(),所以整層背景圖就沒了。

另一種是換色。顏色類屬性不會消失,而是被換成系統色。MDN 列出的清單是 color、background-color、text-decoration-color、text-emphasis-color、border-color、outline-color、column-rule-color、-webkit-tap-highlight-color,以及 SVG 的 fill 與 stroke,並註明「These browser-specified values are selected from the set of system colors.」規範的清單另外還有 accent-color、caret-color、flood-color、lighting-color、rule-color、scrollbar-color、stop-color。

outline-color 在換色那一組,background-image 在丟掉那一組。 這一句就決定了這個 bug 為什麼會發生,也決定了修法該走哪個屬性。

有一個限制要一起講,不然這篇會變成過度承諾。規範明確保留 alpha 的只有 background-color:「its alpha channel is taken from the original background-color value so that transparent backgrounds remain transparent」。其餘屬性是「the UA determines the appropriate forced system color」。也就是說,一個透明的 outline-color 會不會變成不透明的系統色,規範沒有明講,那是瀏覽器決定的行為。這裡的證據是實測的 Chromium 行為、回報者在真實 Windows 11 Aquatic 主題下的結果,以及 USWDS 早就在出貨同一個寫法。

為什麼同一個檔案裡的兩個焦點框,一個活著一個消失?

HDS Core 的焦點框有兩個 mixin,都在 src/scss/_hds-mixins.scss。壞掉的是行內版 hds-focus-ring-inline,形狀大致是這樣(簡化示意,完整版在原檔):

@mixin hds-focus-ring-inline {
  outline: none;
  // Four repeating-linear-gradient layers draw the 2,3 dash spec on all four edges.
  background-image: repeating-linear-gradient(...) /* 另外三層 */;
  background-size: 100% 1px, 100% 1px, 1px 100%, 1px 100%;
}

四層 repeating-linear-gradient 分別畫上下左右四邊的虛線。強制色彩模式一開,四層背景圖全部算成 none,而第一行的 outline: none 還在,結果是零指示。

區塊版 hds-focus-ring 的寫法不一樣:它設 position: relative; outline: none;,然後在 ::before 上用 background-color: var(--hds-palette-focus, ...) 搭配 mask-image: url("data:image/svg+xml,...") 去畫。背景「色」是被換成系統色而不是被丟掉,url() 形式的遮罩也留著,所以那個框還是畫得出來(雖然後來證實它淡到另有問題,下面會講)。

同一個檔案、同一份設計、同一個視覺規格,兩種畫法,在強制色彩模式下的命運完全相反。差別不在誰寫得用心,而在用到的屬性落在「丟掉」還是「換色」那一組。

修法:四行 CSS,動 mixin 不動元件

合併的 diff 是 +21 / -0,兩個檔案。扣掉 changeset 檔的 11 行、五行註解和一行空白,正式的部分是這樣:

@media (forced-colors: active) {
  outline: $border-high-contrast;
  outline-offset: 0;
}

$border-high-contrast 是 USWDS 自己的變數,定義在 packages/uswds-core/src/styles/variables/border-high-contrast.scss,值是 2px solid transparent修法本體是一個透明的 outline。 平常它畫不出任何東西,強制色彩模式一開,瀏覽器把 outline-color 換成系統色,框就自己回來了。USWDS 早就在用同一招:usa-range 的 focus thumb 是 @media (forced-colors: active) { outline: $border-high-contrast; }usa-date-picker 的 hover 是同一組再加 outline-offset: -2px,checkbox 與 radio 的顏色 helper 則套在 ::before 上加 outline-offset: 2px。在 USWDS repo 用 GitHub 程式碼搜尋,這個變數出現在 10 個檔案,包含 usa-button、usa-accordion 和 usa-pagination。

回報者在 issue 裡也提過一個近似的修法:outline: 1px solid transparent; outline-offset: 1px;,而且主張不需要 media query,因為透明 outline 平常本來就畫不出東西。合併的版本不是那個:它把恢復動作關進 @media (forced-colors: active) 裡,並且用專案已經有的 USWDS 變數,而那個變數本身剛好就是 2px solid transparent

第二個決定是改 mixin 而不是改元件。這個行內框有五個呼叫點:

選擇器 檔案
a:not(:has(> img, > svg)):focus-visible src/scss/base/_content-rules.scss:100
.usa-link:focus-visible src/scss/components/_link.scss:38
.usa-button--unstyled:focus-visible:not(...) src/scss/components/_button.scss:235
.hds-blockquote__attribution a:focus-visible src/scss/components/_blockquote.scss:208
.usa-breadcrumb__link:focus-visible src/scss/components/_breadcrumb.scss:75

一個區塊,五處同時修好,之後新加的呼叫點自動帶著。這也符合這個 repo 給 AI 代理看的規則檔 AGENTS.md 的要求:焦點樣式要走既有的 mixin 基礎建設,不要在元件層硬寫。

怎麼驗證?陽性對照比多寫幾個斷言有用

我用 Playwright 開 forcedColors: 'active' 的 context,送真正的 Tab 按鍵讓 :focus-visible 成立,然後對建置出來的 dist/css/hds.min.css 讀 computed style。PR 裡的結果表是這樣:

情境 outline 漸層層數
強制色彩關閉,修正前 none 4
強制色彩關閉,修正後 none 4
強制色彩開啟,修正前 none 0
強制色彩開啟,修正後 系統色 solid 2px 0

前兩列一模一樣,這是「預設渲染完全沒變」的證據。真正救了我的是第三欄。我在 PR 裡寫的是:

the gradient count going 4 -> 0 under emulation acts as a positive control: that only happens when forced-colors is genuinely active. An early version of my harness loaded the page with setContent, which silently blocked the file:// stylesheet, and every element came back looking like the browser default. That is the same trap from the other direction, and it would have "proved" there was no bug.

漸層數從 4 掉到 0,只有在強制色彩真的生效時才會發生。我早期那版 harness 用 setContent 載頁面,樣式表被默默擋掉,每個元素都回報成瀏覽器預設值,看起來一切正常。回報者踩過同一個坑的另一面,他在 issue 留言裡特別警告:

Make sure the forced colors media query is actually matching before you trust what you're looking at. Switching on the Windows theme doesn't always reach a browser that's already open, and if it isn't active then everything looks correct and you'd come away thinking there's no bug. I lost some time to that.

他另外提醒:那個框畫在 inset: -2px 的偽元素上,也就是在元素自己的框外面,任何只看元素邊界的檢查都會漏掉它,把好的元素報成壞的。

一個量測工具會用兩種方式騙你:讓壞的看起來好,或讓好的看起來壞。陽性對照的用處是釘住「這次量測的條件真的成立」,而不是只斷言結果。

我沒有加測試,而且在 PR 裡寫明了

這一筆沒有任何自動化測試,理由我直接寫進 PR:這個專案的測試套件是在無頭 chromium 裡跑 Storybook stories、靠 Chromatic 做視覺驗證,兩邊目前都不模擬 forced-colors,所以新增一個 FocusTest story 只會快照到正常模式的框,對這個 bug 什麼都證明不了,而正常模式的框 FocusLink 已經涵蓋。.storybook/modes.js 裡的 Chromatic modes 就是 HDS 那六個色盤,沒有 forced-colors 這一項。

然後我提了最小可行版本:用獨立 Playwright context 的 vitest browser test,跟 storybook 專案分開,才不會影響其他 stories,這筆 PR 加或另開一筆都可以。維護者在這筆 PR 沒有要求。

把沒跑到的驗證寫進 PR,這個習慣我在 HOT 無人機系統那一筆就用過,不是這次才學到的。這次的差別是連測試本身都沒有,那更要講清楚:與其讓維護者以為有一層保護,不如讓他知道現在完全沒有。chainladder 那筆剛好相反,測試存在、覆蓋率是綠的,只是它用的資料集永遠走不到出事的那個邊界

維護者補的範圍:「完全沒有」和「有但太淡」是兩條不同的 WCAG

PR 從開到合併是 31 天 20 小時 38 分。這個專案的 CONTRIBUTING.md 寫的是「We aim to respond within 1 to 2 weeks. Because the project is currently supported by a single core maintainer, review times can occasionally fluctuate...」等待期間維護者自己把 main 併進我的分支兩次(8 月 19 日、9 月 10 日)讓它保持可合併。數字給到這裡,不下評語。

她的核准留言技術上沒有糾正任何東西,但補了範圍:

Approve. Reviewed against a fresh build on this branch and confirmed the compiled dist/css/hds.min.css carries the forced-colors outline at all five inline call sites... Diagnosis is correct, the $border-high-contrast approach matches the USWDS idiom used in usa-range/usa-date-picker, and default rendering is provably unchanged.

要注意她做的是什麼:拿一份重新建置的 CSS,確認五個選擇器上的 outline 真的在。那是對產物的檢查,不是重跑我的模擬量測,強度比較弱,我不會把它說成「有人獨立重現了我的數據」。

她接著切了範圍:

One note on scope: this fixes the inline ring (the "nothing at all" case). Manual testing surfaced that the block-level hds-focus-ring (buttons, accordion, icon buttons, pagination, tables, side nav) still renders a too-faint dashed remnant in forced-colors; this is the "faint but there" case @stevenpelletier90 flagged in the issue comment. That's a separate follow-up (will track in #176), not a gap in this PR. Merging this as-is.

這條分界值得記下來:「完全沒有」是 2.4.7 Focus Visible(WCAG 2.1 與 2.2 都列 AA)的硬失敗,「有但太淡」不歸 2.4.7 管,是 1.4.11 Non-text Contrast(AA)的對比問題。 淡淡的框在「存在與否」這題上技術性通過,在對比那題上不及格。她在 issue #176 的盤點裡把這句寫得更直白:

This is a 1.4.11 Non-text Contrast AA concern, not strictly 2.4.7. The faint ring technically "passes" presence but fails contrast.

同一份盤點還列出連結失去了虛線底線(也是漸層畫的,被丟掉了),以及圖示與表單狀態指示消失,其中最嚴重的是 radio 的實心圓點:選了哪一項看不出來。

然後她自己動手。我這筆合併之後 62 分鐘,她開了 PR #230「fix: restore block-level focus rings and link underline in forced-colors mode」;5 分 27 秒後,也就是我這筆合併之後 67 分 42 秒,她自己按下合併,+30 / -1。commit 訊息寫著要「Draw a solid $border-high-contrast outline and hide the masked ::before instead, matching the inline-ring fix in #185」,順便把連結底線改回真正的 text-decoration。圖示與 radio 那一桶另外開了 issue #229,到 9 月 16 日為止還開著。

所以請不要把這篇讀成「我修好了 HDS 的強制色彩支援」。我修的是行內焦點框的五個呼叫點;區塊框和連結底線是她的 #230;圖示和表單狀態還在 #229 裡。

回報者最後在 PR 串上留了一句:「Thanks @abbybowman. I am very glad I could help with this.」這個順序是對的,他先看到、先查清楚。

這個 repo 沒有 AI 揭露政策,我也沒有揭露

我用 grep 在 PR 開出來時 main 的那個 commit(ce48aa37)和現在的 main 各查過一次:真正規定貢獻者義務的兩份文件,CONTRIBUTING.md.github/PULL_REQUEST_TEMPLATE.md,兩個版本都對單獨的 "AI" 這個字,以及 LLM、copilot、claude、generative、disclos、assisted、"generated by"、authorship,全部零命中。PR 範本的檢查清單也沒有這一項。我的 PR 本體沒寫任何揭露,commit 也沒有 Co-Authored-By。專案沒有要求,我也沒有主動提,這就是事實。

有意思的是這個 repo 反過來有兩件相關的東西。一是 AGENTS.md,61 行,開頭第一句就講明白是寫給誰看的:「This file orients AI agents working in this repository ... Humans do not need this file.」它列出的硬性規則裡,有一條就是這次改動直接踩到的那條:「Focus rings: use the existing mixins (hds-focus-ring, hds-focus-ring-inline, hds-focus-ring-size). Never hardcode focus styles.」CLAUDE.md.cursorrules.github/copilot-instructions.md 三個檔案都很短,內容只是叫任何代理去讀完整份 AGENTS.md。二是 docs/CREDITS.md,專案自己揭露了 AI 使用:「HDS Core was originally developed within NASA's Office of the Chief Information Officer (OCIO) in 2026, with assistance from agency-approved AI tools such as ChatGSFC and NMC AI Hub.」

我對這件事只有一句立場:維護者實際檢查的是證據,一份重新建置的 CSS、五個選擇器上的 outline,不是我的來源聲明。

現在的狀態:已合併,尚未發版

到 2026 年 9 月 16 日為止,這個修正在 main 上,但還沒進任何一個版本。GitHub 最新 release 和 npm 的 latest 標籤都是 v0.10.0,發布於 2026 年 8 月 19 日,早於 9 月 13 日的合併。我們那個 changeset 檔(patch bump)還躺在 .changeset/ 裡沒被消化,旁邊就是維護者自己那筆 forced-colors-block-rings.md。CHANGELOG 最上面一筆還是 0.10.0。合併不等於上線,這句話我寧可寫得太清楚。

changeset 的內容我是這樣寫的:「Text links and other inline focus targets now show a focus ring in forced-colors mode (Windows High Contrast).」以及「Nothing changes outside forced-colors mode. The gradient ring is untouched, so default rendering is identical.」

帶走的東西

  1. 強制色彩模式分成「丟掉」和「換色」兩組規則。url()background-image 被丟掉,顏色類屬性被換成系統色。你的視覺效果用到哪一組,決定它在高對比下的死活。漸層和 box-shadow 這類靠背景畫的裝飾,要有第二條腿。
  2. 視覺上等價的兩種畫法,在無障礙模式下不等價。 同一份設計規格,一個用四層漸層、一個用 mask-image 加背景色,結果一個消失一個留著。設計系統裡任何「換個方式畫同一個東西」的地方,都值得回去看一次。
  3. 修 mixin,不要修元件。 五個呼叫點,一個 @media 區塊,之後新增的呼叫點自動帶著。
  4. 量測工具會從兩個方向騙你。 加一個只有在條件真的成立時才會變的陽性對照,比多寫三個斷言有用。
  5. 合併不等於發版。 寫對外文案的時候,這兩件事之間隔著一個 changeset。

PR 原頁:nasa/hds-core#185,+21 / -0,其中正式程式碼四行。先前幾筆的整理在24 天內併入六個組織的七筆 PR,這一筆不在那份名單裡,它在名單的時間窗結束之後才合併。

常見問題

為什麼我的焦點框在 Windows 高對比模式下會不見?

因為在 CSS 的強制色彩模式(forced-colors)底下,只要 background-image 的值不是 url() 形式,就會被強制算成 none。MDN 的說法是「background-image is forced to none for values that are not url-based」,CSS Color Adjust Module Level 1 規範寫的是「Background-image computes to none unless the original value contains a url() function」。所以用 linear-gradient 或 repeating-linear-gradient 畫出來的框會整片消失;如果同一條規則又寫了 outline: none,鍵盤使用者眼前就什麼都不剩。

強制色彩模式會拿掉漸層,那它到底改掉哪些屬性?

背景圖只要不是 url() 就變成 none,所以漸層消失,而 url() 形式的 mask-image 與背景圖會留著。顏色類屬性不是被拿掉而是被換成系統色,MDN 列出的清單包含 color、background-color、text-decoration-color、text-emphasis-color、border-color、outline-color、column-rule-color、-webkit-tap-highlight-color,以及 SVG 的 fill 和 stroke;規範另外還列了 accent-color、caret-color、flood-color、lighting-color、rule-color、scrollbar-color、stop-color。outline-color 在這組清單上,這就是修法可以靠 outline 把框拿回來的原因。

要怎麼讓焦點框在強制色彩模式下看得見?

在 @media (forced-colors: active) 裡把 outline 還回去。USWDS 為此提供的變數是 $border-high-contrast: 2px solid transparent,已經用在 usa-range、usa-date-picker 和 checkbox/radio 的顏色 helper 上。nasa/hds-core PR #185 合併的整段正式改動就是四行:@media (forced-colors: active) { outline: $border-high-contrast; outline-offset: 0; }。要注意規範只保證 background-color 的 alpha 會被保留,其他屬性的強制色由瀏覽器決定,所以「透明 outline 會變成可見的系統色」這件事是實測與 USWDS 既有做法支撐的,不是規範給的承諾。

焦點框看不見算不算 WCAG 失敗?

算。WCAG 2.1 與 2.2 的成功準則 2.4.7 Focus Visible 都是 AA 級:「Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible.」框完全不見就是直接違反。另一種情況是框還在但太淡,那不歸 2.4.7 管,是 1.4.11 Non-text Contrast(AA)的問題,要求使用者介面元件與圖形物件對相鄰顏色至少 3:1。這兩條的分界正是 hds-core 維護者切分後續工作的依據。

沒有 Windows 機器要怎麼測強制色彩模式?

Chrome DevTools 的 Rendering 面板可以用 Emulate CSS media feature forced-colors: active;Playwright 可以開 forcedColors 設為 active 的 context,並且要送真正的 Tab 按鍵,:focus-visible 才會成立。更重要的是加一個陽性對照,也就是斷言某個只有在查詢真的生效時才會改變的量。回報者和我都遇過「看起來完全正常」的假陰性,他是因為切換 Windows 佈景主題沒有傳到已經開著的瀏覽器,我是因為用 setContent 載頁面害樣式表沒載進來。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

需要技術協助?

免費諮詢,24 小時內回覆。