Skip to content

fix: map USWDS hero callout and dark sections onto the HDS dark palette - #166

Open
suthat wants to merge 1 commit into
nasa:mainfrom
suthat:fix/uswds-hero-dark-section-theming
Open

fix: map USWDS hero callout and dark sections onto the HDS dark palette#166
suthat wants to merge 1 commit into
nasa:mainfrom
suthat:fix/uswds-hero-dark-section-theming

Conversation

@suthat

@suthat suthat commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What this PR does

I picked this up from #148, and the first thing I did was build the CSS and drop the untouched USWDS landing page template on top of it, because I wanted to see the thing with my own eyes before deciding anything. The issue undersells it a little. The hero callout is a slab of dark red with a cyan headline sitting on it, there's a red button on the red box, and further down the graphic list sits on the same red with more cyan. Nothing else in HDS looks remotely like that. If I were a team migrating a site over from USWDS, this is one of the first screens I'd see, and I'd probably conclude the theme was broken and go file a ticket — which is roughly what happened here.

The cause is short. USWDS paints both of its dark layout contexts — .usa-hero__callout and .usa-section--dark — with color("primary-darker"), and colors the headings inside them with color("accent-cool"). HDS maps the primary family to NASA Red, and accent-cool is a family HDS never themes at all, so the red and the cyan both come straight through untouched.

This PR maps both surfaces onto palette 4 (dark, Carbon 90), so everything sitting on them — headings, body copy, links, buttons, focus rings — resolves against the surface it's actually on.

Closes #148

The part I got wrong first

My instinct was the lazy one: this is exactly what the palette system is for, so just tell adopters to wrap the section in .hds-palette-dark and close the issue with a docs change. I'm glad I tried it before writing it down, because it doesn't work, and the reason is the whole reason this fix has to live in the framework instead of the guide.

The red isn't a palette custom property. It's a plain background-color on the component itself, so no ancestor wrapper can reach it. And when I wrapped it anyway, .hds-palette-white made things actively worse — the headings gave up their cyan and became Carbon Black on the red block. So the advice we'd have been handing adopters was "add this class and it gets harder to read."

Then I stopped eyeballing it and measured what was actually shipping. A couple of these were worse than I expected:

Element Before After
Surface USWDS primary-darker #8b0a03 Carbon 90 #17171b
Headings on those surfaces accent-cool #00bde3, 4.38:1 --hds-palette-heading white, 17.9:1
Hero --alt eyebrow White — fine on its own, but it's the line that gets flattened once the cyan goes --hds-palette-muted Carbon 30, 9.1:1
.usa-link in a dark section Carbon 90 on red, 1.8:1 White, 17.9:1
.usa-button--outline label Carbon Black on red, 2.1:1 White, 17.9:1
.usa-button on the hero callout #d83933 on #8b0a032.1:1 against its own container #d83933 on Carbon 90, 3.9:1

The three in bold fail WCAG 2.1 AA. The link one in particular bothered me: 1.8:1 is not "a bit low," it's a link you cannot see. That happens because an HDS component dropped inside a USWDS dark section is still reading white-palette values — nothing ever told it the ground underneath it had changed color.

What I chose, and why

I deliberately didn't invent anything. HDS already has a designed dark surface, and it already knows how to relate every token to it, so I mapped the two USWDS contexts onto that rather than picking a new dark red or a new neutral. It means no new colors enter the system, and adopters get correct behaviour on markup they already shipped without going back and adding classes to it.

I kept the selectors inside :where() so they carry zero specificity. If a team wants a different surface there, they drop .hds-palette-blue on the same element and win, no !important needed. That's the same contract as the USWDS surface bridges that already live directly above this block in base/_palettes.scss — I tried hard to make this read like it belongs next to them rather than like something bolted on afterwards.

Two things I chose not to touch, in case it looks like an oversight. USWDS pins <p> and <a> inside .usa-section--dark to white on the elements themselves; I left those alone, because white is what the dark scheme resolves to anyway, and the blue palette — the other surface someone would plausibly swap in on a section marked dark — is also dark with white body text. And .usa-section--light is untouched: USWDS paints it base-lightest, which the HDS theme already maps to white, so it agrees with the default palette without help.

The bit I'm least sure about

The USWDS hero heading is two-tone: a small --alt eyebrow above the statement line. Once the cyan is gone, painting both with the heading color flattens it into one undifferentiated block of white, and the design intent of the component quietly disappears.

I gave the eyebrow the muted token HDS already uses for overlines and metadata (Carbon 30, 9.1:1 on Carbon 90). It reads well and it keeps the hierarchy, but I want to be honest that this is a design call and not a contrast fix, so I've written it up in docs/DESIGN.md under Creative Director Review rather than burying it. If design would rather the eyebrow stay full white, or take a different treatment entirely, it's one declaration to change and I'll happily change it.

Something I nearly shipped a bug on

Themed dark surfaces created a print problem I didn't see coming and only caught because I ran the page through print emulation on a whim.

Browsers drop background colors when printing, so white text on those sections would have printed as a blank page. The existing reset in base/_print.scss only covers palette containers, and these two aren't palette containers — they carry the dark scheme without being one — so they're now listed there too. USWDS pins those <p> and <a> colors on the elements themselves, where the reset only reached them by inheritance and lost, so those get an explicit black.

Type of change

  • Bug fix (patch)
  • New feature or component (minor)
  • Breaking change (see Public API section below)
  • Documentation only
  • Tooling or CI (no effect on published output)

I went back and forth on patch vs minor. It's a fix in intent, but it visibly changes markup adopters already have on their pages, and the semver rubric treats that as a visible change rather than a silent correction — so, minor. Push back if you read the rubric differently.

Checklist

  • npm run format:fix and npm run lint:scss:fix pass
  • npm run lint:js and npm run lint:md pass
  • Tested across all 6 palettes in Storybook
  • Tested across mobile, tablet, and desktop viewports
  • Automated a11y checks pass (npm test)
  • Storybook documentation updated (if component changed)

I don't like ticking boxes without saying what's behind them, so:

The new PaletteA11y story renders the hero and the dark section inside all six palettes and runs axe across the lot. Viewports I checked by hand at 320, 640 and 1024 — the callout goes full-bleed on mobile and the graphic list collapses to one column, and both keep the surface and stay readable.

The a11y one I want to flag specifically. A test that passes tells you nothing until you've watched it fail, so I reverted the SCSS and re-ran the suite to make sure the new story actually catches this bug rather than just going green next to it. On the pre-fix CSS it fails on color-contrast for .usa-hero__heading (2.14:1) and .usa-button--outline (2.14:1). Reapplied the fix, clean. That's the bit that makes me comfortable this regression can't quietly come back.

Docs went to docs/DESIGN.md (rationale, before/after, the design question flagged), docs/ARCHITECTURE.md (why layer order handles this without specificity tricks, plus the print note), stories/guides/USWDS.mdx (adopters no longer need to hand-wrap these two, but other dark contexts like .usa-dark-background still need it), and AGENTS.md so nobody — me included, six months from now — "simplifies" this away without knowing what it was for.

Public API and changesets

  • Ran npm run update:api-snapshot and reviewed the diff
  • Changeset added (npx changeset) with appropriate bump level
  • Bump level matches the semver rubric

The snapshot picks up three selectors: .usa-hero__callout, .usa-hero__heading--alt, .usa-section--dark. No new Sass symbols, so the public Sass surface is unchanged.

Visual review

Storybook: Guides → Existing USWDS Site → Dark Sections is the focused case, and Landing Page shows both surfaces in the full USWDS template if you want to see them in context.

Notes for reviewers

The eyebrow color is the one open question; everything else is built from tokens that already exist, so it should be uncontroversial, but I'd rather you tell me than assume.

Worth saying out loud: this will visibly change any site currently shipping a USWDS hero callout or .usa-section--dark under HDS. Red block becomes Carbon 90. That's the point of the issue, but somebody will notice it in a release, so the changeset says so plainly.

On the status:needs-design-review label — I read that as "the color decision needs a designer to sign off," not "don't touch this yet," so I've put forward a specific answer assembled entirely from existing tokens rather than leaving it open. If that was the wrong read, tell me and I'll pull it back to a proposal. If design wants a different surface, it's one block in base/_palettes.scss.

Scope I deliberately left alone: other USWDS dark contexts like .usa-dark-background and custom section modifiers. Those still need an adopter-applied palette class. I kept this to the two surfaces named in the issue rather than expanding it on my own initiative — happy to follow up in a separate PR if you'd like the same treatment there.

USWDS fills .usa-hero__callout and .usa-section--dark with
primary-darker and colors the headings inside them accent-cool. Under
the HDS theme that renders a NASA-red block with cyan headings, a red
call-to-action sitting on the red hero box, and HDS components inside
the section still reading white-palette values — .usa-link came out at
1.8:1 and outline button labels at 2.1:1 against the red. A palette
wrapper cannot reach any of it, because the red is a background-color
on the component itself.

Both surfaces now carry the full dark scheme (palette 4, Carbon 90), so
headings, text, links, buttons, and focus rings resolve against the
surface they actually sit on. The hero's --alt eyebrow takes the muted
token so the two-tone heading survives; that one is flagged for
creative director review in DESIGN.md. Selectors stay inside :where(),
so an adopter who wants a different surface still wins with a palette
class on the same element.

Print styles reset both surfaces as well, including USWDS's white <p>
and <a> inside .usa-section--dark, which the palette reset only reached
by inheritance and lost.

Adds a Dark Sections story whose six-palette axe check fails on the
pre-fix CSS, so the regression cannot come back unnoticed.

Refs nasa#148

Co-authored-by: Cursor <cursoragent@cursor.com>
@suthat

suthat commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Heads up on the red X: Chromatic is failing on Missing project token, not on a visual diff. It dies 23 seconds in, before it builds anything.

That's the fork-PR secrets rule — GitHub doesn't expose secrets.CHROMATIC_PROJECT_TOKEN to pull_request runs from a fork, so projectToken arrives empty and the CLI bails immediately. Same thing happened on #160 with the identical error, and Chromatic ran fine once it went through from inside the repo. Nothing I can do from this side.

Every other check is green, including the ones that would actually catch a mistake here: public API contract, CSS hash comparison, the test suite, bundle size, token drift, and all the linters.

One thing worth knowing before someone kicks Chromatic off: this PR will produce real visual changes that need accepting as new baselines. That's expected, not a regression — the whole point is that the hero callout and .usa-section--dark stop being NASA Red and become Carbon 90. Expect diffs on the Landing Page story and on the new Dark Sections story.

Happy to rebase or re-run anything if it helps.

@abbybowman
abbybowman self-requested a review August 1, 2026 13:44
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]: Bare USWDS hero and dark sections render off-brand (red blocks + cyan headings) under HDS

1 participant