Access
Accessibility
The bar is WCAG 2.1 AA in both themes, for any accent. Each requirement says how the system meets it and the test that proves it.
WCAG 2.1 AA in both themes, for any accent the user can pick. Much of the keyboard and ARIA work already exists in the app; the system writes it down so new components inherit it.
Contrast
4.5:1 text, 3:1 UI and focus rings, verified for dark and light and for the accent extremes. Derived accent-text renders wherever the raw accent would fail.
- How the system meets it: --accent-text, --accent-ring and --on-accent are derived from the accent, so all 18 swatches hold AA in both themes. The source's tokens/contrast-report.md lists every pair.
- Test: Node, no DOM: compute every text and ring pair of the report from tokens.json, for all 18 accents in both themes. Fail under 4.5:1 or 3:1.
Color is never alone
Every status has a word or an icon beside the color (Paused, Overdue, "Signed in · Synced"); project colors are identification, not meaning.
- How the system meets it: StatusPill, LiveDot and the priority mark all take their word. Zone and project hues are never read as a status.
- Test: Every .live, .ipill and .pri has its word in its own text or in an accessible name.
Focus
A visible ring on every control, at 3:1 for any accent; focus moves into dialogs and popovers and returns on close.
- How the system meets it: base.css draws a 2 px --accent-ring outline at a 2 px offset on every :focus-visible, 3:1 or better for any accent. The halo stays on inputs as decoration only.
- Test: No rule removes the outline from a focusable selector without a :focus-visible ring. After Escape in the account menu, document.activeElement is the account card.
Keyboard
Escape closes drawers, dialogs, inline edits and arrange mode; arrow keys reorder and move through menus; Home and the list edges are no-ops that still prevent scroll.
- How the system meets it: Every component sheet in the source's HeyGoal Components lists its keyboard path.
- Test: Set each Escape state, dispatch Escape, expect it cleared. ArrowDown on the last menu item keeps the order and is defaultPrevented. With a toast showing Undo, Ctrl+Z restores the snapshot; in an input it does nothing.
Semantics
role=dialog aria-modal, the app behind a dialog is inert, arranged items carry "Projects, 3 of 9. Drag, or use the arrow keys", aria-haspopup=menu on the account card, aria-describedby on the arrange hint.
- How the system meets it: Each component sheet lists what a screen reader says.
- Test: While the door is up, #gate has role=dialog and aria-modal=true and .app is inert. In arrange mode every [data-navkey] and .sec label matches ", N of M.". .me has aria-haspopup=menu and aria-expanded.
Motion
Three-state Motion setting; Reduced stills everything but the deliberate hover lift; System follows the OS flag and says so in Settings.
- How the system meets it: One token mode, [data-motion=reduced], sets 50 movement tokens; the card lift keeps its values.
- Test: With motion set to reduced, html has data-motion=reduced; with system, it follows a stubbed matchMedia.
Touch and pointer
44 px targets; drag by a grip on touch so the drawer still scrolls; 5 px threshold before a drag starts.
- How the system meets it: --control-height is 44 px; the card tools grow to 36 px (--control-height-compact). Icon buttons stay 40 px to the eye and reach 44 px under pointer: coarse (decided 2026-10-12). Menu items drag by the grip on touch.
- Test: CSS text: .btn, .input and .seg read var(--control-height); under (pointer: coarse) .icon-btn::after reaches var(--control-height); the drag handlers keep the 5 px threshold.
Display scale
Fractional widths matter at 135% (Gabriel's screen): test at 100, 125, 135, 150 and 200%.
- How the system meets it: Hairlines are --border-width; the top-bar line is --topbar-line.
- Test: Browser gate, not jsdom: screenshots at 100, 125, 135, 150 and 200%, both themes and the 390 px phone.
Live regions
The running timer, the sync status and toasts announce changes without stealing focus.
- How the system meets it: Two polite regions, hidden from sight. The timer says "Timer paused at 1:24:07" and "Timer running again". The account card says "Synced", "Offline · saved here" or "Sync failed". Never the ticking seconds.
- Test: #toast has role=status and aria-live=polite; [data-elapsed] is inside no live region; the timer and sync regions exist.
Print and PDF
Printable sheets stay readable in grayscale; PDF text is real text, so it can be searched and read aloud.
- How the system meets it: --paper-* colors in both themes; the invoice sheet prints without the shell.
- Test: exports-check.js: the invoice PDF holds its number, total and status as text.
design-system\HeyGoal design system · works offline, open any page in a browser.