From d8c01873053e59c896e60911b8e5c14b00693c7f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 20 Oct 2026 20:26:48 +0200 Subject: [PATCH] fix(ui): reconcile shared accessibility and read recovery --- docs/redesign/phase-7/COMMITS.md | 4 + docs/redesign/phase-7/DEBT.md | 2 + docs/redesign/phase-7/PRESERVATION.md | 2 + docs/redesign/phase-7/README.md | 2 + docs/redesign/phase-7/p7-10/README.md | 2 +- docs/redesign/phase-7/p7-11/README.md | 30 ++ docs/redesign/phase-7/p7-11/SHARED.md | 17 + .../p7-11/evidence/shared-results.json | 357 ++++++++++++++++++ .../p7-11/evidence/shared-static-checks.json | 104 +++++ docs/roadmap/todo.md | 2 +- .../e2e/phase7-application-forms.spec.ts | 28 ++ .../src/app-shell-notifications.test.tsx | 6 +- .../src/application-checklist.test.tsx | 14 + .../src/application-workflow-assist.test.tsx | 52 ++- job-tracker-ui/src/components/AddJobModal.tsx | 6 +- .../src/components/ApplicationChecklist.tsx | 6 +- .../components/ApplicationWorkflowAssist.tsx | 48 ++- .../src/components/InterviewPrep.tsx | 2 +- .../src/components/QuickCommandDialog.tsx | 2 +- .../src/file-picker-activation.test.tsx | 2 + job-tracker-ui/src/i18n/translations.ts | 118 ++++++ job-tracker-ui/src/interview-prep.test.tsx | 13 + job-tracker-ui/src/layout/AppShell.tsx | 5 +- job-tracker-ui/src/quick-capture.test.tsx | 1 + .../src/quick-command-preservation.test.tsx | 2 + 25 files changed, 802 insertions(+), 25 deletions(-) create mode 100644 docs/redesign/phase-7/p7-11/README.md create mode 100644 docs/redesign/phase-7/p7-11/SHARED.md create mode 100644 docs/redesign/phase-7/p7-11/evidence/shared-results.json create mode 100644 docs/redesign/phase-7/p7-11/evidence/shared-static-checks.json diff --git a/docs/redesign/phase-7/COMMITS.md b/docs/redesign/phase-7/COMMITS.md index 9c1f3e5..a0d2bfd 100644 --- a/docs/redesign/phase-7/COMMITS.md +++ b/docs/redesign/phase-7/COMMITS.md @@ -2,6 +2,10 @@ ## Continuation — 20 October 2026 +P7-10 System **d824f59d2b3e9b680bee5952802aa3c0453aee03** normally pushed/directly verified clean13:40:25UTC. CI-only main repair merge **6db62095c6dc6f7e8275447827c62b09f9b2d2e3** pushed/directly verified clean13:40:41UTC. P7-10 is implemented CONDITIONAL GO and published. P7-11 [shared integration](p7-11/SHARED.md) has80/11 affected/consumer frontend, current7version/10command vectors and native5modal+1Strategy PASS; final hygiene/publication follows.59paired label additions are the shared foundation for subsequent responsible-owner commits. Whole candidate frontend898/101 and backend1057 pass; final ARIA continuations receive focused/native proof and fresh P7-12 regression. + +Independent fixture/focus repair **bba52af4b8724f1a5c74013ce1476a20ca3972c2** normally pushed/directly verified on development18:05:08UTC, with all P7-11 WIP retained. Main's equivalent **3e1057d** plus Qwen adapter cherry-pick **f7dc22e5e7efe1fd1d64f6ace2c04cf2f95bf558** are pushed/directly verified18:05UTC. Release833 tests; no verified deployment/model activation or production GO claim. Run832's16failures are explained in its release report; one flaky Rules case remains debt. + P7-10 [System](p7-10/SYSTEM.md):20unit/46existingbackend/3native plus app/browser TypeScript/lint/build PASS. ExportEAHpn0GPj6rRy3WtHltbf;14axe0violations/100incompletes, two final images inspected after dirty-label correction. Publication follows final staged checks. Audit **f9e1d06bf7fc4081b70ebf461f1645ef219e0a93** pushed/directly verified13:26:54UTC. Separate Qwen adapter preparation **1d34406d1bacdb33d180f0e7e37bb0cb099ece5e** pushed/directly verified13:33:16UTC, full offline sidecar56/56; model activation remains pending. Main d1fcd23 CI832 is independently running; no verified deployment claim. P7-10 [Audit](p7-10/AUDIT.md) final bounded gate:9 frontend,18 existing backend and3 native cases PASS; app/browser TypeScript, lint/build PASS. Export vZuD4f-46FJekdIR5rhRk; seven scans0violations/110incomplete nodes, two final images inspected. Ordinary REDs and pre-layout candidate remain retained. Users hygiene follow-up **4760fd102c21c684bd139206a12194c6fc8f6104** passed staged/full-phase whitespace and was normally pushed/directly verified13:10:57UTC. Audit publication follows after staged checks; System tests remain separate WIP. diff --git a/docs/redesign/phase-7/DEBT.md b/docs/redesign/phase-7/DEBT.md index fbbac83..54b8291 100644 --- a/docs/redesign/phase-7/DEBT.md +++ b/docs/redesign/phase-7/DEBT.md @@ -1,5 +1,7 @@ # Phase 7 — Retained debt and mandatory gates +P7-11 shared/CV continuation corrects the evidenced roleless Checklist/Interview/Undo/Redo tooltip spans, static version-badge name and dangling Quick Search title reference. Final modal26scans0violations/176incomplete observations retain172contrast and4hidden-focus menu-transition contexts; final Documents18scans0/105 are contrast-only. This is bounded source/DOM/browser remediation, not human reader/device or every-pixel conformance. Correspondence/gallery modal-background transitions, existing library wrappers, contrast/physical/output/reliability and production gates remain qualified. No old manual requirement or intermittent Rules incident is closed by rerun. + P7-09's [sender repair](p7-09/evidence/oauth-results.json) confirms/fixes uncorrelated OAuth messages and delayed startup-language feedback. Consumer regression retains89 axe incompletes, including46 roleless-span `aria-label` diagnostics (45 Notes/Tasks,1 Interview) for bounded P7-11 responsible-owner triage. No existing manual/provider/release gate is closed; the Settings presentation findings remain pending. First-boundary [Preferences/Connections results](p7-09/evidence/preferences-results.json) repair notification draft/reset/recovery/snapshot/disposal and truthful provider loading/error/write-refresh/IMAP-port/focus states. New18 scans retain62 incompletes and existing36 retain222; indeterminate layered contrast, short badges and closing-menu settled focus/AT remain qualified. RD11/RD12/RD13 roots are not closed by successful or controlled recovery. Data lifecycle additionally requires the demonstrated app-export/import casing compatibility repair before its round-trip claim; no restore/transaction/replay capability is invented. diff --git a/docs/redesign/phase-7/PRESERVATION.md b/docs/redesign/phase-7/PRESERVATION.md index 6a04e3d..f74dbab 100644 --- a/docs/redesign/phase-7/PRESERVATION.md +++ b/docs/redesign/phase-7/PRESERVATION.md @@ -1,5 +1,7 @@ # Phase 7 — Initial preservation matrix +P7-11 [shared integration](p7-11/SHARED.md) adds bounded PRES-E07/E08/W04/W09 evidence: valid badge/command names, retained item-specific checklist/interview actions, contextual form headings and Strategy GET-only error recovery with exact intentional-empty contract. Existing AI/Pro and operations semantics remain. All57 groups stay56 PARTIAL/C11 DEFERRED and33 routes; accessibility incompletes/manual/provider/output/production gates remain qualified. No Phase8 work. + P7-10 [System](p7-10/SYSTEM.md) adds bounded PRES-A03/E09 proof: four independent diagnostic reads, explicit failed-read availability, denied-refresh disposal, GET-only retry, unchanged SMTP password/clear payloads, retained newer drafts and synchronized/session-guarded operations.20unit/46backend/3native cases pass; administrator browser writes are controlled fixtures, not operational acceptance. All57 groups remain56 PARTIAL/C11 DEFERRED and33 routes. No schema/renderer/provider/AI-safety change. Human/device/provider/output/release qualifications remain. P7-10 [Audit](p7-10/AUDIT.md) adds PRES-A02/E08/E09 proof: exact Undo allowlist, owned Restore versus administrator Undo, persisted event/readback and ordinary-user denials. GET-only recovery, synchronized writes, current-language errors and disposed-session guards pass. All57 groups retain56 PARTIAL/C11 DEFERRED,33 routes. No server authorization/data contract or PDF/AI safeguard change. System and shared/manual accessibility acceptance remain pending. diff --git a/docs/redesign/phase-7/README.md b/docs/redesign/phase-7/README.md index f48ee40..a258202 100644 --- a/docs/redesign/phase-7/README.md +++ b/docs/redesign/phase-7/README.md @@ -2,6 +2,8 @@ ## Current continuation — 20 October 2026 +P7-10 is implemented CONDITIONAL GO and published throughd824f59; release repair merge6db6209 is pushed. P7-11 shared integration and paired label foundation have scoped unit/native/type/lint/build proof, with normal publication following; generated-label and error-owner source follow as separate commits. Candidate full frontend898/101 and backend1057 pass, sidecar56/56; final ARIA continuations and P7-12 full browser/source acceptance remain. Main f7dc22e release833 is testing; live/model activation remain unverified. Earlier observations below are historical. + Latest: P7-09 published8af9168; P7-10 Users7ecc0d6/4760fd1 and Auditf9e1d06 are published; System implemented CONDITIONAL GO with20unit/46backend/3native/types/lint/build PASS, final hygiene/publication pending. Current derived inventory884/99frontend,348browser/57files,1057backend; separate full sidecar56/56. These do not replace P7-12's fresh integrated run. Main d1fcd23 is testing in run832 after a narrowly identified Playwright-runtime corruption repair; deployment remains unverified. Qwen3.5 adapter preparation1d34406 is pushed on development, with no live model activation. Earlier observations below remain historical. P7-00–08 and P7-09 presentation are implemented CONDITIONAL GO. The [P7-09 import prerequisite](p7-09/IMPORT_COMPATIBILITY.md) passes full frontend 837/97, full backend 1057, affected native 25 and type/lint/build gates, published as `3028d81`. [Remaining data presentation](p7-09/DATA_PRIVACY_BACKUP.md) has affected47/6 plus22backendcontracts and final native/type/lint/build evidence, with final consumer/hygiene/publication pending. Derived inventory is855/99frontend,339browser,1057backend; this is not a fresh full integrated suite. P7-10 Administration, P7-11 residual UI and P7-12 acceptance remain. The historical sections below retain their as-observed baselines. diff --git a/docs/redesign/phase-7/p7-10/README.md b/docs/redesign/phase-7/p7-10/README.md index 0dc6aa1..16c64ac 100644 --- a/docs/redesign/phase-7/p7-10/README.md +++ b/docs/redesign/phase-7/p7-10/README.md @@ -8,6 +8,6 @@ Follow the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md): exactly th |---|---| | [Users](USERS.md) | Implemented CONDITIONAL GO; published7ecc0d6/4760fd1; follow-up passed staged/full-phase whitespace and remote verification13:10:57UTC | | [Audit](AUDIT.md) | Implemented CONDITIONAL GO; publishedf9e1d06, remote verified13:26:54UTC; exact Undo allowlist and owner-filtered Restore distinction verified | -| [System](SYSTEM.md) | Implemented CONDITIONAL GO;20frontend/46backend/3native plus types/lint/build PASS, final hygiene/publication pending; SMTP drafts and operational scopes preserved | +| [System](SYSTEM.md) | Implemented CONDITIONAL GO;20frontend/46backend/3native plus types/lint/build PASS; publishedd824f59, clean direct remote verification13:40:25UTC; SMTP drafts and operational scopes preserved | All 57 preservation groups retain **56 PARTIAL / C11 DEFERRED**, with 33 routes. P5-13 is withheld; followMode remains deferred. No Phase 8 implementation. Production readiness remains NO-GO; the independently owner-authorized main release is still unverified after run831 failed with browser crashes/malformed Playwright JavaScript. Do not infer admin recent-auth enforcement: the current Users/Audit/System actions do not implement that separate guard. Preserve actual account-lifecycle/export recent-auth checks and record this gap rather than inventing a security protocol during presentation migration. diff --git a/docs/redesign/phase-7/p7-11/README.md b/docs/redesign/phase-7/p7-11/README.md new file mode 100644 index 0000000..994cb2b --- /dev/null +++ b/docs/redesign/phase-7/p7-11/README.md @@ -0,0 +1,30 @@ +# P7-11 — Residual integration + +Entry: P7-10 System `d824f59d2b3e9b680bee5952802aa3c0453aee03`, normally pushed/directly verified with a clean checkout20October13:40:25UTC. CI-only main continuation was merged and pushed as `6db62095c6dc6f7e8275447827c62b09f9b2d2e3`, clean13:40:41UTC. The approved handoff requires responsible-owner repairs, not another migration of completed pages. + +## Recorded baseline and bounded owners + +| Finding | Impact / responsible boundary | +|---|---| +| Roleless version badge and disabled-control tooltip spans carry unsupported names | Accessibility; shared shell, Checklist and Interview owners | +| Add Job and Strategy headings skip semantic levels | Accessibility; existing form owners | +| Strategy GET failures become an apparent absent plan | Usability; existing Match-tab Strategy read owner; exact `strategy_not_generated` remains intentional emptiness | +| Known template, generated reason/tailoring and history metadata remain English in Bokmål | Localisation; existing gallery/reminder/application/history owners; user and unknown metadata remain verbatim | +| Resumed-draft fallback freezes opening language; singular counts use plural English | Localisation; existing correspondence presentation without draft/save protocol changes | +| CV private-source failures show raw `Not Found`, lack contextual heading and read retry | Usability; existing CV entry owner, private identifiers and server authorization unchanged | +| Unknown route has generic shell title and h4 owner heading | Accessibility/context; existing wildcard route, Home and Back actions retained | +| Documents action splits the last letter at1024px | Responsive usability; existing table and action sizing, metadata/query/download contracts unchanged | + +These findings were carried from implemented checkpoints and verified in source, unit regressions and/or browser reproduction. Cosmetic preferences are not additional scope. Analytics already uses the approved DashboardView report owner; it is reused with unchanged periods, cohorts, source links and statistics. Shared loading/feedback and existing confirmations remain; no duplicate framework is introduced. + +## Execution boundaries + +1. [Shared accessibility and forms](SHARED.md), including the paired translation foundation used by the following owners. +2. Generated-label localisation follows separately, preserving all stored metadata and user text. +3. Documents and error navigation follows separately, including CV history display in its existing owner. + +The independently authorized release repair is separate: development `bba52af4b8724f1a5c74013ce1476a20ca3972c2` was pushed/directly verified18:05:08UTC. Main contains equivalent fixture/focus repair `3e1057d` and Qwen compatibility `f7dc22e`, normally pushed and directly verified20October18:05UTC. Run833 is testing; deployment/model activation are unverified. [Release evidence](../../../infrastructure/2026-10-20-browser-fixture-and-focus-contracts.md) retains run832's314passing/16failed/oneflaky outcome and the two demonstrated causes. No failed assertion was hidden or timeout relaxed. + +## Acceptance boundaries + +P7-11 implementation and publication are in progress. Full integrated acceptance belongs to P7-12. All57 preservation groups retain56 PARTIAL/C11 DEFERRED and33 routes; no capability or route is removed. Manual reader/device/software-keyboard/zoom, production output/provider, reliability and release requirements remain. P5-13 stays withheld; followMode remains deferred. No Phase8 implementation or new dashboard customization. Production readiness remains NO-GO independently of the owner's authorization to release main. diff --git a/docs/redesign/phase-7/p7-11/SHARED.md b/docs/redesign/phase-7/p7-11/SHARED.md new file mode 100644 index 0000000..11ac30b --- /dev/null +++ b/docs/redesign/phase-7/p7-11/SHARED.md @@ -0,0 +1,17 @@ +# Shared accessibility and forms + +Final modal continuation **5/5 PASS**,18:23:09–18:24:16UTC; combined with the separate Strategy case,26all-enabled scans have0violations/176incomplete observations:172contrast and4hidden-focus MUI menu-transition contexts. The previous dangling-title and roleless Undo/Redo observations are absent in this fresh vector. No human/device acceptance follows. + +**Implemented CONDITIONAL GO; final hygiene/publication pending.** Existing shell/form owners remain; no navigation or mutation protocol replacement. + +The static administrator version badge now has a valid image role and localized version/commit accessible name. Checklist and Interview disabled-control tooltips describe their wrappers rather than placing names on roleless spans; item-specific inner-button names and disabled hover help remain. Add Job step headings are h3 under its dialog title, with progress text a paragraph. Strategy has a contextual h2 in the existing Match tab. Raw incomplete triage also proved Quick Search referenced a nonexistent MUI-generated title ID; its existing explicit name now stands without that dangling reference. Two existing command assertions failed before the correction; all10 command cases pass afterward. + +Strategy's GET read distinguishes failures from exact404 `strategy_not_generated`. Retry only reads the same plan; denial has no retry, locale changes do not refetch, stale previous-job responses cannot replace the current owner, and plan-read completion cannot unlock controls while the independent initial operation read remains pending. Existing Pro admission, generation/operation endpoints and safeguards remain unchanged. No model calls or qualification run. + +Meaningful initial regressions:34passed/11failed across six direct suites; expanded final affected/consumer result **80/80 across11 suites PASS**, TypeScript and targeted lint PASS. An additional English/Bokmål version-label case failed before localization and passed afterward; no net count change is claimed from overlapping runs. The later full run reconciles inventories. + +Five native confirmation consumers pass with explicit named-body and both-button focus traversal. The separate Strategy native workflow passes in English desktop and Bokmål320px dark: controlled503 remains distinct from empty; one keyboard GET retry produces exact not-generated emptiness with preserved focus and zero browser mutations. The first native fixture mistakenly selected Overview; source inspection corrected it to the actual Match tab. That fixture failure is retained and is not a product regression. Automated scans and images are recorded separately from human acceptance. + +Two Strategy final images were actually inspected:1440px English unavailable and320px Bokmål dark exact-empty. Full-page fixed-shell/skip-link capture artifacts retain their limits; source/advert prose remains document content, not translated UI labels. Other images and incomplete contexts are retained. The final modal rerun after Quick Search correction records updated scan totals in [portable results](evidence/shared-results.json); earlier incomplete vectors remain historical, including the CV-toolbar spans corrected by the error-owner boundary. No axe rule is hidden. + +The accompanying59paired translation additions support all three residual boundaries. New labels are presentation-only; unused rows in this first commit do not activate downstream features. All consumers retain existing routes/IDs/actions. Full frontend898/101 and backend1057 pass before final small ARIA continuations; relevant continuation tests pass and P7-12 runs fresh against the committed final source. Shared feedback/confirm infrastructure, renderer geometry, API authorization and P5-13 withholding remain unchanged. Static checks and normal push follow; human/physical/provider/production conditions remain. diff --git a/docs/redesign/phase-7/p7-11/evidence/shared-results.json b/docs/redesign/phase-7/p7-11/evidence/shared-results.json new file mode 100644 index 0000000..944cdda --- /dev/null +++ b/docs/redesign/phase-7/p7-11/evidence/shared-results.json @@ -0,0 +1,357 @@ +{ + "observedUtc": "2026-10-20T18:24:31.227604+00:00", + "sourceScope": "Post-run source identities; no claim of pre-run capture or that later catalogue/comment changes were in earlier exports", + "sources": { + "src/layout/AppShell.tsx": "e75a58f2984bf47d7edc3c2c51439242aba2df32675a30b1f17088095ea1d634", + "src/components/QuickCommandDialog.tsx": "27cbbb8b8596fdf4e7474eccfdb6d9a8a219f4baff6a9b8013b825ce5fc2c411", + "src/components/AddJobModal.tsx": "a572ab197526a7b8263ba369b7230db7781176f3e820d255e89d7f71bab94c63", + "src/components/ApplicationChecklist.tsx": "2c58d440669a76ec1882326e1039ca604f5bf7e38fd71ac6800d22e260a1fcc2", + "src/components/InterviewPrep.tsx": "454c97a2cad3a4045fbc301c5da717db79fa41b53a231e9f3f3cc437eb5881d1", + "src/components/ApplicationWorkflowAssist.tsx": "7a9cbb1f38879bb9e9156d19675e9db40c466d06008cc5495dfd99b6b4dc088b", + "src/i18n/translations.ts": "15cc09b7dfcea607b6a597d1379aaad5d3e1c01adcc518846a9047b398ba5458" + }, + "native": [ + { + "name": "release-modal-contracts", + "stats": { + "startTime": "2026-10-20T18:23:09.847Z", + "duration": 66822.658, + "expected": 5, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "path": ".artifacts/phase7/current/ui/release-modal-contracts.json", + "sha256": "f5352b3ab09d5f4e3c9c45cea369965efbad6d2211d0fad8b7a42502f4560f91", + "command": { + "name": "release-modal-contracts", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/named-controls-modal-state.spec.ts", + "e2e/phase7-discovery.spec.ts", + "--grep", + "named controls, modal keyboard|keyboard native create", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\release-modal-contracts-results" + ], + "startedUtc": "2026-10-20T18:23:09.321036+00:00", + "finishedUtc": "2026-10-20T18:24:16.708620+00:00", + "exitCode": 0 + } + }, + { + "name": "strategy-residual", + "stats": { + "startTime": "2026-10-20T18:10:59.174Z", + "duration": 16095.752, + "expected": 1, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "path": ".artifacts/phase7/current/ui/strategy-residual.json", + "sha256": "f58413b956da9464f07262d328c5f4712cccdc767e9164e4c7dc7fb801ba8106", + "command": { + "name": "strategy-residual", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/phase7-application-forms.spec.ts", + "--grep", + "Strategy failed reads", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\strategy-residual-results" + ], + "startedUtc": "2026-10-20T18:10:58.660686+00:00", + "finishedUtc": "2026-10-20T18:11:15.305746+00:00", + "exitCode": 0 + } + } + ], + "images": [ + { + "name": "p7-discovery-nb-light-360-native-create-held", + "path": ".artifacts/phase7/p7-11/shared-visuals/p7-discovery-nb-light-360-native-create-held-d66c78aa.png", + "sha256": "d66c78aa7e307ba36246428ebe2275c6dc68d5c5069287f32bf5bdee4e7837d2" + }, + { + "name": "p7-discovery-nb-light-360-native-delete-held", + "path": ".artifacts/phase7/p7-11/shared-visuals/p7-discovery-nb-light-360-native-delete-held-ab570faa.png", + "sha256": "ab570faa611f3074f8ae2ae2b088b40ff7d5b7572c25531384f57b892316775d" + }, + { + "name": "p706-forms-en-light-1440-strategy-read-unavailable", + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-en-light-1440-strategy-read-unavailable-bd863839.png", + "sha256": "bd86383954334e34052a6ca5150a08fb5c03824721b0951276145c075da39a7a" + }, + { + "name": "p706-forms-en-light-1440-strategy-exact-not-generated", + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-en-light-1440-strategy-exact-not-generated-f2c5bfc1.png", + "sha256": "f2c5bfc1b7d4abfba14cf43f9002cc34fec20262d08657d62f6f45ae6b1bd310" + }, + { + "name": "p706-forms-nb-dark-320-strategy-read-unavailable", + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-nb-dark-320-strategy-read-unavailable-bdac6183.png", + "sha256": "bdac618366e6869d9f141b939e2b525dff88610fe1bf58d4d0279147efd885cb" + }, + { + "name": "p706-forms-nb-dark-320-strategy-exact-not-generated", + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-nb-dark-320-strategy-exact-not-generated-1ad739a1.png", + "sha256": "1ad739a1fe0133e8228cf5fc2f629e82bda121bfc8d9960c58356afea866c0b3" + } + ], + "scans": [ + { + "path": ".artifacts/phase7/p7-11/shared-visuals/p7-discovery-nb-light-360-native-create-held-audit-c82b8641.json", + "sha256": "c82b864175ca44673005263b83b9763ed778629430fabb1bf6697aaf12fb36f1", + "violations": 0, + "incompleteNodes": 1, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/p7-discovery-nb-light-360-native-delete-held-audit-2e8a5542.json", + "sha256": "2e8a55425bca9ad5f5c23c865d940b6b108315372dd9002c0653d3dcaf42b834", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/command-axe-647699d7.json", + "sha256": "647699d71b0d56b61f3af200452834d81210fa67b33696806f02abb7a8442c61", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cv-controls-axe-c71e3b74.json", + "sha256": "c71e3b74f32943b3a75b85b152e8f6a5c704ef0b65440cd0a93f225cf1b0c57e", + "violations": 0, + "incompleteNodes": 25, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cover-controls-axe-52c71913.json", + "sha256": "52c719132e9830a1736707443438408cf2843f22e508ca1098e567c32bf9f483", + "violations": 0, + "incompleteNodes": 6, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/prompt-axe-5a4a000a.json", + "sha256": "5a4a000ad4272cd7ac79c5f5f41c00ca6c6a62658912a2409dc17c134c1fe398", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/confirm-axe-15ee5431.json", + "sha256": "15ee543140cd0d42b7254759ebd4cb045644eca882248c7567e80788bccfa001", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/command-axe-5895b5c3.json", + "sha256": "5895b5c3a1046273c59c01c000ff183b0b01c34d1590ccd1814e61c5fc6dc87a", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cv-controls-axe-db3f7506.json", + "sha256": "db3f7506b91f58b816c29466e7939aa1a1af24e4f5f86a05ffa5782cd0fd02e8", + "violations": 0, + "incompleteNodes": 30, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cover-controls-axe-900a7709.json", + "sha256": "900a77096f5cef09372362420aaa6f9e52ffe94dc55fd6acb234767f2b70cfb6", + "violations": 0, + "incompleteNodes": 6, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/prompt-axe-d2e174df.json", + "sha256": "d2e174df033f399a942508acc5e7d046945a228ea022e0408b06945b8b719141", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/confirm-axe-15ee5431.json", + "sha256": "15ee543140cd0d42b7254759ebd4cb045644eca882248c7567e80788bccfa001", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/command-axe-c9062adb.json", + "sha256": "c9062adb6dd8206f866ab7af94f0ca38524b7f2478f9d3d0ade0b62405b121a5", + "violations": 0, + "incompleteNodes": 0, + "incompleteRules": [] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cv-controls-axe-d2dea077.json", + "sha256": "d2dea077614afeec933815d852a1ce72b5c63c41fcf3a3139d757d5519cd6087", + "violations": 0, + "incompleteNodes": 24, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cover-controls-axe-c180f9b6.json", + "sha256": "c180f9b684782328f0a33d32c6fb48705958c9370780bf71c7270f7ff83029b3", + "violations": 0, + "incompleteNodes": 10, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/prompt-axe-5a4a000a.json", + "sha256": "5a4a000ad4272cd7ac79c5f5f41c00ca6c6a62658912a2409dc17c134c1fe398", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/confirm-axe-15ee5431.json", + "sha256": "15ee543140cd0d42b7254759ebd4cb045644eca882248c7567e80788bccfa001", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/command-axe-c9062adb.json", + "sha256": "c9062adb6dd8206f866ab7af94f0ca38524b7f2478f9d3d0ade0b62405b121a5", + "violations": 0, + "incompleteNodes": 0, + "incompleteRules": [] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cv-controls-axe-549ca659.json", + "sha256": "549ca65975a2e785420651c37d0be7e9d3c2da3d83857a396c50a2db339bf591", + "violations": 0, + "incompleteNodes": 24, + "incompleteRules": [ + "aria-hidden-focus", + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/cover-controls-axe-61e54ade.json", + "sha256": "61e54adebc11daf24dfa3d6c4620c65c61a7867dfe671a6b0d2b62e62088105f", + "violations": 0, + "incompleteNodes": 10, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/prompt-axe-f58690b5.json", + "sha256": "f58690b5f684193dbe77aa4227754fb3b6384abda77c1a977b5d8f5c97135b22", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/confirm-axe-15ee5431.json", + "sha256": "15ee543140cd0d42b7254759ebd4cb045644eca882248c7567e80788bccfa001", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-en-light-1440-strategy-read-unavailable-audit-09114185.json", + "sha256": "0911418580ebbad2ad3ea66b75195da55852288738efd81ae8000ccebdfb9a77", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-en-light-1440-strategy-exact-not-generated-audit-c68e7baa.json", + "sha256": "c68e7baae8e9ebf228061b925af86d7932ee3dfe3f56eab0602a887d3ed8fa8f", + "violations": 0, + "incompleteNodes": 2, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-nb-dark-320-strategy-read-unavailable-audit-af80696d.json", + "sha256": "af80696d1ff9ff05489d77f869e4375b135ae89f13f5a9cd8ee029e324571968", + "violations": 0, + "incompleteNodes": 7, + "incompleteRules": [ + "color-contrast" + ] + }, + { + "path": ".artifacts/phase7/p7-11/shared-visuals/p706-forms-nb-dark-320-strategy-exact-not-generated-audit-ad14073a.json", + "sha256": "ad14073a4462a71d5aae93170f6d932bf42cec7241985c78905b181e3dc3e6ce", + "violations": 0, + "incompleteNodes": 7, + "incompleteRules": [ + "color-contrast" + ] + } + ], + "focusedUnit": { + "path": ".artifacts/phase7/p7-11/shared/green.json", + "sha256": "4cdfac6b887315b6734b5eb8e6b9d15fadc0d7279dd31f98193db03d895f2b96", + "numPassedTests": 80, + "numFailedTests": 0, + "numPendingTests": 0, + "numPassedTestSuites": 11, + "qualification": "Overlapping focused vectors; not a new full-suite total. Final integrated run covers subsequent documented corrections." + } +} diff --git a/docs/redesign/phase-7/p7-11/evidence/shared-static-checks.json b/docs/redesign/phase-7/p7-11/evidence/shared-static-checks.json new file mode 100644 index 0000000..c110b2b --- /dev/null +++ b/docs/redesign/phase-7/p7-11/evidence/shared-static-checks.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "checkpoint": "p7-11", + "status": "PASS", + "checkedUtc": "2026-10-20T18:26:12.103739+00:00", + "commands": [ + { + "name": "secret-scan", + "command": [ + "rtk", + "proxy", + "C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", + "-X", + "utf8", + "scripts/supply-chain.py", + "scan-secrets" + ], + "startedUtc": "2026-10-20T18:26:08.411065+00:00", + "finishedUtc": "2026-10-20T18:26:11.161244+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T182608Z/secret-scan.log", + "logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd" + }, + { + "name": "working-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check" + ], + "startedUtc": "2026-10-20T18:26:11.162273+00:00", + "finishedUtc": "2026-10-20T18:26:11.270209+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T182608Z/working-whitespace.log", + "logSha256Raw": "85634f1014a6f42ddd6b5982ccec8a9cc597157a051cf7fd824992355be51c5c" + }, + { + "name": "staged-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--cached", + "--check" + ], + "startedUtc": "2026-10-20T18:26:11.270721+00:00", + "finishedUtc": "2026-10-20T18:26:11.372430+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T182608Z/staged-whitespace.log", + "logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "phase7-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check", + "b5319981ad3d1bd635daff861453d23c0de5320b" + ], + "startedUtc": "2026-10-20T18:26:11.372430+00:00", + "finishedUtc": "2026-10-20T18:26:11.609452+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T182608Z/phase7-whitespace.log", + "logSha256Raw": "4d9b55a18a0e78345259dd2290d41d06f98d6ae3cec9e60a218744e90d41667a" + } + ], + "untrackedSecretScan": { + "files": 7, + "findings": 0 + }, + "documentation": { + "files": 38, + "relativeLinks": 642, + "errors": [] + }, + "preservation": { + "groups": 57, + "statuses": { + "PARTIAL": 56, + "PASS": 0, + "FAIL": 0, + "DEFERRED": 1 + }, + "originalRoutesPreserved": true, + "currentConcreteRoutes": 33 + }, + "production": { + "main": "f7dc22e5e7efe1fd1d64f6ace2c04cf2f95bf558", + "originMain": "f7dc22e5e7efe1fd1d64f6ace2c04cf2f95bf558", + "status": "NO-GO", + "qualification": "Local fetched refs; direct push observation recorded separately." + }, + "hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.", + "limits": [ + "Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.", + "Local links check target existence, not URL/heading anchors.", + "Static evidence is not runtime/manual/device/provider/production acceptance." + ] +} diff --git a/docs/roadmap/todo.md b/docs/roadmap/todo.md index e28987a..1df2625 100644 --- a/docs/roadmap/todo.md +++ b/docs/roadmap/todo.md @@ -93,7 +93,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m - [x] P7-07 [owner-scoped Documents index](../redesign/phase-7/p7-07/README.md): implemented CONDITIONAL GO, final hygiene PASS; UI5a67ec3 and evidence84350b7 published through9dfea12, directly verified21:01:11UTC. Finite86/8+native13/types/lint/build/18images/15axe0/101qualified;33routes/57statuses retained. MariaDB/human/device/production qualifications remain. - [x] P7-08 [existing notifications and operations](../redesign/phase-7/p7-08/README.md): bothpresentationboundariesimplementedCONDITIONALGO. UTC9dfea/recent40cfe04 published; Operations77/7+native12+types/lint/build/14Ops+1P2actualimages PASS;12Opsaxe0/3+14notifaxe0/102qualified. FinalOpshygiene/normalpushpending.791/95frontend323browser1054backend derivedonly;57groups33routes/mainunchanged/productionNO-GO. P7-09next. - [x] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): implemented CONDITIONAL GO. Sender/Preferences/import/company published; remaining data presentation47/6 affected frontend,22backendcontracts, final native23/types/lint/build PASS. 37axe0violations/141incomplete nodes qualified; six final PNGs inspected. Final hygiene/publication recorded in checkpoint ledger. Inventory855/99frontend,339browser,1057backend; no fresh whole-suite claim. P5-13 withheld, manual/provider/output/release qualifications retained. -- [x] P7-10 Users7ecc0d6/4760fd1 and Auditf9e1d06 published; System20frontend/46backend/3native plus types/lint/build PASS, final hygiene/publication pending. Implemented CONDITIONAL GO; shared/manual accessibility conditions remain. +- [x] P7-10 Users7ecc0d6/4760fd1, Auditf9e1d06 and Systemd824f59 published/directly verified; implemented CONDITIONAL GO. Shared/manual accessibility conditions remain. - [ ] P7-11 residual Analytics/error/empty/dialog integration. - [ ] P7-12 integrated migration acceptance and Phase 8 handoff. diff --git a/job-tracker-ui/e2e/phase7-application-forms.spec.ts b/job-tracker-ui/e2e/phase7-application-forms.spec.ts index 841c56c..b065c53 100644 --- a/job-tracker-ui/e2e/phase7-application-forms.spec.ts +++ b/job-tracker-ui/e2e/phase7-application-forms.spec.ts @@ -220,6 +220,34 @@ async function capture(page: Page, info: TestInfo, sample: Sample, state: string expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]); } +test('[P7-11 E08] Strategy failed reads remain distinct from exact not-generated state and retry only GET in both locales', async ({ page, session }, info) => { + const job = await createJob(page.request, session.owner, 'Recorded Strategy read recovery'); + let unavailable = true; + await page.route(`**/api/jobapplications/${job.id}/focus-plan`, route => { + expect(route.request().method()).toBe('GET'); + return route.fulfill(unavailable ? { status: 503, json: { title: 'Controlled unavailable Strategy read' } } : { status: 404, json: { code: 'strategy_not_generated' } }); + }); + for (const sample of [desktop, { language: 'nb', theme: 'dark', width: 320, height: 844 }] as Sample[]) { + unavailable = true; + await enter(page, session.owner, job, 'answers', sample); + await keyboard(page, page.getByRole('tablist', { name: translations[sample.language].workspaceSections, exact: true }).getByRole('tab', { name: translations[sample.language].workspaceMatch, exact: true })); + const t = translations[sample.language]; + const error = page.getByRole('alert').filter({ hasText: t.workspaceStrategyLoadFailed }); + await expect(error).toBeVisible(); + await expect(page.getByText(t.workspaceStrategyEmpty, { exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: t.workspaceStrategyProRequired, exact: true })).toBeDisabled(); + await capture(page, info, sample, 'strategy-read-unavailable'); + const before = count(session, 'GET', `/api/jobapplications/${job.id}/focus-plan`).length; + unavailable = false; + await keyboard(page, error.getByRole('button', { name: t.retry, exact: true })); + await expect(page.getByRole('heading', { name: t.workspaceStrategyTitle, level: 2, exact: true })).toBeFocused(); + await expect(page.getByText(t.workspaceStrategyEmpty, { exact: true })).toBeVisible(); + expect(count(session, 'GET', `/api/jobapplications/${job.id}/focus-plan`).length).toBe(before + 1); + await capture(page, info, sample, 'strategy-exact-not-generated'); + } + expect(mutations(session)).toEqual([]); +}); + test('[P7-06 forms] held CV read Retry preserves its request and actual attach/replace/detach remain owner scoped', async ({ page, session, playwright }, info) => { test.setTimeout(120_000); await source(page.request, session.owner); const job = await createJob(page.request, session.owner); const first = await variant(page.request, session.owner, 'First reusable CV'); const second = await variant(page.request, session.owner, 'Second reusable CV'); diff --git a/job-tracker-ui/src/app-shell-notifications.test.tsx b/job-tracker-ui/src/app-shell-notifications.test.tsx index 9e80078..1d0b1e4 100644 --- a/job-tracker-ui/src/app-shell-notifications.test.tsx +++ b/job-tracker-ui/src/app-shell-notifications.test.tsx @@ -44,7 +44,8 @@ test("notification bell exposes its unread count and keyboard-accessible action" expect(open.mock.calls[0][0]).toBeInstanceOf(HTMLElement); }); -test("shows build metadata only when the caller supplies the admin-only badge", () => { +test.each([["en", "Application version 2.4.1, commit abc1234"], ["nb", "Applikasjonsversjon 2.4.1, revisjon abc1234"]])("shows localized %s build metadata only when the caller supplies the admin-only badge", (language, label) => { + localStorage.setItem("uiLanguage", language); const commonProps = { pageTitle: "Dashboard", breadcrumbs: ["Home"], @@ -67,7 +68,8 @@ test("shows build metadata only when the caller supplies the admin-only badge", ); expect(screen.getByTestId("admin-version-badge")).toHaveTextContent("v2.4.1"); - expect(screen.getByLabelText("Application version 2.4.1, commit abc1234")).toBeInTheDocument(); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + expect(screen.getByRole("img", { name: label })).toBe(screen.getByTestId("admin-version-badge")); rerender( diff --git a/job-tracker-ui/src/application-checklist.test.tsx b/job-tracker-ui/src/application-checklist.test.tsx index 4c6cf29..33d7797 100644 --- a/job-tracker-ui/src/application-checklist.test.tsx +++ b/job-tracker-ui/src/application-checklist.test.tsx @@ -122,6 +122,20 @@ test("reordering sends the new id order", async () => { expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/checklist/order", [2, 1, 3])); }); +test("move and remove tooltips describe their wrappers without replacing item-specific button names", async () => { + mockedApi.patch.mockReturnValueOnce(new Promise(() => {})); + renderChecklist(); + const down = await screen.findByRole("button", { name: "Move down: Prepare a CV for this role" }); + for (const label of ["Move up: Prepare a CV for this role", "Move down: Prepare a CV for this role", "Remove: Prepare a CV for this role"]) { + expect(screen.getByRole("button", { name: label }).parentElement).not.toHaveAttribute("aria-label"); + } + fireEvent.click(screen.getByRole("checkbox", { name: "Prepare a CV for this role" })); + expect(down).toBeDisabled(); + fireEvent.mouseOver(down.parentElement!); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Move down"); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + test("removing an item calls delete", async () => { mockedApi.delete.mockResolvedValue({ data: undefined } as any); diff --git a/job-tracker-ui/src/application-workflow-assist.test.tsx b/job-tracker-ui/src/application-workflow-assist.test.tsx index 44a4c92..57458ff 100644 --- a/job-tracker-ui/src/application-workflow-assist.test.tsx +++ b/job-tracker-ui/src/application-workflow-assist.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { api } from './api'; import { AccountPlanProvider } from './accountPlan'; @@ -15,7 +15,7 @@ jest.mock('./api', () => ({ const mockedApi = api as jest.Mocked; -beforeEach(() => jest.clearAllMocks()); +beforeEach(() => { jest.clearAllMocks(); localStorage.clear(); }); test('recruiter status suggestions require an explicit apply action', async () => { const applied = jest.fn(); @@ -50,8 +50,56 @@ test('strategy snapshot shows saved output and queues regeneration only on reque render(); expect(await screen.findByText('Lead with delivery evidence.')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Strategy snapshot', level: 2 })).toBeVisible(); expect(mockedApi.post).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Regenerate' })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/focus-plan/operations', { attachmentIds: null })); expect(await screen.findByText('queued')).toBeInTheDocument(); }); + +function strategy(jobId = 42) { + return ; +} + +test('failed strategy reads stay distinct from empty, localize without refetching and retry only the read', async () => { + let finishOperation!: (result: any) => void; + mockedApi.get.mockImplementation((url: string) => url.endsWith('/operation') ? new Promise(resolve => { finishOperation = resolve; }) : Promise.reject({ response: { status: 503 } })); + render(strategy()); + expect(await screen.findByText('Strategy snapshot could not be loaded.')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled(); + await act(async () => finishOperation({ data: null })); + expect(screen.queryByText('No strategy snapshot yet. Generate one when you want AI-assisted planning.')).not.toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + const calls = mockedApi.get.mock.calls.length; + act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' }))); + expect(await screen.findByText('Kunne ikke hente strategisammendraget.')).toBeVisible(); + expect(mockedApi.get).toHaveBeenCalledTimes(calls); + mockedApi.get.mockResolvedValueOnce({ data: { strategicSummary: 'Saved plan', immediatePriorities: [], proofPointsToLeadWith: [], cvBulletIdeas: [], coverLetterAngles: [], followUpApproach: [] } } as any); + const retry = screen.getByRole('button', { name: 'Prøv igjen' }); + act(() => retry.focus()); fireEvent.click(retry); + expect(await screen.findByDisplayValue('Saved plan')).toBeVisible(); + expect(mockedApi.get.mock.calls.slice(calls).map(call => call[0])).toEqual(['/jobapplications/42/focus-plan']); + expect(screen.getByRole('heading', { name: 'Strategioversikt', level: 2 })).toHaveFocus(); + expect(mockedApi.post).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Krever Pro' })).toBeDisabled(); +}); + +test('only the explicit not-generated response is an intentional empty strategy', async () => { + mockedApi.get.mockRejectedValue({ response: { status: 404, data: { code: 'strategy_not_generated' } } }); + render(strategy()); + expect(await screen.findByText('No strategy snapshot yet. Generate one when you want AI-assisted planning.')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + +test('a late strategy response cannot replace another job and denial never offers read retry', async () => { + let finish!: (result: any) => void; + mockedApi.get.mockImplementation((url: string) => url === '/jobapplications/42/focus-plan' ? new Promise(resolve => { finish = resolve; }) : Promise.reject({ response: { status: 403 } })); + const view = render(strategy()); + view.rerender(strategy(43)); + expect(await screen.findByText('Strategy snapshot could not be loaded.')).toBeVisible(); + await act(async () => finish({ data: { strategicSummary: 'Do not show old owner plan', immediatePriorities: [], proofPointsToLeadWith: [], cvBulletIdeas: [], coverLetterAngles: [], followUpApproach: [] } })); + expect(screen.queryByDisplayValue('Do not show old owner plan')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index a4c6c96..96b1164 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -445,13 +445,13 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr {steps.map((label) => {label})} - + {t("addJobModalStepProgress", { current: activeStep + 1, total: steps.length, step: steps[activeStep] })} {activeStep === 0 ? - {t("addJobModalStartWithAdvert")} + {t("addJobModalStartWithAdvert")} {t("addJobModalStartWithAdvertBody")} @@ -609,7 +609,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr {activeStep >= 2 ? - {steps[activeStep]} + {steps[activeStep]} {activeStep === 2 ? t("addJobModalCvStepHelp") : null} {activeStep === 3 ? t("addJobModalCoverLetterStepHelp") : null} diff --git a/job-tracker-ui/src/components/ApplicationChecklist.tsx b/job-tracker-ui/src/components/ApplicationChecklist.tsx index f6526a1..c39a1b6 100644 --- a/job-tracker-ui/src/components/ApplicationChecklist.tsx +++ b/job-tracker-ui/src/components/ApplicationChecklist.tsx @@ -150,21 +150,21 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb )} - + move(item, -1)}> - + move(item, 1)}> - + remove(item)}> diff --git a/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx b/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx index 1abe9b3..c966430 100644 --- a/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx +++ b/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx @@ -62,19 +62,41 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { const [plan, setPlan] = useState(null); const [operation, setOperation] = useState(null); const [loading, setLoading] = useState(true); + const [readingPlan, setReadingPlan] = useState(false); + const [readError, setReadError] = useState<{ cause: unknown; retryable: boolean } | null>(null); const announced = useRef(null); + const owner = useRef({ mounted: false, jobId, generation: 0 }); + const readSequence = useRef(0); + const readPending = useRef(false); + const headingRef = useRef(null); const loadPlan = useCallback(async () => { + if (!owner.current.mounted || owner.current.jobId !== jobId || readPending.current) return; + const lifetime = owner.current.generation; + const request = ++readSequence.current; + const active = () => owner.current.mounted && owner.current.jobId === jobId && owner.current.generation === lifetime && request === readSequence.current; + readPending.current = true; + setReadingPlan(true); try { const { data } = await api.get(`/jobapplications/${jobId}/focus-plan`); + if (!active()) return; setPlan(data); - } catch { - setPlan(null); + setReadError(null); + } catch (cause) { + if (!active()) return; + const response = (cause as { response?: { status?: number; data?: { code?: string } } })?.response; + if (response?.status === 404 && response.data?.code === "strategy_not_generated") { setPlan(null); setReadError(null); } + else setReadError({ cause, retryable: !response?.status || response.status >= 500 }); + } finally { + if (active()) { readPending.current = false; setReadingPlan(false); } } }, [jobId]); useEffect(() => { let active = true; + owner.current = { mounted: true, jobId, generation: owner.current.generation + 1 }; + readPending.current = false; + setPlan(null); setReadError(null); setOperation(null); setLoading(true); Promise.all([ loadPlan(), @@ -82,17 +104,18 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { .then(({ data }) => { if (active) setOperation(data); }) .catch(() => { if (active) setOperation(null); }), ]).finally(() => { if (active) setLoading(false); }); - return () => { active = false; }; + return () => { active = false; owner.current.mounted = false; owner.current.generation += 1; readSequence.current += 1; readPending.current = false; }; }, [jobId, loadPlan]); useEffect(() => { if (!operation || terminal(operation.status)) return; + let active = true; const timer = window.setTimeout(() => { api.get(`/operations/${operation.id}`) - .then(({ data }) => setOperation(data)) + .then(({ data }) => { if (active) setOperation(data); }) .catch(() => undefined); }, 1000); - return () => window.clearTimeout(timer); + return () => { active = false; window.clearTimeout(timer); }; }, [operation]); useEffect(() => { @@ -133,17 +156,24 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { }; const working = !!operation && !terminal(operation.status); + const reading = loading || readingPlan; + const readErrorTitle = t("workspaceStrategyLoadFailed"); + const readErrorDetail = readError ? getApiErrorMessage(readError.cause, readErrorTitle) : ""; return ( - {t("workspaceStrategyTitle")} + {t("workspaceStrategyTitle")} {t("workspaceStrategySubtitle")} - + {readError ? { headingRef.current?.focus(); void loadPlan(); }}>{t("retry")} : undefined}> + {readErrorTitle} + {readErrorDetail !== readErrorTitle ? {readErrorDetail} : null} + : null} {operation && operation.status !== "succeeded" ? ( {operation.canCancel ? : null} @@ -152,14 +182,14 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) { {operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""} ) : null} - {loading && !plan ? : plan ? ( + {reading && !plan ? : plan ? ( - ) : {t("workspaceStrategyEmpty")}} + ) : !readError ? {t("workspaceStrategyEmpty")} : null} ); } diff --git a/job-tracker-ui/src/components/InterviewPrep.tsx b/job-tracker-ui/src/components/InterviewPrep.tsx index 3a53b8c..2d51401 100644 --- a/job-tracker-ui/src/components/InterviewPrep.tsx +++ b/job-tracker-ui/src/components/InterviewPrep.tsx @@ -381,7 +381,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: { )} - + searchInputRef.current?.focus() } }} fullWidth maxWidth="sm"> + searchInputRef.current?.focus() } }} fullWidth maxWidth="sm"> diff --git a/job-tracker-ui/src/file-picker-activation.test.tsx b/job-tracker-ui/src/file-picker-activation.test.tsx index b987de8..e4aa93d 100644 --- a/job-tracker-ui/src/file-picker-activation.test.tsx +++ b/job-tracker-ui/src/file-picker-activation.test.tsx @@ -61,6 +61,8 @@ test.each([0, 1, 2, 3])("Add Job attachment bucket %s retains keyboard selection await screen.findByDisplayValue("Supported role"); fireEvent.click(screen.getByRole("button", { name: "Continue" })); for (let step = 0; step < bucket; step += 1) fireEvent.click(screen.getByRole("button", { name: "Skip and continue" })); + const headings = screen.getAllByRole("heading").map(heading => heading.tagName); + expect(headings).toEqual(["H2", "H3"]); const input = document.querySelector('input[type="file"]')!; expect(input.multiple).toBe(true); expect(input.accept).toContain(".pdf,.doc,.docx,.txt,.md"); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 88f7442..140d013 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -5,6 +5,7 @@ export const translations = { appTitle: "Jobbjakt", appTagline: "Track your hunt", applicationLanguage: "Application language", + applicationVersionCommit: "Application version {version}, commit {commit}", dashboard: "Dashboard", jobApplications: "Job Applications", reminders: "Reminders", @@ -130,6 +131,61 @@ export const translations = { correspondenceInbox: "Job email", emailInboxSubtitle: "Review linked correspondence and suggested recruitment messages in one place.", emailInboxItems: "{count} items", + emailInboxItem: "{count} item", + emailInboxAttachmentCount: "{count} attachment", + historySourceManual: "Manual", + historySourceAi: "AI suggestion", + historySourceTemplate: "Template", + historySourceRestore: "Restored version", + historySourceAutosave: "Autosave", + historySourceCreate: "Created", + cvTemplateModernName: "Modern", + cvTemplateModernDescription: "Clean SaaS-style layout with a confident accent header.", + cvTemplateMinimalName: "Minimal", + cvTemplateMinimalDescription: "Quiet, spacious, typography-first. Nothing competes with the content.", + cvTemplateExecutiveName: "Executive", + cvTemplateExecutiveDescription: "High-contrast, serif, leadership-weighted. For senior and client-facing roles.", + cvTemplateTechnicalName: "Technical", + cvTemplateTechnicalDescription: "Dense two-column layout tuned for engineering CVs — skills and projects up front.", + cvTemplateAtsClassicName: "ATS Classic", + cvTemplateAtsClassicDescription: "Single column, no graphics, maximum parser compatibility. The safe default.", + cvTemplateNordicName: "Nordic", + cvTemplateNordicDescription: "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.", + cvTemplateElegantName: "Elegant", + cvTemplateElegantDescription: "Editorial serif headings over sans body, premium spacing and a plum accent.", + cvTemplateCreativeName: "Creative", + cvTemplateCreativeDescription: "Bold accent sidebar and photo-forward header for design and product roles.", + cvTemplateCodeName: "Code", + cvTemplateCodeDescription: "Developer-focused editorial layout with numbered sections and precise mono details.", + cvTemplateCategoryModern: "Modern Professional", + cvTemplateCategoryMinimal: "Minimal", + cvTemplateCategoryExecutive: "Executive", + cvTemplateCategoryTechnical: "Technical", + cvTemplateCategoryAts: "ATS Professional", + cvTemplateCategoryCreative: "Creative", + assetsTailoringHighlightTitle: "Skills to highlight", + assetsTailoringHighlightDetail: "The advert asks for these and your profile already has them — put them where they are seen first.", + assetsTailoringExperienceTitle: "Experience to prioritise", + assetsTailoringExperienceDetail: "Ordered by how much of the advert each role actually covers.", + assetsTailoringProjectsTitle: "Projects to emphasise", + assetsTailoringProjectsDetail: "These projects demonstrate what the advert is asking for.", + assetsTailoringEvidenceTitle: "Verified evidence to reuse", + assetsTailoringEvidenceDetail: "Facts you previously approved. Adapt the wording, but keep the claim accurate.", + assetsTailoringKeywordsTitle: "Keywords to include", + assetsTailoringKeywordsDetail: "Vocabulary from the advert. Use the ones that are honestly true of you — never pad.", + assetsTailoringGapsTitle: "Gaps to address", + assetsTailoringGapsDetail: "Asked for but not found in your profile. Add them if you have them; otherwise be ready to talk about them.", + reminderReasonInterview: "Interview stage reached but prep notes are still missing.", + reminderReasonCvAndAnswers: "Tailored CV and saved application answers still need work.", + reminderReasonCv: "Tailored CV missing for this role.", + reminderReasonAnswers: "Saved application answers still need work.", + reminderReasonUnscheduled: "No response yet and no follow-up is scheduled.", + reminderReasonDue: "Follow-up is due for this role.", + reminderReasonNoGaps: "No urgent workflow gaps are blocking this job right now.", + reminderReasonNoReplyDays: "No reply after {days}d", + reminderReasonStalledDays: "Stalled after {days}d", + reminderReasonFeedbackDays: "Feedback requested {days}d ago", + reminderReasonWaitingDays: "Waiting {days}d", emailInboxLinked: "{count} linked", emailInboxLinkedOnPage: "{count} linked on this page", emailInboxInboundCount: "{count} inbound", @@ -869,6 +925,8 @@ export const translations = { cvEditorShowPhoto: "Show profile photo", cvEditorContactIcons: "Contact icons", cvEditorOpenFailed: "Could not open this CV.", + cvEditorSourceUnavailable: "This CV is unavailable or you no longer have access to it.", + cvEditorPermissionDenied: "You do not have permission to open this CV.", cvEditorDiscardMessage: "This CV has unsaved changes. Leave and discard them?", cvEditorDiscardTitle: "Discard unsaved CV changes?", cvEditorDiscardLeave: "Discard and leave", @@ -2759,6 +2817,7 @@ export const translations = { workspaceStatusSuggestionApplied: "Application status updated from the latest message.", workspaceStatusSuggestionFailed: "Could not apply the suggested status.", workspaceStrategyTitle: "Strategy snapshot", + workspaceStrategyLoadFailed: "Strategy snapshot could not be loaded.", workspaceStrategySubtitle: "An on-demand plan grounded in this advert and your saved career data.", workspaceStrategyProRequired: "Pro required", workspaceStrategyGenerate: "Generate", @@ -2787,6 +2846,7 @@ export const translations = { appTitle: "Jobbjakt", appTagline: "Hold oversikt over jobbsøkingen", applicationLanguage: "Språk i appen", + applicationVersionCommit: "Applikasjonsversjon {version}, revisjon {commit}", dashboard: "Dashboard", jobApplications: "Jobbsøknader", reminders: "Påminnelser", @@ -2912,6 +2972,61 @@ export const translations = { correspondenceInbox: "Jobb-e-post", emailInboxSubtitle: "Se tilknyttet korrespondanse og foreslåtte rekrutteringsmeldinger på ett sted.", emailInboxItems: "{count} elementer", + emailInboxItem: "{count} element", + emailInboxAttachmentCount: "{count} vedlegg", + historySourceManual: "Manuell", + historySourceAi: "KI-forslag", + historySourceTemplate: "Mal", + historySourceRestore: "Gjenopprettet versjon", + historySourceAutosave: "Automatisk lagring", + historySourceCreate: "Opprettet", + cvTemplateModernName: "Moderne", + cvTemplateModernDescription: "Ren SaaS-inspirert utforming med en tydelig topptekst i aksentfarge.", + cvTemplateMinimalName: "Minimalistisk", + cvTemplateMinimalDescription: "Rolig og luftig, med typografien i sentrum. Ingenting tar oppmerksomheten fra innholdet.", + cvTemplateExecutiveName: "Leder", + cvTemplateExecutiveDescription: "Høy kontrast og serifftype med vekt på ledelse. For seniorroller og kundevendte roller.", + cvTemplateTechnicalName: "Teknisk", + cvTemplateTechnicalDescription: "Kompakt tokolonneutforming for tekniske CV-er, med ferdigheter og prosjekter først.", + cvTemplateAtsClassicName: "ATS klassisk", + cvTemplateAtsClassicDescription: "Én kolonne uten grafikk, med best mulig støtte for CV-tolkere. Et trygt standardvalg.", + cvTemplateNordicName: "Nordisk", + cvTemplateNordicDescription: "Rolig sidekolonne i kjølig blått, god luft og skandinavisk enkelhet.", + cvTemplateElegantName: "Elegant", + cvTemplateElegantDescription: "Redaksjonelle serifoverskrifter over brødtekst uten seriffer, god luft og en plommefarget aksent.", + cvTemplateCreativeName: "Kreativ", + cvTemplateCreativeDescription: "Tydelig sidekolonne i aksentfarge og topptekst med vekt på bilde, for design- og produktroller.", + cvTemplateCodeName: "Kode", + cvTemplateCodeDescription: "Redaksjonell utforming for utviklere med nummererte seksjoner og presise detaljer i fastbreddeskrift.", + cvTemplateCategoryModern: "Moderne og profesjonell", + cvTemplateCategoryMinimal: "Minimalistisk", + cvTemplateCategoryExecutive: "Leder", + cvTemplateCategoryTechnical: "Teknisk", + cvTemplateCategoryAts: "ATS-profesjonell", + cvTemplateCategoryCreative: "Kreativ", + assetsTailoringHighlightTitle: "Ferdigheter å fremheve", + assetsTailoringHighlightDetail: "Annonsen etterspør disse, og de finnes allerede i profilen din. Plasser dem der de blir sett først.", + assetsTailoringExperienceTitle: "Erfaring å prioritere", + assetsTailoringExperienceDetail: "Sortert etter hvor mye av annonsen hver rolle faktisk dekker.", + assetsTailoringProjectsTitle: "Prosjekter å fremheve", + assetsTailoringProjectsDetail: "Disse prosjektene viser det annonsen etterspør.", + assetsTailoringEvidenceTitle: "Bekreftet dokumentasjon å gjenbruke", + assetsTailoringEvidenceDetail: "Fakta du tidligere har godkjent. Tilpass ordlyden, men behold en korrekt påstand.", + assetsTailoringKeywordsTitle: "Nøkkelord å ta med", + assetsTailoringKeywordsDetail: "Ord fra annonsen. Bruk dem som faktisk stemmer for deg, uten å pynte på innholdet.", + assetsTailoringGapsTitle: "Mangler å ta tak i", + assetsTailoringGapsDetail: "Etterspurt, men ikke funnet i profilen din. Legg dem til hvis du har dem, ellers bør du være forberedt på å snakke om dem.", + reminderReasonInterview: "Du har kommet til intervju, men mangler fortsatt forberedelsesnotater.", + reminderReasonCvAndAnswers: "Tilpasset CV og lagrede søknadssvar trenger fortsatt arbeid.", + reminderReasonCv: "Tilpasset CV mangler for denne rollen.", + reminderReasonAnswers: "Lagrede søknadssvar trenger fortsatt arbeid.", + reminderReasonUnscheduled: "Ingen respons ennå, og ingen oppfølging er planlagt.", + reminderReasonDue: "Det er tid for oppfølging av denne rollen.", + reminderReasonNoGaps: "Ingen akutte mangler i arbeidsflyten blokkerer denne jobben nå.", + reminderReasonNoReplyDays: "Ingen svar etter {days} dager", + reminderReasonStalledDays: "Stoppet opp etter {days} dager", + reminderReasonFeedbackDays: "Tilbakemelding ble forespurt for {days} dager siden", + reminderReasonWaitingDays: "Ventet i {days} dager", emailInboxLinked: "{count} tilknyttet", emailInboxLinkedOnPage: "{count} tilknyttet på denne siden", emailInboxInboundCount: "{count} innkommende", @@ -3651,6 +3766,8 @@ export const translations = { cvEditorShowPhoto: "Vis profilbilde", cvEditorContactIcons: "Kontaktikoner", cvEditorOpenFailed: "Kunne ikke åpne denne CV-en.", + cvEditorSourceUnavailable: "Denne CV-en er utilgjengelig, eller du har ikke lenger tilgang til den.", + cvEditorPermissionDenied: "Du har ikke tilgang til å åpne denne CV-en.", cvEditorDiscardMessage: "Denne CV-en har ulagrede endringer. Vil du forlate siden og forkaste dem?", cvEditorDiscardTitle: "Forkaste ulagrede CV-endringer?", cvEditorDiscardLeave: "Forkast og forlat", @@ -5541,6 +5658,7 @@ export const translations = { workspaceStatusSuggestionApplied: "Søknadsstatusen ble oppdatert fra den siste meldingen.", workspaceStatusSuggestionFailed: "Kunne ikke bruke den foreslåtte statusen.", workspaceStrategyTitle: "Strategioversikt", + workspaceStrategyLoadFailed: "Kunne ikke hente strategisammendraget.", workspaceStrategySubtitle: "En behovsstyrt plan basert på denne annonsen og dine lagrede karrieredata.", workspaceStrategyProRequired: "Krever Pro", workspaceStrategyGenerate: "Generer", diff --git a/job-tracker-ui/src/interview-prep.test.tsx b/job-tracker-ui/src/interview-prep.test.tsx index 19f43ef..0871a34 100644 --- a/job-tracker-ui/src/interview-prep.test.tsx +++ b/job-tracker-ui/src/interview-prep.test.tsx @@ -86,6 +86,19 @@ test("prep renders grouped items with progress and marks AI-sourced ones", async expect(screen.getByText("From AI")).toBeInTheDocument(); }); +test("delete tooltip describes its wrapper while the disabled button keeps its item name", async () => { + routeGet(); + mockedApi.patch.mockReturnValueOnce(new Promise(() => {})); + render(); + const remove = await screen.findByRole("button", { name: "Delete: Funding history" }); + expect(remove.parentElement).not.toHaveAttribute("aria-label"); + fireEvent.click(screen.getByRole("checkbox", { name: "Ready: Funding history" })); + expect(remove).toBeDisabled(); + fireEvent.mouseOver(remove.parentElement!); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Delete"); + expect(mockedApi.delete).not.toHaveBeenCalled(); +}); + test("adding a prep item posts the chosen category and title", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any); diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index 44a5f56..5a0e908 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -340,10 +340,11 @@ export default function AppShell({ const buildVersion = buildMetadata?.version.trim(); const buildCommit = buildMetadata?.commitSha?.trim(); const buildBadge = buildVersion ? ( - + { renderModal(); expect(await screen.findByText('Start med jobbannonsen')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Start med jobbannonsen', level: 3 })).toBeVisible(); expect(screen.getByRole('button', { name: 'Skriv inn detaljer manuelt' })).toBeInTheDocument(); expect(screen.getByText('Trinn 1 av 6: Legg til jobb')).toBeInTheDocument(); }); diff --git a/job-tracker-ui/src/quick-command-preservation.test.tsx b/job-tracker-ui/src/quick-command-preservation.test.tsx index f9a6fa3..bdebafa 100644 --- a/job-tracker-ui/src/quick-command-preservation.test.tsx +++ b/job-tracker-ui/src/quick-command-preservation.test.tsx @@ -24,6 +24,8 @@ test.each([ const onClose = jest.fn(); render(); expect(screen.getByRole("dialog", { name: "Quick Search" })).toBeVisible(); + const labelIds = screen.getByRole("dialog", { name: "Quick Search" }).getAttribute("aria-labelledby"); + expect(!labelIds || labelIds.split(/\s+/).every(id => document.getElementById(id))).toBe(true); fireEvent.change(screen.getByRole("textbox", { name: "Quick Search" }), { target: { value: "Nordlys" } }); fireEvent.click(await screen.findByText(label, { exact: true })); expect(onNavigate).toHaveBeenCalledWith(destination);