style(ui): open career and document surfaces and show single-month activity

This commit is contained in:
cesnimda
2026-11-04 07:16:25 +01:00
parent 79d7bbe0a1
commit b31ce6176b
35 changed files with 375 additions and 47 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
# Career/document surfaces and single-month activity
4 November 2026. Continue from published and independently verified `79d7bbe0a151472c551dfdc73cce628e92207d5a`, runtimea7d72673. Earlier source-bound evidence remains valid within its scope. New application edits are within approved IM10 visual correction.
Career: remove decorative overview status/import/recent-CV section surfaces and action-card frames. Keep14 edit targets, completion indicators, warning/error/progress states and every link. Open the workspace navigation, profile/import container, import suggestion, history collection and structured editor without moving their state/ref/ID/conditional boundaries. Form fields, import comparison objects, controls, save/conflict/history, canonical profile ownership and legacy readers remain. No extraction engine or PDF renderer change.
Documents: move the existing Jump to documents control before the filter stack so phone/keyboard users can reach results immediately. Preserve the same focus-only handler and return action, all filters and URL semantics. Mobile documents become divided articles; desktop table and explicit empty/error states remain. Download/source/permission owners are untouched.
Activity V15: a nonzero single-month fixture produced move-only SVG paths, leaving the graph blank. Add a filled application point and an outlined response point on the existing common scale. Different radii retain both indicators if values coincide. Recorded values, multi-month paths, zero-data state, legend and accessible exact-value table remain unchanged. No generated/interpolated data is introduced.
Native evidence:81 tests/seven Career/history/import/evidence/editor/completeness/dashboard suites plus33 document tests/one disjoint suite PASSED (114 distinct). Two new EN/NB document assertions reproduced the late jump control before it moved; the existing focus/no-request checks pass. The marker regression reproduced zero markers. Its first full-suite attempt encountered a prior test's retained PUT call; compare the new test's before/after write count, preserving the no-new-write guarantee. Retain both failure artifacts. An unsupported test selector option was removed after TypeScript rejected it; final types/full lint pass. No additional backend or physical PDF checks are required for these frontend-only changes.
Rendered preparation: existing calendar, document/mail and Career pilots passed on priora7d72673 export; those are preserved in *-before directories and do not qualify this successor. Existing verifiers have opt-in nine-width output directories and expected-count assertions. Targeted viewport captures show the edited section/message rather than resetting every screenshot to the page top. Career matrix additionally checks that all14 keyboard edit targets are unobscured. A separate activity-marker mode reuses the dashboard fixture while skipping unaffected onboarding/layout-save flows; their published36/72 evidence remains separate.
Pending: settled source commit/build; pilots;36-configuration Career216, document/mail216, calendar108 and activity36 scans/captures; actual inspection, manifests and publication. These are intended counts, not execution claims. Mail uses intercepted synthetic save/confirmed-send/uncertain-outcome only; no real delivery, provider or model. Full34-route/57-group, complete journeys, human/device/native-language, final integrated security/recovery/release and owner visual acceptance remain OPEN.
Rollback reverts these presentation changes and marker addition; no schema/data migration or document revision rollback. Review capacity limitation: the previous review agent reached its usage limit; no new independent review or security acceptance is claimed for this successor.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,74 @@
{
"screens": [
{
"width": 320,
"language": "en",
"mode": "light",
"state": "agenda",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "month",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "linked-application",
"violations": []
}
],
"journeys": [
{
"width": 320,
"language": "en",
"mode": "light",
"agendaMonthSelectionPersistedPreferenceBoundedMonthNavigationLinkedDeadline": "PASS"
}
],
"errors": [],
"unexpectedWrites": [],
"reads": [
{
"width": 320,
"language": "en",
"mode": "light",
"from": "2026-11-01",
"to": "2026-11-30"
},
{
"width": 320,
"language": "en",
"mode": "light",
"from": "2026-12-01",
"to": "2026-12-31"
},
{
"width": 320,
"language": "en",
"mode": "light",
"from": "2026-11-01",
"to": "2026-11-30"
},
{
"width": 320,
"language": "en",
"mode": "light",
"from": "2026-11-01",
"to": "2026-11-30"
}
],
"blockedExternal": [
"https://fonts.googleapis.com",
"https://fonts.googleapis.com"
],
"limits": [
"Controlled API fixtures, not a live backend/provider or reminder delivery.",
"Date-level recorded deadline/follow-up display; no fabricated interview appointments or source timezone reconstruction.",
"No native-device, physical rendering, final security or release qualification."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,99 @@
{
"screens": [
{
"width": 320,
"language": "en",
"mode": "light",
"state": "overview",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "profile",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "import-review",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "library",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "cv-editor",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "letter",
"violations": []
}
],
"journeys": [
{
"width": 320,
"language": "en",
"mode": "light",
"all14KeyboardEditTargets": "PASS",
"canonicalSaveStableIds": "PASS",
"legacyImportComparisonSelectionNoApply": "PASS",
"libraryKeyboardOpen": "PASS",
"cvResponsiveRename": "PASS",
"letterResponsiveManualSave": "PASS"
}
],
"errors": [],
"unexpectedWrites": [],
"mockedWrites": [
{
"width": 320,
"language": "en",
"mode": "light",
"action": "canonicalProjectSave"
},
{
"width": 320,
"language": "en",
"mode": "light",
"action": "variantRename"
},
{
"width": 320,
"language": "en",
"mode": "light",
"action": "manualLetterSave"
}
],
"renderReads": 1,
"unavailableReads": [
"/api/companies",
"/api/companies",
"/api/cv/themes/modern/preview",
"/api/jobapplications/42/cv",
"/api/companies"
],
"blockedExternal": [
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com"
],
"limits": [
"All API and preview responses are controlled fixtures; not backend end-to-end or physical PDF qualification.",
"Legacy import comparison and selection only; no parser, model, import acceptance or source mutation.",
"No native device, screen reader, provider, integrated security, deployment or release acceptance."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,81 @@
{
"screens": [
{
"width": 320,
"language": "en",
"mode": "light",
"state": "documents",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "document-filtered",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "inbox",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "message",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "send-confirmation",
"violations": []
},
{
"width": 320,
"language": "en",
"mode": "light",
"state": "uncertain-send",
"violations": []
}
],
"journeys": [
{
"width": 320,
"language": "en",
"mode": "light",
"documentFilterFocusDownloadSource": "PASS",
"inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry": "PASS"
}
],
"errors": [],
"unexpectedWrites": [],
"mockedWrites": [
{
"width": 320,
"language": "en",
"mode": "light",
"action": "saveDraft"
},
{
"width": 320,
"language": "en",
"mode": "light",
"action": "confirmedSendFixtureUncertain"
}
],
"unavailableReads": [],
"blockedExternal": [
"https://fonts.googleapis.com",
"https://fonts.googleapis.com"
],
"limits": [
"Synthetic metadata/binary/mail/provider fixtures; not live storage, mailbox, OAuth or delivery.",
"Browser covers draft save, explicit confirmation and controlled uncertain outcome only; other recovery/conflict paths remain targeted tests.",
"No real sending, native device, final integrated security or release acceptance."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -7,12 +7,15 @@ import path from 'node:path';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; 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 { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/calendar/browser'); fs.mkdirSync(out, { recursive: true }); const visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const widths = process.env.A3_PILOT ? [320] : visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440];
const expectedJourneys = widths.length * (process.env.A3_PILOT ? 1 : 4);
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/calendar' : '../../docs/ui-modernisation/evidence/calendar/browser'); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], reads: [], blockedExternal: [] }; const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], reads: [], blockedExternal: [] };
const now = new Date(), month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const now = new Date(), month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const browser = await chromium.launch(); const browser = await chromium.launch();
try { try {
for (const width of (process.env.A3_PILOT ? [320] : [320, 390, 768, 1440])) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) { for (const width of widths) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) {
console.log(`Checking ${width}/${language}/${mode}`); console.log(`Checking ${width}/${language}/${mode}`);
const t = (key, params = {}) => translations[language][key].replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)); const t = (key, params = {}) => translations[language][key].replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' }); const page = await context.newPage(); const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' }); const page = await context.newPage();
@@ -39,7 +42,7 @@ try {
const scan = async state => { const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: axe.violations }); assert.deepEqual(axe.violations, []); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: axe.violations }); assert.deepEqual(axe.violations, []);
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true }); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
}; };
await page.goto('http://localhost:4338/reminders'); await page.goto('http://localhost:4338/reminders');
const calendar = page.getByRole('region', { name: t('calendarAgendaTitle'), exact: true }); await calendar.getByRole('link', { name: /Electrical engineer/ }).waitFor(); const calendar = page.getByRole('region', { name: t('calendarAgendaTitle'), exact: true }); await calendar.getByRole('link', { name: /Electrical engineer/ }).waitFor();
@@ -61,6 +64,7 @@ try {
assert.equal((await page.locator('main').innerText()).includes('Invalid Date'), false); await scan('linked-application'); assert.equal((await page.locator('main').innerText()).includes('Invalid Date'), false); await scan('linked-application');
report.journeys.push({ width, language, mode, agendaMonthSelectionPersistedPreferenceBoundedMonthNavigationLinkedDeadline: 'PASS' }); await context.close(); report.journeys.push({ width, language, mode, agendaMonthSelectionPersistedPreferenceBoundedMonthNavigationLinkedDeadline: 'PASS' }); await context.close();
} }
assert.equal(report.journeys.length, expectedJourneys); assert.equal(report.screens.length, expectedJourneys * 3);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); 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} calendar journeys,${report.screens.length} axe scans/captures,zero writes.`); console.log(`PASS:${report.journeys.length} calendar journeys,${report.screens.length} axe scans/captures,zero writes.`);
} finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Controlled API fixtures, not a live backend/provider or reminder delivery.', 'Date-level recorded deadline/follow-up display; no fabricated interview appointments or source timezone reconstruction.', 'No native-device, physical rendering, final security or release qualification.'] }, null, 2)); await browser.close(); } } finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Controlled API fixtures, not a live backend/provider or reminder delivery.', 'Date-level recorded deadline/follow-up display; no fabricated interview appointments or source timezone reconstruction.', 'No native-device, physical rendering, final security or release qualification.'] }, null, 2)); await browser.close(); }
+13 -10
View File
@@ -7,12 +7,13 @@ import fs from 'node:fs';
import path from 'node:path'; 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 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 { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/career/browser'); const visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/career' : '../../docs/ui-modernisation/evidence/career/browser');
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], renderReads: 0, unavailableReads: [], blockedExternal: [] }; const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], renderReads: 0, unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch(); const browser = await chromium.launch();
try { try {
const configurations = process.env.A3_BOUNDARY ? [320, 390, 768, 1000, 1001, 1440].map(width => [width, 'en', 'light']) : 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_BOUNDARY ? [320, 390, 768, 1000, 1001, 1440].map(width => [width, 'en', 'light']) : process.env.A3_PILOT ? [[320, 'en', 'light']] : (visualMatrix ? [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) { for (const [width, language, mode] of configurations) {
console.log(`Checking ${width}/${language}/${mode}`); console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; }; const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
@@ -66,11 +67,13 @@ try {
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]); if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }); report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
}); });
const scan = async state => { const scan = async (state, target) => {
if (target) await target.evaluate(node => node.scrollIntoView({ block: 'center', behavior: 'instant' }));
else await page.evaluate(() => window.scrollTo(0, 0));
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`); 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(); 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`); report.screens.push({ width, language, mode, state, capture: target ? 'targeted viewport' : width >= 768 ? 'full page at top' : 'viewport at top', violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true }); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 && !target });
}; };
const careerSection = async key => { const careerSection = async key => {
if (width >= 600) await page.getByRole('tab', { name: t(key), exact: true }).click(); if (width >= 600) await page.getByRole('tab', { name: t(key), exact: true }).click();
@@ -81,7 +84,7 @@ try {
for (const key of ['contact', 'summary', 'skills', 'interests', 'awards', 'publications', 'organisations', 'references', 'languages', 'jobs', 'education', 'certifications', 'projects', 'otherSections']) { for (const key of ['contact', 'summary', 'skills', 'interests', 'awards', 'publications', 'organisations', 'references', 'languages', 'jobs', 'education', 'certifications', 'projects', 'otherSections']) {
await page.locator(`[data-edit-career-section="${key}"]`).press('Enter'); await page.locator(`[data-edit-career-section="${key}"]`).press('Enter');
await expect.poll(() => page.locator(`#career-section-${key}`).evaluate(node => node.contains(document.activeElement))).toBe(true); await expect.poll(() => page.locator(`#career-section-${key}`).evaluate(node => node.contains(document.activeElement))).toBe(true);
if (process.env.A3_BOUNDARY) await expect.poll(async () => page.evaluate(() => { if (process.env.A3_BOUNDARY || visualMatrix) await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement, rect = active.getBoundingClientRect(); const active = document.activeElement, rect = active.getBoundingClientRect();
const x = Math.min(innerWidth - 1, Math.max(0, rect.x + rect.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rect.y + rect.height / 2)); const x = Math.min(innerWidth - 1, Math.max(0, rect.x + rect.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rect.y + rect.height / 2));
const hit = document.elementFromPoint(x, y); const hit = document.elementFromPoint(x, y);
@@ -91,10 +94,10 @@ try {
} }
await page.locator('[data-edit-career-section="projects"]').click(); await page.locator('[data-edit-career-section="projects"]').click();
const projectName = page.locator('#career-section-projects input').first(); await projectName.fill('Updated community project'); const projectName = page.locator('#career-section-projects input').first(); await projectName.fill('Updated community project');
await page.getByRole('button', { name: t('profileSaveChanges'), exact: true }).click(); await expect.poll(() => career.revision).toBe(8); await expect(projectName).toBeEnabled(); await scan('profile'); await page.getByRole('button', { name: t('profileSaveChanges'), exact: true }).click(); await expect.poll(() => career.revision).toBe(8); await expect(projectName).toBeEnabled(); await scan('profile', projectName);
await careerSection('careerWorkspaceImportTab'); await careerSection('careerWorkspaceImportTab');
const run = page.locator('#career-import-run-41'); await expect(run).toContainText('A2'); await expect(run).toContainText('B1'); const run = page.locator('#career-import-run-41'); await expect(run).toContainText('A2'); await expect(run).toContainText('B1');
await run.getByRole('checkbox').check(); await expect(run.getByRole('checkbox')).toBeChecked(); await scan('import-review'); await run.getByRole('checkbox').check(); await expect(run.getByRole('checkbox')).toBeChecked(); await scan('import-review', run);
assert.equal(report.mockedWrites.filter(row => row.width === width && row.language === language && row.mode === mode).length, 1); assert.equal(report.mockedWrites.filter(row => row.width === width && row.language === language && row.mode === mode).length, 1);
await page.goto('http://localhost:4338/career/builder'); await page.goto('http://localhost:4338/career/builder');
const cvCard = page.getByRole('link', { name: t('cvDashboardOpenNamed').replace('{name}', 'Teacher CV'), exact: true }); const cvCard = page.getByRole('link', { name: t('cvDashboardOpenNamed').replace('{name}', 'Teacher CV'), exact: true });
@@ -107,7 +110,7 @@ try {
assert.ok(width > 1000 ? right.x >= left.x + left.width - 2 : right.y >= left.y + left.height - 2, 'CV pane geometry'); assert.ok(width > 1000 ? right.x >= left.x + left.width - 2 : right.y >= left.y + left.height - 2, 'CV pane geometry');
} }
const cvName = page.getByRole('textbox', { name: t('cvEditorName'), exact: true }); await cvName.fill('Updated teacher CV'); const cvName = page.getByRole('textbox', { name: t('cvEditorName'), exact: true }); await cvName.fill('Updated teacher CV');
await expect.poll(() => variant.name, { timeout: 15000 }).toBe('Updated teacher CV'); await scan('cv-editor'); await expect.poll(() => variant.name, { timeout: 15000 }).toBe('Updated teacher CV'); await scan('cv-editor', cvName);
await page.goto('http://localhost:4338/jobs/42?section=application&sub=letter'); await page.goto('http://localhost:4338/jobs/42?section=application&sub=letter');
const text = page.getByRole('textbox', { name: t('jobDetailsCoverLetter'), exact: true }); await expect(text).toBeEnabled(); const text = page.getByRole('textbox', { name: t('jobDetailsCoverLetter'), exact: true }); await expect(text).toBeEnabled();
await text.fill('Dear team\n\nUpdated teaching experience.'); await text.fill('Dear team\n\nUpdated teaching experience.');
@@ -117,7 +120,7 @@ try {
await expect(letterPreview).toContainText('Updated teaching experience.'); const panes = page.locator('[data-letter-studio-layout] > div'); const left = await panes.nth(0).boundingBox(), right = await panes.nth(1).boundingBox(); await expect(letterPreview).toContainText('Updated teaching experience.'); const panes = page.locator('[data-letter-studio-layout] > div'); const left = await panes.nth(0).boundingBox(), right = await panes.nth(1).boundingBox();
assert.ok(width > 1000 ? right.x >= left.x + left.width - 2 : right.y >= left.y + left.height - 2, 'Letter pane geometry'); assert.ok(width > 1000 ? right.x >= left.x + left.width - 2 : right.y >= left.y + left.height - 2, 'Letter pane geometry');
} }
await page.getByRole('button', { name: t('save'), exact: true }).click(); await expect.poll(() => letter.revision).toBe(8); await expect(text).toBeEnabled(); await scan('letter'); await page.getByRole('button', { name: t('save'), exact: true }).click(); await expect.poll(() => letter.revision).toBe(8); await expect(text).toBeEnabled(); await scan('letter', text);
report.journeys.push({ width, language, mode, all14KeyboardEditTargets: 'PASS', canonicalSaveStableIds: 'PASS', legacyImportComparisonSelectionNoApply: 'PASS', libraryKeyboardOpen: 'PASS', cvResponsiveRename: 'PASS', letterResponsiveManualSave: 'PASS' }); report.journeys.push({ width, language, mode, all14KeyboardEditTargets: 'PASS', canonicalSaveStableIds: 'PASS', legacyImportComparisonSelectionNoApply: 'PASS', libraryKeyboardOpen: 'PASS', cvResponsiveRename: 'PASS', letterResponsiveManualSave: 'PASS' });
await context.close(); await context.close();
} }
+16 -3
View File
@@ -5,11 +5,12 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const visualMatrix = process.env.A3_VISUAL_MATRIX === '1'; const visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const markerOnly = process.env.A3_ACTIVITY_MARKER === '1';
const widths = process.env.A3_PILOT ? [320] : visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]; const widths = process.env.A3_PILOT ? [320] : visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440];
const languages = process.env.A3_PILOT ? ['en'] : ['en', 'nb']; const languages = process.env.A3_PILOT ? ['en'] : ['en', 'nb'];
const modes = process.env.A3_PILOT ? ['light'] : ['light', 'dark']; const modes = process.env.A3_PILOT ? ['light'] : ['light', 'dark'];
const expectedJourneys = widths.length * languages.length * modes.length; const expectedJourneys = widths.length * languages.length * modes.length;
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/dashboard' : '../../docs/ui-modernisation/evidence/dashboard/browser'); const out = path.resolve(import.meta.dirname, markerOnly ? '../../docs/ui-modernisation/visual-correction/evidence/activity-markers' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/dashboard' : '../../docs/ui-modernisation/evidence/dashboard/browser');
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] }; const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] };
const browser = await chromium.launch(); const browser = await chromium.launch();
@@ -68,6 +69,18 @@ try {
const a = document.querySelector('[data-dashboard-widget=agenda]').getBoundingClientRect(), b = document.querySelector('[data-dashboard-widget=funnel]').getBoundingClientRect(); const a = document.querySelector('[data-dashboard-widget=agenda]').getBoundingClientRect(), b = document.querySelector('[data-dashboard-widget=funnel]').getBoundingClientRect();
return Math.abs(a.top - b.top) < 2 && a.left < b.left; return Math.abs(a.top - b.top) < 2 && a.left < b.left;
}); });
if (markerOnly) {
const activity = page.locator('[data-dashboard-widget=activity]');
await page.waitForFunction(() => document.querySelectorAll('[data-dashboard-widget=activity] svg circle').length === 2);
const markers = activity.locator('svg circle');
assert.equal(await markers.nth(0).getAttribute('cy'), '20'); assert.equal(await markers.nth(1).getAttribute('cy'), '140');
await activity.scrollIntoViewIfNeeded();
const panel = activity.getByRole('group'); if (await panel.count()) await panel.evaluate(node => { node.scrollTop = 0; });
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, state: 'single-month-markers', violations: scan.violations }); assert.deepEqual(scan.violations, []);
await page.screenshot({ path: path.join(out, `single-month-${language}-${mode}-${width}.png`) });
report.journeys.push({ width, language, mode, recordedSingleMonthMarkersSharedScale: 'PASS' }); await context.close(); continue;
}
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, violations: scan.violations }); assert.deepEqual(scan.violations, []); report.screens.push({ width, language, mode, violations: scan.violations }); assert.deepEqual(scan.violations, []);
await page.screenshot({ path: path.join(out, `dashboard-${language}-${mode}-${width}.png`), fullPage: true }); await page.screenshot({ path: path.join(out, `dashboard-${language}-${mode}-${width}.png`), fullPage: true });
@@ -100,9 +113,9 @@ try {
} }
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []);
assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
assert.equal(report.mockedWrites.length, expectedJourneys); assert.equal(report.mockedWrites.length, markerOnly ? 0 : expectedJourneys);
assert.equal(report.journeys.length, expectedJourneys); assert.equal(report.journeys.length, expectedJourneys);
assert.equal(report.screens.length, expectedJourneys * 2); assert.equal(report.screens.length, expectedJourneys * (markerOnly ? 1 : 2));
console.log(`PASS:${report.journeys.length} dashboard journeys,${report.screens.length} scoped axe sweeps/captures; controlled API persistence only.`); console.log(`PASS:${report.journeys.length} dashboard journeys,${report.screens.length} scoped axe sweeps/captures; controlled API persistence only.`);
} finally { } finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic API fixtures, not real backend end-to-end','no native device/screen reader/model/provider/security/release qualification'] }, null, 2)); fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic API fixtures, not real backend end-to-end','no native device/screen reader/model/provider/security/release qualification'] }, null, 2));
@@ -7,11 +7,14 @@ import path from 'node:path';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; 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 { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/documents-inbox/browser'); fs.mkdirSync(out, { recursive: true }); const visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const widths = process.env.A3_PILOT ? [320] : visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440];
const expectedJourneys = widths.length * (process.env.A3_PILOT ? 1 : 4);
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/documents-inbox' : '../../docs/ui-modernisation/evidence/documents-inbox/browser'); fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] }; const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch(); const browser = await chromium.launch();
try { try {
for (const width of (process.env.A3_PILOT ? [320] : [320, 390, 768, 1440])) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) { for (const width of widths) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) {
console.log(`Checking ${width}/${language}/${mode}`); console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; }; const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage(); const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage();
@@ -46,10 +49,12 @@ try {
}; };
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]); report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }); if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]); report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
}); });
const scan = async state => { const scan = async (state, target) => {
if (target) await target.evaluate(node => node.scrollIntoView({ block: 'center', behavior: 'instant' }));
else await page.evaluate(() => window.scrollTo(0, 0));
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: axe.violations }); assert.deepEqual(axe.violations, []); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, capture: target ? 'targeted viewport' : width >= 768 ? 'full page at top' : 'viewport at top', violations: axe.violations }); assert.deepEqual(axe.violations, []);
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true }); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 && !target });
}; };
await page.goto('http://localhost:4338/documents'); await page.goto('http://localhost:4338/documents');
const docs = page.getByRole('region', { name: t('documentsYourDocuments'), exact: true }); await docs.getByText(document.name, { exact: true }).waitFor(); await scan('documents'); const docs = page.getByRole('region', { name: t('documentsYourDocuments'), exact: true }); await docs.getByText(document.name, { exact: true }).waitFor(); await scan('documents');
@@ -57,18 +62,18 @@ try {
await docs.getByRole('button', { name: t('documentsReturnFilters'), exact: true }).click(); const search = page.getByRole('textbox', { name: t('documentsSearch'), exact: true }); await expect(search).toBeFocused(); assert.equal(documentReads, reads); await docs.getByRole('button', { name: t('documentsReturnFilters'), exact: true }).click(); const search = page.getByRole('textbox', { name: t('documentsSearch'), exact: true }); await expect(search).toBeFocused(); assert.equal(documentReads, reads);
await search.fill('certificate'); await expect(docs.getByText(cv.name, { exact: true })).toHaveCount(0); await docs.getByText(document.name, { exact: true }).waitFor(); await search.fill('certificate'); await expect(docs.getByText(cv.name, { exact: true })).toHaveCount(0); await docs.getByText(document.name, { exact: true }).waitFor();
const download = page.waitForEvent('download'); await docs.getByRole('button', { name: t('documentsDownloadAttachment'), exact: true }).click(); const file = await download; assert.equal(file.suggestedFilename(), document.name); assert.equal(fs.readFileSync(await file.path(), 'utf8'), 'Controlled certificate fixture'); const download = page.waitForEvent('download'); await docs.getByRole('button', { name: t('documentsDownloadAttachment'), exact: true }).click(); const file = await download; assert.equal(file.suggestedFilename(), document.name); assert.equal(fs.readFileSync(await file.path(), 'utf8'), 'Controlled certificate fixture');
assert.equal(await docs.getByRole('link', { name: t('documentsOpenSource'), exact: true }).getAttribute('href'), document.sourceHref); assert.equal(await docs.locator('time').getAttribute('datetime'), document.recordedAt); await scan('document-filtered'); assert.equal(await docs.getByRole('link', { name: t('documentsOpenSource'), exact: true }).getAttribute('href'), document.sourceHref); assert.equal(await docs.locator('time').getAttribute('datetime'), document.recordedAt); await scan('document-filtered', docs.getByText(document.name, { exact: true }));
await page.goto('http://localhost:4338/correspondence'); const messages = page.getByRole('region', { name: t('emailInboxLinkedMessages'), exact: true }); await messages.getByText(mail.subject, { exact: true }).waitFor(); await scan('inbox'); await page.goto('http://localhost:4338/correspondence'); const messages = page.getByRole('region', { name: t('emailInboxLinkedMessages'), exact: true }); await messages.getByText(mail.subject, { exact: true }).waitFor(); await scan('inbox');
const initialMailReads = mailReads; await page.getByRole('button', { name: t('emailInboxJumpMessages'), exact: true }).press('Enter'); await expect(messages).toBeFocused(); await messages.getByRole('button', { name: t('emailInboxReturnFilters'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('correspondenceSearch'), exact: true })).toBeFocused(); assert.equal(mailReads, initialMailReads); const initialMailReads = mailReads; await page.getByRole('button', { name: t('emailInboxJumpMessages'), exact: true }).press('Enter'); await expect(messages).toBeFocused(); await messages.getByRole('button', { name: t('emailInboxReturnFilters'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('correspondenceSearch'), exact: true })).toBeFocused(); assert.equal(mailReads, initialMailReads);
await messages.getByRole('button', { name: t('emailInboxViewMessage'), exact: true }).click(); await messages.getByText('Please choose an interview time. <script>inert text</script>', { exact: true }).waitFor(); await scan('message'); await messages.getByRole('button', { name: t('emailInboxViewMessage'), exact: true }).click(); const messageBody = messages.getByText('Please choose an interview time. <script>inert text</script>', { exact: true }); await messageBody.waitFor(); await scan('message', messageBody);
await messages.getByRole('button', { name: t('emailInboxReply'), exact: true }).click(); const compose = page.getByRole('region', { name: t('emailInboxDraftTitle'), exact: true }); await messages.getByRole('button', { name: t('emailInboxReply'), exact: true }).click(); const compose = page.getByRole('region', { name: t('emailInboxDraftTitle'), exact: true });
await compose.getByRole('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test'); await compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }).fill('Thank you. Please confirm the available interview times.'); await compose.getByRole('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test'); await compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }).fill('Thank you. Please confirm the available interview times.');
await compose.getByRole('button', { name: t('emailInboxSaveDraft'), exact: true }).click(); await expect.poll(() => draft?.revision).toBe(4); await compose.getByRole('button', { name: t('emailInboxSaveDraft'), exact: true }).click(); await expect.poll(() => draft?.revision).toBe(4);
await compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true }).click(); const dialog = page.getByRole('dialog', { name: t('emailInboxConfirmSendTitle'), exact: true }); await dialog.waitFor(); assert.equal(sends, 0); await scan('send-confirmation'); await compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true }).click(); const dialog = page.getByRole('dialog', { name: t('emailInboxConfirmSendTitle'), exact: true }); await dialog.waitFor(); assert.equal(sends, 0); await scan('send-confirmation', dialog);
await dialog.getByRole('button', { name: t('emailInboxSendEmail'), exact: true }).click(); await compose.getByText(t('emailInboxDeliveryUncertain'), { exact: true }).waitFor(); await expect(compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true })).toBeDisabled(); await dialog.getByRole('button', { name: t('emailInboxSendEmail'), exact: true }).click(); await compose.getByText(t('emailInboxDeliveryUncertain'), { exact: true }).waitFor(); await expect(compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true })).toBeDisabled();
assert.equal(sends, 1); await expect(compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true })).toHaveValue(draft.bodyText); await scan('uncertain-send'); assert.equal(sends, 1); await expect(compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true })).toHaveValue(draft.bodyText); await scan('uncertain-send', compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }));
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close(); report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
} }
assert.equal(report.screens.length, report.journeys.length * 6); assert.equal(report.mockedWrites.length, report.journeys.length * 2); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); assert.equal(report.journeys.length, expectedJourneys); assert.equal(report.screens.length, report.journeys.length * 6); assert.equal(report.mockedWrites.length, report.journeys.length * 2); 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} document/mail journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} fixture-only save/send requests.`); console.log(`PASS:${report.journeys.length} document/mail journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} fixture-only save/send requests.`);
} finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Synthetic metadata/binary/mail/provider fixtures; not live storage, mailbox, OAuth or delivery.', 'Browser covers draft save, explicit confirmation and controlled uncertain outcome only; other recovery/conflict paths remain targeted tests.', 'No real sending, native device, final integrated security or release acceptance.'] }, null, 2)); await browser.close(); } } finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Synthetic metadata/binary/mail/provider fixtures; not live storage, mailbox, OAuth or delivery.', 'Browser covers draft save, explicit confirmation and controlled uncertain outcome only; other recovery/conflict paths remain targeted tests.', 'No real sending, native device, final integrated security or release acceptance.'] }, null, 2)); await browser.close(); }
@@ -248,6 +248,10 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
{[0, 0.5, 1].map(fraction => <line key={fraction} x1="20" x2="740" y1={180 - fraction * 160} y2={180 - fraction * 160} stroke={palette.divider} />)} {[0, 0.5, 1].map(fraction => <line key={fraction} x1="20" x2="740" y1={180 - fraction * 160} y2={180 - fraction * 160} stroke={palette.divider} />)}
<path d={chart.applied} fill="none" stroke={palette.primary.main} strokeWidth="3" /> <path d={chart.applied} fill="none" stroke={palette.primary.main} strokeWidth="3" />
<path d={chart.responses} fill="none" stroke={palette.success.main} strokeWidth="3" strokeDasharray="7 5" /> <path d={chart.responses} fill="none" stroke={palette.success.main} strokeWidth="3" strokeDasharray="7 5" />
{analytics.length === 1 ? <>
<circle cx="20" cy={180 - analytics[0].applied / chart.max * 160} r="5" fill={palette.primary.main} />
<circle cx="20" cy={180 - analytics[0].responses / chart.max * 160} r="8" fill="none" stroke={palette.success.main} strokeWidth="3" />
</> : null}
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, mb: 1 }}>
<Typography variant="caption" color="text.secondary">{monthLabel(analytics[0].month)}</Typography> <Typography variant="caption" color="text.secondary">{monthLabel(analytics[0].month)}</Typography>
@@ -125,6 +125,23 @@ test('[PRES-S01] activity chart uses one shared zero-based scale and a readable
expect(firstY(paths[0])).toBeLessThan(firstY(paths[1])); expect(firstY(paths[0])).toBeLessThan(firstY(paths[1]));
}); });
test('[PRES-S01] a single recorded month has visible markers on the shared scale and retains exact values', async () => {
const writesBefore = mockedApi.put.mock.calls.length;
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/analytics') ? [monthly[0]] : data(url) }) as any);
open();
const activity = await screen.findByRole('region', { name: 'Application activity' });
const markers = activity.querySelectorAll('svg circle');
expect(markers).toHaveLength(2);
expect(markers[0]).toHaveAttribute('cy', '20');
expect(markers[1]).toHaveAttribute('cy', '140');
for (const marker of markers) expect(Number(marker.getAttribute('r'))).toBeGreaterThan(0);
fireEvent.click(within(activity).getByText('View monthly values'));
const table = within(activity).getByRole('table');
expect(within(table).getByRole('cell', { name: '4' })).toBeVisible();
expect(within(table).getByRole('cell', { name: '1' })).toBeVisible();
expect(mockedApi.put).toHaveBeenCalledTimes(writesBefore);
});
test('[P7-02 PRES-S01 S02] setup guidance treats pending and failed summaries as unknown until their owner retries', async () => { test('[P7-02 PRES-S01 S02] setup guidance treats pending and failed summaries as unknown until their owner retries', async () => {
let resolveStats!: (response: { data: typeof stats }) => void; let resolveStats!: (response: { data: typeof stats }) => void;
let fail = false; let fail = false;
+4 -1
View File
@@ -100,7 +100,10 @@ test.each(['en', 'nb'] as const)('%s document filters and results support reques
localStorage.setItem('uiLanguage', language); renderPage(); localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Metadata CV ÆØÅ', { exact: true }); await screen.findByText('Metadata CV ÆØÅ', { exact: true });
const count = mockedApi.get.mock.calls.length; const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('documentsJumpResults', {}, language) })); const jump = screen.getByRole('button', { name: copy('documentsJumpResults', {}, language) });
const search = screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) });
expect(jump.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(jump);
expect(screen.getByRole('region', { name: copy('documentsYourDocuments', {}, language) })).toHaveFocus(); expect(screen.getByRole('region', { name: copy('documentsYourDocuments', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('documentsReturnFilters', {}, language) })); fireEvent.click(screen.getByRole('button', { name: copy('documentsReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) })).toHaveFocus(); expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) })).toHaveFocus();
+11 -11
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Paper, Select, Tab, Tabs, TextField, Typography } from "@mui/material"; import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Select, Tab, Tabs, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
@@ -522,8 +522,8 @@ export default function CareerProfilePage() {
: change.id === "Contact" && change.confidence === "Low" && change.label === "Contact details" ? t("careerImportContactDetails") : change.label; : change.id === "Contact" && change.confidence === "Low" && change.label === "Contact details" ? t("careerImportContactDetails") : change.label;
return ( return (
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, flex: "1 1 160px", overflow: "visible", overflowWrap: "anywhere" } }}> <Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, flex: "1 1 160px", overflow: "visible", overflowWrap: "anywhere" } }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}> <Box component="nav" aria-label={t("careerWorkspaceNavigation")} sx={{ borderBottom: 1, borderColor: "divider", minWidth: 0 }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}> <FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" } }}>
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel> <InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
<Select <Select
labelId="career-workspace-section-label" labelId="career-workspace-section-label"
@@ -541,14 +541,14 @@ export default function CareerProfilePage() {
value={workspaceSection} value={workspaceSection}
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)} onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
aria-label={t("careerWorkspaceNavigation")} aria-label={t("careerWorkspaceNavigation")}
sx={{ display: { xs: "none", sm: "flex" }, px: 1 }} sx={{ display: { xs: "none", sm: "flex" } }}
> >
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} /> <Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
<Tab value="profile" label={t("careerWorkspaceProfileTab")} /> <Tab value="profile" label={t("careerWorkspaceProfileTab")} />
<Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} /> <Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} />
<Tab value="import" label={t("careerWorkspaceImportTab")} /> <Tab value="import" label={t("careerWorkspaceImportTab")} />
</Tabs> </Tabs>
</Paper> </Box>
{workspaceSection === "overview" ? ( {workspaceSection === "overview" ? (
<CareerWorkspaceOverview <CareerWorkspaceOverview
@@ -568,7 +568,7 @@ export default function CareerProfilePage() {
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null} {evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper variant="outlined" id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}> {workspaceSection === "profile" || workspaceSection === "import" ? <Box id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, minWidth: 0, scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
<input <input
ref={cvInputRef} ref={cvInputRef}
type="file" type="file"
@@ -627,7 +627,7 @@ export default function CareerProfilePage() {
</Alert> </Alert>
) : null} ) : null}
{workspaceSection === "profile" ? <Paper variant="outlined" sx={{ mb: 2.5, p: { xs: 1.5, sm: 2 }, borderRadius: 3 }}> {workspaceSection === "profile" ? <Box sx={{ mb: 2.5, py: 1.5, borderBottom: 1, borderColor: "divider" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
<Typography component="h2" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography> <Typography component="h2" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
@@ -659,7 +659,7 @@ export default function CareerProfilePage() {
{t("careerProfileImportReady")} {t("careerProfileImportReady")}
</Alert> : null} </Alert> : null}
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null} {!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
</Paper> : null} </Box> : null}
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}> {workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
@@ -843,7 +843,7 @@ export default function CareerProfilePage() {
</Box> </Box>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
<PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")} surface> <PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")}>
{runsLoading ? <LinearProgress aria-label={t("loading")} /> : null} {runsLoading ? <LinearProgress aria-label={t("loading")} /> : null}
{runsLoadError !== null ? <Alert severity="error" action={<Button color="inherit" disabled={runsLoading} onClick={() => void loadExtractionRuns()}>{t("retry")}</Button>}>{t("careerOverviewImportUnavailable")}</Alert> : null} {runsLoadError !== null ? <Alert severity="error" action={<Button color="inherit" disabled={runsLoading} onClick={() => void loadExtractionRuns()}>{t("retry")}</Button>}>{t("careerOverviewImportUnavailable")}</Alert> : null}
{latestRun ? ( {latestRun ? (
@@ -1011,7 +1011,7 @@ export default function CareerProfilePage() {
</Box> </Box>
</Box> </Box>
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}> <Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface> <PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")}>
<Box sx={{ display: 'grid', gap: 3 }}> <Box sx={{ display: 'grid', gap: 3 }}>
<Box id="career-section-contact"> <Box id="career-section-contact">
<PersonalInformationSection <PersonalInformationSection
@@ -1090,7 +1090,7 @@ export default function CareerProfilePage() {
</Box> </Box>
</Paper> : null} </Box> : null}
</Box> </Box>
); );
} }
+5 -3
View File
@@ -274,7 +274,10 @@ export default function DocumentsPage() {
</FormControl>; </FormControl>;
return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}> return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>} /> <PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
<Button onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>
</Box>} />
<Stack component="aside" aria-label={t("documentsFilters")} spacing={2}> <Stack component="aside" aria-label={t("documentsFilters")} spacing={2}>
<TextField inputRef={searchRef} fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} /> <TextField inputRef={searchRef} fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}>
@@ -286,7 +289,6 @@ export default function DocumentsPage() {
</Box> </Box>
{jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null} {jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null}
<Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography> <Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography>
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
</Stack> </Stack>
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}> <Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} onClick={() => searchRef.current?.focus()}>{t("documentsReturnFilters")}</Button> <Button size="small" sx={{ mb: 1 }} onClick={() => searchRef.current?.focus()}>{t("documentsReturnFilters")}</Button>
@@ -301,7 +303,7 @@ export default function DocumentsPage() {
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ "& th, & td": { verticalAlign: "top", overflowWrap: "anywhere" } }}> <TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ "& th, & td": { verticalAlign: "top", overflowWrap: "anywhere" } }}>
<TableHead><TableRow><TableCell sx={{ width: "30%" }}>{t("documentsName")}</TableCell><TableCell sx={{ width: "17%" }}>{t("documentsJobFilter")}</TableCell><TableCell sx={{ width: "15%" }}>{t("documentsState")}</TableCell><TableCell sx={{ width: "18%" }}>{t("documentsRecordedDate")}</TableCell><TableCell sx={{ width: "20%" }}>{t("documentsActions")}</TableCell></TableRow></TableHead> <TableHead><TableRow><TableCell sx={{ width: "30%" }}>{t("documentsName")}</TableCell><TableCell sx={{ width: "17%" }}>{t("documentsJobFilter")}</TableCell><TableCell sx={{ width: "15%" }}>{t("documentsState")}</TableCell><TableCell sx={{ width: "18%" }}>{t("documentsRecordedDate")}</TableCell><TableCell sx={{ width: "20%" }}>{t("documentsActions")}</TableCell></TableRow></TableHead>
<TableBody>{items.map((item) => <TableRow key={item.id}><TableCell component="th" scope="row">{details(item)}</TableCell><TableCell>{job(item)}</TableCell><TableCell>{badges(item)}</TableCell><TableCell>{date(item)}</TableCell><TableCell>{actions(item)}</TableCell></TableRow>)}</TableBody> <TableBody>{items.map((item) => <TableRow key={item.id}><TableCell component="th" scope="row">{details(item)}</TableCell><TableCell>{job(item)}</TableCell><TableCell>{badges(item)}</TableCell><TableCell>{date(item)}</TableCell><TableCell>{actions(item)}</TableCell></TableRow>)}</TableBody>
</Table></TableContainer> : <Stack spacing={2}>{items.map((item) => <Paper key={item.id} component="article" aria-label={item.name} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}><Stack spacing={1.5}>{details(item)}{job(item)}{badges(item)}{date(item)}{actions(item)}</Stack></Paper>)}</Stack>} </Table></TableContainer> : <Stack spacing={2}>{items.map((item) => <Box key={item.id} component="article" aria-label={item.name} sx={{ py: 2, borderBottom: 1, borderColor: "divider", minWidth: 0 }}><Stack spacing={1.5}>{details(item)}{job(item)}{badges(item)}{date(item)}{actions(item)}</Stack></Box>)}</Stack>}
{result.totalPages > 1 ? <Pagination page={page} count={Math.min(result.totalPages, 10000)} onChange={(_, next) => { resultsRef.current?.focus(); updateQuery({ page: String(next) }); }} aria-label={t("documentsPages")} getItemAriaLabel={(kind, target) => t(kind === "page" ? "documentsGoToPage" : kind === "previous" ? "documentsPreviousPage" : kind === "next" ? "documentsNextPage" : kind === "first" ? "documentsFirstPage" : "documentsLastPage", { page: target ?? 1 })} sx={{ mt: 2, "& .MuiPagination-ul": { flexWrap: "wrap", gap: 0.5 } }} /> : null} {result.totalPages > 1 ? <Pagination page={page} count={Math.min(result.totalPages, 10000)} onChange={(_, next) => { resultsRef.current?.focus(); updateQuery({ page: String(next) }); }} aria-label={t("documentsPages")} getItemAriaLabel={(kind, target) => t(kind === "page" ? "documentsGoToPage" : kind === "previous" ? "documentsPreviousPage" : kind === "next" ? "documentsNextPage" : kind === "first" ? "documentsFirstPage" : "documentsLastPage", { page: target ?? 1 })} sx={{ mt: 2, "& .MuiPagination-ul": { flexWrap: "wrap", gap: 0.5 } }} /> : null}
</> : null} </> : null}
</Box> </Box>
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Box, Button, Chip, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material"; import { Alert, Box, Button, Chip, LinearProgress, Skeleton, Stack, Typography } from "@mui/material";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; import PersonOutlineIcon from "@mui/icons-material/PersonOutline";
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined"; import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
@@ -43,7 +43,7 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
onNavigate?: () => void; onNavigate?: () => void;
}) { }) {
return ( return (
<Paper variant="outlined" component="article" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}> <Box component="article" sx={{ py: 1.5, borderBottom: 1, borderColor: "divider", minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box> <Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography> <Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography>
@@ -57,7 +57,7 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
> >
{label} {label}
</Button> </Button>
</Paper> </Box>
); );
} }
@@ -142,7 +142,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
<Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}> <Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}>
{isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null} {isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
<PageSection surface title={t("careerOverviewProfileStatus")} level="h3"> <PageSection title={t("careerOverviewProfileStatus")} level="h3">
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center" }}>
<Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography> <Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography>
{loading ? <Skeleton width={42} /> : profileKnown ? <Typography sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography> : null} {loading ? <Skeleton width={42} /> : profileKnown ? <Typography sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography> : null}
@@ -157,7 +157,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
) : null} ) : null}
<Button ref={profileLinkRef} href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button> <Button ref={profileLinkRef} href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
</PageSection> </PageSection>
<PageSection surface title={t("careerOverviewCvImport")} level="h3"> <PageSection title={t("careerOverviewCvImport")} level="h3">
{importUnknown ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> {importUnknown ? <Typography role="status" color="text.secondary">{t("loading")}</Typography>
: runsLoadError ? <Alert severity="error" action={onRetryRuns ? <Button color="inherit" disabled={runsLoading} onClick={() => { importLinkRef.current?.focus(); onRetryRuns(); }}>{t("retry")}</Button> : undefined}>{t("careerOverviewImportUnavailable")}</Alert> : runsLoadError ? <Alert severity="error" action={onRetryRuns ? <Button color="inherit" disabled={runsLoading} onClick={() => { importLinkRef.current?.focus(); onRetryRuns(); }}>{t("retry")}</Button> : undefined}>{t("careerOverviewImportUnavailable")}</Alert>
: <Alert severity={importState.severity}>{importState.message}</Alert>} : <Alert severity={importState.severity}>{importState.message}</Alert>}
@@ -186,7 +186,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} /> <ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
</Box> </Box>
<PageSection surface title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}> <PageSection title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}>
{recentLoading ? ( {recentLoading ? (
<Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack> <Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack>
) : recentError ? ( ) : recentError ? (