Accessibility
The target is WCAG 2.2 AA, asserted mechanically rather than promised.
The build gate
$ pnpm --filter @gridd/ui check:tokens ok tokens: 89 colours, 26 contrast obligations, all above floor Each ratio the generator measures is written back into tokens.css beside the token, as a comment — so --gd-accent-ink in the dark theme reads #A9A4F2; /* 7.83:1 on --gd-surface · AA · 4.77:1 on --gd-accent-wash · AA */.
What is checked
--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-accent-inkon --gd-accent-washAA--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--gd-ai-markon --gd-surfaceAA-nontext--gd-marker-flowon --gd-surfaceAA-nontextAA 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.
High contrast
When the operating system reports forced-colors: active — Windows High Contrast, chiefly — the browser overrides color, background-color, border-color and box-shadow with the user's own palette. That is a good default and it is not optional. It also means every piece of state this system carries in a FILL or a SHADOW stops existing, and has to be re-stated in terms the forced palette keeps.
GdSwitch
Broke: on and off differ only in the track's background, and every background is repainted to Canvas — the two states became the same picture
Now: track and knob both re-stated: Highlight on HighlightText when on, Canvas inside a CanvasText border when off
GdSegmented
Broke: the selected thumb was a surface plus --gd-elevation-raised, and shadows are removed outright
Now: the selected segment becomes a filled Highlight instead of a lifted one
Popovers, menus, tooltips, toasts
Broke: separated from the page by a shadow alone, so they read as inline content
Now: a 1px CanvasText border — borders are repainted rather than removed, so they survive
GdSlider
Broke: the opposite problem: its WebKit track is a linear-gradient, and background-IMAGE is not overridden — so it kept painting brand indigo on a page the user had set to their own two colours
Now: the image is dropped and the fill re-stated in Highlight
Selected rows and options
Broke: chosen was a wash, and washes are flattened away
Now: Highlight on HighlightText; a selected GdCard takes Highlight on the border it already widens
The focus ring
Broke: the seven controls that draw theirs as a box-shadow lost it entirely
Now: a real outline in Highlight — square rather than radius-following, which is the right trade against no ring
The rules of that file
- Say it in system colour keywords —
CanvasText,Highlight,HighlightText,ButtonBorder,GrayText,LinkText. A--gd-*token resolves to a hex the browser is about to discard. forced-color-adjust: noneonly where the next declarations put a system colour back. Switching the override off and then painting a brand hex is how a component ends up unreadable in exactly this mode.- Prefer a border to a re-fabricated fill. Borders are repainted rather than removed, so
border: 1px solid CanvasTextrestores the edge of anything a shadow was carrying, and costs nothing anywhere else. - Status hue is not recoverable, and is not meant to be. Colour never carries planning content here — the layered glyphs of GdApprovalStack and the words do that, and both survive untouched. This restores structure and state, never meaning that was not in the colour.
Rules
- If it is clickable, make it the element. Render a real
buttonor a real link — never adivwith a click handler. - Use the one focus ring.
base.cssdraws it withoutline. A control that owns its own ring — input, textarea, select, the hero search pill — setsbox-shadow: var(--gd-border-focus-ring)instead. An invalid control swaps to--gd-border-focus-ring-invalidand nothing else. - Never use a wash as a focus indicator.
--gd-accent-ringis emphasis — the selected parcel's glow — and nothing more. - Never remove the ring locally. If a component needs it somewhere else — on the pill around an input rather than the input — PUT it there first.
- Never let colour be the only carrier. An alert announces its tone in words. The approval stack must read in pure greyscale.
- Only claim
aria-modalif the background really is inert. Build on the native<dialog>and it is. - Give every field a real label. A placeholder may repeat the label; it may never be the label.
--gd-ink-placeholderfails AA on purpose. - Do not re-implement reduced motion per component. It is honoured once, globally.
- Floor inputs at 16px on touch, so iOS does not zoom the page on focus.
Behavior & Anatomy
The floor is a build step, not a policy
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.
It keeps finding them. A token may carry SEVERAL obligations — an ink is a link on one ground and a latched control's label on another — and until the model could state both, --gd-accent-ink declared the easy ground and shipped the hard one broken: 5.72:1 on the dark surface, 3.49:1 on --gd-accent-wash, which meant the saved star and the unsaved star were the same button to look at. That is why the table above is one row per PAIR rather than one row per token.
Why a div with a click handler is not on the table
It answers Enter, silently ignores Space — which every native button activates on — and is unreachable by keyboard without a tabindex nobody remembers to add. Every interactive component here renders a real button or a real link.
One ring, declared once
The ring is the solid accent at 2px in both forms. base.css draws it with outline; a control that owns its own sets box-shadow: var(--gd-border-focus-ring) instead. A keyboard user therefore sees one indicator everywhere. The invalid form changes the hue and never the geometry, for the same reason.
A soft alpha halo is decoration, not an indicator: it comes nowhere near the 3:1 that 1.4.11 asks of focus. --gd-accent-ring is emphasis, never focus. And taking an indicator away is only allowed once a better one is visible in its place, which is why the ring is never simply removed.
Why a placeholder cannot be a label
--gd-ink-placeholder is #9A9EA8 in the light theme — 2.85:1, recorded as failing. It is permitted on exactly two things: a disabled control's label, which WCAG 1.4.3 exempts, and a placeholder that merely repeats a visible label. A placeholder carrying information a user needs is a defect in the form, and the token cannot enforce that, so it is written down instead.
Global, so a new animation is covered the moment it is written
Reduced motion is honoured once rather than per component, because a per-component preference is a preference somebody will forget on the component after next.