WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: ATB-34 axe-core a11y implementation plan

Step-by-step plan: add axe-core + jsdom deps, create consolidated test
file with jsdom env pragma, one happy-path WCAG AA test per page route.

+584
+584
docs/plans/2026-02-27-axe-core-a11y.md
··· 1 + # ATB-34: Axe-core Automated Accessibility Testing — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a single consolidated test file to `apps/web` that runs axe-core against every full-page HTML route and asserts zero WCAG AA violations. 6 + 7 + **Architecture:** Vitest's `@vitest-environment jsdom` per-file pragma switches the test file to a jsdom environment, providing `DOMParser` and `document` globals. Each test calls the Hono route handler directly via `app.request()`, parses the HTML response with `DOMParser`, and passes the resulting `Document` to `axe.run()`. Module-level `vi.mock` intercepts `fetchApi` and `getSession` calls so no real network is needed. 8 + 9 + **Tech Stack:** `axe-core`, `jsdom`, Vitest, Hono (test via `app.request()`), `vi.mock` for module mocking 10 + 11 + **Design doc:** `docs/plans/2026-02-27-axe-core-a11y-design.md` 12 + 13 + **Known limitation:** jsdom has no CSS engine. Axe-core's `color-contrast` rules are automatically skipped. These tests cover structural/semantic WCAG AA rules only (landmark regions, heading hierarchy, form labels, aria attributes, link purpose). Color contrast requires manual or Playwright-based verification. 14 + 15 + --- 16 + 17 + ### Task 1: Add dependencies 18 + 19 + **Files:** 20 + - Modify: `apps/web/package.json` 21 + 22 + **Step 1: Add axe-core, jsdom, and vitest as devDependencies** 23 + 24 + Run from the repo root (requires devenv shell — `devenv shell` first): 25 + 26 + ```bash 27 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 28 + pnpm --filter @atbb/web add -D axe-core jsdom vitest 29 + ``` 30 + 31 + Expected: pnpm updates `apps/web/package.json` and `pnpm-lock.yaml`. 32 + 33 + **Step 2: Verify the additions appear in package.json** 34 + 35 + Check that `apps/web/package.json` now contains: 36 + ```json 37 + "devDependencies": { 38 + "axe-core": "...", 39 + "jsdom": "...", 40 + "vitest": "..." 41 + } 42 + ``` 43 + 44 + **Step 3: Commit** 45 + 46 + ```bash 47 + git add apps/web/package.json pnpm-lock.yaml 48 + git commit -m "chore(web): add axe-core, jsdom, vitest as explicit devDependencies (ATB-34)" 49 + ``` 50 + 51 + --- 52 + 53 + ### Task 2: Create the test file skeleton 54 + 55 + **Files:** 56 + - Create: `apps/web/src/__tests__/a11y.test.ts` 57 + 58 + **Step 1: Create the file with the environment pragma, imports, and module mocks** 59 + 60 + ```typescript 61 + // @vitest-environment jsdom 62 + import { describe, it, expect, vi, beforeEach } from "vitest"; 63 + import axe from "axe-core"; 64 + 65 + // ── Module mocks ────────────────────────────────────────────────────────────── 66 + // Must be declared before any imports that use these modules. 67 + // vi.mock is hoisted to the top of the file by Vitest's transform. 68 + 69 + vi.mock("../lib/api.js", () => ({ 70 + fetchApi: vi.fn(), 71 + })); 72 + 73 + vi.mock("../lib/session.js", () => ({ 74 + getSession: vi.fn(), 75 + getSessionWithPermissions: vi.fn(), 76 + canLockTopics: vi.fn().mockReturnValue(false), 77 + canModeratePosts: vi.fn().mockReturnValue(false), 78 + canBanUsers: vi.fn().mockReturnValue(false), 79 + })); 80 + 81 + vi.mock("../lib/logger.js", () => ({ 82 + logger: { 83 + debug: vi.fn(), 84 + info: vi.fn(), 85 + warn: vi.fn(), 86 + error: vi.fn(), 87 + fatal: vi.fn(), 88 + }, 89 + })); 90 + 91 + // ── Import mocked modules so we can configure return values per test ────────── 92 + import { fetchApi } from "../lib/api.js"; 93 + import { getSession, getSessionWithPermissions } from "../lib/session.js"; 94 + 95 + // ── Route factories ─────────────────────────────────────────────────────────── 96 + import { createHomeRoutes } from "../routes/home.js"; 97 + import { createLoginRoutes } from "../routes/login.js"; 98 + import { createBoardsRoutes } from "../routes/boards.js"; 99 + import { createTopicsRoutes } from "../routes/topics.js"; 100 + import { createNewTopicRoutes } from "../routes/new-topic.js"; 101 + import { createNotFoundRoute } from "../routes/not-found.js"; 102 + 103 + // ── Constants ───────────────────────────────────────────────────────────────── 104 + const APPVIEW_URL = "http://localhost:3000"; 105 + 106 + // ── Typed mock handles ──────────────────────────────────────────────────────── 107 + const mockFetchApi = vi.mocked(fetchApi); 108 + const mockGetSession = vi.mocked(getSession); 109 + const mockGetSessionWithPermissions = vi.mocked(getSessionWithPermissions); 110 + 111 + // ── Shared reset ────────────────────────────────────────────────────────────── 112 + beforeEach(() => { 113 + mockFetchApi.mockReset(); 114 + // Default: unauthenticated session for all routes. 115 + // Override in individual tests that need authenticated state. 116 + mockGetSession.mockResolvedValue({ authenticated: false }); 117 + mockGetSessionWithPermissions.mockResolvedValue({ 118 + authenticated: false, 119 + permissions: new Set<string>(), 120 + }); 121 + }); 122 + 123 + // ── A11y helper ─────────────────────────────────────────────────────────────── 124 + // NOTE: jsdom has no CSS engine, so axe-core's color-contrast rules are 125 + // skipped automatically. These tests cover structural/semantic WCAG AA rules 126 + // only (landmark regions, heading hierarchy, form labels, aria attributes). 127 + async function checkA11y(html: string): Promise<void> { 128 + const doc = new DOMParser().parseFromString(html, "text/html"); 129 + const results = await axe.run(doc, { 130 + runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, 131 + }); 132 + const summary = results.violations 133 + .map( 134 + (v) => 135 + ` [${v.id}] ${v.description}\n` + 136 + v.nodes.map((n) => ` → ${n.html}`).join("\n") 137 + ) 138 + .join("\n"); 139 + expect( 140 + results.violations, 141 + `WCAG AA violations found:\n${summary}` 142 + ).toHaveLength(0); 143 + } 144 + 145 + describe("WCAG AA accessibility — one happy-path test per page route", () => { 146 + // Tests go here in Task 3–8 147 + }); 148 + ``` 149 + 150 + **Step 2: Verify the file can be collected by Vitest without errors** 151 + 152 + ```bash 153 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 154 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts 155 + ``` 156 + 157 + Expected output: `0 tests` collected, no import/type errors. If you see a module resolution error, fix the import paths before proceeding. 158 + 159 + **Step 3: Commit** 160 + 161 + ```bash 162 + git add apps/web/src/__tests__/a11y.test.ts 163 + git commit -m "test(web): scaffold a11y test file with jsdom environment and module mocks (ATB-34)" 164 + ``` 165 + 166 + --- 167 + 168 + ### Task 3: Home page test 169 + 170 + The home route calls `fetchApi` three times: 171 + 1. `fetchApi("/forum")` and `fetchApi("/categories")` in parallel (stage 1) 172 + 2. `fetchApi("/categories/1/boards")` for each category (stage 2) 173 + 174 + Use URL-pattern dispatch in `mockImplementation` so parallel calls resolve correctly regardless of call order. 175 + 176 + **Files:** 177 + - Modify: `apps/web/src/__tests__/a11y.test.ts` 178 + 179 + **Step 1: Add the home page test inside the `describe` block** 180 + 181 + ```typescript 182 + it("home page / has no violations", async () => { 183 + mockFetchApi.mockImplementation((path: string) => { 184 + if (path === "/forum") { 185 + return Promise.resolve({ 186 + id: "1", 187 + did: "did:plc:forum", 188 + name: "Test Forum", 189 + description: "A test forum", 190 + indexedAt: "2024-01-01T00:00:00.000Z", 191 + }); 192 + } 193 + if (path === "/categories") { 194 + return Promise.resolve({ 195 + categories: [ 196 + { 197 + id: "1", 198 + did: "did:plc:forum", 199 + name: "General", 200 + description: null, 201 + slug: "general", 202 + sortOrder: 0, 203 + }, 204 + ], 205 + }); 206 + } 207 + if (path === "/categories/1/boards") { 208 + return Promise.resolve({ 209 + boards: [ 210 + { 211 + id: "1", 212 + did: "did:plc:forum", 213 + name: "Test Board", 214 + description: null, 215 + slug: "test", 216 + sortOrder: 0, 217 + }, 218 + ], 219 + }); 220 + } 221 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 222 + }); 223 + 224 + const routes = createHomeRoutes(APPVIEW_URL); 225 + const res = await routes.request("/"); 226 + expect(res.status).toBe(200); 227 + await checkA11y(await res.text()); 228 + }); 229 + ``` 230 + 231 + **Step 2: Run just this test** 232 + 233 + ```bash 234 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 235 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts -t "home page" 236 + ``` 237 + 238 + Expected: PASS. If axe reports violations, inspect the violation `[id]` and fix the corresponding HTML in `apps/web/src/routes/home.tsx` or `apps/web/src/layouts/base.tsx`. 239 + 240 + **Step 3: Commit once passing** 241 + 242 + ```bash 243 + git add apps/web/src/__tests__/a11y.test.ts 244 + git commit -m "test(web): add WCAG AA test for home page (ATB-34)" 245 + ``` 246 + 247 + --- 248 + 249 + ### Task 4: Login page test 250 + 251 + The login route calls `getSession()` which returns early (no fetch) when no `atbb_session` cookie is present — which is the case here since we don't include a Cookie header. No `fetchApi` calls needed. 252 + 253 + **Files:** 254 + - Modify: `apps/web/src/__tests__/a11y.test.ts` 255 + 256 + **Step 1: Add the login page test** 257 + 258 + ```typescript 259 + it("login page /login has no violations", async () => { 260 + // getSession returns { authenticated: false } immediately with no cookie header. 261 + // No fetchApi calls are made. 262 + const routes = createLoginRoutes(APPVIEW_URL); 263 + const res = await routes.request("/login"); 264 + expect(res.status).toBe(200); 265 + await checkA11y(await res.text()); 266 + }); 267 + ``` 268 + 269 + **Step 2: Run just this test** 270 + 271 + ```bash 272 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 273 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts -t "login page" 274 + ``` 275 + 276 + Expected: PASS. 277 + 278 + **Step 3: Commit once passing** 279 + 280 + ```bash 281 + git add apps/web/src/__tests__/a11y.test.ts 282 + git commit -m "test(web): add WCAG AA test for login page (ATB-34)" 283 + ``` 284 + 285 + --- 286 + 287 + ### Task 5: Board page test 288 + 289 + The board route calls `fetchApi` three times in sequence: 290 + 1. `fetchApi("/boards/1")` and `fetchApi("/boards/1/topics?offset=0&limit=25")` in parallel (stage 1) 291 + 2. `fetchApi("/categories/1")` for the category breadcrumb (stage 2) 292 + 293 + **Files:** 294 + - Modify: `apps/web/src/__tests__/a11y.test.ts` 295 + 296 + **Step 1: Add the board page test** 297 + 298 + ```typescript 299 + it("board page /boards/:id has no violations", async () => { 300 + mockFetchApi.mockImplementation((path: string) => { 301 + if (path === "/boards/1") { 302 + return Promise.resolve({ 303 + id: "1", 304 + did: "did:plc:forum", 305 + uri: "at://did:plc:forum/space.atbb.forum.board/1", 306 + name: "Test Board", 307 + description: null, 308 + slug: "test", 309 + sortOrder: 0, 310 + categoryId: "1", 311 + categoryUri: null, 312 + createdAt: "2024-01-01T00:00:00.000Z", 313 + indexedAt: "2024-01-01T00:00:00.000Z", 314 + }); 315 + } 316 + if (path === "/boards/1/topics?offset=0&limit=25") { 317 + return Promise.resolve({ topics: [], total: 0, offset: 0, limit: 25 }); 318 + } 319 + if (path === "/categories/1") { 320 + return Promise.resolve({ 321 + id: "1", 322 + did: "did:plc:forum", 323 + name: "General", 324 + description: null, 325 + slug: null, 326 + sortOrder: null, 327 + forumId: null, 328 + createdAt: null, 329 + indexedAt: null, 330 + }); 331 + } 332 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 333 + }); 334 + 335 + const routes = createBoardsRoutes(APPVIEW_URL); 336 + const res = await routes.request("/boards/1"); 337 + expect(res.status).toBe(200); 338 + await checkA11y(await res.text()); 339 + }); 340 + ``` 341 + 342 + **Step 2: Run just this test** 343 + 344 + ```bash 345 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 346 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts -t "board page" 347 + ``` 348 + 349 + Expected: PASS. 350 + 351 + **Step 3: Commit once passing** 352 + 353 + ```bash 354 + git add apps/web/src/__tests__/a11y.test.ts 355 + git commit -m "test(web): add WCAG AA test for board page (ATB-34)" 356 + ``` 357 + 358 + --- 359 + 360 + ### Task 6: Topic page test 361 + 362 + The topics route calls `getSessionWithPermissions()` (which sees no cookie → returns unauthenticated immediately). Then calls `fetchApi` three times: 363 + 1. `fetchApi("/topics/1?offset=0&limit=25")` (stage 1 — the topic + replies) 364 + 2. `fetchApi("/boards/1")` (stage 2 — board breadcrumb, non-fatal) 365 + 3. `fetchApi("/categories/1")` (stage 3 — category breadcrumb, non-fatal) 366 + 367 + **Files:** 368 + - Modify: `apps/web/src/__tests__/a11y.test.ts` 369 + 370 + **Step 1: Add the topic page test** 371 + 372 + ```typescript 373 + it("topic page /topics/:id has no violations", async () => { 374 + mockFetchApi.mockImplementation((path: string) => { 375 + if (path.startsWith("/topics/1")) { 376 + return Promise.resolve({ 377 + topicId: "1", 378 + locked: false, 379 + pinned: false, 380 + post: { 381 + id: "1", 382 + did: "did:plc:user", 383 + rkey: "abc123", 384 + title: "Test Topic Title", 385 + text: "Hello world, this is a test post.", 386 + forumUri: null, 387 + boardUri: null, 388 + boardId: "1", 389 + parentPostId: null, 390 + createdAt: "2024-01-01T00:00:00.000Z", 391 + author: { did: "did:plc:user", handle: "alice.test" }, 392 + }, 393 + replies: [], 394 + total: 0, 395 + offset: 0, 396 + limit: 25, 397 + }); 398 + } 399 + if (path === "/boards/1") { 400 + return Promise.resolve({ 401 + id: "1", 402 + did: "did:plc:forum", 403 + uri: "at://did:plc:forum/space.atbb.forum.board/1", 404 + name: "Test Board", 405 + description: null, 406 + slug: null, 407 + sortOrder: null, 408 + categoryId: "1", 409 + categoryUri: null, 410 + createdAt: null, 411 + indexedAt: null, 412 + }); 413 + } 414 + if (path === "/categories/1") { 415 + return Promise.resolve({ 416 + id: "1", 417 + did: "did:plc:forum", 418 + name: "General", 419 + description: null, 420 + slug: null, 421 + sortOrder: null, 422 + forumId: null, 423 + createdAt: null, 424 + indexedAt: null, 425 + }); 426 + } 427 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 428 + }); 429 + 430 + const routes = createTopicsRoutes(APPVIEW_URL); 431 + const res = await routes.request("/topics/1"); 432 + expect(res.status).toBe(200); 433 + await checkA11y(await res.text()); 434 + }); 435 + ``` 436 + 437 + **Step 2: Run just this test** 438 + 439 + ```bash 440 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 441 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts -t "topic page" 442 + ``` 443 + 444 + Expected: PASS. 445 + 446 + **Step 3: Commit once passing** 447 + 448 + ```bash 449 + git add apps/web/src/__tests__/a11y.test.ts 450 + git commit -m "test(web): add WCAG AA test for topic page (ATB-34)" 451 + ``` 452 + 453 + --- 454 + 455 + ### Task 7: New-topic page test (authenticated) 456 + 457 + The new-topic route requires authentication to show the form — the unauthenticated state renders only a plain "Log in to create a topic" paragraph, skipping the labeled inputs that are the primary a11y surface. Test the authenticated state. 458 + 459 + `getSession` is called with no `atbb_session` cookie in the default `beforeEach` mock. For this test only, override it to return an authenticated session. 460 + 461 + **Files:** 462 + - Modify: `apps/web/src/__tests__/a11y.test.ts` 463 + 464 + **Step 1: Add the new-topic page test** 465 + 466 + ```typescript 467 + it("new-topic page /new-topic (authenticated) has no violations", async () => { 468 + // Override the default unauthenticated session for this test only. 469 + mockGetSession.mockResolvedValueOnce({ 470 + authenticated: true, 471 + did: "did:plc:user", 472 + handle: "alice.test", 473 + }); 474 + 475 + mockFetchApi.mockImplementation((path: string) => { 476 + if (path === "/boards/1") { 477 + return Promise.resolve({ 478 + id: "1", 479 + did: "did:plc:forum", 480 + uri: "at://did:plc:forum/space.atbb.forum.board/1", 481 + name: "Test Board", 482 + description: null, 483 + slug: null, 484 + sortOrder: null, 485 + categoryId: "1", 486 + categoryUri: null, 487 + createdAt: null, 488 + indexedAt: null, 489 + }); 490 + } 491 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 492 + }); 493 + 494 + const routes = createNewTopicRoutes(APPVIEW_URL); 495 + const res = await routes.request("/new-topic?boardId=1"); 496 + expect(res.status).toBe(200); 497 + await checkA11y(await res.text()); 498 + }); 499 + ``` 500 + 501 + **Step 2: Run just this test** 502 + 503 + ```bash 504 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 505 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts -t "new-topic" 506 + ``` 507 + 508 + Expected: PASS. 509 + 510 + **Step 3: Commit once passing** 511 + 512 + ```bash 513 + git add apps/web/src/__tests__/a11y.test.ts 514 + git commit -m "test(web): add WCAG AA test for new-topic page (ATB-34)" 515 + ``` 516 + 517 + --- 518 + 519 + ### Task 8: Not-found page test 520 + 521 + The not-found catch-all renders without any API calls (no session cookie → `getSession` returns unauthenticated immediately with no fetch). 522 + 523 + **Files:** 524 + - Modify: `apps/web/src/__tests__/a11y.test.ts` 525 + 526 + **Step 1: Add the not-found page test** 527 + 528 + ```typescript 529 + it("not-found page has no violations", async () => { 530 + // No fetchApi or session calls — unauthenticated with no cookie. 531 + const routes = createNotFoundRoute(APPVIEW_URL); 532 + const res = await routes.request("/anything-that-does-not-exist"); 533 + expect(res.status).toBe(404); 534 + await checkA11y(await res.text()); 535 + }); 536 + ``` 537 + 538 + **Step 2: Run the full a11y test suite** 539 + 540 + ```bash 541 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 542 + pnpm --filter @atbb/web exec vitest run src/__tests__/a11y.test.ts 543 + ``` 544 + 545 + Expected: 6 tests, all PASS. If any fail with axe violations: 546 + - Read the `[violation-id]` in the error message 547 + - Look up the rule at https://dequeuniversity.com/rules/axe/ to understand what's wrong 548 + - Fix the HTML in the relevant route file or `base.tsx` 549 + - Re-run until all 6 pass 550 + 551 + **Step 3: Run the full web test suite to confirm no regressions** 552 + 553 + ```bash 554 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH \ 555 + pnpm --filter @atbb/web test 556 + ``` 557 + 558 + Expected: all tests pass. 559 + 560 + **Step 4: Commit** 561 + 562 + ```bash 563 + git add apps/web/src/__tests__/a11y.test.ts 564 + git commit -m "test(web): add WCAG AA test for not-found page (ATB-34)" 565 + ``` 566 + 567 + --- 568 + 569 + ### Task 9: Mark complete and sync docs 570 + 571 + **Step 1: Update the Linear issue** 572 + 573 + - Go to https://linear.app/atbb/issue/ATB-34 574 + - Change status from Backlog → Done 575 + - Add a comment: "Implemented in `apps/web/src/__tests__/a11y.test.ts`. Six happy-path tests (one per page route) using axe-core + Vitest jsdom environment. Known limitation: color-contrast rules skipped (jsdom has no CSS engine). Tests run in CI as part of `pnpm test`." 576 + 577 + **Step 2: Move plan docs to complete** 578 + 579 + ```bash 580 + mv docs/plans/2026-02-27-axe-core-a11y-design.md docs/plans/complete/ 581 + mv docs/plans/2026-02-27-axe-core-a11y.md docs/plans/complete/ 582 + git add docs/plans/ 583 + git commit -m "docs: move ATB-34 plan docs to complete" 584 + ```