test(a11y): gate primary workspaces with axe
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 `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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Focused frontend: 2 suites, 6 tests passed.
|
||||||
- Full frontend: 64 suites, 272 tests passed.
|
- Full frontend: 64 suites, 272 tests passed.
|
||||||
- Next production build and TypeScript: passed.
|
- Next production build and TypeScript: passed.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+17
-4
@@ -34,6 +34,7 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.28.5",
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
@@ -74,6 +75,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/@azure/msal-browser": {
|
||||||
"version": "5.17.0",
|
"version": "5.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.0.tgz",
|
||||||
@@ -4259,7 +4273,7 @@
|
|||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.62.1"
|
"playwright": "1.62.1"
|
||||||
@@ -10731,7 +10745,7 @@
|
|||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.62.1"
|
"playwright-core": "1.62.1"
|
||||||
@@ -10750,7 +10764,7 @@
|
|||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -10763,7 +10777,6 @@
|
|||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@babel/preset-react": "^7.28.5",
|
"@babel/preset-react": "^7.28.5",
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return clientId
|
return clientId
|
||||||
? <Box aria-label={actionLabel} aria-busy={working} sx={{ display: "flex", justifyContent: "center", minHeight: 40 }}><div ref={hostRef} /></Box>
|
? <Box role="group" aria-label={actionLabel} aria-busy={working} sx={{ display: "flex", justifyContent: "center", minHeight: 40 }}><div ref={hostRef} /></Box>
|
||||||
: <Button fullWidth variant="outlined" disabled>{actionLabel}</Button>;
|
: <Button fullWidth variant="outlined" disabled>{actionLabel}</Button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -474,15 +474,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
<InputLabel id="job-table-status-label">{t("jobTableStatus")}</InputLabel>
|
||||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
|
<Select labelId="job-table-status-label" value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
|
||||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
<InputLabel id="job-table-company-label">{t("jobTableCompany")}</InputLabel>
|
||||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
|
<Select labelId="job-table-company-label" value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
|
||||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||||
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>Company {companyFilterId}</MenuItem> : null}
|
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>Company {companyFilterId}</MenuItem> : null}
|
||||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||||
@@ -500,8 +500,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
|
|
||||||
{mode === "jobs" ? (
|
{mode === "jobs" ? (
|
||||||
<FormControl fullWidth size="small">
|
<FormControl fullWidth size="small">
|
||||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
<InputLabel id="job-table-readiness-label">{t("jobTableReadiness")}</InputLabel>
|
||||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => changeReadiness(e.target.value as ReadinessFilter)}>
|
<Select labelId="job-table-readiness-label" value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => changeReadiness(e.target.value as ReadinessFilter)}>
|
||||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||||
@@ -552,15 +552,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControl sx={{ width: { xs: "100%", sm: 180 } }} size="small">
|
<FormControl sx={{ width: { xs: "100%", sm: 180 } }} size="small">
|
||||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
<InputLabel id="job-table-status-label">{t("jobTableStatus")}</InputLabel>
|
||||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
|
<Select labelId="job-table-status-label" value={statusFilter} label={t("jobTableStatus")} onChange={(e) => changeStatus(e.target.value)}>
|
||||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl sx={{ width: { xs: "100%", sm: 220 } }} size="small">
|
<FormControl sx={{ width: { xs: "100%", sm: 220 } }} size="small">
|
||||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
<InputLabel id="job-table-company-label">{t("jobTableCompany")}</InputLabel>
|
||||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
|
<Select labelId="job-table-company-label" value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => changeCompany(e.target.value as number | "All")}>
|
||||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||||
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>Company {companyFilterId}</MenuItem> : null}
|
{selectedCompanyIsLoading ? <MenuItem value={companyFilterId}>Company {companyFilterId}</MenuItem> : null}
|
||||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||||
@@ -578,8 +578,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null}
|
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => changeNeedsFollowUp(e.target.checked)} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null}
|
||||||
{mode === "jobs" ? (
|
{mode === "jobs" ? (
|
||||||
<FormControl size="small" sx={{ width: { xs: "100%", sm: 180 } }}>
|
<FormControl size="small" sx={{ width: { xs: "100%", sm: 180 } }}>
|
||||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
<InputLabel id="job-table-readiness-label">{t("jobTableReadiness")}</InputLabel>
|
||||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => changeReadiness(e.target.value as ReadinessFilter)}>
|
<Select labelId="job-table-readiness-label" value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => changeReadiness(e.target.value as ReadinessFilter)}>
|
||||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
|
ListItem,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
@@ -168,38 +169,38 @@ export default function AppShell({
|
|||||||
{rows.map((item) => {
|
{rows.map((item) => {
|
||||||
const selected = item.to === activeTo;
|
const selected = item.to === activeTo;
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<ListItem key={item.to} disablePadding sx={{ mb: 0.5 }}>
|
||||||
key={item.to}
|
<ListItemButton
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onClick={() => onNavigate(item.to)}
|
onClick={() => onNavigate(item.to)}
|
||||||
title={desktopNavCollapsed ? item.label : undefined}
|
title={desktopNavCollapsed ? item.label : undefined}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
mb: 0.5,
|
minHeight: 44,
|
||||||
minHeight: 44,
|
px: desktopNavCollapsed ? 1 : 1.5,
|
||||||
px: desktopNavCollapsed ? 1 : 1.5,
|
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
|
||||||
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
|
border: "1px solid transparent",
|
||||||
border: "1px solid transparent",
|
color: SIDEBAR_TEXT_MUTED,
|
||||||
color: SIDEBAR_TEXT_MUTED,
|
"&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
|
||||||
"&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
|
"&.Mui-selected": {
|
||||||
"&.Mui-selected": {
|
backgroundColor: SIDEBAR_SELECTED_BG,
|
||||||
backgroundColor: SIDEBAR_SELECTED_BG,
|
color: SIDEBAR_SELECTED_TEXT,
|
||||||
color: SIDEBAR_SELECTED_TEXT,
|
},
|
||||||
},
|
"&.Mui-selected:hover": {
|
||||||
"&.Mui-selected:hover": {
|
backgroundColor: SIDEBAR_SELECTED_BG,
|
||||||
backgroundColor: SIDEBAR_SELECTED_BG,
|
},
|
||||||
},
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
||||||
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
{item.badgeCount && item.badgeCount > 0 ? (
|
||||||
{item.badgeCount && item.badgeCount > 0 ? (
|
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
||||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
{item.icon}
|
||||||
{item.icon}
|
</Badge>
|
||||||
</Badge>
|
) : item.icon}
|
||||||
) : item.icon}
|
</ListItemIcon>
|
||||||
</ListItemIcon>
|
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||||
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
</ListItemButton>
|
||||||
</ListItemButton>
|
</ListItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
@@ -319,7 +320,7 @@ export default function AppShell({
|
|||||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||||
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
||||||
>
|
>
|
||||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900 }}>{initials}</Avatar>
|
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : <Box sx={{ width: 42, height: 42 }} />}
|
) : <Box sx={{ width: 42, height: 42 }} />}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -427,7 +428,7 @@ export default function AppShell({
|
|||||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||||
>
|
>
|
||||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
||||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
||||||
@@ -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();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<ButtonGroup size="small" variant="outlined" aria-label={t("applicationLanguage")} sx={{ flex: "0 0 auto", "& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 } }}>
|
<ButtonGroup
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
aria-label={t("applicationLanguage")}
|
||||||
|
sx={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
"& .MuiButton-root": { minWidth: 36, px: 0.6, fontWeight: 800 },
|
||||||
|
...(onDark ? { "& .MuiButton-outlined": { color: "#fff", borderColor: "rgba(255,255,255,0.55)" } } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button>
|
<Button aria-label="English" aria-pressed={language === "en"} variant={language === "en" ? "contained" : "outlined"} onClick={() => setLanguage("en")}>EN</Button>
|
||||||
<Button aria-label="Norsk" aria-pressed={language === "nb"} variant={language === "nb" ? "contained" : "outlined"} onClick={() => setLanguage("nb")}>NO</Button>
|
<Button aria-label="Norsk" aria-pressed={language === "nb"} variant={language === "nb" ? "contained" : "outlined"} onClick={() => setLanguage("nb")}>NO</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type PaletteLike = Record<string, any>;
|
|||||||
|
|
||||||
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
|
// 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.
|
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
|
||||||
const ACCENT = "#6366F1";
|
const ACCENT = "#5B5BD6";
|
||||||
|
|
||||||
function buildPrimary(main: string) {
|
function buildPrimary(main: string) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function LandingPage() {
|
|||||||
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>Jobbjakt</Typography>
|
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>Jobbjakt</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<StackLanguageToggle language={language} setLanguage={setLanguage} />
|
<StackLanguageToggle language={language} setLanguage={setLanguage} onDark />
|
||||||
<GradientButton onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 700 }}>
|
<GradientButton onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 700 }}>
|
||||||
{t("landingSignIn")}
|
{t("landingSignIn")}
|
||||||
</GradientButton>
|
</GradientButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user