Skip to content

fix: restore inline focus ring in forced-colors mode - #185

Merged
abbybowman merged 3 commits into
nasa:mainfrom
ppcvote:fix/forced-colors-inline-focus-ring
Sep 13, 2026
Merged

abbybowman merged 3 commits into
nasa:mainfrom
ppcvote:fix/forced-colors-inline-focus-ring

Conversation

@ppcvote

@ppcvote ppcvote commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #176. Thanks @stevenpelletier90 for the report, the six-palette results, and especially the warning about confirming the media query is actually matching before trusting what you see. That one shaped how I verified this.

Why nothing is painted

hds-focus-ring-inline (src/scss/_hds-mixins.scss) opens with outline: none and then draws its dashed ring with four repeating-linear-gradient layers. Forced-colors mode does not paint background images, so all four layers drop out, and the native outline is already gone. Nothing remains.

This also explains the asymmetry you observed between buttons and links. hds-focus-ring paints through a ::before with mask-image plus a background-color, and background colors are remapped to system colors rather than dropped, so that ring survives. Measured on a keyboard-focused element with forced colors on:

mechanism forced-colors on
a.usa-link 4 gradient layers on the element 0 layers, outline: none — nothing painted
button.usa-button ::before mask + background-color mask still set, background repainted in a system color

The fix

One @media (forced-colors: active) block inside the mixin, using the $border-high-contrast value USWDS already uses for this in usa-range, usa-date-picker and checkbox-and-radio-colors:

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

Fixing the mixin rather than _link.scss covers everything that takes the inline ring. Confirmed in the compiled stylesheet that all five call sites now carry it:

a:not(:has(>img,>svg)):focus-visible            (base/_content-rules.scss)
.usa-link:focus-visible                          (components/_link.scss)
.usa-button--unstyled:focus-visible:not(...)     (components/_button.scss)
.hds-blockquote__attribution a:focus-visible     (components/_blockquote.scss)
.usa-breadcrumb__link:focus-visible              (components/_breadcrumb.scss)

Verification

Playwright with forcedColors: 'active', real Tab keypress so :focus-visible applies, reading computed styles off the built dist/css/hds.min.css:

outline gradient layers
forced-colors off, before and after none 4
forced-colors on, before none 0
forced-colors on, after solid 2px, system color 0

Two things worth calling out:

  • Default rendering is unchanged. The off rows are identical before and after, so nothing ships to sighted users on a normal theme.
  • The media query really matched. Your warning is the failure mode I was most worried about, so 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.

npm run lint:scss, prettier --check and remark all pass. Changeset included as a patch.

On tests

I did not add an automated test. The suite runs Storybook stories in headless chromium and leans on Chromatic for visual verification, and forced-colors is not something either currently emulates, so a new FocusTest story would snapshot the normal ring and tell you nothing about this bug. FocusLink already covers the normal-mode ring.

Your inset: -2px note is well taken, and it is why I asserted on computed outline-style and layer counts rather than geometry or a screenshot. If you would like forced-colors coverage in CI, the smallest version is a vitest browser test with a dedicated playwright context, separate from the storybook project so it does not affect other stories. Happy to add that in this PR or a follow-up, whichever you prefer.

ppcvote and others added 2 commits August 12, 2026 23:41
`hds-focus-ring-inline` sets `outline: none` and then paints its dashed
ring with four repeating-linear-gradient layers. Forced-colors mode does
not paint background images, so all four layers drop out, and since the
native outline was already removed nothing is rendered at all. Keyboard
users on a high-contrast theme cannot see which link has focus.

Restore an outline inside `@media (forced-colors: active)`, using the
`$border-high-contrast` value USWDS already uses for the same purpose, so
the browser repaints it in a system color.

The block ring is unaffected: `hds-focus-ring` paints through a ::before
mask with a background-color, and background colors are remapped rather
than dropped, which is why buttons and inputs still show a ring today and
links do not.

Measured with Playwright forced-colors emulation against the built
stylesheet, reading computed styles on a keyboard-focused link:

  forced-colors off, before and after: outline none, 4 gradient layers
  forced-colors on,  before:           outline none, 0 gradient layers
  forced-colors on,  after:            outline solid 2px, 0 gradient layers

The gradient count dropping 4 -> 0 under emulation doubles as a positive
control that the media query really matched, which the reporter flagged as
easy to get wrong.

Fixes nasa#176
@abbybowman
abbybowman self-requested a review August 20, 2026 14:49
@abbybowman

Copy link
Copy Markdown
Contributor

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 (a:not(:has(>img,>svg)), .usa-link, .usa-button--unstyled, .hds-blockquote__attribution a, .usa-breadcrumb__link). 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.

Thanks @ppcvote for the rigor. The "gradient count 4 to 0 as a positive control" verification is exactly the right way to guard against the false-pass trap @stevenpelletier90 warned about.

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.

@abbybowman
abbybowman merged commit 43da441 into nasa:main Sep 13, 2026
9 of 10 checks passed
abbybowman added a commit to jibin7jose/hds-core that referenced this pull request Sep 13, 2026
…ors mode

hds-focus-ring (buttons, accordion, icon buttons, pagination, side nav) drew its ring with mask-image plus background-color. Forced-colors keeps the mask but repaints the background as a system color, leaving only a thin, too-faint 2,3 dash (1.4.11 Non-text Contrast). Draw a solid $border-high-contrast outline and hide the masked ::before instead, matching the inline-ring fix in nasa#185.

hds-link-appearance lost its gradient underline in forced-colors; restore it as a real text-decoration underline.

Extends nasa#185. Refs nasa#176.
@stevenpelletier90

Copy link
Copy Markdown

Thanks @abbybowman. I am very glad I could help with this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Text links have no visible focus indicator in forced-colors / Windows High Contrast Mode (all palettes)

3 participants