diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md
index 6625f00..2554292 100644
--- a/docs/work-programmes/master-progress.md
+++ b/docs/work-programmes/master-progress.md
@@ -52,6 +52,7 @@ Updated: 2026-08-30
- Added a minimal public `/ready` dependency probe alongside the existing `/health` liveness probe. Nginx exposes both, deployment validation now checks the frontend, API liveness, database readiness, and public auth configuration separately, while detailed dependency metadata remains restricted to Admin/System.
- Hardened deployment replacement semantics: backend/frontend images receive exact commit tags, the prior running core images are retained as the rollback release, and any post-replacement failure automatically restores both previous services while keeping the deployment result failed. Removed the non-actionable blanket `compose pull || true` suppression.
- Completed the next lifecycle-controller boundary by moving status changes and deterministic email status suggestions out of the core controller. The applied-date invariant and cleared-date audit event now live in `JobLifecycleEvents`, so full edits and status-only updates cannot drift.
+- Added axe-powered WCAG A/AA browser gates for public entry points and the Dashboard, Jobs, Kanban, Career, Settings, and Admin/System workspaces. Fixed the shared violations they exposed: primary-action contrast, dark-surface language-toggle contrast, account-avatar contrast, sidebar list semantics, job-filter accessible names, and the Google sign-in wrapper role.
- Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry.
- Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path.
- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry.
@@ -100,6 +101,7 @@ Updated: 2026-08-30
- Health/readiness split: Release build passed with 0 warnings/errors, the complete backend suite passed 736/736, and the focused Playwright probe passed 1/1 against a real disposable SQLite-backed API process.
- Deployment rollback configuration: Docker Compose configuration validation passed with non-secret fixture values. Runtime rollback rehearsal remains pending because the local Linux Docker daemon is offline.
- Status lifecycle extraction: Release build passed with 0 warnings/errors and the complete backend suite passed 736/736, including applied-date preservation, status suggestions, and cross-tenant not-found behavior.
+- Accessibility package: public axe flow passed 1/1 and authenticated axe flow passed 1/1 across six workspaces; ESLint passed with zero warnings; all 60 frontend suites and 260/260 tests passed; optimized Next build and integrated TypeScript passed; npm audit reported zero vulnerabilities after adding `@axe-core/playwright`.
- Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed.
diff --git a/job-tracker-ui/e2e/accessibility.spec.ts b/job-tracker-ui/e2e/accessibility.spec.ts
new file mode 100644
index 0000000..8a86822
--- /dev/null
+++ b/job-tracker-ui/e2e/accessibility.spec.ts
@@ -0,0 +1,40 @@
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test, type Page } from "@playwright/test";
+
+async function expectAccessible(page: Page, label: string) {
+ const results = await new AxeBuilder({ page })
+ .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
+ .analyze();
+
+ expect(results.violations, `${label}: ${JSON.stringify(results.violations, null, 2)}`).toEqual([]);
+}
+
+async function login(page: Page) {
+ await page.goto("/login");
+ await page.getByLabel("Email").fill("e2e@example.test");
+ await page.getByLabel("Current password").fill("E2ePassword123!");
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
+ await expect(page).toHaveURL(/\/dashboard$/);
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
+});
+
+test("public entry points meet automated WCAG A and AA checks", async ({ page }) => {
+ for (const route of ["/", "/login"]) {
+ await page.goto(route);
+ await expect(page.locator("body")).toBeVisible();
+ await expectAccessible(page, route);
+ }
+});
+
+test("primary authenticated workspaces meet automated WCAG A and AA checks", async ({ page }) => {
+ await login(page);
+
+ for (const route of ["/dashboard", "/jobs", "/kanban", "/career", "/settings", "/admin/system"]) {
+ await page.goto(route);
+ await expect(page.locator("main")).toBeVisible();
+ await expectAccessible(page, route);
+ }
+});
diff --git a/job-tracker-ui/package-lock.json b/job-tracker-ui/package-lock.json
index 315dc48..3b1bbd3 100644
--- a/job-tracker-ui/package-lock.json
+++ b/job-tracker-ui/package-lock.json
@@ -34,6 +34,7 @@
"web-vitals": "^2.1.4"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
@@ -74,6 +75,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/@axe-core/playwright": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.13.0"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
"node_modules/@azure/msal-browser": {
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz",
@@ -4259,7 +4273,7 @@
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
@@ -10731,7 +10745,7 @@
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
@@ -10750,7 +10764,7 @@
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -10763,7 +10777,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json
index 1a0464e..7aba7a7 100644
--- a/job-tracker-ui/package.json
+++ b/job-tracker-ui/package.json
@@ -51,6 +51,7 @@
]
},
"devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx
index 7f216d7..8a06596 100644
--- a/job-tracker-ui/src/components/GoogleAuthCard.tsx
+++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx
@@ -192,7 +192,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
}
return clientId
- ?
+ ?
: ;
}
diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx
index 59415be..f992bdb 100644
--- a/job-tracker-ui/src/components/JobTable.tsx
+++ b/job-tracker-ui/src/components/JobTable.tsx
@@ -474,15 +474,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
- {t("jobTableStatus")}
-
- {t("jobTableCompany")}
- changeCompany(e.target.value as number | "All")}>
+ {t("jobTableCompany")}
+ changeCompany(e.target.value as number | "All")}>
{selectedCompanyIsLoading ? : null}
{companies.map((c) => )}
@@ -500,8 +500,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{mode === "jobs" ? (
- {t("jobTableReadiness")}
- changeReadiness(e.target.value as ReadinessFilter)}>
+ {t("jobTableReadiness")}
+ changeReadiness(e.target.value as ReadinessFilter)}>
@@ -552,15 +552,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
/>
- {t("jobTableStatus")}
- changeStatus(e.target.value)}>
+ {t("jobTableStatus")}
+ changeStatus(e.target.value)}>
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => )}
- {t("jobTableCompany")}
- changeCompany(e.target.value as number | "All")}>
+ {t("jobTableCompany")}
+ changeCompany(e.target.value as number | "All")}>
{selectedCompanyIsLoading ? : null}
{companies.map((c) => )}
@@ -578,8 +578,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{mode === "jobs" ? changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null}
{mode === "jobs" ? (
- {t("jobTableReadiness")}
- changeReadiness(e.target.value as ReadinessFilter)}>
+ {t("jobTableReadiness")}
+ changeReadiness(e.target.value as ReadinessFilter)}>
diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx
index 9c3af57..e7a6684 100644
--- a/job-tracker-ui/src/layout/AppShell.tsx
+++ b/job-tracker-ui/src/layout/AppShell.tsx
@@ -13,6 +13,7 @@ import {
Drawer,
IconButton,
List,
+ ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
@@ -168,38 +169,38 @@ export default function AppShell({
{rows.map((item) => {
const selected = item.to === activeTo;
return (
- onNavigate(item.to)}
- title={desktopNavCollapsed ? item.label : undefined}
- sx={{
- borderRadius: 2,
- mb: 0.5,
- minHeight: 44,
- px: desktopNavCollapsed ? 1 : 1.5,
- justifyContent: desktopNavCollapsed ? "center" : "flex-start",
- border: "1px solid transparent",
- color: SIDEBAR_TEXT_MUTED,
- "&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
- "&.Mui-selected": {
- backgroundColor: SIDEBAR_SELECTED_BG,
- color: SIDEBAR_SELECTED_TEXT,
- },
- "&.Mui-selected:hover": {
- backgroundColor: SIDEBAR_SELECTED_BG,
- },
- }}
- >
-
- {item.badgeCount && item.badgeCount > 0 ? (
- 99 ? "99+" : item.badgeCount}>
- {item.icon}
-
- ) : item.icon}
-
- {!desktopNavCollapsed ? : null}
-
+
+ onNavigate(item.to)}
+ title={desktopNavCollapsed ? item.label : undefined}
+ sx={{
+ borderRadius: 2,
+ minHeight: 44,
+ px: desktopNavCollapsed ? 1 : 1.5,
+ justifyContent: desktopNavCollapsed ? "center" : "flex-start",
+ border: "1px solid transparent",
+ color: SIDEBAR_TEXT_MUTED,
+ "&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
+ "&.Mui-selected": {
+ backgroundColor: SIDEBAR_SELECTED_BG,
+ color: SIDEBAR_SELECTED_TEXT,
+ },
+ "&.Mui-selected:hover": {
+ backgroundColor: SIDEBAR_SELECTED_BG,
+ },
+ }}
+ >
+
+ {item.badgeCount && item.badgeCount > 0 ? (
+ 99 ? "99+" : item.badgeCount}>
+ {item.icon}
+
+ ) : item.icon}
+
+ {!desktopNavCollapsed ? : null}
+
+
);
})}
@@ -319,7 +320,7 @@ export default function AppShell({
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
>
- {initials}
+ {initials}
) : }
@@ -427,7 +428,7 @@ export default function AppShell({
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
>
- {initials}
+ {initials}
@@ -556,10 +557,19 @@ export default function AppShell({
);
}
-export function StackLanguageToggle({ language, setLanguage }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void }) {
+export function StackLanguageToggle({ language, setLanguage, onDark = false }: { language: "en" | "nb"; setLanguage: (language: "en" | "nb") => void; onDark?: boolean }) {
const { t } = useI18n();
return (
-
+
diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts
index e7d16b5..cfbf92d 100644
--- a/job-tracker-ui/src/theme.ts
+++ b/job-tracker-ui/src/theme.ts
@@ -4,7 +4,7 @@ type PaletteLike = Record;
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
-const ACCENT = "#6366F1";
+const ACCENT = "#5B5BD6";
function buildPrimary(main: string) {
return {
diff --git a/job-tracker-ui/src/views/LandingPage.tsx b/job-tracker-ui/src/views/LandingPage.tsx
index e8bd173..b90ed9a 100644
--- a/job-tracker-ui/src/views/LandingPage.tsx
+++ b/job-tracker-ui/src/views/LandingPage.tsx
@@ -99,7 +99,7 @@ export default function LandingPage() {
Jobbjakt
-
+
{t("landingSignIn")}