Skip to content

Accessibility

Karthik P edited this page Oct 27, 2026 · 2 revisions

This page sets the website's accessibility target, what every page and component must do to meet it, and how to check each item. Use it before merging any change to the site.

Target

WCAG 2.2 level AA, in light and dark mode, at every width from 320 px. A change that fails an item below does not merge. Items marked Proposed are not yet required.

Checklist

Colour and contrast

Requirement How WCAG
Text at least 4.5:1, large text 3:1 Only the pairs that pass on Color. text-3 never on surface in light mode. 1.4.3
Graphics and control boundaries at least 3:1 accent for markers, focus for the outline. Form-control borders in text-3. The due marker's line and dot have a 2 px bg outline. 1.4.11
Colour is never the only signal Links underlined; current page also medium weight and on surface; active tab also text; callouts carry a title word; the due marker has a shape and a label; every week-strip row has a text label. 1.4.1
Every callout has a title <Callout type="warning" title="Warning: …">. Fumadocs' icon has no text, so without a title a screen reader hears no type. 1.3.1

Focus

Requirement How WCAG
Every focusable element shows a visible focus style One global rule, outline: 2px solid var(--wc-focus); outline-offset: 2px on :focus-visible (Design Tokens, part 4). focus is at least 5.52:1 on every background. 2.4.7
No component removes it Some Fumadocs parts set focus-visible:outline-none with nothing in its place: the accordion trigger, the "On this page" popover trigger, the Notebook header's links (fumadocs-ui 16.17.8). The un-layered global rule overrides them; check these three after every upgrade. 2.4.7
Outlines are not clipped Inside overflow: hidden (a code block's scroll area), use outline-offset: -2px. 2.4.7
Focus is not hidden under the sticky header scroll-padding-top: calc(var(--wc-header) + 16px) (Layout and Spacing). 2.4.11
Focus order follows the visual order Skip link, header, sidebar, content, "On this page". No positive tabindex. 2.4.3
Outline, not box-shadow Outlines survive Windows contrast themes; box-shadow rings disappear. That is also why the global rule switches Fumadocs' ring off. 1.4.11

Keyboard

Part Keys WCAG
Everything Tab and Shift+Tab to reach, Enter or Space to activate, no traps. 2.1.1, 2.1.2
Code tabs Radix Tabs: Left/Right switch tabs, Home/End jump (Radix Tabs). 2.1.1
Search Ctrl+K or ⌘K (a modifier, so not a single-key shortcut). A Radix Dialog: focus moves in and stays, Escape closes and returns focus to the trigger, arrows move through results. 2.1.1, 2.4.3
Theme The toggle only. Fumadocs' single-key "d" shortcut is off: <RootProvider theme={{ hotKey: false }}> (fumadocs-ui 16.17.8, provider/base.d.ts; Root Provider). 2.1.4
Mobile drawer Opens from the menu button; Escape closes it and returns focus to the button. 2.1.2
Scrolling code blocks and tables The scroll container is focusable so arrow keys scroll it. Fumadocs' table wrapper already has tabIndex={0}. 2.1.1
Demo Native controls: <input type="number"> for the SLA, <input type="time"> for times, <button aria-pressed> for holiday toggles and steppers. A slider, if ever used, sits beside a number field with the same value, so no drag is needed. 2.1.1, 2.5.7

The landing demo

Requirement How WCAG
The result is text A sentence below the strip: "Due 09:30 on Wednesday 30 December 2026 (Europe/London)". 1.1.1
Changes are announced The sentence is in an aria-live="polite" region that exists on first render, updated once per committed change, not per keystroke. 4.1.3
The strip has a text alternative The drawing is an element with role="img" and an aria-label built from the same data (Components); the marker inside is aria-hidden; controls sit outside it. 1.1.1
Every control is labelled A visible <label> per input, with the unit: "SLA (hours)". Holiday toggles are named "Friday 25 December: holiday". 3.3.2
Errors are text A message such as "No working time: pick at least one working day." in danger with an icon, linked to the input with aria-describedby, and aria-invalid="true" on the input (Writing Style). 3.3.1
It works before WebAssembly loads The prerendered default is correct; controls are disabled with "Loading workclock…"; a load failure is stated in text. 4.1.3
// website/components/week-strip/result.tsx
export function Result({ text }: { text: string }) {
  return (
    <p aria-live="polite" className="text-ui text-text tabular-nums">
      {text}
    </p>
  );
}

Motion

Requirement How WCAG
Honour reduced motion The global rule on Shape Elevation and Motion; the due marker jumps. 2.3.3 (AAA, adopted)
Nothing moves by itself No autoplay, carousels, loops or scroll effects. 2.2.2

Structure and content

Requirement How WCAG
Page language <html lang="en"> (Typography has the layout). 3.1.1
Skip link Fumadocs has none (fumadocs-ui 16.17.8). "Skip to content" is the first focusable element: to #nd-page (the docs <article>) or #main-content (the landing wrapper). Hidden until focused, then top left above everything (z.skip-link 60). 2.4.1
One H1, no skipped levels The page title is the only H1; MDX starts at ##. 1.3.1, 2.4.6
Unique page titles Each <title> starts with the page name. 2.4.2
Link text says where it goes No "click here". Icon-only controls (GitHub, theme, copy) have an accessible name. 2.4.4, 4.1.2
// website/components/skip-link.tsx
export function SkipLink({ href }: { href: '#nd-page' | '#main-content' }) {
  return (
    <a
      href={href}
      className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-60 focus:rounded-md focus:bg-raised focus:px-4 focus:py-2 focus:text-ui focus:text-text"
    >
      Skip to content
    </a>
  );
}

Images and diagrams

Image Alternative
Header logo (link home) Accessible name "workclock": aria-label on the <svg> or visually hidden text in the link
Informative image alt saying what the image shows that the text does not
Decorative image alt=""
Diagram of working time role="img" and an aria-label stating the schedule and result, plus a caption
Social cards opengraph-image.alt.txt (Brand)
Lucide icons beside text aria-hidden="true" (Lucide's default)

Zoom, reflow and spacing

Requirement How WCAG
Text zooms to 200% Sizes in rem; no maximum-scale or user-scalable=no. 1.4.4
No sideways page scroll at 320 px Layouts on Layout and Spacing. Only code blocks, wide tables and the week strip scroll sideways, inside their own box. 1.4.10
Text spacing can be increased No fixed heights on elements containing text. 1.4.12
Pointer targets at least 24 × 24 px Fumadocs' sidebar links are 36 px tall and TOC links 28 px; keep at least that. Demo controls at least 24 px. Links inside sentences are exempt. 2.5.8

How to test

By hand, for every change

Check Tool Pass when
Keyboard only No mouse Every control can be reached, used and left; the outline is always visible and never under the header.
Screen reader NVDA with Firefox, or VoiceOver with Safari Headings, landmarks and names make sense; the demo announces the new due time.
Zoom 200% and 400% at 1280 px wide Nothing overlaps or is cut off; no sideways page scroll at 400%.
Reduced motion, colour vision, forced colours Chrome DevTools, Rendering panel Nothing slides; every state is told apart; text, focus and the due marker stay visible.
Both themes The theme toggle All of the above in light and dark.

In CI

Both run on the built site in the website CI job, which counts toward ci-ok (Website Tech Stack). Automated checks find only part of the problems; the manual checks stay.

Tool What it does
axe-core through @axe-core/playwright and @playwright/test (dev dependencies of website/) Loads pages of the static export in a real browser, light and dark, and fails on any WCAG A or AA violation it can detect.
lychee via its GitHub Action, pinned by SHA Checks every link in website/out/, internal and external, including the .md files.
// website/tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const paths = ['/', '/docs/'];

for (const colorScheme of ['light', 'dark'] as const) {
  test.describe(colorScheme, () => {
    test.use({ colorScheme });

    for (const path of paths) {
      test(`no WCAG A/AA violations on ${path}`, async ({ page }) => {
        await page.goto(path);
        const results = await new AxeBuilder({ page })
          .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
          .analyze();
        expect(results.violations).toEqual([]);
      });
    }
  });
}

The job serves website/out at the site root before running the test. Lighthouse CI was considered and not adopted: its accessibility audit runs axe, which this already does.

Open items

  • Proposed: below 480 px of viewport height (400% zoom on a 1280 × 720 window), the header and the "On this page" bar stop being sticky (@media (max-height: 480px)), so they do not cover half the screen.
  • Proposed: under @media (forced-colors: active), draw work bars with a CanvasText outline, used in CanvasText and the due marker in Highlight.
  • Proposed: header icon buttons 40 × 40 px.
  • Which paths a11y.spec.ts covers beyond the landing page and docs home (every page, or a list).

Clone this wiki locally