Skip to content

a11y: give every scrollable code block a keyboard focus stop #1204

Description

@vivek7405

Problem

Every scrollable code block on the website should be reachable by keyboard. A <pre> that scrolls horizontally but carries no tabindex can only be scrolled with a pointer, so a keyboard or switch user cannot read the part of the code that is off screen. This is axe's scrollable-region-focusable.

PR #1202 established the rule in website/AGENTS.md and enforced it in website/test/ssr/pre-block-a11y.test.ts, but only on the three marketing pages in that test's PAGES list (/, /what-is-webjs, /why-webjs). Coverage is partial by design and the contract says so. This issue is the rest of the site, which does NOT satisfy the rule today.

Three separate mechanisms produce the gap, and they need different fixes:

  1. The markdown renderer. website/modules/blog/utils/render-post.ts:89 emits <pre class="… overflow-x-auto"> with no tabindex and no accessible name. It is the body renderer for THREE route families: /blog/[slug] (app/blog/[slug]/page.ts:4), /articles/[slug] (app/articles/[slug]/page.ts:3), and /compare/[slug] (app/compare/[slug]/page.ts:3). One edit fixes all three.
  2. The docs and gallery shell. website/lib/ui/docs-shell.ts:129-135 sets .prose-docs pre { … overflow-x: auto }, which makes every <pre> under /docs/** and /ui/** a scroll container. Those <pre> tags are authored inline in the page files and carry no tabindex. docsShell is called from app/docs/layout.ts:157 and app/ui/layout.ts:65. This is the large one: a scan during the feat(website): sell what a WebJs prompt no longer has to specify #1202 review counted roughly 473 bare <pre> tags under app/docs/.
  3. The error page. website/app/error.ts:26 renders <pre class="w-full mb-8 text-sm overflow-x-auto"> with no tabindex and no name.

Design / approach

Two of the three are single-point fixes. The docs and gallery set is the real work, and a per-tag mechanical sweep of ~473 tags is both tedious and prone to drifting back, so prefer extracting a shared emitter.

Suggested shape, matching how this site already factors composed fragments (website/AGENTS.md "How lib/ grows" says one fragment per file under lib/ui/):

  • Add website/lib/ui/code-block.ts exporting a function that returns the correct <pre> markup: tabindex="0" always, plus role="region" and an aria-label when the caller supplies a name. Route the markdown renderer and the docs and gallery pages through it.
  • Do NOT add an aria-label without role="region". A <pre> maps to ARIA role generic, where ARIA prohibits an author-supplied name, so a name without the role is a name no spec-following screen reader announces. That is rule 1 of the same three, and adding names carelessly here would trade one violation for another. A block with no good name should take tabindex="0" alone and no name, which is valid.
  • Names must be unique per page (rule 2). A named region is a landmark, and two landmarks sharing a name collapse into an ambiguous pair. This already bit feat(website): sell what a WebJs prompt no longer has to specify #1202: two cards on the home page both said aria-label="Example files" and had to be named apart. If a generic name is the only option for a docs page with many blocks, prefer no name over a repeated one.

Do the work in stages so each is reviewable and each can extend the test's PAGES list as it lands: the markdown renderer plus the error page first (small, three route families plus one page), then the docs and gallery sweep.

Implementation notes (for the implementing agent)

Where to edit

  • website/modules/blog/utils/render-post.ts around L89, the out.push('<pre class="…">') call in the fenced-code branch. Fixes /blog/[slug], /articles/[slug], /compare/[slug] at once.
  • website/app/error.ts L26.
  • website/app/docs/**/page.ts and website/app/ui/**/page.ts, every inline <pre>. website/lib/ui/docs-shell.ts:129-135 is the CSS that makes them scroll; the CSS itself does not need to change, since tabindex cannot be set from a stylesheet.
  • website/test/ssr/pre-block-a11y.test.ts, the PAGES array near the top. Add each page or route family as it is brought into line. The file's three tests (role for a named block, unique names, focus stop on a scrollable block) already express the rules and need no change to cover a new page.
  • website/AGENTS.md, the Style-section bullet added by feat(website): sell what a WebJs prompt no longer has to specify #1202. It currently says coverage is PARTIAL and explicitly disclaims being an inventory. Update or remove that disclaimer only when coverage is genuinely complete.

Landmines / gotchas

  • Do not reintroduce the two mistakes feat(website): sell what a WebJs prompt no longer has to specify #1202 already made here. A name without role="region" is prohibited by ARIA on generic; two blocks sharing a name produce duplicate landmarks. Both were review findings on that PR.
  • The scrollability detector in the test reads the overflow-x-auto UTILITY CLASS, so it does NOT see a block scrolled only by the .prose-docs pre stylesheet rule. Adding a docs page to PAGES will therefore PASS rule 3 vacuously while the page is still broken. Either give those blocks the utility class as part of the fix, or extend the detector to treat a page rendered through docsShell as scrollable. This is the single most likely way to close the issue without fixing anything.
  • renderPostBody is shared by three route families, so verify all three, not just /blog.
  • Adding tabindex="0" inserts a tab stop. On a docs page with many blocks that is a lot of stops, which is the accepted tradeoff for the WCAG requirement, but sanity-check the tab order on one long page rather than assuming.

Invariants to respect

  • website/AGENTS.md Style section: light DOM, Tailwind utilities, @theme tokens, one <section> per page section, plus the three code-block rules themselves.
  • Root AGENTS.md invariant 9 (no backtick characters inside an html template body) and invariant 11 (prose punctuation and WebJs brand casing) apply to any comment or doc prose added.

Tests + doc surfaces

  • website/test/ssr/pre-block-a11y.test.ts is the layer. Extend PAGES, and prove the counterfactual per page family: reverting the fix must red the rule-3 assertion for that page, NOT pass vacuously (see the landmine above).
  • No packages/*/src change, so the framework doc surfaces, the scaffold, the MCP, and the changelog are all N/A. This is website-only.

Acceptance criteria

  • Every <pre> that scrolls on /blog/[slug], /articles/[slug], /compare/[slug], and the error page carries tabindex="0"
  • Every <pre> that scrolls under /docs/** and /ui/** carries tabindex="0"
  • No block gains an aria-label without role="region", and no two blocks on one page share a name
  • PAGES in pre-block-a11y.test.ts covers every page family brought into line
  • A counterfactual proves the rule-3 assertion actually fires for a docs page, rather than passing vacuously because the detector cannot see a stylesheet-driven scroll container
  • The partial-coverage disclaimer in website/AGENTS.md is updated to match the real state, and removed only if coverage becomes complete
  • The website test suite passes and webjs check is clean

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions