Accessibility CI
Each covered app has two jobs in .github/workflows/test.yml that gate merges to main (#3661). <app> is the app’s directory, e.g. apps/web.
| Job (check name) | What it runs | Fails on |
|---|---|---|
a11y (<app>) | playwright test --project=a11y in the app | Any axe violation tagged wcag2a, wcag2aa, wcag21a, wcag21aa or wcag22aa that is not allowlisted; an expired or malformed allowlist entry; a route that never reaches its ready state; any sandbox problem (see below); a new page missing from the route inventory |
mobile (<app>) | playwright test --project=mobile in the app | Content that runs past the viewport edge at 375x812, 768x1024 or 412x915, whether the page scrolls or clips; axe target-size (WCAG 2.5.8, 24px); a control under 44x44 px that is not allowlisted; the primary flow not working |
Both jobs run on:
- every pull request to
mainthat touches the app or a package it renders from (the app’sa11y_*path filter in thechangesjob), - every push to
mainthat touches the same paths (compared with the commit before the push), - every pull request or push that changes the job’s own definition in
test.yml(#4079; see CI runner), - every manual dispatch.
Editing test.yml elsewhere no longer starts them. To force every job anyway, add the ci:full label to the PR and push, or dispatch the workflow with full checked.
The live sites are also swept nightly, in light and dark mode, for what these jobs can’t see: text axe leaves undecided, form-control borders, focus rings and gevell.com itself. See Contrast sweep.
The two jobs run in parallel. When a PR touches none of those paths, both are skipped, and GitHub counts a skipped job as passing. The gate job depends on both, so a failure blocks the merge through the existing required gate check.
Coverage today:
| App | Jobs | Issue |
|---|---|---|
Gevell PDF (apps/web) | a11y (apps/web), mobile (apps/web) | #3661 |
Gevell Report Card (apps/vpat) | a11y (apps/vpat), mobile (apps/vpat) | #3675 |
Gevell Org Chart (apps/org-chart) | a11y (apps/org-chart), mobile (apps/org-chart) | #3676 |
Gevell Audit (apps/audit) | a11y (apps/audit), mobile (apps/audit) | #3674 |
gevell.com (apps/home) | a11y (apps/home), mobile (apps/home) | #3783 |
Gevell Web (apps/remediate) | a11y (apps/remediate), mobile (apps/remediate) | #3784 |
Gevell PDF Polish (apps/pro) | a11y (apps/pro), mobile (apps/pro) | #3782 |
Gevell Tags (apps/tags) | a11y (apps/tags), mobile (apps/tags) | #3962 |
The sections below describe apps/web in detail. Every app follows the same layout under its own e2e/; per-app differences are under Gevell Report Card, Gevell Org Chart and Gevell Audit.
What is audited
The route inventory is apps/web/e2e/support/routes.ts. It lists every customer-facing page. EXCLUDED_ROUTES names the pages that are deliberately left out, each with a reason:
- admin and dev pages,
- internal tools,
/docs/*, which production redirects to help.gevell.com,- client-side redirect routes,
- the unlinked legacy
/checkout.
route-inventory.a11y.spec.ts fails if a new page.tsx is in neither list.
a11y job
routes.a11y.spec.tsruns every route in light and dark mode. Routes inside the dashboard shell (CCPageShell) also run in each color scheme: classic, warm and premium.file-tools.a11y.spec.tsopens the Remediation tools menu, each of its ten tool dialogs, and the guided-remediation wizard.home.keyboard.a11y.spec.tschecks focus visibility and focus traps on eight public pages.allowlist.a11y.spec.tsvalidates the allowlist.
mobile job
routes.mobile.spec.tsruns every route at the three viewports.- It also has a primary-flow test: main navigation reachable, the upload picker opens, the file detail page opens, and the Download menu fits on screen with large enough targets.
What counts as a touch target. Buttons, form fields, ARIA widget roles, and links that are not inline in running text. For a checkbox or radio, its label counts as part of the target.
Iframes. Nodes inside iframes that hold the customer’s converted document or source PDF are not audited. That content is what the product reports on, not the app’s UI. The frames are identified by title in DOCUMENT_FRAME_TITLES in e2e/support/axe.ts. Every other iframe is audited, for example the Gevell-generated conformance report.
Shared harness
The app-independent parts of the gate live in the workspace package @accessible-org/a11y-harness (packages/a11y-harness/), so every app runs the same checks:
| Module | What it holds |
|---|---|
allowlist.ts | Allowlist entry type, validation (issue link, expiry, 200-day cap, no */*), route wildcards, matching |
axe.ts | WCAG_TAGS and createAxeCheck(), which returns an app’s expectNoAxeViolations bound to its allowlist and document-frame titles |
session.ts | The sandbox hosts, the CI user and the unsigned session |
sandbox.ts | installSandbox(): passes the static server through, answers the mock API and Supabase hosts from the app’s fixture routers, aborts everything else, records problems |
load.ts | loadRoute(): the ready, redirect, theme and loading checks |
mobile.ts | The three viewports, the reflow check, the 44px check (createTargetCheck()), obscuredControls() (controls hidden under a fixed side rail, or under a top bar at the top of the page) and expectWithinViewport(). Content inside a closed <details> is not measured |
route-inventory.ts | Lists an app’s page.tsx routes for its route-inventory spec |
static-server.mjs | serveStatic(): serves an app’s out/, with its rewrites and basePath, and refuses a build without both sandbox hosts |
Each app keeps what is specific to it in its own e2e/support/: the route inventory, the fixtures and fixture router, the allowlist, and thin wrappers that bind the shared modules to them. A change to the harness runs every app’s gate.
How it runs without a backend
npm run build:a11y builds the static export with the API set to http://api.a11y.test and Supabase set to http://supabase.a11y.test. Neither host resolves.
e2e/support/static-server.mjs serves out/. It applies the same /teams/* and /shared/* rewrites as production. It refuses to start unless both sandbox hosts are baked into the build.
e2e/support/mock-backend.ts routes every request the page makes:
| Request | What happens |
|---|---|
| The static server’s own origin | Passed through |
api.a11y.test | Answered from e2e/support/seed/api/*, built from the records in seed/fixtures.ts |
supabase.a11y.test auth endpoints | Answered with the seeded session |
| Anything else (production hosts, third parties, other local ports) | Aborted |
Session. e2e/support/session.ts builds an unexpired, unsigned session, and mock-backend.ts writes it into localStorage. On 127.0.0.1, supabase-js reads it without a network call. The CI user is a customer, not a Gevell admin, so /api/admin/stats returns 403.
Sandbox problems fail the test. These are recorded, and loadRoute fails on any of them:
- an API call with no fixture,
- a blocked request,
- an unexpected Supabase call,
- an error the app reports to
/api/errorsor/api/client-logs, - an uncaught page error.
A route that produces one of these by design lists it in expectedProblems, with a comment.
Ready check. Every route must also render its own data before axe runs: the ready h1 plus a selector for content that only the loaded data produces, with no spinner or “Loading…” left on screen. A mock gap therefore fails the test, rather than producing an audit of an empty state that passes.
No production URL, no credentials and no local stack are involved.
Run it locally
cd apps/webnpm run build:a11y # ~1-2 min; rebuild after changing app codenpm run test:a11y # axe gate (starts the static server itself)npm run test:mobile # mobile gatenpx playwright test --project=a11y -g "/dashboard" # one routeChanges to fixtures or specs (e2e/**) take effect without a rebuild. Product code changes need npm run build:a11y again.
The build can rewrite tracked generated files: next-env.d.ts, public/openapi.yaml, public/.well-known/openapi.yaml and src/data/{faqs,guides}.ts. Do not commit those changes.
When the gate fails
The failure message lists:
- each violation, with its rule, impact, target selector, failure summary and help URL,
- or the sandbox problems.
On CI, the Playwright HTML report (traces and screenshots) is uploaded as the playwright-report-a11y-web or playwright-report-mobile-web artifact.
- Fix the markup. This is almost always the answer.
- New page: add it to
ROUTESwith areadyheading and a data-dependent selector, and add its API calls toseed/api/*. If it should not be audited, add it toEXCLUDED_ROUTESwith a reason. - Only if the fix cannot land in the same PR: add an allowlist entry.
The allowlist
The allowlist is apps/web/e2e/a11y-allowlist.json:
{ "rule": "color-contrast", "route": "/dashboard", "selector": ".badge-failed", "issue": "https://github.com/LarryAnglin/theaccessible-platform/issues/NNNN", "expires": "2026-12-31", "reason": "Why this can't be fixed in the PR that surfaced it"}| Field | What it holds |
|---|---|
rule | An axe rule id, or touch-target-44 for the mobile 44px check |
route | A route id: an id from routes.ts, or an id a spec passes, such as /file tool: Links. Can also be a prefix ending in *, or * |
selector | For axe rules: a substring of the axe target. For touch-target-44: the control description the mobile spec prints, e.g. <button> "Enter URL" |
issue | Required |
expires | Required, no more than 200 days out |
allowlist.a11y.spec.ts fails the build on any of these:
- an expired entry,
- a malformed entry,
- an entry with no issue link,
- an entry that would switch a rule off everywhere (
*route with*selector), - a
touch-target-44entry that does not name one control.
An expired entry also stops suppressing its violation.
Current contents
- Axe (WCAG) entries: none.
touch-target-44baseline: the only entries. These are controls that pass WCAG 2.5.8 at 24px but were under the 44px project standard when the gate landed.- Each entry is scoped to the routes where the control was seen.
- Shared header, footer and sidebar controls that appear on most routes get one
*entry that names the exact control. - All are tracked in #3673 and expire 2027-03-31.
- Remove an entry when you fix its control.
Gevell Report Card (apps/vpat)
Same layout as apps/web, under apps/vpat/e2e/, bound to the shared harness in e2e/support/gate.ts.
- Build.
npm run build:a11yinapps/vpat. It skipsgenerate-build-version.mjs, which calls the build-counter service, so the footer shows the example version. - Backends. The app reads saved evaluations straight from Supabase PostgREST (
vpat_reports), not through the platform API.e2e/support/seed/api-routes.tstherefore has two routers:matchApifor the platform API (workshops catalog, audit trail) andmatchSupabaseforGET /rest/v1/vpat_reports. Any other Supabase call is a sandbox problem. - Seed records (
e2e/support/seed/fixtures.ts): two saved evaluations, one with a mix of Supports, Partially Supports, Does Not Support and Not Applicable, and a CI audit trail with a commit drill-down. - Routes (
e2e/support/routes.ts): all 11 pages, each in light and dark:/and/eae-guide;/auth/login, plus its error state;/auth/callback, audited where an OAuth error lands it (sign-in with the message). The callback itself only shows a spinner while it routes on;/auth/confirm, plus the incomplete-link state;/auth/update-password;/dashboard;/evaluate(new evaluation) and/evaluate?id=(the saved report card);/settings;/audit-trailand/audit-trail/commit, which show an empty form until a repo and API key are entered.
- Loaded audit trail.
audit-trail.a11y.spec.tsfills in the repo and key, then audits the loaded trail and the commit drill-down it links to. - Mobile primary flow. From the dashboard, open the saved evaluation. The grade and the PDF export must be on screen. Go back and start a new evaluation. The upload control must open the file picker.
- Allowlist.
apps/vpat/e2e/a11y-allowlist.json. No axe (WCAG) entries. Thetouch-target-44baseline is tracked in #3693 and expires 2027-03-31. It covers:- the shared header and footer controls (
*entries), - the WCAG criterion reference links in the criteria table.
- the shared header and footer controls (
Run it locally:
cd apps/vpatnpm run build:a11ynpm run test:a11ynpm run test:mobileThe build rewrites the tracked next-env.d.ts. Do not commit that change. The app’s older jsdom axe unit tests (src/__tests__/a11y/) moved from test:a11y to test:a11y:unit. They still run in npm test.
Gevell Org Chart (apps/org-chart)
Same layout again, under apps/org-chart/e2e/. The gate uses its own config, playwright.a11y.config.ts. playwright.config.ts is the older dev-server geometry suite (npm run test:e2e), and it ignores the gate specs.
Build and routing
npm run build:a11ybakes in the sandbox hosts. It emptiesNEXT_PUBLIC_API_URL_FALLBACKand the Stripe key.- The app is built with basePath
/chart, and route URLs carry it.e2e/support/static-server.mjscopies orgchart.gevell.com (worker/index.ts): it strips a leading/chartand serves the export from the root. gevell.com/chart forwards only/chart/*, so the primary flow asserts that the dashboard’s Edit link keeps the prefix. Other plain anchors that drop it are tracked in #3704. - The config does not block service workers. Playwright’s blocking shim reads
navigator.serviceWorkerin every frame, and that throws inside the sandboxedsrcDociframes on/reviewand/share. The app registers no service worker.
Seed records (e2e/support/seed/fixtures.ts)
- The main chart record: nine people, one secondary manager and one dotted-line connection. The dashboard, editor, preview, branding and share pages load it.
- A second chart, so the dashboard list has more than one row.
- Credits, packages, an API key and a hosted chart for the settings pages.
- A third, reparenting chart. It serves the interactive preview document with the drag/keyboard layer,
e2e/fixtures/reparent-before.html.
Routes (e2e/support/routes.ts): every page except /admin and /admin/docs, which are staff-only. Each runs in light and dark.
- Marketing (7) and
/accessibility. /auth/login, plus its error state./auth/callback, in its error state./billing/success,/dashboardand/wizard./editor,/previewand/branding, with the chart record loaded.- The four settings pages.
/share, in both the civic and the radial template./review.
Keyboard reparenting. preview-reparent.a11y.spec.ts covers the keyboard alternative to drag-and-drop:
- focus a box, press M, focus the new manager, press Enter;
- the host must post the move, and Undo must appear;
- axe runs while the move is in progress and again after it lands.
Mobile checks
- Every route also runs
obscuredControls(). The app shell pins a 64px side rail at every width, so nothing may sit under it. - The primary flow:
- from the dashboard, the upload picker opens;
- Edit opens the editor (under
/chart), with the chart title and a Manager select on screen; - the preview frame fits the viewport;
- its Download menu fits, and no menu item is under the rail.
Allowlist (apps/org-chart/e2e/a11y-allowlist.json). No axe (WCAG) entries. The touch-target-44 entries:
- the
/editorpeople grid and its toolbar, tracked in #3700 and expiring 2026-12-31. This needs a small-screen layout; - everything else, tracked in #3701 and expiring 2027-03-31. This covers the shared footer, the app chrome, and form controls styled at 30–42 px.
Run it locally:
cd apps/org-chartnpm run build:a11ynpm run test:a11y # playwright test -c playwright.a11y.config.ts --project=a11ynpm run test:mobileThe older jsdom axe unit tests (src/__tests__/a11y/) moved from test:a11y to test:a11y:unit. They still run in npm test.
Gevell Audit (apps/audit)
Same layout as apps/vpat, under apps/audit/e2e/, bound to the shared harness in e2e/support/gate.ts. Static server port 4313.
Build and API host
npm run build:a11yinapps/audit. Like vpat, it skipsgenerate-build-version.mjs.- The scan clients (
src/lib/api.ts,pdf-audit-api.ts,pdf-wizard-api.ts,audit-report-api.ts) take their host fromsrc/lib/api-base.ts:NEXT_PUBLIC_A11Y_API_URL, which onlybuild:a11ysets, elsehttps://api.gevell.com. It is deliberately notNEXT_PUBLIC_API_URL: developers’.env.*.localfiles set that one (a local.env.production.localpoints it at a localhost API), and a manualnpm run deploywould bake it into every scan request.
Backends. The platform API (workshops catalog, website scans, PDF audits, the PDF verification checklist) and Supabase PostgREST directly: credit_balances and profiles for the signed-in user, and audit_runs for /history. e2e/support/seed/api-routes.ts answers both. Any other call is a sandbox problem.
Seed records
e2e/support/seed/fixtures.ts: a credit balance, a non-admin profile, and five complete CI runs across two targets, so/historyrenders its trend chart, latest grade and table.e2e/support/seed/results.ts: a completed single-page website scan (desktop, mobile and dark-mode findings, a needs-review item, VPAT criteria) and a completed PDF audit (structural checks, veraPDF rules, manual-review risks).
Routes (e2e/support/routes.ts): all 6 pages, 7 route states, each in light and dark:
/,/authand its error state;/auth/callback, audited where it lands (sign-in), since the callback itself only shows a spinner;/scanand/pdf(the empty audit forms, signed in) and/history.
Reports. results.a11y.spec.ts submits a URL on /scan and chooses a PDF on /pdf; the fixture API answers the submit and a first poll that is already complete. It audits each report with every <details> and every “Show HTML” viewer open, and the website report again with its Download menu open. Helpers: e2e/support/results.ts.
Mobile primary flow. On /scan, the URL field and Start Audit are on screen; the scan reaches its report, whose summary and Download button are on screen. Then the PDF tab’s “Choose a PDF” button must open the file picker. Both reports also get the reflow and target checks.
Allowlist. apps/audit/e2e/a11y-allowlist.json. No axe (WCAG) entries. The touch-target-44 baseline covers only controls rendered by shared packages: the footer links and version button (* entries), and the product-name link on the sign-in page (/auth*). Tracked in #3772, expiring 2027-03-31.
Run it locally:
cd apps/auditnpm run build:a11ynpm run test:a11ynpm run test:mobileThe build rewrites the tracked next-env.d.ts. Do not commit that change. The older jsdom axe unit test (src/__tests__/a11y/) moved from test:a11y to test:a11y:unit. It still runs in npm test.
gevell.com (apps/home)
Same layout as apps/audit, under apps/home/e2e/, bound to the shared harness in e2e/support/gate.ts. Static server port 4318.
Build and backends
npm run build:a11yinapps/home. It runsnext builddirectly, so neither theprebuildcodegen (pricing, workshops catalog, marketing-copy CMS) norgenerate-build-version.mjsruns. The build uses the committed snapshots (src/lib/content.generated.ts, andworkshops.generated.tsandpricing*.generated.tsinpackages/shared/src/) and makes no network call.- The site is signed out and has no Supabase client. The static server only requires
http://api.a11y.testin the build. - The build points three things at the mock API host: the platform API (
NEXT_PUBLIC_API_URL, for WorkshopPromoBar and/account/restore), the workshops catalog (NEXT_PUBLIC_WORKSHOPS_API_URL), and the workshop worker (checkout and tax exemption). The worker host is read fromsrc/lib/worker-base.ts:NEXT_PUBLIC_A11Y_WORKER_URL, which onlybuild:a11ysets, elsehttps://worker.gevell.com.
Fixtures (e2e/support/seed/)
- The workshops catalog: two trainings, a two-part workshop and its bundle, dated 2030. The committed snapshot expires as real dates pass, so the gate does not rely on it. WorkshopPromoBar,
/workshopsand/registerall render from this catalog. Every route waits for the promo bar, so it is audited on every page. - The tax-exemption status for the checkout session in
/register?success=true. - The account-restore response.
- Checkout always returns a rejection, so no spec ever follows a checkout URL. Stripe is never contacted.
Routes (e2e/support/routes.ts): every page, 67 route states, each in light and dark.
- Marketing:
/,/about,/why-gevell,/accessibility,/data-privacy,/cicd,/mcp,/integrations/s3,/checklist,/sso,/third-party-licenses. /guidesand its three guides./workshopsand/register, plus/registerback from Stripe: payment succeeded (with the tax-exemption upload) and payment canceled./account/restorewith a token (restored) and without one (error)./legal, and every published legal document, listed fromsrc/content/legal: each current page, each history page and each archived version.
Registration states. register-states.a11y.spec.ts audits the form after an empty submit (every required-field error shown), and filled in with two attendees and the order summary after the fixture rejects checkout.
Mobile.
- Every route at the three viewports. The legal history and archived-version pages are one template each over the same Markdown, so only
/legal/terms’s run there; every current legal document runs. - All three viewports are below the header’s
lgbreakpoint, so the hamburger menu is tested on/and/legal/terms: it opens on screen, its links are on screen, it passes reflow, the target checks and full WCAG 2.2 AA while open, and Escape closes it. - The primary flow: from
/workshops, a Register link opens/registerwith the workshop options loaded, and the filled form reaches checkout (the rejecting fixture).
Allowlist. apps/home/e2e/a11y-allowlist.json. No axe (WCAG) entries. The touch-target-44 baseline is tracked in #3789 and expires 2027-03-31. It covers:
- the shared footer links and version button (
*entries); - links in the legal documents’ Markdown that sit alone in a list item or table cell, such as contact addresses and the subprocessor list (
/legal/*entries).
Run it locally:
cd apps/homenpm run build:a11ynpm run test:a11ynpm run test:mobileThe build rewrites the tracked next-env.d.ts. Do not commit that change. The older jsdom axe unit test (src/__tests__/a11y/) moved from test:a11y to test:a11y:unit. It still runs in npm test.
Gevell Web (apps/remediate)
Same layout as apps/audit, under apps/remediate/e2e/, bound to the shared harness in e2e/support/gate.ts. Static server port 4321.
Build and API host
npm run build:a11yinapps/remediate. Like audit, it skipsgenerate-build-version.mjs.src/lib/api.tsand the PowerPoint download link take their host fromsrc/lib/api-base.ts:NEXT_PUBLIC_A11Y_API_URL, which onlybuild:a11ysets, elsehttps://api.gevell.com. It is deliberately notNEXT_PUBLIC_API_URL, for the same reason as audit.
Backends. The platform API (workshops catalog, stored job HTML, URL-fetch jobs, alt-text decisions, the fix wizard, PowerPoint scan and remediate) and Supabase PostgREST directly: credit_balances and remediation_jobs. e2e/support/seed/api-routes.ts answers both. Any other call is a sandbox problem.
Seed records (e2e/support/seed/fixtures.ts)
- Three past jobs: a URL scan with its full audit and a fix-wizard link, a URL scan whose full audit is still running, and an uploaded file that now passes.
- The URL-fetch job behind the first: desktop, dark and mobile variants, the embedded VPAT audit and the image metadata the Alt Text browser reads.
- The customer page itself (headings, links, a table, a form, three
data:images), so every wizard tool has content. - The wizard’s groups, one AI suggestion and saved progress; a PowerPoint scan and remediation result.
Routes (e2e/support/routes.ts): all 6 pages, 8 route states, each in light and dark:
/and/auth/login;/auth/callback, audited where it lands (/dashboard), since the callback itself only shows a spinner;/dashboard(the jobs table),/dashboard?open=(the Gevell Audit hand-off into the live editor) and/dashboard/pptx;/audit/wizard?id=(the loaded wizard) and/audit/wizardwith no id (its error state).
Interaction states. flows.a11y.spec.ts audits the states in e2e/support/flow-states.ts, in light and dark:
- each job’s asset panel (graded, audit running, file);
- the live editor: visual, code and walkthrough, and its Alt Text browser;
- the hand-off’s mobile and dark variants;
- every fix-wizard tool dialog (Listen, headings, links, tables, alt text, tab order, the 320px reflow preview) and the “behind a login” helper;
- the PowerPoint scan results, remediation result and wrong-file-type error.
Iframes. The frames that render the customer’s page are not audited: Remediated HTML preview, Page preview — …, Tab order preview and Document reading preview (DOCUMENT_FRAME_TITLES in e2e/support/gate.ts).
Mobile. Every route and every interaction state gets the reflow and target checks. The primary flow: on /dashboard, the URL field and Scan button are on screen; a past job’s files and its Wizard link are on screen, and the wizard opens with its grade; on /dashboard/pptx, Choose File opens the file picker.
Allowlist. apps/remediate/e2e/a11y-allowlist.json. No axe (WCAG) entries. The touch-target-44 baseline covers controls under 44px when the gate landed: the shared footer and version button (*), the app header, and page controls scoped to the routes where each was seen. Tracked in #3788, expiring 2027-03-31.
Run it locally:
cd apps/remediatenpm run build:a11ynpm run test:a11ynpm run test:mobileThe build rewrites the tracked next-env.d.ts. Do not commit that change. The older jsdom axe unit test (src/__tests__/a11y/) moved from test:a11y to test:a11y:unit. It still runs in npm test.
Gevell PDF Polish (apps/pro)
Same layout as apps/audit, under apps/pro/e2e/, bound to the shared harness in e2e/support/gate.ts. Static server port 4316 locally; CI passes an OS-assigned port in A11Y_PORT.
Build. npm run build:a11y in apps/pro. Like vpat and audit, it skips generate-build-version.mjs, so the footer shows the example version.
Backends. The platform API only: the workshops catalog, the files endpoints that stage a PDF (upload slot, PUT, confirm), and the flyer (preserve-formatting) endpoints: preflight, tag, job poll, tagged-PDF download, PDF/UA report, and the GET /api/v1/flyer/jobs history. e2e/support/seed/api-routes.ts answers them. Supabase is used for auth only, so any PostgREST call is a sandbox problem.
Seed records (e2e/support/seed/fixtures.ts)
- A My Results history with one job in each terminal disposition: delivered (Download PDF), gaps remain, refunded and failed. There is no queued or processing job: its row spinner is a loading state the harness refuses to audit.
- A three-PDF Polish batch whose files end delivered, gaps remain and refunded.
Routes (e2e/support/routes.ts): all 4 pages, 7 route states, each in light and dark:
/signed out (the sign-in prompt) and signed in (the empty drop zone);/authand its error state;/auth/callback, audited where it lands (/), since the callback itself only shows a spinner;/resultssigned out (the sign-in prompt) and signed in (the seeded history).
Polish panel. polish.a11y.spec.ts chooses the three-PDF batch on /, audits it waiting in the confirm bar, starts it, and audits the finished rows: View PDF, Download PDF and Download report on the delivered file, the remediation handoff on the gaps and refunded files. Helpers: e2e/support/polish.ts. The first job poll waits 2.5 s, so each batch test takes about 5 s.
Mobile primary flow. On /, “Browse for PDFs” is on screen and opens the file picker. One PDF starts at once and is delivered, with Download PDF and Download report on screen. My Results is reachable from the header, and its Download PDF button is on screen. The Polish batch also gets the reflow and target checks, in the confirm bar and finished.
Allowlist. apps/pro/e2e/a11y-allowlist.json. No axe (WCAG) entries. The touch-target-44 baseline covers only controls rendered by shared packages: the header theme toggle and the “My Results” nav link (@anglinai/ui CorporateHeader), the footer links and version button (* entries), and the product-name link on the sign-in page (/auth*). Tracked in #3785, expiring 2027-03-31.
Run it locally:
cd apps/pronpm run build:a11ynpm run test:a11ynpm run test:mobileThe build rewrites the tracked next-env.d.ts. Do not commit that change. The app had no jsdom axe unit tests, so the old test:a11y script (vitest run src/__tests__/a11y/, which matched nothing) was replaced rather than moved.
Gevell Tags (apps/tags)
Same layout as apps/pro, under apps/tags/e2e/, bound to the shared harness in e2e/support/gate.ts. Static server port 4322 locally; CI passes an OS-assigned port in A11Y_PORT.
Build. npm run build:a11y in apps/tags. Besides pointing the API, the tags document API and Supabase at the *.a11y.test hosts, it sets TAGS_ENGINE=mock: the workspace runs the engine’s replay mock (packages/tag-engine-web, pdf-declarations transcript) through the real client and protocol instead of the wasm worker, so the runner needs no Rust toolchain. #wasm-glue is aliased to a stub in that build. Like the other apps it skips generate-build-version.mjs.
Backends. The platform API for the workshops catalog, and the provisional tags document API (#3969): list, create with a presigned upload URL, get with a presigned download URL, and both byte transfers. e2e/support/seed/api-routes.ts answers them; the download serves docs/standards/PDF-Declarations.pdf, the PDF the mock transcript was recorded from (the mock matches it by SHA-256). Supabase is used for auth only.
Routes (e2e/support/routes.ts): 11 route states, each in light and dark: / signed out and in, /auth/login and its error state, /auth/callback (audited where it lands), /dashboard signed out and in, and /edit signed out, with no document, with the workspace open (engine ready), and on the Recover screen. The Recover screen is reached through a real engine crash: ?simulate=crash makes the mock’s in-process worker fail the way a real worker does; production builds ignore the parameter.
Workspace. workspace.a11y.spec.ts audits, in light and dark and in all four layouts (wide, standard, compact, single pane), the workspace, the command palette (with and without matches), keyboard help, and comfortable density; in the compact layout also the inspector drawer (Mod+I, Esc). It checks that F6 and Shift+F6 cycle the regions in spec order, that splitters resize from the keyboard, and that every command in the registry runs from the palette by keyboard. The app’s user agent is the emulated Windows Chrome one, so the specs press Control, never Meta.
Mobile. Every route at the three standard viewports, plus a 320x256 pass (WCAG 1.4.10 at 400% zoom). The 44px project check applies everywhere except the /edit workspace, where dense targets are the decided design (Larry, 2026-10-01, #3972: controls ≥ 24px, comfortable density 36–44px); the workspace still has to pass axe target-size (WCAG 2.5.8). Primary flow: upload a PDF from the dashboard’s file chooser, open a document, and switch between the four views.
Allowlist. apps/tags/e2e/a11y-allowlist.json. No axe (WCAG) entries. The touch-target-44 baseline is apps/pro’s: controls rendered by shared packages (header theme toggle and “Documents” nav link, footer links and version button, the product-name link on the sign-in page). Tracked in #3785, expiring 2027-03-31.
Run it locally:
cd apps/tagsnpm run build:a11ynpm run test:a11ynpm run test:mobile