From 322192b819031527a45b73ca44cac8a4fcd50968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 16 Jul 2026 18:07:37 +0200 Subject: [PATCH 1/4] fix(docs): fix dark-mode callout-link contrast in warning/info/tip panels --- docs/STYLE.md | 17 +++++++++++++++++ docs/css/style.css | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/STYLE.md b/docs/STYLE.md index 882fbb5389..f7cef99665 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -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 `` 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 @@ -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`) | diff --git a/docs/css/style.css b/docs/css/style.css index fbff45ff6d..7f082b8f6e 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -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; From e5aacf492e7a3d33e54bc3368cb6ed78eb944877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 16 Jul 2026 18:07:48 +0200 Subject: [PATCH 2/4] feat(scripts): add docs-a11y-urls.sh to map changed docs pages to URLs --- scripts/docs-a11y-urls.sh | 274 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100755 scripts/docs-a11y-urls.sh diff --git a/scripts/docs-a11y-urls.sh b/scripts/docs-a11y-urls.sh new file mode 100755 index 0000000000..274ad1c563 --- /dev/null +++ b/scripts/docs-a11y-urls.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# scripts/docs-a11y-urls.sh — map changed docs Markdown files to rendered +# URLs for Tier 2 of the docs-a11y gate (see docs-a11y-smart-gate plan and +# .github/workflows/docs-a11y.yml). Reads changed repo-root-relative paths +# on stdin (one per line, e.g. from `git diff --name-only`), applies the +# mapping rules below, de-dupes against the static archetype list already +# in docs/.pa11yci.json, optionally live-probes the mapped pages, caps the +# result, and prints fully-qualified both-theme URLs on stdout (one per +# line) — ready to be jq-merged into a generated pa11y-ci config. +# +# Mapping rules (this site's Hugo config: disableKinds includes "section", +# so _index.md renders nothing; content lives at content/
// +# index.md leaf bundles — see docs/hugo.yaml): +# +# docs/index.md -> / +# docs/404.md -> /404.html +# docs/
//index.md -> /
// +# for
in the mounted set below +# docs/**/_index.md -> dropped (section kind disabled) +# docs/STYLE.md -> dropped (not mounted) +# any other docs/**/*.md -> dropped, with an ::notice +# (an unexpected new location) +# anything not under docs/ or not *.md -> ignored silently (systemic +# files are the archetypes' +# job to catch, not Tier 2's) +# +# Env vars: +# A11Y_MAX_PAGES deterministic cap on mapped pages (default 10) +# A11Y_BASE_URL Hugo server base (default http://127.0.0.1:1313) +# A11Y_PA11YCI path to the static config used for de-dup +# (default /docs/.pa11yci.json) +# +# Usage: +# git diff --name-only --diff-filter=d HEAD^1 HEAD | ./scripts/docs-a11y-urls.sh +# ./scripts/docs-a11y-urls.sh --self-test + +set -euo pipefail + +# Allow running from any directory, like scripts/workflow-lint.sh. +if [ -d ".github/workflows" ]; then + REPO_ROOT="$(pwd)" +else + REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fi + +PA11YCI="${A11Y_PA11YCI:-$REPO_ROOT/docs/.pa11yci.json}" +BASE_PATH="/docker-agent" + +# Sections actually mounted as content in docs/hugo.yaml. +MOUNTED_SECTIONS="getting-started concepts configuration tools providers features guides community" + +# base_url / max_pages — read fresh on every call (rather than snapshotted +# once at parse time) so tests can override them per-invocation via env +# var prefixes on a function call, same as they would on a subprocess. +base_url() { printf '%s' "${A11Y_BASE_URL:-http://127.0.0.1:1313}"; } +max_pages() { printf '%s' "${A11Y_MAX_PAGES:-10}"; } + +notice() { + printf '::notice::%s\n' "$1" >&2 +} + +is_mounted_section() { + local section="$1" + for s in $MOUNTED_SECTIONS; do + [ "$s" = "$section" ] && return 0 + done + return 1 +} + +# map_path — prints the rendered path (e.g. +# "/configuration/sandbox/") on stdout if the input maps to a rendered +# page, or nothing (with a logged ::notice for the unexpected-location +# case) if it doesn't. +map_path() { + local path="$1" + + case "$path" in + *.md) ;; + *) return 0 ;; # not Markdown: ignored silently + esac + + case "$path" in + docs/index.md) + printf '/\n' + return 0 + ;; + docs/404.md) + printf '/404.html\n' + return 0 + ;; + docs/STYLE.md) + return 0 # known, never mounted — dropped silently + ;; + esac + + # docs/**/_index.md never renders (disableKinds: section). + case "$path" in + */_index.md) + return 0 + ;; + esac + + # docs/
//index.md -> /
// + if [[ "$path" =~ ^docs/([^/]+)/([^/]+)/index\.md$ ]]; then + local section="${BASH_REMATCH[1]}" page="${BASH_REMATCH[2]}" + if is_mounted_section "$section"; then + printf '/%s/%s/\n' "$section" "$page" + return 0 + fi + fi + + notice "docs-a11y-urls: unmapped markdown file, dropped: $path" + return 0 +} + +# static_paths — the archetype paths already scanned every run, so Tier 2 +# never re-lists them (compares on path, ignoring the ?theme= query and +# the /docker-agent baseURL prefix). +static_paths() { + [ -f "$PA11YCI" ] || return 0 + jq -r '.urls[]' "$PA11YCI" \ + | sed -E "s#^https?://[^/]+$BASE_PATH##; s/\?theme=(light|dark)$//" \ + | LC_ALL=C sort -u +} + +# probe — returns 0 (keep) if the base URL is unreachable (nothing +# to verify against, e.g. during --self-test) or the page responds 200; +# returns 1 (drop, with a notice) only when the server IS reachable and +# this specific page 404s (renames/deletions the diff filter missed). +probe() { + local path="$1" base + base="$(base_url)" + if [ -z "${_A11Y_SERVER_REACHABLE:-}" ]; then + if curl -s -o /dev/null --max-time 2 "$base$BASE_PATH/" 2>/dev/null; then + _A11Y_SERVER_REACHABLE=1 + else + _A11Y_SERVER_REACHABLE=0 + notice "docs-a11y-urls: $base unreachable, skipping the live-probe safety net" + fi + fi + [ "$_A11Y_SERVER_REACHABLE" = 0 ] && return 0 + + local code + code="$(curl -s -o /dev/null --max-time 5 -w '%{http_code}' "$base$BASE_PATH$path" 2>/dev/null || echo 000)" + if [ "$code" != "200" ]; then + notice "docs-a11y-urls: $path responded $code, dropping (renamed or deleted?)" + return 1 + fi + return 0 +} + +# run_mapping — reads changed paths on stdin, prints final both-theme URLs. +run_mapping() { + local base cap + base="$(base_url)" + cap="$(max_pages)" + unset _A11Y_SERVER_REACHABLE + + local mapped=() + while IFS= read -r line; do + [ -z "$line" ] && continue + while IFS= read -r p; do + [ -n "$p" ] && mapped+=("$p") + done < <(map_path "$line") + done + + # De-dupe against the static archetype list. + local statics + statics="$(static_paths)" + local deduped=() + for p in "${mapped[@]:-}"; do + [ -z "$p" ] && continue + if ! grep -qxF "$p" <<<"$statics" 2>/dev/null; then + deduped+=("$p") + fi + done + + # Uniq + probe. + local uniq_sorted + uniq_sorted="$(printf '%s\n' "${deduped[@]:-}" | grep -v '^$' | LC_ALL=C sort -u || true)" + + local probed=() + while IFS= read -r p; do + [ -z "$p" ] && continue + if probe "$p"; then + probed+=("$p") + fi + done <<<"$uniq_sorted" + + # Deterministic cap: lexicographic, first N win; log the remainder. + local total=${#probed[@]} + local capped=("${probed[@]:0:$cap}") + if [ "$total" -gt "$cap" ]; then + local skipped=("${probed[@]:$cap}") + notice "docs-a11y-urls: $total changed pages mapped, capped at $cap; skipped: ${skipped[*]}" + fi + + for p in "${capped[@]:-}"; do + [ -z "$p" ] && continue + printf '%s%s%s?theme=light\n' "$base" "$BASE_PATH" "$p" + printf '%s%s%s?theme=dark\n' "$base" "$BASE_PATH" "$p" + done +} + +self_test() { + local failures=0 + + check() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf 'ok - %s\n' "$desc" + else + printf 'not ok - %s\n expected: %q\n actual: %q\n' "$desc" "$expected" "$actual" >&2 + failures=$((failures + 1)) + fi + } + + # Individual mapping rules. + check "docs/index.md maps to /" "/" "$(map_path docs/index.md)" + check "docs/404.md maps to /404.html" "/404.html" "$(map_path docs/404.md)" + check "leaf index.md maps to /
//" "/guides/go-sdk/" \ + "$(map_path docs/guides/go-sdk/index.md)" + check "static-list page still maps (de-dup happens later)" "/configuration/sandbox/" \ + "$(map_path docs/configuration/sandbox/index.md)" + check "_index.md is dropped" "" "$(map_path docs/concepts/_index.md 2>/dev/null)" + check "docs/STYLE.md is dropped" "" "$(map_path docs/STYLE.md 2>/dev/null)" + check "unmounted section is dropped (logs a notice)" "" \ + "$(map_path docs/unknown-section/page/index.md 2>/dev/null)" + check "a CSS file is ignored" "" "$(map_path docs/css/style.css 2>/dev/null)" + check "an image is ignored" "" "$(map_path docs/demo.gif 2>/dev/null)" + + # De-dup against the static archetype list, unreachable server (probe no-ops). + local out + out="$(A11Y_BASE_URL="http://127.0.0.1:1" run_mapping <<'EOF' +docs/configuration/sandbox/index.md +docs/guides/go-sdk/index.md +EOF +)" + check "static-list page is de-duped away, non-static page survives" \ + "http://127.0.0.1:1/docker-agent/guides/go-sdk/?theme=light +http://127.0.0.1:1/docker-agent/guides/go-sdk/?theme=dark" "$out" + + # Every emitted URL carries theme= and comes in light+dark pairs. + local non_theme_lines + non_theme_lines="$(printf '%s\n' "$out" | grep -vc 'theme=' || true)" + check "every emitted URL carries theme=" "0" "$non_theme_lines" + + # Cap at N, lexicographic winners, notice for the remainder. + local many + many="$(for i in $(seq -w 1 12); do printf 'docs/guides/page%s/index.md\n' "$i"; done)" + local capped_out notice_out + capped_out="$(printf '%s\n' "$many" | A11Y_BASE_URL="http://127.0.0.1:1" A11Y_MAX_PAGES=10 run_mapping 2>/tmp/docs-a11y-urls-selftest-notices)" + notice_out="$(cat /tmp/docs-a11y-urls-selftest-notices)" + rm -f /tmp/docs-a11y-urls-selftest-notices + check "12 pages capped at 10 -> 20 URLs" "20" "$(printf '%s\n' "$capped_out" | grep -c 'theme=')" + check "lexicographic winner: page01 kept (both themes)" "2" \ + "$(printf '%s\n' "$capped_out" | grep -c '/guides/page01/')" + check "lexicographic loser: page11 skipped" "0" \ + "$(printf '%s\n' "$capped_out" | grep -c '/guides/page11/')" + check "cap emits a notice naming the skipped remainder" "1" \ + "$(printf '%s\n' "$notice_out" | grep -c 'capped at 10')" + + if [ "$failures" -gt 0 ]; then + printf '\ndocs-a11y-urls --self-test: %d failure(s)\n' "$failures" >&2 + return 1 + fi + printf '\ndocs-a11y-urls --self-test: all checks passed\n' >&2 +} + +if [ "${1:-}" = "--self-test" ]; then + self_test +else + run_mapping +fi From 3901da83ac492676d665baff066403467c275fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 16 Jul 2026 18:07:48 +0200 Subject: [PATCH 3/4] ci(docs): expand the static pa11y archetype list to 6 pages (12 URLs) --- docs/.pa11yci.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/.pa11yci.json b/docs/.pa11yci.json index edbe463c81..15c1d734f8 100644 --- a/docs/.pa11yci.json +++ b/docs/.pa11yci.json @@ -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" ] } From ce2bafea6aa312e79dcaaafd96a4f83ea313f267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 16 Jul 2026 18:08:03 +0200 Subject: [PATCH 4/4] ci(docs): wire two-tier changed-page scanning into the docs-a11y gate --- .github/workflows/docs-a11y.yml | 94 +++++++++++++++++++++++++++------ docs/STYLE.md | 63 ++++++++++++++++++---- 2 files changed, 131 insertions(+), 26 deletions(-) diff --git a/.github/workflows/docs-a11y.yml b/.github/workflows/docs-a11y.yml index e0f4e4066a..242694f59c 100644 --- a/.github/workflows/docs-a11y.yml +++ b/.github/workflows/docs-a11y.yml @@ -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: @@ -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 @@ -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 @@ -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() diff --git a/docs/STYLE.md b/docs/STYLE.md index f7cef99665..97c54e00e5 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -341,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.