Skip to content
Foundations

Accessibility

The target is WCAG 2.2 AA, asserted mechanically rather than promised.

The floor is a build step, not a policy

Every colour that declares an obligation is measured against the surface it renders on, in BOTH themes, and the build fails if it is under its floor.

$ pnpm --filter @gridd/ui check:tokens ok tokens: 53 colours, 22 contrast obligations, all above floor

There is no hand-typed ratio anywhere in the system. A number typed by a person goes stale the first time either side of the pair moves, and a stale ratio is worse than no ratio because it reads as verified. The generator computes the real value, writes it into tokens.css as a comment, and refuses to emit a file that fails.

What is checked

Read from the token model, so this list is the set of assertions the build actually runs.

--gd-inkon --gd-surfaceAA
--gd-ink-bodyon --gd-surfaceAA
--gd-ink-secondaryon --gd-surfaceAA
--gd-ink-mutedon --gd-surfaceAA
--gd-ink-fainton --gd-surfaceAA-large
--gd-ink-inverseon --gd-surface-inverseAA
--gd-ink-on-solidon --gd-surface-solidAA
--gd-ink-on-accenton --gd-accentAA
--gd-ink-on-accent-bodyon --gd-accentAA
--gd-ink-on-accent-mutedon --gd-accentAA-large
--gd-line-controlon --gd-surfaceAA-nontext
--gd-accenton --gd-surfaceAA-nontext
--gd-accent-inkon --gd-surfaceAA
--gd-successon --gd-surfaceAA-nontext
--gd-success-inkon --gd-surfaceAA
--gd-success-on-solidon --gd-successAA-nontext
--gd-infoon --gd-surfaceAA-nontext
--gd-info-inkon --gd-surfaceAA
--gd-info-on-solidon --gd-infoAA-nontext
--gd-cautionon --gd-surfaceAA-nontext
--gd-caution-inkon --gd-surfaceAA
--gd-caution-on-solidon --gd-cautionAA-nontext
--gd-dangeron --gd-surfaceAA-nontext
--gd-danger-inkon --gd-surfaceAA
--gd-danger-on-solidon --gd-dangerAA-nontext

AA is 4.5:1, for body text. AA-large is 3:1, for type at 18.66px or 14px bold. AA-nontext is 3:1 under WCAG 1.4.11 — control boundaries, focus rings, and any graphic you must perceive to operate the thing.

Non-negotiables

  • If it is clickable, make it the element. A div with a click handler answers Enter, silently ignores Space, and is unreachable without a tabindex. Every interactive component here renders a real button or a real link.
  • One focus ring, declared once. The solid accent at 2px — base.css draws it with outline, and a control that owns its own ring (input, textarea, select, the hero search pill) sets box-shadow: var(--gd-border-focus-ring), which is the same colour and weight but follows a custom radius. An invalid control swaps only the hue (--gd-border-focus-ring-invalid); the geometry never changes.
  • A wash is not an indicator. The form controls used a 14%-alpha halo until 2026-08-02 — pretty, and nowhere near the 3:1 that 1.4.11 asks of a focus indicator. --gd-accent-ring survives as emphasis (the selected parcel's glow), never as focus.
  • The ring is never removed locally. If a component needs it somewhere else — on the pill around an input rather than the input — it must PUT it there; taking an indicator away is only allowed once a better one is visible in its place.
  • A colour is never the only carrier. An alert announces its tone in words to a screen reader. The approval stack is legible in pure greyscale.
  • aria-modal is a promise the background is inert. Either keep it or do not claim it — which is why the dialog is built on the native element rather than a div.
  • A placeholder is not a label. --gd-ink-placeholder fails AA on purpose; if a placeholder is carrying information, that is a defect in the form.
  • Reduced motion is honoured once, globally, so a new animation is covered the moment it is written.
  • Inputs floor at 16px on touch, so iOS does not zoom the page on focus.

Navigate

Esc