Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 77 additions & 17 deletions .github/workflows/docs-a11y.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
# Scans the built docs site for WCAG 2 AA accessibility violations
# with pa11y-ci, using the same "hugo server" flow contributors run
# locally (see docs/Dockerfile). Lands as the last step of the
# docs-a11y-audit remediation (docs-a11y-audit plan, PR 9): the fixes
# in PRs 1-8 land first so this job starts from a green baseline.
# Scans the built docs site for WCAG 2 AA accessibility violations with
# pa11y-ci, using the same "hugo server" flow contributors run locally
# (see docs/Dockerfile). Originally landed as the last step of the
# docs-a11y-audit remediation (PR 9); the docs-a11y-smart-gate plan later
# evolved it from a fixed URL list into a path-aware, two-tier scan:
#
# docs/.pa11yci.json lists each scanned page twice, once with
# ?theme=light and once with ?theme=dark: the headless browser's
# default color-scheme is otherwise indeterminate (observed as
# "light" locally), so without the query param the gate would only
# ever exercise one theme. js/app.js's initTheme() honors ?theme=
# ahead of localStorage/prefers-color-scheme for exactly this reason.
# Tier 1 (always): a small static list of layout-archetype pages in
# docs/.pa11yci.json, each scanned in both themes — guards against
# systemic regressions (shared CSS/JS/templates/SVG) on every run.
#
# pa11y only treats HTML_CodeSniffer "error"-level results as
# failures (warnings/notices are informational and excluded by
# default), so this gates on real violations rather than every
# possible nit — see docs/.pa11yci.json for the scanned URLs and
# docs/STYLE.md for how to reproduce a failure locally.
# Tier 2 (pull_request only): the content pages the PR actually
# modifies, mapped from changed Markdown to rendered URLs by
# scripts/docs-a11y-urls.sh (deterministically capped, both themes) —
# guards against authored-content issues on exactly what changed. A
# generated config (static + changed) is used when there's anything to
# add; push-to-main and content-free PRs fall back to the static-only
# config, since there's no PR diff to compute Tier 2 from.
#
# Each URL is listed once with ?theme=light and once with ?theme=dark:
# the headless browser's default color-scheme is otherwise indeterminate
# (observed as "light" locally), so without the query param the gate
# would only ever exercise one theme. js/app.js's initTheme() honors
# ?theme= ahead of localStorage/prefers-color-scheme for exactly this
# reason.
#
# The changed-file diff is git-native (`git diff HEAD^1 HEAD` against the
# pull_request test-merge commit, fetch-depth: 2) rather than a
# changed-files action, keeping the SHA-pinned, minimal-supply-chain
# convention (see scripts/docs-a11y-urls.sh's header for the mapping
# rules and env vars, and docs/STYLE.md for how to reproduce a scan
# locally).
#
# pa11y only treats HTML_CodeSniffer "error"-level results as failures
# (warnings/notices are informational and excluded by default), so this
# gates on real violations rather than every possible nit.
name: docs-a11y

permissions:
Expand All @@ -31,10 +48,12 @@ on:
paths:
- ".github/workflows/docs-a11y.yml"
- "docs/**"
- "scripts/docs-a11y-urls.sh"
pull_request:
paths:
- ".github/workflows/docs-a11y.yml"
- "docs/**"
- "scripts/docs-a11y-urls.sh"

env:
HUGO_VERSION: 0.163.0
Expand All @@ -45,6 +64,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need the merge commit's parent too, so `git diff HEAD^1 HEAD`
# (below) can compute the PR's changed files without a
# changed-files action or extra token scope.
fetch-depth: 2

# pa11y-ci@3.1.0 bundles an old Puppeteer whose postinstall Chromium
# download doesn't happen under `npx --yes` ("Could not find expected
Expand All @@ -70,11 +94,47 @@ jobs:
- name: Wait for server
run: npx --yes wait-on@7.2.0 http://127.0.0.1:1313/docker-agent/ --timeout 60000

# Tier 2: map this PR's changed content Markdown to rendered URLs.
# `actions/checkout` puts the pull_request test-merge commit at
# HEAD, whose first parent (HEAD^1) is the base branch tip, so this
# diff is exactly the PR's changed files — token-free and identical
# for fork PRs. Skipped on push (no PR diff to compute from).
- name: Compute changed-page URLs
id: changed-urls
if: github.event_name == 'pull_request'
env:
A11Y_BASE_URL: http://127.0.0.1:1313
run: |
git diff --name-only --diff-filter=d HEAD^1 HEAD \
| ./scripts/docs-a11y-urls.sh > /tmp/changed-urls.txt
count=$(wc -l < /tmp/changed-urls.txt | tr -d ' ')
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "Tier 2: $count changed-page URL(s):"
cat /tmp/changed-urls.txt

# Merge Tier 2's URLs into the static (Tier 1) config from
# docs/.pa11yci.json. Only runs when there's something to add;
# otherwise the run step below falls back to the static-only config.
- name: Assemble pa11y config
if: github.event_name == 'pull_request' && steps.changed-urls.outputs.count != '0'
run: |
jq -Rn '[inputs]' /tmp/changed-urls.txt > /tmp/changed-urls.json
jq --slurpfile extra /tmp/changed-urls.json '.urls += $extra[0]' \
docs/.pa11yci.json > /tmp/pa11yci.generated.json
echo "Merged pa11y-ci config (static + changed pages):"
jq -r '.urls[]' /tmp/pa11yci.generated.json

- name: Run pa11y-ci
working-directory: docs
env:
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
run: npx --yes pa11y-ci@3.1.0 --config .pa11yci.json
run: |
config=.pa11yci.json
if [ -f /tmp/pa11yci.generated.json ]; then
config=/tmp/pa11yci.generated.json
fi
echo "Using pa11y-ci config: $config"
npx --yes pa11y-ci@3.1.0 --config "$config"

- name: Show Hugo server log on failure
if: failure()
Expand Down
6 changes: 5 additions & 1 deletion docs/.pa11yci.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
"http://127.0.0.1:1313/docker-agent/configuration/sandbox/?theme=light",
"http://127.0.0.1:1313/docker-agent/getting-started/quickstart/?theme=light",
"http://127.0.0.1:1313/docker-agent/404.html?theme=light",
"http://127.0.0.1:1313/docker-agent/features/tui/?theme=light",
"http://127.0.0.1:1313/docker-agent/concepts/multi-agent/?theme=light",
"http://127.0.0.1:1313/docker-agent/?theme=dark",
"http://127.0.0.1:1313/docker-agent/configuration/sandbox/?theme=dark",
"http://127.0.0.1:1313/docker-agent/getting-started/quickstart/?theme=dark",
"http://127.0.0.1:1313/docker-agent/404.html?theme=dark"
"http://127.0.0.1:1313/docker-agent/404.html?theme=dark",
"http://127.0.0.1:1313/docker-agent/features/tui/?theme=dark",
"http://127.0.0.1:1313/docker-agent/concepts/multi-agent/?theme=dark"
]
}
80 changes: 71 additions & 9 deletions docs/STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,22 @@ tokens, templates, or JS, keep these conventions:
terms): blue-300 in dark (blue-400 only reads 4.08:1 on the dark
card), blue-500 in light (already 5.0:1 there). Use it instead of
the bare `--accent` for any *new* text-on-card component.
- **Links inside a callout need their own color, not the prose-link
one.** Callout panels (warning/info/tip) sit on their own
variant-colored background, not the page background, so
`--accent-hover` (the dark prose-link color) can fail there even
where it's fine everywhere else — it read only 3.95:1 on the dark
warning callout (`#643700`). `.content .callout a:not(.btn)` scopes
`--blue-200` to callout links in dark (6.46:1 warning / 11.50:1 info /
8.51:1 tip); light already passed with `--accent` (4.60–4.83:1) and
is restored explicitly rather than left to inherit the dark value.
Because that selector's specificity ties with `.content
a:not(.btn):hover`'s, the hover state needed its own explicit
`.content .callout a:not(.btn):hover` rule too — an unconditional,
equal-specificity selector coming later in the stylesheet otherwise
wins over a `:hover` one regardless of hover state, flattening the
hover color. Same lesson as the specificity bullets above: always
check what a new equal-or-higher-specificity rule shadows nearby.
- **SVGs embedded via `<img>` can't inherit `currentColor` or CSS
custom properties from the page**, so a single hardcoded palette has
to clear AA against *both* a near-black dark card (`#1E2129`) and a
Expand Down Expand Up @@ -234,6 +250,7 @@ tokens, templates, or JS, keep these conventions:
| `.pain-x` badge (white text) | `--error-strong` `#DC2626`, 4.83:1 | same |
| `#search-input` (search trigger button) text | `--text-muted` (gray-600), 5.88:1 on `--bg-hover` white | gray-300, 6.07:1 on `--bg-hover` gray-800 (was `--text-muted` gray-400, 4.42:1 — fail) |
| `.preview-banner a` | `--accent-on-card` (unchanged, blue-500 5.0:1) | `--accent-on-card` blue-300, 5.89:1 (was `--accent` blue-400, 4.08:1 — fail) |
| `.content .callout a:not(.btn)` (links inside a callout) | `--accent`, 4.60–4.83:1 (unchanged) | `--blue-200`, 6.46:1 warning / 11.50:1 info / 8.51:1 tip (was `--accent-hover` blue-400, 3.95:1 on warning — fail) |
| `.btn-primary`/`.btn-secondary` as `.content a` links (Quick Start, Back to Docs) | unchanged (blue-500-on-white 5.00:1 / white-on-translucent) | own `color` restored via `.content a:not(.btn)`, was 2.55:1 (`--accent-hover` leaking onto a white button bg) |
| `how-it-works.svg` diagram text | gray-600 body 5.88:1 / blue-500 accent 5.00:1 (light-only file, on white) | gray-400 body 5.59:1 / blue-300 accent 5.89:1 (dark-only file, on `#1E2129`) |

Expand Down Expand Up @@ -324,24 +341,69 @@ that touches `js/app.js`, `layouts/`, or the demo media:
### Running the accessibility scan locally

CI (`docs-a11y` workflow) runs [pa11y-ci](https://github.com/pa11y/pa11y-ci)
against the URLs in `docs/.pa11yci.json` at the `WCAG2AA` standard.
Each page is listed twice, once with `?theme=light` and once with
`?theme=dark` — `js/app.js`'s `initTheme()` honors that query param
ahead of `localStorage`/`prefers-color-scheme`, so the gate
deterministically exercises both themes regardless of the scanning
browser's default color scheme (observed to default to light
headless, which is exactly why earlier local runs only ever caught
light-mode regressions). To reproduce locally:
at the `WCAG2AA` standard against a two-tier URL list:

- **Tier 1 (always)** — a small static set of layout-archetype pages
in `docs/.pa11yci.json`: home (`/`), `configuration/sandbox/`
(wide tables + callouts), `getting-started/quickstart/` (tutorial
prose), `404.html` (standalone layout), `features/tui/` (the
densest page — images, inline HTML, many callouts/tables), and
`concepts/multi-agent/` (deep heading structure, tables). Each is
listed twice, once with `?theme=light` and once with `?theme=dark`
— 12 URLs in total. This tier guards against systemic regressions
(shared CSS/JS/templates/SVG) and runs on every push and PR.
- **Tier 2 (pull requests only)** — the content pages the PR actually
modifies. `scripts/docs-a11y-urls.sh` maps changed Markdown to
rendered URLs (dropping `_index.md`/`STYLE.md`/non-content files,
de-duping against the Tier 1 list, live-probing to catch renames,
and emitting both themes), deterministically capped at
`${A11Y_MAX_PAGES:-10}` changed pages (lexicographic sort, so a
PR touching more than that always scans the same subset and logs
the rest as skipped). The CI workflow jq-merges these URLs into a
generated config on top of `docs/.pa11yci.json`; a push to `main`
or a PR that touches no content Markdown stays static-only (Tier 1).

`js/app.js`'s `initTheme()` honors the `?theme=` query param ahead of
`localStorage`/`prefers-color-scheme`, so the gate deterministically
exercises both themes regardless of the scanning browser's default
color scheme (observed to default to light headless, which is exactly
why earlier local runs only ever caught light-mode regressions).

To reproduce the full two-tier scan locally:

```bash
# Serve the site the same way the Dockerfile does
docker build -t docs docs/
docker run --rm -p 1313:1313 -v $(pwd)/docs:/src docs

# In another terminal, once it's serving at http://localhost:1313/docker-agent/
# In another terminal, once it's serving at http://localhost:1313/docker-agent/:

# Tier 1 only (the static archetype list):
cd docs && npx --yes pa11y-ci@3.1.0 --config .pa11yci.json

# Tier 1 + Tier 2 (mirrors what a PR's CI run scans), from the repo root:
git diff --name-only main...HEAD | A11Y_BASE_URL=http://localhost:1313 ./scripts/docs-a11y-urls.sh > /tmp/changed-urls.txt
jq -Rn '[inputs]' /tmp/changed-urls.txt > /tmp/changed-urls.json
jq --slurpfile extra /tmp/changed-urls.json '.urls += $extra[0]' docs/.pa11yci.json > /tmp/pa11yci.generated.json
npx --yes pa11y-ci@3.1.0 --config /tmp/pa11yci.generated.json
```

`scripts/docs-a11y-urls.sh --self-test` exercises the mapping rules
against fixtures (no server needed) and is safe to run any time.

**Adding or retiring a Tier 1 archetype:** edit the `urls` array in
`docs/.pa11yci.json` (both the light and dark entries — never add
or remove just one theme). Before adding a page, run the full local
scan above with it included and confirm 0 errors in both themes
first (the "green-baseline" rule) — a latent shared-CSS bug that
happens to be invisible on the current six pages must be fixed
*before* the new archetype joins the always-on gate, or every future
PR starts failing on a pre-existing issue unrelated to its own change.

**Tuning the Tier-2 cap:** `A11Y_MAX_PAGES` (default 10) is the only
knob; raise it in the workflow's `Compute changed-page URLs` step if
PRs regularly touch more content pages than that.

`pa11y-ci` only fails the build on HTML_CodeSniffer `error`-level
results (warnings/notices are informational and don't fail CI), so a
local failure here is a real regression worth fixing before pushing.
Expand Down
15 changes: 15 additions & 0 deletions docs/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,21 @@ details.sidebar-section[open] > summary.sidebar-heading::after {
}
[data-theme="light"] .callout-warning p { color: #92400E; }

/* Links inside a callout sit on a variant-colored panel (warning/info/tip),
* not the page background, so the shared .content a:not(.btn) color can
* fail there even where it's fine elsewhere: --accent-hover (dark) reads
* only 3.95:1 on the dark warning background (#643700). --blue-200 clears
* AA on all three dark callout backgrounds (6.46:1 warning / 11.50:1 info /
* 8.51:1 tip); light already passes with --accent (4.60-4.83:1) so it's
* restored explicitly rather than left to inherit a dark-only value. Both
* rules match .content a:not(.btn)'s own specificity (0,2,1) plus one, so
* they win regardless of source order; an explicit :hover variant is
* needed too or these same-specificity rules would flatten the shared
* hover color (--link-hover, already AA on every callout background). */
.content .callout a:not(.btn) { color: var(--blue-200); }
.content .callout a:not(.btn):hover { color: var(--link-hover); }
[data-theme="light"] .content .callout a:not(.btn) { color: var(--accent); }

/* ===== Cards Grid ===== */
.cards {
display: grid;
Expand Down
Loading
Loading