From 6a08d1b7afe0d5911d075f15ddb9b08e74529f2b Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Sat, 17 Oct 2026 18:45:49 +0300 Subject: [PATCH] fix(a11y): skip link, labelled nav, table scope and 320px reflow on the website --- docs/code-patterns.md | 2 +- scripts/a11y/probe.mjs | 43 ++++++++++++++++++++++++++++++++++ site/layouts/baseof.html | 5 ++-- site/layouts/brokers/list.html | 4 +++- site/static/css/site.css | 21 +++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 scripts/a11y/probe.mjs diff --git a/docs/code-patterns.md b/docs/code-patterns.md index 408d1d6..1220af3 100644 --- a/docs/code-patterns.md +++ b/docs/code-patterns.md @@ -10,7 +10,7 @@ - **History startup**: CLI commands use `openHistoryStore`, which opens the schema and runs the versioned, transactional saved-reply migration via `inbox.MigrateNoDataResponses`. Keep new entry points on this helper so upgrades do not require visiting the web UI. The migration is local-only and retryable after a rollback. - **Pipeline UI**: counts and pending rows share `history.GetActionItems`; use it for Pipeline and Action Needed together. The Pipeline URL carries view, status, response type, search, review, and page filters. Response search filters the active profile in SQLite before pagination, including stored email bodies. - **Multi-profile scoping**: any new `history.Store` method that reads/writes per-user data should take a `profileID string` first parameter and filter by it (see [multi-profile.md](multi-profile.md)) - the two deliberate exceptions are `ClearBrokerResponses`/`GetAllBrokerResponses`, which operate on the one shared inbox across all profiles. -- **Accessibility**: CI runs axe-core (WCAG 2.2 A/AA, light and dark) over the website and the web UI against fictional seed data: `scripts/a11y/run.sh` runs the same thing locally (needs Go, Hugo, Node and Chrome). Add a new web UI page to the URL list in that script. Rules of thumb it enforces: every ``/icon-only control needs an `aria-label`, links inside body text need an underline, text must hit 4.5:1 in both themes. In the web UI use `--accent` for text and borders and `--accent-solid` for fills behind white text, since the dark theme needs a lighter accent for text than for buttons. Headings step down one level at a time (`h1` then `h2`), never by tag size. ## Known quirks diff --git a/scripts/a11y/probe.mjs b/scripts/a11y/probe.mjs new file mode 100644 index 0000000..cdaa806 --- /dev/null +++ b/scripts/a11y/probe.mjs @@ -0,0 +1,43 @@ +// Keyboard and structure probe that axe can't do: tab stops, focus rings, +// skip link, mouse-only controls, 320px reflow. Informational, not run in CI. +// usage: node probe.mjs ... +import puppeteer from "puppeteer-core"; + +const [chrome, ...urls] = process.argv.slice(2); +const b = await puppeteer.launch({ executablePath: chrome, headless: true, args: ["--no-sandbox"] }); +for (const url of urls) { + const p = await b.newPage(); + await p.setViewport({ width: 1280, height: 900 }); + await p.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); + await p.goto(url, { waitUntil: "networkidle0" }); + const st = await p.evaluate(() => { + const q = (s) => [...document.querySelectorAll(s)]; + const interactive = "a[href],button,input,select,textarea,summary,[tabindex]"; + return { + h1: q("h1").length, main: q("main").length, + skipLink: q("a[href^='#']").some((a) => /skip/i.test(a.textContent)), + mouseOnly: q("[onclick],[hx-get],[hx-post]").filter((e) => !e.matches(interactive + ",form") && !e.closest("a,button")).length, + thNoScope: q("th:not([scope])").length, + smallTargets: q(interactive).filter((e) => { const r = e.getBoundingClientRect(); return r.width && (r.width < 24 || r.height < 24) && getComputedStyle(e).display !== "inline"; }).map((e) => (e.textContent || e.name || e.id).trim().slice(0, 25)), + }; + }); + const noRing = []; + let stops = 0; + for (let i = 0; i < 100; i++) { + await p.keyboard.press("Tab"); + const f = await p.evaluate(() => { + const e = document.activeElement; + if (!e || e === document.body) return null; + const c = getComputedStyle(e); + return { id: e.tagName + ":" + (e.textContent || e.name || "").trim().slice(0, 25), ring: (c.outlineStyle !== "none" && parseFloat(c.outlineWidth) > 0) || c.boxShadow !== "none" }; + }); + if (!f) break; + stops++; + if (!f.ring) noRing.push(f.id); + } + await p.setViewport({ width: 320, height: 800 }); + const overflow = await p.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + console.log(url, JSON.stringify({ ...st, stops, noRing, overflowAt320: overflow })); + await p.close(); +} +await b.close(); diff --git a/site/layouts/baseof.html b/site/layouts/baseof.html index 9574231..82d4765 100644 --- a/site/layouts/baseof.html +++ b/site/layouts/baseof.html @@ -23,13 +23,14 @@ {{ block "head" . }}{{ end }} +
eraser -
-
+
{{ block "main" . }}{{ end }}