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:
cesnimda
2026-11-04 01:13:24 +01:00
parent 1c88cacb4d
commit b6d830f279
14 changed files with 47 additions and 18 deletions
@@ -0,0 +1,13 @@
# Account sections and editor hierarchy — active qualification
4 November 2026. Continuation from published/independently verified `1c88cacb4d4d6137ba82e74e53fa27faadb86c1e`, which qualifies flat application source `c9138af01e6dd7cc407a9b71c921914d8c1299f0` with65 selected tests and36-configuration/216-state browser evidence. Those captures are bound to their original source, not automatically to this successor.
Account changes: remove seven decorative PageSection surfaces from account navigation/identity/details/password, two-factor/session management and mail connection collection. Keep provider objects, recovery-code presentation, every form/control/ref, mounted draft, permission guard and confirmation dialog. The redundant inner Profile heading becomes the paired EN/NB **Identity / Identitet** label; shell Profile h1 remains. Canonical Career Profile and legacy careerOnly behaviour remain separate and unchanged.
Editor hierarchy: only canonical numeric job-detail routes use the existing editor-width token (100%). Keep the shell h1/focus target but render its generic route label/description compactly; recorded role h2 uses the existing page-title type scale. This follows approved A3 role hierarchy and makes more useful desktop space without fetching/duplicating domain data or changing navigation. List/Settings/unrelated route headings retain their existing styles.
PASSED:81 selected account/provider/security/export/catalogue tests/eight suites;16 shell/navigation/layout/theme tests/four suites; full lint and export build. Independent read-only reviews found no actionable findings in the seven surface removals, verifier branches, or scoped editor hierarchy. New Identity label is checked by the updated heading contract and paired translation catalogue; native-language acceptance remains a human qualification.
Initial phone pilots passed seven account states (before final Identity copy) and six editor states including role-title visual hierarchy. Required final runs on the settled export: **36 configurations /252 account scans** (focused details/connections/security, summary, cancelled session confirmation, cancelled data confirmation with fixture403 export, mail connections), plus **36 /216 application scans** to qualify the changed role heading. No production account, actual deletion/revocation/export, provider activation or model is permitted by these fixtures. Skipped admin/processing checks explicitly remain NOT RUN in account-only mode.
Current status: final browser matrices pending. Use A3_ACCOUNT_FLAT=1 with verify-a3-account-admin.mjs, and A3_VISUAL_MATRIX=1 / A3_FLAT_WORKSPACE=1 with verify-a3-applications.mjs. The existing sources/prototypes do not need another concept-selection decision. Keep source/export identities stable during runs; record actual reports, manually inspect lower focused account sections at phone/tablet/desktop, then seal/publish evidence. Do not declare full34-route/57-group, security/release or owner visual acceptance from these scoped checks.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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');
+1 -1
View File
@@ -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 ? (
+2
View File
@@ -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",
+3 -1
View File
@@ -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) => (
+1 -1
View File
@@ -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}
+4 -4
View File
@@ -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}