diff --git a/docs/audits/evidence/ux-003-kanban-dark-after.png b/docs/audits/evidence/ux-003-kanban-dark-after.png new file mode 100644 index 0000000..e303640 Binary files /dev/null and b/docs/audits/evidence/ux-003-kanban-dark-after.png differ diff --git a/docs/audits/evidence/ux-003-kanban-dark-before.png b/docs/audits/evidence/ux-003-kanban-dark-before.png new file mode 100644 index 0000000..b979c58 Binary files /dev/null and b/docs/audits/evidence/ux-003-kanban-dark-before.png differ diff --git a/job-tracker-ui/e2e/job-discovery.spec.ts b/job-tracker-ui/e2e/job-discovery.spec.ts index 9ed4621..ad8b116 100644 --- a/job-tracker-ui/e2e/job-discovery.spec.ts +++ b/job-tracker-ui/e2e/job-discovery.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from "@playwright/test"; import path from "node:path"; +const updateAuditEvidence = process.env.UPDATE_AUDIT_EVIDENCE === "1"; + async function login(page: Page) { await page.goto("/login"); await page.getByLabel("Email").fill("e2e@example.test"); @@ -62,7 +64,7 @@ test("discovery remains usable across widths and opens the reviewed import", asy await expect(page.getByText("Work arrangement not provided by this source").first()).toBeVisible(); await expect(page.getByText(/Application deadline:/)).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-375-light.png"), fullPage: true }); + if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-375-light.png"), fullPage: true }); await page.getByLabel("Sort results").click(); await page.getByRole("option", { name: "Title A–Z" }).click(); @@ -82,7 +84,7 @@ test("discovery remains usable across widths and opens the reviewed import", asy await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark"); await page.getByRole("button", { name: "Search NAV" }).click(); await expect(page.getByText("2 vacancies found")).toBeVisible(); - await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-1440-dark.png"), fullPage: true }); + if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "jobs-001-discovery-1440-dark.png"), fullPage: true }); await page.getByRole("button", { name: "Save to tracker" }).first().click(); await expect(page.getByRole("dialog")).toBeVisible(); diff --git a/job-tracker-ui/e2e/kanban-theme.spec.ts b/job-tracker-ui/e2e/kanban-theme.spec.ts new file mode 100644 index 0000000..8341b8a --- /dev/null +++ b/job-tracker-ui/e2e/kanban-theme.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Page } from "@playwright/test"; +import path from "node:path"; + +const updateAuditEvidence = process.env.UPDATE_AUDIT_EVIDENCE === "1"; + +async function login(page: Page) { + await page.goto("/login"); + await page.getByLabel("Email").fill("e2e@example.test"); + await page.getByLabel("Current password").fill("E2ePassword123!"); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +test("kanban columns use dark theme surfaces", async ({ page }) => { + let moveRequests = 0; + await page.addInitScript(() => { + window.localStorage.setItem("uiLanguage", "en"); + window.localStorage.setItem("themeMode:anon", "dark"); + }); + await page.route("**/api/jobapplications/board", async (route) => { + await route.fulfill({ json: [{ + id: 71, + jobTitle: "Synthetic platform engineer", + companyId: 1, + company: { id: 1, name: "Synthetic AS" }, + status: "Saved", + dateApplied: null, + savedAt: "2026-08-10T10:00:00Z", + daysSince: null, + tags: "[\"C#\",\"Azure\"]", + }, { + id: 72, + jobTitle: "Custom status role", + companyId: 1, + company: { id: 1, name: "Synthetic AS" }, + status: "Take-home assignment", + dateApplied: "2026-08-09T10:00:00Z", + savedAt: "2026-08-09T10:00:00Z", + daysSince: 1, + }] }); + }); + await page.route("**/api/jobapplications/71/status", async (route) => { moveRequests += 1; await route.fulfill({ json: {} }); }); + + await login(page); + await page.goto("/kanban"); + await expect(page.getByText("Synthetic platform engineer")).toBeVisible(); + const column = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]"); + expect(await column.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe("rgb(245, 242, 250)"); + if (updateAuditEvidence) await page.screenshot({ path: path.resolve("..", "docs", "audits", "evidence", "ux-003-kanban-dark-after.png"), fullPage: true }); + + const card = page.getByText("Synthetic platform engineer", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiCard-root')][1]"); + await card.focus(); + await page.keyboard.press("Space"); + await expect(card).toHaveAttribute("aria-pressed", "true"); + const active = page.getByRole("group", { name: "Active column" }); + await expect(active).toHaveAttribute("data-drop-state", "valid"); + await expect(page.getByRole("group", { name: "Other column, not a drop target" })).toHaveAttribute("aria-disabled", "true"); + await active.focus(); + await page.keyboard.press("Enter"); + await expect.poll(() => moveRequests).toBe(1); + await expect(page.getByText("Job moved to Applied.")).toBeAttached(); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + const overflow = await page.evaluate(() => ({ + documentWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + offenders: Array.from(document.querySelectorAll("*")).map((element) => { + const rect = element.getBoundingClientRect(); + return { tag: element.tagName, className: element.className?.toString().slice(0, 80), left: rect.left, right: rect.right, width: rect.width }; + }).filter((element) => (element.right > window.innerWidth + 1 && element.right < window.innerWidth + 40) || element.left < -1).slice(0, 12), + })); + const pageScroll = await page.evaluate(() => { + document.documentElement.scrollLeft = 999; + const scrollLeft = document.documentElement.scrollLeft; + document.documentElement.scrollLeft = 0; + return scrollLeft; + }); + expect(pageScroll, JSON.stringify(overflow)).toBe(0); + await page.evaluate(() => { + const userKey = window.localStorage.getItem("authUserKey") || "anon"; + window.localStorage.setItem(`themeMode:${userKey}`, "light"); + }); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light"); + const lightColumn = page.getByText("Not Applied", { exact: true }).locator("xpath=ancestor::*[contains(@class,'MuiPaper-root')][1]"); + expect(await lightColumn.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe("rgb(245, 242, 250)"); +}); diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index 64ea6d9..0dbe126 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { + Alert, Box, Card, CardContent, @@ -12,10 +13,10 @@ import { Paper, Typography, } from "@mui/material"; -import { alpha, useTheme } from "@mui/material/styles"; +import { useTheme } from "@mui/material/styles"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; -import { api } from "../api"; +import { api, getApiErrorMessage } from "../api"; import ViewStateNotice from "./ViewStateNotice"; import { JobApplication } from "../types"; import { useI18n } from "../i18n/I18nProvider"; @@ -40,12 +41,12 @@ type Status = PipelineStatus; type ColumnKey = PipelineGroup | "Other"; const TONE_PALETTE: Record string> = { - error: (theme) => theme.palette.error.main, - warning: (theme) => theme.palette.warning.main, - success: (theme) => theme.palette.success.main, - info: (theme) => alpha(theme.palette.primary.main, 0.95), - primary: (theme) => theme.palette.primary.main, - default: (theme) => theme.palette.primary.main, + error: (theme) => (theme.vars?.palette ?? theme.palette).error.main, + warning: (theme) => (theme.vars?.palette ?? theme.palette).warning.main, + success: (theme) => (theme.vars?.palette ?? theme.palette).success.main, + info: (theme) => (theme.vars?.palette ?? theme.palette).info.main, + primary: (theme) => (theme.vars?.palette ?? theme.palette).primary.main, + default: (theme) => (theme.vars?.palette ?? theme.palette).primary.main, }; function toneColor(theme: any, status: Status | "Other"): string { @@ -84,8 +85,13 @@ function cardPill(job: JobApplication, t: (key: any, params?: any) => string): { export default function KanbanBoard() { const theme = useTheme(); + const palette = theme.vars?.palette ?? theme.palette; + const customShadows = (theme.vars as any)?.customShadows ?? (theme as any).customShadows; const { t } = useI18n(); const [dragJobId, setDragJobId] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + const [moveError, setMoveError] = useState(""); + const [announcement, setAnnouncement] = useState(""); const [menuAnchor, setMenuAnchor] = useState(null); const [menuJobId, setMenuJobId] = useState(null); @@ -129,15 +135,31 @@ export default function KanbanBoard() { // stages stay on the card menu. const onDropTo = async (group: PipelineGroup) => { if (!dragJobId) return; + const movingJobId = dragJobId; const status = GROUP_ENTRY_STATUS[group]; setDragJobId(null); - await api.patch(`/jobapplications/${dragJobId}/status`, { status }); - jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j))); + setDragOverColumn(null); + setMoveError(""); + try { + await api.patch(`/jobapplications/${movingJobId}/status`, { status }); + jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j))); + setAnnouncement(`Job moved to ${statusLabel(t, status)}.`); + } catch (error: any) { + setMoveError(getApiErrorMessage(error, "Unable to move this job right now.")); + setAnnouncement("Job move failed."); + } }; const setStatus = async (id: number, status: Status) => { - await api.patch(`/jobapplications/${id}/status`, { status }); - jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); + setMoveError(""); + try { + await api.patch(`/jobapplications/${id}/status`, { status }); + jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); + setAnnouncement(`Job moved to ${statusLabel(t, status)}.`); + } catch (error: any) { + setMoveError(getApiErrorMessage(error, "Unable to move this job right now.")); + setAnnouncement("Job move failed."); + } }; const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? ""); @@ -157,7 +179,7 @@ export default function KanbanBoard() { }, [groups, t]); return ( - + {t("kanbanHint")} @@ -174,22 +196,46 @@ export default function KanbanBoard() { {columns.map(({ key, label, droppable }) => { const list = groups.get(key) ?? []; // Colour the column by its entry stage so the header dot still carries meaning. - const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : theme.palette.grey[500]; + const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : palette.text.disabled; + const isDragActive = dragJobId !== null; + const isActiveTarget = dragOverColumn === key; return ( { if (isDragActive) setDragOverColumn(key); }} + onBlur={() => { if (dragOverColumn === key) setDragOverColumn(null); }} + onKeyDown={(event) => { + if (event.key === "Escape" && isDragActive) { + event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled."); + } else if ((event.key === "Enter" || event.key === " ") && isDragActive) { + event.preventDefault(); + if (droppable) void onDropTo(key as PipelineGroup); + else setAnnouncement(`${label} is not a valid drop target.`); + } + }} + onDragEnter={() => { if (isDragActive) setDragOverColumn(key); }} + onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragOverColumn(null); }} onDragOver={(e) => { if (droppable) e.preventDefault(); }} onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }} sx={{ @@ -198,8 +244,13 @@ export default function KanbanBoard() { minHeight: 220, flex: { xs: "0 0 85vw", md: "none" }, scrollSnapAlign: { xs: "start", md: "none" }, - border: "none", - backgroundColor: theme.palette.grey[100], + border: `1px ${isDragActive ? "dashed" : "solid"} ${isActiveTarget ? palette.primary.main : palette.divider}`, + outline: isActiveTarget ? `2px solid ${droppable ? palette.primary.main : palette.error.main}` : "none", + outlineOffset: 2, + opacity: isDragActive && !droppable ? 0.65 : 1, + backgroundColor: isActiveTarget && droppable ? palette.action.hover : palette.grey[100], + transition: "background-color .15s, border-color .15s, outline-color .15s, opacity .15s", + "&:focus-visible": { boxShadow: customShadows?.focus }, }} > @@ -216,7 +267,7 @@ export default function KanbanBoard() { px: 0.75, py: 0.15, borderRadius: 999, - backgroundColor: theme.palette.grey[300], + backgroundColor: palette.grey[300], }} > @@ -237,14 +288,32 @@ export default function KanbanBoard() { setDragJobId(j.id)} - onDragEnd={() => setDragJobId(null)} + role="button" + tabIndex={0} + aria-pressed={dragJobId === j.id} + aria-label={`${j.jobTitle}, ${statusLabel(t, j.status)}. ${dragJobId === j.id ? "Picked up; focus a column and press Enter to move." : "Press Space to pick up."}`} + onKeyDown={(event) => { + if (event.key === "Escape" && dragJobId === j.id) { + event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled."); + } else if (event.key === " ") { + event.preventDefault(); + const pickingUp = dragJobId !== j.id; + setDragJobId(pickingUp ? j.id : null); + setDragOverColumn(null); + setAnnouncement(pickingUp ? `${j.jobTitle} picked up. Focus a column and press Enter to move.` : "Keyboard move cancelled."); + } + }} + onDragStart={() => { setDragJobId(j.id); setAnnouncement(`${j.jobTitle} picked up.`); }} + onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }} sx={{ cursor: "grab", borderRadius: 2.5, borderLeft: `4px solid ${cardColor}`, transition: "box-shadow .15s, transform .15s", + outline: dragJobId === j.id ? `2px solid ${palette.primary.main}` : "none", + outlineOffset: 2, "&:hover": { boxShadow: 4, transform: "translateY(-1px)" }, + "&:focus-visible": { boxShadow: customShadows?.focus }, "&:active": { cursor: "grabbing" }, }} > @@ -255,6 +324,7 @@ export default function KanbanBoard() { { e.stopPropagation(); setMenuJobId(j.id); @@ -275,7 +345,7 @@ export default function KanbanBoard() { sx={{ height: 22, fontWeight: 700, - backgroundColor: alpha(cardColor, 0.14), + backgroundColor: `color-mix(in srgb, ${cardColor} 14%, transparent)`, color: cardColor, }} /> @@ -288,7 +358,7 @@ export default function KanbanBoard() { key={tag} size="small" label={tag} - sx={{ height: 22, backgroundColor: alpha(theme.palette.primary.main, 0.12), color: "primary.main", fontWeight: 700 }} + sx={{ height: 22, backgroundColor: palette.action.hover, color: "primary.main", fontWeight: 700 }} /> ))} @@ -302,8 +372,8 @@ export default function KanbanBoard() { sx={{ height: 24, fontWeight: 700, - backgroundColor: alpha(theme.palette[pill.tone].main, 0.14), - color: theme.palette[pill.tone].main, + backgroundColor: `color-mix(in srgb, ${palette[pill.tone].main} 14%, transparent)`, + color: palette[pill.tone].main, }} /> @@ -330,6 +400,11 @@ export default function KanbanBoard() { ) : null} + {moveError ? setMoveError("")}>{moveError} : null} + + {announcement} + + {/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */} { setMenuAnchor(null); setMenuJobId(null); }}> {PIPELINE_GROUPS.flatMap((g) => { diff --git a/job-tracker-ui/src/kanban-grouped-board.test.tsx b/job-tracker-ui/src/kanban-grouped-board.test.tsx index a23bb88..7d666c7 100644 --- a/job-tracker-ui/src/kanban-grouped-board.test.tsx +++ b/job-tracker-ui/src/kanban-grouped-board.test.tsx @@ -11,6 +11,7 @@ jest.mock('./api', () => ({ get: jest.fn(), patch: jest.fn(() => Promise.resolve({ data: {} })), }, + getApiErrorMessage: jest.fn((_error, fallback) => fallback), })); // eslint-disable-next-line import/first @@ -86,6 +87,25 @@ test('a custom status gets an Other column that is not a drop target', async () await screen.findByText('Other'); // Custom statuses survive rather than being coerced into a canonical stage. expect(screen.getByText('Take-home assignment')).toBeInTheDocument(); + fireEvent.dragStart(screen.getByText('Odd Role').closest('.MuiCard-root')!); + const other = screen.getByRole('group', { name: 'Other column, not a drop target' }); + expect(other).toHaveAttribute('aria-disabled', 'true'); + expect(other).toHaveAttribute('data-drop-state', 'invalid'); +}); + +test('loading and retryable error states replace the board', async () => { + let rejectLoad!: (reason: unknown) => void; + mockedApi.get.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectLoad = reject; }) as any); + renderBoard(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Not Applied column' })).not.toBeInTheDocument(); + rejectLoad(new Error('offline')); + + expect(await screen.findByText('Unable to load the kanban board')).toBeInTheDocument(); + mockedApi.get.mockResolvedValueOnce({ data: [] } as any); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeInTheDocument(); }); test('dropping onto a group applies that group entry stage', async () => { @@ -97,6 +117,9 @@ test('dropping onto a group applies that group entry stage', async () => { const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!; fireEvent.dragStart(card.closest('.MuiCard-root')!); + expect(card.closest('.MuiCard-root')).toHaveAttribute('aria-pressed', 'true'); + fireEvent.dragEnter(column); + expect(column).toHaveAttribute('data-drop-state', 'active'); fireEvent.dragOver(column); fireEvent.drop(column); @@ -104,13 +127,44 @@ test('dropping onto a group applies that group entry stage', async () => { await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/7/status', { status: 'Applied' })); }); +test('keyboard pickup and drop moves a card to a valid column', async () => { + mockedApi.get.mockResolvedValue({ data: [job(11, 'Keyboard Role', 'Saved')] } as any); + renderBoard(); + + const card = (await screen.findByText('Keyboard Role')).closest('.MuiCard-root')!; + fireEvent.keyDown(card, { key: ' ' }); + expect(card).toHaveAttribute('aria-pressed', 'true'); + + const column = screen.getByRole('group', { name: 'Active column' }); + fireEvent.focus(column); + fireEvent.keyDown(column, { key: 'Enter' }); + + await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/11/status', { status: 'Applied' })); + expect(await screen.findByText('Job moved to Applied.')).toBeInTheDocument(); +}); + +test('a failed move keeps the card and reports a retryable error', async () => { + mockedApi.get.mockResolvedValue({ data: [job(12, 'Failed Move Role', 'Saved')] } as any); + mockedApi.patch.mockRejectedValueOnce(new Error('offline')); + renderBoard(); + + const card = (await screen.findByText('Failed Move Role')).closest('.MuiCard-root')!; + const column = screen.getByRole('group', { name: 'Active column' }); + fireEvent.dragStart(card); + fireEvent.dragOver(column); + fireEvent.drop(column); + + expect(await screen.findByText('Unable to move this job right now.')).toBeInTheDocument(); + expect(screen.getByText('Failed Move Role')).toBeInTheDocument(); +}); + test('the card menu offers precise stages grouped by section', async () => { mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any); renderBoard(); await screen.findByText('Saved Role'); - fireEvent.click(screen.getByRole('button', { name: '' }) ?? screen.getAllByRole('button')[0]); + fireEvent.click(screen.getByRole('button', { name: 'Change status for Saved Role' })); const menu = await screen.findByRole('menu'); // Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all. diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index a58daa3..eed5c48 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -489,6 +489,7 @@ export default function AppShell({ p: { xs: 2, sm: 3 }, bgcolor: "background.default", minHeight: "100vh", + minWidth: 0, }} >