style(ui): reduce initial dashboard gaps and flatten discovery rows

This commit is contained in:
cesnimda
2026-11-04 01:58:30 +01:00
parent df0ef68cd9
commit a7d726738b
88 changed files with 1748 additions and 20 deletions
+14 -6
View File
@@ -4,12 +4,18 @@ import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/dashboard/browser');
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 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');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
for (const width of [320, 390, 768, 1440]) for (const language of ['en', 'nb']) for (const mode of ['light', 'dark']) {
for (const width of widths) for (const language of languages) for (const mode of modes) {
console.log(`Checking ${width}/${language}/${mode}`);
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
@@ -58,7 +64,7 @@ try {
assert.equal(await week.locator('time').first().getAttribute('datetime'), deadline);
assert.equal(await week.getByRole('link', { name: 'Community nurse' }).count(), 0);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
if (width === 1440) await page.waitForFunction(() => {
if (width >= 1440) await page.waitForFunction(() => {
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;
});
@@ -94,9 +100,11 @@ 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, 16);
console.log('PASS: 16 dashboard journeys,32 scoped axe sweeps,32 captures; controlled API persistence only.');
assert.equal(report.mockedWrites.length, expectedJourneys);
assert.equal(report.journeys.length, expectedJourneys);
assert.equal(report.screens.length, expectedJourneys * 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, 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic API fixtures, not real backend end-to-end','no native device/screen reader/model/provider/security/release qualification'] }, null, 2));
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic API fixtures, not real backend end-to-end','no native device/screen reader/model/provider/security/release qualification'] }, null, 2));
await browser.close();
}
+12 -6
View File
@@ -4,12 +4,18 @@ import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/discovery/browser');
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 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/discovery' : '../../docs/ui-modernisation/evidence/discovery/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
for (const width of [320, 390, 768, 1440]) for (const language of ['en', 'nb']) for (const mode of ['light', 'dark']) {
for (const width of widths) for (const language of languages) for (const mode of modes) {
console.log(`Checking ${width}/${language}/${mode}`);
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
@@ -50,7 +56,7 @@ try {
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, []);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
};
await page.goto('http://localhost:4338/discover');
const filters = page.getByRole('complementary', { name: language === 'en' ? 'Search filters' : 'Søkefiltre' }); await filters.waitFor();
@@ -84,11 +90,11 @@ try {
report.journeys.push({ width, language, mode, searchSavePauseRunDismissConfirmedDelete: 'PASS' });
await context.close();
}
assert.equal(report.screens.length, 48); assert.equal(report.journeys.length, 16); assert.equal(report.mockedWrites.length, 80);
assert.equal(report.screens.length, expectedJourneys * 3); assert.equal(report.journeys.length, expectedJourneys); assert.equal(report.mockedWrites.length, expectedJourneys * 5);
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []);
assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log('PASS:16 controlled discovery journeys,48 axe scans/captures,80 intercepted fixture mutations.');
console.log(`PASS:${report.journeys.length} controlled discovery journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} intercepted fixture mutations.`);
} finally {
fs.writeFileSync(path.join(out, 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic provider/API fixtures, not live NAV or backend qualification', 'no new providers/geography or native/security/release acceptance'] }, null, 2));
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic provider/API fixtures, not live NAV or backend qualification', 'no new providers/geography or native/security/release acceptance'] }, null, 2));
await browser.close();
}
@@ -38,6 +38,21 @@ test('default catalogue preserves every section and supported widths', () => {
expect(defaultDashboardLayout().widgets[0].visible).toBe(true);
});
test('initial dashboard keeps every widget in a denser bounded layout without resizing saved geometry', () => {
const fresh = defaultDashboardLayout();
expect(fresh.widgets).toHaveLength(10);
expect(fresh.widgets.every(widget => widget.visible)).toBe(true);
expect(Math.max(...fresh.widgets.map(widget => widget.y + widget.h))).toBeLessThanOrEqual(42);
const saved = { version: 2, revision: 4, widgets: [
{ id: 'sources', visible: true, size: 'standard', x: 0, y: 14, w: 6, h: 8 },
{ id: 'salary', visible: false, size: 'full', x: 0, y: 26, w: 12, h: 12 },
] };
const normalized = normalizeDashboardLayout(saved);
expect(normalized.revision).toBe(4);
expect(normalized.widgets.slice(0, 2)).toEqual(saved.widgets);
expect(normalized.widgets.slice(2).every(widget => !widget.visible)).toBe(true);
});
test.each([1, 2])('new schema %s preferences use task-first A1 defaults without omitting widgets', version => {
const layout = normalizeDashboardLayout({ version, revision: 0, widgets: [] });
expect(layout.widgets[0]).toMatchObject({ id: 'attention', visible: true, x: 0, y: 0, w: 12 });
+2 -1
View File
@@ -34,7 +34,8 @@ export function defaultDashboardLayout(): DashboardLayout {
return { version: 2, revision: 0, widgets: arrangeDashboardWidgets(order.map(id => {
const definition = WIDGETS.find(widget => widget.id === id)!;
const w = id === 'attention' ? 12 : id === 'agenda' ? 8 : id === 'funnel' ? 4 : definition.defaultSize === 'full' ? 12 : 6;
return { id, visible: true, size: w === 12 ? 'full' : 'standard', x: 0, y: 0, w, h: id === 'cards' ? 5 : id === 'attention' ? 9 : 8 };
const h = id === 'cards' ? 5 : id === 'attention' ? 9 : ['stageAge', 'sources', 'salary', 'companies', 'skills'].includes(id) ? 4 : 8;
return { id, visible: true, size: w === 12 ? 'full' : 'standard', x: 0, y: 0, w, h };
})) };
}
@@ -192,8 +192,8 @@ export default function JobDiscoveryPage() {
<TextField label={t("jobDiscoveryMunicipality")} value={location} disabled={busy} onChange={(event) => setLocation(event.target.value)} />
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress aria-hidden="true" size={18} color="inherit" /> : <SearchIcon />} disabled={busy}>{t("jobDiscoverySearchNav")}</Button>
</Box>
<Card variant="outlined" aria-busy={savedSearchState === "loading"}>
<CardContent sx={{ display: "grid", gap: 1.5, p: "var(--app-card-padding)", "&:last-child": { pb: "var(--app-card-padding)" } }}>
<Box aria-busy={savedSearchState === "loading"}>
<Box sx={{ display: "grid", gap: 1.5 }}>
<Box>
<Typography component="h2" variant="sectionTitle">{t("jobDiscoverySavedSearches")}</Typography>
<Typography variant="body2" color="text.secondary">{t("jobDiscoverySavedSearchesHelp")}</Typography>
@@ -226,8 +226,8 @@ export default function JobDiscoveryPage() {
))}
</Stack>
)}
</CardContent>
</Card>
</Box>
</Box>
</Box>
<Box role="region" aria-label={t("jobDiscoveryResults")} tabIndex={-1} ref={resultsRef} aria-busy={loading} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
<PageHeader level="h2" title={t("jobDiscoveryResults")} actions={<Button disabled={busy} onClick={() => queryRef.current?.focus()}>{t("jobDiscoveryReturnFilters")}</Button>} />
@@ -252,8 +252,8 @@ export default function JobDiscoveryPage() {
) : null}
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2 }}>
{sortedJobs.map((job) => (
<Card key={job.id} component="article" aria-label={job.title} variant="outlined" sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<CardContent sx={{ p: "var(--app-card-padding)" }}>
<Card key={job.id} component="article" aria-label={job.title} variant="outlined" sx={{ display: "flex", flexDirection: "column", minWidth: 0, border: 0, borderBottom: 1, borderColor: "divider", borderRadius: 0, bgcolor: "transparent", overflow: "visible" }}>
<CardContent sx={{ px: 0, py: 1.5 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}>
<Chip label={job.sourceName || job.source.toUpperCase()} size="small" color="primary" variant="outlined" />
{job.isNew ? <Chip label={t("jobDiscoveryNewResult")} size="small" color="success" /> : null}
@@ -270,7 +270,7 @@ export default function JobDiscoveryPage() {
<Typography variant="caption" color="text.secondary">{t("jobDiscoveryWorkArrangementMissing")}</Typography>
</Stack>
</CardContent>
<CardActions sx={{ mt: "auto", p: "var(--app-card-padding)", pt: 0, flexWrap: "wrap", gap: 1, "& .MuiButton-root": { ml: 0, whiteSpace: "normal", maxWidth: "100%" } }}>
<CardActions sx={{ mt: "auto", px: 0, pt: 0, pb: 1.5, flexWrap: "wrap", gap: 1, "& .MuiButton-root": { ml: 0, whiteSpace: "normal", maxWidth: "100%" } }}>
<Button href={job.url} target="_blank" rel="noreferrer">{t("jobDiscoveryViewListing")}</Button>
<Button startIcon={<AddIcon />} onClick={() => navigate("/jobs?add=" + encodeURIComponent(job.url))}>{t("jobDiscoverySaveTracker")}</Button>
{activeSavedSearchId !== null ? <Button color="inherit" disabled={busy} onClick={() => void dismissJob(job.id)}>{t("jobDiscoveryDismiss")}</Button> : null}