Status: active
How to keep the Powernode platform's frontend accessible. These standards are normative.
Status: automated axe enforcement not yet implemented — the WCAG AA standards in this guide are normative and reviewed manually in code review, but the automated tooling described below (axe-core pre-commit hook, CI axe sweep,
jest-axecomponent tests, and thenpm run a11y:*/theme:a11y-checkscripts) is not wired up today:jest-axe/@axe-core/reactare not installed, no test usestoHaveNoViolations, and no pre-commit or CI step runs axe. Treat the enforcement sections as the target pipeline, not current behavior. Today, accessibility is enforced by manual code review only.
- What this guide covers
- Prerequisites
- Compliance target
- Color contrast
- Focus management
- Semantic HTML and ARIA
- Forms
- Tables
- Keyboard navigation
- Custom components
- Error handling and feedback
- Status announcements
- Skip links
- Reduced motion and high contrast
- Automated testing
- Manual testing checklist
- Enforcement
- Related guides
- Materials previously at
This document establishes the mandatory accessibility standards for the Powernode platform. WCAG 2.1 Level AA compliance is non-negotiable for any UI shipped on master. Today these rules are validated in code review; the pre-commit and CI axe automation described below is the planned enforcement pipeline, not yet wired up (see the status callout at the top of this guide).
The audience is every frontend engineer. The rules apply uniformly to admin panels, end-user surfaces, content management UI, and AI interaction surfaces.
- Read
docs/guides/frontend.md— accessibility builds on the theme + form patterns there @testing-library/jest-domis installed;@axe-core/reactandjest-axeare not yet project dependencies — install them only if you want to run the (planned) automated axe checks described below- A screen reader for manual testing (NVDA on Windows, VoiceOver on macOS, Orca on Linux)
flowchart LR
Dev[Developer commits change]
PreCommit[Pre-commit hook: axe-core on changed components]
CI[CI: full axe-core sweep + jest-axe]
Review[Code review: WCAG AA checklist]
Merge[Merge to develop]
Dev --> PreCommit
PreCommit -->|pass| CI
PreCommit -->|fail| Dev
CI -->|pass| Review
CI -->|fail| Dev
Review -->|approve| Merge
Review -->|request changes| Dev
| Standard | Target |
|---|---|
| WCAG 2.1 | Level AA |
| ARIA | 1.2 |
| Keyboard | All interactive elements operable |
| Screen readers | Full content accessible via NVDA / VoiceOver / Orca |
| Color contrast | 4.5:1 body text, 3:1 large text, 3:1 focus indicators |
| Reduced motion | Respect prefers-reduced-motion |
| Surface | Minimum ratio |
|---|---|
| Body text | 4.5:1 |
| Large text (18pt+ or 14pt+ bold) | 3:1 |
| Interactive element focus states | 3:1 |
| Meaningful graphical elements | 3:1 |
The theme system is designed to meet these ratios automatically. Use theme classes — never raw Tailwind colors:
// CORRECT — theme classes guarantee contrast
const styles = {
primaryText: 'text-theme-primary', // 4.5:1+ on theme-surface
secondaryText: 'text-theme-secondary', // 4.5:1+ on theme-surface
inputBase: 'bg-theme-surface text-theme-primary',
errorText: 'text-theme-danger',
successText: 'text-theme-success',
warningText: 'text-theme-warning',
};
// FORBIDDEN — no contrast guarantee
const forbidden = [
'text-yellow-400 on bg-yellow-100', // poor contrast
'text-gray-400 on bg-white', // below 4.5:1
'text-blue-300 on bg-blue-100', // insufficient
];const AccessibleInput: React.FC<InputProps> = ({ error, ...props }) => (
<input
className={cn(
'w-full px-3 py-2 rounded-md border',
'bg-theme-surface text-theme-primary',
'placeholder:text-theme-muted',
'border-theme-primary focus:border-theme-primary',
'focus:ring-2 focus:ring-theme-primary focus:ring-offset-0',
error && 'border-theme-danger focus:border-theme-danger focus:ring-theme-danger',
'disabled:opacity-50 disabled:bg-theme-background disabled:text-theme-secondary',
)}
{...props}
/>
);Every interactive element MUST display a visible focus indicator of at least 2px with 3:1 contrast against its background:
const focusStyles = {
standard: 'focus:ring-2 focus:ring-theme-primary focus:ring-offset-2 focus:outline-none',
critical: 'focus:ring-2 focus:ring-theme-primary focus:ring-offset-2 focus:outline-none focus:bg-theme-primary/10',
input: 'focus:ring-2 focus:ring-theme-primary focus:border-theme-primary focus:ring-offset-0',
button: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-theme-primary',
};Use focus-visible: for buttons and clickable elements so the ring appears only on keyboard focus (not mouse focus, which is visually noisy).
const FocusTrap: React.FC<{ children: React.ReactNode; active: boolean }> = ({ children, active }) => {
const trapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!active) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusables = trapRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (!focusables?.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [active]);
return <div ref={trapRef}>{children}</div>;
};When a modal closes, focus must return to the element that opened it. Implement this with the focus-trap pattern above plus capturing the trigger element on open and calling .focus() on it after close. (Note: the shared Modal at frontend/src/shared/components/ui/Modal.tsx does not currently expose a returnFocusTo/focus-restore prop — the snippet below is illustrative, not a reference to an existing prop.)
Use exactly one <h1> per page. Subsections use <h2>, sub-subsections <h3>, etc. Never skip levels (h2 → h4 is a bug).
<main>
<h1>Admin Settings</h1>
<section aria-labelledby="rate-limit-heading">
<h2 id="rate-limit-heading">Rate Limiting</h2>
<div role="group" aria-labelledby="emergency-controls">
<h3 id="emergency-controls">Emergency Controls</h3>
{/* ... */}
</div>
</section>
</main>Section + aria-labelledby is required for any region of related content.
Every page must use HTML5 landmarks: <main>, <nav>, <aside>, <header>, <footer>. Custom landmarks via role="search", role="status", etc.
Use native HTML elements when possible (<button>, <a href>, <input>). ARIA is for filling gaps the native HTML can't cover — never as a substitute for proper semantic markup.
Every form input MUST have an associated label, via htmlFor matching the input's id:
<form>
<label htmlFor="disable-duration" className="block text-sm font-medium text-theme-primary mb-1">
Disable Duration (minutes)
<span className="text-theme-danger ml-1" aria-label="required">*</span>
</label>
<input
id="disable-duration"
type="number" min="1" max="480" required
aria-describedby="disable-help"
className="..."
/>
<p id="disable-help" className="mt-1 text-sm text-theme-secondary">
Temporarily disable rate limiting for maintenance (1-480 minutes)
</p>
<fieldset className="border border-theme-primary rounded-lg p-4">
<legend className="px-2 text-sm font-medium text-theme-primary">
Rate Limit Categories
</legend>
{/* checkboxes / radios live inside the fieldset */}
</fieldset>
</form>The shared useForm hook (see frontend guide) wires id, aria-describedby, and aria-invalid automatically when you spread form.fieldProps(name).
- Add
requiredattribute to the input - Visually mark with
*ANDaria-label="required" - Validate client-side AND server-side; display inline errors
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
className={cn(
'w-full px-3 py-2 border rounded-md',
errors.email
? 'border-theme-danger focus:ring-theme-danger'
: 'border-theme-primary focus:ring-theme-primary',
)}
/>
{errors.email && (
<p id="email-error" role="alert" className="mt-1 text-sm text-theme-danger">
{errors.email}
</p>
)}Data tables MUST use <table>, <caption>, <thead> + <th scope="col">, and <tbody>. Action columns must have aria-label on icon-only buttons:
<table className="w-full" aria-labelledby="violations-heading">
<caption id="violations-heading" className="text-lg font-medium mb-4">
Recent Rate Limiting Violations
</caption>
<thead>
<tr>
<th scope="col" className="text-left px-4 py-2">Endpoint</th>
<th scope="col" className="text-left px-4 py-2">Identifier</th>
<th scope="col" className="text-left px-4 py-2">Count/Limit</th>
<th scope="col" className="text-left px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
{violations.map(v => (
<tr key={v.id}>
<td className="px-4 py-2">{v.endpoint}</td>
<td className="px-4 py-2">{v.identifier}</td>
<td className="px-4 py-2">{v.count}/{v.limit}</td>
<td className="px-4 py-2">
<button onClick={() => clearLimits(v.identifier)} aria-label={`Clear limits for ${v.identifier}`}>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</button>
</td>
</tr>
))}
</tbody>
</table>Decorative icons inside buttons need aria-hidden="true". The button's accessible name comes from aria-label.
Every interactive element MUST be operable via keyboard:
| Key | Expected behavior |
|---|---|
Tab / Shift+Tab |
Move focus forward / backward |
Enter / Space |
Activate buttons, submit forms |
Escape |
Close modals, dismiss overlays, cancel pending actions |
Arrow keys |
Navigate within custom components (menus, dropdowns, tab lists) |
Home / End |
Jump to first / last in lists |
Tab order follows visual layout. Avoid manual tabIndex values except tabIndex={-1} (programmatically focusable but not in tab order) and tabIndex={0} (in tab order). Never use positive tabIndex values — they break the natural order and confuse screen reader users.
Provide a "Skip to main content" link as the first focusable element on every page:
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50
bg-theme-primary text-theme-inverse px-4 py-2 rounded"
>
Skip to main content
</a>
<main id="main-content">{/* page */}</main>The sr-only class hides the link until it receives focus.
When you build a custom dropdown, tab list, autocomplete, or combobox, you MUST implement keyboard support and ARIA roles per the WAI-ARIA Authoring Practices.
const AccessibleDropdown: React.FC<DropdownProps> = ({ options, onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
if (activeIndex >= 0) {
onSelect(options[activeIndex]);
setIsOpen(false);
} else {
setIsOpen(prev => !prev);
}
break;
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => (i + 1) % options.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => (i <= 0 ? options.length - 1 : i - 1));
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(options.length - 1);
break;
}
};
return (
<div className="relative" onKeyDown={handleKeyDown}
role="combobox" aria-expanded={isOpen} aria-haspopup="listbox">
{/* ... */}
</div>
);
};Error summaries at the top of a form trigger an aria-live="assertive" announcement; inline field errors use role="alert":
{errors.length > 0 && (
<div role="alert" aria-live="assertive"
className="mb-6 p-4 bg-theme-danger-background border border-theme-danger rounded-lg">
<h3 className="text-lg font-medium text-theme-danger mb-2">
Please correct the following errors:
</h3>
<ul className="list-disc list-inside space-y-1">
{errors.map((err, i) => (
<li key={i} className="text-theme-danger">{err}</li>
))}
</ul>
</div>
)}Use aria-live regions to announce status changes that aren't focus-related (background save success, async operation complete, etc.):
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage && `Status: ${statusMessage}`}
</div>aria-live="polite"— waits until current speech is done; use for non-critical updatesaria-live="assertive"— interrupts current speech; use only for errors and time-critical alerts
In addition to "Skip to main content," provide:
- Skip past navigation
- Skip to footer / utility links
- Skip past complex repeating regions (e.g., long table headers)
// Illustrative — there is no shared `useMediaQuery` hook in the frontend today;
// use window.matchMedia directly or add a hook before copying this verbatim.
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const transitionClasses = reducedMotion ? '' : 'transition-all duration-200';Disable non-essential animations (page transitions, parallax, auto-play) when the user prefers reduced motion. Loading spinners are essential — keep them, but cap to 1Hz pulse.
The theme provider exposes a high-contrast variant. The user's OS-level preference flows into the theme automatically; you don't need to override.
Status: not yet implemented.
jest-axeis not a project dependency yet — install it first (npm i -D jest-axe) before the example below will run. No current test usestoHaveNoViolations.
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('SettingsForm', () => {
it('has no a11y violations', async () => {
const { container } = render(<SettingsForm />);
expect(await axe(container)).toHaveNoViolations();
});
it('inputs have labels', () => {
const { getByLabelText } = render(<SettingsForm />);
expect(getByLabelText(/disable duration/i)).toBeInTheDocument();
});
it('errors announce to screen readers', () => {
const { getByRole } = render(<SettingsForm initialErrors={['Required']} />);
expect(getByRole('alert')).toHaveAttribute('aria-live', 'assertive');
});
});Status: not yet implemented. Planned: a pre-commit script that runs axe-core on changed components and fails the commit on violations. No such hook exists today (
scripts/pre-commit-quality-check.shhas no axe step).
Status: not yet implemented. The scripts below are the planned monitoring surface; none of
a11y:scorecard,a11y:metrics, ortheme:a11y-checkexist infrontend/package.jsonyet, so running them returns "missing script."
# Generate accessibility scorecard (planned)
npm run a11y:scorecard
# Track over time (planned)
npm run a11y:metrics
# Validate theme contrast compliance (planned)
npm run theme:a11y-checkBefore merging significant UI changes, walk through:
- Every interactive element reachable via Tab
- Logical tab order matches visual layout
- Escape closes modals / dismisses overlays
- Arrow keys navigate custom components
- Enter/Space activates buttons and controls
- Focus indicators visible on every element
- Headings form a logical outline
- Form inputs have labels and descriptions
- Error messages announced
- Status changes announced via
aria-live - Tables have captions and column headers
- Interactive elements have descriptive names
- Text meets 4.5:1
- Large text meets 3:1
- Focus indicators meet 3:1
- Error states remain readable
- Theme switching preserves all ratios
Accessibility enforcement is manual code review today. The pre-commit and CI layers below are the planned (not-yet-wired-up) automation — see the status callout at the top of this guide:
| Layer | Mechanism | Status |
|---|---|---|
| Pre-commit | axe-core runs on changed components; commits fail on violations | Planned (not implemented) |
| CI | Full axe-core sweep on every PR; PRs blocked on regressions | Planned (not implemented) |
| Code review | Manual review of new patterns against this guide | Active |
User testing (real assistive-tech users walking through new flows) runs ad-hoc per major release.
- Frontend — patterns this accessibility guide builds on
- Testing — broader testing strategy
docs/reference/theme-system.md— theme tokens engineered for contrast
This guide consolidates content from these legacy paths (preserved in git history for one release cycle):
docs/platform/ACCESSIBILITY_COMPLIANCE_STANDARDS.md
Last verified: 2026-06-04