diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index dee5fa0e..e2c59c47 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { headers } from 'next/headers';
import { Orbitron, Exo_2 } from 'next/font/google';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { OfflineBanner } from '../components/OfflineBanner';
+import { AxeAccessibility } from '../components/AxeAccessibility';
import { WalletProvider } from '../lib/wallet/WalletProvider';
import { darkModeInitScript } from '../lib/darkMode';
import '../styles/tokens.css';
@@ -44,6 +45,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
+ {/* Dev-only @axe-core/react checker; tree-shaken out of prod bundles. */}
+
{children}
diff --git a/frontend/src/components/AxeAccessibility.tsx b/frontend/src/components/AxeAccessibility.tsx
new file mode 100644
index 00000000..b3151f4e
--- /dev/null
+++ b/frontend/src/components/AxeAccessibility.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import * as React from 'react';
+import { useEffect } from 'react';
+import { reportAccessibility } from '../lib/reportAccessibility';
+
+/**
+ * Renders nothing in the DOM. On mount — and only in a development client
+ * build — it wires up @axe-core/react so axe violations show up in the
+ * DevTools console as you develop.
+ *
+ * Rendered directly inside the root `` (see src/app/layout.tsx) so it is
+ * part of the top-level React tree and shares the app's React/ReactDOM
+ * instances with @axe-core/react. It is fully eliminated from the production
+ * bundle: the NODE_ENV guard inside `reportAccessibility` becomes `false` at
+ * build time and the guarded imports are tree-shaken out.
+ */
+export function AxeAccessibility() {
+ useEffect(() => {
+ void reportAccessibility(React);
+ }, []);
+
+ return null;
+}
+
+export default AxeAccessibility;
\ No newline at end of file
diff --git a/frontend/src/lib/__tests__/reportAccessibility.test.ts b/frontend/src/lib/__tests__/reportAccessibility.test.ts
new file mode 100644
index 00000000..cbee58a1
--- /dev/null
+++ b/frontend/src/lib/__tests__/reportAccessibility.test.ts
@@ -0,0 +1,52 @@
+// The React instance the harness passes through to @axe-core/react.
+const fakeReact = { __marker: true } as unknown as typeof import('react');
+
+jest.mock('@axe-core/react', () => ({ __esModule: true, default: jest.fn() }));
+jest.mock('react-dom', () => ({ __isMockDOM: true }));
+
+describe('reportAccessibility', () => {
+ // Load fresh module state per test so the internal `initialized` singleton
+ // guard resets between cases.
+ const load = () => {
+ jest.resetModules();
+ const mod = require('../reportAccessibility') as typeof import('../reportAccessibility');
+ const axeDefault = (require('@axe-core/react') as { default: jest.Mock }).default;
+ axeDefault.mockClear();
+ return { reportAccessibility: mod.reportAccessibility, axeDefault };
+ };
+
+ it('is a no-op outside a development build (production env)', async () => {
+ const { reportAccessibility, axeDefault } = load();
+ jest.replaceProperty(process.env, 'NODE_ENV', 'production');
+
+ await reportAccessibility(fakeReact);
+
+ expect(axeDefault).not.toHaveBeenCalled();
+ });
+
+ it('initializes @axe-core/react in a development build', async () => {
+ const { reportAccessibility, axeDefault } = load();
+ jest.replaceProperty(process.env, 'NODE_ENV', 'development');
+
+ const config = { rules: { 'color-contrast': { enabled: true } } };
+ await reportAccessibility(fakeReact, config);
+
+ expect(axeDefault).toHaveBeenCalledTimes(1);
+ const [reactArg, domArg, timeout, cfg] = axeDefault.mock.calls[0];
+ expect(reactArg).toBe(fakeReact);
+ expect(domArg.__isMockDOM).toBe(true);
+ expect(timeout).toBe(1000);
+ expect(cfg).toEqual(config);
+ });
+
+ it('only initializes once even when called multiple times', async () => {
+ const { reportAccessibility, axeDefault } = load();
+ jest.replaceProperty(process.env, 'NODE_ENV', 'development');
+
+ await reportAccessibility(fakeReact);
+ await reportAccessibility(fakeReact);
+ await reportAccessibility(fakeReact);
+
+ expect(axeDefault).toHaveBeenCalledTimes(1);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/reportAccessibility.ts b/frontend/src/lib/reportAccessibility.ts
new file mode 100644
index 00000000..4ac209b7
--- /dev/null
+++ b/frontend/src/lib/reportAccessibility.ts
@@ -0,0 +1,43 @@
+/**
+ * Dev-only accessibility harness for @axe-core/react.
+ *
+ * @axe-core/react monkey-patches ReactDOM so it re-scans the committed DOM
+ * after each render and logs any axe violations to the DevTools console. It
+ * catches issues locally during development before they ever reach CI's
+ * `accessibility.yml`, which runs the heavyweight axe/Lighthouse/pa11y audits.
+ *
+ * Everything here is a no-op under `next build`: Next.js statically replaces
+ * `process.env.NODE_ENV === 'development'` with `false` at build time, so the
+ * guarded branch — including the dynamic `@axe-core/react` and `react-dom`
+ * imports — is dead-code-eliminated and tree-shaken out of the production
+ * bundle. Only the unguarded harness (a handful of bytes) remains.
+ */
+
+// Ensure we only ever initialize axe once, so multiple client re-mounts
+// (Fast Refresh, route transitions) don't patch ReactDOM repeatedly.
+let initialized = false;
+
+/**
+ * Initialize @axe-core/react in a development client build.
+ *
+ * Must be called from a client component that shares the app's React/ReactDOM
+ * instances. `config` is forwarded to axe and lets callers restrict rules; the
+ * default runs axe's standard WCAG rule set.
+ */
+export async function reportAccessibility(
+ ReactModule: typeof import('react'),
+ config?: Record
+): Promise {
+ if (process.env.NODE_ENV !== 'development') return;
+ if (typeof window === 'undefined') return;
+ if (initialized) return;
+
+ initialized = true;
+
+ const axe = await import('@axe-core/react');
+ const ReactDOM = await import('react-dom');
+
+ // (ReactModule, ReactDOM, timeoutMs, config) — timeout is how long axe waits
+ // after a render before scanning, to avoid throttling during bursty updates.
+ axe.default(ReactModule, ReactDOM, 1000, config);
+}
\ No newline at end of file