Skip to content

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 runsFails on
a11y (<app>)playwright test --project=a11y in the appAny 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 appContent 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 main that touches the app or a package it renders from (the app’s a11y_* path filter in the changes job),
  • every push to main that 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:

AppJobsIssue
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.ts runs 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.ts opens the Remediation tools menu, each of its ten tool dialogs, and the guided-remediation wizard.
  • home.keyboard.a11y.spec.ts checks focus visibility and focus traps on eight public pages.
  • allowlist.a11y.spec.ts validates the allowlist.

mobile job

  • routes.mobile.spec.ts runs 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:

ModuleWhat it holds
allowlist.tsAllowlist entry type, validation (issue link, expiry, 200-day cap, no */*), route wildcards, matching
axe.tsWCAG_TAGS and createAxeCheck(), which returns an app’s expectNoAxeViolations bound to its allowlist and document-frame titles
session.tsThe sandbox hosts, the CI user and the unsigned session
sandbox.tsinstallSandbox(): passes the static server through, answers the mock API and Supabase hosts from the app’s fixture routers, aborts everything else, records problems
load.tsloadRoute(): the ready, redirect, theme and loading checks
mobile.tsThe 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.tsLists an app’s page.tsx routes for its route-inventory spec
static-server.mjsserveStatic(): 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:

RequestWhat happens
The static server’s own originPassed through
api.a11y.testAnswered from e2e/support/seed/api/*, built from the records in seed/fixtures.ts
supabase.a11y.test auth endpointsAnswered 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/errors or /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

Terminal window
cd apps/web
npm run build:a11y # ~1-2 min; rebuild after changing app code
npm run test:a11y # axe gate (starts the static server itself)
npm run test:mobile # mobile gate
npx playwright test --project=a11y -g "/dashboard" # one route

Changes 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.

  1. Fix the markup. This is almost always the answer.
  2. New page: add it to ROUTES with a ready heading and a data-dependent selector, and add its API calls to seed/api/*. If it should not be audited, add it to EXCLUDED_ROUTES with a reason.
  3. 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"
}
FieldWhat it holds
ruleAn axe rule id, or touch-target-44 for the mobile 44px check
routeA 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 *
selectorFor axe rules: a substring of the axe target. For touch-target-44: the control description the mobile spec prints, e.g. <button> "Enter URL"
issueRequired
expiresRequired, 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-44 entry that does not name one control.

An expired entry also stops suppressing its violation.

Current contents

  • Axe (WCAG) entries: none.
  • touch-target-44 baseline: 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:a11y in apps/vpat. It skips generate-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.ts therefore has two routers: matchApi for the platform API (workshops catalog, audit trail) and matchSupabase for GET /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-trail and /audit-trail/commit, which show an empty form until a repo and API key are entered.
  • Loaded audit trail. audit-trail.a11y.spec.ts fills 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. The touch-target-44 baseline 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.

Run it locally:

Terminal window
cd apps/vpat
npm run build:a11y
npm run test:a11y
npm run test:mobile

The 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:a11y bakes in the sandbox hosts. It empties NEXT_PUBLIC_API_URL_FALLBACK and the Stripe key.
  • The app is built with basePath /chart, and route URLs carry it. e2e/support/static-server.mjs copies orgchart.gevell.com (worker/index.ts): it strips a leading /chart and 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.serviceWorker in every frame, and that throws inside the sandboxed srcDoc iframes on /review and /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, /dashboard and /wizard.
  • /editor, /preview and /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 /editor people 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:

Terminal window
cd apps/org-chart
npm run build:a11y
npm run test:a11y # playwright test -c playwright.a11y.config.ts --project=a11y
npm run test:mobile

The 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:a11y in apps/audit. Like vpat, it skips generate-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 from src/lib/api-base.ts: NEXT_PUBLIC_A11Y_API_URL, which only build:a11y sets, else https://api.gevell.com. It is deliberately not NEXT_PUBLIC_API_URL: developers’ .env.*.local files set that one (a local .env.production.local points it at a localhost API), and a manual npm run deploy would 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 /history renders 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:

  • /, /auth and its error state;
  • /auth/callback, audited where it lands (sign-in), since the callback itself only shows a spinner;
  • /scan and /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:

Terminal window
cd apps/audit
npm run build:a11y
npm run test:a11y
npm run test:mobile

The 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:a11y in apps/home. It runs next build directly, so neither the prebuild codegen (pricing, workshops catalog, marketing-copy CMS) nor generate-build-version.mjs runs. The build uses the committed snapshots (src/lib/content.generated.ts, and workshops.generated.ts and pricing*.generated.ts in packages/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.test in 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 from src/lib/worker-base.ts: NEXT_PUBLIC_A11Y_WORKER_URL, which only build:a11y sets, else https://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, /workshops and /register all 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.
  • /guides and its three guides.
  • /workshops and /register, plus /register back from Stripe: payment succeeded (with the tax-exemption upload) and payment canceled.
  • /account/restore with a token (restored) and without one (error).
  • /legal, and every published legal document, listed from src/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 lg breakpoint, 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 /register with 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:

Terminal window
cd apps/home
npm run build:a11y
npm run test:a11y
npm run test:mobile

The 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:a11y in apps/remediate. Like audit, it skips generate-build-version.mjs.
  • src/lib/api.ts and the PowerPoint download link take their host from src/lib/api-base.ts: NEXT_PUBLIC_A11Y_API_URL, which only build:a11y sets, else https://api.gevell.com. It is deliberately not NEXT_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/wizard with 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:

Terminal window
cd apps/remediate
npm run build:a11y
npm run test:a11y
npm run test:mobile

The 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);
  • /auth and its error state;
  • /auth/callback, audited where it lands (/), since the callback itself only shows a spinner;
  • /results signed 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:

Terminal window
cd apps/pro
npm run build:a11y
npm run test:a11y
npm run test:mobile

The 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:

Terminal window
cd apps/tags
npm run build:a11y
npm run test:a11y
npm run test:mobile