import * as React from 'react'; import type { AxeResults, ElementContext, RunOptions } from 'axe-core'; import { useParam, useRoute } from '~/router'; /** * The showcase's own axe run — the half of `@storybook/addon-a11y` that * `test/a11y.test.tsx` cannot replace. * * The Vitest suite is the CI gate and it stays exactly as it is. What it cannot * do is `color-contrast`: jsdom has no layout engine and no computed colours, so * that rule is disabled there and can only ever report "incomplete". Here there * is a real browser, so it is the *first* thing this turns on. * * Two things are load-bearing: * * 1. **axe-core is imported lazily and never at module scope.** It is ~600 kB — * two thirds of the whole site — and it must not enter the main chunk, must * not be fetched when the panel is off, and must not exist at all in a built * site. `import.meta.env.DEV` gates the call, so Rollup drops the dynamic * import from a production `showcase:build` entirely. * 2. **The audit is keyed on the route, not on a timer.** Every page here is one * document to the browser, so nothing else would ever re-run it. * * Deliberately *unlike* `figma/use-figma.ts`: there is no ref-counted * subscription. That file counts subscribers to start and stop a 20-second * polling interval, and there is no interval here — the whole cost is one lazy * import and one run per navigation. A ref-count would be the shape without the * reason for it. */ /** axe's own severity ladder, most severe first — the order the panel groups by. */ export const IMPACTS = ['critical', 'serious', 'moderate', 'minor'] as const; export type Impact = (typeof IMPACTS)[number]; export interface A11yNode { /** CSS selector axe reports for the offending element. */ target: string; /** Why *this* element failed, as opposed to why the rule exists. */ summary: string; } export interface A11yViolation { /** Rule id — `color-contrast`, `nested-interactive`, … */ id: string; impact: Impact; help: string; helpUrl: string; nodes: A11yNode[]; } export interface A11yState { status: 'idle' | 'running' | 'ready' | 'error'; violations: A11yViolation[]; error?: string; /** Where the last result came from, so a stale panel is visibly stale. */ path?: string; } const IDLE: A11yState = { status: 'idle', violations: [] }; let state: A11yState = IDLE; const listeners = new Set<() => void>(); function emit(next: A11yState) { state = next; for (const listener of listeners) listener(); } function subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; } const getSnapshot = () => state; /** * The one call the panel makes, named structurally. * * Narrower than `typeof import('axe-core')` and deliberately so: the whole * surface used here is `run`, and the type import above is erased at build time, * so nothing about axe reaches the bundle except through the dynamic import * below. */ type Axe = { run(context: ElementContext, options: RunOptions): Promise }; /* The module handle, kept across runs — loading 600 kB once per navigation would make the panel cost more than the page it is auditing. */ let loading: Promise | null = null; /* * Two separate things are going on in this function. * * **The `import.meta.env.DEV` guard is what removes axe from a build, and it * has to be here rather than only at the call sites.** Gating the *effect* is * not enough: `loadAxe` is reachable from an exported `runAudit`, so the * bundler has to keep the dynamic import alive and emits the whole 579 kB as * its own chunk — never fetched, but sitting in `showcase/dist` waiting to be * deployed, and re-triggering the chunk-size warning Phase 2 cleared. With the * guard the condition folds to a constant, the branch below becomes * unreachable, and the chunk is not emitted at all. Verified against the built * asset list, which is the only way to know. * * **The `?? mod` is the interop, not defensive padding.** axe-core is a UMD * CommonJS bundle with no `exports` map and no `module` field, so Vite * pre-bundles it and hands back an ESM wrapper whose `default` is the axe * object. TypeScript, reading `export = axe`, types the module as the namespace * itself and knows nothing about that wrapper. Both descriptions are right * about their own layer; taking whichever is actually there reconciles them * without asserting the other one away. */ function loadAxe(): Promise { if (!import.meta.env.DEV) { return Promise.reject(new Error('axe-core is not bundled outside development')); } loading ??= import('axe-core').then( (mod) => (mod as unknown as { default?: Axe }).default ?? mod ); return loading; } /** * Only `violations`, and only against `
`. * * Scoping to main is what keeps the report about the *component being * documented* rather than about the site's own chrome — and `
` is itself * a landmark, so `region` (which `test/a11y.test.tsx` has to disable) is * satisfiable here and stays on. */ /* Not `as const`: axe types `resultTypes` as a mutable array, so a readonly tuple picks the callback overload of `run` instead and the call stops returning a promise. */ const OPTIONS: RunOptions = { resultTypes: ['violations'] }; /** * Runs are serialised through one chain, and the store owns it. * * `axe.run()` rejects outright — *"Axe is already running"* — if a second call * starts before the first resolves, and two calls is the normal case here, not * an edge one: the panel and the toolbar's badge both read this store, so both * mount the same route effect and both ask for the same audit on the same tick. * The token then supersedes whichever was queued behind a newer request, so a * duplicate costs nothing and a navigation mid-run simply waits its turn rather * than colliding. * * This is the ref-count in `figma/use-figma.ts` arriving at the same place from * the other direction: there the store owns a timer because the work is * periodic, here it owns a queue because the work is exclusive. Either way the * work cannot live in the hook, because the hook runs once per consumer. */ let chain: Promise = Promise.resolve(); let token = 0; /** How long `
` must hold still before the audit runs — see `useA11y`. */ const SETTLE = 400; export function runAudit(path: string): Promise { const mine = ++token; emit({ ...state, status: 'running' }); chain = chain.then(() => execute(path, mine)); return chain; } async function execute(path: string, mine: number) { /* Superseded while queued — a second consumer asked for the same audit, or the route changed again before this one reached the front. */ if (mine !== token) return; try { const axe = await loadAxe(); const context = document.querySelector('main') ?? document; const results = await axe.run(context, OPTIONS); if (mine !== token) return; emit({ status: 'ready', path, violations: results.violations.map((violation) => ({ id: violation.id, /* axe types `impact` as nullable and adds `serious`/`critical` only at runtime; anything unrecognised is treated as the mildest rather than dropped, so a rule can never vanish from the panel silently. */ impact: (IMPACTS as readonly string[]).includes(violation.impact ?? '') ? (violation.impact as Impact) : 'minor', help: violation.help, helpUrl: violation.helpUrl, nodes: violation.nodes.map((node) => ({ target: node.target.join(' '), summary: node.failureSummary ?? '', })), })), }); } catch (error) { if (mine !== token) return; emit({ status: 'error', violations: [], error: String(error) }); } } /** * `?a11y=on` in the hash — the same mechanism as every other global toggle, so * a page with the audit open is a link someone else can open. * * There is no `off`-by-default subtlety to it: unlike the Figma bridge, which * is useful in development and inert in a build, axe is simply not shipped to a * built site at all. */ export function useA11yMode() { return useParam('a11y', 'off'); } export function useA11y(): A11yState & { enabled: boolean; rerun: () => void } { const [mode] = useA11yMode(); const { path, params } = useRoute(); const enabled = import.meta.env.DEV && mode === 'on'; const current = React.useSyncExternalStore(subscribe, getSnapshot); /* * Theme and language are audit inputs, not decoration. * * Dark mode repaints every colour, so a `color-contrast` result taken in light * describes a page that no longer exists — and the light-mode failures here * all pass in dark, which is exactly the reading you would be misled by. The * observer below does not catch either one: the theme is a class on ``, * outside `
` entirely, and a language switch replaces text nodes, which * is `characterData` rather than `childList`. Both are in the hash, so keying * the effect on them is enough. */ const theme = params.get('theme') ?? 'light'; const lang = params.get('lang') ?? ''; /* * Audit when `
` stops changing, not on a timer after navigation. * * A fixed delay was the first attempt and it is wrong in a way that is easy * to miss: every page but the overview is a lazy chunk, so on a slow tick the * audit measured the Suspense fallback and cheerfully reported one violation * on a `` that was gone a moment later. Waiting *longer* only moves the * race — the Figma panel and the props table arrive later still. * * The observer inverts it: any change restarts the clock, so the run happens * once the page has settled, whenever that is. It also re-audits after a * playground change or a filter, which a route-keyed effect never would. * * Only `childList`, not `attributes` or `characterData` — the animated * Progress demos tick an attribute every 700ms, and observing those would * keep restarting the clock on a page that is otherwise idle. */ React.useEffect(() => { if (!enabled) return; const main = document.querySelector('main'); if (!main) return; let timer: ReturnType; const schedule = () => { clearTimeout(timer); timer = setTimeout(() => void runAudit(path), SETTLE); }; schedule(); const observer = new MutationObserver(schedule); observer.observe(main, { childList: true, subtree: true }); return () => { clearTimeout(timer); observer.disconnect(); }; }, [enabled, path, theme, lang]); const rerun = React.useCallback(() => void runAudit(path), [path]); return { ...(enabled ? current : IDLE), enabled, rerun }; }