feat(ui): show recorded application context beside opened mail

This commit is contained in:
cesnimda
2026-11-04 08:00:43 +01:00
parent dd284782fd
commit a9208ab4b3
8 changed files with 63 additions and 13 deletions
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,11 @@
# Mail rows, early navigation and recorded application context
4 November 2026. Continue from published and independently verified receiptdd284782, application runtimeb31ce617. Remove the whole-inbox decorative surface, make messages divided semantic articles, and move the existing Jump to messages action before filters. Compose/draft forms and explicit send confirmation keep meaningful boundaries. The jump action remains visible only in inbox mode, matching the prior hidden inbox container. Every ref, ID, message/detail owner, provider capability guard, fallback, draft revision/conflict, explicit confirmation, uncertain-send protection and unlink handler remains unchanged.
Reference comparison then identified the approved A3 right-side application context. Opened messages now place the existing recorded role/company and single Open application action in a named aside on the right above1000px. On narrower layouts the context precedes the message, preserving immediate access without scrolling through long prose. DOM order is context then message; desktop grid placement presents the primary message left/context right. Closed rows retain their original inline identity/action. No extra fetch, inferred application status or fabricated appointment is added. An independent draft can belong to a different application/thread, so its existing explicit identity, ownership and confirmed-send controls remain in their separate form.
RED: both EN/NB keyboard-navigation cases reproduced the late jump control. An initial full37-case run exposed two obsolete `.MuiPaper-root` test selectors; the helper now selects the named article, preserving all detail/unlink assertions. Two additional EN/NB regressions reproduced the absent named application context. Final39 inbox cases PASSED, replacing the overlapping37 total. Context checks preserve exactly one application action on open/close and no mutation. TypeScript, full lint and verifier syntax/scoped lint PASSED. All failure reports retained. No API/server changes.
Pending for this successor: settled export build, pilot and36-config mail-only144-state rendered run, independent review, source/export/evidence reconciliation and publication. Existing document journeys are skipped in mail-only mode and remain qualified by the preceding source-bound receipt; no old evidence is overwritten. The verifier checks right/stacked context geometry, single application action, request-free jump/return, inert plain text, draft save, explicit confirmation and uncertain outcome/no retry. Planned counts are not PASSED execution.
NOT RUN: real mailbox/provider/delivery, native/device, integrated security or release. Earlierb31ce617 captures show the preceding mail runtime and cannot qualify these edits. No new owner decision is needed for this ordinary approved IM10 correction; final A3/full route/journey acceptance remains OPEN.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,9 +8,10 @@ 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 visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const mailOnly = process.env.A3_MAIL_FLAT === '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 out = path.resolve(import.meta.dirname, mailOnly ? '../../docs/ui-modernisation/visual-correction/evidence/mail-flat' : 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 {
@@ -56,6 +57,7 @@ try {
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 });
};
if (!mailOnly) {
await page.goto('http://localhost:4338/documents');
const docs = page.getByRole('region', { name: t('documentsYourDocuments'), exact: true }); await docs.getByText(document.name, { exact: true }).waitFor(); await scan('documents');
const reads = documentReads; await page.getByRole('button', { name: t('documentsJumpResults'), exact: true }).press('Enter'); assert.equal(await docs.evaluate(node => node === window.document.activeElement), true);
@@ -63,17 +65,25 @@ try {
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', 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(); 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('emailInboxViewMessage'), exact: true }).click(); const messageBody = messages.getByText('Please choose an interview time. <script>inert text</script>', { exact: true }); await messageBody.waitFor();
const article = messages.getByRole('article', { name: mail.subject, exact: true });
const applicationContext = article.getByRole('complementary', { name: t('emailInboxJob'), exact: true });
await expect(applicationContext).toContainText(mail.jobTitle); await expect(applicationContext).toContainText(mail.companyName);
await expect(article.getByRole('button', { name: t('emailInboxOpenJob'), exact: true })).toHaveCount(1);
const contentBounds = await article.locator('[data-mail-message-content]').boundingBox(), contextBounds = await applicationContext.boundingBox();
assert.ok(width > 1000 ? contextBounds.x >= contentBounds.x + contentBounds.width - 2 && Math.abs(contextBounds.y - contentBounds.y) < 2 : contextBounds.y + contextBounds.height <= contentBounds.y + 2, 'Recorded application context is right on desktop and before the body on narrow screens');
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', 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', compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }));
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: mailOnly ? 'NOT RUN' : 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
}
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'));
assert.equal(report.journeys.length, expectedJourneys); assert.equal(report.screens.length, report.journeys.length * (mailOnly ? 4 : 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(); }
@@ -53,7 +53,7 @@ function pageData(items: ReturnType<typeof messageItem>[], page = 1, total = ite
}
function messageRow(subject: string) {
return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement);
return within(screen.getByRole('article', { name: subject }));
}
function LocationProbe() {
@@ -118,11 +118,29 @@ function renderPage(initialPath = '/') {
}
describe('CorrespondenceInboxPage', () => {
test.each(['en', 'nb'] as const)('%s opened message retains one application action in named context', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Interview invite', { exact: true });
const row = messageRow('Interview invite');
fireEvent.click(row.getByRole('button', { name: copy('emailInboxViewMessage', {}, language) }));
const context = await row.findByRole('complementary', { name: copy('emailInboxJob', {}, language) });
expect(context).toHaveTextContent('Acme Systems');
expect(context).toHaveTextContent('Backend Engineer');
expect(row.getAllByRole('button', { name: copy('emailInboxOpenJob', {}, language) })).toHaveLength(1);
expect(within(context).getByRole('button', { name: copy('emailInboxOpenJob', {}, language) })).toBeEnabled();
fireEvent.click(row.getByRole('button', { name: copy('emailInboxHideMessage', {}, language) }));
expect(row.queryByRole('complementary')).not.toBeInTheDocument();
expect(row.getAllByRole('button', { name: copy('emailInboxOpenJob', {}, language) })).toHaveLength(1);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test.each(['en', 'nb'] as const)('%s inbox filters and messages support request-free keyboard navigation', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Interview invite', { exact: true });
const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxJumpMessages', {}, language) }));
const jump = screen.getByRole('button', { name: copy('emailInboxJumpMessages', {}, language) });
const search = screen.getByRole('textbox', { name: copy('correspondenceSearch', {}, language) });
expect(jump.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(jump);
expect(screen.getByRole('region', { name: copy('emailInboxLinkedMessages', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch', {}, language) })).toHaveFocus();
@@ -675,7 +675,7 @@ export default function CorrespondenceInboxPage() {
};
return (
<PageSection surface title={t("correspondenceInbox")} description={t("emailInboxSubtitle")}>
<PageSection title={t("correspondenceInbox")} description={t("emailInboxSubtitle")} actions={view === "inbox" ? <Button size="small" onClick={() => messagesPanel.current?.focus()}>{t("emailInboxJumpMessages")}</Button> : undefined}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t(inboxTotal === 1 ? "emailInboxItem" : "emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
@@ -777,7 +777,6 @@ export default function CorrespondenceInboxPage() {
</Select>
</FormControl>
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
<Button size="small" onClick={() => messagesPanel.current?.focus()} sx={{ justifySelf: "start", gridColumn: "1 / -1" }}>{t("emailInboxJumpMessages")}</Button>
</Box>
{draft ? (
@@ -830,10 +829,10 @@ export default function CorrespondenceInboxPage() {
<Stack spacing={1.25}>
{(hasCurrentInbox ? items : []).map((item) => (
<Paper component="article" aria-labelledby={`${rowId}-${item.id}`} key={item.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Box component="article" aria-labelledby={`${rowId}-${item.id}`} key={item.id} sx={{ py: 1.5, borderBottom: 1, borderColor: "divider", minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</Typography>
{selectedMessageId !== item.id ? <Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</Typography> : null}
<Typography id={`${rowId}-${item.id}`} component="h3" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{item.subject || item.contentPreview || t("emailInboxNoSubject")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}
@@ -846,7 +845,7 @@ export default function CorrespondenceInboxPage() {
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
{item.attachmentCount > 0 ? <Chip size="small" label={t(item.attachmentCount === 1 ? "emailInboxAttachmentCount" : "emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
<Button size="small" variant="text" disabled={!!unlinkingThreadId} aria-expanded={selectedMessageId === item.id} aria-controls={selectedMessageId === item.id ? `${rowId}-${item.id}-message` : undefined} onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
<Button size="small" variant="text" disabled={!!unlinkingThreadId} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
{selectedMessageId !== item.id ? <Button size="small" variant="text" disabled={!!unlinkingThreadId} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button> : null}
{item.provider === "gmail" && item.externalThreadId ? (
<Button size="small" color="warning" variant="text" disabled={!!unlinkingThreadId} onClick={() => void unlinkGmailThread(item)}>
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
@@ -855,7 +854,14 @@ export default function CorrespondenceInboxPage() {
</Box>
</Box>
{selectedMessageId === item.id ? (
<Box ref={detailPanel} tabIndex={-1} role="group" id={`${rowId}-${item.id}-message`} aria-label={t("emailInboxMessage")} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
<Box ref={detailPanel} tabIndex={-1} role="group" id={`${rowId}-${item.id}-message`} aria-label={t("emailInboxMessage")} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider", display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, "@media (min-width:1001px)": { gridTemplateColumns: "minmax(0, 1fr) minmax(14rem, 20rem)" } }}>
<Box component="aside" aria-label={t("emailInboxJob")} sx={{ minWidth: 0, overflowWrap: "anywhere", "@media (min-width:1001px)": { gridColumn: "2", gridRow: "1", borderLeft: 1, borderColor: "divider", pl: 2 } }}>
<Typography component="h4" variant="sectionTitle">{t("emailInboxJob")}</Typography>
<Typography sx={{ fontWeight: 700 }}>{item.jobTitle || t("emailInboxUnknownRole")}</Typography>
<Typography color="text.secondary">{item.companyName || t("emailInboxUnknownCompany")}</Typography>
<Button size="small" variant="text" disabled={!!unlinkingThreadId} sx={{ mt: 1 }} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
</Box>
<Box data-mail-message-content sx={{ minWidth: 0, "@media (min-width:1001px)": { gridColumn: "1", gridRow: "1" } }}>
<ViewStateNotice compact loading={detailLoading} title={t("emailInboxMessageLoadFailed")} error={detailError ? { ...detailError, message: detailError.message || t("emailInboxMessageLoadFailed") } : null} onRetry={() => { detailPanel.current?.focus({ preventScroll: true }); return detailItem.current ? loadMessage(detailItem.current) : undefined; }} />
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{t("emailInboxProviderCopyUnavailable")}</Alert> : null}
{messageDetail ? <>
@@ -872,9 +878,10 @@ export default function CorrespondenceInboxPage() {
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
) : null}
</> : null}
</Box>
</Box>
) : null}
</Paper>
</Box>
))}
</Stack>
{hasCurrentInbox && inboxTotalPages > 1 ? (