fix(ui): complete P5-00 shell readiness gates

This commit is contained in:
cesnimda
2026-09-21 21:38:11 +02:00
parent c325eb68c6
commit 926f2e5164
52 changed files with 6630 additions and 64 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Redesign traceability
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). These specify future tests/changes, not implemented preservation. The P4-06 conditional gates remain explicit; no Phase 5 application changes have started.
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.
+7 -5
View File
@@ -4,11 +4,13 @@ Reconciled against [P4-06](../phase-4/p4-06/README.md#opendeferred-issue-reconci
| Issue | Status / exact limit | Classification / owner and deadline |
|---|---|---|
| P4-06-O01 collapse→mobile labels/focus occlusion | Open, manually reproduced and present before P4 | **Blocks P5-01**; P5-00 AppShell prerequisite |
| P4-06-O02 long admin badge at 320px | Open synthetic long-metadata failure; actual short production badge overlap not proven | **Blocks P5-01**; P5-00 responsive admin-header prerequisite |
| P4-02-D01 command invalid list children | Open loaded axe finding | **Blocks P5-01**; P5-00 semantic repair before utility migration |
| P4-03-D01 light Redo Tooltip 3.89:1 | Open, narrow exception only | **Blocks P5-01** shared hovered/focused controls; P5-00 theme pair repair, retain other contrast checks |
| P4-04-O01 toast covers bottom action | Open; waiting for normal dismissal was not repair | **Blocks P5-01** shell/feedback acceptance; P5-00 bounded placement, no notification platform |
| P4-06-O01 collapse→mobile labels/focus occlusion | Addressed in [P5-00](p5-00/README.md); also aligns controls/drawers at 1024px and closes the temporary modal on desktop entry | Shell implementation gate ready; retain boundary/open-resize and focus regressions |
| P4-06-O02 long admin badge at 320px | Addressed in P5-00: same admin-only metadata in narrow account menu, header spacers track actual height | Responsive header gate ready; no claim that actual production badge overlap was previously proven |
| P4-02-D01 command invalid list children | Addressed in P5-00; populated/empty lists and native keyboard destinations protected | Command semantics gate ready; no catalogue expansion, D02–D04 unchanged |
| P4-03-D01 light Redo Tooltip 3.89:1 | Addressed in P5-00 shared opaque semantic pair; narrow old exception removed | Tooltip gate ready for tested loaded targets; not universal accessibility certification |
| P4-04-O01 toast covers bottom action | Addressed in P5-00: bottom feedback clearance, native success toast tested while open | Feedback gate ready for the defined Connections action; not a universal overlay manager or company-modal error repair |
| P5-00-D01 fixed worker data with system clock | **Open** unrelated fixture: 915/916 backend pass on 2026-09-20, expected Applied becomes Ghosted after 60 days | **Conditions P5-01 baseline acceptance**. Separately authorize existing fixed-clock test injection, rerun all 916; no production worker change or skipped/expected test |
| P5-00-D02 browser Back loses Jobs row focus | **Open** existing route behavior exposed by waiting for the actual workspace before Back; all four locale/theme cases. Explicit workspace Back passes `focusJobId` and remains protected | **Blocks affected P5-03/08 navigation accessibility acceptance**, not unchanged shell. Browser query and local-density reset plus explicit-button focus have separate oracles; no route/table application fix in P5-00 |
| P3-O02 command error→empty / late query results | Source-confirmed search catches; explicit failure/pending isolation needs owner tests | **Blocks later slice P5-02 acceptance**; retain existing simple commands in 01, no expanded command redesign in 00 |
| P3-O02 workspace search replacement loses `followMode` | Source-confirmed `setParams({section})` | **Blocks later slice P5-08b** adapter migration until retained-query proof |
| P4-02-D03 mobile progress scroll not focusable | Open loaded axe finding | **Blocks P5-08a affected Overview migration**, not shell; targeted G-WORKSPACE proof |
+2
View File
@@ -4,6 +4,8 @@ Planning at `2a2f9e48052c562ef599825458d0d3a6f9f9fc61`. **None of these gates is
## G-SHELL — P5-00, before P5-01 starts
Successor evidence: [P5-00 implementation and validation](p5-00/README.md). The five shell defects below are addressed, including the shared 1024px threshold and open-drawer resize lifecycle. P5-01 remains **CONDITIONAL GO** on the separate P5-00-D01 backend clock-fixture baseline; do not infer 916 passing tests. The following table preserves the original required evidence.
P5-00 is required. Resolve these narrowly against the **existing** shell first, with regression evidence before regrouping or restyling it. This separates baseline remediation from redesign regressions.
| Existing condition | Bounded prerequisite / responsible owner | Required exit evidence |
+4 -2
View File
@@ -1,8 +1,10 @@
# Phase 5 — Representative screens / major UI redesign plan
**Planning complete; implementation not started.** Source checkpoint `2a2f9e48052c562ef599825458d0d3a6f9f9fc61`, inspected on 2026-09-13 on `codex/ui-redesign-phase4`. This is a documentation-only checkpoint. No application, backend, test, CI, dependency, schema or infrastructure changes; no P5-00/P5-01 implementation, B07 or production work.
**Current successor: [P5-00 readiness implementation](p5-00/README.md).** The five bounded shell conditions are implemented; P5-01 has a **CONDITIONAL GO**, pending restoration of the backend baseline affected by the separately recorded clock-dependent test P5-00-D01. No P5-01 visible redesign, B07 or production work has started. F04 remains open.
P4-06 gave a **CONDITIONAL GO for planning**. This package preserves that distinction: **P5-00 is required and P5-01 is not yet cleared for implementation**. Accepted readiness evidence must precede the first major visible redesign, P5-01 AppShell/grouped navigation.
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.
P4-06 gave a **CONDITIONAL GO for planning**. The original plan required the separate P5-00 gate before P5-01 AppShell/grouped navigation, the first major visible redesign. Use the successor's explicit acceptance conditions; implementation success is not a production release approval.
## Package
+180
View File
@@ -0,0 +1,180 @@
# P5-00 — Shell readiness gates
Source checkpoint: `c325eb68c6f6f293be92ecbbbb09b705a7da5033`, branch `codex/ui-redesign-phase4`. Local verification: 2026-09-20–21, Windows, disposable API/SQLite and Chromium. The containing commit is discoverable with `git log -- docs/redesign/phase-5/p5-00`.
This is the five-condition [G-SHELL readiness slice](../READINESS_GATES.md#g-shell--p5-00-before-p5-01-starts), not the visible AppShell redesign. **P5-01 and B07 have not started. F04 remains open.** No route, provider, permission, data, API, schema, dependency or CI configuration changes are included.
## Readiness decision
**CONDITIONAL GO for P5-01.** The five shell conditions have independent regression/visual oracles and are ready: mobile labels/focus and breakpoint lifecycle; admin-header reflow; command semantics; shared Tooltip accessibility; bottom-action toast clearance. Mapping a PRES group does not certify every workflow in that group.
The remaining acceptance condition is **P5-00-D01**, an unrelated pre-existing backend test that now fails because its fixed fixture dates are evaluated against the real clock. The complete backend result is 915/916, not the inherited 916 passing. Restore that test's deterministic clock in a separately authorized test-only correction and rerun all 916 before starting P5-01. This checkpoint does not grant an exception to the baseline gate. F04 remains an independent production gate; package, CV and later-slice gates are unchanged. **P5-01 and B07 were not begun.**
## Reproduction and implementation
| Gate / original evidence | Root cause and bounded correction | Preserved behavior |
|---|---|---|
| P4-06-O01 mobile labels/focus | The persisted desktop collapse flag also hid mobile text and changed its padding; the app bar was above the modal drawer. Mobile now has its own presentation decision, named navigation dialog/items and `aria-current`. The temporary drawer stays above the header; MUI retains focus trapping/restoration. After its entry transition, focus enters the first destination only if the paper still owns focus. | Same destinations, active-route lookup, permission filtering, 254/84px widths, desktop storage key, callbacks and route handling. No navigation regrouping. |
| Related breakpoint mismatch, independently reproduced before repair | Controls used 768px while drawer display used MUI `md` (1024px), leaving 768–1023px without either navigation form. Controls and both drawer variants now share `theme.breakpoints.down("md")`: temporary navigation below 1024px, permanent navigation at/above 1024px. | Original permanent-sidebar threshold, desktop collapse preference and existing page content width below 1024px. Table/cards and Settings tabs/select keep their separate, unchanged consumer breakpoints. |
| P4-06-O02 narrow admin metadata | The synthetic long version badge shared the narrow logo/control row. On phones, the same version/commit appears in the existing account menu as a non-interactive MUI `ListSubheader`; it is skipped by menu keyboard selection. Desktop badge is retained. | Admin-only metadata is still supplied by the unchanged App owner; no new request, destination or public metadata exposure. |
| Related wrapped-header occlusion, confirmed in rendered 768px view | Existing fixed spacers assumed 68/76px even when admin controls wrapped into a taller header. A native `ResizeObserver` measures the existing header; its two inert spacers follow that height. | Existing control ordering, responsive wrap, typography, page-width and header/content owners. No header redesign. |
| P4-02-D01 command list semantics | A `ul` contained direct `div role=button` results and a `div` empty state. Existing result buttons and empty text now use MUI `ListItem` children. | Six static commands, native job/company search, query reset, exact job ID / company `?edit=id` destinations, Tab/Enter/Escape, Add opens review without POST. No new command catalogue or arrow-navigation model. |
| P4-03-D01 light Redo Tooltip, reproduced at 3.89:1 | The inherited translucent default Tooltip background composed into a weak light-theme pair. Shared Tooltip foreground/background now use opaque existing `background.paper`/`text.primary` semantic roles in inverse order; the arrow uses the same background. | Existing MUI tooltips, names, placement, timings and P4 focus. No feature color constants or new tokens. Only the repaired narrow contrast exception is removed. |
| P4-04-O01 bottom-action toast overlap | Moving the fixed toast upward alone could still cover Connections. Keep the original bottom/right placement and reserve a stable 96px bottom feedback lane in the shell; the existing Snackbar's measured wrapped height can increase that clearance. Matching root `scroll-padding-bottom` keeps native focus/scrolling out of that lane. Safe-area insets are included. | Original 3500ms lifetime, click-away/Escape/dismiss, hover/focus pause, explicit action callback, content/severity, provider order and session disposal. No toast queue or overlay framework. |
Toast clearance is layout space, not a new notification feature. The stable minimum prevents the bottom action from being initially positioned underneath delayed feedback; measurement handles ordinary wrapped translations and resizing. It is not a guarantee that a floating toast can never visually overlap arbitrary mid-page content. Very large messages, software keyboards, third-party fixed widgets and non-shell public pages remain separate interaction cases. The repaired bottom Connections action and toast close action are both hit-testable while the toast is open; waiting for dismissal is not the oracle.
A later complete run reproduced the 390px dark/English Connections overlap despite the reserved space: native scrolling could still align the control with the viewport bottom, under the fixed toast. Root scroll padding is therefore required as well as physical page padding. The regression deliberately uses native `scrollIntoView({ block: "end" })` while the toast is open, then hit-tests both actions. This prevents an accidentally favorable prior scroll position from hiding the defect. Message duration is unchanged.
The first complete regression run caught an incorrect intermediate choice: extending the **permanent** sidebar down to 768px produced 106px horizontal overflow in the unchanged populated Job Workspace. That run is retained as initial evidence, not accepted. The correction aligns controls to the original permanent-sidebar `md` threshold instead. The original workspace overflow assertion is unchanged and its two theme sweeps now additionally cover 900/1023/1024px. No workspace application code was edited, no `overflow-x: hidden` mask was added, and no forced collapse preference was introduced.
Manual open-drawer 900→1024 testing also reproduced an empty accessibility tree: CSS hid the temporary drawer but its still-open MUI modal kept the desktop page `aria-hidden`. Its `open` prop now requires the temporary layout, and entering desktop closes the parent's temporary-drawer state. Returning to phone leaves it closed until explicitly opened. The four locale/theme shell tests cover this transition, desktop keyboard access and phone reopen/Escape return. Desktop collapse storage and unsaved page state remain independent.
## Changed owners
Application files, all under `job-tracker-ui/src/`:
- `layout/AppShell.tsx`: mobile label/stacking/current-state/focus, shared breakpoint, admin metadata disclosure, measured header spacers and feedback clearance only.
- `components/QuickCommandDialog.tsx`: valid list children; existing callbacks retained.
- `theme.ts`: shared semantic Tooltip pair/arrow only.
- `toast.tsx`: measure the mounted Snackbar, expose its clearance through a CSS custom property and use matching native root scroll padding; retain normal bottom placement with safe-area support. No private content is stored in that property.
- `i18n/translations.ts`: Navigation and Application version labels in English/Bokmål.
Regression files:
- `src/shell-readiness.test.tsx`: two localized shell controls and two toast behavior cases.
- `src/quick-command-preservation.test.tsx`: one populated/empty semantic-list case, retaining all prior action cases.
- `e2e/shell-readiness.spec.ts`: fourteen real-App cases: shell/reflow, command semantics and Tooltip/open-toast journeys in all four theme/locale pairs, plus two dirty-editor journeys.
- `e2e/account-isolation.spec.ts`: one additional real multi-tab account-switch case. Its pre-existing unsupported `Playwright` type import is replaced with the installed `PlaywrightWorkerArgs["playwright"]` type to type-check this now-edited test; no runtime auth change.
- `e2e/smoke.spec.ts`: extend the existing populated Job Workspace light/dark viewport sweeps with 900/1023/1024px to prevent recurrence of the intermediate sidebar/content regression; assertions and workflow unchanged, no additional test case.
- `e2e/table-density.spec.ts`: correct inherited navigation timing/return-path oracles found by the full run. Wait for the actual workspace heading before browser Back, then assert the documented local-density reset and preserved query. Separately require row-focus restoration through the existing workspace Back button. No table/navigation application code or density contract changes; this validation-only adjustment is necessary to make the shared-shell regression check reliable.
- `e2e/named-controls-modal-state.spec.ts`, `e2e/builder-preview-keyboard.spec.ts`: remove only the repaired command-list and light-Tooltip expected-rule branches. Existing unrelated workspace checks and P2-D01 remain unchanged.
## Preservation trace
All IDs refer to their original complete [Phase 2 contracts](../../phase-2/PRESERVATION_MATRIX.md). The rows below identify only the affected sub-workflows. Application routes/`App.tsx` and provider tree are unchanged.
| PRES → existing behavior | P5-00 change / protection | Automated evidence | Visual/UX evidence |
|---|---|---|---|
| E07 → collapse, drawer, active destination, commands | Separate mobile from stored desktop collapse; named/current items; valid command lists | `shell-readiness` unit/browser: collapse→reload→320, first/last target, focus trap/Escape/route selection, expanded control; exactly one appropriate navigation control at 767/768/900/1023/1024px; original `sidebar-active-nav` | VS-SHELL drawer, account menu and 768/900px header; VS-UTILITY populated/empty command list |
| E05/E09 → logout and account-private disposal | No identity/provider changes; protect open utility lifetime | Additional `account-isolation`: A command/company, account menu and notification open in three tabs; logout→B removes them; fresh B search cannot see A; original B02 cases retained | Manual shell controls plus browser DOM absence; native API ownership tests, not screenshots, prove the security boundary |
| E08 → light/dark, EN/NB, feedback/dialogs | Semantic Tooltip and non-obscured bottom feedback; existing focus system | Four theme/locale browser pairs; toast timer/dismiss/action units; existing theme/dialog tests | Redo Tooltip, notification Close Tooltip, open toast and focused controls |
| S02 → notification entry/actions | Existing entry/name/permissions unchanged | Original `notifications-popover` / `app-shell-notifications`; account-switch notification content fixture | Actual Close Tooltip and popover entry; no new producer or task semantics |
| S03 → Settings/theme/language | Same Settings consumer; header clearance and footer space | Existing Settings fixture; dirty CV survives real language toggles and another tab's Settings theme change | Settings title/reflow, bottom Connections action in both themes/locales |
| S05 → protected account export | No export code changes; use its real success feedback | One native ZIP per Tooltip journey; replay identical local response for additional viewport checks to respect account-data rate limits; original backend export coverage | Toast visible concurrently with Connections at desktop/390/320 |
| A03 → admin build metadata | Retain version/commit in phone account menu and desktop badge | Real admin fixture long synthetic version/name; first account action focused; non-admin account-switch fixture has no badge; original admin tests | VS-SHELL 1440/1024/1023/900/768/767/390/320 reflow; mobile account metadata |
| J03/J08 → Add review and job/company command entry | Structural wrapper only | Native job URL/company edit dialog; Add opens without creating a second job; prior unit callback tests | Command dialog populated/empty/keyboard; company workflow itself unchanged |
| J05/C06 → shared tooltip consumers and editor navigation | Tooltip only; no table/editor persistence changes | Inherited table-density/preview fixtures; new Redo hover/focus; dirty CV failed-save→language/theme→Cancel retains content, Discard leaves without writing | Actual loaded CV Redo, Settings control page; preview geometry is not certified |
No other PRES contract is intentionally changed. Complete inherited frontend/backend/browser coverage remains the broader regression check, with the baseline limitation below stated explicitly.
## Test design and evidence limits
Native authentication, ownership, profile/variant/job/company reads and exports use a disposable local API. External browser requests are blocked in the new fixtures. Notification text in the account-switch test is a single synthetic response, not proof of notification production. Dirty-editor PUT deliberately returns 503; existing draft ownership/confirmation logic runs unchanged. One native ZIP is replayed for additional viewport captures; this is placement proof, not a bypass or redesign of the existing two-per-minute account export limiter.
The new loaded scans use WCAG 2 A/AA and 2.1 A/AA tags and fail on any violation in the actual drawer/account-menu/command/Tooltip scope. Violations and incomplete rules are retained separately. Generic inherited route scans and incomplete contrast results are not complete accessibility passes. The historical unrelated mobile job-workspace scroll finding remains explicitly recognized in its existing test.
Final P5-00 scoped results: **24 scans, zero violations**; eight scans retain incomplete `color-contrast` checks and eight retain incomplete `aria-valid-attr-value` checks. These are not claimed as completed passes. Across the inherited loaded fixtures, two `scrollable-region-focusable` findings remain in the already-deferred workspace scope, with other incomplete rules retained in `RESULTS.json`. The visual index contains **32 unedited screenshots**.
### Inherited density navigation oracle corrected
The first complete run on the final application code reported one unexpected light/Bokmål density failure: after opening a job and going Back, expected Compact but received Comfortable. Three unchanged focused repetitions passed, so repeating until green would conceal the race. A temporary probe waiting for the actual workspace heading reproduced the failure deterministically. `App.tsx` uses separate `/jobs` and `/jobs/:id` route elements; `JobTable` owns component-local density. Both are unchanged from the source checkpoint. The [P4-05 contract](../../phase-4/p4-05/README.md#density-contract) and its permanent unit test explicitly require reset on unmount. The browser test's comment claiming the table remained mounted was incorrect: an immediate Back sometimes beat the suspended route's commit.
The permanent test now waits for the loaded workspace, uses browser Back, requires the original query and expects Comfortable. This also exposed **P5-00-D02**: browser Back does not restore row focus after the table actually unmounts. A second complete run reproduced this in all four theme/locale cases. The existing unit contract restores row focus through the workspace's **Back to applications** button, whose `close()` passes `focusJobId`; browser history returns to an earlier entry without that field. The browser test now additionally reopens the workspace and requires query and row focus after that explicit button. It retains the existing focus assertion on the actual supported path rather than claiming browser Back has that behavior.
P5-00-D02 remains open for P5-03/08 navigation accessibility work; `App.tsx`, `JobTable` and `ApplicationWorkspacePage` are unchanged from the source checkpoint. It does not block unchanged shell migration, but browser-Back focus must not be called verified. The initial failures/probe are retained in `RESULTS.json`; no retry, exclusion or expected-failure annotation hides them. The full suite is rerun after this test-only correction. No density persistence or navigation application behavior is added here.
Manual local browser review supplements Playwright: 320px Bokmål drawer labels, Tab focus ring above the dimmed header, Escape return; 768px dark Bokmål Settings title/header; dark command input→Tab→Escape with focus return; light English 1440px controls. Screen-reader AX observations are not an NVDA/Narrator session. Physical touch/software keyboard, native screen reader announcements, OS forced colors and 200%/400% browser zoom remain unverified operator checks, not passes.
## Visual and responsive evidence
The final screenshot/result index is [RESULTS.json](RESULTS.json). Captures use synthetic local data; no authentication storage, browser traces, credentials or production content are committed. The index records exact test/state names and image hashes. Screenshots complement the native behavioral assertions, rather than proving ownership or persistence by appearance.
| Theme / locale | Intermediate header | Focused phone navigation | Commands | Loaded Tooltip | Open phone toast |
|---|---|---|---|---|---|
| Light / English | [768px](visual/admin-768-light-en.png) | [320px](visual/drawer-light-en.png) | [Input focus](visual/commands-light-en.png) | [Redo](visual/redo-tooltip-light-en.png) | [320px](visual/open-toast-320-light-en.png) |
| Light / Bokmål | [768px](visual/admin-768-light-nb.png) | [320px](visual/drawer-light-nb.png) | [Input focus](visual/commands-light-nb.png) | [Redo](visual/redo-tooltip-light-nb.png) | [320px](visual/open-toast-320-light-nb.png) |
| Dark / English | [768px](visual/admin-768-dark-en.png) | [320px](visual/drawer-dark-en.png) | [Input focus](visual/commands-dark-en.png) | [Redo](visual/redo-tooltip-dark-en.png) | [320px](visual/open-toast-320-dark-en.png) |
| Dark / Bokmål | [768px](visual/admin-768-dark-nb.png) | [320px](visual/drawer-dark-nb.png) | [Input focus](visual/commands-dark-nb.png) | [Redo](visual/redo-tooltip-dark-nb.png) | [320px](visual/open-toast-320-dark-nb.png) |
Additional representative captures include 900/1440px headers, phone admin metadata, desktop open-toast placement and dirty-editor Cancel dialogs. Dirty-dialog filenames identify the starting theme; their capture occurs after the deliberate cross-tab theme switch, so the visible theme is the opposite one. The automated shell sweep checks **320, 390, 767, 768, 900, 1023, 1024 and 1440px**. Below 1024px there is one named temporary-navigation trigger; at/above 1024px there is one desktop collapse/expand control and a reachable permanent destination. The 768/900/1023px cases open and close the drawer by keyboard. Open-drawer resizing restores desktop accessibility, then phone reopen/Escape returns correctly. The populated workspace's original no-overflow assertions also run at 375/768/900/1023/1024/1440 in both themes.
Review specifically retains the existing header control ordering, sidebar spacing/groups, provisional identity, Settings sections and bottom feedback presentation. The 768/900px layout keeps the page's full content width and readable labels. Mobile metadata remains available without occupying the logo row; its first actual action receives focus. The focused navigation outline is visible above the dimmed header. Connections and the toast close button are simultaneously visible and hit-testable. Existing desktop header wrapping, page styling, broad shell composition and command appearance are deferred to P5-01/02, not silently redesigned here.
The final rebuilt open-toast phone images were inspected in all four theme/locale pairs, plus representative desktop pairs. Some post-axe dark/Bokmål desktop captures still show the previously recorded missing glyph/icon fragments; those images are incomplete visual evidence for typography/navigation rendering, not a pass for the affected pixels. Raw captures are retained, not retouched. Phone text/control views and native hit-test assertions remain separate evidence. Carry this known capture-quality limitation into P5-01 visual review; no speculative application repair was made.
## Final validation
The complete baseline runs use the final application candidate. The earlier focused breakpoint proof is identified separately and its cases are repeated in the final main browser suite. No retry, skip or expected-failure annotation was added to conceal a new failure.
| Check | Inherited | Final result / change |
|---|---|---|
| Backend Release tests | 916 passing | **915 pass / 1 fail / 916 total**; unchanged clock-dependent fixture P5-00-D01 below; no tests added/removed/skipped |
| Frontend Jest | 354 / 75 suites | **359 / 76 suites pass**; +2 localized shell cases, +2 toast behavior cases, +1 command list case |
| Main Chromium browser suite | 51 actual passes + P2-D01 expected failure | **66 actual passes + unchanged P2-D01 expected failure**, zero unexpected failures/skips/retries; reporter says 67 passed because it includes the expected failure. +15 cases (14 readiness journeys + one account-switch case), no inherited case removed |
| Focused breakpoint/workspace proof, before final scroll-padding change | — | **5 pass**; four theme/locale shell journeys and the unchanged workspace overflow assertion with extra widths; repeated in the final main suite |
| Focused final Tooltip/toast native-scroll proof | — | **4 pass**; all theme/locale pairs, each at 1440/390/320px while feedback is open |
| B04 rate-limit HTTP suite | 9 | **9 pass** |
| Production-mode ingress HTTP suite | 6 | **6 pass**; local API/proxy metadata proof, not deployed F04 closure |
| B03 PDF admission HTTP suite | 1 | **1 pass** |
| Effective Compose/configuration | 3 | **3 pass**, real Compose rendering plus adversarial mutations |
| Standalone TypeScript / edited E2E typecheck / lint | Pass | **Pass**; historical TS2571 is absent, not an expected failure |
| Frontend production / API Release builds | Pass | **Pass**; API zero warnings/errors |
| Supply-chain script / model-evaluation harness / extension | 3 / 5 / 3 | **3 / 5 / 3 pass**; no live provider certification |
| Secret / whitespace / current Markdown links | Pass | **Pass**; staged tracked-secret scan, working/staged whitespace checks, 132 current/root Markdown documents with zero missing local file targets |
B01–B05 coverage remains in the executed backend and HTTP/browser suites: import destinations, ownership/session isolation, render admission, rate partitions and CSV export. The sole backend failure is identified below, not in those security contracts. Dependencies, package scripts, CI gates, auth, API and deployment configuration are unchanged.
### Commands
Root commands (SDK path is this machine's existing SDK):
```powershell
rtk proxy C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=p5-00.trx" --results-directory .artifacts/redesign-p5-00 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
rtk proxy C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi/JobTrackerApi.csproj --configuration Release --no-restore -warnaserror
rtk proxy python scripts/test-ingress.py
rtk proxy python scripts/test-supply-chain.py
rtk proxy python scripts/test-ollama-evaluation.py
rtk proxy node --test browser-extension/tests/*.test.mjs
rtk proxy python scripts/supply-chain.py scan-secrets
rtk proxy git diff --check
rtk proxy git diff --cached --check
rtk proxy python .artifacts/p4-04-check-doc-links.py
```
In `job-tracker-ui`, `DOTNET_ROOT=C:/Users/Connor/.dotnet`, `DOTNET_HOST_PATH=C:/Users/Connor/.dotnet/dotnet.exe`, `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api`, `NEXT_TELEMETRY_DISABLED=1`, `PLAYWRIGHT_STATIC_EXPORT=true`, `PLAYWRIGHT_PREBUILT_EXPORT=true`:
```powershell
rtk proxy npm test -- --runInBand --json --outputFile=../.artifacts/p5-00-jest.json
rtk proxy npm run typecheck
rtk proxy npm run lint
rtk proxy npm run build
rtk proxy node node_modules/typescript/bin/tsc --project ../.artifacts/p5-00-e2e-tsconfig.json --noEmit
rtk proxy node node_modules/@playwright/test/cli.js test --grep "mobile labels|the dedicated application workspace" --reporter=list,json --output=../.artifacts/p5-00-resize-results
rtk proxy node node_modules/@playwright/test/cli.js test e2e/table-density.spec.ts --reporter=list,json --output=../.artifacts/p5-00-density-final-results
rtk proxy node node_modules/@playwright/test/cli.js test e2e/shell-readiness.spec.ts --grep "real Tooltip" --reporter=list,json --output=../.artifacts/p5-00-scroll-final-results
rtk proxy node node_modules/@playwright/test/cli.js test --config=playwright.rate-limits.config.ts --reporter=list,json --output=../.artifacts/p5-00-rate-limits-results
rtk proxy node node_modules/@playwright/test/cli.js test --config=playwright.ingress.config.ts --reporter=list,json --output=../.artifacts/p5-00-ingress-results
rtk proxy node node_modules/@playwright/test/cli.js test --config=playwright.pdf.config.ts --reporter=list,json --output=../.artifacts/p5-00-pdf-results
rtk proxy node node_modules/@playwright/test/cli.js test --reporter=list,json
```
Each Playwright run sets `PLAYWRIGHT_JSON_OUTPUT_FILE` to its `.artifacts/p5-00-{resize-final,density-final,scroll-padding-final,rate-limits,ingress,pdf,browser}.json` result. Security suites run sequentially against their own disposable API. The extra temporary E2E tsconfig extends the application's tsconfig, disables incremental output, sets Node types/typeRoots to the installed UI dependencies, and includes the six edited/new E2E specs plus `src/mui-theme.d.ts`; it changes no project/CI configuration. The existing local Markdown checker resolves relative file targets in current/root documents, excluding historical archives; it does not certify remote URLs or anchor correctness. In nested Windows PowerShell, `npm.cmd` runs the same npm scripts without depending on the host's `.ps1` execution policy.
### P5-00-D01 — unrelated date-dependent backend fixture
On 2026-09-20 and again in the final 2026-09-21 run, the unchanged `BackgroundWorkerTenantTests.Rules_worker_uses_each_owners_settings_and_is_idempotent` fails at line 79: expected `Applied`, actual `Ghosted`. Its fixture seeds `DateApplied = FixedNow.AddDays(-30)` (2026-07-16), but the rules worker receives `TimeProvider.System`. The real clock has crossed the second owner's 60-day threshold. The production worker is applying that configured rule; this test mixes fixed data and a moving clock.
All 916 backend tests were executed: **915 passed, one failed, none skipped**. Neither backend application code nor this test was changed. This is distinct from P2-D01, SQLite write contention and a P5-00 UI regression. A requested, separate one-line test-only correction would pass the already existing `MutableTimeProvider(FixedNow)` to this worker and then rerun all 916; no production clock/rule change is proposed. Until that correction is authorized and validated, the inherited all-green backend baseline is not restored. Do not waive, filter out or relabel this test as expected failure.
An earlier overlapping API/build attempt encountered Windows apphost file locking. Runs were subsequently serialized. That tooling conflict is not the cause of the assertion above. The interrupted browser run was replaced by a complete final run, not combined into a fabricated aggregate.
## Deferred scope
- F04/B06 production ingress evidence remains open: no host, firewall, DNS, TLS or deployed topology proof was collected.
- P2-D01 remains the identified native SQLite package/tailoring expected failure. No archive success is claimed and its later assertions remain blocked.
- P4-02-D02 company modal error feedback, D03 workspace progress scrolling, D04 literal AI context, P4-04-O02 SQLite contention, P4-05-O01 raw status/date localization remain separately scoped.
- P5-00-D02: loaded Job Workspace → browser Back preserves the query but loses row focus. The explicit workspace Back button's focus contract is separately protected. Resolve browser-history focus before affected P5-03/08 navigation migration; no Jobs implementation was changed here.
- CV revision integrity, rendering/margins/templates, package preservation and Table/Board query parity gates are unchanged. No backend/calendar/billing/worker redesign or B07 work.
- The actual Redo Tooltip capture still shows the CV preview's horizontal-overflow warning. It is retained as G-CV-RENDER evidence, not hidden or treated as a tooltip failure; P5-00 does not certify preview geometry.
- P5-01 owns visible grouped navigation; P5-02 owns command catalogue/presentation extensions and search error states. Dashboard/Jobs/CV visual redesign has not begun.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+7 -6
View File
@@ -1,18 +1,19 @@
# TODO
Status reconciled: 2026-09-13.
Status reconciled: 2026-09-21.
## Current repository work
- **Phase 5 planning checkpoint — complete, implementation unstarted:** [bounded plan](../redesign/phase-5/README.md) at source `2a2f9e4` maps all 57 contracts/31 routes. Recommended next instruction is **P5-00 shell readiness only**; P5-01 is not cleared until its independent focus/semantics/tooltip/feedback evidence passes. Board integration, native package and CV revision/rendering gates apply only to dependent slices. No P5-00/P5-01 implementation or B07 was begun; F04 remains open.
- **P5-00 — readiness implementation complete, P5-01 CONDITIONAL GO:** [record](../redesign/phase-5/p5-00/README.md) covers the five bounded shell gates, shared 1024px navigation boundary and open-drawer resize lifecycle. No visible/grouped-navigation redesign, P5-01 or B07 work. The unrelated backend baseline condition below must be resolved before P5-01 starts.
- **P5-00-D01 — open test-baseline defect:** the unchanged rules-worker tenant test seeds July 16 data but uses the system clock; its 60-day assertion now fails (915/916 backend pass). Separately authorize the existing fixed-clock fixture injection and rerun all 916. No production rules change, skipped test or expected-failure annotation is proposed. [Evidence](../redesign/phase-5/p5-00/README.md#p5-00-d01--unrelated-date-dependent-backend-fixture).
- **P5-00-D02 — open Jobs browser-Back focus debt:** after the workspace actually renders, browser Back preserves the query but does not restore row focus. The explicit workspace Back button carries the existing focus contract. Corrected browser evidence distinguishes them; no application behavior changed. Resolve before affected P5-03/08 navigation migration. [Evidence](../redesign/phase-5/p5-00/README.md#inherited-density-navigation-oracle-corrected).
- **Phase 5 plan:** [bounded plan](../redesign/phase-5/README.md) maps all 57 contracts/31 routes. Board integration, native package and CV revision/rendering gates apply only to dependent slices. F04 remains open.
- **P2-D01 — open:** repair the SQLite `DateTimeOffset` ordering baseline before package/tailoring restructuring; retain the expected-failure browser oracle until native capture and historical-binary preservation are proven. P2-O01's initial rules-500 behavior also remains unconfirmed.
- **P3-O01/P3-O02 — open:** address analytics population/period labels and navigation/query/error retention when those surfaces are next changed. Evidence is in the current [redesign decision records](../redesign/phase-3/OPEN_DECISIONS.md).
- **P4-02-D01–D04 — existing accessibility/content debt:** command-list semantic children, company save-error feedback outside the modal's accessible subtree, the mobile application-progress scroll region, and the literal AI context `{name}` placeholder. Evidence and owning future slices are in the [P4-02 record](../redesign/phase-4/p4-02/README.md). Do not disable modal background isolation or hide findings.
- **P4-03-D01 — existing shared Tooltip contrast debt:** the light Redo tooltip fails the text contrast target. Evidence and the narrow regression exception are in the [P4-03 record](../redesign/phase-4/p4-03/README.md); repair in a scoped shared-theme follow-up.
- **P4-04-O01 — existing fixed-toast overlap:** export-success feedback can temporarily obscure a bottom-of-page action. Review shared feedback placement with later shell work; P4-04 leaves timing/placement unchanged and verifies the action after normal dismissal. [Evidence](../redesign/phase-4/p4-04/README.md).
- **P4-02-D02–D04 — open accessibility/content debt:** company save-error feedback outside the modal's accessible subtree, mobile application-progress scroll region and literal AI context `{name}`. D01 command-list semantics are addressed in P5-00; D02–D04 are unchanged. [Original evidence](../redesign/phase-4/p4-02/README.md). Do not disable modal background isolation or hide findings.
- **P5-00 scoped closures:** P4-03-D01 Tooltip contrast, P4-04-O01 bottom-action toast overlap, P4-06-O01 mobile labels/focus and P4-06-O02 synthetic long admin metadata are addressed with [readiness evidence](../redesign/phase-5/p5-00/README.md). This does not certify all overlays, all accessibility targets or deployed production behavior.
- **P4-04-O02 — existing SQLite notification-save contention:** overlapping native preference read/write requests reproduced HTTP 500 with SQLite error 5 in two of ten local runs. The sequential UI test now awaits and requires the save response before readback; server concurrency remains unresolved. Investigate separately from page-layout/density work. [Evidence](../redesign/phase-4/p4-04/README.md).
- **P4-05-O01 — existing table localization debt:** status menu/bulk actions expose raw English status keys in Bokmål, and applied dates follow the browser locale. Keep status values/date semantics intact until the Phase 5 Jobs localization work; P4-05 localizes its new density/paging/accessible names only. [Evidence](../redesign/phase-4/p4-05/README.md).
- **P4-06-O01/O02 — existing responsive shell debt:** a persisted collapsed desktop sidebar removes visible labels from the mobile drawer and puts its first focused row under the fixed app bar; a long synthetic admin version badge overlaps the 320px header. Reproduced manually and traced to pre-Phase-4 code. Gate the later shell migration on separate mobile-label/focus and long-metadata reflow proof; no shell remediation is included in P4-06. [Evidence](../redesign/phase-4/p4-06/README.md).
- **F04/B06 — open:** complete the authorized main release and production ingress/runtime verification; the browser gate and runner diagnostics remain unresolved. No gate should be bypassed.
## Delivery and feature gates
+52 -2
View File
@@ -1,10 +1,10 @@
import { expect, test as base, type APIRequestContext, type Playwright, type Page } from "@playwright/test";
import { expect, test as base, type APIRequestContext, type PlaywrightWorkerArgs, type Page } from "@playwright/test";
const apiUrl = "http://localhost:5302/api";
const password = "E2ePassword123!";
type Account = { api: APIRequestContext; email: string; id: string; sessionContext?: string; headers: Record<string, string> };
async function signIn(playwright: Playwright, email: string): Promise<Account> {
async function signIn(playwright: PlaywrightWorkerArgs["playwright"], email: string): Promise<Account> {
const api = await playwright.request.newContext();
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
@@ -114,6 +114,56 @@ async function uiLogin(page: Page, email: string) {
await expect(page).toHaveURL(/\/dashboard$/);
}
test("[P5-00 PRES-E05 E07 E08] account switch disposes open command, account and notification surfaces", async ({ context, accounts }) => {
test.setTimeout(90_000);
const { a, b } = accounts;
const company = await a.api.post(`${apiUrl}/companies`, { headers: a.headers, data: { name: "Private A shell company" } });
expect(company.status()).toBe(201);
await context.route("**/*", route => {
const url = new URL(route.request().url());
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
});
await context.addInitScript(() => localStorage.setItem("uiLanguage", "en"));
const session = await context.newPage();
await uiLogin(session, a.email);
const command = await context.newPage();
await command.goto("/dashboard");
await command.getByRole("button", { name: /Quick Search/ }).click();
await command.getByRole("textbox", { name: "Quick Search", exact: true }).fill("Private A shell");
await expect(command.getByRole("button", { name: /Private A shell company/ })).toBeVisible();
const account = await context.newPage();
await account.goto("/dashboard");
await account.getByRole("button", { name: "User", exact: true }).click();
await expect(account.getByRole("menu")).toBeVisible();
const notification = await context.newPage();
// Only notification content is a fixture; authentication/logout/switch and company search are native.
await notification.route(`${apiUrl}/notifications?limit=10`, route => route.fulfill({ json: [{
id: "p500-private-a", kind: "info", title: "Private A shell notice", message: "Account A only", createdAtUtc: new Date().toISOString(),
}] }), { times: 1 });
await notification.goto("/dashboard");
await notification.getByRole("button", { name: "Notifications", exact: true }).click();
await expect(notification.getByText("Private A shell notice", { exact: true })).toBeVisible();
await session.getByRole("button", { name: "User", exact: true }).click();
await session.getByRole("menuitem", { name: "Sign out", exact: true }).click();
await expect(session).toHaveURL(/\/login\?sessionChanged=1$/);
await uiLogin(session, b.email);
for (const tab of [command, account, notification]) {
await expect(tab.getByRole("dialog")).toHaveCount(0);
await expect(tab.getByRole("menu")).toHaveCount(0);
await expect(tab.getByText(/Private A shell/)).toHaveCount(0);
await tab.goto("/dashboard");
await expect(tab.getByText(a.email, { exact: true })).toHaveCount(0);
await expect(tab.getByTestId("admin-version-badge")).toHaveCount(0);
}
await command.getByRole("button", { name: /Quick Search/ }).click();
await command.getByRole("textbox", { name: "Quick Search", exact: true }).fill("Private A shell");
await expect(command.getByText("No matching commands or records.", { exact: true })).toBeVisible();
await notification.getByRole("button", { name: "Notifications", exact: true }).click();
await expect(notification.getByText("Private A shell notice", { exact: true })).toHaveCount(0);
expect(await storedName(a)).toBe("Private A");
expect(await storedName(b)).toBe("Private B");
});
test("two tabs discard A's private form before B can use it, including Back navigation", async ({ context, accounts }) => {
test.setTimeout(90_000);
const { a, b } = accounts;
@@ -50,9 +50,8 @@ async function capture(page: Page, info: TestInfo, state: string) {
async function scan(page: Page, info: TestInfo, state: string) {
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
// P4-03-D01: unchanged light Tooltip foreground/background fails on Redo.
// Retain the full finding; no preview rule or other contrast failure is exempted.
expect(result.violations.filter(v => !(state === "loaded-final" && v.id === "color-contrast" && v.nodes.every(n => n.target.length === 1 && n.target[0] === ".MuiTooltip-tooltip")))).toEqual([]);
// P5-00 repairs the shared Tooltip pair; no contrast exception remains.
expect(result.violations).toEqual([]);
}
async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) {
@@ -52,12 +52,10 @@ async function scan(page: Page, info: TestInfo, name: string, dialog = false) {
if (dialog) axe = axe.include('[role="dialog"]');
const result = await axe.analyze();
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
// P4-02-D01: unchanged command List renders button divs directly under a ul.
// Record the full result; only this precise existing structural defect is deferred.
const unexpected = result.violations.filter(v => !(name === "command" && v.id === "list" && v.nodes.every(n => n.html.startsWith("<ul ") && n.html.includes("MuiList-root"))));
// Dialogs gate all other rules; loaded pages gate naming and inherited contrast.
// P5-00 repairs the command-list boundary; dialogs now gate every rule.
// Loaded pages still gate naming and inherited contrast.
// Preview scrolling and wider workspace structure remain separately recorded debt.
expect(dialog ? unexpected : result.violations.filter(v => ["label", "select-name", "aria-input-field-name", "aria-dialog-name", "color-contrast"].includes(v.id))).toEqual([]);
expect(dialog ? result.violations : result.violations.filter(v => ["label", "select-name", "aria-input-field-name", "aria-dialog-name", "color-contrast"].includes(v.id))).toEqual([]);
}
for (const [mode, language, width] of [["light", "en", 1440], ["dark", "nb", 1440], ["light", "nb", 390], ["dark", "en", 390]] as const) {
+366
View File
@@ -0,0 +1,366 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from "@playwright/test";
import { translations } from "../src/i18n/translations";
const apiUrl = "http://localhost:5302/api";
const password = "E2ePassword123!";
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(c => c.name === "XSRF-TOKEN")!.value;
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
}
const test = base.extend<{ owner: { headers: Record<string, string>; jobId: number; companyId: number; cvId: number } }>({
owner: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p500-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ["Admin"] } })).status()).toBe(200);
const headers = await login(page.request, email);
await context.route("**/*", route => {
const url = new URL(route.request().url());
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
});
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys beredskap æøå" } });
expect(company.status()).toBe(201);
const companyId = (await company.json()).id;
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: "Tilgjengelig plattformutvikler", status: "Saved" } });
expect(job.ok()).toBe(true);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Readiness CV" } });
expect(cv.status()).toBe(200);
await provide({ headers, jobId: (await job.json()).id, companyId, cvId: (await cv.json()).id });
} finally { await admin.dispose(); }
},
});
async function settled(page: Page) {
await page.evaluate(async () => {
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await Promise.all(document.getAnimations().filter(a => a.playState === "running" && Number.isFinite(a.effect?.getComputedTiming().endTime)).map(a => a.finished.catch(() => undefined)));
});
}
async function capture(page: Page, info: TestInfo, state: string) {
await settled(page);
const path = info.outputPath(`${state}.png`);
await page.screenshot({ path });
await info.attach(state, { path, contentType: "image/png" });
}
async function scan(page: Page, info: TestInfo, state: string, selector?: string) {
await settled(page);
let axe = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]);
if (selector) axe = axe.include(selector);
const result = await axe.analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: "application/json" });
expect(result.violations).toEqual([]);
}
async function exposed(target: Locator) {
await expect.poll(() => target.evaluate(node => {
const r = node.getBoundingClientRect();
return r.left >= 0 && r.right <= innerWidth + 1 && r.top >= 0 && r.bottom <= innerHeight + 1 && node.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
})).toBe(true);
}
async function ring(target: Locator) {
await expect.poll(() => target.evaluate(node => ({ width: getComputedStyle(node).outlineWidth, style: getComputedStyle(node).outlineStyle }))).toEqual({ width: "3px", style: "solid" });
}
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
const suffix = `${mode}-${language}`;
test.describe(suffix, () => {
test.beforeEach(async ({ page, owner }) => {
await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
});
test(`[P5-00 PRES-E07 E08 A03] ${suffix} mobile labels, focus and admin reflow`, async ({ page }, info) => {
test.setTimeout(120_000);
const t = translations[language];
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/settings");
await expect(page.getByRole("heading", { level: 1, name: t.settings })).toBeVisible();
await page.getByRole("button", { name: t.collapseSidebar }).click();
await page.reload();
await expect(page.getByRole("button", { name: t.expandSidebar })).toBeVisible();
for (const width of [1440, 1024, 1023, 900, 768, 767, 390, 320]) {
await page.setViewportSize({ width, height: width < 360 ? 740 : width < 768 ? 844 : 900 });
await settled(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
const header = page.getByRole("banner");
await exposed(page.getByRole("heading", { level: 1, name: t.settings }));
const controls = header.locator("button");
for (const control of await controls.all()) if (await control.isVisible()) await exposed(control);
if (width >= 1024) {
await expect(header.getByRole("button", { name: t.expandSidebar, exact: true })).toHaveCount(1);
await expect(header.getByRole("button", { name: t.openNavigation, exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: t.dashboard, exact: true })).toHaveCount(1);
await exposed(page.getByRole("button", { name: t.dashboard, exact: true }));
await exposed(page.getByTestId("admin-version-badge"));
} else {
await expect(header.getByRole("button", { name: t.openNavigation, exact: true })).toHaveCount(1);
await expect(header.getByRole("button", { name: t.expandSidebar, exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: t.dashboard, exact: true })).toHaveCount(0);
await expect(page.getByTestId("admin-version-badge")).toHaveCount(0);
const user = page.getByRole("button", { name: t.user, exact: true });
await user.click();
await expect(page.getByRole("menu")).toContainText(t.applicationVersion.replace("{version}", "e2e-verification"));
await expect(page.getByRole("menu")).toContainText("e2e1234");
if (width === 320) {
await expect(page.getByRole("menuitem", { name: t.profileMenu, exact: true })).toBeFocused();
await capture(page, info, `account-metadata-${suffix}`);
await scan(page, info, `account-metadata-${suffix}`, '[role="menu"]');
}
await page.keyboard.press("Escape");
await expect(user).toBeFocused();
if ([768, 900, 1023].includes(width)) {
const navigation = header.getByRole("button", { name: t.openNavigation, exact: true });
await navigation.focus();
await navigation.press("Enter");
const first = page.getByRole("dialog", { name: t.navigation }).getByRole("button", { name: t.dashboard, exact: true });
await expect(first).toBeFocused();
await exposed(first);
await page.keyboard.press("Escape");
await expect(navigation).toBeFocused();
}
}
if ([1440, 900, 768, 320].includes(width)) await capture(page, info, `admin-${width}-${suffix}`);
}
const trigger = page.getByRole("button", { name: t.openNavigation });
await trigger.focus();
await trigger.press("Enter");
const drawer = page.getByRole("dialog");
await expect(drawer).toHaveAccessibleName(/.+/);
const dashboard = drawer.getByRole("button", { name: t.dashboard, exact: true });
await expect(dashboard.getByText(t.dashboard, { exact: true })).toBeVisible();
// Opening the modal sets a meaningful entry; Tab traverses its controls.
await expect(dashboard).toBeFocused();
await exposed(dashboard);
await ring(dashboard);
await expect(drawer.getByRole("button", { name: t.settings, exact: true })).toHaveAttribute("aria-current", "page");
const links = drawer.getByRole("button");
for (let i = 0; i < await links.count(); i++) {
await page.keyboard.press("Tab");
expect(await drawer.evaluate(node => node.contains(document.activeElement))).toBe(true);
}
await links.last().focus();
await links.last().scrollIntoViewIfNeeded();
await exposed(links.last());
await dashboard.focus();
await dashboard.scrollIntoViewIfNeeded();
await capture(page, info, `drawer-${suffix}`);
await scan(page, info, `drawer-${suffix}`, '[role="dialog"]');
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await exposed(trigger);
await trigger.press("Enter");
await dashboard.press("Enter");
await expect(page).toHaveURL(/\/dashboard$/);
await expect(drawer).not.toBeVisible();
await page.setViewportSize({ width: 1440, height: 900 });
await expect(page.getByRole("button", { name: t.expandSidebar })).toBeVisible();
await page.getByRole("button", { name: t.expandSidebar }).click();
await page.setViewportSize({ width: 390, height: 844 });
await trigger.click();
await expect(dashboard).toBeFocused();
await exposed(dashboard);
// A hidden temporary drawer must not keep desktop content aria-hidden or trap focus.
await page.setViewportSize({ width: 1024, height: 900 });
await expect(drawer).toHaveCount(0);
const desktopToggle = page.getByRole("button", { name: t.collapseSidebar, exact: true });
await desktopToggle.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(desktopToggle).toBeFocused();
await ring(desktopToggle);
await expect(page.getByRole("button", { name: t.dashboard, exact: true })).toHaveCount(1);
await page.setViewportSize({ width: 390, height: 844 });
await expect(drawer).toHaveCount(0);
await trigger.press("Enter");
await expect(dashboard).toBeFocused();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
});
test(`[P5-00 PRES-E07 J03 J08] ${suffix} command list, keyboard and no-write actions`, async ({ page, owner }, info) => {
test.setTimeout(90_000);
const t = translations[language];
await page.setViewportSize({ width: language === "nb" ? 390 : 1440, height: 900 });
await page.goto("/settings");
await expect(page.getByRole("heading", { level: 1, name: t.settings })).toBeVisible();
const trigger = page.getByRole("button", { name: new RegExp(t.quickSearch) });
await trigger.focus();
await trigger.press("Enter");
const dialog = page.getByRole("dialog", { name: t.quickSearch });
const input = dialog.getByRole("textbox", { name: t.quickSearch });
await expect(input).toBeFocused();
await capture(page, info, `commands-${suffix}`);
await scan(page, info, `commands-${suffix}`, '[role="dialog"]');
await page.keyboard.press("Tab");
await expect(dialog.getByRole("button", { name: new RegExp(t.goToDashboard) })).toBeFocused();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await page.keyboard.press("Control+k");
await input.fill("Nordlys");
const job = dialog.getByRole("button", { name: /Nordlys.*Tilgjengelig/ });
await expect(job).toBeVisible();
await job.focus();
await job.press("Enter");
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.jobId}$`));
await page.keyboard.press("Control+k");
await input.fill("Nordlys");
const company = dialog.getByRole("button", { name: new RegExp(`^Nordlys beredskap æøå ${t.openCompanies}$`) });
await expect(company).toBeVisible();
await company.press("Enter");
await expect(page).toHaveURL(new RegExp(`/companies\\?edit=${owner.companyId}$`));
// Wait for the route's asynchronous record read before dismissing its modal.
await expect(dialog).not.toBeVisible();
await expect(page.getByRole("dialog", { name: t.companiesEdit })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).not.toBeVisible();
await page.keyboard.press("Control+k");
await input.fill("no-such-readiness-command");
await expect(dialog.getByText(t.noMatchingCommands)).toBeVisible();
await scan(page, info, `commands-empty-${suffix}`, '[role="dialog"]');
await input.fill("");
await dialog.getByRole("button", { name: new RegExp(t.addNewJob) }).press("Enter");
await expect(dialog).not.toBeVisible();
await expect(page.getByRole("dialog")).toBeVisible();
expect((await (await page.request.get(`${apiUrl}/jobapplications`)).json()).total).toBe(1);
});
test(`[P5-00 PRES-E08 C06 S05] ${suffix} real Tooltip contrast and open-toast action access`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[language];
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`/career/builder/${owner.cvId}`);
await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click();
const headline = page.getByRole("textbox", { name: t.cvEditorHeadlineOverride, exact: true });
await headline.fill("Edited readiness headline");
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
await page.getByRole("button", { name: t.cvEditorUndo, exact: true }).click();
const redo = page.getByRole("button", { name: t.cvEditorRedo, exact: true });
await expect(redo).toBeEnabled();
await expect(redo).toHaveAccessibleName(t.cvEditorRedo);
await redo.hover();
const tooltip = page.getByRole("tooltip", { name: t.cvEditorRedo, exact: true });
await expect(tooltip).toBeVisible();
await capture(page, info, `redo-tooltip-${suffix}`);
await scan(page, info, `redo-tooltip-${suffix}`, '[role="tooltip"]');
await page.mouse.move(0, 0);
await redo.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(redo).toBeFocused();
await expect(tooltip).toBeVisible();
await ring(redo);
await page.goto("/settings");
await page.getByRole("button", { name: t.notifications, exact: true }).click();
const notificationClose = page.getByRole("button", { name: t.close, exact: true });
await expect(notificationClose).toHaveAccessibleName(t.close);
await notificationClose.hover();
await expect(page.getByRole("tooltip", { name: t.close, exact: true })).toBeVisible();
await scan(page, info, `shell-tooltip-${suffix}`, '[role="tooltip"]');
await notificationClose.click();
const main = page.getByRole("main");
let exported: APIResponse | undefined;
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: width < 360 ? 740 : width < 768 ? 844 : 900 });
const choose = async (label: string) => {
if (width >= 768) await main.getByRole("tab", { name: label, exact: true }).click();
else {
await main.getByRole("combobox", { name: t.settingsNavigation, exact: true }).click();
await page.getByRole("option", { name: label, exact: true }).click();
}
};
await choose(t.settingsTabBackup);
// Complete the real export after navigating back to the bottom action.
// Otherwise clicking General correctly dismisses the toast via click-away.
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route(`${apiUrl}/export/account`, async route => {
// One native ZIP, replayed for viewport checks to respect the 2/minute owner limit.
exported ??= await route.fetch();
expect(exported.status()).toBe(200);
await held;
await route.fulfill({ response: exported });
}, { times: 1 });
const download = page.waitForEvent("download");
await main.getByRole("button", { name: t.accountExportDownload, exact: true }).click();
await choose(t.settingsTabGeneral);
const manage = main.getByRole("button", { name: t.settingsManageConnectedAccounts, exact: true });
await manage.focus();
release();
expect((await download).suggestedFilename()).toMatch(/\.zip$/);
const toast = page.getByRole("alert").filter({ hasText: t.accountExportDownloaded });
await expect(toast).toBeVisible();
// Deliberately align at the viewport end: reserved page space alone does
// not stop native scrolling from placing an action underneath the toast.
await manage.evaluate(node => node.scrollIntoView({ block: "end" }));
// Hover uses Snackbar's normal pause behavior; timing is not shortened or mocked.
await toast.hover();
await toast.getByRole("button").focus();
await exposed(manage);
await capture(page, info, `open-toast-${width}-${suffix}`);
await expect(toast).toBeVisible();
await exposed(toast.getByRole("button"));
await toast.getByRole("button").click();
await expect(toast).not.toBeVisible();
}
});
if ((mode === "light" && language === "en") || (mode === "dark" && language === "nb")) {
test(`[P5-00 PRES-E07 E09 C06 S02 S03] ${suffix} dirty editor survives shell utilities and cancel`, async ({ page, context, owner }, info) => {
test.setTimeout(90_000);
const t = translations[language];
await page.setViewportSize({ width: language === "nb" ? 320 : 1440, height: 900 });
await page.goto(`/career/builder/${owner.cvId}`);
await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click();
// A deterministic failed save keeps the real editor dirty; all reads stay native.
await page.route(`${apiUrl}/cv/variants/${owner.cvId}`, route => route.request().method() === "PUT"
? route.fulfill({ status: 503, json: { message: "Readiness save unavailable" } }) : route.continue());
const headline = () => page.getByRole("textbox", { name: translations[language].cvEditorHeadlineOverride, exact: true });
await headline().fill("Unsaved readiness draft æøå");
await expect(page.getByText(t.cvEditorSaveFailed, { exact: true })).toBeVisible();
const other = language === "en" ? "nb" : "en";
await page.locator("header").getByRole("button", { name: other === "nb" ? "Norsk" : "English", exact: true }).click();
await expect(page.getByRole("textbox", { name: translations[other].cvEditorHeadlineOverride, exact: true })).toHaveValue("Unsaved readiness draft æøå");
await page.locator("header").getByRole("button", { name: language === "nb" ? "Norsk" : "English", exact: true }).click();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
// Change the existing browser preference through Settings in a second tab.
const settings = await context.newPage();
await settings.goto("/settings");
await settings.getByRole("combobox", { name: t.settingsTheme, exact: true }).click();
await settings.getByRole("option", { name: mode === "light" ? t.settingsThemeDark : t.settingsThemeLight, exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode === "light" ? "dark" : "light");
await settings.close();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
if (language === "nb") {
const trigger = page.getByRole("button", { name: t.openNavigation });
await trigger.click();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
await trigger.click();
await page.getByRole("dialog").getByRole("button", { name: t.dashboard, exact: true }).click();
} else {
await page.keyboard.press("Control+k");
await page.getByRole("dialog").getByRole("button", { name: new RegExp(t.goToDashboard) }).click();
}
const confirm = page.getByRole("dialog", { name: t.cvEditorDiscardTitle });
await expect(confirm).toBeVisible();
await capture(page, info, `dirty-cancel-${suffix}`);
await confirm.getByRole("button", { name: t.cancel, exact: true }).click();
await expect(headline()).toHaveValue("Unsaved readiness draft æøå");
await expect(page).toHaveURL(new RegExp(`/career/builder/${owner.cvId}$`));
await page.keyboard.press("Control+k");
await page.getByRole("dialog").getByRole("button", { name: new RegExp(t.goToDashboard) }).click();
await confirm.getByRole("button", { name: t.cvEditorDiscardLeave, exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
expect(JSON.stringify(await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json())).not.toContain("Unsaved readiness draft");
});
}
});
}
+2 -2
View File
@@ -267,7 +267,7 @@ test("the dedicated application workspace survives deep links, long data and uns
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "light"));
await page.reload();
for (const width of [375, 768, 1440]) {
for (const width of [375, 768, 900, 1023, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.getByRole("heading", { name: title })).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
@@ -279,7 +279,7 @@ test("the dedicated application workspace survives deep links, long data and uns
await expect(page.getByLabel("Application answer", { exact: true })).toHaveValue("A reviewed answer that must not be lost.");
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
for (const width of [375, 768, 1440]) {
for (const width of [375, 768, 900, 1023, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.getByRole("heading", { name: title })).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+11 -2
View File
@@ -168,9 +168,18 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
expect(new URL(page.url()).pathname).toBe("/jobs");
await first.focus(); await first.press("Enter");
await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[0]}$`));
await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible();
await page.goBack();
// Existing App keeps the list mounted behind the dedicated workspace.
await expect(density).toHaveText(t.densityCompact);
// The dedicated route unmounts the table. Browser Back preserves the query;
// density is deliberately local and returns to its default.
expect(new URL(page.url()).search).toBe(query);
await expect(density).toHaveText(t.densityComfortable);
// P5-00-D02: browser Back does not restore row focus in the existing app.
// The explicit workspace Back action carries focusJobId; protect that path.
await first.focus(); await first.press("Enter");
await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible();
await page.getByRole("button", { name: t.workspaceBack, exact: true }).click();
await expect(page).toHaveURL(url => url.pathname === "/jobs" && url.search === query);
await expect(first).toBeFocused();
await main.getByRole("button", { name: t.tableNextPage }).click();
@@ -5,6 +5,7 @@ import {
Dialog,
DialogContent,
List,
ListItem,
ListItemButton,
ListItemText,
TextField,
@@ -135,13 +136,13 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
<List sx={{ mt: 1 }}>
{allItems.length === 0 ? (
<Box sx={{ px: 2, py: 3 }}>
<ListItem sx={{ px: 2, py: 3 }}>
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
</Box>
</ListItem>
) : (
allItems.map((item) => (
<ListItem key={item.id} disablePadding>
<ListItemButton
key={item.id}
onClick={() => {
item.action();
onClose();
@@ -150,6 +151,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
>
<ListItemText primary={item.label} secondary={item.hint} />
</ListItemButton>
</ListItem>
))
)}
</List>
+4
View File
@@ -13,6 +13,8 @@ export const translations = {
trash: "Trash",
settings: "Settings",
openNavigation: "Open navigation",
navigation: "Navigation",
applicationVersion: "Application version {version}",
expandSidebar: "Expand sidebar",
collapseSidebar: "Collapse sidebar",
addJob: "Add Job",
@@ -2464,6 +2466,8 @@ export const translations = {
trash: "Papirkurv",
settings: "Innstillinger",
openNavigation: "Åpne navigasjon",
navigation: "Navigasjon",
applicationVersion: "Applikasjonsversjon {version}",
expandSidebar: "Utvid sidemenyen",
collapseSidebar: "Skjul sidemenyen",
addJob: "Legg til jobb",
+56 -20
View File
@@ -17,6 +17,7 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Toolbar,
@@ -24,6 +25,7 @@ import {
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { Theme } from "@mui/material/styles";
import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
@@ -118,7 +120,7 @@ export default function AppShell({
children: React.ReactNode;
}) {
const { language, setLanguage, t } = useI18n();
const isMobile = useMediaQuery("(max-width:767.95px)");
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
@@ -127,6 +129,22 @@ export default function AppShell({
}
});
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
const navCollapsed = !isMobile && desktopNavCollapsed;
const [header, setHeader] = useState<HTMLElement | null>(null);
const [headerHeight, setHeaderHeight] = useState<number>();
useEffect(() => {
if (!isMobile && drawerOpen) onToggleDrawer(false);
}, [isMobile, drawerOpen, onToggleDrawer]);
useEffect(() => {
if (!header || typeof ResizeObserver === "undefined") return;
const measure = () => setHeaderHeight(Math.ceil(header.getBoundingClientRect().height));
measure();
const observer = new ResizeObserver(measure);
observer.observe(header);
return () => observer.disconnect();
}, [header]);
useEffect(() => {
try {
@@ -159,15 +177,15 @@ export default function AppShell({
);
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
<Box sx={{ px: navCollapsed ? 0.75 : 1.25, pt: 1 }}>
{groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
{section && !desktopNavCollapsed ? (
<Box key={section || "_"} sx={{ mb: navCollapsed ? 1 : 1.25 }}>
{section && !navCollapsed ? (
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 600, textTransform: "uppercase" }}>
{section}
</Typography>
) : null}
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
<List sx={{ px: navCollapsed ? 0.25 : 0.75, pt: navCollapsed ? 0.25 : 0.75 }}>
{rows.map((item) => {
const selected = item.to === activeTo;
return (
@@ -175,12 +193,14 @@ export default function AppShell({
<ListItemButton
selected={selected}
onClick={() => onNavigate(item.to)}
title={desktopNavCollapsed ? item.label : undefined}
aria-label={item.label}
aria-current={selected ? "page" : undefined}
title={navCollapsed ? item.label : undefined}
sx={{
borderRadius: 2,
minHeight: 44,
px: desktopNavCollapsed ? 1 : 1.5,
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
px: navCollapsed ? 1 : 1.5,
justifyContent: navCollapsed ? "center" : "flex-start",
border: "1px solid transparent",
color: SIDEBAR_TEXT_MUTED,
"&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT },
@@ -193,14 +213,14 @@ export default function AppShell({
},
}}
>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
<ListItemIcon sx={{ minWidth: navCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
{item.badgeCount && item.badgeCount > 0 ? (
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
{item.icon}
</Badge>
) : item.icon}
</ListItemIcon>
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
{!navCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
</ListItemButton>
</ListItem>
);
@@ -213,10 +233,10 @@ export default function AppShell({
const drawerContent = (
<Box sx={{ "--app-focus-color": "var(--palette-focus-inverse)", height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ px: navCollapsed ? 1.5 : 2.25, py: navCollapsed ? 2 : 2.5, display: "flex", justifyContent: navCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: navCollapsed ? "center" : "flex-start" }}>
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
{!desktopNavCollapsed ? (
{!navCollapsed ? (
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: SIDEBAR_SELECTED_TEXT }}>
Jobbjakt
@@ -270,6 +290,7 @@ export default function AppShell({
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
ref={setHeader}
position="fixed"
color="inherit"
elevation={0}
@@ -277,7 +298,7 @@ export default function AppShell({
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none",
zIndex: muiTheme.zIndex.drawer + 1,
zIndex: isMobile ? muiTheme.zIndex.appBar : muiTheme.zIndex.drawer + 1,
})}
>
<Toolbar
@@ -299,6 +320,8 @@ export default function AppShell({
size="small"
color="secondary"
aria-label={t("openNavigation")}
aria-haspopup="dialog"
aria-expanded={drawerOpen}
onClick={() => onToggleDrawer(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
@@ -311,7 +334,6 @@ export default function AppShell({
Jobbjakt
</Typography>
</Box>
{buildBadge}
</Box>
<StackLanguageToggle language={language} setLanguage={setLanguage} />
@@ -452,6 +474,12 @@ export default function AppShell({
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{isMobile && buildVersion ? (
<ListSubheader disableSticky role="presentation" sx={{ maxWidth: 280, py: 1, lineHeight: 1.5, overflowWrap: "anywhere" }}>
<Typography variant="body2">{t("applicationVersion", { version: buildVersion })}</Typography>
{buildCommit ? <Typography variant="caption">commit {buildCommit}</Typography> : null}
</ListSubheader>
) : null}
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
@@ -484,7 +512,7 @@ export default function AppShell({
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
display: isMobile ? "none" : "block",
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
@@ -498,17 +526,23 @@ export default function AppShell({
}}
open
>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
<Box aria-hidden sx={{ height: headerHeight ?? { xs: 68, md: 76 }, flexShrink: 0 }} />
{drawerContent}
</Drawer>
<Drawer
variant="temporary"
open={drawerOpen}
open={isMobile && drawerOpen}
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
slotProps={{
paper: { "aria-label": t("navigation") },
transition: { onEntered: (node: HTMLElement) => {
if (node === document.activeElement) node.querySelector<HTMLElement>('[role="button"]')?.focus();
} },
}}
sx={{
display: { xs: "block", md: "none" },
display: isMobile ? "block" : "none",
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
borderRight: `1px solid ${SIDEBAR_BORDER}`,
@@ -525,13 +559,15 @@ export default function AppShell({
sx={{
flex: 1,
p: "var(--app-page-gutter)",
// Keep the usual feedback lane stable before a delayed toast arrives.
pb: "calc(var(--app-page-gutter) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
bgcolor: "background.default",
minHeight: "100vh",
minWidth: 0,
}}
>
<Box sx={{ mx: "auto", maxWidth: pageLayout ? `var(--app-page-width-${pageLayout})` : 1320, width: "100%", minWidth: 0 }}>
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box aria-hidden sx={{ height: headerHeight ?? { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
@@ -45,3 +45,15 @@ test("[PRES-E07 PRES-J04] the capture command opens review without creating a jo
await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue(""));
expect(screen.queryByText("Nordlys AS - Platform engineer")).not.toBeInTheDocument();
});
test("[P5-00 PRES-E07] command and empty results use valid list semantics", async () => {
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
const list = screen.getByRole("list");
expect(screen.getAllByRole("listitem")).toHaveLength(6);
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-such-result" } });
mockedApi.get.mockResolvedValue({ data: [] } as never);
await screen.findByText("No matching commands or records.");
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
expect(mockedApi.post).not.toHaveBeenCalled();
});
@@ -0,0 +1,72 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { CssVarsProvider } from "@mui/material/styles";
import AppShell from "./layout/AppShell";
import { getTheme } from "./theme";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { ToastProvider, useToast } from "./toast";
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => true }));
for (const language of ["en", "nb"] as const) {
test(`[P5-00 PRES-E07 E08 A03] ${language} mobile names/current route ignore desktop collapse and retain metadata`, () => {
localStorage.setItem("uiLanguage", language);
localStorage.setItem("appShellDesktopSidebarCollapsed", "1");
const t = translations[language];
function Shell() {
const [open, setOpen] = useState(false);
return <AppShell pageTitle={t.settings} breadcrumbs={[]} pathname="/settings"
nav={[{ to: "/dashboard", label: t.dashboard, icon: <span /> }, { to: "/settings", label: t.settings, icon: <span /> }, { to: "/admin/users", label: "Hidden admin", icon: <span />, hidden: true }]}
navBottom={[]} drawerOpen={open} onToggleDrawer={setOpen} onNavigate={jest.fn()}
user={{ userName: "Åse" }} buildMetadata={{ version: "long-version-æøå", commitSha: "abc1234" }}>
<div>Existing page</div>
</AppShell>;
}
render(<CssVarsProvider theme={getTheme("light") as any}><I18nProvider><Shell /></I18nProvider></CssVarsProvider>);
expect(screen.queryByTestId("admin-version-badge")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: t.user }));
expect(screen.getByRole("menu")).toHaveTextContent("long-version-æøå");
expect(screen.getByRole("menu")).toHaveTextContent("abc1234");
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: t.openNavigation }));
const drawer = screen.getByRole("dialog", { name: t.navigation });
expect(within(drawer).getByRole("button", { name: t.dashboard })).toHaveTextContent(t.dashboard);
expect(within(drawer).getByRole("button", { name: t.settings })).toHaveAttribute("aria-current", "page");
expect(within(drawer).queryByText("Hidden admin")).not.toBeInTheDocument();
expect(localStorage.getItem("appShellDesktopSidebarCollapsed")).toBe("1");
});
}
function ToastTrigger({ action }: { action?: () => void }) {
const { toast } = useToast();
return <button onClick={() => toast("Downloaded æøå", "success", action ? { label: "Undo", onClick: action } : undefined)}>Notify</button>;
}
test("[P5-00 PRES-E08 S05] toast retains its full existing display duration and dismiss action", () => {
jest.useFakeTimers();
try {
render(<ToastProvider><ToastTrigger /></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
act(() => jest.advanceTimersByTime(3499));
expect(screen.getByRole("alert")).toHaveTextContent("Downloaded æøå");
act(() => jest.advanceTimersByTime(1));
// Flush the existing exit transition after React observes the timeout.
act(() => jest.advanceTimersByTime(300));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
fireEvent.click(within(screen.getByRole("alert")).getByRole("button"));
act(() => jest.advanceTimersByTime(300));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
} finally { jest.useRealTimers(); }
});
test("[P5-00 PRES-E08] toast action is explicit and executes once", () => {
const action = jest.fn();
render(<ToastProvider><ToastTrigger action={action} /></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
expect(action).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Undo" }));
expect(action).toHaveBeenCalledTimes(1);
});
+5 -2
View File
@@ -518,11 +518,14 @@ export const getTheme = (_mode: "light" | "dark") => {
},
MuiTooltip: {
styleOverrides: {
tooltip: {
tooltip: ({ theme }: any) => ({
backgroundColor: theme.vars.palette.text.primary,
color: theme.vars.palette.background.paper,
borderRadius: 8,
fontWeight: 600,
fontSize: 12,
},
}),
arrow: ({ theme }: any) => ({ color: theme.vars.palette.text.primary }),
},
},
MuiChip: {
+22 -2
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Alert, Button, Snackbar } from "@mui/material";
import { Alert, Button, GlobalStyles, Snackbar } from "@mui/material";
type Severity = "success" | "info" | "warning" | "error";
@@ -24,6 +24,19 @@ const ToastContext = createContext<ToastApi | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [t, setT] = useState<Toast>({ open: false, message: "", severity: "info" });
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [clearance, setClearance] = useState(0);
useEffect(() => {
if (!element) { setClearance(0); return; }
// Reserve the actual wrapped message height, bottom offset and a 24px gap.
const measure = () => setClearance(Math.ceil(element.getBoundingClientRect().height) + 48);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [element]);
const toast = useCallback(
(message: string, severity: Severity = "info", action?: { label: string; onClick: () => void }) => {
@@ -42,12 +55,19 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
return (
<ToastContext.Provider value={value}>
<GlobalStyles styles={{
":root": { "--app-toast-clearance": `${clearance}px` },
// Native focus/scrollIntoView must also leave the feedback lane clear.
html: { scrollPaddingBottom: "calc(max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))" },
}} />
{children}
<Snackbar
ref={setElement}
open={t.open}
autoHideDuration={3500}
onClose={() => setT((p) => ({ ...p, open: false }))}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
sx={{ bottom: { xs: "calc(8px + env(safe-area-inset-bottom, 0px))", sm: "calc(24px + env(safe-area-inset-bottom, 0px))" } }}
>
<Alert
onClose={() => setT((p) => ({ ...p, open: false }))}