Repository navigation
Accessibility
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.
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.
| 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 |
| 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 |
| 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 |
| 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>
);
}| 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 |
| 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>
);
}| 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) |
| 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 |
| 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. |
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.
-
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), drawworkbars with aCanvasTextoutline,usedinCanvasTextand the due marker inHighlight. - Proposed: header icon buttons 40 × 40 px.
- Which paths
a11y.spec.tscovers beyond the landing page and docs home (every page, or a list).
workclock is MIT licensed. The repository documentation is authoritative; this wiki summarises it.