diff --git a/docs/redesign/phase-7/COMMITS.md b/docs/redesign/phase-7/COMMITS.md index 72cb390..3ebcc23 100644 --- a/docs/redesign/phase-7/COMMITS.md +++ b/docs/redesign/phase-7/COMMITS.md @@ -1,6 +1,8 @@ # Phase 7 — Commit and evidence ledger -Latest pending publication: [P7-09 separate OAuth sender repair](p7-09/OAUTH_SENDER.md), final39/3 frontend, native3+5+ordinaryP2-D01=9, four type/lint/build gates PASS. [Finite results](p7-09/evidence/oauth-results.json) retain both meaningful RED series, failed fixture attempts,23 held inputs, seven actual final image reviews and89 consumer axe incompletes. Bounded repair CONDITIONAL GO; final staged hygiene/commit/push pending. +16 frontend/0 suites and+3 browser derive807/95 and326; backend1054 unchanged, no integrated run. Actual commit/remote receipt follows observation. Both Settings presentation boundaries remain pending. +Current pending publication: [P7-09 Preferences/Connections](p7-09/PREFERENCES.md), final91/6 frontend, unchanged-backend52, native5+OAuth3+Rules/Settings8+ordinaryP2-D01=17 and type/lint/build PASS onInBPthl1fsnID3NNdgwbI. All18 new PNGs reviewed;54 scans0/284 remain qualified. Source34/earlier31/32 scopes and every RED/failed fixture attempt are [recorded](p7-09/evidence/preferences-results.json). Final staged hygiene/normal commit/push pending; +17/0 derives824/95 and+5 browser331, backend1054 unchanged. Data/privacy/plan/backup and its separate import prerequisite remain pending; main unchanged/productionNO-GO. + +Latest published repair: [P7-09 OAuth sender](p7-09/OAUTH_SENDER.md), **dac29278db3c187349b97771ca2f383c9e6e0962**, clean normal push directly verified2026-10-17T08:27:48.643285UTC, HEAD=origin=live/main27657f3 unchanged. Final39/3 frontend, native3+5+ordinaryP2-D01=9, four type/lint/build gates and staged hygiene PASS. [Finite results](p7-09/evidence/oauth-results.json) retain both meaningful RED series, failed fixture attempts,23 held inputs, seven actual final image reviews and89 consumer axe incompletes. Bounded repair CONDITIONAL GO; +16 frontend/0 suites and+3 browser derive807/95 and326; backend1054 unchanged, no integrated run. Both Settings presentation boundaries remain pending. Policy: commit each completed checkpoint or coherent repair and push normally to `codex/ui-redesign-phase4`, with direct remote=HEAD verification. Latest completed P7-08 publication is `c5176e3251c869e13284b994b59b2f7aa9220eb1`, directly verified clean at 2026-10-17T02:03:53.815275 UTC. Earlier Git connection failures are historical and superseded by the recorded successful receipts. Production main remains `27657f31131cd95d42f0e1290853ec9c9b3959a4`; no force push, main push or automatic deployment. diff --git a/docs/redesign/phase-7/DEBT.md b/docs/redesign/phase-7/DEBT.md index dace8bc..fbbac83 100644 --- a/docs/redesign/phase-7/DEBT.md +++ b/docs/redesign/phase-7/DEBT.md @@ -2,6 +2,8 @@ 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. + P7-07's [final local UI evidence](p7-07/evidence/ui-results.json) adds no whole-group or production acceptance. All RD01–RD28 dispositions remain. Its 15 axe scans retain 101 incomplete nodes: 15 short notification badges, 85 overlapped form backgrounds and one partially obscured Dashboard label inside the navigation scroll container. These require the existing manual contrast/state qualifications; zero automated violations does not close them. Two bounded P7-11 residuals are recorded from actual images: a long English action wraps its final letter at 1024px, and the unchanged CV missing-source owner displays raw `Not Found` error text. The Documents attachment-error layout was corrected and reverified. Historical Git connection failures were superseded by successful normal pushes, most recently P7-08 `c5176e3`, directly verified clean at 2026-10-17T02:03:53.815275 UTC. Actual MariaDB, human/device/provider/output and production gates remain open. diff --git a/docs/redesign/phase-7/PRESERVATION.md b/docs/redesign/phase-7/PRESERVATION.md index 49e336f..09c9d6a 100644 --- a/docs/redesign/phase-7/PRESERVATION.md +++ b/docs/redesign/phase-7/PRESERVATION.md @@ -2,6 +2,8 @@ P7-09's [separate sender repair](p7-09/evidence/oauth-results.json) preserves M01/M02/M06/E09 and all57 statuses:39 affected caller cases,3 new local-sender journeys,5 existing Application journeys and ordinary P2-D01 pass. No real provider grant, new permission, route, data or document-output acceptance is claimed. Preferences and data lifecycle remain pending. +The first [Preferences/Connections boundary](p7-09/evidence/preferences-results.json) now preserves S03/S04/S05/M06/E09:91 frontend,52 unchanged-backend,17 native including Rules/Settings/OAuth/ordinaryP2-D01 PASS; all18 new PNGs reviewed. Section save scopes, six Rules fields, canonical ownership and server admission remain intact. Manual/whole-group qualifications and all57 statuses/33routes stay unchanged. Data lifecycle and final publication remain pending. + Latest completed P7-08 reconciliation: [Operations results](p7-08/evidence/operations-results.json) retain all 57 statuses below, with 56 PARTIAL and C11 DEFERRED. The original 32 routes remain alongside the approved `/documents` route (33 current patterns). Fresh affected77/7 and native12 pass, including ordinary P2-D01. Human/device/output and whole-group qualifications remain. P7-08 publication `c5176e3251c869e13284b994b59b2f7aa9220eb1` was directly verified clean at 2026-10-17T02:03:53.815275 UTC; older connection failures remain historical evidence. All **57 original protected groups** remain in scope. This initial matrix derives the exact IDs/statuses and original evidence/limitations from [Phase6 preservation](../phase-6/p6-05/PRESERVATION.md), reconciled against its [aggregate](../phase-6/p6-05/evidence/validation.json), [original functionality ledger](../audit/FUNCTIONALITY_INVENTORY.md), [Phase2 anchors](../phase-2/PRESERVATION_MATRIX.md) and [Phase3 impacts](../phase-3/PRESERVATION_IMPACT_MATRIX.md). The short rows never replace protected sub-capabilities. diff --git a/docs/redesign/phase-7/README.md b/docs/redesign/phase-7/README.md index d5a72fa..b122d47 100644 --- a/docs/redesign/phase-7/README.md +++ b/docs/redesign/phase-7/README.md @@ -18,7 +18,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede | P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided | | [P7-07](p7-07/README.md) | Documents owner-scoped index | Implemented CONDITIONAL GO; contract/UI published and directly verified through9dfea12 at21:01:11UTC. Final affected86/8, native8+CV4+ordinaryP2-D01/types/lint/build PASS;18 reviewed PNGs,15axe0/101 qualified. Original32+Documents=33routes; actual MariaDB/human/device/production qualifications remain | | [P7-08](p7-08/README.md) | Existing notifications and operations | Implemented CONDITIONAL GO and published: UTC9dfea/recent40cfe04/Operationsc5176e3, clean direct refs verified02:03:53UTC. Ops77/7+native12/types/lint/build/14Ops+1P2image reviews PASS;12Opsaxe0/3+14notifaxe0/102 qualified.57groups33routes/F20 preserved | -| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Separate sender repair final39/3+native9+types/lint/build PASS; final publication pending. Preferences/Connections then data/privacy/plan/backup follow. Existing Rules/save owners and all withholding/manual/release qualifications retained | +| [P7-09](p7-09/README.md) | Remaining Settings, Connections and data lifecycle | Sender repair dac2927 published. Preferences/Connections91/6+backend52+native17+gates PASS; all18 new PNGs reviewed; final hygiene/publication pending. Data/privacy/plan/backup follows with separate import compatibility prerequisite. Rules/save owners and all qualifications retained | | P7-10 | Admin operational presentation | Existing distinct Users/Audit/System routes and server authorization; planned | | P7-11 | Remaining Analytics/error/empty/dialog integration | Inventory-confirmed residual presentation only; existing reports remain; planned | | P7-12 | Integrated full-migration acceptance | Every executed slice pushed with finite evidence; fresh applicable full regression and all debt dispositions; planned | diff --git a/docs/redesign/phase-7/p7-09/OAUTH_SENDER.md b/docs/redesign/phase-7/p7-09/OAUTH_SENDER.md index 3112068..61bad2f 100644 --- a/docs/redesign/phase-7/p7-09/OAUTH_SENDER.md +++ b/docs/redesign/phase-7/p7-09/OAUTH_SENDER.md @@ -39,3 +39,5 @@ Seven of76 retained final PNGs were actually displayed and reviewed, covering Co The **16 new ordinary frontend cases / zero suites** derive **807/95**; three new browser cases derive **326**; backend stays **1054**. These are inventories, not fresh integrated passes. All57 groups stay56 PARTIAL/C11 DEFERRED and33 routes remain. Final [staged hygiene](evidence/oauth-static-checks.json) **PASS** at08:27:17 UTC: secret and all whitespace gates,25 docs/583 links,57 groups/33 routes. Normal commit/publication follows this gate. Bounded repair: **CONDITIONAL GO**; both P7-09 presentation boundaries remain pending. Actual OAuth grants, provider interoperability and production qualification remain open. Local callback HTML and connect URLs are explicitly controlled client-guard fixtures, and canonical server status remains disconnected. No mailbox, inference, send or grant is contacted. P5-13/worker/model withholding and production **NO-GO** remain unchanged. + +Normal publication **`dac29278db3c187349b97771ca2f383c9e6e0962`** is directly verified at **2026-10-17T08:27:48.643285 UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. This observed receipt supersedes the preceding pending-publication observation. [Preferences and Connections](PREFERENCES.md) now proceeds; no production or Phase8 authorization follows. diff --git a/docs/redesign/phase-7/p7-09/PREFERENCES.md b/docs/redesign/phase-7/p7-09/PREFERENCES.md new file mode 100644 index 0000000..93194b7 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/PREFERENCES.md @@ -0,0 +1,49 @@ +# P7-09 — Preferences and Connections + +First presentation boundary from the [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md). The separate [sender repair](OAUTH_SENDER.md) is normally pushed as **`dac29278db3c187349b97771ca2f383c9e6e0962`**. Direct refs at **2026-10-17T08:27:48.643285 UTC** prove clean HEAD=origin=live development and unchanged local/origin/live main27657f3. Exact receipt: `.artifacts/phase7/p7-09/oauth-push-receipt.json`. No publication blocker remains for that repair. + +## Baseline and bounded scope + +The published entry has final **39/3 frontend**, sender3/Application5/ordinaryP2-D01=9 native and four type/lint/build gates passing on `j_uwah2_BImymFemE3cLg`. These executed entry results remain separate from new presentation acceptance. Existing Settings14, Connections12 and correspondence13 are retained. Derived inventory at entry is807/95 frontend,326 browser,1054 backend; the latest whole-suite counts remain inherited untilP7-12. + +| Owner | Confirmed friction | Required bounded result | +|---|---|---| +| Settings notification preference | Locale-dependent GET replaces unsaved edits; failed initial read lacks recovery; duplicate saves and late acknowledgements can overwrite newer choices | Stable single-flight read; explicit GET-only Retry; independent draft/last-acknowledged baseline, dirty/Discard and submitted-snapshot save acknowledgement; current-language feedback and disposal | +| Parent Rules session | Existing six-field draft/validation/snapshot/discard owner is accepted | Retain all fields,1–365 validation, internal-tab retention, section save scope and current routes/panel associations | +| Mailbox providers | Failed/unfinished status resembles disconnection; repeated actions and failed post-write refresh can mislead; IMAP fields remain editable during submission | Independent truthful loading/error/known status, GET-only Retry and no mutation replay; scoped action exclusion and captured IMAP payload; sender correlation unchanged | +| Connected accounts presentation | Legacy rounded wrapper, heavy weights and duplicated header style | Existing PageSection, appropriate heading hierarchy, wrapping and shared spacing; no new information architecture | + +User/local appearance/table preferences, server language, notification email delivery and Rules remain independent. No save-all, new revision protocol, shared settings store, provider feature or backend/schema change. The existing theme/router/MUI/toast/session ownership stays authoritative. Failed real writes and RD11/RD12/RD13 remain visible and separately owned; a successful mocked or later real response does not diagnose their historical root cause. + +## Evidence and acceptance + +Meaningful ordinary REDs are retained: Settings **14 PASS / 7 FAIL** at08:31:47–08:31:58 UTC, before its product changes; Connections **12 PASS / 9 FAIL**, before its product changes. Failures demonstrate draft replacement, duplicate writes, stale feedback/disposal, missing recovery and incorrect provider/port states. Final GREEN and combined consumers, serialized production build/browser journeys, finite width/theme/language screenshots, all axe incomplete contexts, preservation reconciliation and staged hygiene are **PENDING**. Existing sender tests stay strict. + +Candidate Settings **21/1 PASS** and Connections **22/1 PASS** retain every existing case. Connections adds a separate meaningful removed-IMAP-field focus regression (**21 PASS / 1 FAIL**, then22 PASS). The new boolean preference owner acknowledges only its submitted snapshot; provider failures retain an unknown or refresh-required state and Retry performs GET only. IMAP no longer coerces an empty/zero port to993; it validates the existing server range1–65535 before submitting the captured credentials. No protocol or grant semantics change. + +The first combined **91/6 PASS** was followed by a failed lint gate (two cleanup-ref warnings). Capturing the stable ref objects in effect setup corrected those warnings without suppression. The next91/6 PASS/type gate exposed26 unsupported Testing Library `ByRole` `exact:true` options; only those options were removed, preserving literal accessible names, default exact matching and every assertion. No build/native ran in either failed batch. Raw capsules `.artifacts/phase7/p7-09/preferences-lint-attempt1/` and `preferences-types-attempt1/` retain the actual failures. Final combined **91/6 PASS**,08:37:32–08:37:59 UTC, and fresh app/browser TypeScript/lint PASS now precede browser validation. No failed gate is represented as successful acceptance. + +Unchanged-binary backend **52/52 PASS**,08:30:10–08:30:13 UTC: ExternalOrigin14, Gmail19, Microsoft7, IMAP9 and NotificationSettings3. The command's additional `RulesControllerTests` filter matches no cases and supplies no Rules coverage; existing/native Rules evidence remains separate. Exact raw command/TRX/log is `.artifacts/phase7/current/backend-career/p709-preferences-contracts-20261017T083010Z/`. This is not a new backend test, Release build or whole-suite run. No real grant, IMAP connection, send, model call, billing, deletion, restore or production action belongs to this boundary. + +PRES-S03/S04/S05/M06/E09 and all57 groups remain binding; no whole-group promotion. Human reader/touch/software keyboard/real zoom and production font/photo/output requirements remain open. **Presentation acceptance: PENDING.** Data/privacy/plan/backup follows this boundary; Phase8 implementation and deployment remain prohibited. + +## Final first-boundary result + +Implemented Preferences/Connections: **CONDITIONAL GO**, pending final [staged hygiene](evidence/preferences-static-checks.json) and normal publication. [Portable results](evidence/preferences-results.json) identify final91/6, backend52/52, all17 native cases, frozen source/export, peer review and every failed attempt. No skip, expected failure, assertion relaxation or flaky outcome is used. + +| Executed evidence | Actual result | +|---|---| +| Combined frontend | **91/6 PASS**,08:37:32–08:37:59 UTC; Settings21, Connections22, correspondence13, i18n6, onboarding23, notification shell6 | +| Build and types | App TypeScript08:37:55–08:38:07 and production build08:38:13–08:38:28 PASS, export **`InBPthl1fsnID3NNdgwbI`**. Final native-only fixture edits get fresh lint08:45:29–08:46:00 and browser TypeScript08:46:00–08:46:07 PASS; no fictional rebuild | +| New browser journeys | **5/5 PASS**,08:41:11–08:42:04: actual persistence, separate saves, submitted snapshot/newer draft, owner/disposal, controlled read/write recovery and seven paired width/theme/language samples | +| Existing consumers | OAuth **3/3 PASS**,08:42:04–08:42:22; Rules4+Settings4 **8/8 PASS**,08:46:07–08:47:56; ordinary **P2-D01 1/1 PASS**,08:47:56–08:48:10 | +| Source preservation | Final34 inputs held08:45:29–08:48:37. Earlier build/unit31 inputs and primary/OAuth32 inputs match, with only the three separately recorded native fixture changes. Both backend DLLs unchanged | +| Visual and accessibility | All18 new PNGs actually displayed/reviewed: parent0–8, independent peer9–17. New18 scans0 violations/62 incompletes; existing36 scans0/222 retained separately | + +The first browser attempt2/5 failed at an early mobile/desktop selector branch before the lazy panel appeared; waiting for either visible control corrects that fixture precondition. The first existing consumer run3/8 failed at a global status locator that also selected P7-08's unread announcement and four obsolete Skeleton assertions. Their replacements scope the exact Rules status and assert the actual named busy notification group/progress indicator. Held reads, exact HTTP/status/persistence/export and downstream assertions remain unchanged. All raw failures remain retained. + +Every incomplete category is recorded: new43/consumer174 overlapped backgrounds, new18/consumer36 short badges, new1/consumer12 closing MUI-menu focus diagnostics. Full contexts remain raw. These are indeterminate contrast or pending settled keyboard/AT checks, not waived findings or human acceptance. No new rule is disabled. Consumer PNGs are retained without claiming all were visually reviewed. + +**17 new frontend cases / zero suites** derive **824/95**; **five new browser cases** derive **331**; backend remains1054. These remain inventories, not full-suite execution; inherited whole573/89,1033 backend and49 sidecar awaitP7-12. All57 groups remain56 PARTIAL/C11 DEFERRED, with33 routes; RD11/RD12/RD13/RD21 and manual/provider/output/release debt remain. The next data lifecycle boundary first needs the separately reviewable PascalCase-export/camelCase-import compatibility repair; it is not implemented here. + +Final staged hygiene **PASS**,2026-10-17T08:50:12 UTC: secret and working/staged/phase whitespace gates,26 docs/593 local links,57 groups/33 routes. All33 tracked staged inputs match the final canonical-LF snapshots; the sole ignored input is the recorded local runner. Normal commit/push follows; the actual hash/remote receipt is recorded after observation. Data lifecycle and wholeP7-09 remain pending. diff --git a/docs/redesign/phase-7/p7-09/README.md b/docs/redesign/phase-7/p7-09/README.md index 185fbdd..c6bcae7 100644 --- a/docs/redesign/phase-7/p7-09/README.md +++ b/docs/redesign/phase-7/p7-09/README.md @@ -29,6 +29,6 @@ Notification reads depend on locale callbacks and can replace an unsaved choice; All **57 groups remain 56 PARTIAL / C11 DEFERRED**, with **33 current routes**. RD11 language, RD12 notification and RD13 Rules failures remain independently diagnosed; successful reruns do not close them. F20/RD21, other reliability/scaling debt, human screen-reader, physical touch/software keyboard, real zoom and production fonts/photos/print remain qualified. P5-13 is withheld; followMode is deferred. Production remains **NO-GO**, main is unchanged and Phase 8 implementation has not started. -The [separate sender repair](OAUTH_SENDER.md) now has final39/3, four type/lint/build gates and nine ordinary browser cases passing, including P2-D01. [Finite results](evidence/oauth-results.json) retain the actual REDs,39/3 final source, three new client-guard cases and all qualifications. Derived inventories become807/95 frontend and326 browser; backend1054 unchanged. Sender repair final hygiene/publication remain pending; preferences/Connections then data/privacy/plan/backup are still the two approved presentation boundaries. +The [separate sender repair](OAUTH_SENDER.md) is published asdac2927, directly verified clean08:27:48 UTC; its39/3, native9, type/lint/build/hygiene and all qualifications remain recorded. The first [Preferences/Connections boundary](PREFERENCES.md) now has final91/6, backend52, native17 and gates PASS, all18 new PNGs reviewed and qualified54 axe scans0/284. [Finite results](evidence/preferences-results.json) retain precise source/run scopes and failures. Derived inventories become824/95 frontend and331 browser; backend1054 unchanged. First-boundary final hygiene/publication remain pending; data/privacy/plan/backup follows, beginning with the separately bounded import compatibility repair. Neither the second boundary nor allP7-09 is accepted yet. **P7-09 acceptance: PENDING.** Each repair/presentation boundary needs meaningful regression, applicable native/visual evidence, preservation reconciliation, hygiene, commit and normal push/direct verification. diff --git a/docs/redesign/phase-7/p7-09/evidence/oauth-results.json b/docs/redesign/phase-7/p7-09/evidence/oauth-results.json index 9405fbf..6e242d9 100644 --- a/docs/redesign/phase-7/p7-09/evidence/oauth-results.json +++ b/docs/redesign/phase-7/p7-09/evidence/oauth-results.json @@ -3,7 +3,7 @@ "checkpoint": "P7-09", "slice": "Independent OAuth sender repair", "recordedUtc": "2026-10-17T08:26:39.793290+00:00", - "decision": "Bounded client repair CONDITIONAL GO; final staged hygiene PASS, normal publication pending", + "decision": "Bounded client repair CONDITIONAL GO; staged hygiene PASS, normally pushed and directly verified", "entry": { "path": "docs/redesign/phase-7/p7-09/evidence/entry.json", "bytes": 6161, @@ -459,5 +459,34 @@ "path": "docs/redesign/phase-7/p7-09/evidence/oauth-static-checks.json", "sha256": "2efe01046430d1b9522ec348b0dfb1462d16589dff137ef7068f4b5b6609017e", "checkedUtc": "2026-10-17T08:27:18.310998+00:00" + }, + "publication": { + "command": [ + "rtk", + "proxy", + "git", + "push", + "origin", + "HEAD:refs/heads/codex/ui-redesign-phase4" + ], + "startedUtc": "2026-10-17T08:27:46.777675+00:00", + "finishedUtc": "2026-10-17T08:27:48.414087+00:00", + "exitCode": 0, + "head": "dac29278db3c187349b97771ca2f383c9e6e0962", + "log": ".artifacts/phase7/p7-09/oauth-push-receipt.log", + "logSha256": "68af881724e54d7bfb8f98c4c3e8b7b53b7ac72ab0bfb4b269d65ba2e2e053d7", + "observedUtc": "2026-10-17T08:27:48.643285+00:00", + "originDevelopment": "dac29278db3c187349b97771ca2f383c9e6e0962", + "liveDevelopment": "dac29278db3c187349b97771ca2f383c9e6e0962", + "main": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "pendingPaths": [], + "workingTreeClean": true, + "raw": { + "path": ".artifacts/phase7/p7-09/oauth-push-receipt.json", + "sha256": "d997d9128b720a690f456e5fe094a7af5bd59efce4b0415430c4b01804ffff68", + "bytes": 856 + } } } diff --git a/docs/redesign/phase-7/p7-09/evidence/preferences-results.json b/docs/redesign/phase-7/p7-09/evidence/preferences-results.json new file mode 100644 index 0000000..6c55876 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/preferences-results.json @@ -0,0 +1,2347 @@ +{ + "schemaVersion": 1, + "checkpoint": "P7-09", + "slice": "Preferences and Connections presentation", + "recordedUtc": "2026-10-17T08:48:39.039670+00:00", + "decision": "Implemented first presentation boundary CONDITIONAL GO; final staged hygiene PASS, normal publication pending; data lifecycle remains pending", + "precedingPublication": { + "command": [ + "rtk", + "proxy", + "git", + "push", + "origin", + "HEAD:refs/heads/codex/ui-redesign-phase4" + ], + "startedUtc": "2026-10-17T08:27:46.777675+00:00", + "finishedUtc": "2026-10-17T08:27:48.414087+00:00", + "exitCode": 0, + "head": "dac29278db3c187349b97771ca2f383c9e6e0962", + "log": ".artifacts/phase7/p7-09/oauth-push-receipt.log", + "logSha256": "68af881724e54d7bfb8f98c4c3e8b7b53b7ac72ab0bfb4b269d65ba2e2e053d7", + "observedUtc": "2026-10-17T08:27:48.643285+00:00", + "originDevelopment": "dac29278db3c187349b97771ca2f383c9e6e0962", + "liveDevelopment": "dac29278db3c187349b97771ca2f383c9e6e0962", + "main": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "pendingPaths": [], + "workingTreeClean": true + }, + "capsules": [ + { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/results.json", + "bytes": 95033, + "sha256": "26538f4adad91ba9d14078c590c7b3a5cac07d798383f97d0d643e01b746221f", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/results.json", + "bytes": 145792, + "sha256": "0af4dad1bd5f38d86aa25c46ec3c67181652f84dc917fbf276acf35dc7005502", + "hashPolicy": "Exact raw bytes" + } + ], + "buildId": "InBPthl1fsnID3NNdgwbI", + "source": { + "before": { + "path": ".artifacts/phase7/p7-09/preferences-final-pre5/source.json", + "bytes": 9416, + "sha256": "4dfa82ec4087f591a3650616061560aefe1bf285b14cc63ca9c4f2977dfe1058", + "hashPolicy": "Exact raw bytes" + }, + "after": { + "path": ".artifacts/phase7/p7-09/preferences-final-post5/source.json", + "bytes": 9416, + "sha256": "d09f997d3efb93a6eca4ed7953993f805c67db95eb96064116b4986549cff04e", + "hashPolicy": "Exact raw bytes" + }, + "beforeUtc": "2026-10-17T08:45:29.374733+00:00", + "afterUtc": "2026-10-17T08:48:37.471222+00:00", + "inputs": 34, + "heldAcrossLatestSettingsConsumers": true, + "earlierProductFreeze": { + "path": ".artifacts/phase7/p7-09/preferences-final-pre3/source.json", + "bytes": 9416, + "sha256": "c1e443b94026fd8fc64cc3e6ec891cd688125fd49442fb1556d2a5318a5fe6b9", + "hashPolicy": "Exact raw bytes" + }, + "unchangedEarlierInputs": 31, + "primaryNativeFreeze": { + "path": ".artifacts/phase7/p7-09/preferences-final-pre4/source.json", + "bytes": 9416, + "sha256": "62e50ec0c8a4769f5c66a2d4554d632758de27dc6dfd348b686f5fb98e8643c7", + "hashPolicy": "Exact raw bytes" + }, + "unchangedPrimaryNativeInputs": 32, + "onlyLaterChanges": [ + "job-tracker-ui/e2e/phase7-settings-preferences.spec.ts", + "job-tracker-ui/e2e/rules-draft-repair.spec.ts", + "job-tracker-ui/e2e/settings-page-layout.spec.ts" + ], + "records": [ + { + "path": "job-tracker-ui/src/components/SettingsView.tsx", + "bytes": 18674, + "sha256": "d1078255adfbe9ef3a20b87a7f3bbe78707561587e6cd1316afe9b89cae8b294", + "canonicalLfSha256": "13d30b0f44251c65d3dc86756fabb464624043db9f5b6cb519ef7ce9d7a0d6a4" + }, + { + "path": "job-tracker-ui/src/settings-view.test.tsx", + "bytes": 25769, + "sha256": "1b5dfa2e75554ea53c462ec21c041f9761884390aace42d4f3a02a218b90db17", + "canonicalLfSha256": "e44a6b91e4480ab491a87cafd7ecb7634a1298b278fe0224a346451dc509f2d7" + }, + { + "path": "job-tracker-ui/src/components/EmailProviderConnections.tsx", + "bytes": 19328, + "sha256": "1009d1d5ee514b67dcb66738d1a4fad256b2dee049f88d4c748766b7360357b2", + "canonicalLfSha256": "5c5211c49012f6dc77d049e7164dd1f5807c9254e3278c2f47158d7f6209d73d" + }, + { + "path": "job-tracker-ui/src/email-provider-connections.test.tsx", + "bytes": 30212, + "sha256": "764315a88dd7becbcda26b647c5258915f8e380f0ff2f9c031f7fb322b339b87", + "canonicalLfSha256": "764315a88dd7becbcda26b647c5258915f8e380f0ff2f9c031f7fb322b339b87" + }, + { + "path": "job-tracker-ui/src/components/Correspondence.tsx", + "bytes": 42569, + "sha256": "f42e7e549c9060f9d4f84111fc5d430eac353832c9cd6f6a56fa14b20c013251", + "canonicalLfSha256": "45f0b6b442676e62e8644b57863d595936d43608841d4fadaafa936d5606036a" + }, + { + "path": "job-tracker-ui/src/correspondence-gmail-import.test.tsx", + "bytes": 26424, + "sha256": "eb1a3172c528df0f9876785e4ef4ae25220227c0801d247f76a61bd779c54653", + "canonicalLfSha256": "f59abb397a716951399c64dfa6fbaeba3c63aaddd60e45eed7cdd5caf590a1e8" + }, + { + "path": "job-tracker-ui/src/views/ConnectedAccountsPage.tsx", + "bytes": 798, + "sha256": "1deb20b05c221c0749537f8cb02a4be26959e72f26bc447e838704be625ac3b6", + "canonicalLfSha256": "09cbe497948cecacacc9e6e1b6c216bb0feb24977afcfe6c3a1ed0c10bd2708e" + }, + { + "path": "job-tracker-ui/src/i18n/translations.ts", + "bytes": 330096, + "sha256": "9351fbccc6f3e0fa2fd360d338b240d0313bd250902725a68bece906a3d54011", + "canonicalLfSha256": "9351fbccc6f3e0fa2fd360d338b240d0313bd250902725a68bece906a3d54011" + }, + { + "path": "job-tracker-ui/src/api.ts", + "bytes": 4948, + "sha256": "bec8dc665fef61f3c88938e9a61dda786a16008d6af9bc7688bdd615e7ba337c", + "canonicalLfSha256": "f75ac7be07a743396843ddad5fd3371e2d5fd3d9c5d933e09e26fae2159f2378" + }, + { + "path": "job-tracker-ui/src/auth.ts", + "bytes": 4822, + "sha256": "0282a19bf34a915d6c4cd3bb32c041103206b1741b737e8a5e7b3f083a839acb", + "canonicalLfSha256": "1cbdd59f73c5a961613b2569679f96630cded407974876a4ca8abccf28780b0e" + }, + { + "path": "job-tracker-ui/src/sessionBoundary.ts", + "bytes": 2248, + "sha256": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9", + "canonicalLfSha256": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9" + }, + { + "path": "JobTrackerApi/Controllers/GmailController.cs", + "bytes": 51060, + "sha256": "05df8fea68b8c1d3a6b4a17bcd7c4b281b01a86fabf4c853eb495820ee3f4d2a", + "canonicalLfSha256": "24eb6093d8067b2fe59b15ceb024f2a368d92ef615abb5c4455b830b914fbcb5" + }, + { + "path": "JobTrackerApi/Controllers/MicrosoftGraphController.cs", + "bytes": 5394, + "sha256": "b386c2e7413fd78ec252b01480e9163acafac24474b48c797e64fc34f660b6f0", + "canonicalLfSha256": "232176a4e265cc2c261f08b02aa38719661171cf3859768c097eea4a56be898c" + }, + { + "path": "JobTrackerApi.Tests/ExternalOriginTests.cs", + "bytes": 5698, + "sha256": "ff91757dd21da47708371ac7aa29bc6acc0ad4f9e1e0124a6ed5374c981be434", + "canonicalLfSha256": "f619dc7989cba2d492ae317bbc9acd7724317cb9203c8833fdb4a1086ff83189" + }, + { + "path": "JobTrackerApi/Controllers/NotificationSettingsController.cs", + "bytes": 1481, + "sha256": "f668e19c344f506d577dc2b8ec6be19439cef2bea2ad098680e57075456f0299", + "canonicalLfSha256": "101130256b15432e41a32312f67bfa2f147c556e996869fced036cba92ad9517" + }, + { + "path": "job-tracker-ui/src/emailOAuth.ts", + "bytes": 1769, + "sha256": "dfd0f0ac46d295a2c5a0b9a6bb6bfffcceef6b6871cec7c92c429418cdbce8a9", + "canonicalLfSha256": "dfd0f0ac46d295a2c5a0b9a6bb6bfffcceef6b6871cec7c92c429418cdbce8a9" + }, + { + "path": "job-tracker-ui/e2e/phase7-oauth-sender.spec.ts", + "bytes": 26234, + "sha256": "ae3600200122855ca7daf2bbf10c26f0eddc5600502b5d697fb6bdfd95b590c1", + "canonicalLfSha256": "ae3600200122855ca7daf2bbf10c26f0eddc5600502b5d697fb6bdfd95b590c1" + }, + { + "path": "JobTrackerApi.Tests/GmailControllerTests.cs", + "bytes": 46879, + "sha256": "69ab904ad8f62cb57309d2038651ea1c5301070c5a6b76d2dda4150ca75e86e8", + "canonicalLfSha256": "6d552871f5c8b84948671b976f28b3190f0fb9c56fc0345c5ea9d10fe85d3740" + }, + { + "path": "JobTrackerApi.Tests/MicrosoftGraphControllerTests.cs", + "bytes": 6880, + "sha256": "0847a7b630cc5cf52a9eb1196d8c5e7aeb7d7b37b7bf327131ef2e6f32ecde57", + "canonicalLfSha256": "fca5fb45a51516801236e437fddd8a84fc0906e2de685d1656134612a1a390e0" + }, + { + "path": "job-tracker-ui/src/i18n/I18nProvider.tsx", + "bytes": 3184, + "sha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113", + "canonicalLfSha256": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113" + }, + { + "path": "job-tracker-ui/src/toast.tsx", + "bytes": 3425, + "sha256": "83ff8de595c459b9fc403ff1ff9cd91bbdfaad9b85df197418aedbb9b26f5770", + "canonicalLfSha256": "0c4d9df47526d5d9a9eddd397872138bb54bc7d736db74dc082056e33fe50d25" + }, + { + "path": "job-tracker-ui/playwright.config.ts", + "bytes": 2669, + "sha256": "01840494c782b2cc3c4c639dd2978a98ffa685b656e6a61fe4c503aeac6798f3", + "canonicalLfSha256": "bcf1dc87bd573f251ab3030088801fa5966907c86fdb90c701003c180ed969d3" + }, + { + "path": ".artifacts/phase7/current/run-ui.py", + "bytes": 7420, + "sha256": "94c7b160e3829b6ac0275dfe0488132c3d8e36dd3d59d32fb7642296c63dcb98", + "canonicalLfSha256": "94c7b160e3829b6ac0275dfe0488132c3d8e36dd3d59d32fb7642296c63dcb98" + }, + { + "path": "job-tracker-ui/src/components/RulesSettingsCard.tsx", + "bytes": 3297, + "sha256": "15cb36d8ef43a0b74b263bf2adc95f8982761b3cb92f44df3b9caf67c309a0c9", + "canonicalLfSha256": "969c389ba1581293509853fd5a41a60a9b569ef0d23eafd7f2fc0077ca25d922" + }, + { + "path": "job-tracker-ui/src/components/ViewStateNotice.tsx", + "bytes": 2340, + "sha256": "fd4bb314346a1603d9e01a15fd02f01576b9fd959e83f8ff0b1dfe5ec94378e9", + "canonicalLfSha256": "7b6c3e6c311860897eb5327936dbb129e860781929cfd9a4d4e17f81c607c70c" + }, + { + "path": "job-tracker-ui/src/layout/PageLayout.tsx", + "bytes": 2330, + "sha256": "9d7d8b3df0f533cc608c8b44519e12f1c9a8547fe91401c45023c6d2c34e111d", + "canonicalLfSha256": "31c7510446382039cd24b96e5709d5d8dfd00a0b84fd319a53183d1da6c2efa4" + }, + { + "path": "job-tracker-ui/e2e/phase7-settings-preferences.spec.ts", + "bytes": 28953, + "sha256": "b6736470a2b2a702a09ccd0e712def7689d56e419c28669a7a91acd75279f046", + "canonicalLfSha256": "b6736470a2b2a702a09ccd0e712def7689d56e419c28669a7a91acd75279f046" + }, + { + "path": "job-tracker-ui/e2e/rules-draft-repair.spec.ts", + "bytes": 16227, + "sha256": "62399159f0c2fc7a748eb1157d0fedda03d2d96f53787273539023f37be8b473", + "canonicalLfSha256": "62399159f0c2fc7a748eb1157d0fedda03d2d96f53787273539023f37be8b473" + }, + { + "path": "job-tracker-ui/e2e/settings-page-layout.spec.ts", + "bytes": 12405, + "sha256": "2db9a988be4664539d78250c93cc2ca98b85379b6294e6762b8ffb9213396acd", + "canonicalLfSha256": "2db9a988be4664539d78250c93cc2ca98b85379b6294e6762b8ffb9213396acd" + }, + { + "path": "JobTrackerApi/Controllers/ImapController.cs", + "bytes": 3680, + "sha256": "470db9b775f16945c1c9954a4cfdf8eacf2f67d1328c963c1c5b1072cb2d3139", + "canonicalLfSha256": "ea9a4068e45690c949aef2dd48cfb1570938e84f268d46da9f8ffd84e377f15a" + }, + { + "path": "JobTrackerApi/Controllers/RulesController.cs", + "bytes": 4899, + "sha256": "526c5c7edfc2c226862067be1480d149728a8c0544eb9a3d00113a981145e3d0", + "canonicalLfSha256": "247d305904f69ec51c006dc01eeab5f7501c6aab5046fe5000ebaa98b564b9c2" + }, + { + "path": "JobTrackerApi.Tests/NotificationSettingsControllerTests.cs", + "bytes": 2807, + "sha256": "d411cc091623e9ec55315e677bb78984f97369287cb167bed748054506e55a95", + "canonicalLfSha256": "9dc9849425b71adda0ce64b404a6edd02d0389a1f6ee42561fea32e008e6103c" + }, + { + "path": "JobTrackerApi.Tests/RulesEngineProspectTests.cs", + "bytes": 2699, + "sha256": "d92b4312f9937b4238a86150648958c531f5581a61f9ebaf0f8a4acc597614f2", + "canonicalLfSha256": "9ae9b05cd1246cbcc215433875ccf24869f0f4deb051b62958f460ccd05523aa" + }, + { + "path": "JobTrackerApi.Tests/ImapControllerTests.cs", + "bytes": 5745, + "sha256": "ed9fc218df99b0f0b68802270fd8e5f69ef4d2bc1f328e1e667026b6bf584837", + "canonicalLfSha256": "b169ec0e8b2d4211481b45a4acd05d3c84a5ed8c6022b371619a7207146ccfeb" + } + ], + "qualification": "91-case unit/app types/build apply to unchanged product and unit bytes from pre3. Only three native fixtures subsequently changed: hydration precondition, named Rules status and semantic loading oracle. Primary5/OAuth3 use32 matching inputs from pre4; latest lint/browser types and updated Settings8/P2 use held34 pre5/post5 inputs. No rebuild or unit rerun is invented after native-only edits." + }, + "regression": { + "frontend": 91, + "frontendSuites": 6, + "native": 17, + "nativeNew": 5, + "nativeConsumers": 12, + "skipped": 0, + "expectedFailures": 0, + "flaky": 0, + "delta": "Settings14→21 (+7); Connections12→22 (+10); all existing cases retained. Zero new suites. New native5 adds5; existing consumers are not additive.", + "derivedInventory": { + "frontend": 824, + "frontendSuites": 95, + "browser": 331, + "backend": 1054 + }, + "inheritedWhole": { + "frontend": 573, + "frontendSuites": 89, + "backend": 1033, + "sidecar": 49 + } + }, + "backend": { + "command": { + "command": [ + "rtk", + "proxy", + "C:/Users/Connor/.dotnet/dotnet.exe", + "test", + "JobTrackerApi.Tests/JobTrackerApi.Tests.csproj", + "--configuration", + "Release", + "--no-build", + "--filter", + "FullyQualifiedName~NotificationSettingsControllerTests|FullyQualifiedName~RulesControllerTests|FullyQualifiedName~ImapControllerTests|FullyQualifiedName~GmailControllerTests|FullyQualifiedName~MicrosoftGraphControllerTests|FullyQualifiedName~ExternalOriginTests", + "--logger", + "trx;LogFileName=career.trx", + "--results-directory", + "E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-preferences-contracts-20261017T083010Z" + ], + "startedUtc": "2026-10-17T08:30:10.099164+00:00", + "finishedUtc": "2026-10-17T08:30:13.142917+00:00", + "exitCode": 0, + "logSha256": "f4602bbeb4bdc85415c7252d16c75bcba76a4de91ec0f6f338b0f42e249e9440", + "counts": { + "total": 52, + "passed": 52, + "failed": 0, + "notExecuted": 0 + }, + "resultSha256": "88739254b981d32cc4dbb4d6aded2e1574b95d59b7ff1c4f74122b4acb32e3a5" + }, + "raw": { + "path": ".artifacts/phase7/current/backend-career/p709-preferences-contracts-20261017T083010Z/command.json", + "bytes": 1124, + "sha256": "c7c91e6c076774a5af0b099be548c86ab8cee8e41138da66506eb48ddbbeef70", + "hashPolicy": "Exact raw bytes" + }, + "binariesUnchanged": [ + { + "path": "JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll", + "bytes": 4063744, + "sha256": "e1d2c1bfc7ba4d9816d5d7b80503e79d2a647ad0ae2f4777e2703508fd32681c", + "hashPolicy": "Exact raw bytes" + }, + { + "path": "JobTrackerApi.Tests/bin/Release/net9.0/JobTrackerApi.Tests.dll", + "bytes": 1997312, + "sha256": "425d098903732802b5b304072c6f2e2f4479d74a56cf292d8c152544592dd99a", + "hashPolicy": "Exact raw bytes" + } + ], + "actualClasses": { + "ExternalOriginTests": 14, + "GmailControllerTests": 19, + "MicrosoftGraphControllerTests": 7, + "ImapControllerTests": 9, + "NotificationSettingsControllerTests": 3 + }, + "qualification": "No-build existing52 cases; extra RulesControllerTests filter matches zero. Native Rules cases supply separate evidence. No backend/schema/protocol or test-count addition." + }, + "history": [ + { + "path": ".artifacts/phase7/p7-09/preferences/p709-preferences-red-20261017/results.json", + "bytes": 25942, + "sha256": "35c93a9fd3331582896ad9eac48a8ad11a7d4ea24eb688b8bab86fbedd289973", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/preferences/p709-preferences-candidate1-20261017/results.json", + "bytes": 3646, + "sha256": "4478bb5e63b9e310709f7e816524966913d3e1dfaf8da9d8b35fe8d55aa5c162", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/connections-worker-receipt.json", + "bytes": 1957, + "sha256": "3b024c170209bdb4d413d5d400302f20d0af7884d8aa2e3c0021e2d547a81480", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/preferences-lint-attempt1/results.json", + "bytes": 18600, + "sha256": "af0b9ca44505b2c3a58335bc045e52a22026ef4bd1e5f4b43c3a0b91bb448f55", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/preferences-types-attempt1/results.json", + "bytes": 19538, + "sha256": "213a91af6bb3f2da1b7fd67f4b061f481d1aff63d54c99bbf477ab03f659d1f3", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/preferences-native-attempt1/results.json", + "bytes": 65420, + "sha256": "11cd0b41ea24de1f98c255d63066ce9fe504982a300926e5effc959d5cbe680c", + "hashPolicy": "Exact raw bytes" + }, + { + "path": ".artifacts/phase7/p7-09/preferences-consumers-attempt1/results.json", + "bytes": 125277, + "sha256": "b176ecfc4f001b52bba84cd2b759b3f4b9d94b71b1bcc9d10a464d8ce2bcbbad", + "hashPolicy": "Exact raw bytes" + } + ], + "visual": { + "primaryRetained": 18, + "primaryActuallyReviewed": 18, + "parent": { + "path": ".artifacts/phase7/p7-09/preferences-root-visual-review.json", + "bytes": 5354, + "sha256": "81ee9be1ff2e4723335b57d855ff8c5bb8b9c0af4adc9f9bc41ad469e6eb857c", + "hashPolicy": "Exact raw bytes" + }, + "peer": { + "path": ".artifacts/phase7/p7-09/preferences-peer-visual-review.json", + "bytes": 202910, + "sha256": "d2d724942605244992e29cbbb2e5f2879363ddec08fe98ad307605f96c0a66a2", + "hashPolicy": "Exact raw bytes" + }, + "qualification": "Parent actually displayed0–8; independent peer9–17. Consumer PNGs are retained separately and are not all claimed reviewed. Full-page images and automated reflow are finite Chromium evidence, not physical device/reader/real zoom/output acceptance." + }, + "accessibility": { + "primary": { + "scans": [ + { + "test": "[P7-09 S03] a held actual notification acknowledgement preserves newer edits, rejects duplicate activation and retains separate Rules and Discard baselines", + "attachment": "held-notification-newer-draft-nb-390-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/005-held-notification-newer-draft-nb-390-axe.json", + "sha256": "86352e751fa54fe6045661ec856e6d5ad5ff689a6dec6d8abbfaf1811fe08708", + "bytes": 2793, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 3 + } + ] + }, + { + "test": "[P7-09 S03] controlled initial GET503 recovers by focused GET-only Retry and controlled save503 retains its draft for one explicit real save", + "attachment": "controlled-notification-initial-get503-en-320-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/010-controlled-notification-initial-get503-en-320-axe.json", + "sha256": "a3cad321de6e3a2e411b5a25c82e6c9015c9b2f7b8d51ccc87939fb54e3e0ab4", + "bytes": 2141, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03] controlled initial GET503 recovers by focused GET-only Retry and controlled save503 retains its draft for one explicit real save", + "attachment": "controlled-notification-save503-draft-en-320-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/013-controlled-notification-save503-draft-en-320-axe.json", + "sha256": "28f435ce60b69c9135ff556a1103e0473b713464d884a7a30f9f4efcef062580", + "bytes": 3391, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 M06] a controlled Gmail status failure stays unknown while independent real providers remain disconnected and Retry only reads status", + "attachment": "controlled-gmail-status503-independent-providers-en-390-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/018-controlled-gmail-status503-independent-providers-en-390-axe.json", + "sha256": "43cdbd2c445777e3b54706aaa688a94786ddeb823737cb14fdc0a4d91fac4a70", + "bytes": 4179, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-320-nb-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/023-notifications-320-nb-dark-axe.json", + "sha256": "923f6e196a8c60d3d16714ace084def510ed7d1c05bcb542ebebf2e707b6feeb", + "bytes": 2135, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-320-nb-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/026-controlled-long-address-connections-320-nb-dark-axe.json", + "sha256": "9dc5ca4baca5d7d6d3b3d76b43dddeb0b33c9e0e47180bc7ed34554d81846d4d", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-390-en-light-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/029-notifications-390-en-light-axe.json", + "sha256": "a3cad321de6e3a2e411b5a25c82e6c9015c9b2f7b8d51ccc87939fb54e3e0ab4", + "bytes": 2141, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-390-en-light-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/032-controlled-long-address-connections-390-en-light-axe.json", + "sha256": "9dc5ca4baca5d7d6d3b3d76b43dddeb0b33c9e0e47180bc7ed34554d81846d4d", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-768-nb-light-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/035-notifications-768-nb-light-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "bytes": 2082, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-768-nb-light-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/038-controlled-long-address-connections-768-nb-light-axe.json", + "sha256": "9dc5ca4baca5d7d6d3b3d76b43dddeb0b33c9e0e47180bc7ed34554d81846d4d", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-1024-en-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/041-notifications-1024-en-dark-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "bytes": 2082, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-1024-en-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/044-controlled-long-address-connections-1024-en-dark-axe.json", + "sha256": "f1a69b2bf633884f54fd9140574bfd4de75e6d4d3f5d732c21965acae812ad5e", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-1280-nb-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/047-notifications-1280-nb-dark-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "bytes": 2082, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-1280-nb-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/050-controlled-long-address-connections-1280-nb-dark-axe.json", + "sha256": "f1a69b2bf633884f54fd9140574bfd4de75e6d4d3f5d732c21965acae812ad5e", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-1440-en-light-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/053-notifications-1440-en-light-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "bytes": 2082, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-1440-en-light-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/056-controlled-long-address-connections-1440-en-light-axe.json", + "sha256": "f1a69b2bf633884f54fd9140574bfd4de75e6d4d3f5d732c21965acae812ad5e", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "notifications-1920-en-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/059-notifications-1920-en-dark-axe.json", + "sha256": "e0d372ebdd5ba5ac1c9586f97f497f17ad8ebbb8b368e4b877c81e080985d91d", + "bytes": 2082, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths", + "attachment": "controlled-long-address-connections-1920-en-dark-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/embedded/settings-preferences/062-controlled-long-address-connections-1920-en-dark-axe.json", + "sha256": "f1a69b2bf633884f54fd9140574bfd4de75e6d4d3f5d732c21965acae812ad5e", + "bytes": 4187, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-verified/settings-preferences.json", + "sha256": "f3ceff709c3a8d86f5d51f2ae9dd0b38b274e617339818507132f465de501709", + "bytes": 1769769, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + } + ], + "violationNodes": 0, + "incompleteNodes": 62, + "checkContexts": [ + { + "kind": "incomplete", + "rule": "color-contrast", + "check": "color-contrast", + "message": "Element's background color could not be determined because it is overlapped by another element", + "occurrences": 43, + "representativeContexts": [ + { + "attachment": "held-notification-newer-draft-nb-390-axe", + "target": [ + ".MuiButton-containedSizeSmall" + ], + "data": { + "contrastRatio": 0, + "fontSize": "9.8pt (13px)", + "fontWeight": "bold", + "messageKey": "bgOverlap", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + }, + { + "attachment": "held-notification-newer-draft-nb-390-axe", + "target": [ + ".MuiSelect-select" + ], + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "bgOverlap", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + } + ] + }, + { + "kind": "incomplete", + "rule": "color-contrast", + "check": "color-contrast", + "message": "Element content is too short to determine if it is actual text content", + "occurrences": 18, + "representativeContexts": [ + { + "attachment": "held-notification-newer-draft-nb-390-axe", + "target": [ + ".MuiBadge-badge" + ], + "data": { + "contrastRatio": 0, + "fontSize": "9.0pt (12px)", + "fontWeight": "normal", + "messageKey": "shortTextContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + }, + { + "attachment": "controlled-notification-initial-get503-en-320-axe", + "target": [ + ".MuiBadge-badge" + ], + "data": { + "contrastRatio": 0, + "fontSize": "9.0pt (12px)", + "fontWeight": "normal", + "messageKey": "shortTextContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + } + ] + }, + { + "kind": "incomplete", + "rule": "aria-hidden-focus", + "check": "focusable-modal-open", + "message": "Check that focusable elements are not tabbable in the current state", + "occurrences": 1, + "representativeContexts": [ + { + "attachment": "controlled-notification-save503-draft-en-320-axe", + "target": [ + "#menu-" + ], + "data": null, + "relatedNodes": [ + { + "html": "
  • Notifications
  • ", + "target": [ + "li[data-value=\"2\"]" + ] + } + ] + } + ] + } + ], + "qualification": "Every violation/incomplete category counted; complete node/check vectors remain exact raw report and extracted JSON. Parent contextual triage pending." + }, + "consumer": { + "oauth-sender": { + "scans": [], + "violationNodes": 0, + "incompleteNodes": 0, + "checkContexts": [], + "qualification": "Every violation/incomplete category counted; complete node/check vectors remain exact raw report and extracted JSON. Parent contextual triage pending." + }, + "settings-consumers": { + "scans": [ + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-draft-en-light-1440-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 10, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/002-rules-draft-en-light-1440-axe.json", + "sha256": "88e8d638a63fc360c51e68363474347cb87c2030f0a4ee2c49be53bac82c6021", + "bytes": 7949, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 10 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-validation-en-light-1440-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 10, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/003-rules-validation-en-light-1440-axe.json", + "sha256": "865d91bff40d59f9cb31e87385424d436b1434f07296c555e5823d3aab3e96d5", + "bytes": 8028, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 10 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-save-error-en-light-1440-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 10, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/004-rules-save-error-en-light-1440-axe.json", + "sha256": "b371bd240bcaf6c21c2ac2c4e2517d5177c2a6b84947878353d153c9cdba8e0e", + "bytes": 7949, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 10 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-discard-en-light-1440-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 9, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/005-rules-discard-en-light-1440-axe.json", + "sha256": "169f4003b04f59882074f75c648ada1908b7187396132a7ac5c1b0cc505a6caa", + "bytes": 7170, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 9 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-draft-nb-dark-768-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 10, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/006-rules-draft-nb-dark-768-axe.json", + "sha256": "d9f296aaa6f6fad59731e838547c4d5bdd994a2a17e06dd71ba88727d7866be5", + "bytes": 7979, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 10 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-validation-nb-dark-768-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 10, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/007-rules-validation-nb-dark-768-axe.json", + "sha256": "95eeeed4d9adb9bb307d4d11e1fd6034eee0601854fa466b4db3972cf06e7452", + "bytes": 8058, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 10 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-save-error-nb-dark-768-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 10, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/008-rules-save-error-nb-dark-768-axe.json", + "sha256": "1f6a91d42fd2ea9908a68edcfa4129a266ea5430d2ec86f9de1ba59552c55f1f", + "bytes": 7979, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 10 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-discard-nb-dark-768-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 9, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/009-rules-discard-nb-dark-768-axe.json", + "sha256": "dd4d02b9b2f4406ce9d3610de42116dbb276996bc623fc0b04115d45db105824", + "bytes": 7170, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 9 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-draft-en-dark-390-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 8, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/010-rules-draft-en-dark-390-axe.json", + "sha256": "5eb14c96fc03248f973a02fe5679c69b5ec483ee740dfdf4537038a393b49bfc", + "bytes": 6485, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-validation-en-dark-390-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 8, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/011-rules-validation-en-dark-390-axe.json", + "sha256": "96ac674b1a79130ed502b765669830580ee308d7d1c7ca27a9d85bf81033d947", + "bytes": 6564, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-save-error-en-dark-390-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 8, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/012-rules-save-error-en-dark-390-axe.json", + "sha256": "f1b1f808ce019c16fdce3eee37848dcc190e7edbdb0c9088f3738e7a313d8464", + "bytes": 6485, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-discard-en-dark-390-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 9, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/013-rules-discard-en-dark-390-axe.json", + "sha256": "1594c13d28fc773c2fcbf987f22d180d280bf1084239b2da79ce45235aa2cf4f", + "bytes": 7732, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-draft-nb-light-320-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 8, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/014-rules-draft-nb-light-320-axe.json", + "sha256": "ff17d6cb452787ab8599d96e4775d5ea1c74331ec15ed3e121030ba36b73f60e", + "bytes": 6486, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-validation-nb-light-320-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 9, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/015-rules-validation-nb-light-320-axe.json", + "sha256": "4312a84bec39863aaa1be143578a7d741cd689aeb8291a09594d174c8954b5e9", + "bytes": 7813, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-save-error-nb-light-320-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 9, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/016-rules-save-error-nb-light-320-axe.json", + "sha256": "50ae445e52b5bf2330133e2c249f0d2ca981dbe28704400f89d6e57891f96de6", + "bytes": 7734, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports", + "attachment": "rules-discard-nb-light-320-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 9, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/017-rules-discard-nb-light-320-axe.json", + "sha256": "e9b0a5224d8f5b56a6b649ef86e61423ebb78f7d1bf2e6d262ec278a8fbb2f3b", + "bytes": 7734, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 8 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/en Settings headings, reflow and retained actions", + "attachment": "general-light-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/020-general-light-en-axe.json", + "sha256": "613f49e4c3c33d32cde5d850470b2d35742b551718b4c772d4b097ee953de870", + "bytes": 6244, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/en Settings headings, reflow and retained actions", + "attachment": "phone-light-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 6, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/028-phone-light-en-axe.json", + "sha256": "38e4c461c131f5e8af34c44f449f1b79fbfaa7fd23b932830d3ab22ed0af2521", + "bytes": 7438, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 6 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/en Settings headings, reflow and retained actions", + "attachment": "rules-error-light-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/030-rules-error-light-en-axe.json", + "sha256": "8076617095e81116a35478b1ee2ef16f2225fdef2a32c2d9460c34d748fe6044", + "bytes": 4669, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/en Settings headings, reflow and retained actions", + "attachment": "notifications-error-light-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/037-notifications-error-light-en-axe.json", + "sha256": "90b72c5e973f2ecd39cbd51cb91ae0453e33726cc270dc456dcfa0369f3e1443", + "bytes": 2892, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/en Settings headings, reflow and retained actions", + "attachment": "backup-light-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/039-backup-light-en-axe.json", + "sha256": "c5fa75af2d21367c2a07e21b2fddd988e493a35b6335f06c910dccd0f3e9a12b", + "bytes": 4661, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/nb Settings headings, reflow and retained actions", + "attachment": "general-light-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/040-general-light-nb-axe.json", + "sha256": "30de05977deb4ffff75874b97cd55f263b90e8e29bee0616f654752244c2312d", + "bytes": 6249, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/nb Settings headings, reflow and retained actions", + "attachment": "phone-light-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 6, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/048-phone-light-nb-axe.json", + "sha256": "65ab42aca38d0e06b573614cbea9d08b736ac4d0687d27b51b2eb2ccc81cc174", + "bytes": 7444, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 6 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/nb Settings headings, reflow and retained actions", + "attachment": "rules-error-light-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/050-rules-error-light-nb-axe.json", + "sha256": "cef74e79a3bf8be1273b48f97a319e041fe486dd3c3f8a8e2fa7cd006b17aa72", + "bytes": 4671, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/nb Settings headings, reflow and retained actions", + "attachment": "notifications-error-light-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/057-notifications-error-light-nb-axe.json", + "sha256": "f230d0a975ac9210e9b64dc00bbb8f6eb547acbdb437d530ae4aa222fb9c2032", + "bytes": 2886, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] light/nb Settings headings, reflow and retained actions", + "attachment": "backup-light-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/059-backup-light-nb-axe.json", + "sha256": "6865adbc47e17a713f0be022604906b7ffcc33cf73f2a370b7d916f620d23236", + "bytes": 4677, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/en Settings headings, reflow and retained actions", + "attachment": "general-dark-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/060-general-dark-en-axe.json", + "sha256": "d70948b3b9b025023a98217b97b1f667814527b9dfe9848ad95bb8c0e0481f05", + "bytes": 6243, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/en Settings headings, reflow and retained actions", + "attachment": "phone-dark-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 6, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/068-phone-dark-en-axe.json", + "sha256": "6bec00e976f8eea23a9f8436a89adb0bf071b456cac185b085d4fa3b28805c1b", + "bytes": 7437, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 6 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/en Settings headings, reflow and retained actions", + "attachment": "rules-error-dark-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/070-rules-error-dark-en-axe.json", + "sha256": "8076617095e81116a35478b1ee2ef16f2225fdef2a32c2d9460c34d748fe6044", + "bytes": 4669, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/en Settings headings, reflow and retained actions", + "attachment": "notifications-error-dark-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/077-notifications-error-dark-en-axe.json", + "sha256": "90b72c5e973f2ecd39cbd51cb91ae0453e33726cc270dc456dcfa0369f3e1443", + "bytes": 2892, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/en Settings headings, reflow and retained actions", + "attachment": "backup-dark-en-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/079-backup-dark-en-axe.json", + "sha256": "c5fa75af2d21367c2a07e21b2fddd988e493a35b6335f06c910dccd0f3e9a12b", + "bytes": 4661, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/nb Settings headings, reflow and retained actions", + "attachment": "general-dark-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 5, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/080-general-dark-nb-axe.json", + "sha256": "fbcb4e932716d6c3ec1da1a02da62381e42a01de970c56a989c870a38724e946", + "bytes": 6251, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 5 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/nb Settings headings, reflow and retained actions", + "attachment": "phone-dark-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 6, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/088-phone-dark-nb-axe.json", + "sha256": "867104321fc262e82a3d91de229518fc48a45b43a62fed3659ee1f5723c839a5", + "bytes": 7446, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 6 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/nb Settings headings, reflow and retained actions", + "attachment": "rules-error-dark-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/090-rules-error-dark-nb-axe.json", + "sha256": "cef74e79a3bf8be1273b48f97a319e041fe486dd3c3f8a8e2fa7cd006b17aa72", + "bytes": 4671, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/nb Settings headings, reflow and retained actions", + "attachment": "notifications-error-dark-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 2, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/097-notifications-error-dark-nb-axe.json", + "sha256": "f230d0a975ac9210e9b64dc00bbb8f6eb547acbdb437d530ae4aa222fb9c2032", + "bytes": 2886, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + }, + { + "test": "[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] dark/nb Settings headings, reflow and retained actions", + "attachment": "backup-dark-nb-axe", + "geometry": null, + "engine": null, + "toolOptions": null, + "violationNodes": 0, + "incompleteNodes": 3, + "raw": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/embedded/settings-consumers/099-backup-dark-nb-axe.json", + "sha256": "6865adbc47e17a713f0be022604906b7ffcc33cf73f2a370b7d916f620d23236", + "bytes": 4677, + "hashPolicy": "Exact measured raw bytes" + }, + "originalEmbeddedReport": { + "path": ".artifacts/phase7/p7-09/preferences-final-consumers/settings-consumers.json", + "sha256": "8345eb767a5b3ae9c328e60495fabc802d281582b5240964409144872737ee2d", + "bytes": 351850, + "hashPolicy": "Exact measured raw bytes" + }, + "context": null, + "rules": [ + { + "kind": "incomplete", + "id": "aria-hidden-focus", + "nodes": 1 + }, + { + "kind": "incomplete", + "id": "color-contrast", + "nodes": 2 + } + ] + } + ], + "violationNodes": 0, + "incompleteNodes": 222, + "checkContexts": [ + { + "kind": "incomplete", + "rule": "color-contrast", + "check": "color-contrast", + "message": "Element content is too short to determine if it is actual text content", + "occurrences": 36, + "representativeContexts": [ + { + "attachment": "rules-draft-en-light-1440-axe", + "target": [ + ".MuiBadge-badge" + ], + "data": { + "contrastRatio": 0, + "fontSize": "9.0pt (12px)", + "fontWeight": "normal", + "messageKey": "shortTextContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + }, + { + "attachment": "rules-validation-en-light-1440-axe", + "target": [ + ".MuiBadge-badge" + ], + "data": { + "contrastRatio": 0, + "fontSize": "9.0pt (12px)", + "fontWeight": "normal", + "messageKey": "shortTextContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + } + ] + }, + { + "kind": "incomplete", + "rule": "color-contrast", + "check": "color-contrast", + "message": "Element's background color could not be determined because it is overlapped by another element", + "occurrences": 174, + "representativeContexts": [ + { + "attachment": "rules-draft-en-light-1440-axe", + "target": [ + "#settings-tab-1" + ], + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "bgOverlap", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + }, + { + "attachment": "rules-draft-en-light-1440-axe", + "target": [ + "#settings-tab-2" + ], + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "bgOverlap", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [] + } + ] + }, + { + "kind": "incomplete", + "rule": "aria-hidden-focus", + "check": "focusable-modal-open", + "message": "Check that focusable elements are not tabbable in the current state", + "occurrences": 12, + "representativeContexts": [ + { + "attachment": "rules-discard-en-dark-390-axe", + "target": [ + "#menu-" + ], + "data": null, + "relatedNodes": [ + { + "html": "
  • Follow-ups
  • ", + "target": [ + "li[data-value=\"1\"]" + ] + } + ] + }, + { + "attachment": "rules-validation-nb-light-320-axe", + "target": [ + "#menu-" + ], + "data": null, + "relatedNodes": [ + { + "html": "
  • Oppfølging
  • ", + "target": [ + "li[data-value=\"1\"]" + ] + } + ] + } + ] + } + ], + "qualification": "Every violation/incomplete category counted; complete node/check vectors remain exact raw report and extracted JSON. Parent contextual triage pending." + }, + "application-p2d01": { + "scans": [], + "violationNodes": 0, + "incompleteNodes": 0, + "checkContexts": [], + "qualification": "Every violation/incomplete category counted; complete node/check vectors remain exact raw report and extracted JSON. Parent contextual triage pending." + } + }, + "triage": "New18 scans0 violations/62 incompletes:43 overlapped backgrounds,18 short badges,1 closing aria-hidden MUI menu. Exact raw node/check contexts remain; no rule disabled or human contrast/Tab/AT acceptance inferred. Consumer categories remain separately qualified." + }, + "preservation": { + "groups": 57, + "partial": 56, + "deferred": [ + "PRES-C11" + ], + "currentRoutes": 33, + "targets": [ + "PRES-S03", + "PRES-S04", + "PRES-S05", + "PRES-M06", + "PRES-E09" + ], + "ordinaryP2D01": "PASS", + "qualification": "Existing six Rules fields, internal drafts, section save scopes, server identity/ownership, local preferences and exact OAuth guard remain. No provider grant/send, schema, auth, stored meaning, CV/PDF renderer or route change." + }, + "peerReview": "Read-only native and product review found no blocking introduced defect; final cleanup-ref captures verified. Parent corrected only26 unsupported unit ByRole options, preserving names/default exact matching and assertions.", + "remaining": [ + "Data/privacy/plan/backup boundary, including separately bounded app-export JSON compatibility repair, remains pending.", + "RD11/RD12/RD13/RD21 and other reliability/scaling debt remain open; real success and controlled503 recovery do not diagnose historical failures.", + "Human reader, physical touch/software keyboard, real zoom, effective contrast and production font/photo/print/provider qualifications remain open.", + "Real provider grants, IMAP, SMTP/send, model inference, billing/deletion/restore and production were not activated. Known external font CSS was aborted.", + "P5-13 remains withheld; followMode deferred; main unchanged; production NO-GO; Phase8 not started." + ], + "staticChecks": { + "path": "docs/redesign/phase-7/p7-09/evidence/preferences-static-checks.json", + "sha256": "0eff751419d605164d574167ad636807cbd3da6ae57511e42b361dbf2834c72e", + "checkedUtc": "2026-10-17T08:50:12.966801+00:00" + }, + "stagedSource": { + "path": ".artifacts/phase7/p7-09/preferences-staged-source.json", + "sha256": "de9f96d2b875f7e3634113307568c0ab264d096a1faf6fc158ac5a6485e15f1f", + "trackedInputs": 33 + } +} diff --git a/docs/redesign/phase-7/p7-09/evidence/preferences-static-checks.json b/docs/redesign/phase-7/p7-09/evidence/preferences-static-checks.json new file mode 100644 index 0000000..15109cc --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/preferences-static-checks.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "checkpoint": "p7-09", + "status": "PASS", + "checkedUtc": "2026-10-17T08:50:12.966801+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-17T08:50:09.548379+00:00", + "finishedUtc": "2026-10-17T08:50:12.129725+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261017T085009Z/secret-scan.log", + "logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d" + }, + { + "name": "working-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check" + ], + "startedUtc": "2026-10-17T08:50:12.129725+00:00", + "finishedUtc": "2026-10-17T08:50:12.220613+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261017T085009Z/working-whitespace.log", + "logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6" + }, + { + "name": "staged-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--cached", + "--check" + ], + "startedUtc": "2026-10-17T08:50:12.220613+00:00", + "finishedUtc": "2026-10-17T08:50:12.315419+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261017T085009Z/staged-whitespace.log", + "logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6" + }, + { + "name": "phase7-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check", + "b5319981ad3d1bd635daff861453d23c0de5320b" + ], + "startedUtc": "2026-10-17T08:50:12.315419+00:00", + "finishedUtc": "2026-10-17T08:50:12.477488+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261017T085009Z/phase7-whitespace.log", + "logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6" + } + ], + "untrackedSecretScan": { + "files": 0, + "findings": 0 + }, + "documentation": { + "files": 26, + "relativeLinks": 593, + "errors": [] + }, + "preservation": { + "groups": 57, + "statuses": { + "PARTIAL": 56, + "PASS": 0, + "FAIL": 0, + "DEFERRED": 1 + }, + "originalRoutesPreserved": true, + "currentConcreteRoutes": 33 + }, + "production": { + "main": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4", + "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 404f20c..284b327 100644 --- a/docs/roadmap/todo.md +++ b/docs/roadmap/todo.md @@ -92,7 +92,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m - [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established. - [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. -- [ ] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): separate sender repair final39/3+native9+four gates PASS, final publication pending; both approved presentation boundaries remain. Derived807/95 frontend,326 browser,1054 backend; no full-suite claim. +- [ ] P7-09 [Settings, Connections and data lifecycle](../redesign/phase-7/p7-09/README.md): sender repairdac2927 published; Preferences/Connections91/6+backend52+native17+gates PASS, final hygiene/publication pending. Data/privacy/plan/backup follows separate import compatibility prerequisite. Derived824/95 frontend,331 browser,1054 backend; no full-suite claim. - [ ] P7-10 Users, Audit and System administration presentation. - [ ] 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-settings-preferences.spec.ts b/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts new file mode 100644 index 0000000..84a7c93 --- /dev/null +++ b/job-tracker-ui/e2e/phase7-settings-preferences.spec.ts @@ -0,0 +1,149 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test'; +import { translations } from '../src/i18n/translations'; + +const apiUrl = 'http://localhost:5302/api'; +const password = 'E2ePassword123!'; +type Language = 'en' | 'nb'; +type Headers = Record; +type Owner = { id: string; email: string; headers: Headers }; +type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number }; +type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] }; +const boundary = 'Actual isolated local API/Identity/CSRF/Free owners and authenticated notification/Rules DTO persistence are exercised. Only explicitly named GET503, PUT503 and long-address responses are controlled browser projections. Read retries never replay writes. Mailbox status remains actually disconnected; no provider connect/disconnect, IMAP credential submission, mail/model call, billing/deletion/restore, Docker, main or Phase8 action occurs. Google Fonts css2 stylesheet requests are recorded and aborted. All other nonlocal requests are rejected. Viewports and keyboard are Chromium automation; actual browser zoom, physical touch/software keyboard and human screen reader acceptance remain unverified.'; +const rules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 }; +function copy(key: string, language: Language = 'en') { return (translations[language] as Record)[key] ?? key; } +function hold() { let release!: () => void; const pending = new Promise(resolve => { release = resolve; }); return { pending, release }; } +function browserRequests(session: Session, path: string, method?: string) { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && (!method || value.method === method)); } +async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT', path: string, headers?: Headers, data?: unknown) { + const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data }); + const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result; +} +async function json(response: APIResponse, status = 200): Promise { expect(response.status(), response.url()).toBe(status); return response.json(); } +async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise { + expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200); + const me = await json<{ id: string; sessionContext: string }>(await call(records, api, 'GET', '/auth/me')); + const token = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); + return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; +} +async function createOwner(records: RequestRecord[], admin: APIRequestContext, headers: Headers) { + const email = `p709-preferences-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; + expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email; +} +const test = base.extend<{ session: Session }>({ + session: async ({ page, context, playwright }, provide, info) => { + expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); + const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext(); + context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search }); }); + context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); }); + await context.route('**/*', route => { + const request = route.request(); const url = new URL(request.url()); + if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); } + const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); + const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname); + const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname); + if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); } + return route.continue(); + }); + await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); }); + try { + const administrator = await login(records, admin, 'e2e@example.test'); const email = await createOwner(records, admin, administrator.headers); const owner = await login(records, page.request, email); + expect((await json<{ entitlements: { ai: boolean } }>(await call(records, page.request, 'GET', '/auth/me'))).entitlements.ai).toBe(false); + await provide({ owner, admin, adminHeaders: administrator.headers, requests: records }); expect(forbidden).toEqual([]); + } finally { await info.attach('settings-preferences-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); } + }, +}); +async function start(page: Page, path = '/settings', width = 1440) { await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await expect(page.getByRole('main').getByRole('heading', { level: 1 })).toBeVisible(); } +async function keyboard(control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press('Enter'); } +async function choose(page: Page, label: string, language: Language = 'en') { + const main = page.getByRole('main'); const selector = main.getByRole('combobox', { name: copy('settingsNavigation', language), exact: true }); + const tab = main.getByRole('tab', { name: label, exact: true }); + await expect.poll(async () => await selector.isVisible() || await tab.isVisible()).toBe(true); + if (await selector.isVisible()) { await keyboard(selector); const option = page.getByRole('option', { name: label, exact: true }); await option.focus(); await option.press('Enter'); await expect(selector).toBeFocused(); } + else { await keyboard(tab); await expect(tab).toHaveAttribute('aria-selected', 'true'); } +} +function notificationGroup(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('group', { name: copy('settingsNotificationsTitle', language), exact: true }); } +function notificationCheckbox(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('checkbox', { name: copy('settingsNotificationsFollowUpReminders', language), exact: true }); } +async function toggle(control: Locator) { await expect(control).toBeEnabled(); await control.focus(); await control.press('Space'); } +async function persistedNotifications(session: Session, api: APIRequestContext) { return json<{ emailFollowUpRemindersEnabled: boolean }>(await call(session.requests, api, 'GET', '/notification-settings')); } +function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method() === method && new URL(value.url()).pathname === `/api${path}`); } +async function disconnected(session: Session, api: APIRequestContext) { for (const provider of ['gmail', 'microsoft-graph', 'imap']) expect(await json<{ connected: boolean }>(await call(session.requests, api, 'GET', `/${provider}/status`))).toMatchObject({ connected: false }); } +async function capture(page: Page, info: TestInfo, name: string) { + const geometry = await page.getByRole('main').evaluate(main => { + const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root')); + return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden').map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) }; + }); + await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]); + await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); + const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]); +} + +test('[P7-09 S03 E09] notification and Rules saves stay independent, persist after reload and ignore forged owners across actual account disposal', async ({ page, playwright, session }, info) => { + test.setTimeout(150_000); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true }); await start(page); + const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('6'); + await choose(page, copy('settingsTabNotifications')); const checkbox = notificationCheckbox(page); await expect(checkbox).toBeChecked(); await toggle(checkbox); + const notificationSave = response(page, 'PUT', '/notification-settings'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); const saved = await notificationSave; expect(saved.request().postDataJSON()).toEqual({ emailFollowUpRemindersEnabled: false }); expect(saved.status()).toBe(200); + expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0); + await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('6'); const ruleSave = response(page, 'PUT', '/rules'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('rulesSave'), exact: true })); expect((await ruleSave).status()).toBe(204); + expect(await json(await call(session.requests, page.request, 'GET', '/rules'))).toEqual({ ...rules, appliedFollowUpDays: 6 }); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); + await page.reload(); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).not.toBeChecked(); await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('6'); await rule.fill('9'); + const retainedA = await playwright.request.newContext({ storageState: await page.context().storageState() }); const anonymous = await playwright.request.newContext(); + try { + const emailB = await createOwner(session.requests, session.admin, session.adminHeaders); const second = await login(session.requests, page.request, emailB); + expect((await call(session.requests, page.request, 'PUT', '/notification-settings', second.headers, { emailFollowUpRemindersEnabled: false })).status()).toBe(200); + expect((await call(session.requests, page.request, 'PUT', `/notification-settings?ownerUserId=${session.owner.id}`, second.headers, { emailFollowUpRemindersEnabled: true, ownerUserId: session.owner.id })).status()).toBe(200); + expect(await persistedNotifications(session, retainedA)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true }); + await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); await expect(rule).toHaveCount(0); await page.goto('/settings'); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).toBeChecked(); await expect(notificationGroup(page).getByRole('status')).toHaveCount(0); + await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('14'); expect((await call(session.requests, anonymous, 'GET', '/notification-settings')).status()).toBe(401); expect((await call(session.requests, anonymous, 'PUT', '/notification-settings', undefined, { emailFollowUpRemindersEnabled: false })).status()).toBe(401); + await info.attach('native-independent-settings-owner-readback', { body: JSON.stringify({ notificationA: false, savedRuleA: 6, disposedRuleDraftA: 9, notificationB: true, ruleB: 14, forgedOwnerIgnored: true, anonymous: [401, 401], qualification: 'All saves/readbacks above are actual local HTTP; account B retains its own session and only B changed from the forged-owner request.' }), contentType: 'application/json' }); + } finally { await retainedA.dispose(); await anonymous.dispose(); } +}); + +test('[P7-09 S03] a held actual notification acknowledgement preserves newer edits, rejects duplicate activation and retains separate Rules and Discard baselines', async ({ page, session }, info) => { + test.setTimeout(120_000); await start(page, '/settings', 390); const initialRules = response(page, 'GET', '/rules'); await choose(page, copy('settingsTabFollowUps')); expect((await initialRules).status()).toBe(200); const rule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays'), exact: true }); await expect(rule).toHaveValue('14'); await rule.fill('5'); await choose(page, copy('settingsTabNotifications')); + const gate = hold(); let writes = 0; let acknowledged = false; + await page.route(`${apiUrl}/notification-settings`, async route => { if (route.request().method() !== 'PUT') return route.continue(); writes++; expect(route.request().postDataJSON()).toEqual({ emailFollowUpRemindersEnabled: false }); const actual = await route.fetch(); expect(actual.status()).toBe(200); acknowledged = true; await gate.pending; return route.fulfill({ response: actual }); }); + try { + const checkbox = notificationCheckbox(page); await expect(checkbox).toBeChecked(); await toggle(checkbox); const saved = response(page, 'PUT', '/notification-settings'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); await page.keyboard.press('Enter'); await expect.poll(() => acknowledged).toBe(true); expect(writes).toBe(1); await expect(page.getByRole('main').getByRole('button', { name: copy('settingsSaving'), exact: true })).toBeDisabled(); + await toggle(checkbox); await choose(page, copy('settingsTabFollowUps')); await expect(rule).toHaveValue('5'); await choose(page, copy('settingsTabNotifications')); await expect(checkbox).toBeChecked(); + const language = response(page, 'PUT', '/auth/preferences/language'); await keyboard(page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await language).status()).toBe(204); await expect(notificationCheckbox(page, 'nb')).toBeChecked(); expect(writes).toBe(1); + gate.release(); expect((await saved).status()).toBe(200); await expect(notificationGroup(page, 'nb').getByRole('status')).toContainText(copy('assetsUnsavedChanges', 'nb')); await expect(notificationCheckbox(page, 'nb')).toBeChecked(); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0); + await capture(page, info, 'held-notification-newer-draft-nb-390'); await keyboard(notificationGroup(page, 'nb').getByRole('button', { name: copy('assetsDiscardChanges', 'nb'), exact: true })); await expect(notificationCheckbox(page, 'nb')).not.toBeChecked(); await expect(notificationGroup(page, 'nb').getByRole('status')).toHaveCount(0); + await choose(page, copy('settingsTabFollowUps', 'nb'), 'nb'); const translatedRule = page.getByRole('spinbutton', { name: copy('rulesAppliedFollowUpDays', 'nb'), exact: true }); await expect(translatedRule).toHaveValue('5'); await keyboard(page.getByRole('main').getByRole('button', { name: copy('assetsDiscardChanges', 'nb'), exact: true })); await expect(translatedRule).toHaveValue('14'); expect(await json(await call(session.requests, page.request, 'GET', '/rules'))).toEqual(rules); expect(writes).toBe(1); + await info.attach('actual-notification-snapshot-acknowledgement', { body: JSON.stringify({ submitted: false, actualPersistedBeforeClientAck: false, newerDraft: true, notificationDiscardBaseline: false, independentRulesDraft: 5, rulesDiscardBaseline: 14, nativeWrites: writes, qualification: 'The actual successful local PUT response was held after route.fetch; no invented success DTO or backend write projection.' }), contentType: 'application/json' }); + } finally { gate.release(); } +}); + +test('[P7-09 S03] controlled initial GET503 recovers by focused GET-only Retry and controlled save503 retains its draft for one explicit real save', async ({ page, session }, info) => { + test.setTimeout(120_000); let failRead = true; let failWrite = true; + await page.route(`${apiUrl}/notification-settings`, route => { const method = route.request().method(); if (method === 'GET' && failRead) { failRead = false; return route.fulfill({ status: 503, json: {} }); } if (method === 'PUT' && failWrite) { failWrite = false; return route.fulfill({ status: 503, json: {} }); } return route.continue(); }); + await start(page, '/settings', 320); await choose(page, copy('settingsTabNotifications')); const group = notificationGroup(page); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsLoadFailed')); await expect(notificationCheckbox(page)).toHaveCount(0); await capture(page, info, 'controlled-notification-initial-get503-en-320'); + const read = response(page, 'GET', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('retry'), exact: true })); expect((await read).status()).toBe(200); await expect(notificationCheckbox(page)).toBeChecked(); await expect(group).toBeFocused(); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(0); + await toggle(notificationCheckbox(page)); const failed = response(page, 'PUT', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); expect((await failed).status()).toBe(503); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsSaveFailed')); await expect(notificationCheckbox(page)).not.toBeChecked(); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: true }); + await choose(page, copy('settingsTabGeneral')); await choose(page, copy('settingsTabNotifications')); await expect(group.getByRole('alert')).toContainText(copy('settingsNotificationsSaveFailed')); await expect(notificationCheckbox(page)).not.toBeChecked(); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(1); await capture(page, info, 'controlled-notification-save503-draft-en-320'); + const retried = response(page, 'PUT', '/notification-settings'); await keyboard(group.getByRole('button', { name: copy('settingsNotificationsSave'), exact: true })); expect((await retried).status()).toBe(200); await expect(group.getByRole('alert')).toHaveCount(0); expect(await persistedNotifications(session, page.request)).toEqual({ emailFollowUpRemindersEnabled: false }); expect(browserRequests(session, '/notification-settings', 'GET')).toHaveLength(2); expect(browserRequests(session, '/notification-settings', 'PUT')).toHaveLength(2); expect(browserRequests(session, '/rules', 'PUT')).toHaveLength(0); await disconnected(session, page.request); + await info.attach('controlled-notification-recovery-boundary', { body: JSON.stringify({ boundary, controlled: ['first browser GET503', 'first browser PUT503 without backend write'], actual: ['Retry GET200', 'explicit retry PUT200', 'owner readback false'], autoWriteRetries: 0, locale500OrSqliteQualification: 'RD11/RD12/RD13 are not reproduced or closed by controlled503 recovery. Every unprojected response remains a strict status assertion.' }), contentType: 'application/json' }); +}); + +test('[P7-09 M06] a controlled Gmail status failure stays unknown while independent real providers remain disconnected and Retry only reads status', async ({ page, session }, info) => { + test.setTimeout(90_000); await disconnected(session, page.request); let failRead = true; + await page.route(`${apiUrl}/gmail/status`, route => { expect(route.request().method()).toBe('GET'); if (failRead) { failRead = false; return route.fulfill({ status: 503, json: {} }); } return route.continue(); }); + await start(page, '/settings/connected-accounts', 390); const gmail = page.getByRole('region', { name: 'Gmail', exact: true }); const microsoft = page.getByRole('region', { name: 'Outlook / Microsoft 365', exact: true }); const imap = page.getByRole('region', { name: copy('emailConnectionsOtherImap'), exact: true }); + await expect(gmail.getByRole('alert')).toContainText(copy('emailConnectionsStatusFailed')); await expect(gmail.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toHaveCount(0); await expect(gmail.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toHaveCount(0); await expect(microsoft.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toBeVisible(); await expect(microsoft.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toBeEnabled(); await expect(imap.getByRole('textbox', { name: copy('emailConnectionsImapHost'), exact: true })).toBeVisible(); + await capture(page, info, 'controlled-gmail-status503-independent-providers-en-390'); const before = session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET'); const read = response(page, 'GET', '/gmail/status'); await keyboard(gmail.getByRole('button', { name: copy('retry'), exact: true })); expect((await read).status()).toBe(200); await expect(gmail.getByText(copy('emailConnectionsNotConnected'), { exact: true })).toBeVisible(); await expect(gmail.getByRole('heading', { level: 3, name: 'Gmail', exact: true })).toBeFocused(); await expect(gmail.getByRole('button', { name: copy('emailConnectionsConnect'), exact: true })).toBeEnabled(); expect(browserRequests(session, '/gmail/status', 'GET')).toHaveLength(2); expect(browserRequests(session, '/microsoft-graph/status', 'GET')).toHaveLength(1); expect(browserRequests(session, '/imap/status', 'GET')).toHaveLength(1); expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual(before); await disconnected(session, page.request); + await info.attach('controlled-connections-get-only-recovery', { body: JSON.stringify({ boundary, controlled: 'First browser Gmail status503 only', recovered: 'Actual Gmail GET200 connected=false', otherProviders: 'Actual Graph/IMAP disconnected before and after', providerMutationRequests: 0, realMailboxGrants: 0 }), contentType: 'application/json' }); +}); + +test('[P7-09 S03 M06 E08] representative English/Bokmål light/dark Settings and controlled long-address Connections reflow across seven widths', async ({ page, session }, info) => { + test.setTimeout(240_000); await start(page); await disconnected(session, page.request); + const address = `controlled-${'long-mailbox-segment-'.repeat(9)}@example.test`; const samples = [ + { width: 320, language: 'nb', mode: 'dark' }, { width: 390, language: 'en', mode: 'light' }, { width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' }, { width: 1280, language: 'nb', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'en', mode: 'dark' }, + ] as const; + await page.route(`${apiUrl}/gmail/status`, route => { expect(route.request().method()).toBe('GET'); return route.fulfill({ status: 200, json: { connected: true, gmailAddress: address, lastSyncedAt: null, lastSyncAttemptedAt: null, lastSyncMode: null, lastSyncSource: null, lastSyncStatus: null, lastSyncError: null } }); }); + for (const sample of samples) { + expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: sample.language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204); + await page.evaluate(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample); await page.setViewportSize({ width: sample.width, height: sample.width < 768 ? 844 : 900 }); await page.goto('/settings'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode); await choose(page, copy('settingsTabNotifications', sample.language), sample.language); await expect(notificationCheckbox(page, sample.language)).toBeChecked(); await toggle(notificationCheckbox(page, sample.language)); const save = notificationGroup(page, sample.language).getByRole('button', { name: copy('settingsNotificationsSave', sample.language), exact: true }); await expect(save).toBeEnabled(); await save.focus(); await expect(save).toBeFocused(); expect(await save.evaluate(node => getComputedStyle(node).outlineStyle)).toBe('solid'); await capture(page, info, `notifications-${sample.width}-${sample.language}-${sample.mode}`); + await choose(page, copy('settingsTabGeneral', sample.language), sample.language); await keyboard(page.getByRole('main').getByRole('button', { name: copy('settingsManageConnectedAccounts', sample.language), exact: true })); await expect(page).toHaveURL(/\/settings\/connected-accounts$/); const gmail = page.getByRole('region', { name: 'Gmail', exact: true }); await expect(gmail.getByText(address, { exact: true })).toBeVisible(); await expect(gmail.getByRole('button', { name: copy('emailConnectionsDisconnect', sample.language), exact: true })).toBeEnabled(); await capture(page, info, `controlled-long-address-connections-${sample.width}-${sample.language}-${sample.mode}`); + } + await page.unroute(`${apiUrl}/gmail/status`); await disconnected(session, page.request); expect(session.requests.filter(value => value.channel === 'browser' && value.method !== 'GET')).toEqual([]); + await info.attach('finite-settings-connections-matrix', { body: JSON.stringify({ boundary, samples, controlledProjection: { endpoint: 'GET /gmail/status', connected: true, gmailAddress: address }, actualServerStatus: 'Gmail/Graph/IMAP remain disconnected; the long address is a UI-only projection and no Disconnect/Connect action is activated.', scope: 'Seven paired viewport/theme/locale samples, not the full Cartesian product. Desktop and mobile keyboard section navigation, visible focused Save, raw reflow/axe and PNG evidence only; physical/reader/zoom remain pending.' }), contentType: 'application/json' }); +}); diff --git a/job-tracker-ui/e2e/rules-draft-repair.spec.ts b/job-tracker-ui/e2e/rules-draft-repair.spec.ts index 59cc835..1939e86 100644 --- a/job-tracker-ui/e2e/rules-draft-repair.spec.ts +++ b/job-tracker-ui/e2e/rules-draft-repair.spec.ts @@ -190,7 +190,7 @@ test('[P5-15-01R-02] delayed native Rules reads and save completion do not reset await route.continue(); }); await page.goto('/settings'); await choose(page, translations.en.settingsTabFollowUps); - await expect(page.getByRole('status')).toHaveText(translations.en.rulesLoading); + await expect(page.getByRole('main').getByRole('region', { name: translations.en.rulesTitle, exact: true }).getByRole('status')).toHaveText(translations.en.rulesLoading); await choose(page, translations.en.settingsTabNotifications); await choose(page, translations.en.settingsTabFollowUps); releaseRead(); const field = page.getByRole('spinbutton', { name: translations.en.rulesAppliedFollowUpDays, exact: true }); diff --git a/job-tracker-ui/e2e/settings-page-layout.spec.ts b/job-tracker-ui/e2e/settings-page-layout.spec.ts index fb8b303..51e54d6 100644 --- a/job-tracker-ui/e2e/settings-page-layout.spec.ts +++ b/job-tracker-ui/e2e/settings-page-layout.spec.ts @@ -130,7 +130,9 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb await page.reload(); await page.setViewportSize({ width: 320, height: 900 }); await chooseSection(t.settingsTabNotifications); - await expect(main.locator(".MuiSkeleton-root")).toBeVisible(); + const notifications = main.getByRole("group", { name: t.settingsNotificationsTitle, exact: true }); + await expect(notifications).toHaveAttribute("aria-busy", "true"); + await expect(notifications.getByRole("progressbar", { name: t.loading, exact: true })).toBeVisible(); await capture(page, info, `notifications-loading-${suffix}`); release(); const pref = main.getByRole("checkbox", { name: t.settingsNotificationsFollowUpReminders, exact: true }); diff --git a/job-tracker-ui/src/components/EmailProviderConnections.tsx b/job-tracker-ui/src/components/EmailProviderConnections.tsx index 41e01f1..3cfc738 100644 --- a/job-tracker-ui/src/components/EmailProviderConnections.tsx +++ b/job-tracker-ui/src/components/EmailProviderConnections.tsx @@ -1,13 +1,27 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import { useToast } from "../toast"; import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types"; import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt, type EmailOAuthProvider } from "../emailOAuth"; +import { PageSection } from "../layout/PageLayout"; + +type Provider = EmailOAuthProvider | "imap"; +type ConnectionStatus = GmailStatus & MicrosoftGraphStatus & ImapStatus; +type ProviderState = { + status: ConnectionStatus | null; + loading: boolean; + readFailed: boolean; + refreshRequired: boolean; + busy: "connect" | "disconnect" | null; + error: { kind: "disconnect" | "imap"; cause: unknown } | null; +}; +const providerPaths = { gmail: "/gmail", microsoft: "/microsoft-graph", imap: "/imap" }; +const initialProvider = (): ProviderState => ({ status: null, loading: true, readFailed: false, refreshRequired: false, busy: null, error: null }); +const providerWork = () => ({ reading: false, writing: false, refreshRequired: false, sequence: 0, controller: null as AbortController | null, status: null as ConnectionStatus | null }); // Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use // the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController / @@ -17,49 +31,74 @@ export default function EmailProviderConnections() { const { toast } = useToast(); const { t } = useI18n(); - const [gmailStatus, setGmailStatus] = useState(null); - const [microsoftStatus, setMicrosoftStatus] = useState(null); - const [imapStatus, setImapStatus] = useState(null); - - const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" }); - const [imapConnecting, setImapConnecting] = useState(false); + const [providers, setProviders] = useState({ gmail: initialProvider(), microsoft: initialProvider(), imap: initialProvider() }); + const work = useRef({ gmail: providerWork(), microsoft: providerWork(), imap: providerWork() }); + const headings = useRef>>({}); + const panels = useRef>>({}); + const pendingFocus = useRef<{ provider: Provider; element: HTMLElement } | null>(null); + const [imapForm, setImapForm] = useState({ host: "", port: "993", useSsl: true, username: "", password: "" }); + const [invalidPort, setInvalidPort] = useState(false); const oauth = useRef({ mounted: true, generation: 0, starting: new Set(), attempts: new Map() }); const startupTranslation = useRef(t); useEffect(() => { startupTranslation.current = t; }, [t]); + const updateProvider = useCallback((provider: Provider, patch: Partial) => { + setProviders(current => ({ ...current, [provider]: { ...current[provider], ...patch } })); + }, []); + + const rememberFocus = (provider: Provider) => { + const element = document.activeElement; + if (element instanceof HTMLElement && panels.current[provider]?.contains(element)) pendingFocus.current = { provider, element }; + }; + + useEffect(() => { + const pending = pendingFocus.current; + if (!pending) return; + pendingFocus.current = null; + const active = document.activeElement; + if (active === document.body || active === pending.element && (pending.element.matches(":disabled") || !pending.element.isConnected)) headings.current[pending.provider]?.focus(); + }, [providers]); + useEffect(() => { const session = oauth.current; + const providerSessions = work.current; session.mounted = true; - return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); }; + return () => { + session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); + for (const provider of Object.values(providerSessions)) { provider.sequence += 1; provider.controller?.abort(); provider.reading = false; } + }; }, []); - const loadGmailStatus = useCallback(async () => { + const loadStatus = useCallback(async (provider: Provider, afterWrite = false): Promise => { + const session = oauth.current; + const current = work.current[provider]; + if (!session.mounted || current.reading || current.writing && !afterWrite) return null; + current.reading = true; + const sequence = ++current.sequence; + const generation = session.generation; + const controller = new AbortController(); + current.controller = controller; + updateProvider(provider, { loading: true, readFailed: false }); + const active = () => session.mounted && generation === session.generation && sequence === current.sequence; try { - const res = await api.get("/gmail/status"); - setGmailStatus(res.data); + const { data } = await api.get(`${providerPaths[provider]}/status`, { signal: controller.signal }); + if (!active()) return null; + if (typeof data?.connected !== "boolean") throw new Error("Invalid connection status"); + current.status = data; + current.refreshRequired = false; + updateProvider(provider, { status: data, readFailed: false, refreshRequired: false }); + return data; } catch { - setGmailStatus({ connected: false }); + if (active()) updateProvider(provider, { readFailed: true }); + return null; + } finally { + if (active()) { current.reading = false; current.controller = null; updateProvider(provider, { loading: false }); } } - }, []); - - const loadMicrosoftStatus = useCallback(async () => { - try { - const res = await api.get("/microsoft-graph/status"); - setMicrosoftStatus(res.data); - } catch { - setMicrosoftStatus({ connected: false }); - } - }, []); - - const loadImapStatus = useCallback(async () => { - try { - const res = await api.get("/imap/status"); - setImapStatus(res.data); - } catch { - setImapStatus({ connected: false }); - } - }, []); + }, [updateProvider]); + const loadGmailStatus = useCallback(async () => { await loadStatus("gmail"); }, [loadStatus]); + const loadMicrosoftStatus = useCallback(async () => { await loadStatus("microsoft"); }, [loadStatus]); + const loadImapStatus = useCallback(async () => { await loadStatus("imap"); }, [loadStatus]); useEffect(() => { void loadGmailStatus(); @@ -90,11 +129,14 @@ export default function EmailProviderConnections() { const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => { const session = oauth.current; - if (!session.mounted || session.starting.has(provider)) return; + const current = work.current[provider]; + if (!session.mounted || session.starting.has(provider) || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return; const previous = session.attempts.get(provider); if (previous && !previous.popup.closed) { previous.popup.focus(); return; } session.attempts.delete(provider); session.starting.add(provider); + rememberFocus(provider); + updateProvider(provider, { busy: "connect", error: null }); const generation = session.generation; try { const res = await api.get<{ url: string }>(connectUrlPath); @@ -107,75 +149,115 @@ export default function EmailProviderConnections() { } catch (error) { if (session.mounted && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel })), "error"); } finally { - if (generation === session.generation) session.starting.delete(provider); + if (session.mounted && generation === session.generation) { session.starting.delete(provider); updateProvider(provider, { busy: null }); } } }; - const disconnect = async (path: string, reload: () => Promise, providerLabel: string) => { + const disconnect = async (provider: Provider, providerLabel: string) => { + const session = oauth.current; + const current = work.current[provider]; + if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status?.connected || provider !== "imap" && session.starting.has(provider)) return; + current.writing = true; + const generation = session.generation; + rememberFocus(provider); + updateProvider(provider, { busy: "disconnect", error: null }); try { - await api.delete(path); - await reload(); - toast(t("emailConnectionsDisconnected", { provider: providerLabel }), "success"); + await api.delete(`${providerPaths[provider]}/connection`); + if (!session.mounted || generation !== session.generation) return; + current.status = null; + current.refreshRequired = true; + updateProvider(provider, { status: null, refreshRequired: true }); + const status = await loadStatus(provider, true); + if (session.mounted && generation === session.generation && status && !status.connected) toast(startupTranslation.current("emailConnectionsDisconnected", { provider: providerLabel }), "success"); } catch (error) { - toast(getApiErrorMessage(error, t("emailConnectionsDisconnectFailed", { provider: providerLabel })), "error"); + if (session.mounted && generation === session.generation) updateProvider(provider, { error: { kind: "disconnect", cause: error } }); + } finally { + if (session.mounted && generation === session.generation) { current.writing = false; updateProvider(provider, { busy: null }); } } }; const connectImap = async () => { + const session = oauth.current; + const current = work.current.imap; + if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return; if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) { toast(t("emailConnectionsRequired"), "error"); return; } - setImapConnecting(true); + const port = Number(imapForm.port); + if (!imapForm.port.trim() || !Number.isInteger(port) || port < 1 || port > 65535) { setInvalidPort(true); return; } + const submitted = { ...imapForm }; + current.writing = true; + const generation = session.generation; + rememberFocus("imap"); + updateProvider("imap", { busy: "connect", error: null }); try { - await api.post("/imap/connect", imapForm); - setImapForm((prev) => ({ ...prev, password: "" })); - await loadImapStatus(); - toast(t("emailConnectionsImapConnected"), "success"); + await api.post("/imap/connect", { ...submitted, port }); + if (!session.mounted || generation !== session.generation) return; + rememberFocus("imap"); + // A completed submission must not clear credentials edited for another attempt. + setImapForm(previous => Object.keys(submitted).every(key => previous[key as keyof typeof previous] === submitted[key as keyof typeof submitted]) ? { ...previous, password: "" } : previous); + current.status = null; + current.refreshRequired = true; + updateProvider("imap", { status: null, refreshRequired: true }); + const status = await loadStatus("imap", true); + if (session.mounted && generation === session.generation && status?.connected) toast(startupTranslation.current("emailConnectionsImapConnected"), "success"); } catch (error) { - toast(getApiErrorMessage(error, t("emailConnectionsImapFailed")), "error"); + if (session.mounted && generation === session.generation) updateProvider("imap", { error: { kind: "imap", cause: error } }); } finally { - setImapConnecting(false); + if (session.mounted && generation === session.generation) { current.writing = false; updateProvider("imap", { busy: null }); } } }; - return ( - - {t("emailConnectionsTitle")} - - {t("emailConnectionsBody")} - + const providerFeedback = (provider: Provider, providerLabel: string) => { + const state = providers[provider]; + return <> + {state.loading ? {t("emailConnectionsLoading")} : null} + {state.readFailed ? { rememberFocus(provider); void loadStatus(provider); }}>{t("retry")}}> + {t(state.refreshRequired ? "emailConnectionsRefreshRequired" : "emailConnectionsStatusFailed")} + : null} + {state.error ? {getApiErrorMessage(state.error.cause, t(state.error.kind === "imap" ? "emailConnectionsImapFailed" : "emailConnectionsDisconnectFailed", { provider: providerLabel }))} : null} + ; + }; + return ( + { panels.current.gmail = node; }} + headingRef={node => { headings.current.gmail = node; }} onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")} - onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")} - /> + onDisconnect={() => void disconnect("gmail", "Gmail")} + >{providerFeedback("gmail", "Gmail")} { panels.current.microsoft = node; }} + headingRef={node => { headings.current.microsoft = node; }} onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")} - onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")} - /> + onDisconnect={() => void disconnect("microsoft", "Outlook")} + >{providerFeedback("microsoft", "Outlook")} - - void disconnect("/imap/connection", loadImapStatus, "IMAP")} - /> - {!imapStatus?.connected && ( - + state={providers.imap} + address={providers.imap.status?.username ?? null} + panelRef={node => { panels.current.imap = node; }} + headingRef={node => { headings.current.imap = node; }} + onDisconnect={() => void disconnect("imap", "IMAP")} + > + {providerFeedback("imap", "IMAP")} + {providers.imap.status && !providers.imap.status.connected && !providers.imap.readFailed && !providers.imap.refreshRequired && ( + setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))} + error={invalidPort} + helperText={invalidPort ? t("emailConnectionsPortInvalid") : undefined} + slotProps={{ htmlInput: { min: 1, max: 65535, step: 1 } }} + onChange={(e) => { setImapForm((prev) => ({ ...prev, port: e.target.value })); setInvalidPort(false); }} /> - {imapConnecting ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")} + {providers.imap.busy === "connect" ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")} )} - + - + ); } function ProviderRow({ label, - connected, + state, address, + panelRef, + headingRef, onConnect, onDisconnect, + children, }: { label: string; - connected: boolean; + state: ProviderState; address: string | null; + panelRef: (node: HTMLElement | null) => void; + headingRef: (node: HTMLHeadingElement | null) => void; onConnect?: () => void; onDisconnect: () => void; + children: React.ReactNode; }) { const { t } = useI18n(); return ( - - - {label} - {connected ? ( - } - color="success" - variant="outlined" - label={address || t("emailConnectionsConnected")} - sx={{ mt: 0.5 }} - /> - ) : ( - {t("emailConnectionsNotConnected")} - )} - - {connected ? ( - - ) : ( - onConnect && - )} - + + + + {label} + {state.status && !state.loading && !state.readFailed && !state.refreshRequired ? + {state.status.connected ? address || t("emailConnectionsConnected") : t("emailConnectionsNotConnected")} + : null} + + {state.status && !state.readFailed && !state.refreshRequired ? state.status.connected ? ( + + ) : onConnect ? : null : null} + + {children} + ); } diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 1ffb944..1c382e5 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, @@ -10,7 +10,6 @@ import { InputLabel, MenuItem, Select, - Skeleton, Tab, Tabs, Typography, @@ -30,6 +29,7 @@ import { useI18n } from "../i18n/I18nProvider"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { PageSection } from "../layout/PageLayout"; +import ViewStateNotice from "./ViewStateNotice"; interface Props { pageSize: 15 | 20 | 25; @@ -66,21 +66,38 @@ export default function SettingsView({ const { language, setLanguage, t } = useI18n(); const { toast } = useToast(); const [notificationPrefs, setNotificationPrefs] = useState(null); - const [notificationError, setNotificationError] = useState(null); + const [savedNotificationPrefs, setSavedNotificationPrefs] = useState(null); + const [notificationError, setNotificationError] = useState<{ cause: unknown; action: boolean } | null>(null); + const [loadingNotifications, setLoadingNotifications] = useState(true); const [savingNotifications, setSavingNotifications] = useState(false); const [savedRules, setSavedRules] = useState(null); const [rulesDraft, setRulesDraft] = useState(null); - const [rulesLoadError, setRulesLoadError] = useState(null); + const [rulesLoadError, setRulesLoadError] = useState<{ cause: unknown } | null>(null); const [rulesSaveError, setRulesSaveError] = useState(false); const [invalidRules, setInvalidRules] = useState([]); const [savingRules, setSavingRules] = useState(false); const rulesSession = useRef({ mounted: true, loading: false, saving: false }); + const notificationSession = useRef({ mounted: true, loading: false, saving: false, generation: 0 }); + const notificationBody = useRef(null); + const notificationRetryFocus = useRef(null); + const currentTranslation = useRef(t); const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field]))); + const notificationsDirty = Boolean(notificationPrefs && savedNotificationPrefs && notificationPrefs.emailFollowUpRemindersEnabled !== savedNotificationPrefs.emailFollowUpRemindersEnabled); + + useEffect(() => { currentTranslation.current = t; }, [t]); useEffect(() => { const session = rulesSession.current; + const notifications = notificationSession.current; session.mounted = true; - return () => { session.mounted = false; }; + notifications.mounted = true; + return () => { + session.mounted = false; + notifications.mounted = false; + notifications.generation += 1; + notifications.loading = false; + notifications.saving = false; + }; }, []); const loadRules = async () => { @@ -94,7 +111,7 @@ export default function SettingsView({ setSavedRules(data); setRulesDraft(ruleDraft(data)); } catch (error) { - if (session.mounted) setRulesLoadError(getApiErrorMessage(error, t("rulesLoadFailed"))); + if (session.mounted) setRulesLoadError({ cause: error }); } finally { session.loading = false; } }; @@ -120,7 +137,7 @@ export default function SettingsView({ setSavedRules(submitted); // A completed save acknowledges its snapshot, not edits made while it was pending. setRulesDraft(current => current && RULE_FIELDS.every(field => current[field] === submittedDraft[field]) ? ruleDraft(submitted) : current); - toast(t("rulesSaved"), "success"); + toast(currentTranslation.current("rulesSaved"), "success"); } catch { if (session.mounted) setRulesSaveError(true); } finally { @@ -136,27 +153,74 @@ export default function SettingsView({ setRulesSaveError(false); }; + const loadNotifications = useCallback(async () => { + const session = notificationSession.current; + if (!session.mounted || session.loading) return; + const generation = session.generation; + session.loading = true; + setLoadingNotifications(true); + setNotificationError(null); + try { + const { data } = await api.get("/notification-settings"); + if (!session.mounted || generation !== session.generation) return; + setNotificationPrefs(data); + setSavedNotificationPrefs(data); + } catch (cause) { + if (session.mounted && generation === session.generation) setNotificationError({ cause, action: false }); + } finally { + if (generation === session.generation) { + session.loading = false; + if (session.mounted) setLoadingNotifications(false); + } + } + }, []); + + useEffect(() => { void loadNotifications(); }, [loadNotifications]); + + const retryNotifications = () => { + const active = document.activeElement; + if (active instanceof HTMLElement && notificationBody.current?.contains(active)) notificationRetryFocus.current = active; + void loadNotifications(); + }; + useEffect(() => { - let active = true; - api.get("/notification-settings") - .then(({ data }) => { if (active) setNotificationPrefs(data); }) - .catch((error) => { if (active) setNotificationError(getApiErrorMessage(error, t("settingsNotificationsLoadFailed"))); }); - return () => { active = false; }; - }, [t]); + const target = notificationRetryFocus.current; + if (loadingNotifications || !target) return; + notificationRetryFocus.current = null; + if (!target.isConnected && document.activeElement === document.body) notificationBody.current?.focus(); + }, [loadingNotifications, notificationError, notificationPrefs]); const saveNotifications = async () => { - if (!notificationPrefs) return; + const session = notificationSession.current; + if (!session.mounted || !notificationPrefs || session.saving) return; + const submitted = { emailFollowUpRemindersEnabled: notificationPrefs.emailFollowUpRemindersEnabled }; + const generation = session.generation; + session.saving = true; setSavingNotifications(true); setNotificationError(null); try { - const { data } = await api.put("/notification-settings", notificationPrefs); - setNotificationPrefs(data); - toast(t("settingsNotificationsSaved"), "success"); - } catch (error) { - setNotificationError(getApiErrorMessage(error, t("settingsNotificationsSaveFailed"))); - } finally { setSavingNotifications(false); } + const { data } = await api.put("/notification-settings", submitted); + if (!session.mounted || generation !== session.generation) return; + setSavedNotificationPrefs(data); + setNotificationPrefs(current => current?.emailFollowUpRemindersEnabled === submitted.emailFollowUpRemindersEnabled ? data : current); + toast(currentTranslation.current("settingsNotificationsSaved"), "success"); + } catch (cause) { + if (session.mounted && generation === session.generation) setNotificationError({ cause, action: true }); + } finally { + if (generation === session.generation) { + session.saving = false; + if (session.mounted) setSavingNotifications(false); + } + } }; + const discardNotifications = () => { + if (!savedNotificationPrefs || notificationSession.current.saving) return; + setNotificationPrefs(savedNotificationPrefs); + setNotificationError(null); + }; + const notificationReadStatus = (notificationError?.cause as { response?: { status?: number } } | undefined)?.response?.status; + return ( @@ -274,7 +338,7 @@ export default function SettingsView({ - void saveRules()} onRetry={() => void loadRules()} onDiscard={discardRules} onChange={(field, value) => { setRulesDraft(current => current ? { ...current, [field]: value } : current); @@ -284,15 +348,25 @@ export default function SettingsView({ - {notificationError ? {notificationError} : null} - {!notificationPrefs ? : + + {!notificationPrefs ? : + {notificationError?.action ? {getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))} : null} + {notificationsDirty ? {t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")} : null} setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />} label={t("settingsNotificationsFollowUpReminders")} /> {t("settingsNotificationsEmailBody")} - + + + + } + {t("settingsNotificationsDelivery")} diff --git a/job-tracker-ui/src/email-provider-connections.test.tsx b/job-tracker-ui/src/email-provider-connections.test.tsx index 1d14830..10531c3 100644 --- a/job-tracker-ui/src/email-provider-connections.test.tsx +++ b/job-tracker-ui/src/email-provider-connections.test.tsx @@ -62,6 +62,8 @@ describe("EmailProviderConnections", () => { beforeEach(() => { jest.clearAllMocks(); mockedApi.get.mockReset(); + mockedApi.post.mockReset(); + mockedApi.delete.mockReset(); window.localStorage.clear(); }); @@ -108,13 +110,14 @@ describe("EmailProviderConnections", () => { await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test"); await userEvent.type(screen.getByLabelText("Username"), "user@example.test"); await userEvent.type(screen.getByLabelText("Password"), "secret"); - await userEvent.click(screen.getByRole("button", { name: /connect imap account/i })); + await act(async () => { await userEvent.click(screen.getByRole("button", { name: /connect imap account/i })); }); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({ host: "imap.example.test", username: "user@example.test", password: "secret", }))); + await waitFor(() => expect(statusReads()).toHaveLength(4)); }); it("[P7-09 OAuth] unsolicited success and error messages cannot announce or refresh any connection", async () => { @@ -221,4 +224,207 @@ describe("EmailProviderConnections", () => { expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); }); + + it("[P7-09 Connections] unknown and failed provider reads remain truthful and Retry reads only that provider once", async () => { + const initial = deferred(); const retry = deferred(); let gmailReads = 0; + mockedApi.get.mockImplementation((url: string) => url === "/gmail/status" + ? (++gmailReads === 1 ? initial.promise : retry.promise) + : Promise.resolve({ data: { connected: false } } as any)); + renderComponent(); await screen.findByLabelText("IMAP host"); + const gmail = screen.getByRole("region", { name: "Gmail" }); + expect(within(gmail).getByText(translations.en.emailConnectionsLoading)).toBeVisible(); + expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument(); + expect(within(gmail).queryByRole("button", { name: "Connect" })).not.toBeInTheDocument(); + await act(async () => initial.reject(new Error("offline"))); + expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.en.emailConnectionsStatusFailed); + expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument(); + const button = within(gmail).getByRole("button", { name: translations.en.retry }); + act(() => { fireEvent.click(button); fireEvent.click(button); }); + expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/status")).toHaveLength(2); + expect(mockedApi.get.mock.calls.filter(([url]) => url === "/microsoft-graph/status")).toHaveLength(1); + expect(mockedApi.get.mock.calls.filter(([url]) => url === "/imap/status")).toHaveLength(1); + await act(async () => retry.resolve({ data: { connected: false } })); + expect(within(gmail).getByText("Not connected")).toBeVisible(); + expect(within(gmail).getByRole("button", { name: "Connect" })).toBeEnabled(); + expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled(); + }); + + it("[P7-09 Connections] a held status read follows the current language without a locale refetch", async () => { + const pending = deferred(); + mockedApi.get.mockImplementation((url: string) => url === "/gmail/status" ? pending.promise : Promise.resolve({ data: { connected: false } } as any)); + renderComponent(); await screen.findByLabelText("IMAP host"); + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + await act(async () => pending.reject(new Error("offline"))); + expect(within(screen.getByRole("region", { name: "Gmail" })).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsStatusFailed); + expect(screen.queryByText(translations.en.emailConnectionsStatusFailed)).not.toBeInTheDocument(); + expect(statusReads()).toHaveLength(3); + }); + + it("[P7-09 Connections] a committed disconnect with failed refresh blocks writes until GET-only recovery", async () => { + const pending = deferred(); let reads = 0; + mockedApi.get.mockImplementation((url: string) => { + if (url !== "/gmail/status") return Promise.resolve({ data: { connected: false } } as any); + reads += 1; + return reads === 1 ? Promise.resolve({ data: { connected: true, gmailAddress: "owner@example.test" } } as any) + : reads === 2 ? Promise.reject(new Error("refresh offline")) : Promise.resolve({ data: { connected: false } } as any); + }); + mockedApi.delete.mockReturnValue(pending.promise); + renderComponent(); await screen.findByText("owner@example.test"); + const gmail = screen.getByRole("region", { name: "Gmail" }); + const disconnect = within(gmail).getByRole("button", { name: "Disconnect" }); + act(() => { fireEvent.click(disconnect); fireEvent.click(disconnect); }); + expect(mockedApi.delete).toHaveBeenCalledTimes(1); + expect(mockedApi.delete).toHaveBeenCalledWith("/gmail/connection"); + await act(async () => pending.resolve({ status: 204 })); + expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.en.emailConnectionsRefreshRequired); + expect(within(gmail).queryByText("Not connected")).not.toBeInTheDocument(); + expect(within(gmail).queryByRole("button", { name: "Connect" })).not.toBeInTheDocument(); + const repeated = within(gmail).queryByRole("button", { name: "Disconnect" }); + if (repeated) { expect(repeated).toBeDisabled(); fireEvent.click(repeated); } + expect(mockedApi.delete).toHaveBeenCalledTimes(1); + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsRefreshRequired); + fireEvent.click(within(gmail).getByRole("button", { name: translations.nb.retry })); + await waitFor(() => expect(within(gmail).getByText(translations.nb.emailConnectionsNotConnected)).toBeVisible()); + expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(reads).toBe(3); + expect(statusReads().filter(([url]) => url !== "/gmail/status")).toHaveLength(2); + }); + + it("[P7-09 Connections] failed disconnect preserves its known status and uses current-language explicit retry", async () => { + const pending = deferred(); + mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/gmail/status" ? { connected: true, gmailAddress: "still-connected@example.test" } : { connected: false } } as any)); + mockedApi.delete.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ status: 204 } as any); + renderComponent(); await screen.findByText("still-connected@example.test"); + const gmail = screen.getByRole("region", { name: "Gmail" }); + fireEvent.click(within(gmail).getByRole("button", { name: "Disconnect" })); + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + await act(async () => pending.reject(new Error("offline"))); + expect(within(gmail).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsDisconnectFailed.replace("{provider}", "Gmail")); + expect(within(gmail).getByText("still-connected@example.test")).toBeVisible(); + expect(statusReads()).toHaveLength(3); + fireEvent.click(within(gmail).getByRole("button", { name: translations.nb.emailConnectionsDisconnect })); + await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(statusReads()).toHaveLength(4)); + }); + + it("[P7-09 Connections] IMAP submits one captured snapshot and a late success cannot clear a newer password", async () => { + disconnectedProviderReads(); const pending = deferred(); mockedApi.post.mockReturnValue(pending.promise); + renderComponent(); await screen.findByLabelText("IMAP host"); + fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "submitted-secret" } }); + const button = screen.getByRole("button", { name: "Connect IMAP account" }); + act(() => { fireEvent.click(button); fireEvent.click(button); }); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", { host: "imap.example.test", port: 993, useSsl: true, username: "owner@example.test", password: "submitted-secret" }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "newer-secret" } }); + await act(async () => pending.resolve({ data: { username: "owner@example.test" } })); + await waitFor(() => expect(statusReads()).toHaveLength(4)); + expect(screen.getByLabelText("Password")).toHaveValue("newer-secret"); + expect(screen.getByLabelText("IMAP host")).toHaveValue("imap.example.test"); + expect(mockedApi.post).toHaveBeenCalledTimes(1); + }); + + it("[P7-09 Connections] failed IMAP save retains credentials for an explicit retry and current fallback", async () => { + disconnectedProviderReads(); const pending = deferred(); mockedApi.post.mockReturnValueOnce(pending.promise).mockResolvedValueOnce({ data: {} } as any); + renderComponent(); await screen.findByLabelText("IMAP host"); + fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "retained-secret" } }); + fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" })); + await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); + await act(async () => pending.reject(new Error("offline"))); + expect(within(screen.getByRole("region", { name: translations.nb.emailConnectionsOtherImap })).getByRole("alert")).toHaveTextContent(translations.nb.emailConnectionsImapFailed); + expect(screen.getByLabelText("IMAP-vert")).toHaveValue("imap.example.test"); + expect(screen.getByLabelText("Brukernavn")).toHaveValue("owner@example.test"); + expect(screen.getByLabelText("Passord")).toHaveValue("retained-secret"); + expect(statusReads()).toHaveLength(3); + fireEvent.click(screen.getByRole("button", { name: translations.nb.emailConnectionsConnectImap })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2)); + expect(mockedApi.post.mock.calls[1]).toEqual(["/imap/connect", { host: "imap.example.test", port: 993, useSsl: true, username: "owner@example.test", password: "retained-secret" }]); + await waitFor(() => expect(screen.getByLabelText("Passord")).toHaveValue("")); + }); + + it("[P7-09 Connections] disposed reads and writes cannot refresh, toast or clear a new panel", async () => { + const oldRead = deferred(); const oldWrite = deferred(); let first = true; + mockedApi.get.mockImplementation((url: string) => first && url === "/gmail/status" ? oldRead.promise : Promise.resolve({ data: { connected: false } } as any)); + mockedApi.post.mockReturnValue(oldWrite.promise); + const old = renderComponent(); await screen.findByLabelText("IMAP host"); + fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.old.test" } }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "old@example.test" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "old-secret" } }); + fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" })); + old.unmount(); first = false; + renderComponent(); await screen.findByLabelText("IMAP host"); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "new-panel-secret" } }); + const reads = statusReads().length; + await act(async () => { oldRead.resolve({ data: { connected: true, gmailAddress: "disposed@example.test" } }); oldWrite.resolve({ data: {} }); }); + expect(statusReads()).toHaveLength(reads); + expect(screen.queryByText("disposed@example.test")).not.toBeInTheDocument(); + expect(screen.queryByText(translations.en.emailConnectionsImapConnected)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toHaveValue("new-panel-secret"); + }); + + it("[P7-09 Connections] invalid IMAP ports remain visible and prevent sending a silently substituted port", async () => { + disconnectedProviderReads(); renderComponent(); await screen.findByLabelText("IMAP host"); + fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } }); + const port = screen.getByLabelText("Port"); + for (const value of ["", "0", "1.5", "65536"]) { + fireEvent.change(port, { target: { value } }); + fireEvent.click(screen.getByRole("button", { name: "Connect IMAP account" })); + expect(port).toHaveValue(value ? Number(value) : null); + expect(port).toHaveAttribute("aria-invalid", "true"); + expect(port).toHaveAccessibleDescription(translations.en.emailConnectionsPortInvalid); + } + expect(mockedApi.post).not.toHaveBeenCalled(); + }); + + it("[P7-09 Connections] removed Retry restores meaningful focus without stealing deliberate field focus", async () => { + let attempt = 0; const pending = deferred(); + mockedApi.get.mockImplementation((url: string) => url !== "/gmail/status" ? Promise.resolve({ data: { connected: false } } as any) + : ++attempt === 1 ? Promise.reject(new Error("offline")) : pending.promise); + const first = renderComponent(); await screen.findByLabelText("IMAP host"); + const gmail = screen.getByRole("region", { name: "Gmail" }); + const retry = await within(gmail).findByRole("button", { name: translations.en.retry }); + act(() => { retry.focus(); fireEvent.click(retry); }); + await act(async () => pending.resolve({ data: { connected: false } })); + expect(gmail).toContainElement(document.activeElement as HTMLElement); + expect(document.body).not.toBe(document.activeElement); + // A later explicit field move while a retry is pending must remain authoritative. + let read = 0; const later = deferred(); + mockedApi.get.mockImplementation((url: string) => url !== "/gmail/status" ? Promise.resolve({ data: { connected: false } } as any) + : ++read === 1 ? Promise.reject(new Error("offline")) : later.promise); + // A new mounted panel is an independent read session. + first.unmount(); + const current = renderComponent(); + const panels = screen.getAllByRole("region", { name: "Gmail" }); const newest = panels[panels.length - 1]; + const nextRetry = await within(newest).findByRole("button", { name: translations.en.retry }); + act(() => { nextRetry.focus(); fireEvent.click(nextRetry); }); + const fields = screen.getAllByLabelText("Password"); const field = fields[fields.length - 1]; act(() => field.focus()); + await act(async () => later.resolve({ data: { connected: false } })); + expect(field).toHaveFocus(); current.unmount(); + }); + + it("[P7-09 Connections] an IMAP field removed after commit has a meaningful fallback while refresh is pending", async () => { + const saved = deferred(); const refreshed = deferred(); let imapReads = 0; + mockedApi.get.mockImplementation((url: string) => url === "/imap/status" && ++imapReads > 1 ? refreshed.promise : Promise.resolve({ data: { connected: false } } as any)); + mockedApi.post.mockReturnValue(saved.promise); + renderComponent(); await screen.findByLabelText("IMAP host"); + fireEvent.change(screen.getByLabelText("IMAP host"), { target: { value: "imap.example.test" } }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "owner@example.test" } }); + const password = screen.getByLabelText("Password"); + fireEvent.change(password, { target: { value: "secret" } }); + const button = screen.getByRole("button", { name: "Connect IMAP account" }); + act(() => { button.focus(); fireEvent.click(button); }); + act(() => password.focus()); + await act(async () => saved.resolve({ data: {} })); + expect(password).not.toBeInTheDocument(); + const imap = screen.getByRole("region", { name: translations.en.emailConnectionsOtherImap }); + expect(within(imap).getByRole("heading", { name: translations.en.emailConnectionsOtherImap })).toHaveFocus(); + const other = connectButton("Gmail"); act(() => other.focus()); + await act(async () => refreshed.resolve({ data: { connected: true, username: "owner@example.test" } })); + expect(other).toHaveFocus(); expect(mockedApi.post).toHaveBeenCalledTimes(1); + }); }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 707b447..6cb30e7 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -456,6 +456,11 @@ export const translations = { connectedAccountsBody: "Connect the inboxes Jobbjakt may use to identify recruiter correspondence. Connecting an inbox never sends a message on your behalf.", connectedAccountsBack: "Back to settings", emailConnectionsTitle: "Linked email accounts", + emailConnectionsLoading: "Checking connection…", + emailConnectionsStatusFailed: "Connection status could not be checked.", + emailConnectionsRefreshRequired: "The action completed, but the connection status could not be refreshed. Refresh before taking another action.", + emailConnectionsDisconnecting: "Disconnecting…", + emailConnectionsPortInvalid: "Enter a whole port number from 1 to 65535.", emailConnectionsBody: "Connect a mailbox so recruiter correspondence can be linked to jobs automatically.", emailConnectionsGmailConnected: "Gmail connected.", emailConnectionsGmailFailed: "Gmail connection failed.", @@ -1291,6 +1296,7 @@ export const translations = { settingsQuickCaptureManual: "Or copy the bookmarklet code", settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.", settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.", + settingsNotificationsDraftScope: "Notification changes stay while you switch Settings sections. Save or discard them before leaving Settings; leaving discards unsaved changes.", settingsNotificationsFollowUpReminders: "Email reminders for follow-ups", settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs", settingsNotificationsInAppReminders: "Highlight reminders in the app", @@ -3210,9 +3216,14 @@ export const translations = { operationsFailed: "Mislyktes: {category}", gmailReviewQueue: "Gmail-gjennomgang", connectedAccounts: "Tilkoblede kontoer", - connectedAccountsBody: "Koble til innboksene Jobbjakt kan bruke til å identifisere korrespondanse med rekrutterere. En tilkoblet innboks sender aldri meldinger på dine vegne.", + connectedAccountsBody: "Koble til innboksene Jobbjakt kan bruke til å identifisere korrespondanse med rekrutterere. Å koble til en innboks sender ikke meldinger på dine vegne.", connectedAccountsBack: "Tilbake til innstillinger", emailConnectionsTitle: "Tilkoblede e-postkontoer", + emailConnectionsLoading: "Kontrollerer tilkoblingen…", + emailConnectionsStatusFailed: "Tilkoblingsstatusen kunne ikke kontrolleres.", + emailConnectionsRefreshRequired: "Handlingen er fullført, men tilkoblingsstatusen kunne ikke oppdateres. Oppdater før du utfører en ny handling.", + emailConnectionsDisconnecting: "Kobler fra…", + emailConnectionsPortInvalid: "Angi et helt portnummer fra 1 til 65535.", emailConnectionsBody: "Koble til en innboks slik at korrespondanse med rekrutterere automatisk kan knyttes til jobber.", emailConnectionsGmailConnected: "Gmail er koblet til.", emailConnectionsGmailFailed: "Tilkobling til Gmail mislyktes.", @@ -4048,6 +4059,7 @@ export const translations = { settingsQuickCaptureManual: "Eller kopier bokmerkekoden", settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.", settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.", + settingsNotificationsDraftScope: "Varslingsendringer beholdes når du bytter del i Innstillinger. Lagre eller forkast dem før du går ut; ulagrede endringer går tapt når du forlater Innstillinger.", settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger", settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber", settingsNotificationsInAppReminders: "Fremhev påminnelser i appen", diff --git a/job-tracker-ui/src/settings-view.test.tsx b/job-tracker-ui/src/settings-view.test.tsx index 6c43302..9f5581b 100644 --- a/job-tracker-ui/src/settings-view.test.tsx +++ b/job-tracker-ui/src/settings-view.test.tsx @@ -1,12 +1,13 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import SettingsView from './components/SettingsView'; import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import { api } from './api'; +import { translations, type TranslationKey } from './i18n/translations'; jest.mock('./api', () => ({ api: { @@ -316,3 +317,135 @@ test('[PRES-S03] Rules draft updates do not refetch an unrelated notification pr await act(async () => undefined); expect(mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings')).toHaveLength(reads); }); + +function preferenceCopy(key: TranslationKey) { return translations[document.documentElement.lang === 'nb-NO' ? 'nb' : 'en'][key]; } +function notificationPanel() { return within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabNotifications') })); } +function notificationReads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings'); } +function notificationWrites() { return mockedApi.put.mock.calls.filter(([url]) => url === '/notification-settings'); } +async function openNotifications() { + fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') })); + return notificationPanel().findByRole('checkbox', { name: preferenceCopy('settingsNotificationsFollowUpReminders') }); +} +async function preferenceLanguage(language: 'en' | 'nb') { + await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); }); +} +function preferenceDeferred() { + let resolve!: (value: any) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +test('[P7-09 preferences] initial notification failure offers a single GET Retry and recovers its vanished focused action', async () => { + const pending = preferenceDeferred(); const ordinaryGet = mockedApi.get.getMockImplementation()!; let reads = 0; + mockedApi.get.mockImplementation((url: string, config?: any) => url === '/notification-settings' + ? ++reads === 1 ? Promise.reject(new Error('offline')) : pending.promise : ordinaryGet(url, config)); + renderView(); fireEvent.click(screen.getByRole('tab', { name: 'Notifications' })); + expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsLoadFailed); + const retry = notificationPanel().getByRole('button', { name: 'Retry' }); + act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); }); + expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled(); + await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } })); + expect(await notificationPanel().findByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked(); + expect(notificationPanel().queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + expect(notificationPanel().getByRole('group', { name: translations.en.settingsNotificationsTitle })).toHaveFocus(); + expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('[P7-09 preferences] notification drafts survive internal sections and locale changes without a GET or save', async () => { + renderView(); const checkbox = await openNotifications(); expect(checkbox).toBeChecked(); fireEvent.click(checkbox); + const reads = notificationReads().length; + fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); await screen.findByLabelText('Applied: follow-up days'); + fireEvent.click(screen.getByRole('tab', { name: 'General' })); await preferenceLanguage('nb'); + const retained = await openNotifications(); + expect(retained).not.toBeChecked(); expect(notificationReads()).toHaveLength(reads); + expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.nb.settingsNotificationsDraftScope); + await preferenceLanguage('en'); expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked(); + expect(notificationReads()).toHaveLength(reads); expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('[P7-09 preferences] one held notification save acknowledges its snapshot and preserves later edits across sections', async () => { + const pending = preferenceDeferred(); mockedApi.put.mockImplementationOnce(() => pending.promise); + renderView(); const checkbox = await openNotifications(); fireEvent.click(checkbox); + const save = notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave }); + act(() => { fireEvent.click(save); fireEvent.click(save); }); + expect(notificationWrites()).toEqual([['/notification-settings', { emailFollowUpRemindersEnabled: false }]]); + fireEvent.click(checkbox); expect(checkbox).toBeChecked(); + fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); await screen.findByLabelText('Applied: follow-up days'); + await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } })); + expect(await openNotifications()).toBeChecked(); + expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.en.assetsUnsavedChanges); + fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges })); + expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked(); + expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument(); + expect(notificationReads()).toHaveLength(1); expect(notificationWrites()).toHaveLength(1); +}); + +test('[P7-09 preferences] failed notification save keeps its current-locale draft and retries only the original Save', async () => { + mockedApi.put.mockRejectedValueOnce(new Error('offline')); + renderView(); fireEvent.click(await openNotifications()); + fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave })); + expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsSaveFailed); + const reads = notificationReads().length; await preferenceLanguage('nb'); + expect(notificationPanel().getByRole('alert')).toHaveTextContent(translations.nb.settingsNotificationsSaveFailed); + expect(notificationPanel().getByRole('checkbox', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked(); + expect(notificationPanel().queryByRole('button', { name: translations.nb.retry })).not.toBeInTheDocument(); + fireEvent.click(notificationPanel().getByRole('button', { name: translations.nb.settingsNotificationsSave })); + await waitFor(() => expect(notificationPanel().queryByRole('alert')).not.toBeInTheDocument()); + expect(notificationWrites()).toEqual([ + ['/notification-settings', { emailFollowUpRemindersEnabled: false }], + ['/notification-settings', { emailFollowUpRemindersEnabled: false }], + ]); + expect(notificationReads()).toHaveLength(reads); + expect(await screen.findByText(translations.nb.settingsNotificationsSaved)).toBeVisible(); +}); + +test('[P7-09 preferences] discarding a notification draft leaves the independent Rules draft untouched', async () => { + renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); + fireEvent.click(await openNotifications()); + fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges })); + expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked(); + expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); + expect(screen.getByRole('status')).toHaveTextContent(translations.en.rulesDraftScope); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('[P7-09 preferences] a disposed notification save cannot publish into surviving providers or a new Settings session', async () => { + const pending = preferenceDeferred(); mockedApi.put.mockImplementationOnce(() => pending.promise); + function Session() { + const [visible, setVisible] = React.useState(true); + return + + {visible ? undefined} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => undefined} themeMode="dark" onThemeModeChange={() => undefined} /> : null} + ; + } + render(); fireEvent.click(await openNotifications()); + fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave })); + expect(notificationWrites()).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: 'Toggle Settings session' })); + await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } })); + expect(screen.queryByText(translations.en.settingsNotificationsSaved)).not.toBeInTheDocument(); + expect(notificationReads()).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'Toggle Settings session' })); + expect(await openNotifications()).toBeChecked(); expect(notificationReads()).toHaveLength(2); + expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument(); + expect(notificationWrites()).toHaveLength(1); +}); + +test('[P7-09 preferences] Rules load failure follows locale without reloading and Retry retains the six-field contract', async () => { + const ordinaryGet = mockedApi.get.getMockImplementation()!; let reads = 0; + mockedApi.get.mockImplementation((url: string, config?: any) => url === '/rules' && ++reads === 1 ? Promise.reject(new Error('offline')) : ordinaryGet(url, config)); + renderView(); fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); + expect(await screen.findByRole('alert')).toHaveTextContent(translations.en.rulesLoadFailed); + await preferenceLanguage('nb'); + expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.rulesLoadFailed); + expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: translations.nb.retry })); + const fields = [ + ['rulesAppliedFollowUpDays', 14], ['rulesAppliedGhostDays', 30], ['rulesOfferFollowUpDays', 7], + ['rulesOfferGhostDays', 14], ['rulesFeedbackFollowUpDays', 7], ['rulesFeedbackGhostDays', 14], + ] as const; + for (const [key, value] of fields) expect(await screen.findByLabelText(translations.nb[key])).toHaveValue(value); + expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(2); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); diff --git a/job-tracker-ui/src/views/ConnectedAccountsPage.tsx b/job-tracker-ui/src/views/ConnectedAccountsPage.tsx index e629719..c5fa283 100644 --- a/job-tracker-ui/src/views/ConnectedAccountsPage.tsx +++ b/job-tracker-ui/src/views/ConnectedAccountsPage.tsx @@ -1,24 +1,20 @@ import React from "react"; -import { Box, Button, Paper, Typography } from "@mui/material"; +import { Box, Button } from "@mui/material"; import { useNavigate } from "react-router-dom"; import EmailProviderConnections from "../components/EmailProviderConnections"; import { useI18n } from "../i18n/I18nProvider"; +import { PageHeader } from "../layout/PageLayout"; export default function ConnectedAccountsPage() { const navigate = useNavigate(); const { t } = useI18n(); return ( - - - {t("connectedAccounts")} - - {t("connectedAccountsBody")} - - - + + navigate("/settings")}>{t("connectedAccountsBack")}} /> );