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';
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/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 now = new Date(), month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const browser = await chromium.launch();
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}`);
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();
@@ -39,7 +42,7 @@ try {
const scan = async 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, []);
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');
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');
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'));
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(); }
+13 -10
View File
@@ -7,12 +7,13 @@ 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/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 });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], renderReads: 0, unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
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) {
console.log(`Checking ${width}/${language}/${mode}`);
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]);
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}`);
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(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
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.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 && !target });
};
const careerSection = async key => {
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']) {
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);
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 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);
@@ -91,10 +94,10 @@ try {
}
await page.locator('[data-edit-career-section="projects"]').click();
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');
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);
await page.goto('http://localhost:4338/career/builder');
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');
}
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');
const text = page.getByRole('textbox', { name: t('jobDetailsCoverLetter'), exact: true }); await expect(text).toBeEnabled();
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();
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' });
await context.close();
}
+16 -3
View File
@@ -5,11 +5,12 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
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 languages = process.env.A3_PILOT ? ['en'] : ['en', 'nb'];
const modes = process.env.A3_PILOT ? ['light'] : ['light', 'dark'];
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 });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] };
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();
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();
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 });
@@ -100,9 +113,9 @@ try {
}
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []);
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.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.`);
} 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));
@@ -7,11 +7,14 @@ import path from 'node:path';
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 { 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 browser = await chromium.launch();
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}`);
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();
@@ -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: '{}' });
});
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);
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 });
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.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 && !target });
};
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');
@@ -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 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');
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');
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 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('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();
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();
}
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.`);
} 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} />)}
<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" />
{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 sx={{ display: "flex", justifyContent: "space-between", gap: 2, mb: 1 }}>
<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]));
});
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 () => {
let resolveStats!: (response: { data: typeof stats }) => void;
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();
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
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();
fireEvent.click(screen.getByRole('button', { name: copy('documentsReturnFilters', {}, language) }));
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 { 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 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;
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" } }}>
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}>
<Box component="nav" aria-label={t("careerWorkspaceNavigation")} sx={{ borderBottom: 1, borderColor: "divider", minWidth: 0 }}>
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" } }}>
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
<Select
labelId="career-workspace-section-label"
@@ -541,14 +541,14 @@ export default function CareerProfilePage() {
value={workspaceSection}
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
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="profile" label={t("careerWorkspaceProfileTab")} />
<Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} />
<Tab value="import" label={t("careerWorkspaceImportTab")} />
</Tabs>
</Paper>
</Box>
{workspaceSection === "overview" ? (
<CareerWorkspaceOverview
@@ -568,7 +568,7 @@ export default function CareerProfilePage() {
{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
ref={cvInputRef}
type="file"
@@ -627,7 +627,7 @@ export default function CareerProfilePage() {
</Alert>
) : 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={{ minWidth: 0 }}>
<Typography component="h2" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
@@ -659,7 +659,7 @@ export default function CareerProfilePage() {
{t("careerProfileImportReady")}
</Alert> : 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" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
@@ -843,7 +843,7 @@ export default function CareerProfilePage() {
</Box>
</AccordionDetails>
</Accordion>
<PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")} surface>
<PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")}>
{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}
{latestRun ? (
@@ -1011,7 +1011,7 @@ export default function CareerProfilePage() {
</Box>
</Box>
<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 id="career-section-contact">
<PersonalInformationSection
@@ -1090,7 +1090,7 @@ export default function CareerProfilePage() {
</Box>
</Paper> : null}
</Box> : null}
</Box>
);
}
+5 -3
View File
@@ -274,7 +274,10 @@ export default function DocumentsPage() {
</FormControl>;
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}>
<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 }}>
@@ -286,7 +289,6 @@ export default function DocumentsPage() {
</Box>
{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>
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
</Stack>
<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>
@@ -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" } }}>
<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>
</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}
</> : null}
</Box>
@@ -1,6 +1,6 @@
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 PersonOutlineIcon from "@mui/icons-material/PersonOutline";
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
@@ -43,7 +43,7 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
onNavigate?: () => void;
}) {
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={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography>
@@ -57,7 +57,7 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
>
{label}
</Button>
</Paper>
</Box>
);
}
@@ -142,7 +142,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
<Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}>
{isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null}
<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" }}>
<Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography>
{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}
<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 surface title={t("careerOverviewCvImport")} level="h3">
<PageSection title={t("careerOverviewCvImport")} level="h3">
{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>
: <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 />} />
</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 ? (
<Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack>
) : recentError ? (