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 - + : 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)} /> { setStatusAnchor(null); setStatusJobId(null); }}> diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index 4f38045..63374bc 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, - Button, Card, CardContent, Chip, @@ -16,7 +15,7 @@ import { } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; -import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { api, getApiErrorMessage } from "../api"; import ViewStateNotice from "./ViewStateNotice"; @@ -24,6 +23,7 @@ import { JobApplication } from "../types"; import { useI18n } from "../i18n/I18nProvider"; import { useViewResource } from "../hooks/useViewResource"; import { jobsQueryFromSearch } from "../jobsQuery"; +import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { GROUP_ENTRY_STATUS, PIPELINE_GROUPS, @@ -73,12 +73,13 @@ function cardPill(job: JobApplication, t: (key: any, params?: any) => string, la return null; } -export default function KanbanBoard() { +export default function KanbanBoard({ refreshToken = 0, onResultCount }: { refreshToken?: number; onResultCount?: (count: number | null) => void }) { const theme = useTheme(); const palette = theme.vars?.palette ?? theme.palette; const { t, language } = useI18n(); const location = useLocation(); - const params = useMemo(() => jobsQueryFromSearch(location.search), [location.search]); + const query = useMemo(() => jobsQueryFromSearch(location.search), [location.search]); + const params = useDebouncedValue(query, 250); const boardRef = useRef(null); const navigate = useNavigate(); const [focusAfterMoveId, setFocusAfterMoveId] = useState(null); @@ -98,11 +99,14 @@ export default function KanbanBoard() { { initialData: [], errorMessage: t("kanbanLoadFailed"), - deps: [t, params], + deps: [t, params, refreshToken], }, ); const jobs = jobsResource.data; + useEffect(() => { + onResultCount?.(jobsResource.loading || jobsResource.refreshing || jobsResource.error ? null : jobs.length); + }, [onResultCount, jobs.length, jobsResource.loading, jobsResource.refreshing, jobsResource.error]); useEffect(() => { if (jobsResource.loading || jobsResource.refreshing || jobsResource.error) return; @@ -198,16 +202,7 @@ export default function KanbanBoard() { return ( - - - {t("kanbanHint")} - {t("kanbanSeparateViews")} - - - - - - + {t("kanbanHint")} () => null); +const mockedApi = api as jest.Mocked; +const jobs = Array.from({ length: 27 }, (_, i) => ({ id: i + 1, jobTitle: i === 0 ? "Different" : `Engineer ${i}`, companyId: 1, company: { id: 1, name: "Nordlys" }, status: i === 1 ? "Custom review" : "Saved", location: "Oslo", isDeleted: false })); +function Consumer({ mode = "jobs" }: { mode?: "jobs" | "trash" }) { + const location = useLocation(); + const navigate = useNavigate(); + const [refreshToken, setRefreshToken] = useState(0); + return <>{location.pathname}{location.search} + + + ; +} +function show(query = "", mode: "jobs" | "trash" = "jobs") { + return render(); +} +const reads = (path: string) => mockedApi.get.mock.calls.filter(([url]) => url === path); +const ids = (selector: string, attribute: string) => Array.from(document.querySelectorAll(selector), el => Number(el.getAttribute(attribute))).sort((a, b) => a - b); +beforeEach(() => { + localStorage.clear(); jest.clearAllMocks(); + mockedApi.get.mockImplementation((url, config) => { + const p = (config?.params ?? {}) as { q?: string; status?: string; page?: number }; + const matching = jobs.filter(job => (!p.q || job.jobTitle.includes(p.q)) && (!p.status || p.status === "All" || job.status === p.status)); + return Promise.resolve({ data: url === "/companies" ? [jobs[0].company] : url === "/jobapplications/board" ? matching : url === "/jobapplications" ? { items: matching.slice(((p.page ?? 1) - 1) * 15, (p.page ?? 1) * 15), total: matching.length, page: p.page, pageSize: 15 } : [] } as any); + }); +}); + +test("Table/Board switches preserve query, complete membership, page, density and history without hidden reads", async () => { + show("?q=Engineer&page=2&sortBy=jobTitle&sortDir=asc"); + await screen.findByText("16–26 of 26"); + expect(reads("/jobapplications/board")).toHaveLength(0); + const pageIds = ids("[data-job-row-id]", "data-job-row-id"); + fireEvent.click(screen.getAllByRole("checkbox", { name: /Select Engineer/ })[0]); + fireEvent.mouseDown(screen.getByRole("combobox", { name: t.listDensity })); + fireEvent.click(await screen.findByRole("option", { name: "Compact" })); + const count = reads("/jobapplications").length; + const board = screen.getByRole("link", { name: "Board" }); + board.focus(); fireEvent.click(board); + await waitFor(() => expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(jobs.slice(1).map(j => j.id))); + expect(board).toHaveFocus(); + expect(board).toHaveAttribute("aria-current", "page"); + expect(reads("/jobapplications")).toHaveLength(count); + expect(reads("/jobapplications/board")).toHaveLength(1); + expect(screen.queryByRole("combobox", { name: t.listDensity })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("link", { name: "Table" })); + await screen.findByText("16–26 of 26"); + expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual(pageIds); + expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent("Compact"); + expect(screen.getAllByRole("checkbox", { name: /Select Engineer/ }).every(el => !(el as HTMLInputElement).checked)).toBe(true); + expect(reads("/jobapplications/board")).toHaveLength(1); + fireEvent.click(screen.getByText("History back")); + await waitFor(() => expect(document.querySelectorAll("[data-kanban-card-id]")).toHaveLength(26)); + expect(screen.getByLabelText("Route")).toHaveTextContent("view=board"); + fireEvent.click(screen.getByText("History forward")); + await screen.findByText("16–26 of 26"); + expect(screen.getByLabelText("Route")).toHaveTextContent("view=table"); +}); + +test.each(["?q=Engineer&status=Custom+review", "?q=absent"])("direct Board and both switch directions retain exact result membership: %s", async query => { + show(`${query}&view=board`); + const expected = query.includes("absent") ? [] : [2]; + await screen.findByText(`${expected.length} matching jobs`); + expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(expected); + expect(reads("/jobapplications")).toHaveLength(0); + fireEvent.click(screen.getByRole("link", { name: "Table" })); + await waitFor(() => expect(reads("/jobapplications")).toHaveLength(1)); + await waitFor(() => expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual(expected)); + fireEvent.click(screen.getByRole("link", { name: "Board" })); + await screen.findByText(`${expected.length} matching jobs`); + expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual(expected); + expect(reads("/jobapplications/board").every(([, config]) => (config?.params as { q?: string }).q === (expected.length ? "Engineer" : "absent"))).toBe(true); +}); + +test("Board uses the shared filter controls and saved query owner without fetching Table", async () => { + show("?view=board"); + await screen.findByText("27 matching jobs"); + fireEvent.change(screen.getByRole("textbox", { name: t.jobTableSearch }), { target: { value: "Engineer" } }); + await screen.findByText("26 matching jobs"); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Status" })); + fireEvent.click(await screen.findByRole("option", { name: "Offer" })); + await screen.findByText("0 matching jobs"); + expect(reads("/jobapplications")).toHaveLength(0); + expect(screen.getByLabelText("Route")).toHaveTextContent("view=board&q=Engineer&status=Offer"); +}); + +test("Trash stays a Table even when supplied a Board view parameter", async () => { + show("?view=board", "trash"); + await screen.findByText("1–15 of 27"); + expect(screen.queryByRole("group", { name: "Jobs view" })).not.toBeInTheDocument(); + expect(reads("/jobapplications/board")).toHaveLength(0); + expect(reads("/jobapplications")[0][1]?.params).toMatchObject({ deletedOnly: true }); + expect(within(screen.getByRole("table")).getAllByRole("row")).toHaveLength(16); +}); + +test("saved filters applied in Board reset the Table page and refresh only the active population", async () => { + localStorage.setItem(getUserScopedStorageKey("jt_saved_views_v1"), JSON.stringify([{ id: "review", name: "Review shortlist", params: { q: "Engineer", status: "Custom review" }, createdAt: "2026-09-29" }])); + show("?view=board&page=2"); + await screen.findByText("27 matching jobs"); + fireEvent.click(screen.getByRole("button", { name: t.savedViewsTooltip })); + fireEvent.click(await screen.findByText("Review shortlist")); + await screen.findByText("1 matching jobs"); + expect(screen.getByLabelText("Route")).not.toHaveTextContent("page="); + expect(ids("[data-kanban-card-id]", "data-kanban-card-id")).toEqual([2]); + const before = reads("/jobapplications/board").length; + fireEvent.click(screen.getByRole("button", { name: "Job created" })); + await waitFor(() => expect(reads("/jobapplications/board")).toHaveLength(before + 1)); + expect(reads("/jobapplications")).toHaveLength(0); + fireEvent.click(screen.getByRole("link", { name: "Table" })); + await waitFor(() => expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual([2])); + expect(reads("/jobapplications")[0][1]?.params).toMatchObject({ page: 1, q: "Engineer", status: "Custom review" }); +}); diff --git a/job-tracker-ui/src/kanban-grouped-board.test.tsx b/job-tracker-ui/src/kanban-grouped-board.test.tsx index 93ae6f2..6306622 100644 --- a/job-tracker-ui/src/kanban-grouped-board.test.tsx +++ b/job-tracker-ui/src/kanban-grouped-board.test.tsx @@ -202,7 +202,7 @@ test('full-board population keeps jobs beyond the table page and offers a separa expect(await screen.findByText('Role 26')).toBeVisible(); expect(screen.getAllByRole('button', { name: /Press Space to pick up/ })).toHaveLength(27); expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/board', expect.objectContaining({ params: expect.objectContaining({ includeDeleted: false }) })); - expect(screen.getByRole('link', { name: /Job Applications/ })).toHaveAttribute('href', '/jobs?source=board'); + expect(screen.queryByRole('link', { name: /Job Applications/ })).not.toBeInTheDocument(); }); test('keyboard Escape cancels movement without a write and successful movement restores card focus', async () => { @@ -247,7 +247,7 @@ test('Board sends the shared logical URL query, excluding Table paging and clien q: 'Board', status: 'Custom review', companyId: 7, location: 'Tromsø', needsFollowUp: true, readiness: 'interview', includeDeleted: true, sortBy: 'jobTitle', sortDir: 'asc', } }); - expect(screen.getByRole('link', { name: /Job Applications/ })).toHaveAttribute('href', expect.stringContaining('q=Board&status=Custom%20review')); + // The shared workspace owns the view switch; this renderer only loads the supplied query. }); test('a successful filtered move reloads membership and focuses the board when the job leaves it', async () => {