diff --git a/docs/redesign/phase-2/TRACEABILITY.md b/docs/redesign/phase-2/TRACEABILITY.md
index 223e0ce..1a790f8 100644
--- a/docs/redesign/phase-2/TRACEABILITY.md
+++ b/docs/redesign/phase-2/TRACEABILITY.md
@@ -1,5 +1,7 @@
# Redesign traceability
+Current Jobs integration: [P5-04b scoped trace and evidence](../phase-5/p5-04b/README.md#preservation-traceability) maps PRES-J03–J07, W01/W02, E07–E09 and S03 to the shared workspace, compatibility adapter, native/unit regressions and rendered evidence. Its containing commit identifies the implementation checkpoint; earlier phase records below remain historical evidence.
+
Phase 5 planning: [57-contract change impact](../phase-5/PRESERVATION_IMPACT.md), [navigation migration](../phase-5/NAVIGATION_MIGRATION.md) and [visual evidence protocol](../phase-5/VISUAL_ACCEPTANCE.md). The successor [P5-00 readiness trace](../phase-5/p5-00/README.md#preservation-trace) records bounded shell prerequisite changes and actual evidence. P5-01 visible redesign has not begun; baseline, package/CV and production conditions remain explicit.
Successor planning checkpoint: [Phase 3 impact matrix and architecture](../phase-3/PRESERVATION_IMPACT_MATRIX.md). It maps all 57 contracts to proposed destinations/validation, without changing their requirements or claiming implemented/visual parity. The subsequent [P4-01 trace sheet](../phase-4/p4-01/TRACEABILITY.md), [P4-02 named-control/modal record](../phase-4/p4-02/README.md), [P4-03 preview keyboard record](../phase-4/p4-03/README.md), [P4-04 Settings layout/typography record](../phase-4/p4-04/README.md) and [P4-05 table/list density record](../phase-4/p4-05/README.md) link shared foundation changes to regression and representative visual evidence. [P4-06 acceptance](../phase-4/p4-06/README.md) and its [57-row reconciliation](../phase-4/p4-06/PRESERVATION_ACCEPTANCE.md) retain scoped evidence, incomplete checks and affected-migration gates; they do not claim exhaustive preservation or production readiness.
diff --git a/docs/redesign/phase-5/NAVIGATION_MIGRATION.md b/docs/redesign/phase-5/NAVIGATION_MIGRATION.md
index 1546d36..5191c24 100644
--- a/docs/redesign/phase-5/NAVIGATION_MIGRATION.md
+++ b/docs/redesign/phase-5/NAVIGATION_MIGRATION.md
@@ -22,7 +22,7 @@ Keep existing children visible in shell secondary navigation until their workspa
| Job applications | Job search → Jobs primary, `/jobs` | Tracked Table default; P5-03 | J03–J06 |
| Discover jobs | Jobs secondary, `/discover` | Jobs secondary row; discovery owner unchanged | J01, J02 |
| Reminders + count | Jobs secondary, label Follow-ups, `/reminders` | Retain count and all reasons, not just overdue rows | W11 |
-| Kanban board | Jobs secondary, `/kanban` | P5-04a same route; switch/alias only after P5-04b parity | J07 |
+| Kanban board | Jobs view switch, `/jobs?view=board`; `/kanban` retained | P5-04b integrates after proven parity; alias replaces history and preserves query/hash/state | J07 |
| Companies | Jobs secondary, `/companies` | Preserve `?edit=id`, contacts/history | J08 |
| Offer comparison | Jobs secondary, `/offers` | Retain recorded offer comparison, no new salary model | J09 |
| Job email / Correspondence | Job search → Correspondence **primary**, `/correspondence` | Inbox, Drafts, Review remain independent; not hidden in Documents | M01–M06 |
@@ -66,7 +66,7 @@ No compatibility removal date is approved. Each row retains refresh, direct entr
| `/jobs/:id` | Dedicated job workspace | 08a, 08b | W01–W12, M01, M02 |
| `/reminders` | Jobs → Follow-ups | 01 link only | W11 |
| `/operations` | Notification/account activity utility | 01, 02 link only | W12 |
-| `/kanban` | Keep standalone until parity; then direct replace to `/jobs?view=board` | 04a, gated 04b | J07 |
+| `/kanban` | P5-04b direct replace to `/jobs?view=board`, retaining other query/hash/state | 04a, 04b | J07 |
| `/companies` | Jobs → Companies, preserve `?edit=id` | 01, 02 link only | J08 |
| `/offers` | Jobs → Offers | 01 link only | J09 |
| `/correspondence` | Correspondence primary; inbox/drafts/review | 01 link only | M01–M06 |
diff --git a/docs/redesign/phase-5/OPEN_BLOCKERS.md b/docs/redesign/phase-5/OPEN_BLOCKERS.md
index 68c571a..b5123db 100644
--- a/docs/redesign/phase-5/OPEN_BLOCKERS.md
+++ b/docs/redesign/phase-5/OPEN_BLOCKERS.md
@@ -24,7 +24,7 @@ Reconciled against [P4-06](../phase-4/p4-06/README.md#opendeferred-issue-reconci
| F01 / audit D01 stale CV revision (related profile/job revisions distinct) | Open version increments without expected revision guard | **Blocks P5-09–14 broad CV**; separately authorized integrity work with cross-client proof. Profile/job persistence changes likewise gated, but their unchanged page links are safe. No automatic B07 |
| Creation-intent idempotency | Open independent contract; not B02 identity or stale revision | **Separate backend/product work**; no Add/import/POST-on-mount expansion in Phase 5. Existing create review/no-write navigation must remain |
| CV margins/templates/preview/pagination | Open, user concern reiterated during P4; current preview has extra pagination/cropping logic | **Blocks P5-09–14** via dedicated G-CV-RENDER corpus/repair checkpoint; keyboard passing does not prove geometry. No broad renderer replacement inside UI slice |
-| Table/Board filter/population mismatch | **Read prerequisite resolved in [P5-04b readiness](p5-04b-readiness/README.md)**: shared logical query, complete ID membership, owner/trash/derived filters and mutation refresh proven | **GO for P5-04b** with existing full-array scaling limits documented; integration itself remains unimplemented and must preserve query/route state |
+| Table/Board filter/population mismatch | **Read prerequisite resolved in [P5-04b readiness](p5-04b-readiness/README.md)**: shared logical query, complete ID membership, owner/trash/derived filters and mutation refresh proven | [P5-04b integration](p5-04b/README.md) preserves that query/route contract; full-array scaling remains an explicit limitation |
| P3-O01 analytics names/periods/populations | Open source-confirmed semantic limitations | **Blocks P5-06/07 accurate presentation acceptance**; labels/values tests within those slices, backend metrics/data repair separate |
| F04/B06 production ingress | **Open**; repository/local checks are not deployed proof. Historical DNS/HTTPS observations do not prove actual peer/firewall boundary or current topology | **Production gate; does not block local P5 slices**. Operator evidence for actual Traefik peer, nginx trust, listeners/ports, direct/spoofed access, B04 client IP, firewall/NAT/IPv6, DNS/TLS/redirect and restart needed; no closure or credentials accessed here |
| Protected-main CI/browser runner and deployment | Unresolved remote-runner evidence; local tests pass | **Separate release gate**; feature-branch docs/UI push is not production deployment approval |
diff --git a/docs/redesign/phase-5/PHASE5_PLAN.md b/docs/redesign/phase-5/PHASE5_PLAN.md
index 08ea190..d7ba9e0 100644
--- a/docs/redesign/phase-5/PHASE5_PLAN.md
+++ b/docs/redesign/phase-5/PHASE5_PLAN.md
@@ -13,7 +13,7 @@ Sources: [Phase 3 sequencing](../phase-3/PHASE4_IMPLEMENTATION_PLAN.md), [P4-06
| 3 — P5-02 | Global shell utilities | Interaction and visible UI; medium | P5-01; existing command and notification capabilities |
| 4 — P5-03 | Jobs Table and mobile cards | Major visible UI; high | P5-01/02, G-JOBS behavior fixtures; includes shared Trash consumer |
| 5 — P5-04a | Board presentation | Major visible UI; high | P5-03; keep `/kanban` and existing population |
-| 6 — P5-04b | Table/Board workspace integration | Architecture/interaction; medium | **Blocked** on G-JOBS read/query parity and separately authorized contract work |
+| 6 — P5-04b | Table/Board workspace integration | Architecture/interaction; medium | G-JOBS proven by separate readiness; [integration record](p5-04b/README.md) owns current acceptance |
| 7 — P5-06 | Dashboard presentation | Major visible UI; high | P5-01/02 and G-METRICS; need not wait for 04b |
| 8 — P5-07 | Existing-report Analytics destination | Presentation/refactoring; high | P5-06; exact existing report inventory/definitions and preference proof |
| 9 — P5-08a | Job header and Overview | Major visible UI; high | Jobs entry regression, G-WORKSPACE mobile proof; no package relocation |
diff --git a/docs/redesign/phase-5/READINESS_GATES.md b/docs/redesign/phase-5/READINESS_GATES.md
index e7326f8..14739d5 100644
--- a/docs/redesign/phase-5/READINESS_GATES.md
+++ b/docs/redesign/phase-5/READINESS_GATES.md
@@ -24,7 +24,7 @@ Exit: focused reproduction now passes, no general axe exclusions, final complete
## G-JOBS — before table/board consolidation
-**Current query gate: GO after [P5-04b readiness](p5-04b-readiness/README.md).** Both existing endpoints now use one server query, the separate clients share URL interpretation, and native membership/refresh/ownership evidence passes. Full Board population remains O(N), not one Table page; see the explicit performance ceiling. Integration has not begun. The paragraphs below preserve the original prerequisite and why the readiness checkpoint was necessary.
+**Current query gate: GO after [P5-04b readiness](p5-04b-readiness/README.md).** Both existing endpoints now use one server query and native membership/refresh/ownership evidence passes. [P5-04b integration](p5-04b/README.md) uses that unchanged contract in the shared workspace. Full Board population remains O(N), not one Table page; see the explicit performance ceiling. The paragraphs below preserve the original prerequisite and why the readiness checkpoint was necessary.
Before P5-03, extend PRES-J05/J06 behavioral coverage for saved-view create/select/delete, filtered selection, partial bulk failures, column/page preferences, custom-status URLs, Back/refresh, capture review without writes and 320px actions. Preserve current local Comfortable default; density is neither server-persisted nor a CV setting.
diff --git a/docs/redesign/phase-5/README.md b/docs/redesign/phase-5/README.md
index c3401d2..c298bf7 100644
--- a/docs/redesign/phase-5/README.md
+++ b/docs/redesign/phase-5/README.md
@@ -1,6 +1,6 @@
# Phase 5 — Representative screens / major UI redesign plan
-**Current successor: [P5-04b query readiness](p5-04b-readiness/README.md), GO for the shared-read prerequisite.** P5-01–P5-04a implementation records remain authoritative for their scopes. The separate Table and Board now share proven logical query membership; Table/Board integration has not begun. P5-02 manual screen-reader acceptance remains mandatory for P5-15 and does not block independent Jobs work. P2-D01, F04, native touch verification and recorded baseline timing observations remain open. B07 has not started.
+**Current successor: [P5-04b Table/Board integration](p5-04b/README.md).** Jobs now has two URL-addressable views with shared filters, matching membership and a retained `/kanban` alias. The [query-readiness checkpoint](p5-04b-readiness/README.md) remains the backend contract evidence; P5-01–P5-04a records remain authoritative for their scopes. P5-02 manual screen-reader acceptance remains mandatory for P5-15. P2-D01, F04, native touch verification, Board scaling and recorded baseline timing observations remain open. P5-06 and B07 have not started.
Historical planning checkpoint: source `2a2f9e48052c562ef599825458d0d3a6f9f9fc61`, inspected on 2026-09-13 on `codex/ui-redesign-phase4`. That checkpoint was documentation-only; the successor record above owns implementation/validation status.
diff --git a/docs/redesign/phase-5/p5-04b/README.md b/docs/redesign/phase-5/p5-04b/README.md
new file mode 100644
index 0000000..5534ef2
--- /dev/null
+++ b/docs/redesign/phase-5/p5-04b/README.md
@@ -0,0 +1,105 @@
+# P5-04b — Table/Board integration
+
+Source checkpoint: `6fc8937701a073f89fb680235bce58029a27550b`, branch `codex/ui-redesign-phase4`. This slice implements only the approved [P5-04b plan](../PHASE5_PLAN.md). The [separate readiness checkpoint](../p5-04b-readiness/README.md) established matching-ID parity first; its backend contract, authorization, ordering and complete Board response are unchanged here.
+
+## Structure and compatibility
+
+- `/jobs` defaults to Table; `/jobs?view=table` and `/jobs?view=board` explicitly select presentation. Unknown view values safely use Table. No per-account presentation preference or SavedView schema is added.
+- `/kanban` remains an authenticated route. Its replace redirect targets `/jobs`, sets `view=board` and retains all other query parameters, hash and router state. A conflicting `view=table` on the legacy **Board** URL is overwritten so historical Board links still mean Board. The alias does not add a history entry or redirect externally. All 31 route/compatibility patterns remain.
+- The existing Jobs sidebar destination now covers both views; the separate Board child is removed. Discover, Reminders, Companies, Offers, Trash and other destinations remain. No provider/auth/permission change or command-catalogue expansion.
+- App retains the shared heading and Add Job action. The existing `JobTable` is the bounded workspace owner: one responsive filter/saved-view toolbar, a labelled Table/Board link group, matching-result count, and either the original Table/mobile cards or `KanbanBoard`. A new wrapper/framework would duplicate the existing mature filter owner, so none was introduced.
+- The view links have `aria-current="page"`, text labels, shared theme focus treatment and distinct filled/outlined states. They use native navigation links, support keyboard Enter and remain mounted across switches. Activating the current view is a no-op. New view choices push history; filter edits retain the established replace behavior. Back/Forward restores the recorded query/view.
+
+## State and lifetime contract
+
+Search, status (including raw custom values), company, location, follow-up, readiness, include-deleted scope and sort parameters survive switching. The Table's page stays in the URL, is ignored by the Board API parser, and is restored on return. Changing filters or applying a saved view resets the Table page as before. Board grouping/stage ordering still controls visual card placement; Table sort does not redefine Board columns.
+
+Table selection is explicitly page/view-local: it clears when leaving or re-entering Table, including history traversal. Hidden selections cannot drive bulk actions. Comfortable/Compact remains local to the mounted workspace and survives a view switch; persisted page-size/column preferences keep their existing owner. Density/columns/pagination/bulk controls are Table-only. Trash always renders Table, even with a crafted `view=board` parameter.
+
+Job opening still records the originating row/card on its history entry. Full workspace Back and browser Back restore the originating query and focus. Switching presentation clears only the transient `focusJobId` hint on the new history entry, preventing an old job-return hint from stealing focus from the switch; unrelated route state is retained.
+
+## Loading and performance
+
+Board code is lazy-loaded on first selection, with an accessible local fallback. Only the active results representation is mounted. The Table controller/filter owner remains mounted, but its `useViewResource` read is disabled while Board is active; it reloads on return. Table data/preferences can remain in local memory during the switch, not in a new shared/account cache. Board unmounts on leaving and reloads on return. It receives the existing creation refresh token, so Add Job refreshes whichever representation is active.
+
+Board query edits debounce for 250 ms using the existing hook, avoiding a full read per keystroke. Initial Board entry reads immediately. One settled Board query uses one complete read; there is no Table-page crawl, arbitrary page size, hidden full-result preload or duplicate Table read. The native regression counts Jobs-result requests across both switch directions. Company-filter metadata still loads through the existing `useCompanies` owner and is now available in Board too; the one-read statement does not claim the entire application makes only one HTTP request. Reload/error behavior and status PATCH → membership reload remain unchanged.
+
+**Residual ceiling:** Board still returns and renders the full matching array (O(N)); this integration does not add a result cap or virtualization. Server data can change between separate reads; matching-ID parity is the same-query/stable-data guarantee established by readiness, not a database snapshot across requests. Large-account virtualization/paging requires separate design work.
+
+## Preservation traceability
+
+This maps affected contracts, not exhaustive coverage of every branch in the 57-contract catalogue.
+
+| Contract | Existing behavior → integration | Automated evidence | Visual/UX evidence |
+|---|---|---|---|
+| PRES-J03/J04 | Reviewed creation/capture → existing Add Job action now shared with Board, same modal and post-create refresh | Existing quick-capture/command/create regressions and extension checks; new active-view creation-refresh case | Shared Add Job entry visible in Table and Board screenshots; no new creation flow |
+| PRES-J05 | Filters, saved views, sort/page/columns, Table/mobile actions → shared query owner and view links | `jobs-view-integration.test.tsx`, retained `job-table-density`, native `board-presentation` membership/history cases and `table-density` | Same filtered population, page-two return, desktop/mobile Table, EN/NB and both themes |
+| PRES-J06 | Bulk/edit/Trash/restore → selection cleared at view boundary, no Board bulk UI | Existing partial-bulk/Table/Trash tests; new Trash view-parameter and selection-lifetime checks | Retained Table controls; density/browser suite |
+| PRES-J07 | Full population, custom statuses, pointer/keyboard moves and precise menu → existing Board renderer below shared filters | Retained 15 Board unit cases; native move/failure/custom/filter tests; integration membership/count checks | Keyboard switch/drop/focus, custom query, empty/error/loading, narrow Board |
+| PRES-W01/W02 | Open job/full workspace/Back and status progression → canonical query preserved | Native Table row Back and Board card Back; unchanged workspace/status tests | Focus returns to initiating row/card |
+| PRES-E07 | Navigation, mobile shell and active destination → one Jobs item, Board alias remains | Native legacy replace/history/hash/state case; updated shell test now asserts Jobs current + Board switch current | Desktop/rail/mobile shell retained |
+| PRES-E08 | Locale/theme/focus/error semantics → existing MUI tokens and labels | Full frontend/browser suites; integrated axe/focus checks | Light/dark × English/Bokmål, phone/tablet/desktop |
+| PRES-E09 | Account/private-state disposal, authenticated reads → unchanged provider/API boundaries | Full B02/account isolation and backend parity/ownership regressions; native fixture ID ownership comparison | No new private storage or identity source |
+| PRES-S03 | Page-size/column/theme/language preferences → existing mechanisms retained | Existing preference/density tests plus local density switch test | Comfortable/Compact retained in Table; no Board density claim |
+
+## Files and scope
+
+Application changes are limited to `src/App.tsx` (alias/sidebar route wiring), `components/JobTable.tsx` (existing shared owner and conditional renderer), `components/KanbanBoard.tsx` (embedded toolbar removal, debounce, active refresh/count), and `i18n/translations.ts` (EN/NB view/count labels and workspace subtitle). No backend, schema, query parser, drag/drop engine, authentication, theme, CV, package or production infrastructure change.
+
+Tests: new `jobs-view-integration.test.tsx`; updated Board unit assertions move the old standalone switch responsibility to the integration fixture; `e2e/board-presentation.spec.ts` adds canonical routing/history and both-view evidence without removing movement/failure checks; `shell-readiness.spec.ts` reflects the approved single Jobs destination while retaining rail/mobile/heading-focus coverage. `kanban-theme.spec.ts` now polls the observable no-horizontal-scroll result after resize, allowing the shared responsive toolbar's React update to settle; it records the measured samples and still fails persistent overflow.
+
+## Validation and evidence
+
+Inherited baseline: 934 backend, 384 frontend, 76 actual browser passes + P2-D01 expected failure; rate-limit 9, ingress 6, PDF 1, configuration 3. Final validation is complete; no unexpected failures remain.
+
+Six new frontend cases cover: both-direction history/page/density/selection and hidden-read behavior; custom-status membership; zero-result membership; shared Board filters; Trash isolation; and saved-query application plus active creation-refresh. The new native browser case proves legacy replacement (including conflicting view, hash and router state) and unknown-view fallback. Existing native Board coverage additionally exercises both-direction ID parity, page-two return, switch focus, one request per selected view, no-results/custom queries and four locale/theme combinations. Existing move/error/Back assertions remain.
+
+| Check | Current result |
+|---|---|
+| Backend Release regression suite | 934 passed, unchanged |
+| Frontend Jest suite | Final-code run: 390 passed in 78 suites, 119.9 seconds; 384 + 6 integration cases |
+| Full native browser suite | 77 actual passes + unchanged P2-D01 expected failure, 9.8 minutes; 76 + 1 legacy-route case. Zero skipped, unexpected or flaky outcomes |
+| Rate-limit / production-mode ingress / PDF admission | 9 / 6 / 1 passed, 10.3 / 6.4 / 7.7 seconds; unchanged |
+| TypeScript / ESLint | Passed |
+| Frontend production export / API Release `-warnaserror` | Passed; API zero warnings/errors |
+| Ingress configuration | 3 passed |
+| Supply-chain policy / Ollama evaluation / browser extension | 3 / 5 / 3 passed |
+| Staged secret scan / whitespace / local Markdown targets | Passed; links check local files, not external URLs or anchor fragments |
+
+Commands use the repository-required `rtk`/`rtk proxy` wrapper. UI commands: `npm.cmd test -- --runInBand --silent`, `npm.cmd run typecheck`, `npm.cmd run lint`, `npm.cmd run build`; build uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api`, `CI=false`. Backend: `C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj -c Release --no-restore --logger 'console;verbosity=minimal'` and `build JobTrackerApi/JobTrackerApi.csproj -c Release --no-restore -warnaserror`. Offline checks: `python scripts/test-ingress.py`, `python scripts/test-supply-chain.py`, `python scripts/test-ollama-evaluation.py`, `node --test browser-extension/tests/*.test.mjs`, `python scripts/supply-chain.py scan-secrets`, `git diff --check`, `git diff --cached --check`.
+
+Native browser command: `npm.cmd run test:e2e -- --reporter=line,json`, with `DOTNET_ROOT=C:/Users/Connor/.dotnet`, `DOTNET_HOST_PATH=C:/Users/Connor/.dotnet/dotnet.exe`, `PLAYWRIGHT_STATIC_EXPORT=true`, `PLAYWRIGHT_PREBUILT_EXPORT=true`, `PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/integration-accepted.json`. Security commands ran sequentially against the same local API port with the same .NET environment: `npm.cmd run test:rate-limits -- --reporter=line`, `npm.cmd exec playwright test -- --config=playwright.ingress.config.ts --reporter=line`, `npm.cmd run test:pdf-admission -- --reporter=line`. These are local regressions, not production ingress proof.
+
+### Rendered visual/interaction review
+
+Evidence is synthetic local data captured through the actual API + exported application, not mocked UI screenshots. Reviewed the actual rendered images for readable hierarchy, status labels, restrained shared surfaces, wrapping, selected links, focus and result context:
+
+Capture date: **2026-09-29**, local Playwright Chromium, standard synthetic owner (27 fixture jobs including one custom stage), EN/Bokmål and current test clock; no real user data or provider. The [readiness screenshots](../p5-04b-readiness/README.md) and [P5-04a record](../p5-04a/README.md) are the pre-integration reference. This is reviewed state evidence, not a clock-frozen pixel-diff claim. Existing fixture viewport heights are retained (320×720, 768×900, 1024×900); the broader Phase 5 visual protocol's other heights/1920-wide matrix is not newly certified by this bounded slice. Resolve this implementation's SHA with `git log -1 -- docs/redesign/phase-5/p5-04b/README.md`, avoiding a self-referential hash placeholder.
+
+- Desktop 1440×900: [Table light/EN](evidence/integrated-table-desktop-light-en.png), [Board light/EN](evidence/board-desktop-light-en.png), [Table dark/NB](evidence/integrated-table-desktop-dark-nb.png), [Board dark/NB](evidence/board-desktop-dark-nb.png).
+- Phone 390×844: [Table light/EN](evidence/integrated-table-phone-light-en.png), [Board light/EN](evidence/board-phone-light-en.png), [Table dark/NB](evidence/integrated-table-phone-dark-nb.png), [Board dark/NB](evidence/board-phone-dark-nb.png). All four theme/language combinations are captured in the native test output; this committed set retains representative pairs.
+- Narrow 320×720: [Board dark/NB](evidence/board-phone-320-dark-nb.png), with wrapped follow-up label and retained filter/switch access. Native overflow assertions also cover 768 and 1024 widths. Desktop Table retains its established internal horizontal scrolling for optional columns/actions; this is not page-level overflow.
+- Shared query: [24 matching Board jobs with keyboard switch focus](evidence/integrated-filtered-board-keyboard.png), [page two of that same query](evidence/integrated-filtered-table-page-two.png), [custom status](evidence/integrated-custom-status-board.png), [zero results](evidence/board-zero-query-light-en.png).
+- [Retryable error](evidence/board-error-light-en.png) and [empty state](evidence/board-empty-light-en.png). Lazy loading and actual network loading are both exercised; retry does not fall back to an unfiltered read.
+
+Native keyboard automation verifies Enter on the view link keeps focus on that same control after content replacement; the screenshot shows the Phase 4 focus ring. Existing pointer/keyboard move, custom-stage menu, failed-write, row/card Back and shell drawer checks remain active. These are automated browser interactions plus human-style screenshot review by the agent, not a manual screen-reader session. The inherited post-axe missing-glyph screenshot artifact remains an evidence limitation; no typography/icon fix was smuggled into integration.
+
+Development checks found and corrected test-only label/matcher/type assumptions. The old loading fixture initially observed the new lazy-code fallback before its network handler existed; it now waits for the actual Board request before releasing its simulated error. No production behavior was weakened to make that fixture pass.
+
+The first full browser run (13.2 minutes) had 73 actual passes + P2-D01 expected failure and four unexpected Table-density failures (one per theme/locale). The added result summary duplicated the existing visible loading message: `main.getByText(t.loading, { exact: true })` found two elements at `table-density.spec.ts:235`. The summary now reports only settled results; the established results area owns loading/error feedback. The original four assertions were left unchanged. This was an integration regression, not inherited debt or an expected failure.
+
+The [next browser run](evidence/second-browser-summary.json) (10.1 minutes) passed all four unchanged Table-density cases and had 76 actual passes + P2-D01 expected failure, with one unexpected failure: the old immediate post-resize Board assertion read 27px horizontal scroll at 375px. Its failure screenshot showed the settled mobile toolbar fitting the viewport; the same test had passed in the first full run. Integration adds a `useMediaQuery`-driven shared toolbar, so reading the frame immediately after `setViewportSize` is not a stable readiness check. The assertion now uses bounded Playwright polling with the same zero-scroll expectation and records samples. No application overflow-hiding rule or relaxed threshold was added.
+
+Three focused repetitions passed after that test correction: [measured resize samples](evidence/resize-samples.json) were **[27, 0], [27, 0], [0]**. This independently confirms the brief pre-reflow measurement and subsequent zero overflow, rather than merely obtaining a lucky passing rerun.
+
+The [final full browser result](evidence/final-browser-summary.json) records **77 actual passes plus P2-D01's unchanged expected failure**, with no retries, unexpected failures or skipped cases. Its resize sample is `[0]`. The fixture retains all prior Board movement/failure and Table density assertions.
+
+**Accessibility evidence limits:** final Board phone axe checks reported no violations but left `color-contrast` incomplete for eight nodes in each theme. The empty Board check left six contrast nodes and one `aria-prohibited-attr` node incomplete. These are unresolved automated checks, not passes or screen-reader acceptance. Keyboard/focus regressions pass and rendered screenshots were inspected; manual spoken-name/live-state acceptance remains mandatory for P5-15. No existing expected failure or incomplete check was reclassified to obtain this checkpoint.
+
+## Open acceptance items / stop
+
+Keep open: P5-02 manual screen-reader spoken names/live-state acceptance for P5-15, native touch-drag verification, P2-D01 expected failure/native package proof, F04 production ingress evidence, Board full-array scaling, and readiness R01/R02 command-focus/CV baseline observations. Automated axe/keyboard checks do not close manual screen-reader/touch requirements. Other inherited debt remains with its existing owner.
+
+P5-06, P5-05's unapproved quick preview, CV prerequisites and B07 are outside this slice. Stop after the separate integration checkpoint; no subsequent implementation is authorized by this record.
+
+**P5-04b acceptance: complete within the approved scope.** The shared-query prerequisite remains satisfied; native membership, history, alias, active-view request and focus evidence prove the integration. No integration blocker remains. The open items above retain their separate acceptance owners; completion does not certify production ingress, native touch or manual screen-reader behavior.
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-desktop-dark-nb.png b/docs/redesign/phase-5/p5-04b/evidence/board-desktop-dark-nb.png
new file mode 100644
index 0000000..92f07b7
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-desktop-dark-nb.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-desktop-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/board-desktop-light-en.png
new file mode 100644
index 0000000..fe9a490
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-desktop-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-empty-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/board-empty-light-en.png
new file mode 100644
index 0000000..a25f79b
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-empty-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-error-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/board-error-light-en.png
new file mode 100644
index 0000000..f334e44
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-error-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-phone-320-dark-nb.png b/docs/redesign/phase-5/p5-04b/evidence/board-phone-320-dark-nb.png
new file mode 100644
index 0000000..5756c85
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-phone-320-dark-nb.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-phone-dark-nb.png b/docs/redesign/phase-5/p5-04b/evidence/board-phone-dark-nb.png
new file mode 100644
index 0000000..fc0c3ec
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-phone-dark-nb.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-phone-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/board-phone-light-en.png
new file mode 100644
index 0000000..d339545
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-phone-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/board-zero-query-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/board-zero-query-light-en.png
new file mode 100644
index 0000000..ac2ea05
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/board-zero-query-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/final-browser-summary.json b/docs/redesign/phase-5/p5-04b/evidence/final-browser-summary.json
new file mode 100644
index 0000000..dfdc4ce
--- /dev/null
+++ b/docs/redesign/phase-5/p5-04b/evidence/final-browser-summary.json
@@ -0,0 +1,689 @@
+{
+ "stats": {
+ "startTime": "2026-09-29T20:46:15.719Z",
+ "duration": 588417.8189999999,
+ "expected": 78,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "actualPasses": 77,
+ "expectedFailures": [
+ {
+ "title": "[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change",
+ "expected": "failed",
+ "outcome": "expected",
+ "actual": [
+ "failed"
+ ]
+ }
+ ],
+ "unexpected": [],
+ "boardAxe": [
+ {
+ "name": "board-phone-light-axe",
+ "violations": [],
+ "incomplete": [
+ {
+ "id": "color-contrast",
+ "nodes": 8
+ }
+ ]
+ },
+ {
+ "name": "board-phone-dark-axe",
+ "violations": [],
+ "incomplete": [
+ {
+ "id": "color-contrast",
+ "nodes": 8
+ }
+ ]
+ },
+ {
+ "name": "board-empty-light-en-axe",
+ "violations": [],
+ "incomplete": [
+ {
+ "id": "aria-prohibited-attr",
+ "nodes": 1
+ },
+ {
+ "id": "color-contrast",
+ "nodes": 6
+ }
+ ]
+ }
+ ],
+ "mobileResizeScrollSamples": [
+ [
+ 0
+ ]
+ ],
+ "cases": [
+ {
+ "title": "public entry points meet automated WCAG A and AA checks",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "primary authenticated workspaces meet automated WCAG A and AA checks",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "retained profile request from A cannot write using B cookies and fresh B CSRF",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "missing and forged session contexts fail closed while same-account profile saves work",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "crafted CV identifiers cannot cross read update delete or job-association ownership",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E05 E07 E08] account switch disposes open command, account and notification surfaces",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "two tabs discard A's private form before B can use it, including Back navigation",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J07 J05 W01 W02 E07 E08 E09] Board query membership matches Table and preserves moves, focus and route",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J05 J07 E07 W01] legacy Board replaces history and preserves query, hash and route state; default Jobs stays Table",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J07 E08] Board loading, retryable error and fully empty columns",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/en preview keyboard, pagination and responsive state",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/nb preview keyboard, pagination and responsive state",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/nb preview keyboard, pagination and responsive state",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/en preview keyboard, pagination and responsive state",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "CSV export has safe cells and headers, and browser download preserves its UTF-8 bytes",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "discovery remains usable across widths and opens the reviewed import",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "kanban columns use dark theme surfaces",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/en/1440 named controls, modal keyboard and retained draft",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/nb/1440 named controls, modal keyboard and retained draft",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/nb/390 named controls, modal keyboard and retained draft",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/en/390 named controls, modal keyboard and retained draft",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-C01 PRES-C06 PRES-C07 PRES-C10] mobile CV override persists without changing the canonical profile or sibling CV",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change",
+ "expected": "failed",
+ "outcome": "expected",
+ "actual": [
+ "failed"
+ ]
+ },
+ {
+ "title": "[PRES-S03 PRES-W11] responsive settings navigation keeps rule edits and persists notification preferences",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-S03] rule changes persist through the real API",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-W01 PRES-J04] keyboard command navigation and legacy workspace entry preserve the selected job",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-A01 PRES-S04 PRES-E07] non-admin navigation cannot grant API privileges or unlock CV AI",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] light/en/1440 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] light/en/390 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] light/nb/1440 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] light/nb/390 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-E08 PRES-J05 PRES-J07] light loaded status semantics, navigation and command focus",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] dark/en/1440 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] dark/en/390 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] dark/nb/1440 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-J08] dark/nb/390 loading \u00e2\u2020\u2019 error \u00e2\u2020\u2019 retry \u00e2\u2020\u2019 empty \u00e2\u2020\u2019 populated",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 PRES-E08 PRES-J05 PRES-J07] dark loaded status semantics, navigation and command focus",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/en Settings headings, reflow and retained actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/nb Settings headings, reflow and retained actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/en Settings headings, reflow and retained actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/nb Settings headings, reflow and retained actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 E08 A03] light-en mobile labels, focus and admin reflow",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 J03 J08] light-en command list, keyboard and no-write actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-02 PRES-E07 E08 E09 S02] light-en utility search and notification states",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E08 C06 S05] light-en real Tooltip contrast and open-toast action access",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-01 PRES-E07 E08 A03] light-en grouped shell, rail menu and route focus",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 E09 C06 S02 S03] light-en dirty editor survives shell utilities and cancel",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 E08 A03] light-nb mobile labels, focus and admin reflow",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 J03 J08] light-nb command list, keyboard and no-write actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-02 PRES-E07 E08 E09 S02] light-nb utility search and notification states",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E08 C06 S05] light-nb real Tooltip contrast and open-toast action access",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-01 PRES-E07 E08 A03] light-nb grouped shell, rail menu and route focus",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 E08 A03] dark-en mobile labels, focus and admin reflow",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 J03 J08] dark-en command list, keyboard and no-write actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-02 PRES-E07 E08 E09 S02] dark-en utility search and notification states",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E08 C06 S05] dark-en real Tooltip contrast and open-toast action access",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-01 PRES-E07 E08 A03] dark-en grouped shell, rail menu and route focus",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 E08 A03] dark-nb mobile labels, focus and admin reflow",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 J03 J08] dark-nb command list, keyboard and no-write actions",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-02 PRES-E07 E08 E09 S02] dark-nb utility search and notification states",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E08 C06 S05] dark-nb real Tooltip contrast and open-toast action access",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-01 PRES-E07 E08 A03] dark-nb grouped shell, rail menu and route focus",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[P5-00 PRES-E07 E09 C06 S02 S03] dark-nb dirty editor survives shell utilities and cancel",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "API liveness and database readiness are separate public probes",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "public plans stay honest, responsive and keyboard accessible",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "login establishes an authenticated session",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "a Free account keeps manual work available while AI actions stay honestly locked",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "a saved job can be created through the reviewed UI flow",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "the dedicated application workspace survives deep links, long data and unsaved navigation",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "Career Workspace loads from the authenticated application shell",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "a public CV renders anonymously and downloads as PDF",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "the Code template exports a long structured CV as searchable multi-page PDF",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J05 J06 W01 W02 S03 E08 E09] light/en table and cards preserve behavior at both densities",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J05 J06 W01 W02 S03 E08 E09] light/nb table and cards preserve behavior at both densities",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J05 J06 W01 W02 S03 E08 E09] dark/en table and cards preserve behavior at both densities",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ },
+ {
+ "title": "[PRES-J05 J06 W01 W02 S03 E08 E09] dark/nb table and cards preserve behavior at both densities",
+ "expected": "passed",
+ "outcome": "expected",
+ "actual": [
+ "passed"
+ ]
+ }
+ ]
+}
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-custom-status-board.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-custom-status-board.png
new file mode 100644
index 0000000..19704eb
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-custom-status-board.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-filtered-board-keyboard.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-filtered-board-keyboard.png
new file mode 100644
index 0000000..613c228
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-filtered-board-keyboard.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-filtered-table-page-two.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-filtered-table-page-two.png
new file mode 100644
index 0000000..5b9e452
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-filtered-table-page-two.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-table-desktop-dark-nb.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-desktop-dark-nb.png
new file mode 100644
index 0000000..0b60a35
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-desktop-dark-nb.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-table-desktop-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-desktop-light-en.png
new file mode 100644
index 0000000..b00a12e
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-desktop-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-table-phone-dark-nb.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-phone-dark-nb.png
new file mode 100644
index 0000000..8d654f6
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-phone-dark-nb.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/integrated-table-phone-light-en.png b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-phone-light-en.png
new file mode 100644
index 0000000..4fd7dc8
Binary files /dev/null and b/docs/redesign/phase-5/p5-04b/evidence/integrated-table-phone-light-en.png differ
diff --git a/docs/redesign/phase-5/p5-04b/evidence/resize-samples.json b/docs/redesign/phase-5/p5-04b/evidence/resize-samples.json
new file mode 100644
index 0000000..43bb791
--- /dev/null
+++ b/docs/redesign/phase-5/p5-04b/evidence/resize-samples.json
@@ -0,0 +1,23 @@
+{
+ "stats": {
+ "startTime": "2026-09-29T20:45:27.001Z",
+ "duration": 18374.364,
+ "expected": 3,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "mobileScrollSamples": [
+ [
+ 27,
+ 0
+ ],
+ [
+ 27,
+ 0
+ ],
+ [
+ 0
+ ]
+ ]
+}
diff --git a/docs/redesign/phase-5/p5-04b/evidence/second-browser-summary.json b/docs/redesign/phase-5/p5-04b/evidence/second-browser-summary.json
new file mode 100644
index 0000000..db68998
--- /dev/null
+++ b/docs/redesign/phase-5/p5-04b/evidence/second-browser-summary.json
@@ -0,0 +1,67 @@
+{
+ "stats": {
+ "startTime": "2026-09-29T20:34:53.673Z",
+ "duration": 608707.245,
+ "expected": 77,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ },
+ "actualPasses": 76,
+ "expectedFailures": [
+ {
+ "title": "[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves frozen documents after current sources change",
+ "expected": "failed",
+ "outcome": "expected",
+ "actual": [
+ "failed"
+ ]
+ }
+ ],
+ "unexpected": [
+ {
+ "title": "kanban columns use dark theme surfaces",
+ "expected": "passed",
+ "outcome": "unexpected",
+ "actual": [
+ "failed"
+ ]
+ }
+ ],
+ "boardAxe": [
+ {
+ "name": "board-phone-light-axe",
+ "violations": [],
+ "incomplete": [
+ {
+ "id": "color-contrast",
+ "nodes": 8
+ }
+ ]
+ },
+ {
+ "name": "board-phone-dark-axe",
+ "violations": [],
+ "incomplete": [
+ {
+ "id": "color-contrast",
+ "nodes": 8
+ }
+ ]
+ },
+ {
+ "name": "board-empty-light-en-axe",
+ "violations": [],
+ "incomplete": [
+ {
+ "id": "aria-prohibited-attr",
+ "nodes": 1
+ },
+ {
+ "id": "color-contrast",
+ "nodes": 6
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/roadmap/todo.md b/docs/roadmap/todo.md
index d28996c..9948a45 100644
--- a/docs/roadmap/todo.md
+++ b/docs/roadmap/todo.md
@@ -4,6 +4,8 @@ Status reconciled: 2026-09-28.
## Current repository work
+- **P5-04b complete — Table/Board integration:** one Jobs filter owner and URL view switch, retained `/kanban` alias, Table page/selection lifetime and full Board population. [Acceptance/evidence](../redesign/phase-5/p5-04b/README.md). Baseline: 934 backend, 390 frontend, 77 actual browser passes + unchanged P2-D01 expected failure; security 9/6/1, configuration 3. No backend contract changes, P5-06 or B07. F04, P2-D01, incomplete axe/manual screen-reader/native touch and Board scaling remain open.
+
- **P5-04b query readiness complete — GO for integration's read prerequisite:** shared server query and URL interpretation, full Board versus paged Table ID parity, refresh/focus and failure evidence. [Record](../redesign/phase-5/p5-04b-readiness/README.md). Baseline: 934 backend, 384 frontend, 76 actual browser passes + unchanged P2-D01 expected failure; security 9/6/1, configuration 3. Earlier unchanged command-focus/CV smoke failures are recorded as R01/R02, not silently closed by passing reruns. No integration, P5-06 or B07 implemented; F04/manual screen-reader/native touch remain open.
- **P5-03 — Jobs Table and mobile cards complete:** restrained table/card presentation, existing Comfortable/Compact controls, localized display, partial-bulk retry, and Browser Back row focus have [real-browser and unit evidence](../redesign/phase-5/p5-03/README.md). Baseline: 918 backend, 377 frontend, 74 actual browser passes plus unchanged P2-D01 expected failure. P5-00-D02 and P4-05-O01 close for Jobs/Trash; P5-02 manual screen-reader acceptance and F04 remain open. Stop before P5-04a.
diff --git a/job-tracker-ui/e2e/board-presentation.spec.ts b/job-tracker-ui/e2e/board-presentation.spec.ts
index 1577575..5da0e45 100644
--- a/job-tracker-ui/e2e/board-presentation.spec.ts
+++ b/job-tracker-ui/e2e/board-presentation.spec.ts
@@ -78,13 +78,39 @@ test("[PRES-J07 J05 W01 W02 E07 E08 E09] Board query membership matches Table an
await page.goto("/kanban?q=Board&status=Saved");
await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24);
await capture(page, info, "board-filtered-query-light-en");
- await page.getByRole('link', { name: 'Job Applications', exact: true }).click();
- await expect(page).toHaveURL(url => url.pathname === '/jobs' && url.search === '?q=Board&status=Saved');
+ await page.getByRole('link', { name: 'Table', exact: true }).click();
+ await expect(page).toHaveURL(url => url.pathname === '/jobs' && url.search === '?q=Board&status=Saved&view=table');
await expect(page.locator('[data-job-row-id]')).toHaveCount(15);
const visibleIds = await page.locator('[data-job-row-id]').evaluateAll(rows => rows.map(row => Number(row.getAttribute('data-job-row-id'))));
expect(visibleIds.every(id => ids.slice(3).includes(id))).toBe(true);
await page.goBack();
await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24);
+ await page.goForward();
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(15);
+ await page.getByRole('button', { name: 'Go to next page' }).click();
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(9);
+ const secondPageIds = await page.locator('[data-job-row-id]').evaluateAll(rows => rows.map(row => row.getAttribute('data-job-row-id')));
+ const viewReads: string[] = [];
+ const recordRead = (request: import('@playwright/test').Request) => {
+ if (request.method() === 'GET' && /^\/api\/jobapplications(?:\/board)?$/.test(new URL(request.url()).pathname)) viewReads.push(new URL(request.url()).pathname);
+ };
+ page.on('request', recordRead);
+ const boardSwitch = page.getByRole('link', { name: 'Board', exact: true });
+ await boardSwitch.focus(); await page.keyboard.press('Enter');
+ await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24);
+ await expect(boardSwitch).toBeFocused();
+ await expect(boardSwitch).toHaveAttribute('aria-current', 'page');
+ expect(viewReads).toEqual(['/api/jobapplications/board']);
+ expect((await page.locator('[data-kanban-card-id]').evaluateAll(cards => cards.map(card => Number(card.getAttribute('data-kanban-card-id')))))).toEqual(expect.arrayContaining(ids.slice(3)));
+ await capture(page, info, 'integrated-filtered-board-keyboard');
+ await page.getByRole('link', { name: 'Table', exact: true }).click();
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(9);
+ expect(await page.locator('[data-job-row-id]').evaluateAll(rows => rows.map(row => row.getAttribute('data-job-row-id')))).toEqual(secondPageIds);
+ expect(viewReads).toEqual(['/api/jobapplications/board', '/api/jobapplications']);
+ page.off('request', recordRead);
+ await capture(page, info, 'integrated-filtered-table-page-two');
+ await page.goBack();
+ await expect(page.locator('[data-kanban-card-id]')).toHaveCount(24);
const filteredControl = page.locator(`[data-kanban-card-id="${ids[4]}"] [role="button"]`);
await filteredControl.focus(); await page.keyboard.press('Space');
await page.getByRole('group', { name: 'Active column' }).focus(); await page.keyboard.press('Enter');
@@ -96,11 +122,24 @@ test("[PRES-J07 J05 W01 W02 E07 E08 E09] Board query membership matches Table an
await expect(page.locator('[data-kanban-card-id]')).toHaveCount(0);
await expect(page.getByText('No jobs in this group yet.')).toHaveCount(3);
await capture(page, info, 'board-zero-query-light-en');
+ await page.getByRole('link', { name: 'Table', exact: true }).click();
+ await expect(page.getByText('No jobs found.')).toBeVisible();
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(0);
+ await page.getByRole('link', { name: 'Board', exact: true }).click();
+ await expect(page.getByText('No jobs in this group yet.')).toHaveCount(3);
+
+ await page.goto('/jobs?q=Board&status=Take-home%20assignment&view=table');
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(1);
+ await expect(page.locator(`[data-job-row-id="${ids[2]}"]`)).toBeVisible();
+ await page.getByRole('link', { name: 'Board', exact: true }).click();
+ await expect(page.locator('[data-kanban-card-id]')).toHaveCount(1);
+ await expect(page.locator(`[data-kanban-card-id="${ids[2]}"]`)).toBeVisible();
+ await capture(page, info, 'integrated-custom-status-board');
await page.goto("/kanban?source=board");
const cards = page.locator("[data-kanban-card-id]");
await expect(cards).toHaveCount(27);
- expect(new URL(page.url()).search).toBe("?source=board");
+ expect(new URL(page.url()).search).toBe("?source=board&view=board");
expect((await page.request.get(`${apiUrl}/jobapplications/board`)).status()).toBe(200);
const tablePage = await (await page.request.get(`${apiUrl}/jobapplications?page=1&pageSize=15&q=Board`)).json();
expect(tablePage.items.length).toBe(15);
@@ -115,11 +154,21 @@ test("[PRES-J07 J05 W01 W02 E07 E08 E09] Board query membership matches Table an
await expect(cards).toHaveCount(27);
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) })).toBeVisible();
await expect(page.getByRole("group", { name: t.kanbanColumnNotDropTarget.replace("{label}", t.kanbanGroupOther) })).toBeVisible();
- await expect(page.getByRole("link", { name: t.jobApplications })).toHaveAttribute("href", "/jobs?source=board");
+ await expect(page.getByRole("link", { name: t.jobsTableView, exact: true })).toHaveAttribute("href", "/jobs?source=board&view=table");
await capture(page, info, `board-desktop-${mode}-${language}`);
+ await page.getByRole("link", { name: t.jobsTableView, exact: true }).click();
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(15);
+ await capture(page, info, `integrated-table-desktop-${mode}-${language}`);
+ await page.getByRole("link", { name: t.jobsBoardView, exact: true }).click();
+ await expect(cards).toHaveCount(27);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("group", { name: t.kanbanColumnLabel.replace("{label}", t.pipelineGroupNotApplied) })).toBeVisible();
await capture(page, info, `board-phone-${mode}-${language}`);
+ await page.getByRole("link", { name: t.jobsTableView, exact: true }).click();
+ await expect(page.locator('[data-job-row-id]')).toHaveCount(15);
+ await capture(page, info, `integrated-table-phone-${mode}-${language}`);
+ await page.getByRole("link", { name: t.jobsBoardView, exact: true }).click();
+ await expect(cards).toHaveCount(27);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
if (language === "en") await scan(page, info, `board-phone-${mode}`);
await page.setViewportSize({ width: 1440, height: 900 });
@@ -197,13 +246,30 @@ test("[PRES-J07 J05 W01 W02 E07 E08 E09] Board query membership matches Table an
await expect(page).toHaveURL(new RegExp(`/jobs/${ids[0]}$`));
await expect(page.getByRole("heading", { name: /Seniorrådgiver/ })).toBeVisible();
await page.goBack();
- await expect(page).toHaveURL(url => url.pathname === "/kanban" && url.search === "?source=board");
+ await expect(page).toHaveURL(url => url.pathname === "/jobs" && url.search === "?source=board&view=board");
await expect(savedControl).toBeFocused();
} finally {
await admin.dispose();
}
});
+test("[PRES-J05 J07 E07 W01] legacy Board replaces history and preserves query, hash and route state; default Jobs stays Table", async ({ page }) => {
+ await login(page.request, "e2e@example.test");
+ await page.goto('/jobs?view=unknown&q=absent');
+ await expect(page.getByRole('link', { name: 'Table', exact: true })).toHaveAttribute('aria-current', 'page');
+ await expect(page.getByText('No jobs found.')).toBeVisible();
+ await page.evaluate(() => history.pushState({ ...history.state, usr: { integrationSentinel: 'preserved' } }, '', '/kanban?q=absent&status=Custom+review&page=2&view=table#retained'));
+ const length = await page.evaluate(() => history.length);
+ await page.reload();
+ await expect(page).toHaveURL(url => url.pathname === '/jobs' && url.searchParams.get('view') === 'board' && url.searchParams.get('status') === 'Custom review' && url.searchParams.get('q') === 'absent' && url.searchParams.get('page') === '2' && url.hash === '#retained');
+ await expect(page.getByRole('link', { name: 'Board', exact: true })).toHaveAttribute('aria-current', 'page');
+ expect(await page.evaluate(() => history.length)).toBe(length);
+ expect(await page.evaluate(() => history.state.usr.integrationSentinel)).toBe('preserved');
+ await page.goBack();
+ await expect(page).toHaveURL(/\/jobs\?view=unknown&q=absent$/);
+ await expect(page.getByRole('link', { name: 'Table', exact: true })).toHaveAttribute('aria-current', 'page');
+});
+
test("[PRES-J07 E08] Board loading, retryable error and fully empty columns", async ({ page }, info) => {
test.setTimeout(90_000);
await page.goto("/login");
@@ -219,6 +285,8 @@ test("[PRES-J07 E08] Board loading, retryable error and fully empty columns", as
else await route.fulfill({ json: [] });
});
await page.goto("/kanban", { waitUntil: "domcontentloaded" });
+ // The shared workspace also has a lazy-renderer loading state. Wait for the actual read.
+ await expect.poll(() => typeof release).toBe("function");
await expect(page.getByRole("progressbar")).toBeVisible();
await capture(page, info, "board-loading-light-en");
state = "error"; release();
diff --git a/job-tracker-ui/e2e/kanban-theme.spec.ts b/job-tracker-ui/e2e/kanban-theme.spec.ts
index c982170..617a801 100644
--- a/job-tracker-ui/e2e/kanban-theme.spec.ts
+++ b/job-tracker-ui/e2e/kanban-theme.spec.ts
@@ -11,7 +11,7 @@ async function login(page: Page) {
await expect(page).toHaveURL(/\/dashboard$/);
}
-test("kanban columns use dark theme surfaces", async ({ page }) => {
+test("kanban columns use dark theme surfaces", async ({ page }, info) => {
let moveRequests = 0;
await page.addInitScript(() => {
window.localStorage.setItem("uiLanguage", "en");
@@ -96,11 +96,18 @@ test("kanban columns use dark theme surfaces", async ({ page }) => {
await expect.poll(() => moveRequests).toBe(2);
await page.setViewportSize({ width: 375, height: 812 });
- const mobileScroll = await page.evaluate(() => {
- document.documentElement.scrollLeft = 999;
- const scrollLeft = document.documentElement.scrollLeft;
- document.documentElement.scrollLeft = 0;
- return scrollLeft;
- });
- expect(mobileScroll).toBe(0);
+ // The shared toolbar switches its responsive representation through useMediaQuery.
+ // Assert the settled observable layout, not the frame before React handles resize.
+ const mobileScrollSamples: number[] = [];
+ await expect.poll(async () => {
+ const scroll = await page.evaluate(() => {
+ document.documentElement.scrollLeft = 999;
+ const scrollLeft = document.documentElement.scrollLeft;
+ document.documentElement.scrollLeft = 0;
+ return scrollLeft;
+ });
+ mobileScrollSamples.push(scroll);
+ return scroll;
+ }).toBe(0);
+ await info.attach("mobile-resize-scroll-samples", { body: JSON.stringify(mobileScrollSamples), contentType: "application/json" });
});
diff --git a/job-tracker-ui/e2e/shell-readiness.spec.ts b/job-tracker-ui/e2e/shell-readiness.spec.ts
index 0b0e305..b8f67f1 100644
--- a/job-tracker-ui/e2e/shell-readiness.spec.ts
+++ b/job-tracker-ui/e2e/shell-readiness.spec.ts
@@ -366,8 +366,8 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
const navigation = page.getByRole("navigation", { name: t.navigation });
await expect(navigation.getByText(t.jobSearch, { exact: true })).toBeVisible();
await expect(navigation.getByText(t.career, { exact: true })).toBeVisible();
- await expect(navigation.getByRole("button", { name: t.jobApplications, exact: true })).toHaveAttribute("aria-current", "location");
- await expect(navigation.getByRole("button", { name: t.kanbanBoard, exact: true })).toHaveAttribute("aria-current", "page");
+ await expect(navigation.getByRole("button", { name: t.jobApplications, exact: true })).toHaveAttribute("aria-current", "page");
+ await expect(page.getByRole("link", { name: t.jobsBoardView, exact: true })).toHaveAttribute("aria-current", "page");
await expect(navigation.getByRole("button", { name: t.connectedAccounts, exact: true })).toBeInViewport({ ratio: 1 });
await expect(page.getByRole("link", { name: t.skipToMainContent, exact: true })).toHaveAttribute("href", "#app-main-content");
await capture(page, info, `grouped-shell-${suffix}`);
@@ -392,9 +392,9 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
await expect(drawer.getByRole("button", { name: t.trash, exact: true })).toHaveAttribute("aria-current", "page");
await capture(page, info, `grouped-mobile-${suffix}`);
await scan(page, info, `grouped-mobile-${suffix}`, '[role="dialog"]');
- await drawer.getByRole("button", { name: t.kanbanBoard, exact: true }).click();
- await expect(page).toHaveURL(/\/kanban$/);
- await expect(page.getByRole("heading", { level: 1, name: t.kanbanBoard, exact: true })).toBeFocused();
+ await drawer.getByRole("button", { name: t.jobApplications, exact: true }).click();
+ await expect(page).toHaveURL(/\/jobs$/);
+ await expect(page.getByRole("heading", { level: 1, name: t.jobApplications, exact: true })).toBeFocused();
await expect(drawer).not.toBeVisible();
});
diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx
index 56e6446..8f2abe2 100644
--- a/job-tracker-ui/src/App.tsx
+++ b/job-tracker-ui/src/App.tsx
@@ -6,7 +6,6 @@ import { isCancel } from "axios";
import DashboardIcon from "@mui/icons-material/Dashboard";
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
-import ViewKanbanIcon from "@mui/icons-material/ViewKanban";
import BusinessIcon from "@mui/icons-material/Business";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import SettingsIcon from "@mui/icons-material/Settings";
@@ -46,7 +45,6 @@ import JobTrackerThemeProvider from "./ThemeProvider";
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
-const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
const DashboardView = lazy(() => import("./components/DashboardView"));
const JobDiscoveryPage = lazy(() => import("./views/JobDiscoveryPage"));
const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
@@ -69,6 +67,13 @@ const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
+
+function LegacyBoardRedirect() {
+ const location = useLocation();
+ const query = new URLSearchParams(location.search);
+ query.set("view", "board");
+ return ;
+}
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
const ThemeControlsContext = createContext(null);
type MeResponse = {
@@ -294,7 +299,6 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/jobs", activeFor: ["/applications"], label: t("jobApplications"), icon: , section: t("jobSearch"), children: [
{ to: "/discover", label: t("discoverJobs"), icon: },
{ to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount },
- { to: "/kanban", label: t("kanbanBoard"), icon: },
{ to: "/companies", label: t("companies"), icon: },
{ to: "/offers", label: t("offerComparisonNav"), icon: },
{ to: "/trash", label: t("trash"), icon: },
@@ -388,7 +392,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
} />
} />
} />
- } />
+ } />
} />
} />
} />
diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx
index a67f530..ad97e39 100644
--- a/job-tracker-ui/src/components/JobTable.tsx
+++ b/job-tracker-ui/src/components/JobTable.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useLocation, useNavigate } from "react-router-dom";
+import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import {
Box,
@@ -95,6 +95,7 @@ interface Props {
}
const JOB_STATUS_FILTERS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"];
+const KanbanBoard = lazy(() => import("./KanbanBoard"));
function queryPage(search: string): number {
const value = Number(new URLSearchParams(search).get("page"));
@@ -150,6 +151,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const { t, language } = useI18n();
const { confirmAction } = useDialogActions();
const location = useLocation();
+ const boardView = mode === "jobs" && new URLSearchParams(location.search).get("view") === "board";
+ const [boardCount, setBoardCount] = useState(null);
const routeQuery = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
const navigate = useNavigate();
const listRouteRef = useRef(`${location.pathname}${location.search}`);
@@ -180,6 +183,19 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [sortBy, setSortBy] = useState(routeQuery.sortBy);
const [sortDir, setSortDir] = useState<"asc" | "desc">(routeQuery.sortDir);
+ // Selection belongs to the current Table page, never to an invisible view.
+ useEffect(() => {
+ setSelectedIds([]);
+ setBoardCount(null);
+ retainedSelectionAfterReloadRef.current = null;
+ }, [boardView]);
+
+ const viewDestination = (view: "table" | "board") => {
+ const query = new URLSearchParams(location.search);
+ query.set("view", view);
+ return { pathname: "/jobs", search: `?${query}`, hash: location.hash };
+ };
+
const updateListRoute = useCallback((updates: Record) => {
setSelectedIds([]);
const next = new URLSearchParams(location.search);
@@ -299,6 +315,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
},
{
initialData: { items: [], total: 0, page: 1, pageSize },
+ enabled: !boardView,
errorMessage: t("jobTableJobsLoadFailed"),
deps: [params, refreshToken, reloadToken, pageSize, t],
},
@@ -325,12 +342,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
useEffect(() => {
const focusJobId = window.history.state?.jobListFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
- if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
+ if (boardView || typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
const row = document.querySelector(`[data-job-row-id="${focusJobId}"]`);
if (!row) return;
restoredFocusJobIdRef.current = focusJobId;
row.focus();
- }, [filteredJobs, jobsResource.loading, location.state]);
+ }, [boardView, filteredJobs, jobsResource.loading, location.state]);
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
// empty state can actually help a first-time user instead of just saying "nothing here".
@@ -453,6 +470,26 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return (
+ {mode === "jobs" ? (
+
+
+ {(["table", "board"] as const).map((view) => (
+
+ ))}
+
+
+ {boardView
+ ? boardCount !== null ? t("jobsMatchingCount", { count: boardCount }) : ""
+ : jobsResource.loading || jobsResource.refreshing || jobsResource.error ? "" : t("jobsMatchingCount", { count: total })}
+
+
+ ) : null}
{isMobile ? (
@@ -529,11 +566,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
- } onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
+ {!boardView ? } onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
{t("jobTableColumns")}
-
+ : null}
- {densityControl}
+ {!boardView ? densityControl : null}
) : (
@@ -587,13 +624,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
) : null}
{mode === "jobs" ? changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
- {!isMobile ? setColumnsAnchor(e.currentTarget)}> : null}
- {densityControl}
+ {!boardView && !isMobile ? setColumnsAnchor(e.currentTarget)}> : null}
+ {!boardView ? densityControl : null}
)}
- {selectedIds.length > 0 ? (
+ {!boardView && selectedIds.length > 0 ? (
{t("jobTableSelected", { count: selectedIds.length })}
@@ -617,7 +654,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
) : null}
- }> :
)}
t("tableDisplayedRows", { from, to, count })} getItemAriaLabel={(type) => t(type === "next" ? "tableNextPage" : type === "previous" ? "tablePreviousPage" : type === "first" ? "tableFirstPage" : "tableLastPage")} count={total} page={page} onPageChange={(_, next) => { setPage(next); updateListRoute({ page: next > 0 ? String(next + 1) : null }); }} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); updateListRoute({ page: null }); }} rowsPerPageOptions={[15, 20, 25]} />
-
+ }
setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />