CSSAccessibilityWCAGforced-colorsUSWDSDesign Systems開源

A Gradient Focus Ring Vanishes in Forced-Colors Mode: Four Lines of CSS in NASA's HDS Core

· 18 min read
Table of Contents
  1. The bug was reported by someone else
  2. What forced-colors mode actually does
  3. Why two focus rings in the same file behaved differently
  4. The fix: four lines, in the mixin, not in the components
  5. Verification: the positive control mattered more than the assertions
  6. I added no test, and said so in the PR
  7. "Nothing at all" and "faint but there" are different WCAG failures
  8. No AI disclosure policy, and I disclosed nothing
  9. Status: merged, not released
  10. What to take away

In CSS forced-colors mode, which Windows 11 exposes through its contrast themes, background-image computes to none for any value that is not url()-based, so a focus ring painted with repeating-linear-gradient layers disappears completely, and if the same rule also set outline: none, a keyboard user is left with nothing. That was the state of NASA's Horizon Design System (HDS Core): text links, breadcrumb links, blockquote attribution links and unstyled buttons showed no focus indicator at all under a high contrast theme. The fix is a @media (forced-colors: active) block inside the mixin that puts an outline back, merged on September 13, 2026 as nasa/hds-core PR #185, four lines of production CSS.

First, what the project is, so I do not oversell it. HDS Core is the CSS implementation of NASA's Horizon Design System, described on GitHub as "Documentation for NASA's Horizon Design System (HDS), with a Sass/CSS theme layer for the U.S. Web Design System (USWDS)". The README says it is intended for "standalone NASA websites, applications and platforms" approved to operate outside the agency's flagship CMS, and sends everyone else away: "Interagency, non-.gov, and other non-NASA-branded sites should use USWDS instead." It is pre-1.0, currently v0.10.0, with a README note that class names may change between minor versions, and it is released into the public domain under CC0 1.0. It is not something federal sites in general use. I got that wrong once on my own site and corrected it.

The bug was reported by someone else

stevenpelletier90 filed issue #176 on August 3, 2026, nine days before my PR. He did not just report that links had no focus ring. He worked out the mechanism: he cited the MDN forced-colors page himself, gave a per-element results table, noted that icons survived because they use mask-image: url(...), and found that buttons were caught by a separate rule at src/scss/base/_focus.scss:23-30 that covers button, input, select, textarea, iframe, [tabindex] and [contenteditable], but not a. He closed with "This is in HDS, not USWDS", scoping the blame before anyone asked.

His environment was Windows 11 Home build 26200 with the Aquatic contrast theme genuinely switched on, Chrome 150.0.7871.187, HDS Core 0.9.0. One detail that is easy to misread: when he says all six palettes were affected, those are HDS's own color palettes (white, light, midtone, dark, blue, black), not six Windows themes. On the Windows side he used one.

My contribution was the second half: fix it at the mixin level, measure the before and after, and add a positive control so I could trust the measurement.

What forced-colors mode actually does

This is the part worth getting precise, because "high contrast turns everything black and white" is not what happens. Forced colors is a mode where the user has asked the browser to repaint the page from a restricted set of system colors, and it does that with two different rules.

Some things are dropped. CSS Color Adjust Module Level 1 says "Background-image computes to none unless the original value contains a url() function", and MDN puts it as "background-image is forced to none for values that are not url-based". A gradient contains no url(), so the whole layer goes.

Other things are recolored. Color properties are not removed, they are replaced with system colors. MDN's list is color, background-color, text-decoration-color, text-emphasis-color, border-color, outline-color, column-rule-color, -webkit-tap-highlight-color and SVG fill and stroke, with the note "These browser-specified values are selected from the set of system colors." The spec's list adds accent-color, caret-color, flood-color, lighting-color, rule-color, scrollbar-color and stop-color.

outline-color is in the recolored group. background-image is in the dropped group. That single sentence explains both why the bug exists and which property the fix has to use.

One limit belongs in the same breath, or this becomes an overclaim. The spec explicitly preserves the alpha channel only for background-color: "its alpha channel is taken from the original background-color value so that transparent backgrounds remain transparent". For everything else, "the UA determines the appropriate forced system color". So whether a transparent outline-color becomes an opaque system color is user-agent behavior, not a written promise. The evidence here is measured Chromium behavior, the reporter's results under a real Windows 11 Aquatic theme, and the fact that USWDS has been shipping this idiom for a long time.

Why two focus rings in the same file behaved differently

HDS Core has two focus-ring mixins, both in src/scss/_hds-mixins.scss. The broken one is the inline ring, hds-focus-ring-inline, which looks roughly like this (simplified, the real thing is in the file):

@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(...) /* and three more layers */;
  background-size: 100% 1px, 100% 1px, 1px 100%, 1px 100%;
}

Four repeating-linear-gradient layers draw the dashes on the bottom, top, left and right edges. Turn forced colors on and all four compute to none, while the outline: none on the first line is still in force. Net result: no indicator.

The block-level mixin, hds-focus-ring, is built differently. It sets position: relative; outline: none; and then paints a ::before with background-color: var(--hds-palette-focus, ...) and mask-image: url("data:image/svg+xml,..."). A background color gets remapped to a system color rather than dropped, and a url()-based mask survives, so that ring still paints. (It turned out to have its own problem, which is the next section but one.)

Same file, same design, same visual spec, two implementations, opposite fates. The difference is not care. It is whether the properties you reached for land in the dropped group or the recolored group.

The fix: four lines, in the mixin, not in the components

The merged diff is +21 / -0 across two files. Subtract the 11-line changeset file, a five-line comment and a blank line, and the production change is this:

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

$border-high-contrast is a USWDS variable, defined in packages/uswds-core/src/styles/variables/border-high-contrast.scss, and its value is 2px solid transparent. That is the enjoyable part: the fix is a transparent outline. It paints nothing under normal rendering. Under forced colors the browser swaps outline-color for a system color and the ring comes back on its own. USWDS already does exactly this: usa-range puts @media (forced-colors: active) { outline: $border-high-contrast; } on the focused thumb, usa-date-picker uses the same with outline-offset: -2px on hover, and the checkbox and radio color helper applies it to the ::before with outline-offset: 2px. A GitHub code search finds the variable in 10 USWDS files, including usa-button, usa-accordion and usa-pagination.

The reporter had proposed something close in the issue: outline: 1px solid transparent; outline-offset: 1px; with no media query at all, on the argument that a transparent outline draws nothing anyway. The merged fix is not that. It scopes the restore inside @media (forced-colors: active) and uses the project's existing USWDS variable, which happens to be a transparent outline itself.

The second decision was to change the mixin rather than the components. Five call sites take the inline ring:

Selector File
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

One block, five selectors, and any future call site inherits it. It also matches what the repository's own AGENTS.md asks for: use the existing mixin infrastructure, do not hardcode focus styles at the component level.

Verification: the positive control mattered more than the assertions

I measured with Playwright using forcedColors: 'active', a real Tab keypress so :focus-visible applies, and computed styles read off the built dist/css/hds.min.css. The table from the PR:

Condition outline Gradient layers
forced-colors off, before none 4
forced-colors off, after none 4
forced-colors on, before none 0
forced-colors on, after solid 2px in a system color 0

The first two rows being identical is the evidence that default rendering is unchanged. The column that actually saved me is the third one. From the 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.

A gradient count falling from 4 to 0 can only happen when the media query is really matching. My early harness used setContent, the stylesheet never loaded, every element reported browser defaults, and the whole page looked healthy. The reporter had walked into the same trap from the other side, and warned about it in the 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.

He flagged a second one: the ring sits at inset: -2px on the pseudo element, outside the element's own box, so any check that only inspects the element's bounds will miss it and report working elements as broken.

A measurement harness can lie in two directions, making broken things look fine and fine things look broken. A positive control pins down that the conditions of the measurement actually held, which no number of assertions about the result will do for you.

I added no test, and said so in the PR

There is no automated test in this PR, and the PR explains why: the suite runs Storybook stories in headless chromium and leans on Chromatic for visual verification, neither of which currently emulates forced colors, so a new FocusTest story would snapshot the normal ring and tell you nothing about this bug. FocusLink already covers the normal-mode ring. The Chromatic modes in .storybook/modes.js are HDS's six color palettes, with no forced-colors mode among them.

I then offered the smallest useful version: a vitest browser test with a dedicated Playwright context, kept separate from the storybook project so it would not affect other stories, in this PR or a follow-up. The maintainer did not ask for it here.

Writing down the verification you did not run is not a lesson I learned on this one. I used it on the HOT drone-tm fix too. What is different here is that there was no test at all, which makes saying so more important, not less: better that a maintainer knows the guard is missing than assumes one exists. The inverse case is the chainladder bug, where the test existed and coverage was green but the dataset could never reach the boundary.

"Nothing at all" and "faint but there" are different WCAG failures

Open to merged was 31 days, 20 hours and 38 minutes. The project's CONTRIBUTING.md says: "All pull requests are reviewed by HDS Core maintainers. 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..." While it waited, the maintainer merged main into my branch twice, on August 19 and September 10, to keep it current. That is the number and the context; I am not offering a verdict on top of it.

Her approval comment corrected nothing technical, but it did add scope:

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.

Notice what she actually did: she took a fresh build and confirmed the outline is present at five selectors in the compiled CSS. That is a check of the artifact, not a rerun of my emulation, and it is the weaker of the two. I am not going to describe it as anyone independently reproducing my measurements.

Then she drew the line:

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.

That split is the most portable thing in this whole story. "Nothing at all" is a hard failure of SC 2.4.7 Focus Visible, Level AA in both WCAG 2.1 and 2.2. "Faint but there" is not 2.4.7 at all, it is 1.4.11 Non-text Contrast, also Level AA. She put it bluntly in her audit on 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.

The same audit lists what else forced colors had broken: links kept their ring but lost their defining dashed underline, which was gradient-based and therefore dropped, and icons and form-control state indicators disappeared, most seriously the radio's filled circle, where you cannot tell which option is selected.

Then she did the work herself. Sixty-two minutes after mine merged, she opened PR #230, "fix: restore block-level focus rings and link underline in forced-colors mode". Five minutes and twenty-seven seconds later, that is sixty-seven minutes and forty-two seconds after mine merged, she merged it herself, +30 / -1. Its commit message says to "Draw a solid $border-high-contrast outline and hide the masked ::before instead, matching the inline-ring fix in #185", and it restored the link underline as a real text-decoration. The icons and radio bucket became issue #229, still open as of September 16.

So please do not read this as "I fixed forced-colors support in HDS Core". I fixed the inline ring at five call sites. The block-level rings and the link underline are her #230. Icons and form-control states are #229.

The reporter's last word on the thread: "Thanks @abbybowman. I am very glad I could help with this." That order is the right one. He saw it first and did the diagnostic work.

No AI disclosure policy, and I disclosed nothing

I grepped for this twice, once at the main commit that was current when the PR opened (ce48aa37) and once at current main. CONTRIBUTING.md and .github/PULL_REQUEST_TEMPLATE.md, the two documents that actually set contributor obligations, contain zero matches at either commit for the standalone word "AI", or for LLM, copilot, claude, generative, disclos, assisted, "generated by" or authorship. The PR template's checklist has no AI item. My PR body carries no disclosure line and the commit has no Co-Authored-By trailer. The project asked for nothing and I volunteered nothing. That is the whole fact.

What the repository does have is the mirror image of a disclosure policy, in two places. First, AGENTS.md, a 61-line file whose own opening line says who it is for: "This file orients AI agents working in this repository ... Humans do not need this file." Its hard constraints include the rule directly behind this fix: "Focus rings: use the existing mixins (hds-focus-ring, hds-focus-ring-inline, hds-focus-ring-size). Never hardcode focus styles." CLAUDE.md, .cursorrules and .github/copilot-instructions.md are each a short pointer telling any agent to read AGENTS.md in full. Second, docs/CREDITS.md, where the project discloses its own AI use: "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."

I will take one position on this and stop. What the maintainer checked was the evidence, a fresh build and the compiled CSS at five selectors, not a claim about provenance.

Status: merged, not released

As of September 16, 2026 this fix is on main and in no release. The latest GitHub release and the npm latest dist-tag are both v0.10.0, published August 19, 2026, which predates the September 13 merge. Our changeset, a patch bump, is still sitting unconsumed in .changeset/ next to the maintainer's own forced-colors-block-rings.md, and the top entry in the CHANGELOG is still 0.10.0. Merged is not shipped, and I would rather say that too plainly than let it slide.

The changeset text, for what it promises and what it does not:

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.

What to take away

  1. Forced colors has two rules, not one. Non-url() background-image is dropped; color properties are recolored to system colors. Which group your effect depends on decides whether it survives. Gradients and box-shadow, anything painted through the background, need a second leg.
  2. Two visually equivalent implementations are not equivalent in accessibility modes. Same spec, one drawn with four gradients and one with a mask plus a background color, and only one survived. Every place a design system draws the same thing a second way is worth revisiting.
  3. Fix the mixin, not the component. Five call sites, one @media block, and the next call site is covered for free.
  4. A harness can lie in both directions. A positive control, something that can only change when the condition genuinely holds, is worth more than three extra assertions about the result.
  5. Merged is not released. When you write about it publicly, there is a changeset standing between those two words.

The PR: nasa/hds-core#185, +21 / -0, of which four lines are production CSS. Earlier work is collected in seven PRs merged into six organizations in 24 days. This one is not on that list; it merged after that window closed.

FAQ

Why does my CSS focus ring disappear in Windows High Contrast mode?

Because in CSS forced-colors mode, background-image is dropped for any value that is not url()-based. MDN states that background-image is forced to none for values that are not url-based, and CSS Color Adjust Module Level 1 states that background-image computes to none unless the original value contains a url() function. A ring drawn with linear-gradient or repeating-linear-gradient therefore vanishes entirely, and if the same rule also set outline: none, a keyboard user is left with no indicator at all.

Does forced-colors mode remove gradients, and which properties does it override?

Background images survive only if the value contains url(), so gradients are dropped while url()-based background images and mask-image keep working. Color properties are not removed but replaced with system colors. MDN lists color, background-color, text-decoration-color, text-emphasis-color, border-color, outline-color, column-rule-color, -webkit-tap-highlight-color and SVG fill and stroke; the spec adds accent-color, caret-color, flood-color, lighting-color, rule-color, scrollbar-color and stop-color. outline-color being on that list is exactly why an outline can bring a focus indicator back.

How do I make a focus indicator visible in forced-colors mode?

Restore an outline inside @media (forced-colors: active). USWDS ships a variable for this, $border-high-contrast: 2px solid transparent, and already uses it in usa-range, usa-date-picker and the checkbox and radio color helper. The entire production change merged in nasa/hds-core PR #185 is four lines: @media (forced-colors: active) { outline: $border-high-contrast; outline-offset: 0; }. Note that the spec preserves the alpha channel only for background-color; for other properties the user agent determines the forced system color, so a transparent outline becoming visible is measured browser behavior plus an established USWDS idiom, not a guarantee written into the spec.

Is an invisible focus indicator a WCAG failure?

Yes. Success Criterion 2.4.7 Focus Visible is Level AA in both WCAG 2.1 and WCAG 2.2: any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible. A ring that is present but too faint is a different problem, 1.4.11 Non-text Contrast at Level AA, which requires user interface components and graphical objects to have at least 3:1 contrast against adjacent colors. That distinction is how the hds-core maintainer split the follow-up work.

How do I test forced-colors mode without a Windows machine?

Chrome DevTools has Emulate CSS media feature forced-colors: active in the Rendering panel, and Playwright can open a context with forcedColors set to active. Send a real Tab keypress so :focus-visible applies. Most importantly, include a positive control: assert something that can only change when the media query is genuinely matching. Both the reporter and I hit false negatives where everything looked fine, in his case because switching the Windows theme did not reach a browser that was already open, and in mine because loading the page with setContent silently blocked the stylesheet.

Weekly AI Automation Playbook

No fluff — just templates, SOPs, and technical breakdowns you can use right away.

Join the Solo Lab Community

Free resource packs, daily build logs, and AI agents you can talk to. A community for solo devs who build with AI.

Need Technical Help?

Free consultation — reply within 24 hours.