Skip to content

fix(docs): WCAG 2.1 AA accessibility remediation for the docs portal#3687

Merged
Sayt-0 merged 21 commits into
mainfrom
docs/a11y-wcag-remediation
Jul 16, 2026
Merged

fix(docs): WCAG 2.1 AA accessibility remediation for the docs portal#3687
Sayt-0 merged 21 commits into
mainfrom
docs/a11y-wcag-remediation

Conversation

@aheritier

Copy link
Copy Markdown
Contributor

Summary

This PR brings the docker-agent documentation portal (Hugo site under docs/,
published at https://docker.github.io/docker-agent/) to WCAG 2.1 AA
conformance across both the light and dark themes, and adds a CI gate that
keeps it that way.

It implements plan docs-a11y-audit in full. The work was originally
developed and reviewed as a stack of 9 small, independently-reviewable
GitButler branches/PRs (docs/a11y-pr1-light-contrast
docs/a11y-pr9-ci-gate, 21 commits total, cleanly rebased onto the current
main). Per the user's decision, all 9 are combined into this single PR
rather than opened as 9 stacked PRs, to simplify review/merge — the commit
history is preserved as-is so each logical step is still visible individually.

An accessibility audit produced a findings register (Critical/High/
Medium/Low) covering both themes; every finding was fixed, re-verified with
computed contrast ratios, and validated with a live pa11y-ci scan. Two
review rounds (by an independent review agent) surfaced additional
blockers, all of which are fixed here as well — see "Review remediation"
below.

Fixes, grouped by area

Light/dark contrast (tokens)

  • Light: --text-muted darkened from gray-500 to gray-600 (fixes pervasive
    sub-AA text: card/feature copy, sidebar labels, footer, TOC, search UI,
    kbd, etc.); removed a stray opacity: 0.75 de-emphasis on
    .sidebar-subheading (never de-emphasize with opacity — it silently
    breaks contrast math); added gray-700 overrides for tiny uppercase labels.
  • Dark: syntax-highlighter comments, kbd, accent-on-card text
    (--accent-on-card token for .elevator-label/.usecase a/.glossary dt/.ecosystem-tile strong), and the .pain-x badge background all
    brought to AA; .copy-btn colors tokenized with a light override and made
    visible on keyboard focus (it was previously invisible until pre:hover).
  • New white content surface in light mode (--bg-content, docs.docker.com-
    style chrome-vs-content split): header/sidebar/footer stay gray-100 chrome,
    the content column becomes white, fixing marginal link/muted-text ratios
    and improving visual hierarchy. Dark mode is a no-op change (--bg-content
    = the existing dark background).

Search dialog focus management

  • Added a real Tab/Shift-Tab focus trap inside the search modal, and focus
    restore to the trigger on close.
  • Added ArrowUp/ArrowDown/Enter navigation over results; dropped the
    role="listbox"/role="option" misuse (results render as a plain list of
    links); replaced the header's readonly input acting as a fake button
    (which stole focus via a focus listener) with a real, identically-styled
    trigger.
  • Visible :focus-visible rings added on search results and the modal
    input.

Motion & media

  • Added a prefers-reduced-motion: reduce block disabling smooth scroll,
    fade-in, and hover transforms; gated the TOC's scrollIntoView on the same
    media query.
  • Replaced the auto-playing, un-pausable demo.gif with a
    <video muted loop playsinline controls> + poster (with the GIF kept as
    native <video> fallback content for browsers that don't support
    <video> at all). Fixed the video's aspect ratio/responsive CSS, and made
    a genuine playback error (e.g. failed load) fall back to a static poster
    image rather than the looping GIF, so the reduced-motion property of the
    fix holds on the error path too.

Per-theme SVGs

  • how-it-works.svg carries small informative text that cannot clear 4.5:1
    normal-text AA against both a white and a near-black card with one
    compromise palette. Replaced the single-palette approach with two
    theme-specific files (how-it-works.svg for light,
    how-it-works-dark.svg for dark), toggled via CSS display, each
    independently verified against its own single background.

Reflow

  • Wide reference tables (e.g. the Flags table on /configuration/sandbox/)
    no longer force page-level horizontal scroll at 320px/400% zoom; the
    homepage comparison grid's min-width: auto default (which prevented its
    code column from shrinking) is fixed so the whole page reflows correctly
    at 320–360px, with local scrolling preserved inside code/table regions.
    .copy-btn hit area grown to the WCAG 2.2 24px minimum.

ARIA/semantics + dead-selector cleanup

  • Sidebar toggle exposes aria-expanded/aria-controls; closed sidebar is
    no longer keyboard-reachable at ≤1024px; theme toggle maintains
    aria-pressed; skip-link target is reliably focusable
    (tabindex="-1"); TOC heading is a real <h2>; footer headings promoted
    from h4 to h2 to fix a heading-level skip; duplicate title/
    aria-label pairs and a redundant <details title="…"> removed; a single
    shared :focus-visible rule added for consistency.
  • Deleted the dead [data-theme="dark"] … CSS selectors: the site's JS only
    ever removes data-theme for dark (never sets it), so those selectors
    never matched — removed as dead code/maintenance hazard rather than wired
    up, per the plan's resolved decision to keep "dark = bare :root" as the
    convention.

CI gate

  • Added .github/workflows/docs-a11y.yml: builds the Hugo site, serves it,
    and runs pa11y-ci (WCAG2AA) against /, /configuration/sandbox/,
    /getting-started/quickstart/, and /404.html.
  • The gate scans both themes: each of the 4 pages is listed twice in
    docs/.pa11yci.json (?theme=light / ?theme=dark), a query param that
    initTheme() in app.js honors ahead of localStorage/
    prefers-color-scheme, so the scan is deterministic regardless of the
    scanning browser's default color scheme (8 URLs total).
  • Installs a pinned Chrome (browser-actions/setup-chrome, matching this
    repo's pin-by-SHA convention used in sibling workflows) and points
    PUPPETEER_EXECUTABLE_PATH at it, since pa11y-ci's bundled Puppeteer
    doesn't reliably download its own Chromium under npx --yes on
    ubuntu-latest.
  • docs/STYLE.md gained an accessibility section: the AA contrast table for
    tokens (including the new --bg-content/--accent-on-card/--link-hover
    tokens), "never de-emphasize with opacity", how to run the scan locally,
    and a manual verification checklist for the things a static scanner can't
    catch (focus trap/restore/arrow-nav, theme/sidebar toggle state, reduced
    motion, responsive media, link-hover contrast, forced video-error
    fallback, narrow-viewport reflow).

Review remediation (already folded into the commits above)

Two independent review rounds found additional issues beyond the original
audit register, all fixed in this branch:

  • Dark-mode search-trigger contrast regression (introduced by the search-
    focus work itself).
  • Video aspect ratio/responsiveness, and the video-error fallback initially
    swapping in the looping GIF instead of a static poster.
  • The single-palette how-it-works.svg didn't actually clear 4.5:1
    normal-text AA in both themes (see "Per-theme SVGs" above).
  • Dark-mode-only contrast violations surfaced by turning the CI gate into a
    genuine both-theme scan: .preview-banner a, a CSS-specificity leak that
    let prose-link color override .btn-primary/.btn-secondary buttons
    rendered inside .content, and a hardcoded white-on-accent color in
    404.md's "Back to Docs" button.
  • Link hover-state contrast in both themes (a new --link-hover token),
    found via a real headless-Chrome hover simulation rather than static token
    math.
  • A CI-gate infrastructure blocker (pa11y-ci/Puppeteer couldn't find a
    Chrome binary on ubuntu-latest) — fixed via the pinned-Chrome step
    described above; this is a deliberate, documented deviation from "no
    structural workflow change," scoped to CI plumbing only, not the
    accessibility fixes themselves.

Known follow-ups / residuals

  • Video portability to docs.docker.com (unverified). The same Markdown
    content in docs/ is mounted downstream at docs.docker.com, but the
    custom layouts/CSS/JS in this repo are github.io-only. The demo.gif
    <video> swap touches docs/index.md, which does ship downstream, so
    whether docs/demo.mp4 renders/hosts correctly once mounted into
    docs.docker.com's Hugo module build is not verifiable from this
    sandbox
    and needs a downstream preview check before merge. The GIF
    remains in place as native <video> fallback content (for browsers
    without <video> support), and a static poster now covers the playback-
    error path, so there's no functional regression while this is pending.
  • --accent-on-card not actually applied to .usecase a / .preview-banner a (non-blocking, documented). .content a:not(.btn) (added to fix a
    button-color-leak bug) has higher CSS specificity than these components'
    own single-class rules, so it silently wins for their default color too,
    not just hover. This is not currently a contrast failure — both
    colors involved still clear AA (dark 6.32:1, light 5.00:1) — but it means
    --accent-on-card isn't in effect there, which is a latent coupling: a
    future change to the shadowing token alone could unexpectedly move these
    two components' color/contrast. Documented as a known issue; recommend a
    small dedicated follow-up PR to raise those selectors' specificity (or
    formally adopt the shadowing token as their color and drop the unused
    application) rather than expanding this PR's scope.

CI

docs-a11y.yml (both-theme pa11y-ci, 8 URLs) should run on this PR —
final green confirmation on GitHub is the pending item now that this branch
is pushed. All fixes were locally validated (Hugo build + a live
[email protected] run against all 8 URLs: 0 errors) prior to opening this PR,
including the CI-gate's own pinned-Chrome fix reproduced locally with an
equivalent PUPPETEER_EXECUTABLE_PATH setup.

Plan

Implements plan docs-a11y-audit (see the shared plan store for the full
findings register, decisions, and implementation log).

aheritier added 21 commits July 16, 2026 16:22
text-muted (gray-500) read 3.41:1 on chrome and 4.11:1 on white in
light mode, failing WCAG AA 4.5:1 for the many elements that use it
(cards, sidebar, footer, TOC, kbd, search, page-nav...). Bump it to
gray-600 (4.87:1 chrome / 5.88:1 white).

Tiny uppercase chrome labels (sidebar section headings, footer column
headings, page-nav labels) still read thin at gray-600, so give them a
dedicated gray-700 override (7.14:1 chrome / 8.61:1 white).

Also drop .sidebar-subheading's opacity: 0.75, which dragged its
already-muted text down to an effective 2.39:1 in light mode. Dark
mode is unaffected: it keeps --text-muted: gray-400 (6.37:1) and reads
6.37:1 without the opacity too (previously ~4.8:1 with it).

Part of the docs-a11y-audit remediation plan (PR 1 of 9).
…a11y PR 2/9)

Five independent low-contrast spots, all pre-verified against the
dark-mode "do not touch" list before changing:

- Dark syntax comments (#4A5568 on bg-code-block gray-900) read
  2.14:1; tokenize to gray-400 (5.59:1). Light comments already pass
  and are untouched.
- kbd used --text-muted (gray-400) on --bg-code (gray-800 in dark),
  reading 4.42:1; give kbd a dedicated dark color (gray-300, 6.07:1)
  and keep the light theme on --text-muted (already 5.88:1 on white).
- Blue-400 accent text on dark cards (elevator labels, ecosystem tile
  headings, usecase links, glossary terms) read 4.08:1. Add an
  --accent-on-card token (dark: blue-300 5.89:1, light: blue-500,
  same 5.0:1 it already had) and apply it to those four selectors.
- .pain-x badge (white on --error #F87171) read 2.77:1 in both
  themes; add --error-strong (#DC2626, 4.83:1) for badge use only.
  --error itself is untouched.
- .copy-btn hardcoded raw hex ignoring theme tokens (3.38:1 dark,
  4.47:1 marginal light, hover 2.41:1 light); tokenize to gray-400/
  gray-300 (dark) and a light override of gray-600/gray-700, and add
  a :focus-visible state (opacity:1 + outline) so keyboard users can
  see and reach the button, which was previously invisible off hover.

Part of the docs-a11y-audit remediation plan (PR 2 of 9).
Light mode used the same gray-100 for the page background, sidebar,
and content column, flattening the visual hierarchy and dragging text
contrast down for every element in the content column (a white card
on gray-100 read only 1.21:1 of separation).

Add a --bg-content token, applied to .main: gray-100 for header/
sidebar/footer chrome stays as-is, but the content column now sits on
white in light mode, mirroring docs.docker.com's chrome-vs-content
pattern. In dark mode --bg-content resolves to the same --bg-dark the
page already uses, so .main's new explicit background is a no-op —
dark mode is pixel-identical.

With this change, the existing light accent link color (blue-500,
#2560FF) reads 5.00:1 on the new white surface, so no link-color
change is needed. Cards (already white) now rely on their existing
border for separation from the page.

Part of the docs-a11y-audit remediation plan (PR 3 of 9).
…n (a11y PR 4/9)

The search overlay declared aria-modal="true" but nothing enforced it:
Tab could walk focus into the page behind it, and closing the dialog
(Escape, backdrop click, a result click) never returned focus to
whatever had opened it.

app.js: track the previously-focused element in openSearch(), restore
it in closeSearch(), and trap Tab/Shift-Tab inside the modal input +
result links while the dialog is open. Add ArrowUp/ArrowDown
navigation that moves real DOM focus between the query input and the
result links, so keyboard users can browse results without a mouse.

The header's #search-input was a readonly <input> opened by both
click and focus — Shift-Tabbing past it from the theme toggle re-opened
the dialog it had just closed, and its role misled screen reader users
into thinking it was an editable field. Replace it with a <button>
styled identically (still shows the ⌘K hint); this also means Enter/
Space activation is free via native button semantics, so the stray
'focus' listener is simply removed rather than replaced.

CSS: add :focus-visible outlines to .search-result and the modal
input (previously outline: none with only a background-color hover/
focus cue); the header trigger's own focus ring reused --accent-light,
which is only 12%-alpha in dark mode (fine for its other job as a
subtle fill, too faint as a ring) — give it a dedicated, more opaque
ring color instead.

Part of the docs-a11y-audit remediation plan (PR 4 of 9). The listbox/
option ARIA roles on the results themselves are addressed in PR 5.
The search trigger button (#search-input, introduced when this PR
converted it from a readonly input to a real <button>) used
--text-muted for its label text. That resolves to gray-400 (#8B99B2),
which reads a marginal 4.42:1 against the button's own --bg-hover
gray-800 (#2C333F) background in dark mode -- below the 4.5:1 AA
threshold for its 0.8rem text.

Use gray-300 (6.07:1) for the dark default instead, the same value
already used for kbd text on the same gray-800 background, and keep
the existing --text-muted (gray-600, 5.88:1 on white) for light via an
explicit override -- light was never broken.
…5/9)

A grab-bag of semantics fixes, none of which change dark-mode pixels:

- #search-results dropped role="listbox" and result links dropped
  role="option": a listbox/option pattern needs aria-activedescendant
  and full combobox wiring on the input, neither of which existed, so
  the roles just misrepresented a plain list of links to screen
  readers. PR 4 already added real keyboard navigation between them.
- #page-content (the skip-link's target) gets tabindex="-1" so focus
  reliably lands there across browsers when the skip link is used.
- <details class="sidebar-section" title="..."> dropped the title
  attribute, which only duplicated the visible <summary> text for
  screen readers.
- #theme-toggle and .github-link dropped their redundant title
  attributes (aria-label already covers the accessible name); the
  theme toggle now maintains aria-pressed and a state-reflecting
  aria-label ("Switch to light/dark theme") via a new
  updateThemeToggleState() helper called from initTheme()/
  toggleTheme().
- .sidebar-toggle gained aria-expanded/aria-controls, kept in sync by
  the click handler, and the closed off-canvas sidebar (≤1024px) now
  gets visibility: hidden (transition-delayed so the slide animation
  still plays) so it is not Tab-reachable while hidden.
- js/app.js buildTOC() now creates the "Table of contents" label as an
  <h2> instead of a <div>, giving it a real place in the heading
  outline.
- Footer column headings (Product/Resources/Company) changed from
  <h4> to <h2>, removing a heading-level skip in the page outline;
  CSS selectors renamed to match, same visual style.
- Added one shared :focus-visible rule (accent outline) covering
  .btn-icon, .github-link, .sidebar-toggle, .btn, .page-nav a,
  .ecosystem-tile, and footer links, which previously relied on
  inconsistent browser default outlines.
- Deleted the dead [data-theme="dark"] selectors in style.css (on
  .toc-action/.toc-link/.toc-link.active): the JS only ever removes
  the data-theme attribute for dark, never sets it to "dark", so
  these never matched. The :root:not([data-theme="light"]) twins are
  kept and still apply the same styles — "dark = bare :root" stays
  the documented convention (docs-a11y-audit Q4).

Part of the docs-a11y-audit remediation plan (PR 5 of 9).

fix(docs): finish PR 5 CSS updates for renamed footer heading and dead dark selectors
… 6/9)

Nothing on the site checked prefers-reduced-motion: smooth scrolling
(html { scroll-behavior: smooth }), the .content fade-in keyframe, and
assorted hover/press transform transitions all ran unconditionally.
Add a @media (prefers-reduced-motion: reduce) block that zeroes
animation/transition durations and forces scroll-behavior: auto, and
gate js/app.js's TOC-link scrollIntoView on the same media query.

The homepage demo.gif auto-played and looped with no way to pause,
stop, or hide it (WCAG 2.2.2). Swap it for a <video> (muted, loop,
playsinline, controls, poster, preload="metadata") generated from the
same source: demo.mp4 (ffmpeg -movflags faststart, ~55% smaller than
the GIF) and demo-poster.png (first frame). controls gives users a
native pause affordance the GIF never had. The GIF itself is kept as
the <video> element's fallback content for browsers/readers that
can't play the video, so nothing is deleted.

Both new files are mounted in hugo.yaml next to the existing demo.gif
mount so they ship as static assets the same way.

Part of the docs-a11y-audit remediation plan (PR 6 of 9).

NOTE: docs.docker.com portability of the new <video>/mp4 is UNVERIFIED
(docs-a11y-audit plan, Q2) — needs a downstream preview check before
this merges; the GIF fallback exists specifically to de-risk that.
… runtime GIF fallback

The <video> declared width=1200 height=675 (16:9), but demo.mp4 (and
its poster/GIF, generated from the same source) is actually 2000x1600
(5:4). Since .demo-container only had a responsive rule for img, the
video rendered at its raw HTML attribute box and got stretched to the
wrong ratio once displayed.

- Fix the width/height attributes to the video's true 5:4 ratio
  (1200x960) instead of re-encoding the source.
- Add .demo-container video to the existing responsive image rule
  (width: 100%; height: auto) so it scales correctly at every
  viewport -- verified at a 360px viewport: the video box now renders
  at a 1.25 (5:4) ratio matching the source exactly, no
  clipping/stretching.
- The nested <img src=demo.gif> inside <video> is native fallback
  *content*: browsers only render it when they don't support the
  <video> element at all, never on a decode/network failure of a
  supported one. Add handleVideoFallback() in app.js to swap the GIF
  in on the video's error event, covering the common
  file-failed-to-load case that the nested-content fallback can't.
handleVideoFallback() swapped a failed <video> for the nested
demo.gif on the error event -- but that GIF is ~14.7s/367 frames,
loops infinitely, and is not pausable: exactly the WCAG 2.2.2 problem
the video swap was introduced to remove, just reintroduced on the
failure path.

The nested <img> is native fallback CONTENT, rendered by browsers
that do not recognize <video> at all -- that case is unaffected. A
decode/fetch failure of a *supported* video is a different, more
common path, and now falls back to the static demo-poster.png frame
instead: a still image needs no pause control, so it is always a
safe fallback regardless of why playback failed.
… (a11y PR 7/9)

Reference tables (e.g. the Flags table on /configuration/sandbox/) had
no overflow handling, so at 320px viewport width or 400% zoom the
whole page scrolled horizontally instead of just the table (WCAG
1.4.10 reflow). .content table now gets display: block; overflow-x:
auto, scoping the scroll region to the table itself.

.copy-btn's hit area was ~22px tall, under the WCAG 2.5.8 24px target
minimum (.btn-icon is already ~32px and unaffected). Add min-height:
24px with inline-flex centering so the label stays vertically
centered.

Part of the docs-a11y-audit remediation plan (PR 7 of 9).
.compare-side defaulted to min-width:auto (the grid-item default), so
its code content refused to shrink with the column and forced the
whole homepage to scroll horizontally at narrow viewports (~661px
scrollWidth at a 360px viewport -- WCAG 1.4.10 reflow).

Add min-width:0 so the item can shrink; .compare-side pre already has
its own overflow-x:auto, so long code lines keep scrolling locally
instead of dragging the whole page along.
… 8/9)

The homepage flow diagram is embedded via <img>, so it cannot inherit
currentColor from the page and needs one hardcoded palette that works
on both the white light-theme card and the near-black dark-theme card
(#1E2129). The old gray-600 (#566581) read 5.88:1 on white but only
2.74:1 on the dark card, and the "REASONING LOOP" label's brand blue
(#2560FF) read 3.22:1 there — both well under the 4.5:1 normal-text
minimum.

- Base fill/stroke moves to gray-500 (#6C7E9D), the best single-color
  balance against this bg pair (4.11:1 white / 3.92:1 dark) — far
  short of the old dark reading, close to AA on both sides, and a
  neutral single color genuinely cannot do better against endpoints
  this far apart.
- The "REASONING LOOP" label moves to blue-400 (#4A76FF) for the same
  reason (3.94:1 white / 4.08:1 dark).
- Decorative borders and arrows keep the brand blue #2560FF: as
  non-text graphical objects they only need the 3:1 minimum (WCAG
  1.4.11), which #2560FF clears on both surfaces (5.00 light / 3.22
  dark).
- Removed the opacity="0.7"/"0.85" attributes used to de-emphasize
  secondary text: on top of an already-thin color this compounded the
  contrast problem (the same anti-pattern fixed in style.css's
  .sidebar-subheading in PR 1); size/weight already carry the visual
  hierarchy without it.

Part of the docs-a11y-audit remediation plan (PR 8 of 9).
…mpromise palette

The single hardcoded palette (gray-500 body text, blue-400 accent
text) was picked as the best midpoint between a white light card and
the near-black dark card, but a midpoint can't clear 4.5:1 normal-text
AA against both at once: it read ~3.9-4.1:1 either way, still failing.

Split the asset into two theme-specific files instead, each only
needing to pass against a single background:
- how-it-works.svg (light): gray-600 body text (5.88:1 on white),
  blue-500 accent label (5.00:1 on white).
- how-it-works-dark.svg (new): gray-400 body text (5.59:1 on
  #1E2129), blue-300 accent label (5.89:1 on #1E2129) -- the same
  --accent-on-card pairing used elsewhere for brand-blue text on dark
  cards.

index.md now renders both <img>s (theme-light-only / theme-dark-only
classes); style.css toggles them with plain display:none/block per
theme so only the active one is ever in the accessibility tree.
Decorative borders/arrows keep the theme-invariant brand blue
(#2560FF), which already cleared the 3:1 non-text minimum on both
surfaces.
Add docs-a11y.yml: build+serve the Hugo site the same way contributors
do locally (docs/Dockerfile's `hugo server`), then scan / (home),
/configuration/sandbox/ (wide tables + callouts), /getting-started/
quickstart/, and /404.html with pa11y-ci at WCAG2AA. pa11y only fails
on HTML_CodeSniffer "error"-level results (warnings/notices are
informational), so the gate targets real violations, not every
possible nit. Runs on docs/** pushes and PRs, landing last so it
starts from the green baseline PRs 1-8 built.

Running it against the post-PR-1-8 site surfaced one real, previously
unlisted violation: the "Rouge / highlighter-rouge overrides" block in
style.css set color: var(--gray-200) unconditionally on any
<code class="language-*">, Hugo/Chroma's class for every fenced code
block. That rule has the same CSS specificity as, and sits after, the
correctly-themed `[data-theme="light"] .content pre code` rule, so it
silently won in light mode too — breaking any syntax token without its
own explicit color override (Chroma's untyped ".l"/".sd"/".cl"
fallback tokens) at 1.47:1. Fixed by dropping the redundant color
declaration from that block; the themed rule above it already owns
text color for both themes.

docs/STYLE.md documents the new tokens/conventions from PRs 1-9 and
how to reproduce a pa11y-ci run locally.

Part of the docs-a11y-audit remediation plan (PR 9 of 9, final step).

NOTE: the local Docker daemon became unresponsive while re-validating
this specific fix live against a running pa11y-ci scan (it had already
caught the bug above in an earlier run against the post-PR-8 site).
The fix itself is a straightforward CSS-specificity correction verified
by re-reading the cascade, but please re-run pa11y-ci (or let CI run
it) to confirm before merging.
docs/.pa11yci.json listed 4 plain URLs with no theme setup, so the
scan only ever exercised whichever theme the headless browser
initializes by default -- observed locally to be light, meaning the
gate never actually looked at dark mode.

Add a ?theme=light|dark query param that js/app.js's initTheme()
honors ahead of localStorage/prefers-color-scheme, and list each of
the 4 scanned pages twice in .pa11yci.json, once per theme (8 URLs
total). This makes the both-theme scan deterministic regardless of
the browser's default color scheme, rather than depending on it.
…me gate

Running the new deterministic dark scan (previous commit) against the
full site found 2 real, previously-undetected dark-mode AA failures
(both predate this PR stack, caught only because nothing had scanned
dark mode as its own deterministic target before):

- .preview-banner a used --accent (blue-400, 4.08:1 on --bg-card in
  dark) instead of --accent-on-card (blue-300, 5.89:1) -- the same
  token already used for elevator/usecase/glossary text-on-card, just
  missed here.
- .content a's prose-link color (--accent-hover) had higher CSS
  specificity than .btn-primary/.btn-secondary's own color, so it
  silently leaked onto any button rendered as a link inside .content
  (the homepage 'Quick Start' button, 'View on GitHub'). Invisible in
  light mode only because --accent and --blue-500 happen to share a
  hex there; in dark it dropped the Quick Start button to 2.55:1.
  Fixed by excluding .btn links from the prose-link rule
  (.content a:not(.btn)) instead of fighting specificity per button.
- 404.md's 'Back to Docs' button hardcoded color:white against
  background:var(--accent) inline; --accent's dark value (blue-400)
  only reads 3.94:1 against white. Swapped to --accent-strong (a
  fixed blue-500, identical in both themes), which reads 5.00:1 for
  white text in both.

Verified via a local Hugo build + [email protected] (system Chrome) run
across all 4 pages x 2 themes: 8/8 URLs, 0 errors.
The a11y section drifted from what actually shipped after the
developer/review round-trip on this PR stack. Bring it up to date:

- Stop presenting how-it-works.svg's single hardcoded palette as an
  AA-compliant compromise (it wasn't -- ~3.9-4.1:1 both ways, still a
  fail). Describe the per-theme-SVG-variant approach actually
  implemented instead, with its real, passing ratios.
- Document the newly-fixed dark-mode violations (search trigger,
  preview-banner link, .content a specificity vs. .btn-primary/
  .btn-secondary, 404's inline accent) and add them to the contrast
  reference table.
- Note the video aspect-ratio requirement and the runtime GIF
  fallback's real scope (network/decode errors, not every possible
  failure mode).
- Add a manual verification checklist for the JS-driven interactions
  (search focus trap/restore/arrow-nav, theme toggle, sidebar toggle,
  reduced motion, responsive media) that a static HTML scanner like
  pa11y-ci structurally cannot exercise -- this repo has no
  package.json or browser-test harness, and adding one for a handful
  of interaction checks on a dependency-free static docs site would be
  disproportionate; call that out explicitly rather than silently
  leaving the gap uncovered.
- Explain the ?theme= query param mechanism the both-theme pa11y-ci
  gate relies on, both in STYLE.md and in the workflow's own
  comments.
The broad `.content a:not(.btn):hover` rule outranks component link
rules (`.preview-banner a`, `.usecase a`, ...) via specificity, and
hardcoded a single blue (--docker-blue-light, blue-400) for both
themes: 4.08:1 on a dark --bg-card and 3.94:1 on white, both AA fails
-- while every default (non-hover) link state passed.

Add a dedicated --link-hover token, scoped per theme like the other
accent-* tokens (blue-200 in dark, blue-600 in light) and verified
against --bg-card specifically, since it is lighter than --bg-content
in dark and so the harder background to pass. .btn links stay
excluded via :not(.btn), untouched.
STYLE.md previously described the (now-fixed) link hover as a flat,
unthemed blue and did not mention verifying it -- update the contrast
table and conventions with the real --link-hover values, and describe
the corresponding reflow (min-width:auto on grid items) and video-
error-fallback fixes landing alongside this on pr7/pr6.

Also add the three manual-checklist items a second review round found
missing: video-error fallback behavior, link hover contrast, and
page-level narrow-viewport (320-360px) reflow -- none of these are
catchable by the static pa11y-ci scan.
[email protected]'s bundled Puppeteer doesn't download its own Chromium
when installed via `npx --yes`, so the docs-a11y job failed before
scanning: "Could not find expected browser (chrome) locally."
pa11y-ci's own docs call out that Ubuntu > 20.04 needs an explicit
Chrome executable path.

Add a pinned browser-actions/setup-chrome step (matching this repo's
existing convention of pinning tool installs by SHA, e.g. Hugo/Go/Task
in sibling workflows) and point Puppeteer at its output via
PUPPETEER_EXECUTABLE_PATH for the pa11y-ci step, leaving
docs/.pa11yci.json untouched so it stays portable across machines.
Document the same reproduction step in docs/STYLE.md's local-scan
instructions.

Reproduced locally: without an executable path set, `npx --yes
[email protected] --config .pa11yci.json` (the workflow's exact command)
fails with the identical error against a local hugo server; setting
PUPPETEER_EXECUTABLE_PATH to a local Chrome makes the unmodified
config pass 8/8 both-theme URLs. Final green can only be confirmed on
GitHub once pushed.
@aheritier
aheritier requested a review from a team as a code owner July 16, 2026 15:01
@Sayt-0
Sayt-0 merged commit df4c6eb into main Jul 16, 2026
20 checks passed
@Sayt-0
Sayt-0 deleted the docs/a11y-wcag-remediation branch July 16, 2026 15:15
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.

2 participants