Compare commits

...

1 Commits

Author SHA1 Message Date
cesnimda 992608c77f feat(ui): implement approved A3 hybrid navigation 2026-11-03 08:47:19 +01:00
72 changed files with 1492 additions and 20 deletions
@@ -0,0 +1,216 @@
# Jobjakt — Gate F Approval and Continued Autonomous Development
## Owner decision: APPROVED
I approve the **A3 UI-01 Foundation checkpoint under Gate F**.
The approved checkpoint includes:
- Updated palette and typography.
- Shared UI controls.
- Quieter, more consistent surfaces.
- Light and dark theme foundations.
- English and Norwegian compatibility.
- Responsive foundation across the tested viewport widths.
Reported verification:
- 35 targeted tests passed.
- 48 scoped browser/accessibility checks passed.
- TypeScript typecheck passed.
- Lint passed.
- Build passed.
- Application commit: `bdd1de0d`.
- Latest branch receipt: `0d6de714`.
- Remote publication verified.
This approval authorises progression beyond the foundation increment.
It does not constitute production acceptance or waive unperformed integration, accessibility, security or release testing.
## 1. Continue A3 implementation
Proceed immediately with the next authorised A3 milestone: **navigation**.
Preserve the approved A3 design direction:
- D1 visual foundation.
- Original A1 dashboard.
- B1 application tracking.
- Labelled, grouped and collapsible desktop sidebar.
- Hybrid mobile navigation.
- CV editor left and live preview right.
- Full application-detail pages with contextual information on the right.
- Career Profile overview first.
- Calendar agenda first, with month-view option.
- Automation controls under Settings.
- Contextual, optional AI assistance.
- Optional, resumable onboarding.
Do not restart design exploration.
## 2. Complete as much authorised work as possible
I want you to continue development autonomously, making substantial progress without unnecessary interruptions.
After navigation, proceed through the remaining A3 milestones in their approved dependency order:
- Dashboard and application tracking.
- Application details.
- Career Profile.
- CV Builder and import workflows.
- Cover-letter editing.
- Job discovery.
- Calendar and correspondence.
- Documents and administration.
- Settings and automation.
- Optional onboarding.
- Responsive mobile refinement.
- Integrated UI/UX qualification.
Follow the actual approved A3 implementation plan and existing preservation requirements.
For each increment:
1. Inspect the existing implementation.
2. Implement the approved changes.
3. Fix bounded defects.
4. Run relevant tests.
5. Validate responsive layouts and both themes.
6. Verify English/Norwegian behaviour.
7. Check accessibility.
8. Verify preservation of existing functionality.
9. Commit validated changes.
10. Push where available.
11. Record evidence and continuation status.
12. Proceed to the next authorised increment.
Avoid repeatedly asking permission for ordinary reversible implementation decisions already covered by the approved design.
## 3. Milestone approvals
I want maximum autonomous progress, but genuine mandatory owner gates remain in force.
Where the approved implementation plan requires an owner milestone review:
- Complete the increment.
- Prepare the review evidence and screenshots.
- Record the exact proposed next scope.
- Request the required approval.
- Do not bypass that gate.
While awaiting approval, continue other independently authorised tasks that do not depend on the blocked milestone.
Do not treat my Gate F approval as blanket approval of all future protected gates.
Where the authoritative plan allows automatic progression, proceed without stopping.
## 4. Preserve the complete application
The A3 design handoff identifies 57 preservation groups and 34 routes.
Ensure every existing function remains available unless removal has been separately authorised and qualified.
Pay particular attention to:
- Career Profile persistence.
- CV revisions and undo/redo.
- Existing CV and cover-letter documents.
- Imports and exports.
- Authentication and account security.
- Job application data.
- Dashboard customisation.
- Administration permissions.
- RD21 data-integrity protections.
- Existing integrations and automations.
Do not simplify away advanced features merely because they were absent from a design prototype.
## 5. Continue the wider roadmap
Continue independently eligible work across:
1. Phase 10 completion and acceptance.
2. PRODUCT-REVIEW-01.
3. International Global Job Search.
4. Full European localisation.
5. A3 application UI/UX implementation.
6. Multilingual homepage redesign.
7. Security audit and remediation.
8. Release-readiness preparation.
Respect dependencies, explicit owner decisions and authoritative acceptance gates.
Coordinate Global Job Search, translations and A3 so their implementations do not conflict.
Do not repeat accepted work unnecessarily.
## 6. Blockers and continuation
If a task cannot proceed:
- Record the exact blocker.
- Identify its impact and dependency.
- Preserve completed work.
- Continue another independently eligible task.
- Revisit the blocker only when new evidence or an approval makes progress possible.
Maintain the existing durable roadmap continuation records.
Do not build or reactivate the autonomous orchestrator.
Do not spend development time fixing unrelated Git infrastructure issues.
Never claim unsuccessful publication or unperformed tests passed.
## 7. Validation expectations
Continue targeted verification throughout development.
Run broader regression suites when required by the approved milestone gates.
Keep these statuses distinct:
- Implemented.
- Tested.
- Accepted.
- Published.
- Production-ready.
The UI-01 browser checks do not qualify real backend journeys, native-device accessibility or integrated security.
Those requirements remain open.
## 8. Git and production restrictions
Use the existing authorised development branches and worktrees.
Verify the correct integration baseline before combining workstreams.
Commit and publish validated checkpoints when possible.
Do not merge into `main`.
Do not deploy production.
Do not activate unqualified AI models.
Do not perform destructive migrations without approval.
## Final instruction
**Gate F is approved for the A3 UI-01 Foundation checkpoint.**
Proceed immediately to navigation implementation.
Then continue through as much of the remaining authorised A3 overhaul and wider Jobjakt roadmap as possible.
Prioritise completing functional, integrated user journeys rather than accumulating disconnected partial implementations.
Minimise unnecessary interruptions.
Pause only for genuine mandatory decisions, and continue independent work whenever another task is blocked.
Keep the roadmap, test evidence and remaining approval items updated.
Do not merge into main or deploy production.
@@ -0,0 +1,39 @@
# A3-IM02 navigation validation and review
Status: implemented and tested; owner Gate F navigation review remains OPEN before dashboard/onboarding A3-IM03. Foundation Gate F is already accepted, not reopened. See UI-02.md for preserved owners and exact next scope.
## Fresh scoped evidence
| Check | Result / scope |
|---|---|
| Baseline | 15 tests / 3 suites PASSED before navigation edits; BASELINE.json |
| New primary-navigation tests | Five expected failures before implementation; desktop preservation case already passed |
| Current-route regression | Reproduced drawer staying open; fixed in shared handler; red/green case retained |
| Final selected Jest run | 49 tests / 9 suites PASSED; FINAL_TESTS.json. Intermediate32/17 and reviewer11 overlap this total, not extra cases |
| Typecheck / lint / production build | PASSED; built static export used API base /api for controlled loopback checks |
| Real React screen matrix | 32 conditions: login + Companies × 320/390/768/1440 × EN/NB × light/dark |
| Dialog and navigation accessibility | 56 scoped axe sweeps, zero returned violations: 32 complete screen surfaces, 16 company dialogs, 8 mobile More drawers |
| Connected browser checks | 16 configurations: primary route selection and heading focus, secondary navigation, collapse persistence or More/Escape/current-route selection, reviewed synthetic company edit/save and phone notification clearance |
| Primary routes | All four primary shell destinations visited in each configuration (64 transitions). API-unavailable content states are permitted; this verifies navigation/focus, not successful domain/backend journeys |
| Write safety | 16 explicit company PUTs intercepted and fulfilled by local browser fixtures; zero requests reached a backend. Unexpected write methods/paths rejected; no unexpected writes or page exceptions observed |
| Captures | 56 PNGs: 32 screens + 8 More drawers + 8 collapsed desktop/tablet + 8 phone notification states |
| Preservation census | All 57 inherited groups retained; all 34 inherited route declarations present. Source membership is not complete functional or authorisation acceptance |
| Independent source review | No important/critical findings. Conservative extra safe-area clearance noted; native notched-device review still pending |
The browser lane is the actual built React application, with controlled service responses, not the design prototype. It rejects every external request; 40 existing Google Fonts attempts were aborted. Provider/model/production services were never contacted. Only Companies has a successful synthetic mutation fixture; other core destination content may display honest unavailable-service states. No model was started. Main document/page renderer sources are unchanged.
During verification, a mistyped translation test path produced a failed harness run (two real suites/eight cases passed); corrected to the existing catalogue/provider files, then final nine-suite run passed. A broad alert selector also matched Next's route announcer; narrowed to the actual Snackbar feedback. These are resolved harness defects, not hidden passing tests. Layout review reproduced broken English labels at320px: content-based flex widths with minimum44px targets fixed them while retaining11px labels and long-text wrapping. The final browser assertions require Dashboard and Applications to fit one line in English.
No original concepts were regenerated. No route, advanced workflow, permission, document revision, schema, dependency, controller or production configuration was removed. Route declarations are unchanged; navigation grouping is changed deliberately. Existing admin visibility flags, router/legacy redirects, quick search, notification count semantics, desktop storage and ownership disposal remain in their existing owners.
## Review examples
- [Phone navigation](evidence/navigation/companies-en-light-320.png).
- [Bokmål dark desktop](evidence/navigation/companies-nb-dark-1440.png).
- [More drawer](evidence/navigation/more-en-light-390.png).
- [Notification above the bar](evidence/navigation/feedback-nb-dark-320.png).
- [Collapsed sidebar](evidence/navigation/collapsed-en-light-1440.png).
Run the installed harness from job-tracker-ui against the current static export on loopback port4338: `rtk proxy node scripts/verify-a3-theme.mjs --navigation`. This mode writes only evidence/navigation; historical foundation artifacts stay unchanged. SOURCE_MANIFEST.json binds final source, native reports, captures and exported assets. Historical foundation source hashes remain bound to bdd1de0d, not falsely reused as new navigation results.
NOT RUN: real database/backend workflows, hosted CI, real identity/mail/job providers, AI models, physical PDF/container requalification, native screen reader/touch/software keyboard, actual browser zoom, native-language review, final integrated security or release-readiness acceptance. Those gates remain separate. Full A3 UI/UX implementation is not complete.
+3 -3
View File
@@ -1,6 +1,6 @@
# UI-01 — approved A3 foundation implementation
Status: A3-IM01 implementation and scoped validation prepared for Gate F owner review; UI-01 navigation (A3-IM02) remains pending. Implementation branch `codex/a3-ui-01`, isolated integration worktree. Base `d3d338376444b8abdcaf7200f682faa3daf1ad10`; design source `237c79b8cb3e280d7e4d1ef95cdbb2c77bb81c69`; roadmap receipt `286535bef046417a09a156685c937e8be9155147`.
Status: A3-IM01 foundation ACCEPTED under Gate F by explicit owner attachment a178edeb-c3ef-4c0e-b4c2-bcd1ca84b90b, 3 November 2026. Do not repeat that approval request. Navigation A3-IM02 proceeds on codex/a3-ui-02; see UI-02.md. Original implementation branch `codex/a3-ui-01`, isolated integration worktree. Base `d3d338376444b8abdcaf7200f682faa3daf1ad10`; design source `237c79b8cb3e280d7e4d1ef95cdbb2c77bb81c69`; roadmap receipt `286535bef046417a09a156685c937e8be9155147`.
Owner's 3 November 2026 attachment explicitly approves the final A3 foundation and staged real-application implementation. Verbatim authorisation is retained in A3_APPROVAL.md. Gates C/D/E are approved for that scope; deployment, Phase 10, security/provider/native/device/release gates are not waived.
@@ -11,7 +11,7 @@ Owner's 3 November 2026 attachment explicitly approves the final A3 foundation a
- [x] A3-IM01: approved D1 palette, typography, quiet surfaces and shared controls implemented through existing theme.ts. Added visual-contract regression checks; retained semantic contrast, theme/system persistence, focus, reduced-motion, density and dialog checks.
- [x] Targeted tests, types, lint, production build and actual application browser checks in EN/NB, both themes and mobile/desktop. See VALIDATION.md for exact source scopes and pending human/zoom checks.
- [x] Validated checkpoint `bdd1de0dd3259e4e9e9f46c81948f865317c6054` committed, pushed to origin/codex/a3-ui-01 and independently confirmed by git ls-remote. No infrastructure repair. Subsequent documentation receipts do not change this immutable application/evidence identity.
- [ ] Gate F review for A3-IM01 before next source-defined implementation increment. UI-01 navigation is A3-IM02, not silently treated as complete by palette changes.
- [x] Gate F foundation review approved by the owner in GATE_F_FOUNDATION_APPROVAL.md. Navigation is A3-IM02, separately implemented and reviewed.
Scope reaches the existing ThemeProvider and every MUI theme consumer, including navigation semantic colours and shared fields/buttons/cards/dialogs. Preserve component APIs, routes, application state, server data ownership, RD21 and physical CV/PDF renderers. No dependency, API or database changes. Existing semantic status colours must retain readable contrast on all new surfaces; dividers are decorative and do not replace stronger essential field boundaries.
@@ -33,6 +33,6 @@ Independent code review: no important/critical findings. Accepted minor recommen
Actual selected validation: 30 theme/state/dialog tests in five suites plus five disjoint navigation/CV-thumbnail tests in two suites; baseline28 and review16 are overlapping and excluded from that35. Full typecheck/lint/build passed. Final actual React browser lane:32 screen conditions,16 keyboard dialog journeys,48 scoped axe sweeps with zero returned violations,32 screenshots, zero page exceptions or attempted backend writes. All32 existing Google font requests aborted. No physical document or native/human acceptance reopened.
Gate F is OPEN. Source: the approved design handoff `docs/product-modernisation/08-a3-handoff/IMPLEMENTATION_PROPOSAL.md` at design checkpoint237c79b8, A3-IM01 row: "Owner milestone review before next increment." Present this implementation and its declared qualification limits for owner review before A3-IM02 navigation. This is not a repeated final A3 approval request. User's ordinary-implementation authorisation persists; do not ask about individual reversible changes after the relevant milestone entry is authorised.
Historical pre-approval gate note (now satisfied by the explicit attachment above): the approved design handoff `docs/product-modernisation/08-a3-handoff/IMPLEMENTATION_PROPOSAL.md` at design checkpoint237c79b8, A3-IM01 row says "Owner milestone review before next increment." User's ordinary-implementation authorisation persists; do not ask about individual reversible changes after the relevant milestone entry is authorised.
Next UI task after that review: retain all34 routes and57 groups while implementing the approved labelled grouped/collapsible desktop sidebar and hybrid mobile navigation through existing AppShell/router owners. Do not merge this branch into main or activate deployment. The original codex/direct-roadmap-integration ref remains at d3d33837; its existing isolated worktree is currently checked out on codex/a3-ui-01. Generated graph is untracked (18,714 nodes/46,204 edges). Design approval record b7051a3e is local-only on the prototype branch; do not publish that restricted branch.
+27
View File
@@ -0,0 +1,27 @@
# A3-IM02 — navigation
Status: IMPLEMENTED and TESTED; Gate F navigation review OPEN before the next dependent increment. Branch codex/a3-ui-02 from approved foundation receipt 0d6de714. Owner attachment a178edeb-c3ef-4c0e-b4c2-bcd1ca84b90b explicitly accepts foundation Gate F and authorises navigation; later protected milestone reviews remain separate. Verbatim approval: GATE_F_FOUNDATION_APPROVAL.md. Publication receipt is recorded in the continuing roadmap handoff.
Review [navigation evidence and screenshots](NAVIGATION_VALIDATION.md): final 49 tests / 9 suites; typecheck/lint/build; 56 scoped browser accessibility sweeps with zero violations; 56 captures; 16 configurations with primary/secondary navigation, focus/persistence, controlled company save and phone feedback clearance. No live backend qualification. Native/human/device/zoom and final integrated security/release gates remain open.
Tasks: inspect existing owners/callers and baseline tests; add failing mobile/route-preservation tests; implement five-destination hybrid navigation through AppShell, promote Discovery in existing App nav groups, retain every secondary tool/permission/route; reserve mobile clearance for feedback and actions; verify targeted tests, typecheck/lint/build, actual controlled-API browser journeys in both languages/themes/viewport classes; commit/push/read back; prepare next scope and required Gate F review.
No API/schema/dependency or physical document changes. Existing theme/state/router/dialog/toast owners remain. Keep desktop collapse persistence, route-heading focus, skip link, command palette, notification semantics, account menus, legacy redirects and admin guards. Use existing 768px small breakpoint for hybrid mobile navigation, matching compact header controls and the approved tablet sidebar arrangement.
Baseline navigation/notification/readiness results remain separate from final totals. UI-01's35/48 evidence qualifies its own foundation scope only. After this increment, A3-IM03 dashboard/onboarding and A3-IM04 application layouts await the source-defined next milestone review; other independent roadmap work may progress while that decision is pending.
## Implemented scope
The existing AppShell now provides Dashboard, Applications, Discover, Career and More on phones. More and the existing header trigger open the same accessible MUI drawer, retaining all secondary destinations. Career includes builder deep links; Applications includes job/detail/legacy aliases. Secondary tools select More. Visible primaries are derived from the existing permitted navigation tree; hidden ancestors are pruned. No new route or permission system.
Desktop retains labelled/grouped navigation, whole-sidebar collapse persistence and secondary menus. Discovery is now a primary Job Search item. Reminders, correspondence, analytics and existing Operations are workspace destinations; companies/offers/trash, CVs/documents, accounts and admin tools remain accessible. No route declarations removed or rewritten.
The bottom bar is measured, so wrapped labels/safe areas reserve actual space in main padding, native scroll clearance and existing Snackbar positioning. Notifications and feedback remain above the bar. Desktop/public contexts retain zero bar clearance. Native notched-device review remains pending; existing safe-area padding adds a conservative extra gap after the measured bar.
Regression found/fixed: choosing the current destination returned before closing the mobile drawer. The shared handler now closes it and focuses the heading after the transition, while Escape still returns to the opener. Red/green unit regression and browser checks cover it. No unsaved-document guard, persistence, API or physical renderer is replaced.
Independent read-only review found no important/critical findings; reviewer 11 tests overlap the final selected suite and are not additional evidence. All 188 historical dynamic-call reviews remain accounted for; four new static EN/NB navigation keys were added with catalogue parity checks. No new European language was advertised or enabled.
## Next proposed scope / protected review
Gate F navigation review required before dependent A3-IM03. Proposed next scope: approved A1 dashboard hierarchy and optional resumable onboarding using existing DashboardView/grid/layout/checklist owners; preserve schema-2/CAS, hidden positions/gaps, collision handling, drag/resize/keyboard alternatives, existing-data completion, skip/resume and notification behaviour. No schema migration, new provider/model or loss of widgets. Then prepare that increment's targeted state/browser/preservation evidence and review. Existing A3 approvals persist; request only this mandatory milestone entry, not a new overall design choice.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,797 @@
{
"rows": [
{
"screen": "login",
"width": 320,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 320,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 320,
"language": "en",
"mode": "light",
"violations": []
},
{
"screen": "more-navigation",
"width": 320,
"language": "en",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 320,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 320,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 320,
"language": "en",
"mode": "dark",
"violations": []
},
{
"screen": "more-navigation",
"width": 320,
"language": "en",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 320,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 320,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 320,
"language": "nb",
"mode": "light",
"violations": []
},
{
"screen": "more-navigation",
"width": 320,
"language": "nb",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 320,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 320,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 320,
"language": "nb",
"mode": "dark",
"violations": []
},
{
"screen": "more-navigation",
"width": 320,
"language": "nb",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 390,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 390,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 390,
"language": "en",
"mode": "light",
"violations": []
},
{
"screen": "more-navigation",
"width": 390,
"language": "en",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 390,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 390,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 390,
"language": "en",
"mode": "dark",
"violations": []
},
{
"screen": "more-navigation",
"width": 390,
"language": "en",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 390,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 390,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 390,
"language": "nb",
"mode": "light",
"violations": []
},
{
"screen": "more-navigation",
"width": 390,
"language": "nb",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 390,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 390,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 390,
"language": "nb",
"mode": "dark",
"violations": []
},
{
"screen": "more-navigation",
"width": 390,
"language": "nb",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 768,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 768,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 768,
"language": "en",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 768,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 768,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 768,
"language": "en",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 768,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 768,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 768,
"language": "nb",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 768,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 768,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 768,
"language": "nb",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 1440,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 1440,
"language": "en",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 1440,
"language": "en",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 1440,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 1440,
"language": "en",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 1440,
"language": "en",
"mode": "dark",
"violations": []
},
{
"screen": "login",
"width": 1440,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "companies",
"width": 1440,
"language": "nb",
"mode": "light",
"geometry": {
"overflow": false,
"background": "rgb(255, 255, 255)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 1440,
"language": "nb",
"mode": "light",
"violations": []
},
{
"screen": "login",
"width": 1440,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "companies",
"width": 1440,
"language": "nb",
"mode": "dark",
"geometry": {
"overflow": false,
"background": "rgb(40, 35, 52)"
},
"violations": []
},
{
"screen": "company-dialog",
"width": 1440,
"language": "nb",
"mode": "dark",
"violations": []
}
],
"errors": [],
"writes": [],
"mockedWrites": [
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
},
{
"method": "PUT",
"path": "/api/companies/7"
}
],
"journeys": [
{
"width": 320,
"language": "en",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 320,
"language": "en",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 320,
"language": "nb",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 320,
"language": "nb",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 390,
"language": "en",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 390,
"language": "en",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 390,
"language": "nb",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 390,
"language": "nb",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "PASS"
},
{
"width": 768,
"language": "en",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 768,
"language": "en",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 768,
"language": "nb",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 768,
"language": "nb",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 1440,
"language": "en",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 1440,
"language": "en",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 1440,
"language": "nb",
"mode": "light",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
},
{
"width": 1440,
"language": "nb",
"mode": "dark",
"secondaryNavigation": "PASS",
"fourCoreRoutesAndHeadingFocus": "PASS (API-unavailable states allowed)",
"focusAndPersistence": "PASS",
"controlledCompanySave": "PASS",
"mobileFeedbackClearance": "not applicable"
}
],
"blockedExternalRequests": [
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com",
"https://fonts.googleapis.com"
],
"scope": "actual static-export React application with controlled API; existing remote font requests aborted; no backend or provider qualification"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
{
"recordedAtUtc": "2026-11-03T07:46:44.593102+00:00",
"base": "0d6de71403015b0e44ca410d0ce2b844b75b893d",
"policy": "Text/JSON LF-normalised SHA-256; PNG/export bytes raw",
"sources": {
"job-tracker-ui/src/layout/AppShell.tsx": "b516ada49d5a5413eac471ab44f89f7f8a745b3bf4a7212616768f8d2559670d",
"job-tracker-ui/src/App.tsx": "fd265b5ee48bdd2becc9aea7fb885c3ace9e56dc00943a91b2a3f8d336caf33c",
"job-tracker-ui/src/toast.tsx": "8f11b9ecce244a99a7cc7b6d247751e24089f8396a834924a4fa1eb421ab17bc",
"job-tracker-ui/src/i18n/translations.ts": "1e7aee4b9df920058044286f6e0a63eb893bf9eead3f303a7f01ee4d2c9fea77",
"job-tracker-ui/src/a3-navigation.test.tsx": "942c3adc70bc2108c8fb75ea871239254751556ae3d1222f2c63bf836f7616f6",
"job-tracker-ui/scripts/verify-a3-theme.mjs": "3c044657b1c9fbc3006fa3a1f2610d254ce6265814d79393fdca692062513ad9",
"job-tracker-ui/package.json": "3745394e406cefa40d30a87e840bccb359be147bac6ffba736e2ae90898a8626",
"job-tracker-ui/package-lock.json": "bf31e90d7af03e8af059db2aaf893925b9e8881a30a1688034ed88ef45d4fcab",
"docs/pre-phase10-ux/preservation.json": "a4c2a5a0244ea89381008218de32daee1f615a250eb3808b60e0ee103c674d4d",
"docs/ui-modernisation/GATE_F_FOUNDATION_APPROVAL.md": "8cd8b31d677c5c7e3124e0331b3bfe13ae361e90b98ab97eec3fbb83530e1639"
},
"evidence": {
"BASELINE.json": "67fad14774c31354249b54b0d40d45baf7302ac2ab26861cf8a3641b133f94da",
"BROWSER.json": "ded63b6d43ddb2d904d80ccd1e1283ed9bda76fedabd43cdfd6ef99299e7243a",
"collapsed-en-dark-1440.png": "89c098d674d43e56b6e4f434278eb44a20ced9817bc18aadb642c353f0c328d1",
"collapsed-en-dark-768.png": "936ea505ccfedbea72763fb1877037956ff0976554e318d4838886cf0ad33643",
"collapsed-en-light-1440.png": "544358fdc5327e490ac826121d5adf88f84502781bacfb9b650ef565f65bf46e",
"collapsed-en-light-768.png": "777fba6160ff7dc87c8c6a97bca8b85552703834f1e78fdc656bcbf068df4641",
"collapsed-nb-dark-1440.png": "715644887d40046dede401f346999953462db4436b0aeaa5a429d203b380b615",
"collapsed-nb-dark-768.png": "f9ef73455c5c6cc313063e0cc7945efcdc64d3923ff262ad4de66918e2073775",
"collapsed-nb-light-1440.png": "dd4320d18b8e898ebba9361e22c1bdb09f86700ac7affb8dfaf9565b4cad1806",
"collapsed-nb-light-768.png": "02da521d7bf4a755f8adadf2ab6fe7b2fb56a4a93e5fe23f76c8acf634231590",
"companies-en-dark-1440.png": "0c3bafb43beeab05a19312d6b33086ce089175d936ea17764178c81a3911493d",
"companies-en-dark-320.png": "1eb42c34116dd7b972c291fc4f80f8f8482402daee389f26f5ca4be6614c33af",
"companies-en-dark-390.png": "af24cd3d86c868fc057111274621dfd95f38c97b5b70c747d928c151965c821d",
"companies-en-dark-768.png": "7d0d122233207967ff0ec37fe5de88a6824b6088417bce7ebd7b1368b7920f7c",
"companies-en-light-1440.png": "71c83d2bb0dccd3d4f2d90af3aca82ad157e791002f2ce4e9f5df438dc21e078",
"companies-en-light-320.png": "6ae58c6d0aa964cc4c18d058a711c2fd3e2614bd0c6adb2050c50ef7fd7a5397",
"companies-en-light-390.png": "414dae0566c6be5493810acce73aca6a0d61c541b7cd749595ff598176a01fbe",
"companies-en-light-768.png": "2d1bbba81ac1e1062b71b5a7c5c113c62de4a8be85b7c0580274a99a2f798026",
"companies-nb-dark-1440.png": "03179678bfc251480f48ebbe12d7d4e9e31e5bc382da9de0694540fca2d5d4b5",
"companies-nb-dark-320.png": "f8218d2ffc85d1aebf5677f00f858b437c3bf597e34bd38ebe57f99112601dad",
"companies-nb-dark-390.png": "0a6de884928d15983272704204568164fb11f6e7ecc504d38454d5a655f645f1",
"companies-nb-dark-768.png": "e856ee248af99fdf57613f4e5c1e930176e169d46b94c20e57fa9b6f6c792d49",
"companies-nb-light-1440.png": "552c658be5043c9fa8ed14c44d1a9fd43d3a91c4b6dfec5c36bc0e09562c3976",
"companies-nb-light-320.png": "93f40c3d125240801dc56b377907de05930cd31df9da93b2e57a5ac1e69e6b26",
"companies-nb-light-390.png": "a5184e3df93d46643342c70301fdd6ffce6696c2b75ab1cf1bf2bce00fe089d3",
"companies-nb-light-768.png": "bff700939bac8cbf2f3b4e03881a60c76db9fc4a359279ade563c4a7cd16b4ae",
"feedback-en-dark-320.png": "48771faddeb78afbdd693541df4e3f2f8cd1fb2e9c8b54b500d00a26361d9ff9",
"feedback-en-dark-390.png": "8824caf71d48387f422274f37ec1d83efd220f2f95eee0e0ec0e04a90da3831f",
"feedback-en-light-320.png": "a38fba6a3683110f66d0fd42d16170028471265d5189183be18f9adee176c1c0",
"feedback-en-light-390.png": "1eaad948f9de93dbbfe3e976e05514a57141e4ad87ee64e5d3a460c4a259c5bb",
"feedback-nb-dark-320.png": "b7162617c848d9dd315c11f648bc9b39ef71a623b3427f6daa16e8bc6e6c27a3",
"feedback-nb-dark-390.png": "f3eb38a303e6ce5770c348b1c3424c01d2e68c78ab4940a323a292411b962006",
"feedback-nb-light-320.png": "a390116a6495f5692a8a7dac8dcd4fc9db198df0734e20c06915e4164affa687",
"feedback-nb-light-390.png": "14299f29e9fcb20961829b83e9cd883bd66a9518af868e0b85367c6c8da660b4",
"FINAL_TESTS.json": "21b49ca8c4adaacc4dd452404884dc0b67f7a4a4756301ca33e363c972ddd600",
"login-en-dark-1440.png": "7f14b204ac297a11d0f66ae14630fc178db576003f5739c8837c0d89a8c671b5",
"login-en-dark-320.png": "1349497aa9a8c69135fd65a1b033c235519fea6158574df0bfe480168366fbe6",
"login-en-dark-390.png": "e1bc62e9622daed83e702bece9fbbde56a0eb8b44d662436c3c88884dee53571",
"login-en-dark-768.png": "398df1bc62c65b52808dfc4060a15f3ddda4ecd53600e1f0f1f48116d46330e8",
"login-en-light-1440.png": "adf362ed3cb066a868d466317a5a5f1f9e32e80292d780a43b435e67d8250229",
"login-en-light-320.png": "4b0c2f278b6224969bf8d16e73a4bce317948b6fa9b5f2a46ddb4c1692848455",
"login-en-light-390.png": "526ae4c4c998c766ad4cb55ccce65db6dd6448c6ca3a88d5fa497d17ffff006a",
"login-en-light-768.png": "569319b19e4bb37955a805778a87ca4dfd2b13c4c8c33c4ea9d2e0418f9e25cd",
"login-nb-dark-1440.png": "e3dfcad81696cb40e33acae6b2ae9553eb773d5c02e84f9b5974f978a2345389",
"login-nb-dark-320.png": "810c6f9b9dd368de5836620004f777b6c7bfd38d20bd6f9a41bef43082ae40b6",
"login-nb-dark-390.png": "5dd78b744b92c33f70f40848c905a26f38184324cf77f51caf3d947d59d22902",
"login-nb-dark-768.png": "a95543cb6c60c308f756d4f16f480be75d20b03fd9ab855ad3abcd56434bda2c",
"login-nb-light-1440.png": "4ca9d0de9a2aaf217c484073f0b3ffffba91d33bfaceef313bc748ec3ad19cc3",
"login-nb-light-320.png": "d2d9729bcc9c3cf8d3b8044c9b6e63d463a84b2f7aabb362ca5b7bebd9538804",
"login-nb-light-390.png": "7fd0429224e620c58552a9e1ada72e830eb10b3ef392b76f0074a4e6d343f42f",
"login-nb-light-768.png": "8f7d2a44913d5cc3dc835b9bde07f04fd56a300cc9bf502a34825777eb02cb74",
"more-en-dark-320.png": "c40fa318464e71220074095671b4204aacdc580d28462e6f63f89dc8b9019405",
"more-en-dark-390.png": "d605efae414d22759abf1f6c2d6b56505a85aae2e49290c437a036e647efe2fa",
"more-en-light-320.png": "f55d3eb6423f420d6438bddfa4ee1347f16ceef1fdaf58bf60170f58b2e5f95f",
"more-en-light-390.png": "eea3f9b9bca407723645eb266023f53da1833f6a87e4e0871b4724855175287e",
"more-nb-dark-320.png": "3af1e4a296d29219f32913fb79aecb2b1e6359f46d86c467d9e020dc0b98116a",
"more-nb-dark-390.png": "a2f19eeefd7d077e3aedb657c61416dac7ce0000fab1a9d1992a02a64a138776",
"more-nb-light-320.png": "4c79c58da9cfecd01c35c428d6e8cc98c8e4205ff365052e2b9ec9df9c710222",
"more-nb-light-390.png": "54848beb7ed01226788dbd62e4f4aa45fa6b0253be039b39415ab7f4294ef74d",
"SHARED_TESTS.json": "1dbbd317110e00e83344941ec7578542f5e6f9b3fa8eeb6adeb19aee1e64fb6b",
"UNIT_TESTS.json": "127986cfa13d1fe8e2024b3bf9bcaa24c1adcccac9e34223ce2aed81f9adad72"
},
"exportAssets": {
"404.html": "be85dab7930ce2d3afff57d5cea21753da8d5f5d5847f1f87151898c7ba5ed8c",
"favicon.ico": "8d4923e2c487805ea9f852a685b2cf0fb10512ded8290d2a5c37d1cc8fd0702a",
"favicon.svg": "63ecf8f37f8bb146ecfae3917187c6b692d4e801641e8a314486a4007912f3a3",
"index.html": "d1cde190ecd4d266098238c9eb566aed0a14f6adc909a21dac8f30d498e20946",
"index.txt": "f9809ba4b178f1f2f893e65ec53293aa04abc41b85ab3814002653f5b44fe20e",
"logo192.png": "bc652031597fabd19b9f14e22a71f809490d218037521b3b64d013418c1390b4",
"logo512.png": "5129e9bc1ba812b711fd9a646d3a38a906d365a26ee685df183b8752458944d4",
"manifest.json": "2667ce63c2e0835b7007239816a5597639321d333da396a78638dfa9e8a7c935",
"robots.txt": "e5ab0d231eeb01b4a982d1c79a6729cac9797ad15a69247e4f28ba6afc149b4c",
"_not-found.html": "be85dab7930ce2d3afff57d5cea21753da8d5f5d5847f1f87151898c7ba5ed8c",
"_not-found.txt": "76b3b505fbb882825436e514500b4c04e38f9a24b454ab25dec0d1d024988de8",
"__next._full.txt": "f9809ba4b178f1f2f893e65ec53293aa04abc41b85ab3814002653f5b44fe20e",
"__next._tree.txt": "aec474b5548964c46a5f55304277e41f8093d1c17039e09eda89ccb79271d78b",
"__next.__PAGE__.txt": "f8e8e320e738c2e1aafbb89e8e3db352ab95b93ab3b58c5c6a08762f2aef9841",
"mockups/dashboard.svg": "318be3adb1324c26eb30f05572a29528a3e38331c6a604652eef75b54f8c29dc",
"mockups/pipeline.svg": "db2ef41faabeb90e7ce85413e168581686c54535d970fc7d8deda3888a479de4",
"mockups/workspace.svg": "9bbab22349147c7b01d6e18c93c622a6f8aed6dedaf23715189ab54d60e1ddbd",
"_not-found/__next._full.txt": "76b3b505fbb882825436e514500b4c04e38f9a24b454ab25dec0d1d024988de8",
"_not-found/__next._tree.txt": "199ddca5d061f9b16dde7b8be711c042f987ed81fa2d6a3768a3319a7c0194b5",
"_next/static/chunks/0-_aq6ipzcshj.js": "39f90c215bf52aef6274ed3dcc0f8af32e21a37fe8845b3bca0d6ea60da6a8c2",
"_next/static/chunks/07vdc-duql714.js": "5a4840d1ba6b65782459968940a2f4cf973c99f0f77890f4171c4f420f339353",
"_next/static/chunks/0bdt7ln111t48.js": "6184b1a61f22e2a640c17941634fdc75678ce93d142848d9f07f163e0d1fd201",
"_next/static/chunks/0bwvt3mysnclc.js": "7705667a1b75812b7833d9c9dff47fe023015e58e382c40f4507d9948c973af4",
"_next/static/chunks/0cz1d0mv5g_q7.js": "0973c1d64c88adc8e3c950410cb58b288f72118d5965b78049438deb8f2f9683",
"_next/static/chunks/0enhs8x_8oeah.js": "44c76f6178d407e1f147fdf70015fd05c21e58ed220fc922d23dfd1e0f8631bd",
"_next/static/chunks/0jcvc3qm6-lyk.js": "7c27e1fe373b822adc68c63096a5bf38444ad605b9743f0920271652989c56f1",
"_next/static/chunks/0q20g9o8x-k8z.js": "3d4d9086985419aa8866b032158196c774acb8bf318f3c8245508c8daa9c5148",
"_next/static/chunks/0q_pwu9jxv4-d.js": "42a43b3d7245a413c7c29b4cbe87823037a6dbc637e2e282c92405470e0f575d",
"_next/static/chunks/0w69-xg9od0ja.js": "88a7bd64f4fddf57cdbf3e993be2a44e48d5b313e434038bc9a7337d107ae162",
"_next/static/chunks/11-48p8kygxgp.js": "57eb9059599b5a5a30598d0a334912d58af571c0ffe28448ca76ad77c8ea8df9",
"_next/static/chunks/14-lfugawba2w.js": "6a5780ca3c84d7f130f3c0a14431c85c7a1e799e2645b8c171161dab40a64360",
"_next/static/chunks/1435in_k_-dhc.js": "4757476512d5c4dd25f9264a18b304ed4824b23aff1c09e134fcbff21d9c36b6",
"_next/static/chunks/145oicobmjvm7.js": "d6bbbdeed1381652dc7412e5ce673a077579ecd5cee8934cb6b533c814098764",
"_next/static/chunks/14e7ovj0s3elm.js": "070567dd4d23fb9fa0b9ba132185a697af6578045d43b2b1c7bbb2a1594d935d",
"_next/static/chunks/154b5a6du9olf.js": "49e4df3a3e10119ddc34f84767883b9228ff6798a07fd759c2fb8e2be62fe9fb",
"_next/static/chunks/16lfece6cnlln.js": "716363ba044f30b0e70440756b4e5eafa2d25f2e1628e855e3bc4ab9eabee3d3",
"_next/static/chunks/1d0xrdyyzw7qc.js": "c11668ff1e152008e0d7b4454af12eb36c97df46a5860938e2eb5d8429b374a8",
"_next/static/chunks/1h7j7ef9e6i41.js": "cb6cd497566dc188b67d09b9c031ef9bbf36f2b85d0aac02621f56daa44faa34",
"_next/static/chunks/1ke8pzecwofrg.css": "32e9ab963ebadebcbe2c68dffb24cfbfc8cc24255ad91b48b7941fc777bf7439",
"_next/static/chunks/1m3dt58dmprbw.js": "3a80b00707e6a884a03fa636260511585f1760f37c24fd4861ead110e6b3e985",
"_next/static/chunks/1req80b9jj38r.js": "eee4d2e3789e1215195dba99ad0f09d34f8c83da848322b76054722233e9dae3",
"_next/static/chunks/1vlgr-jtg9qr0.js": "6e41421a30b7313860e86c2069ed9dde394e29d004eeb42af46b2d2d124a1acb",
"_next/static/chunks/1w3q8yrcjn2jk.js": "365200e241ee9290e93d7c2fdba169f2b0a8e774541e3cbf3f6f2781110fa644",
"_next/static/chunks/1xn023_28rgzj.js": "4f7f2108d030a48d039930736c92a55710975dddf419fe673f84d92899b1f575",
"_next/static/chunks/1xwc-b795syag.css": "f73c002233f931ca14d0ae58815c47ca9f2659f25b1547a6d1fdca12a2a00b3f",
"_next/static/chunks/1zi8i6dnxiac_.js": "8aea28bbe6a138e5d4b619429ca71570883a7aee974b86d5a4a746d90f704dbc",
"_next/static/chunks/22uh2tvaw80j2.js": "dafe6b4e23dc5c1d32f8b4a60c561238d2d5e7545f23ebc5b3f7304b48626dbf",
"_next/static/chunks/22wx8--1b9tnq.js": "3f7d8e762371262c6a740b4dac21725b424847319afac46055e97074a99ff5f5",
"_next/static/chunks/245ibf0f31ne8.js": "8b27ab5d720bd278b5d73a7e824bfcccf4cc893ced9fa6f1937bb2bc7a65f967",
"_next/static/chunks/280cq9ekejv1f.js": "01de8c1c3555cdd8d711763065ea9d8aec2968c3053f61254e43e26c999027bf",
"_next/static/chunks/2br3ad_-bc7k9.js": "80fa55f7a32db4d743fda36747c36994fde00a0f53f2eb25b2a6d07c6fb97cd7",
"_next/static/chunks/2gd9s_n-0nl1p.js": "31e2811be51a3c4d0ff2d1e3cea756e7fd8d57ef89141b5de69f638c1301d039",
"_next/static/chunks/2j9f-6w2x4auf.js": "a23068fc68514dcaa1375de8802bf7f9bdfc561835e4fe47b9e6fe8f7372f85c",
"_next/static/chunks/2n_psazr8t23q.js": "3e7bfb295ecb0762b9dc8dccd5668edb64bff78fe8e4cc8f612d8bac2fc16b9e",
"_next/static/chunks/2s2gjr6ro25dj.js": "7489e2ac285a05b563c2bcb4eaf9274bf930f2a7385f17d6d5e6850064b88b10",
"_next/static/chunks/2u5qve004opku.js": "be388d9a6aa423082bafb4584c3facc65ddfed71bcfa5c1d06b097b408c5ff97",
"_next/static/chunks/2wae6urahmel1.js": "ae86575883781981cc373601a07009101d81eba801f40d4607e6f3f168777a5b",
"_next/static/chunks/2xlg4_1fj8fxh.js": "8ab4ba521db0167f3dbf684787ebf643cbc66fa7ffff7cdc65a8d8076a926155",
"_next/static/chunks/2z42el0b2mp8_.js": "86ff990ef32c90c4839583e4f1d1bcbddb165dad3141b9fcd2ecec67f4ff24b8",
"_next/static/chunks/334vtqp3-g1j6.js": "c33d42da97f3af7388e29273297e9f42c67f54b7f5ec77a5d0637e2da06d7f7e",
"_next/static/chunks/3720519r84mh6.js": "54358db392a13914e3363046d594e824b1a3ef987913d95002bf48267dded4aa",
"_next/static/chunks/38uwdnzoq4mmb.js": "42ad35e141ec01a0d0a284951f72b2ffa90ee870326002627eacccba075a87f7",
"_next/static/chunks/3bqn9b34na0b9.js": "f1b8ebcc1b22251c33c9f3a5d11c1f893cad2da2e8744a441e790f7347aa9e73",
"_next/static/chunks/3ckk_j5ril9ag.js": "87f631dae7b648d32171e9e05a3e000753ecb54f8d3b86a4401c0e4ab4d73553",
"_next/static/chunks/3f96r6vscobou.js": "d4b452ff3f1433f8fe1df705a8f592326f51b5a8846e6c2790d28df5a977aed3",
"_next/static/chunks/3gkylew_qn7cw.js": "7e6c74c81be0c41ab433e36d65b914f62c48f332922e27f7cc953476f4dd78cb",
"_next/static/chunks/3mowdlh-jq9ii.js": "2c6ccd1500d28ec0c7312cb08ee221f0e4184ba21465ee166a054aa9a6c7f3ed",
"_next/static/chunks/3os11ovm65tyl.js": "c5a908bd61f53f0d75927efb71e53aef8fdd9b2e4fef412a167990dbc702c166",
"_next/static/chunks/3q4v10rrqy40n.js": "d87ff7b7a0f07d0d92eab3876481eb686a12d9c43d8b92cbe406aefa227a11c1",
"_next/static/chunks/3q80eur194l-9.js": "7b52d29843c23a0ed74056eeb1704de14488f36e9d05f2f8271434c61051dce0",
"_next/static/chunks/3rqmk-kmybpa1.js": "415b5e1e89db5c9e0f6ec829c569a128cbc3c9f9043aa01894551f35bfff543e",
"_next/static/chunks/3u61o8t7m_jza.js": "86231c12e0f8cacc8638bed0bc4b34f645fc35e60de342dc154d3cce0c9a5afa",
"_next/static/chunks/40y_aqqilokr2.js": "1f5a5260316134d6bffe949400752ee6b42addf908ae714ef4ae4ebde916dddd",
"_next/static/chunks/43b5x8qjjh3ax.js": "6ee7261cf9a63e0e6ea181bbb09125e09948588b6e10b5c02727c686a6062a3a",
"_next/static/chunks/442d7ylb3po0z.js": "d95cedfbdcd00c674fd718cbbf391ec01c0728018b0db2461a06df7074049132",
"_next/static/chunks/turbopack-25fzg9ay_1ss-.js": "c40f39e993a30c96d6247bb3a7b581538bb046efe8ef8ec07bea3ba32291750f",
"_next/static/ZpcwfMZAXooqQp2hPpeha/_buildManifest.js": "9476e0afd3f9da68a64e6ec58631191fd3eaca62c9f5a377e7517fe9a89e59ea",
"_next/static/ZpcwfMZAXooqQp2hPpeha/_clientMiddlewareManifest.js": "5483e3295dc35dfe29ea932efc85341c916f10ff78ff5b0e965e003710893a4a",
"_next/static/ZpcwfMZAXooqQp2hPpeha/_ssgManifest.js": "678f6ce2cb80b1fe72fc67e7412be6e2ab6ada083111b64f7c40d35e3cba5e00",
"_not-found/__next._not-found/__PAGE__.txt": "8d787d28cec56b6df3db3caf0c41548c2c509ab4c27878961613bcb30a32d4b7"
},
"preservation": {
"groups": 57,
"routesPresent": 34,
"qualification": "source membership only; inherited statuses retained"
},
"checks": {
"typecheck": "exit0",
"lint": "exit0",
"build": "exit0 NEXT_PUBLIC_API_BASE_URL=/api",
"browser": "exit0 node scripts/verify-a3-theme.mjs --navigation"
},
"limits": [
"controlled API;16 mocked company PUTs;no real backend writes",
"40 font requests aborted",
"not full application/human/native/provider/security/release acceptance"
]
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+71 -6
View File
@@ -4,10 +4,12 @@ import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence');
const navigation = process.argv.includes('--navigation');
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence', navigation ? 'navigation' : '.');
fs.mkdirSync(out, { recursive: true });
(async () => {
const browser = await chromium.launch();
const rows = [], errors = [], writes = [], external = [];
const rows = [], errors = [], writes = [], mockedWrites = [], external = [], journeys = [];
try {
for (const width of [320, 390, 768, 1440]) for (const language of ['en', 'nb']) for (const mode of ['light', 'dark']) {
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
@@ -17,15 +19,21 @@ const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evide
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', mode);
}, { language, mode });
let companyName = 'Nordlys synthetic company';
await page.route('**/*', route => {
const request = route.request(), url = new URL(request.url());
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { external.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (navigation && request.method() === 'PUT' && url.pathname === '/api/companies/7') {
const payload = request.postDataJSON(); assert.ok(typeof payload.name === 'string');
companyName = payload.name; mockedWrites.push({ method: 'PUT', path: url.pathname });
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 7, ...payload }) });
}
if (request.method() !== 'GET') { writes.push({ method: request.method(), path: url.pathname }); return route.abort(); }
const fixtures = {
'/api/auth/config': { requireAuth: true, allowRegistration: true, googleEnabled: false, microsoftEnabled: false, turnstileEnabled: false },
'/api/auth/me': { id: 'a3-synthetic-owner', sessionContext: 'a3-synthetic-context', email: 'alex@example.test', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/companies': [{ id: 7, name: 'Nordlys synthetic company', location: 'Tromsø', source: 'Manual' }],
'/api/companies': [{ id: 7, name: companyName, location: 'Tromsø', source: 'Manual' }],
'/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
};
return route.fulfill({ status: Object.hasOwn(fixtures, url.pathname) ? 200 : 404, contentType: 'application/json', body: JSON.stringify(fixtures[url.pathname] ?? { message: 'Not part of this controlled theme check' }) });
@@ -39,7 +47,8 @@ const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evide
const geometry = await page.evaluate(() => ({ overflow: document.documentElement.scrollWidth > innerWidth + 1, background: getComputedStyle(document.body).backgroundColor }));
assert.equal(geometry.overflow, false);
assert.equal(geometry.background, mode === 'light' ? 'rgb(255, 255, 255)' : 'rgb(40, 35, 52)');
const scan = await new AxeBuilder({ page }).include('main').withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
const builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']);
const scan = await (navigation ? builder : builder.include('main')).analyze();
rows.push({ screen, width, language, mode, geometry, violations: scan.violations });
await page.screenshot({ path: path.join(out, `${screen}-${language}-${mode}-${width}.png`), fullPage: true });
assert.deepEqual(scan.violations, [], `${screen}/${mode}/${language}/${width} scoped accessibility`);
@@ -59,6 +68,61 @@ const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evide
assert.deepEqual(dialog.violations, []);
await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' });
assert.equal(await edit.evaluate(node => node === document.activeElement), true);
if (navigation) {
const primary = page.getByRole('navigation', { name: language === 'en' ? 'Primary navigation' : 'Hovednavigasjon', exact: true });
const more = language === 'en' ? 'More' : 'Mer';
if (width < 768) {
assert.equal(await primary.getByRole('button').count(), 5);
if (language === 'en') for (const label of ['Dashboard', 'Applications']) {
const metrics = await primary.getByText(label, { exact: true }).evaluate(node => ({ height: node.getBoundingClientRect().height, width: node.getBoundingClientRect().width, font: getComputedStyle(node).font, lineHeight: Number.parseFloat(getComputedStyle(node).lineHeight), buttonWidth: node.parentElement.getBoundingClientRect().width }));
assert.ok(metrics.height <= metrics.lineHeight + 1, `${label} should fit one line: ${JSON.stringify(metrics)}`);
}
assert.equal(await primary.getByRole('button', { name: more, exact: true }).getAttribute('aria-current'), 'page');
await primary.getByRole('button', { name: more, exact: true }).focus(); await page.keyboard.press('Enter');
const drawer = page.getByRole('dialog'); await drawer.waitFor();
assert.equal(await drawer.getByRole('button', { name: language === 'en' ? 'Companies' : 'Selskaper', exact: true }).getAttribute('aria-current'), 'page');
const menuScan = await new AxeBuilder({ page }).include('[role=dialog]').withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
rows.push({ screen: 'more-navigation', width, language, mode, violations: menuScan.violations }); assert.deepEqual(menuScan.violations, []);
await page.screenshot({ path: path.join(out, `more-${language}-${mode}-${width}.png`), fullPage: true });
await page.keyboard.press('Escape'); await drawer.waitFor({ state: 'hidden' });
assert.equal(await primary.getByRole('button', { name: more, exact: true }).evaluate(node => node === document.activeElement), true);
await primary.getByRole('button', { name: more, exact: true }).click(); await drawer.waitFor();
await drawer.getByRole('button', { name: language === 'en' ? 'Companies' : 'Selskaper', exact: true }).click();
await drawer.waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelector('main h1') === document.activeElement);
} else {
assert.equal(await primary.count(), 0);
await page.getByRole('button', { name: language === 'en' ? 'Collapse sidebar' : 'Skjul sidemenyen', exact: true }).click();
await page.reload(); await page.getByText(companyName, { exact: true }).waitFor();
assert.equal(await page.evaluate(() => localStorage.getItem('appShellDesktopSidebarCollapsed')), '1');
await page.screenshot({ path: path.join(out, `collapsed-${language}-${mode}-${width}.png`), fullPage: true });
}
await page.locator('[data-company-edit="7"]').click();
const form = page.getByRole('dialog'); await form.getByRole('textbox').first().fill('Reviewed synthetic company');
await form.getByRole('button', { name: language === 'en' ? 'Save' : 'Lagre', exact: true }).click();
await form.waitFor({ state: 'hidden' });
const feedback = page.locator('.MuiSnackbar-root [role=alert]'); await feedback.waitFor();
await page.getByText(companyName, { exact: true }).waitFor();
if (width < 768) {
const clearance = await feedback.evaluate(node => ({ bottom: node.getBoundingClientRect().bottom, navTop: document.querySelector('nav[aria-label="Primary navigation"],nav[aria-label="Hovednavigasjon"]').getBoundingClientRect().top }));
assert.ok(clearance.bottom <= clearance.navTop, 'feedback must stay above mobile navigation');
await page.screenshot({ path: path.join(out, `feedback-${language}-${mode}-${width}.png`), fullPage: true });
}
if (width >= 768) await page.getByRole('button', { name: language === 'en' ? 'Expand sidebar' : 'Utvid sidemenyen', exact: true }).click();
const core = width < 768 ? primary : page.getByRole('navigation', { name: language === 'en' ? 'Navigation' : 'Navigasjon', exact: true });
for (const [to, label] of [
['/dashboard', 'Dashboard'],
['/jobs', width < 768 ? (language === 'en' ? 'Applications' : 'Søknader') : (language === 'en' ? 'Job Applications' : 'Jobbsøknader')],
['/discover', width < 768 && language === 'en' ? 'Discover' : (language === 'en' ? 'Discover jobs' : 'Finn jobber')],
['/career', width < 768 ? (language === 'en' ? 'Career' : 'Karriere') : (language === 'en' ? 'Career profile' : 'Karriereprofil')],
]) {
await core.getByRole('button', { name: label, exact: true }).click();
await page.waitForURL(url => url.pathname === to);
await page.waitForFunction(() => document.querySelector('main h1') === document.activeElement);
assert.equal(await core.getByRole('button', { name: label, exact: true }).getAttribute('aria-current'), 'page');
}
journeys.push({ width, language, mode, secondaryNavigation: 'PASS', fourCoreRoutesAndHeadingFocus: 'PASS (API-unavailable states allowed)', focusAndPersistence: 'PASS', controlledCompanySave: 'PASS', mobileFeedbackClearance: width < 768 ? 'PASS' : 'not applicable' });
}
}
}
await context.close();
@@ -66,9 +130,10 @@ const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evide
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
// Existing font stylesheet requests are blocked, not fetched or needed by A3's system stack.
assert.ok(external.every(origin => origin === 'https://fonts.googleapis.com'), 'unexpected external request');
console.log('PASS: 32 actual-application screen conditions, 16 company-dialog keyboard journeys; controlled API only.');
assert.equal(mockedWrites.length, navigation ? 16 : 0);
console.log(`PASS: 32 actual-application screen conditions, 16 company-dialog keyboard journeys; ${journeys.length} navigation/save checks; controlled API only.`);
} finally {
fs.writeFileSync(path.join(out, 'BROWSER.json'), JSON.stringify({ rows, errors, writes, blockedExternalRequests: external, scope: 'actual static-export React application with controlled API; existing remote font requests aborted; no backend or provider qualification' }, null, 2));
fs.writeFileSync(path.join(out, 'BROWSER.json'), JSON.stringify({ rows, errors, writes, mockedWrites, journeys, blockedExternalRequests: external, scope: 'actual static-export React application with controlled API; existing remote font requests aborted; no backend or provider qualification' }, null, 2));
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+6 -5
View File
@@ -329,19 +329,20 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" /> },
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/jobs", activeFor: ["/applications"], label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("jobSearch"), children: [
{ to: "/discover", label: t("discoverJobs"), icon: <SearchIcon fontSize="small" /> },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" /> },
{ to: "/offers", label: t("offerComparisonNav"), icon: <CompareArrowsIcon fontSize="small" /> },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" /> },
] },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/discover", label: t("discoverJobs"), icon: <SearchIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/career", label: t("careerWorkspaceProfileTab"), icon: <AccountCircleIcon fontSize="small" />, section: t("career") },
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
{ to: "/documents", label: t("documentsTitle"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
{ to: "/analytics", label: t("analytics"), icon: <InsightsOutlinedIcon fontSize="small" />, section: t("insights") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("workspace") },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("workspace") },
{ to: "/analytics", label: t("analytics"), icon: <InsightsOutlinedIcon fontSize="small" />, section: t("workspace") },
{ to: "/operations", label: t("operations"), icon: <MemoryIcon fontSize="small" />, section: t("workspace") },
];
const navBottom: NavItem[] = [
+75
View File
@@ -0,0 +1,75 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
import { I18nProvider } from "./i18n/I18nProvider";
import JobTrackerThemeProvider from "./ThemeProvider";
import AppShell, { type NavItem } from "./layout/AppShell";
let mockMobile = true;
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => mockMobile }));
const nav: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: <span /> },
{ to: "/jobs", label: "Jobs", icon: <span />, activeFor: ["/applications"], children: [{ to: "/companies", label: "Companies", icon: <span /> }] },
{ to: "/discover", label: "Discovery", icon: <span /> },
{ to: "/career", label: "Career profile", icon: <span />, children: [{ to: "/career/builder", label: "CV Builder", icon: <span /> }] },
];
const bottom: NavItem[] = [
{ to: "/settings", label: "Settings", icon: <span /> },
{ to: "/admin/users", label: "Hidden admin", icon: <span />, hidden: true },
];
function Shell({ initial = "/dashboard" }: { initial?: string }) {
const [pathname, setPath] = useState(initial);
const [open, setOpen] = useState(false);
return <JobTrackerThemeProvider preference="light" systemPrefersDark={false}><I18nProvider>
<AppShell pageTitle={pathname} breadcrumbs={[]} pathname={pathname} nav={nav} navBottom={bottom} drawerOpen={open}
onToggleDrawer={setOpen} onNavigate={to => { setOpen(false); setPath(to); }}><button>Page action</button></AppShell>
</I18nProvider></JobTrackerThemeProvider>;
}
beforeEach(() => { localStorage.clear(); mockMobile = true; });
test.each([
["en", "Primary navigation", "Applications", "Discover", "Career", "More"],
["nb", "Hovednavigasjon", "Søknader", "Finn jobber", "Karriere", "Mer"],
])("%s hybrid navigation has five labelled destinations and retains secondary tools", (language, label, applications, discover, career, more) => {
localStorage.setItem("uiLanguage", language);
render(<Shell initial="/companies" />);
const primary = screen.getByRole("navigation", { name: label });
expect(within(primary).getAllByRole("button")).toHaveLength(5);
for (const name of [applications, discover, career, more]) expect(within(primary).getByRole("button", { name })).toBeVisible();
expect(within(primary).getByRole("button", { name: more })).toHaveAttribute("aria-current", "page");
fireEvent.click(within(primary).getByRole("button", { name: more }));
const drawer = screen.getByRole("dialog");
expect(within(drawer).getByRole("button", { name: "Companies" })).toHaveAttribute("aria-current", "page");
expect(within(drawer).queryByText("Hidden admin")).not.toBeInTheDocument();
fireEvent.click(within(drawer).getByRole("button", { name: "Settings" }));
expect(screen.getByRole("heading", { level: 1, name: "/settings" })).toHaveFocus();
});
test.each(["/career/builder/42", "/applications/42", "/jobs/42"])("%s selects its primary destination and keyboard route focus survives", initial => {
localStorage.setItem("uiLanguage", "en");
render(<Shell initial={initial} />);
const primary = screen.getByRole("navigation", { name: "Primary navigation" });
expect(within(primary).getAllByRole("button").filter(button => button.getAttribute("aria-current") === "page")).toHaveLength(1);
expect(within(primary).getByRole("button", { name: initial.startsWith("/career") ? "Career" : "Applications" })).toHaveAttribute("aria-current", "page");
fireEvent.click(within(primary).getByRole("button", { name: "Discover" }));
expect(screen.getByRole("heading", { name: "/discover", level: 1 })).toHaveFocus();
});
test("desktop retains labelled collapse, persistence, and all secondary destinations", () => {
mockMobile = false; localStorage.setItem("uiLanguage", "en");
render(<Shell />);
expect(screen.queryByRole("navigation", { name: "Primary navigation" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
expect(localStorage.getItem("appShellDesktopSidebarCollapsed")).toBe("1");
fireEvent.click(screen.getByRole("button", { name: "Jobs" }));
expect(within(screen.getByRole("menu")).getByRole("menuitem", { name: "Companies" })).toBeVisible();
});
test("choosing the current destination from More closes the drawer and focuses its heading", async () => {
localStorage.setItem("uiLanguage", "en"); render(<Shell initial="/companies" />);
fireEvent.click(within(screen.getByRole("navigation", { name: "Primary navigation" })).getByRole("button", { name: "More" }));
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Companies" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("heading", { level: 1, name: "/companies" })).toHaveFocus();
});
+8
View File
@@ -185,6 +185,10 @@ export const translations = {
settings: "Settings",
openNavigation: "Open navigation",
navigation: "Navigation",
navigationPrimary: "Primary navigation",
navigationApplications: "Applications",
navigationDiscover: "Discover",
navigationMore: "More",
skipToMainContent: "Skip to main content",
jobSearch: "Job search",
career: "Career",
@@ -3306,6 +3310,10 @@ export const translations = {
settings: "Innstillinger",
openNavigation: "Åpne navigasjon",
navigation: "Navigasjon",
navigationPrimary: "Hovednavigasjon",
navigationApplications: "Søknader",
navigationDiscover: "Finn jobber",
navigationMore: "Mer",
skipToMainContent: "Hopp til hovedinnhold",
jobSearch: "Jobbsøk",
career: "Karriere",
+66 -4
View File
@@ -12,6 +12,7 @@ import {
Chip,
Divider,
Drawer,
GlobalStyles,
IconButton,
List,
ListItem,
@@ -32,6 +33,7 @@ import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import JobbjaktMark from "../assets/JobbjaktMark";
import { PageHeader, type PageWidth } from "./PageLayout";
@@ -137,7 +139,8 @@ export default function AppShell({
}) {
const { language, setLanguage, t } = useI18n();
const notificationCountId = useId();
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
const mobileDrawerId = useId();
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm"));
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
@@ -149,9 +152,12 @@ export default function AppShell({
const navCollapsed = !isMobile && desktopNavCollapsed;
const [header, setHeader] = useState<HTMLElement | null>(null);
const [headerHeight, setHeaderHeight] = useState<number>();
const [bottomNavigation, setBottomNavigation] = useState<HTMLElement | null>(null);
const [bottomNavigationHeight, setBottomNavigationHeight] = useState(0);
const [secondaryMenu, setSecondaryMenu] = useState<{ anchor: HTMLElement; item: NavItem } | null>(null);
const pageTitleRef = useRef<HTMLHeadingElement>(null);
const pendingFocusPath = useRef<string | null>(null);
const focusAfterDrawerNavigation = useRef(false);
useEffect(() => {
if (!isMobile && drawerOpen) onToggleDrawer(false);
@@ -166,6 +172,16 @@ export default function AppShell({
return () => observer.disconnect();
}, [header]);
useEffect(() => {
if (!bottomNavigation) { setBottomNavigationHeight(0); return; }
const measure = () => setBottomNavigationHeight(Math.ceil(bottomNavigation.getBoundingClientRect().height));
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(bottomNavigation);
return () => observer.disconnect();
}, [bottomNavigation]);
useEffect(() => {
try {
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
@@ -182,12 +198,16 @@ export default function AppShell({
}, [pathname]);
const navigateFromShell = (to: string) => {
setSecondaryMenu(null);
if (isMobile && drawerOpen) {
focusAfterDrawerNavigation.current = true;
onToggleDrawer(false);
}
if (pathname === to) {
pageTitleRef.current?.focus({ preventScroll: true });
return;
}
pendingFocusPath.current = to;
setSecondaryMenu(null);
onNavigate(to);
};
@@ -218,6 +238,22 @@ export default function AppShell({
() => activeNavItemTo(pathname, [...nav, ...navBottom]),
[pathname, nav, navBottom],
);
const mobilePrimary = useMemo(() => {
const visible: NavItem[] = [];
const visit = (item: NavItem) => {
if (item.hidden) return;
visible.push(item); item.children?.forEach(visit);
};
[...nav, ...navBottom].forEach(visit);
return ["/dashboard", "/jobs", "/discover", "/career"]
.map(to => visible.find(item => item.to === to)).filter((item): item is NavItem => Boolean(item));
}, [nav, navBottom]);
const mobileActive = mobilePrimary.find(item => [item.to, ...(item.activeFor ?? [])]
.some(to => pathname === to || pathname.startsWith(`${to}/`)))?.to;
const mobileLabels: Record<string, string> = {
"/dashboard": t("dashboard"), "/jobs": t("navigationApplications"),
"/discover": t("navigationDiscover"), "/career": t("career"),
};
const desktopNavigationRef = useRef<HTMLDivElement>(null);
const mobileNavigationRef = useRef<HTMLDivElement>(null);
// New destinations can extend the existing scrollable list below its initial viewport.
@@ -364,6 +400,7 @@ export default function AppShell({
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<GlobalStyles styles={{ ":root": { "--app-bottom-nav-height": `${isMobile ? Math.max(72, bottomNavigationHeight) : 0}px` } }} />
<Box component="span" id={notificationCountId} role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
{notificationsCount === undefined ? t("notificationsUnreadCountUnavailable") : t("notificationsUnreadCount", { count: notificationsCount })}
</Box>
@@ -403,6 +440,7 @@ export default function AppShell({
aria-label={t("openNavigation")}
aria-haspopup="dialog"
aria-expanded={drawerOpen}
aria-controls={mobileDrawerId}
onClick={() => onToggleDrawer(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
@@ -621,9 +659,14 @@ export default function AppShell({
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
slotProps={{
paper: { "aria-label": t("navigation") },
paper: { id: mobileDrawerId, "aria-label": t("navigation") },
transition: { onEntered: (node: HTMLElement) => {
if (node === document.activeElement || !node.contains(document.activeElement)) node.querySelector<HTMLElement>('[role="button"]')?.focus();
}, onExited: () => {
if (focusAfterDrawerNavigation.current) {
focusAfterDrawerNavigation.current = false;
pageTitleRef.current?.focus({ preventScroll: true });
}
} },
}}
sx={{
@@ -648,7 +691,7 @@ export default function AppShell({
"--app-shell-header-height": `${headerHeight ?? (isMobile ? 124 : 76)}px`,
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))",
pb: "calc(var(--app-page-gutter) + var(--app-bottom-nav-height, 0px) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
bgcolor: "background.default",
minHeight: "100vh",
minWidth: 0,
@@ -672,6 +715,25 @@ export default function AppShell({
{children}
</Box>
</Box>
{isMobile ? <Box component="nav" ref={setBottomNavigation} aria-label={t("navigationPrimary")}
sx={{ position: "fixed", bottom: 0, insetInline: 0, zIndex: theme => theme.zIndex.appBar,
display: "flex", gap: 0.5, minHeight: 72, p: 1, px: 0.5, pb: "max(8px, env(safe-area-inset-bottom, 0px))",
bgcolor: "background.paper", borderTop: "1px solid", borderColor: "divider" }}>
{mobilePrimary.map(item => <Button key={item.to} onClick={() => navigateFromShell(item.to)}
aria-current={mobileActive === item.to ? "page" : undefined}
sx={{ flex: "1 1 auto", minWidth: 44, minHeight: 56, p: 0.25, gap: 0.25, flexDirection: "column", fontSize: 11, letterSpacing: 0,
lineHeight: 1.25, overflowWrap: "anywhere", boxShadow: "none", color: mobileActive === item.to ? "primary.main" : "text.secondary",
bgcolor: mobileActive === item.to ? "action.selected" : "transparent" }}>
{item.icon}<span>{mobileLabels[item.to]}</span>
</Button>)}
<Button onClick={() => onToggleDrawer(true)} aria-haspopup="dialog" aria-controls={mobileDrawerId}
aria-expanded={drawerOpen} aria-current={!mobileActive ? "page" : undefined}
sx={{ flex: "1 1 auto", minWidth: 44, minHeight: 56, p: 0.25, gap: 0.25, flexDirection: "column", fontSize: 11, letterSpacing: 0,
lineHeight: 1.25, overflowWrap: "anywhere", boxShadow: "none", color: !mobileActive ? "primary.main" : "text.secondary",
bgcolor: !mobileActive ? "action.selected" : "transparent" }}>
<MoreHorizIcon fontSize="small" /><span>{t("navigationMore")}</span>
</Button>
</Box> : null}
</Box>
);
}
+2 -2
View File
@@ -58,7 +58,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<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))" },
html: { scrollPaddingBottom: "calc(var(--app-bottom-nav-height, 0px) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))" },
}} />
{children}
<Snackbar
@@ -67,7 +67,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
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))" } }}
sx={{ bottom: { xs: "calc(var(--app-bottom-nav-height, 0px) + 8px + env(safe-area-inset-bottom, 0px))", sm: "calc(var(--app-bottom-nav-height, 0px) + 24px + env(safe-area-inset-bottom, 0px))" } }}
>
<Alert
onClose={() => setT((p) => ({ ...p, open: false }))}