diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8d47b1c..a492bb0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -608,6 +608,9 @@ jobs:
- name: Run aiSettings tests
run: node src/utils/aiApi.test.mjs
+ - name: Run accessibility and internationalization checks
+ run: npm run test:a11y && npm run test:i18n
+
- name: Build
run: npm run build
diff --git a/docs/accessibility-audit.md b/docs/accessibility-audit.md
new file mode 100644
index 0000000..026cfaa
--- /dev/null
+++ b/docs/accessibility-audit.md
@@ -0,0 +1,38 @@
+# Accessibility Audit
+
+Assessment date: 16 July 2026. Scope: React dashboard and static project
+website. Target: practical alignment with WCAG 2.2 AA; this is not a formal
+conformance certification.
+
+## Controls added
+
+- A keyboard-visible skip link targets the dashboard's main content.
+- Primary navigation and mobile navigation have accessible names.
+- Decorative navigation icons are hidden from assistive technology.
+- Icon-only close and status actions have accessible labels.
+- Popovers and connection errors expose dialog semantics and names.
+- Scan results and backend connectivity expose polite live status updates.
+- The off-screen mobile navigation is inert while closed.
+- A source-level CI check rejects positive tab order, non-semantic clickable
+ `div`/`span` elements, missing image alternative text, and missing document
+ language.
+
+## Keyboard review
+
+The expected keyboard path is: skip link, mobile menu when present, language
+selector, scan control, primary navigation, then page content. Native buttons,
+links, inputs and selects retain browser focus behavior. Escape handling remains
+available in the scan input. A full screen-reader/browser matrix remains a
+release-quality follow-up rather than a claim made by this audit.
+
+## Known limitations
+
+- Some data visualizations need separate screen-reader summaries as their
+ components evolve.
+- Focus trapping and restoration for every future modal must be checked during
+ component review.
+- Colour contrast should be rechecked whenever theme tokens change.
+- The static website has its own editing surface and needs repeat manual review
+ when that interface changes.
+
+Run `npm run test:a11y` from `frontend/` for the automated source checks.
diff --git a/docs/internationalization.md b/docs/internationalization.md
new file mode 100644
index 0000000..f848083
--- /dev/null
+++ b/docs/internationalization.md
@@ -0,0 +1,22 @@
+# Internationalization
+
+The dashboard uses message catalogs through `I18nContext`. English is the
+fallback language and Spanish demonstrates a second complete catalog for core
+navigation, page titles, scan controls, status messages and theme controls.
+
+The selected locale is stored locally, applied to the document `lang`
+attribute, and used with `Intl.DateTimeFormat` and `Intl.NumberFormat`. An
+unsupported locale falls back to English. Security identifiers, Azure resource
+names, findings and compliance control IDs are data and are never translated.
+
+## Adding a locale
+
+1. Add a catalog to `frontend/src/i18n/messages.js` using exactly the English
+ keys.
+2. Translate meaning rather than word order; retain `{name}` placeholders.
+3. Add the language's self-name to each catalog.
+4. Run `npm run test:i18n`; missing keys fail the catalog test.
+5. Review navigation at narrow and wide widths and verify date/number output.
+
+The website remains English-first. Additional website locales should reuse the
+same terminology but must not duplicate security data or rule definitions.
diff --git a/frontend/package.json b/frontend/package.json
index 631146a..f54699c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,8 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint . --max-warnings=0",
+ "test:i18n": "node src/i18n/messages.test.mjs",
+ "test:a11y": "node scripts/accessibility-check.mjs",
"preview": "vite preview"
},
"dependencies": {
diff --git a/frontend/scripts/accessibility-check.mjs b/frontend/scripts/accessibility-check.mjs
new file mode 100644
index 0000000..35f8150
--- /dev/null
+++ b/frontend/scripts/accessibility-check.mjs
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const root = path.resolve(import.meta.dirname, '..');
+const sourceRoot = path.join(root, 'src');
+
+function filesUnder(directory) {
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const full = path.join(directory, entry.name);
+ return entry.isDirectory() ? filesUnder(full) : [full];
+ });
+}
+
+const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
+assert.match(html, /]+lang="[a-z]{2}"/i, 'frontend HTML must declare a language');
+
+for (const file of filesUnder(sourceRoot).filter((item) => /\.(jsx?|html)$/.test(item))) {
+ const source = fs.readFileSync(file, 'utf8');
+ assert.doesNotMatch(source, /tabIndex=["'{]?[1-9]/, `${file} uses a positive tab order`);
+ assert.doesNotMatch(source, /<(div|span)\b[^>]*\bonClick=/, `${file} uses a non-semantic clickable element`);
+ for (const image of source.matchAll(/]*>/g)) {
+ assert.match(image[0], /\balt=/, `${file} contains an image without alt text`);
+ }
+}
+
+console.log('accessibility static checks passed');
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 206e84f..6f9e3f0 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { DarkModeProvider } from './contexts/DarkModeContext';
+import { I18nProvider } from './contexts/I18nContext';
import { api } from './utils/api';
import Layout from './components/layout/Layout';
import Discovery from './pages/Discovery';
@@ -25,8 +26,9 @@ export default function App() {
return (
Unable to connect
+Unable to connect
Backend API unreachable
Run Azure Scan
+Run Azure Scan
Leave blank to use the subscription configured on the backend.
@@ -172,7 +168,11 @@ function ScanInputPopover({ onConfirm, onCancel }) { // ── Main Header ──────────────────────────────────────────────────────────── export default function Header({ onMenuToggle }) { const { pathname } = useLocation(); - const page = PAGE_TITLES[pathname] || { title: 'OpenShield', subtitle: '' }; + const { locale, locales, setLocale, t, formatDate } = useI18n(); + const pageKey = PAGE_KEYS[pathname]; + const page = pageKey + ? { title: t(`page.${pageKey}.title`), subtitle: t(`page.${pageKey}.subtitle`) } + : { title: 'OpenShield', subtitle: '' }; const [showConnErr, setConnErr] = useState(false); const [scanning, setScanning] = useState(false); @@ -194,13 +194,13 @@ export default function Header({ onMenuToggle }) { const latest = data?.scans?.[0]; const raw = latest?.started_at || latest?.startedAt; if (raw) { - setLastScanAt(new Date(raw).toLocaleString(undefined, { + setLastScanAt(formatDate(raw, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', })); } }).catch(() => {}); - }, [isLive]); + }, [formatDate, isLive]); const closeConnErr = () => setConnErr(false); @@ -250,7 +250,7 @@ export default function Header({ onMenuToggle }) {