style(ui): open career and document surfaces and show single-month activity
@@ -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.
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
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."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
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."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
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(); }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||