style(ui): simplify account sections and prioritise recorded role titles
81 account and 16 layout tests pass. Settled export built; fresh rendered matrices and final visual acceptance remain pending.
This commit is contained in:
@@ -7,13 +7,15 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
||||
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/account-admin/browser');
|
||||
const accountOnly = Boolean(process.env.A3_ACCOUNT_FLAT);
|
||||
const out = path.resolve(import.meta.dirname, accountOnly ? '../../docs/ui-modernisation/visual-correction/evidence/account-flat' : '../../docs/ui-modernisation/evidence/account-admin/browser');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], exportDenials: [], unavailableReads: [], blockedExternal: [] };
|
||||
const browser = await chromium.launch();
|
||||
const processingOnly = Boolean(process.env.A3_PROCESSING_ONLY);
|
||||
assert.ok(!(processingOnly && accountOnly), 'Account and processing qualification are distinct scopes.');
|
||||
try {
|
||||
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
|
||||
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : (accountOnly ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
|
||||
for (const [width, language, mode] of configurations) {
|
||||
console.log(`Checking ${width}/${language}/${mode}`);
|
||||
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
|
||||
@@ -25,7 +27,7 @@ try {
|
||||
const listing = { items: [operation], total: 1, counts: { failed: 1 }, workers: [{ name: 'CvDocumentImports', flagEnabled: false, individualOperationsRecorded: true }] };
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
|
||||
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
|
||||
if (!p.startsWith('/api/')) return route.continue();
|
||||
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
if (req.method() === 'POST' && p === '/api/export/account') {
|
||||
@@ -48,11 +50,11 @@ try {
|
||||
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
|
||||
report.unavailableReads.push(p); return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ detail: 'Controlled offline read unavailable.' }) });
|
||||
});
|
||||
const scan = async state => {
|
||||
const scan = async (state, keepScroll = false) => {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
|
||||
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
|
||||
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
|
||||
if (!keepScroll) { await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); }
|
||||
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
|
||||
};
|
||||
if (!processingOnly) {
|
||||
@@ -61,6 +63,7 @@ try {
|
||||
const nav = page.getByRole('navigation', { name: t('accountWorkspaceNavigation'), exact: true });
|
||||
for (const [id, key] of [['account-details', 'profileAccountSection'], ['account-connections', 'accountWorkspaceConnections'], ['account-security', 'accountWorkspaceSecurity']]) {
|
||||
await nav.getByRole('button', { name: t(key), exact: true }).press('Enter'); await expect(page.locator(`#${id}`)).toBeFocused(); await expect(name).toHaveValue('Unsaved account name');
|
||||
if (accountOnly) await scan(id, true);
|
||||
}
|
||||
await scan('account');
|
||||
await page.getByRole('button', { name: t('sessionsRevokeOthers'), exact: true }).click(); const confirmation = page.getByRole('dialog');
|
||||
@@ -69,10 +72,13 @@ try {
|
||||
await page.getByRole('button', { name: t('accountExportDownload'), exact: true }).click(); await expect(page.getByText(t('accountExportFailed'), { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: t('accountDeleteButton'), exact: true }).click(); const deletion = page.getByRole('dialog'); await expect(deletion).toContainText('DELETE alex@example.test'); await scan('data-confirmation'); await deletion.getByRole('button', { name: t('cancel'), exact: true }).click();
|
||||
await page.goto('http://localhost:4338/settings/connected-accounts'); await expect(page.getByRole('heading', { name: t('connectedAccounts'), exact: true })).toBeVisible(); await scan('connections');
|
||||
if (!accountOnly) {
|
||||
await page.goto('http://localhost:4338/admin/users'); await expect(page.getByRole('button', { name: t('adminUsersRemoveAdmin'), exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: t('adminUsersDelete'), exact: true })).toBeDisabled(); await scan('last-admin');
|
||||
await page.goto('http://localhost:4338/admin/audit'); await expect(page.getByText(t('adminAuditNoEvents'), { exact: true })).toBeVisible(); await scan('audit-empty');
|
||||
await page.goto('http://localhost:4338/admin/system'); await expect(page.getByText(t('adminSystemLoadFailed'), { exact: true })).toBeVisible(); await scan('system-unavailable');
|
||||
}
|
||||
}
|
||||
if (!accountOnly) {
|
||||
await page.goto('http://localhost:4338/admin/processing'); await expect(page.getByRole('button', { name: t('processingViewDetails').replace('{id}', operation.id), exact: true })).toBeVisible();
|
||||
for (const key of ['processingStatus', 'processingTask', 'processingWorkerFilter', 'processingFailureCode']) {
|
||||
const control = page.getByRole('combobox', { name: t(key), exact: true });
|
||||
@@ -84,9 +90,10 @@ try {
|
||||
await page.getByRole('button', { name: t('processingReturnFilters'), exact: true }).click(); await expect(page.getByRole('form', { name: t('processingFilters'), exact: true })).toBeFocused();
|
||||
await page.getByRole('button', { name: t('processingViewDetails').replace('{id}', operation.id), exact: true }).click(); await expect(page.getByRole('heading', { name: t('processingDetails'), exact: true })).toBeFocused();
|
||||
await expect(page.getByRole('link', { name: operation.job.title, exact: true })).toHaveCount(0); await scan('processing-detail');
|
||||
report.journeys.push({ width, language, mode, draftPreservedAcrossKeyboardSections: processingOnly ? 'NOT RUN' : 'PASS', revocationAndDeletionCancelled: processingOnly ? 'NOT RUN' : 'PASS', recentExportDenial: processingOnly ? 'NOT RUN' : 'PASS', lastAdminActionsDisabled: processingOnly ? 'NOT RUN' : 'PASS', processingKeyboardAndOwnerLink: 'PASS', processingFilterLabelsAndDurationGeometry: 'PASS' }); await context.close();
|
||||
}
|
||||
report.journeys.push({ width, language, mode, draftPreservedAcrossKeyboardSections: processingOnly ? 'NOT RUN' : 'PASS', revocationAndDeletionCancelled: processingOnly ? 'NOT RUN' : 'PASS', recentExportDenial: processingOnly ? 'NOT RUN' : 'PASS', lastAdminActionsDisabled: processingOnly || accountOnly ? 'NOT RUN' : 'PASS', processingKeyboardAndOwnerLink: accountOnly ? 'NOT RUN' : 'PASS', processingFilterLabelsAndDurationGeometry: accountOnly ? 'NOT RUN' : 'PASS' }); await context.close();
|
||||
}
|
||||
assert.equal(report.screens.length, configurations.length * (processingOnly ? 1 : 8)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.exportDenials.length, processingOnly ? 0 : configurations.length);
|
||||
assert.equal(report.screens.length, configurations.length * (accountOnly ? 7 : processingOnly ? 1 : 8)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.exportDenials.length, processingOnly ? 0 : configurations.length);
|
||||
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
|
||||
console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures; no mutation requests.`);
|
||||
} finally {
|
||||
|
||||
@@ -99,6 +99,9 @@ try {
|
||||
assert.ok(width > 1000 ? side.x > main.x + main.width - 2 && Math.abs(side.y - main.y) < 2 : side.y >= main.y + main.height - 2);
|
||||
await scan('detail');
|
||||
if (flatWorkspace) {
|
||||
const pageTitleSize = await page.getByRole('heading', { level: 1 }).evaluate(element => parseFloat(getComputedStyle(element).fontSize));
|
||||
const roleTitleSize = await page.getByRole('heading', { name: 'Community nurse', level: 2, exact: true }).evaluate(element => parseFloat(getComputedStyle(element).fontSize));
|
||||
assert.ok(roleTitleSize > pageTitleSize, 'Recorded role title has stronger visual hierarchy than generic route label');
|
||||
await page.getByRole('button', { name: language === 'en' ? 'Job details' : 'Stillingsdetaljer', exact: true }).click();
|
||||
await expect(page.getByText('Recorded source advertisement.', { exact: true })).toBeVisible();
|
||||
await scan('detail-expanded');
|
||||
|
||||
@@ -428,7 +428,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<AppShell
|
||||
pageTitle={pageTitle}
|
||||
pageSubtitle={pageSubtitle}
|
||||
pageLayout={/^\/settings\/?$/.test(path) ? "reading" : path === "/documents" ? "data" : undefined}
|
||||
pageLayout={/^\/settings\/?$/.test(path) ? "reading" : path === "/documents" ? "data" : /^\/jobs\/\d+\/?$/.test(path) ? "editor" : undefined}
|
||||
breadcrumbs={breadcrumbs}
|
||||
pathname={path}
|
||||
nav={nav}
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function EmailProviderConnections() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageSection surface title={t("emailConnectionsTitle")} description={t("emailConnectionsBody")}>
|
||||
<PageSection title={t("emailConnectionsTitle")} description={t("emailConnectionsBody")}>
|
||||
<Stack spacing={2}>
|
||||
<ProviderRow
|
||||
label="Gmail"
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function SessionsSettingsCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={t("sessionsSectionTitle")} surface>
|
||||
<PageSection title={t("sessionsSectionTitle")}>
|
||||
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadSessions}>{t("retry")}</Button>}>{error}</Alert> : null}
|
||||
{!loading && sessions.length === 0 && !error ? (
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function TwoFactorSettingsCard() {
|
||||
const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery";
|
||||
|
||||
return (
|
||||
<PageSection title={t("twoFactorSectionTitle")} surface>
|
||||
<PageSection title={t("twoFactorSectionTitle")}>
|
||||
{statusLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
{statusError ? <Alert severity="error" sx={{ mb: 1.5 }} action={<Button color="inherit" onClick={loadStatus}>{t("retry")}</Button>}>{statusError}</Alert> : null}
|
||||
{status && !statusLoading ? (
|
||||
|
||||
@@ -1651,6 +1651,7 @@ export const translations = {
|
||||
settingsNotificationsSaveFailed: "Notification settings could not be saved.",
|
||||
settingsCheckSystemStatus: "Check system status",
|
||||
profileTitle: "Profile",
|
||||
profileIdentityTitle: "Identity",
|
||||
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
||||
profileLocalAccount: "Local account",
|
||||
profileGoogleSession: "Google session",
|
||||
@@ -4866,6 +4867,7 @@ export const translations = {
|
||||
settingsNotificationsSaveFailed: "Varslingsinnstillingene kunne ikke lagres.",
|
||||
settingsCheckSystemStatus: "Sjekk systemstatus",
|
||||
profileTitle: "Profil",
|
||||
profileIdentityTitle: "Identitet",
|
||||
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
||||
profileLocalAccount: "Lokal konto",
|
||||
profileGoogleSession: "Google-økt",
|
||||
|
||||
@@ -682,7 +682,9 @@ export default function AppShell({
|
||||
>
|
||||
<Box sx={{ mx: "auto", maxWidth: pageLayout ? `var(--app-page-width-${pageLayout})` : 1320, width: "100%", minWidth: 0 }}>
|
||||
<Box aria-hidden sx={{ height: headerHeight ?? { xs: 56, md: 64 } }} />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: pageLayout === "editor" ? 1 : "var(--app-section-gap)", minWidth: 0,
|
||||
...(pageLayout === "editor" ? { "& h1": { typography: "sectionTitle" }, "& header p": { typography: "caption", mt: 0.5 } } : {}),
|
||||
}}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
|
||||
{breadcrumbs.map((c) => (
|
||||
|
||||
@@ -238,7 +238,7 @@ test('Career Profile section headings describe the visible hierarchy without cha
|
||||
test('account headings separate identity from security sections', async () => {
|
||||
renderWith(ProfilePage);
|
||||
await screen.findByText('DD');
|
||||
expect(screen.getByRole('heading', { name: 'Profile', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Identity', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Account', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Change password', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument();
|
||||
|
||||
@@ -358,7 +358,7 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
||||
const { language, t } = useI18n();
|
||||
if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>;
|
||||
return (
|
||||
<Box sx={{ pt: 0.5, pb: 1.5 }}>
|
||||
<Box sx={{ pt: 0.5, pb: 1.5, "& h2": { typography: "pageTitle" } }}>
|
||||
<PageHeader level="h2" title={overview.jobTitle} description={overview.company ?? undefined} actions={<>
|
||||
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEditApplication")}</Button>
|
||||
{overview.jobUrl ? <Button component="a" href={overview.jobUrl} target="_blank" rel="noopener noreferrer" endIcon={<OpenInNewIcon />}>{t("workspaceOpenAdvert")}</Button> : null}
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<PageSection surface title={t("accountWorkspaceTitle")} description={t("accountWorkspaceBody")}>
|
||||
<PageSection title={t("accountWorkspaceTitle")} description={t("accountWorkspaceBody")}>
|
||||
<Box component="nav" aria-label={t("accountWorkspaceNavigation")} sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{([
|
||||
[accountDetails, t("profileAccountSection")],
|
||||
@@ -186,7 +186,7 @@ export default function ProfilePage() {
|
||||
|
||||
{loading ? <Typography role="status" variant="body2" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
|
||||
<PageSection surface title={careerOnly ? "Master career profile" : t("profileTitle")}>
|
||||
<PageSection title={careerOnly ? "Master career profile" : t("profileIdentityTitle")}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap", minWidth: 0, maxWidth: "100%" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, minWidth: 0, maxWidth: "100%" }}>
|
||||
@@ -255,7 +255,7 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
|
||||
<Box id="account-details" ref={accountDetails} role="group" aria-label={t("profileAccountSection")} tabIndex={-1} sx={{ scrollMarginTop: { xs: "9rem", sm: "6rem" } }}>
|
||||
<PageSection surface title={t("profileAccountSection")}>
|
||||
<PageSection title={t("profileAccountSection")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiFormHelperText-root": { overflowWrap: "anywhere" }, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{!careerOnly && !isLocal ? <Alert severity="info" sx={{ gridColumn: "1 / -1" }}>{t("profileReadOnlyInfo")}</Alert> : null}
|
||||
|
||||
@@ -355,7 +355,7 @@ export default function ProfilePage() {
|
||||
|
||||
</Box>
|
||||
<Box id="account-security" ref={accountSecurity} role="group" aria-label={t("accountWorkspaceSecurity")} tabIndex={-1} sx={{ display: "grid", gap: "var(--app-section-gap)", scrollMarginTop: { xs: "9rem", sm: "6rem" } }}>
|
||||
{!careerOnly ? <PageSection surface title={t("profileChangePassword")}>
|
||||
{!careerOnly ? <PageSection title={t("profileChangePassword")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal" } }}>
|
||||
{!careerOnly && !isLocal ? <Typography sx={{ gridColumn: "1 / -1", color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user