feat(ui): name controls and preserve modal state

This commit is contained in:
cesnimda
2026-09-11 19:31:32 +02:00
parent 682a3028bb
commit ebd4a3e245
52 changed files with 1366 additions and 42 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Redesign traceability
Successor planning checkpoint: [Phase 3 impact matrix and architecture](../phase-3/PRESERVATION_IMPACT_MATRIX.md). It maps all 57 contracts to proposed destinations/validation, without changing their requirements or claiming implemented/visual parity. The subsequent [P4-01 implementation trace sheet](../phase-4/p4-01/TRACEABILITY.md) links shared semantic/focus changes to executed regression and representative visual evidence; P4-02 has not started.
Successor planning checkpoint: [Phase 3 impact matrix and architecture](../phase-3/PRESERVATION_IMPACT_MATRIX.md). It maps all 57 contracts to proposed destinations/validation, without changing their requirements or claiming implemented/visual parity. The subsequent [P4-01 trace sheet](../phase-4/p4-01/TRACEABILITY.md) and [P4-02 named-control/modal record](../phase-4/p4-02/README.md) link shared foundation changes to regression and representative visual evidence. P4-03 remains unstarted.
Phase 2 starts at `2d0f131`. The [preservation matrix](PRESERVATION_MATRIX.md) is the stable baseline: **PRES-E01…PRES-A03**, in the original ledger's groups (not one contiguous numeric range). Old references such as C07 remain valid aliases. Subdivide a later workflow with a suffix, for example `PRES-C07.override-reset`; do not reuse an ID or silently change its meaning.
+2 -1
View File
@@ -4,5 +4,6 @@ Phase 4 starts from `f696f23` on `codex/ui-redesign-phase4`. The 57 preservation
- [P4-01 semantic colors, focus and loaded fixtures](p4-01/README.md)
- [P4-01 preservation and visual evidence](p4-01/TRACEABILITY.md)
- [P4-02 named controls and modal state](p4-02/README.md)
Only P4-01 is authorized in this checkpoint. No route/layout redesign, P4-02, B07, backend/schema work or production deployment is included. F04 remains open pending actual production ingress evidence.
P4-02 is locally validated after `115441a` and the separate documentation restructure `682a302` on the same Phase 4 branch. Its approved scope is named controls and modal/loading state; P4-03 remains unstarted. No route/layout redesign, B07, backend/schema behaviour changes or production deployment is included. F04 remains open pending actual production ingress evidence.
+148
View File
@@ -0,0 +1,148 @@
# P4-02 — Named controls and modal state
Implementation and local checkpoint validation complete on 2026-09-11, from `115441a409f23b56fa7db3349897bf23d738a2b3` on `codex/ui-redesign-phase4`. Scope: the P4-02 row of the [approved plan](../../phase-3/PHASE4_IMPLEMENTATION_PLAN.md). No P4-03, layouts, navigation, backend contracts or B07. F04 remains open.
## Evidence before implementation
- Eight raw Select call sites display labels without linking them to the control: four in CvCustomizeTab, three in the inline CoverLetterAiAssistant, one per attachment row. Existing labelled Settings, Career Profile, Jobs and Correspondence selects are retained.
- All six RichTextField callers already supply a visible label; toolbar buttons have translated names and preserve textarea selection. Protect these existing behaviours instead of replacing the component.
- Confirm/prompt titles already name MUI dialogs. Their explanatory messages are not descriptions; the prompt's textbox is unnamed. Confirmation has no deliberate initial action focus. Preserve MUI focus trapping, Escape and restoration.
- QuickCommandDialog and its placeholder-only textbox are unnamed. Preserve existing Tab/Enter commands, callbacks, query reset and owner boundary; command expansion and P3-O02 request sequencing are separate work.
- ViewStateNotice announces initial loading and errors but removes the status region on success. Six loading-aware notices (Dashboard has two resources, plus Companies, Kanban, Reminders and AdminAudit) and JobTable's error-only uses need distinct treatment. Do not announce a completion merely because an idle/error-only component mounted or an editor changed.
## Preservation mapping before changes
| Contracts | Existing behaviour / planned change | Existing automated protection | P4-02 validation |
|---|---|---|---|
| PRES-C08/C09 | Capability-aware CV controls; link four visible labels without changing patches/defaults | cvBuilder, cv-builder-deep-link, CvBuilder backend, semantic-foundations browser | Named controls, locale, unchanged setting patches; keyboard Select/preview persistence |
| PRES-W06/W08 | Cover-letter AI parameters/review and attachment purpose; link labels only | application-assets, attachments, ApplicationAssets/Attachments backend | Named controls, exact metadata/AI payloads, accept/reject retained |
| PRES-E07/E08 | Command search, translated dialogs and focus | quick-command-preservation, confirm, prompt, dialog-flow, semantic-foundations | Keyboard names, safe confirmation focus, Escape/return, unchanged results |
| PRES-E08/J08/J07/W11/S01/A02 | Read loading/empty/error/retry through ViewStateNotice | loaded-theme-states, use-view-resource, company/reminder/dashboard/admin suites | Persistent polite completion, no repeated editing announcements, error/draft retention |
| PRES-C04/C05/C07/C10, PRES-W01/W09, PRES-J02/J05/J06, PRES-M01/M04, PRES-S05/A01 | Shared dialog callers: rename/delete/discard/restore/send/account/admin actions | Existing CV, workspace, correspondence, account export/deletion, admin suites | Full regression; shared primitive changes only, no caller operation changes |
RichTextField retention also protects PRES-C07 and application-package drafts within PRES-W01. This is a scoped extension of the [57-group matrix](../../phase-2/PRESERVATION_MATRIX.md), not a claim to visually retest every contract. Completed regression and visual evidence follows below.
## Documentation restructure / retained debt
The 2026-09-11 documentation restructure keeps the Phase 2 contracts and approved Phase 3 plan in place. Current open work now belongs in `docs/roadmap/todo.md`; do not recreate archived `docs/todo/work.md`. Concurrent documentation/link changes are separate from this implementation.
Connor explicitly requested committing the restructure first. Commit `682a302` contains that work (current guides, recoverable archive, comment/link updates, example placeholders, tracked roadmap TODO), including correction of stale claims that shipped CV features were new work and that B01 socket enforcement was incomplete. Generated dependency/cache README copies remain ignored locally. Local Markdown link targets, staged whitespace and secret scan passed. It contains no P4-02 UI changes; the one overlapping ApplicationAssets comment was staged independently. P4-02 continues on top of that documentation checkpoint without discarding the in-progress UI work.
## Implementation decisions
- **Eight production files:** `src/components/cv/CvCustomizeTab.tsx`, `src/components/ApplicationAssets.tsx`, `src/components/Attachments.tsx`, `src/components/QuickCommandDialog.tsx`, `src/components/ViewStateNotice.tsx`, `src/confirm.tsx`, `src/prompt.tsx`, `src/i18n/translations.ts` (all under `job-tracker-ui`).
- Use React `useId` for existing visible Select labels: four CV controls, three inline cover-letter AI controls, and each attachment purpose row. The attachment's existing AI-consent switch also receives its translated purpose plus filename; retain its `switch` role. Options, capability filtering, defaults, patch payloads and consent values are unchanged.
- Keep MUI DialogTitle naming and focus traps. Link confirm/prompt messages with `aria-describedby`; prompt input uses the visible prompt as `aria-labelledby` and retains its automatically associated character-count helper, 180-character limit and initial draft. Confirmation initially focuses Cancel; Escape/Cancel still resolve false/null, confirmation still resolves true/the exact entered string, and focus returns through MUI. No modal queue or focus framework added.
- Name command search's actual dialog paper and textbox with the existing translated Quick Search label. MUI forwards a root `aria-label` to a presentation wrapper, so paper slot naming is deliberate. No command destinations, debounce, result logic or keyboard model changed.
- ViewStateNotice retains one visually hidden polite, atomic status region through initial load, success and error. Only a notice which actually observed loading announces `Content loaded.` / `Innhold lastet.` on success, including empty success and recovery from initial failure. The region clears on error; the existing visible Alert/retry policy remains. Error-only callers do not invent completion. Subsequent edits leave the live text unchanged; the browser observes DOM mutations to prove this representative case. Background refresh/retry progress flags are not newly wired across the application; per-resource and manual assistive-technology coverage remains bounded below.
- RichTextField production code is unchanged: six existing caller labels, four translated toolbar actions, Markdown selection/focus restoration and disabled editing remain. Shared tests prove this. No generic form wrapper, form rewrite, dependency, theme/token change, AppShell change, route restructuring or CI change.
## Regression additions
- `named-controls.test.tsx`: five cases (EN/NB CV label-to-setting changes; EN/NB rich text/Markdown/focus; disabled/separately named fields).
- `modal-state.test.tsx`: four cases (EN/NB confirm and prompt cancellation/Escape/accept, initial and returned focus, exact text, helper link and reopen defaults).
- `view-state-announcements.test.tsx`: three cases (EN/NB stable loading/error/recovery region; idle consumers and later loading).
- Existing application-assets, attachments, confirm, prompt and command tests now locate controls by meaningful names/description. The AI explicit apply/reject/save gates and attachment metadata payloads remain asserted.
- Existing six loaded-theme component cases and eight P4-01 company browser cases now expect a persistent completion status instead of its removal. No cases removed.
- New `e2e/named-controls-modal-state.spec.ts`: four representative real-API workflows, each covers commands, company failed-save draft, CV-only override/canonical isolation, inline AI parameter names, attachment purpose/rename/cancelled deletion, modal Tab/Shift+Tab/Escape/focus return. Synthetic non-admin accounts, isolated SQLite, browser outbound traffic blocked; only an explicit company PUT is replaced with 503. No real AI provider calls.
The original complete PRES rows remain the requirements; this record supplies the implementation/test/visual links for the scoped changes above. The commit containing this record is the P4-02 implementation checkpoint (`git log -- docs/redesign/phase-4/p4-02`), avoiding a self-referential hash.
**P4-02-D01 (existing structural accessibility debt, PRES-E07):** opening command search now receives a full dialog axe scan. Its unchanged MUI List is a `ul` containing button `div` children, violating the `list` rule. Baseline source at `115441a` has the same structure. Retain the full scan evidence and narrowly recognize only this command `MuiList-root` defect; every other dialog violation remains blocking. Fix list semantics in the later command/shell slice; this naming change does not certify the existing command list as fully accessible. P2-D01 and F04 remain open.
**P4-02-D02 (existing save-error accessibility debt, PRES-J08/E08):** CompaniesTable keeps its draft after a failed save, but reports the error through ToastProvider outside the MUI dialog. That toast is visually present and excluded from the accessible tree by the modal's background isolation; its 3.5-second dismissal is also insufficient as durable form error feedback. Source is unchanged from the checkpoint. Record the actual 503, retained draft and hidden-alert condition explicitly. A company/form-validation slice should render persistent error feedback inside the dialog; do not weaken modal isolation or globally move toasts during the named-control change. This ticket does not claim that caller's save-error announcement is repaired.
**P4-02-D03 (existing mobile workspace accessibility debt, PRES-W01/E08):** the two phone-width cover-letter scans report `scrollable-region-focusable` on the application progress strip. Its existing `ApplicationWorkspacePage` horizontal Box contains non-interactive stage content without keyboard access to scrolling. Full node evidence is retained in the scan artifact. Fix in its workspace/responsive slice; do not add focusability to unrelated cards or include the workspace layout in P4-02.
**P4-02-D04 (existing translation/content defect, PRES-W06/E08):** the inline cover-letter context displays a literal `{name}` in both languages. `coverAiUsing` contains the placeholder while the unchanged caller appends the CV name separately. Screenshots retain this defect; correct interpolation in the relevant application/AI content slice. It does not change AI source selection or payloads.
## Validation baseline
| Gate | Inherited P4-01 | P4-02 result |
|---|---:|---:|
| Backend Release xUnit | 916 pass | 916 pass, 0 skipped/failed |
| Frontend Jest | 333 pass / 70 suites | 345 pass / 73 suites; +12 cases in three new files |
| General Chromium | 35 actual pass + P2-D01 expected failure | 39 actual pass + the same P2-D01 expected failure; +4 cases |
| TypeScript / lint | pass | pass, including separate checking of both affected E2E files |
| Frontend production / API Release | pass | pass; API 0 warnings/errors with warnings as errors |
| Dedicated rate-limit HTTP regressions | pass | 9 pass |
| Dedicated ingress HTTP regressions | pass | 6 pass |
| Dedicated shared PDF admission HTTP probe | pass | 1 pass |
| Effective Compose / adversarial ingress validation | pass | 3 pass |
Playwright's summary counts 40 expected outcomes as “40 passed.” The JSON report distinguishes **39 `passed → passed` and one `failed → failed`**. The latter is the unchanged SQLite native package-capture `DateTimeOffset` failure in `redesign-preservation.spec.ts`; it is not a successful package workflow. Of the 39 actual passes, **23 pre-P4-01 + 12 P4-01 + 4 P4-02**. No expected-failure annotation or baseline case was removed.
The final candidate includes no application changes after the production build and full backend/frontend run. The final complete browser rerun refreshed screenshots after adjusting capture scroll position and returned the results above. The full backend suite includes B01 destination enforcement, B02 ownership/session, B03 admission, B04 limiting and B05 CSV regressions; dedicated HTTP/configuration checks additionally preserve their ingress interactions. These are local regressions, not deployed ingress evidence.
## Visual, responsive and accessibility review
The final Chromium run captured **28 screenshots**: seven states in each of four combinations. Rendered screenshots were inspected alongside keyboard behaviour; no screenshot-specific application styles were applied. Desktop is 1440 × 900 and phone is 390 × 844. These four representative combinations are not a full eight-combination theme/language/viewport certification.
| Theme / language / width | Representative rendered evidence |
|---|---|
| Light / English / desktop | [Command](visual/command-light-en-1440.png), [CV select](visual/cv-select-light-en-1440.png), [AI controls](visual/cover-controls-light-en-1440.png), [prompt](visual/prompt-light-en-1440.png), [confirm](visual/confirm-light-en-1440.png) |
| Dark / Bokmål / desktop | [Command](visual/command-dark-nb-1440.png), [CV select](visual/cv-select-dark-nb-1440.png), [AI controls](visual/cover-controls-dark-nb-1440.png), [prompt](visual/prompt-dark-nb-1440.png), [retained draft](visual/retained-draft-dark-nb-1440.png) |
| Light / Bokmål / phone | [Command](visual/command-light-nb-390.png), [lower CV controls](visual/cv-treatments-light-nb-390.png), [AI controls](visual/cover-controls-light-nb-390.png), [prompt](visual/prompt-light-nb-390.png), [confirm](visual/confirm-light-nb-390.png) |
| Dark / English / phone | [CV select](visual/cv-select-dark-en-390.png), [lower CV controls](visual/cv-treatments-dark-en-390.png), [AI controls](visual/cover-controls-dark-en-390.png), [prompt](visual/prompt-dark-en-390.png), [confirm](visual/confirm-dark-en-390.png) |
Observed results:
- Prompt/confirm text, messages and character helper remain readable in both themes. P4-01 focus rings remain visible around the edited textbox, returned Select and safe Cancel action without clipping in the captured controls. Confirmation retains warning icon and explanatory text as well as semantic colour.
- Keyboard tests exercise opening, initial focus, Tab/Shift+Tab wrap, Escape, reopening defaults, acceptance/cancellation and return to the initiating control. The same interactions work in both languages and viewport sizes. Reduced-motion preference is active; no new motion is introduced.
- CV font controls keep their existing two-column presentation; lower treatment controls remain reachable by scrolling. Mobile Edit/Preview and the desktop document preview are retained. The existing overflow warning is not hidden or repaired. CV-only persistence is checked against unchanged canonical profile data.
- AI controls keep their inline desktop row and existing stacked phone layout. Free-plan gating remains visible. No extra cards, spacing changes, feature-level colours, branding or theme divergence were introduced. The untranslated placeholder is recorded as D04 rather than cleaned up incidentally.
- The phone command placeholder remains visually truncated alongside its shortcut caption, but its new accessible name is complete. Existing mobile workspace tabs and wide attachment rows still require their own responsive work. Keyboard access to tested actions is proven; 320px reflow and real touch are not.
- The failed company-save screenshot retains the user's draft and visible toast; the automated test explicitly proves that the toast is outside the modal's accessible tree (D02), so this evidence must not be described as successful error announcement.
[SCAN_RESULTS.json](SCAN_RESULTS.json) contains all **58** axe scans from the final general suite: 38 inherited P4-01 scans and 20 new P4-02 scans. Among the 20 new scans, all four CV-control, two desktop AI-control, four prompt and four confirmation scans report no violations. Four command scans retain D01 (`list`); the two phone AI/workspace scans retain D03 (`scrollable-region-focusable`). Across all 58 scans there are **zero `color-contrast` violations**. Incomplete-rule identifiers/counts and offending nodes are retained, so this does not imply manual contrast/assistive-technology certification. Full dialog rules are blocking except the narrowly identified existing command-list defect; loaded-page gates cover naming and inherited contrast while preserving all other scan findings in evidence. No broad rule exclusion was added.
## Scope review and acceptance
The bounded P4-02 implementation is complete: evidenced missing names are linked, shared dialogs expose their descriptions and preserve native MUI focus handling, and loading-aware notices have a stable completion region. Existing rich-text behaviour, draft retention, CV/attachment relationships and AI review gates are retained by behavioural tests. The eight production-file diff contains no AppShell, navigation, layout, theme, backend, schema, auth, dependency or CI change. Shared P4-01 semantic/focus styling remains authoritative. Remaining caller-level and structural defects are explicit below and do not become a claim that all forms or assistive technologies are covered.
## Commands and reproducibility
From the repository root on Windows (all data is synthetic/local):
```powershell
$env:DOTNET_ROOT='C:/Users/Connor/.dotnet'
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger 'trx;LogFileName=p4-02.trx' --results-directory .artifacts/redesign-p4-02 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi/JobTrackerApi.csproj --configuration Release --no-restore -warnaserror
python scripts/test-ingress.py
Set-Location job-tracker-ui
npm test -- --watchAll=false --runInBand
npm run typecheck
npm run lint
node node_modules/typescript/bin/tsc --project ../.artifacts/p4-02-e2e-tsconfig.json
$env:NEXT_PUBLIC_API_BASE_URL='http://localhost:5302/api'
$env:NEXT_TELEMETRY_DISABLED='1'
npm run build
$env:DOTNET_HOST_PATH='C:/Users/Connor/.dotnet/dotnet.exe'
$env:PLAYWRIGHT_STATIC_EXPORT='true'
$env:PLAYWRIGHT_PREBUILT_EXPORT='true'
$env:P4_02_CAPTURE='true'
$env:PLAYWRIGHT_JSON_OUTPUT_FILE='../.artifacts/p4-02-browser-results.json'
node node_modules/@playwright/test/cli.js test --reporter=list,json
node node_modules/@playwright/test/cli.js test --config=playwright.rate-limits.config.ts
node node_modules/@playwright/test/cli.js test --config=playwright.ingress.config.ts
node node_modules/@playwright/test/cli.js test --config=playwright.pdf.config.ts
Set-Location ..
python scripts/supply-chain.py scan-secrets
git diff --check
git diff --cached --check
```
The ignored E2E type config extends the frontend tsconfig, includes `semantic-foundations.spec.ts` and `named-controls-modal-state.spec.ts`, disables incremental caching, and uses frontend Node types. No package/CI change was needed. No recurrence of the old TS2571.
Final repository checks: `python scripts/supply-chain.py scan-secrets` passed with the P4-02 files staged; `git diff --check` and `git diff --cached --check` passed. A local Markdown target check covered 116 current/root documents (excluding preserved archive snapshots), with zero missing local targets. Logs, SQLite fixtures and test traces remain ignored under `.artifacts`/test output; only synthetic screenshots and curated axe findings are committed.
Before-fix regression evidence: eight naming/description failures across the targeted component suites, plus three missing-completion failures; the additional RichTextField selection failure was a test timing assumption (the existing `requestAnimationFrame` completes selection after focus is already present), corrected by awaiting the selected text. All 44 targeted cases then passed. Further harness corrections: RTL `getByRole` uses exact string matching without Playwright's `exact` option; a Next route-announcer alert requires selecting the specific toast; native CV autosave may finish during axe/capture, so await Saved rather than click a disabled Save button. During concurrent docs validation, the shared static output was rebuilt for port 5202: trace requests proved that mismatch; rebuild with 5302 restored the isolated test path. No application workaround or test gate weakening was used for those harness issues.
## Limits / next slice
- All 57 PRES contracts remain unchanged. This is representative named-control/modal proof, not a new coverage percentage for every product workflow.
- No real AI provider evaluation/delivery, production infrastructure or paid-account operations were attempted. Existing Free/Pro and B02/B04/B03 controls remain independently tested.
- Narrator/NVDA/mobile screen reader, real touch/virtual keyboard, 200%/400% zoom, 320px reflow and forced-colors verification remain manual. DOM names/live regions and Chromium keyboard tests do not establish how every assistive technology announces them.
- The load completion region does not add a new background-refresh scheduler, per-resource contextual narration, or field-by-field validation framework. Preserve P4-01 initial loading/error/retry behaviour; broader asynchronous workflow changes need their owner-specific slice.
- P4-02-D01–D04 remain explicit existing defects, not preservation requirements for broken behaviour. P2-D01 and F04 remain open. The existing CV overflow warning and preview keyboard debt belong to P4-03; mobile tabs/attachment-table reflow belong to their workspace/layout slices. No overflow is hidden as a workaround.
- Recommended next ticket only: **P4-03 — builder preview keyboard boundary**, after review of this checkpoint and its existing integrity coordination requirements. It has not started. No B07, broad visual redesign, route/schema/auth change, branding decision or production deployment.
@@ -0,0 +1,808 @@
{
"outcomes": {
"passed -> passed": 39,
"failed -> failed": 1
},
"scans": [
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/en/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "command-axe",
"violations": [
{
"id": "list",
"impact": "serious",
"nodes": [
{
"target": [
".css-17a0s5q"
],
"html": "<ul class=\"MuiList-root MuiList-padding css-17a0s5q\">",
"failureSummary": "Fix all of the following:\n List element has direct children that are not allowed: [role=button]"
}
]
}
],
"incomplete": [
{
"id": "aria-valid-attr-value",
"nodes": 1
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/en/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cv-controls-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/en/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cover-controls-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/en/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "prompt-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 2
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/en/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "confirm-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 2
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/nb/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "command-axe",
"violations": [
{
"id": "list",
"impact": "serious",
"nodes": [
{
"target": [
".css-17a0s5q"
],
"html": "<ul class=\"MuiList-root MuiList-padding css-17a0s5q\">",
"failureSummary": "Fix all of the following:\n List element has direct children that are not allowed: [role=button]"
}
]
}
],
"incomplete": [
{
"id": "aria-valid-attr-value",
"nodes": 1
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/nb/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cv-controls-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/nb/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cover-controls-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/nb/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "prompt-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 2
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/nb/1440 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "confirm-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 2
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/nb/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "command-axe",
"violations": [
{
"id": "list",
"impact": "serious",
"nodes": [
{
"target": [
".css-17a0s5q"
],
"html": "<ul class=\"MuiList-root MuiList-padding css-17a0s5q\">",
"failureSummary": "Fix all of the following:\n List element has direct children that are not allowed: [role=button]"
}
]
}
],
"incomplete": [
{
"id": "aria-valid-attr-value",
"nodes": 1
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/nb/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cv-controls-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/nb/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cover-controls-axe",
"violations": [
{
"id": "scrollable-region-focusable",
"impact": "serious",
"nodes": [
{
"target": [
".css-1nqhdh5"
],
"html": "<div class=\"MuiBox-root css-1nqhdh5\">",
"failureSummary": "Fix any of the following:\n Element should have focusable content\n Element should be focusable"
}
]
}
],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/nb/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "prompt-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] light/nb/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "confirm-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/en/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "command-axe",
"violations": [
{
"id": "list",
"impact": "serious",
"nodes": [
{
"target": [
".css-17a0s5q"
],
"html": "<ul class=\"MuiList-root MuiList-padding css-17a0s5q\">",
"failureSummary": "Fix all of the following:\n List element has direct children that are not allowed: [role=button]"
}
]
}
],
"incomplete": [
{
"id": "aria-valid-attr-value",
"nodes": 1
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/en/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cv-controls-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/en/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "cover-controls-axe",
"violations": [
{
"id": "scrollable-region-focusable",
"impact": "serious",
"nodes": [
{
"target": [
".css-1nqhdh5"
],
"html": "<div class=\"MuiBox-root css-1nqhdh5\">",
"failureSummary": "Fix any of the following:\n Element should have focusable content\n Element should be focusable"
}
]
}
],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 7
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/en/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "prompt-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] dark/en/390 named controls, modal keyboard and retained draft",
"file": "named-controls-modal-state.spec.ts",
"state": "confirm-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/en/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/en/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/en/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E08 PRES-J08] light/en/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/en/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/en/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E08 PRES-J08] light/nb/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/nb/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/nb/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E08 PRES-J08] light/nb/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/nb/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] light/nb/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J05 PRES-J07] light loaded status semantics, navigation and command focus",
"file": "semantic-foundations.spec.ts",
"state": "loaded-jobs-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 6
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-template-button-hover-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-content-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 3
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-customize-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 10
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-ai-locked-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 5
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-history-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 5
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-settings-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 3
}
]
},
{
"test": "[PRES-E08 PRES-J08] dark/en/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/en/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/en/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E08 PRES-J08] dark/en/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/en/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/en/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E08 PRES-J08] dark/nb/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/nb/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/nb/1440 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E08 PRES-J08] dark/nb/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-error-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/nb/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-axe",
"violations": [],
"incomplete": []
},
{
"test": "[PRES-E08 PRES-J08] dark/nb/390 loading → error → retry → empty → populated",
"file": "semantic-foundations.spec.ts",
"state": "loaded-company-dialog-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "color-contrast",
"nodes": 9
}
]
},
{
"test": "[PRES-E07 PRES-E08 PRES-J05 PRES-J07] dark loaded status semantics, navigation and command focus",
"file": "semantic-foundations.spec.ts",
"state": "loaded-jobs-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 5
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-template-button-hover-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-content-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 3
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-customize-axe",
"violations": [],
"incomplete": [
{
"id": "aria-hidden-focus",
"nodes": 1
},
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 10
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-ai-locked-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 5
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-cv-history-axe",
"violations": [],
"incomplete": [
{
"id": "aria-prohibited-attr",
"nodes": 2
},
{
"id": "color-contrast",
"nodes": 5
}
]
},
{
"test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings",
"file": "semantic-foundations.spec.ts",
"state": "loaded-settings-axe",
"violations": [],
"incomplete": [
{
"id": "color-contrast",
"nodes": 3
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+2 -1
View File
@@ -4,9 +4,10 @@ Status reconciled: 2026-09-11.
## Current repository work
- **P4-02 — open:** finish the current UI semantic-control and modal-state review, preserve `RichTextField`/draft behavior, then run full regression and visual evidence. The active implementation record is [`docs/redesign/phase-4/p4-02/README.md`](../redesign/phase-4/p4-02/README.md).
- **P4-03 — not started:** review the completed [P4-02 named-control/modal checkpoint](../redesign/phase-4/p4-02/README.md) before authorizing the builder preview keyboard boundary. Preserve its regression coverage and the existing integrity coordination gates.
- **P2-D01 — open:** repair the SQLite `DateTimeOffset` ordering baseline before package/tailoring restructuring; retain the expected-failure browser oracle until native capture and historical-binary preservation are proven. P2-O01's initial rules-500 behavior also remains unconfirmed.
- **P3-O01/P3-O02 — open:** address analytics population/period labels and navigation/query/error retention when those surfaces are next changed. Evidence is in the current [redesign decision records](../redesign/phase-3/OPEN_DECISIONS.md).
- **P4-02-D01–D04 — existing accessibility/content debt:** command-list semantic children, company save-error feedback outside the modal's accessible subtree, the mobile application-progress scroll region, and the literal AI context `{name}` placeholder. Evidence and owning future slices are in the [P4-02 record](../redesign/phase-4/p4-02/README.md). Do not disable modal background isolation or hide findings.
- **F04/B06 — open:** complete the authorized main release and production ingress/runtime verification; the browser gate and runner diagnostics remain unresolved. No gate should be bypassed.
## Delivery and feature gates
@@ -0,0 +1,186 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type APIRequestContext, type Page, type TestInfo } from "@playwright/test";
import { translations } from "../src/i18n/translations";
const apiUrl = "http://localhost:5302/api";
const password = "E2ePassword123!";
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(c => c.name === "XSRF-TOKEN")!.value;
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
}
const test = base.extend<{ owner: { headers: Record<string, string>; companyId: number; jobId: number; cvId: number } }>({
owner: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p402-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = await login(page.request, email);
await context.route("**/*", route => {
const url = new URL(route.request().url());
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
});
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS" } });
expect(company.status()).toBe(201);
const companyId = (await company.json()).id;
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: "Engineer", status: "Saved" } });
expect(job.ok()).toBe(true);
const jobId = (await job.json()).id;
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Named controls CV", jobApplicationId: jobId } });
expect(cv.status()).toBe(200);
const cvId = (await cv.json()).id;
expect((await page.request.put(`${apiUrl}/jobapplications/${jobId}/cv`, { headers, data: { variantId: cvId } })).status()).toBe(200);
expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(jobId), files: { name: "evidence.txt", mimeType: "text/plain", buffer: Buffer.from("Synthetic æøå\n") } } })).ok()).toBe(true);
await provide({ headers, companyId, jobId, cvId });
} finally { await admin.dispose(); }
},
});
async function capture(page: Page, info: TestInfo, name: string) {
const path = process.env.P4_02_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-02/visual/${name}.png` : info.outputPath(`${name}.png`);
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path });
await info.attach(name, { path, contentType: "image/png" });
}
async function scan(page: Page, info: TestInfo, name: string, dialog = false) {
let axe = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]);
if (dialog) axe = axe.include('[role="dialog"]');
const result = await axe.analyze();
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
// P4-02-D01: unchanged command List renders button divs directly under a ul.
// Record the full result; only this precise existing structural defect is deferred.
const unexpected = result.violations.filter(v => !(name === "command" && v.id === "list" && v.nodes.every(n => n.html.startsWith("<ul ") && n.html.includes("MuiList-root"))));
// Dialogs gate all other rules; loaded pages gate naming and inherited contrast.
// Preview scrolling and wider workspace structure remain separately recorded debt.
expect(dialog ? unexpected : result.violations.filter(v => ["label", "select-name", "aria-input-field-name", "aria-dialog-name", "color-contrast"].includes(v.id))).toEqual([]);
}
for (const [mode, language, width] of [["light", "en", 1440], ["dark", "nb", 1440], ["light", "nb", 390], ["dark", "en", 390]] as const) {
test(`[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] ${mode}/${language}/${width} named controls, modal keyboard and retained draft`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[language];
const suffix = `${mode}-${language}-${width}`;
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
await page.goto("/companies");
const status = page.locator("main").getByRole("status");
await expect(status).toHaveText(t.contentLoaded);
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
const edit = page.getByRole("button", { name: `${t.jobTableEdit}: Nordlys æøå AS`, exact: true });
await edit.focus();
await page.keyboard.press("Control+k");
const commands = page.getByRole("dialog", { name: t.quickSearch, exact: true });
await expect(commands.getByRole("textbox", { name: t.quickSearch })).toBeFocused();
await page.keyboard.press("Tab");
await expect(commands.getByRole("button").first()).toBeFocused();
await scan(page, info, "command", true);
await capture(page, info, `command-${suffix}`);
await page.keyboard.press("Escape");
await expect(edit).toBeFocused();
const statusNode = await status.elementHandle();
await statusNode!.evaluate(node => {
const observer = new MutationObserver(records => { node.setAttribute("data-test-announcements", String(Number(node.getAttribute("data-test-announcements") || 0) + records.length)); });
observer.observe(node, { childList: true, characterData: true, subtree: true });
});
await edit.press("Enter");
const companyDialog = page.getByRole("dialog", { name: t.companiesEdit });
await companyDialog.getByRole("textbox", { name: t.companiesRecruiterName, exact: true }).fill("Retained æøå draft");
await page.route(`${apiUrl}/companies/${owner.companyId}`, route => route.request().method() === "PUT" ? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic save failure" }) }) : route.continue());
await companyDialog.getByRole("button", { name: t.save, exact: true }).click();
// Existing P4-02-D02: this caller reports save errors in the shared toast,
// outside the modal's accessible subtree. Preserve the draft proof while
// recording the hidden-alert defect instead of claiming an AT error pass.
const saveError = page.getByRole("alert", { includeHidden: true }).filter({ hasText: "Synthetic save failure" });
await expect(saveError).toContainText("Synthetic save failure");
await expect(saveError).toBeVisible();
expect(await saveError.evaluate(node => !!node.closest('[aria-hidden="true"]'))).toBe(true);
await expect(companyDialog.getByRole("textbox", { name: t.companiesRecruiterName, exact: true })).toHaveValue("Retained æøå draft");
expect(await statusNode!.getAttribute("data-test-announcements")).toBeNull();
await capture(page, info, `retained-draft-${suffix}`);
await page.keyboard.press("Escape");
await expect(edit).toBeFocused();
expect((await (await page.request.get(`${apiUrl}/companies/${owner.companyId}`)).json()).recruiterName).toBeFalsy();
const canonicalBefore = (await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile;
await page.goto(`/career/builder/${owner.cvId}`);
await page.getByRole("tab", { name: t.cvEditorCustomize, exact: true }).click();
for (const name of [t.cvEditorHeadingFont, t.cvEditorBodyFont, t.cvEditorHeadingTreatment, t.cvEditorHeaderTreatment]) await expect(page.getByRole("combobox", { name, exact: true })).toBeVisible();
const heading = page.getByRole("combobox", { name: new RegExp(t.cvEditorHeadingFont) });
await heading.focus();
await heading.press("Enter");
await page.getByRole("option", { name: "Arial", exact: true }).focus();
await page.keyboard.press("Enter");
await expect(heading).toBeFocused();
await scan(page, info, "cv-controls");
await capture(page, info, `cv-select-${suffix}`);
await page.getByRole("combobox", { name: t.cvEditorHeaderTreatment, exact: true }).scrollIntoViewIfNeeded();
await capture(page, info, `cv-treatments-${suffix}`);
// The existing autosave may finish during the loaded scan; do not click a
// now-disabled Save button or change autosave timing merely for this test.
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
expect((await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.headingFont).toBe("Arial, Helvetica, sans-serif");
expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(canonicalBefore);
await page.goto(`/jobs/${owner.jobId}?section=cover-letter`);
for (const name of [t.coverAiAction, t.coverAiTone, t.coverAiDocumentLanguage]) await expect(page.getByRole("combobox", { name: new RegExp(name) })).toBeVisible();
await scan(page, info, "cover-controls");
await page.getByRole("combobox", { name: new RegExp(t.coverAiAction) }).scrollIntoViewIfNeeded();
await capture(page, info, `cover-controls-${suffix}`);
await page.goto(`/jobs/${owner.jobId}?section=overview`);
await page.getByRole("button", { name: t.workspaceDocuments, exact: true }).click();
const purpose = page.getByRole("combobox", { name: new RegExp(t.attachmentsPurpose) });
await purpose.focus();
await purpose.press("Enter");
await page.getByRole("option", { name: t.attachmentsPurposeCertificate, exact: true }).focus();
await page.keyboard.press("Enter");
await expect(purpose).toBeFocused();
await expect(page.getByRole("switch", { name: `${t.attachmentsAiUse}: evidence.txt` })).toBeVisible();
const rename = page.getByRole("button", { name: `${t.attachmentsRename}: evidence.txt`, exact: true });
await rename.focus();
await rename.press("Enter");
const prompt = page.getByRole("dialog", { name: t.attachmentsRenameTitle });
await expect(prompt).toHaveAccessibleDescription(t.attachmentsRenamePrompt);
const filename = prompt.getByRole("textbox", { name: t.attachmentsRenamePrompt });
await expect(filename).toBeFocused();
await expect(filename).toHaveAccessibleDescription("12/180");
await filename.fill("Reviewed æøå.txt");
await filename.press("Shift+Tab");
await expect(prompt.getByRole("button", { name: t.attachmentsRename, exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(filename).toBeFocused();
await scan(page, info, "prompt", true);
await capture(page, info, `prompt-${suffix}`);
await page.keyboard.press("Escape");
await expect(rename).toBeFocused();
await rename.press("Enter");
await expect(filename).toHaveValue("evidence.txt");
await filename.fill("Reviewed æøå.txt");
await prompt.getByRole("button", { name: t.attachmentsRename, exact: true }).press("Enter");
const remove = page.getByRole("button", { name: `${t.attachmentsDelete}: Reviewed æøå.txt`, exact: true });
await remove.focus();
await remove.press("Enter");
const confirm = page.getByRole("dialog", { name: t.attachmentsDeleteTitle });
const cancel = confirm.getByRole("button", { name: t.cancel, exact: true });
await expect(cancel).toBeFocused();
await expect(confirm).toHaveAccessibleDescription(t.attachmentsDeleteConfirm.replace("{name}", "Reviewed æøå.txt"));
await cancel.press("Shift+Tab");
await expect(confirm.getByRole("button", { name: t.attachmentsDelete, exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(cancel).toBeFocused();
await scan(page, info, "confirm", true);
await capture(page, info, `confirm-${suffix}`);
await cancel.press("Enter");
await expect(remove).toBeFocused();
const attachments = await (await page.request.get(`${apiUrl}/attachments/${owner.jobId}`)).json();
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({ fileName: "Reviewed æøå.txt", purpose: "certificate" });
});
}
@@ -127,7 +127,7 @@ for (const mode of ["light", "dark"] as const) {
phase = "populated";
await page.reload();
await expect(page.locator("main").getByText("Nordlys æøå AS", { exact: true })).toBeVisible();
await expect(page.locator("main").getByRole("status")).toHaveCount(0);
await expect(page.locator("main").getByRole("status")).toHaveText(t.contentLoaded);
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
await loadedContrast(page, info, "loaded-company");
// Both desktop table and phone cards retain named actions and a real dialog.
@@ -235,8 +235,9 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
render(<ApplicationCoverLetterSection jobId={7} />);
expect(await screen.findByText(/application's full job advert and analysis/i)).toBeInTheDocument();
const assistantSelects = screen.getAllByRole("combobox");
fireEvent.mouseDown(assistantSelects[assistantSelects.length - 1]);
expect(screen.getByRole("combobox", { name: "Action" })).toBeVisible();
expect(screen.getByRole("combobox", { name: "Tone" })).toBeVisible();
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Document language" }));
fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i }));
fireEvent.click(screen.getByRole("button", { name: "Generate" }));
+2 -2
View File
@@ -73,14 +73,14 @@ test('attachments metadata controls update purpose and ai usage', async () => {
expect(screen.getByRole('button', { name: 'Rename: resume.pdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete: resume.pdf' })).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole('combobox'));
fireEvent.mouseDown(screen.getByRole('combobox', { name: /purpose/i }));
fireEvent.click(await screen.findByRole('option', { name: /portfolio/i }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/attachments/7', expect.objectContaining({ purpose: 'portfolio', useForAi: true }));
});
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(screen.getByRole('switch', { name: 'Use for AI: resume.pdf' }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/attachments/7', expect.objectContaining({ purpose: 'portfolio', useForAi: false }));
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useId, useState } from "react";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
@@ -558,6 +558,7 @@ const COVER_LETTER_ACTIONS = [
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
@@ -607,14 +608,14 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>{t("coverAiAction")}</InputLabel>
<Select label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
<Select labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>{t("coverAiTone")}</InputLabel>
<Select label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
<InputLabel id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
<Select labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
@@ -622,8 +623,8 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel>{t("coverAiDocumentLanguage")}</InputLabel>
<Select label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import {
Alert,
@@ -83,6 +83,7 @@ function purposeLabel(purpose: string | null | undefined, t: (key: any) => strin
}
export default function Attachments({ jobId }: { jobId: number }) {
const labelId = useId();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction, promptForValue } = useDialogActions();
@@ -297,8 +298,8 @@ export default function Attachments({ jobId }: { jobId: number }) {
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
<TableCell>
<FormControl size="small" fullWidth>
<InputLabel>{t("attachmentsPurpose")}</InputLabel>
<Select value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
<InputLabel id={`${labelId}-${a.id}`}>{t("attachmentsPurpose")}</InputLabel>
<Select labelId={`${labelId}-${a.id}`} value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
<MenuItem value="resume">{t("attachmentsPurposeResume")}</MenuItem>
<MenuItem value="cover-letter">{t("attachmentsPurposeCoverLetter")}</MenuItem>
<MenuItem value="portfolio">{t("attachmentsPurposePortfolio")}</MenuItem>
@@ -310,7 +311,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Switch size="small" checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
<Switch size="small" slotProps={{ input: { role: "switch", "aria-label": `${t("attachmentsAiUse")}: ${a.fileName}` } }} checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
<Typography variant="caption" sx={{ color: "text.secondary" }}>{a.useForAi ? t("attachmentsAiEnabled") : t("attachmentsAiDisabled")}</Typography>
</Box>
</TableCell>
@@ -114,7 +114,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
];
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") } }} fullWidth maxWidth="sm">
<DialogContent sx={{ p: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
<SearchIcon fontSize="small" />
@@ -123,6 +123,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
variant="standard"
fullWidth
placeholder={t("searchPlaceholder")}
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{ disableUnderline: true }}
@@ -1,6 +1,7 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import { visuallyHidden } from "@mui/utils";
import type { ViewResourceError } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
@@ -18,22 +19,30 @@ type Props = {
export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel, onRetry, compact = false }: Props) {
const { t } = useI18n();
const resolvedRetryLabel = retryLabel ?? t("retry");
const [observedLoading, setObservedLoading] = useState(loading);
useEffect(() => { if (loading) setObservedLoading(true); }, [loading]);
// Keep one live region mounted through the read. An idle/error-only consumer
// has not completed a load; unrelated form edits must not announce anything.
const status = <Box component="span" role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
{loading ? t("loading") : !error && observedLoading ? t("contentLoaded") : ""}
</Box>;
if (loading) {
return (
<Box role="status" aria-live="polite" sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
<>{status}<Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
<CircularProgress size={compact ? 24 : 28} aria-label={t("loading")} />
</Box>
</Box></>
);
}
if (!error) return null;
if (!error) return <>{status}</>;
const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error";
const detail = error.message !== title && error.message !== description ? error.message : null;
return (
<Alert
<>{status}<Alert
severity={severity}
sx={{
mb: compact ? 1.5 : 2,
@@ -45,6 +54,6 @@ export default function ViewStateNotice({ loading = false, error = null, title,
<Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography>
{description ? <Typography variant="body2">{description}</Typography> : null}
{detail ? <Typography variant="body2" sx={{ mt: 0.5 }}>{detail}</Typography> : null}
</Alert>
</Alert></>
);
}
@@ -1,4 +1,4 @@
import React from "react";
import React, { useId } from "react";
import {
Box, Button, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Slider, Stack, Switch, TextField, Typography,
} from "@mui/material";
@@ -27,6 +27,7 @@ type Props = {
export default function CvCustomizeTab({ mode, settings, update, themes, sectionLabel }: Props) {
const { t } = useI18n();
const labelId = useId();
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
@@ -58,14 +59,14 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>{t("cvEditorTypography")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingFont")}</InputLabel><Select label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorBodyFont")}</InputLabel><Select label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingFont`}>{t("cvEditorHeadingFont")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingFont`} label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorBodyFont`}>{t("cvEditorBodyFont")}</InputLabel><Select labelId={`${labelId}-cvEditorBodyFont`} label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
</Box>
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
<ControlSlider label={t("cvEditorHeadingSize")} value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
<ControlSlider label={t("cvEditorLineHeight")} value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
{supports("heading") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingTreatment")}</InputLabel><Select label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
{supports("header") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeaderTreatment")}</InputLabel><Select label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
{supports("heading") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingTreatment`}>{t("cvEditorHeadingTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingTreatment`} label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
{supports("header") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeaderTreatment`}>{t("cvEditorHeaderTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeaderTreatment`} label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>{t("cvEditorSkillsPresentation")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorSkillsPresentation") }} label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? "tags"} onChange={(event) => update({ skillsStyle: event.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">{t("cvEditorTags")}</MenuItem><MenuItem value="text">{t("cvEditorCompactText")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="grouped">{t("cvEditorGroupedSkills")}</MenuItem></Select></FormControl>
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}</>}
+2
View File
@@ -29,6 +29,8 @@ test('renders app-owned confirmation dialog', async () => {
expect(await screen.findByText(/are you sure/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Delete job' })).toHaveAccessibleDescription('Are you sure?');
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
test('uses the selected language for shared dialog defaults', async () => {
+5 -4
View File
@@ -1,4 +1,4 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import React, { createContext, useCallback, useContext, useId, useMemo, useRef, useState } from "react";
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
import { useOptionalI18n } from "./i18n/I18nProvider";
@@ -22,6 +22,7 @@ const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const messageId = useId();
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const [state, setState] = useState<ConfirmState>({
open: false,
@@ -58,7 +59,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
return (
<ConfirmContext.Provider value={value}>
{children}
<Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs"
<Dialog open={state.open} onClose={() => closeWith(false)} aria-describedby={messageId} fullWidth maxWidth="xs"
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent>
@@ -67,10 +68,10 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
? i18n?.t("confirmDestructiveHint") ?? "This action may be hard to undo."
: i18n?.t("confirmStandardHint") ?? "Please confirm this action."}
</Alert>
<Typography sx={{ color: "text.secondary" }}>{state.message}</Typography>
<Typography id={messageId} sx={{ color: "text.secondary" }}>{state.message}</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
{state.confirmLabel}
</Button>
+2
View File
@@ -179,6 +179,7 @@ export const translations = {
noWord: "No",
createAndAddAnother: "Create & add another",
loading: "Loading...",
contentLoaded: "Content loaded.",
languageEnglish: "English",
languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Discover jobs",
@@ -2617,6 +2618,7 @@ export const translations = {
noWord: "Nei",
createAndAddAnother: "Opprett og legg til en til",
loading: "Laster...",
contentLoaded: "Innhold lastet.",
languageEnglish: "Engelsk",
languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Finn jobber",
@@ -30,7 +30,8 @@ describe.each(["light", "dark"] as const)("%s loaded company workflow", mode =>
if (outcome === "error") fail({ response: { status: 503 } });
else finish({ data: outcome === "empty" ? [] : [company] });
});
expect(screen.queryByRole("status")).not.toBeInTheDocument();
const status = screen.getByRole("status");
expect(status).toHaveTextContent(outcome === "error" ? "" : "Content loaded.");
if (outcome === "error") {
const error = screen.getByRole("alert");
expect(error).not.toBeEmptyDOMElement();
@@ -45,7 +46,8 @@ describe.each(["light", "dark"] as const)("%s loaded company workflow", mode =>
fireEvent.click(screen.getByRole("button", { name: `Edit: ${company.name}` }));
expect(screen.getByRole("dialog")).toHaveAccessibleName();
// Reading/editing a loaded form must not repeat the loading announcement or save.
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.getByRole("status", { hidden: true })).toBe(status);
expect(status).toHaveTextContent("Content loaded.");
} else {
expect(screen.getByRole("table")).toHaveTextContent("No companies");
}
+62
View File
@@ -0,0 +1,62 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfirmProvider, useConfirm } from "./confirm";
import { PromptProvider, usePrompt } from "./prompt";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
test.each(["en", "nb"] as const)("[PRES-E08 PRES-S05] %s confirmation traps/returns focus and preserves cancel/Escape/accept results", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const result = jest.fn();
function Caller() {
const { confirm } = useConfirm();
return <button onClick={async () => result(await confirm({ message: "Remove this item?", destructive: true }))}>Open</button>;
}
render(<I18nProvider><ConfirmProvider><Caller /></ConfirmProvider></I18nProvider>);
const trigger = screen.getByRole("button", { name: "Open" });
for (const action of ["escape", "cancel", "confirm"] as const) {
userEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: t.confirmAction });
expect(dialog).toHaveAccessibleDescription("Remove this item?");
expect(screen.getByRole("button", { name: t.cancel })).toHaveFocus();
if (action === "escape") fireEvent.keyDown(dialog, { key: "Escape" });
else userEvent.click(screen.getByRole("button", { name: action === "cancel" ? t.cancel : t.confirm }));
await waitFor(() => expect(result).toHaveBeenLastCalledWith(action === "confirm"));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
}
expect(result).toHaveBeenCalledTimes(3);
localStorage.clear();
});
test.each(["en", "nb"] as const)("[PRES-E08 PRES-W08] %s prompt preserves helper link, exact text, cancellation and reopen defaults", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const result = jest.fn();
function Caller() {
const { prompt } = usePrompt();
return <button onClick={async () => result(await prompt({ message: "Name æøå", defaultValue: "CV.pdf" }))}>Open</button>;
}
render(<I18nProvider><PromptProvider><Caller /></PromptProvider></I18nProvider>);
const trigger = screen.getByRole("button", { name: "Open" });
for (const action of ["escape", "cancel", "save"] as const) {
userEvent.click(trigger);
const input = await screen.findByRole("textbox", { name: "Name æøå" });
expect(input).toHaveFocus();
expect(input).toHaveValue("CV.pdf");
expect(input).toHaveAccessibleDescription("6/180");
expect(input).toHaveAttribute("maxlength", "180");
fireEvent.change(input, { target: { value: "Ny CV æøå.pdf" } });
expect(input).toHaveAccessibleDescription("13/180");
if (action === "escape") fireEvent.keyDown(input, { key: "Escape" });
else userEvent.click(screen.getByRole("button", { name: action === "cancel" ? t.cancel : t.save }));
await waitFor(() => expect(result).toHaveBeenLastCalledWith(action === "save" ? "Ny CV æøå.pdf" : null));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
}
expect(result).toHaveBeenCalledTimes(3);
localStorage.clear();
});
@@ -0,0 +1,55 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
import RichTextField from "./components/RichTextField";
import { emptyCvVariantSettings } from "./cvBuilder";
beforeEach(() => localStorage.clear());
test.each(["en", "nb"] as const)("[PRES-C08 PRES-C09] %s CV selects have visible names and retain exact override patches", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const update = jest.fn();
render(<I18nProvider><CvCustomizeTab mode="customize" settings={emptyCvVariantSettings()} update={update} themes={[]} sectionLabel={(_, fallback) => fallback} /></I18nProvider>);
for (const [name, option, patch] of [
[t.cvEditorHeadingFont, "Arial", { headingFont: "Arial, Helvetica, sans-serif" }],
[t.cvEditorBodyFont, "Georgia (serif)", { bodyFont: "Georgia, 'Times New Roman', serif" }],
[t.cvEditorHeadingTreatment, t.cvEditorPlain, { headingStyle: "plain" }],
[t.cvEditorHeaderTreatment, t.cvEditorColourBand, { headerStyle: "band" }],
] as const) {
fireEvent.mouseDown(screen.getByRole("combobox", { name }));
fireEvent.click(await screen.findByRole("option", { name: option }));
expect(update).toHaveBeenLastCalledWith(patch);
}
expect(update).toHaveBeenCalledTimes(4);
});
test.each(["en", "nb"] as const)("[PRES-C07 PRES-W06] %s rich text retains names, selected Markdown and editor focus", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
function Editor() {
const [text, setText] = useState("Norsk æøå");
return <RichTextField label="Career text" value={text} onChange={setText} />;
}
render(<I18nProvider><Editor /></I18nProvider>);
const input = screen.getByRole("textbox", { name: "Career text" }) as HTMLTextAreaElement;
for (const name of [t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink]) expect(screen.getByRole("button", { name })).toBeEnabled();
act(() => input.focus());
input.setSelectionRange(6, 9);
fireEvent.click(screen.getByRole("button", { name: t.richTextBold }));
expect(input).toHaveValue("Norsk **æøå**");
await waitFor(() => expect(input).toHaveFocus());
await waitFor(() => expect(input.value.slice(input.selectionStart, input.selectionEnd)).toBe("æøå"));
});
test("[PRES-C07 PRES-W06] disabled rich text cannot format or edit and repeated fields keep separate labels", () => {
const change = jest.fn();
render(<I18nProvider><section aria-label="First"><RichTextField label="First draft" value="Saved" onChange={change} disabled /></section><RichTextField label="Second draft" value="Other" onChange={change} /></I18nProvider>);
expect(screen.getByRole("textbox", { name: "First draft" })).toBeDisabled();
expect(screen.getByRole("textbox", { name: "Second draft" })).toBeEnabled();
for (const button of within(screen.getByRole("region", { name: "First" })).getAllByRole("button")) { expect(button).toBeDisabled(); fireEvent.click(button); }
expect(change).not.toHaveBeenCalled();
});
+2
View File
@@ -23,6 +23,8 @@ test('renders app-owned prompt dialog', async () => {
expect(await screen.findByText(/choose a new name/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('resume.pdf')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Choose a new name' })).toHaveAccessibleDescription('10/180');
expect(screen.getByRole('dialog', { name: 'Rename attachment' })).toHaveAccessibleDescription('Choose a new name');
expect(screen.getByRole('button', { name: /rename/i })).toBeInTheDocument();
});
+5 -4
View File
@@ -1,4 +1,4 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import React, { createContext, useCallback, useContext, useId, useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
import { useOptionalI18n } from "./i18n/I18nProvider";
@@ -23,6 +23,7 @@ const PromptContext = createContext<PromptContextValue | null>(null);
export function PromptProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const messageId = useId();
const [state, setState] = useState<PromptState>({
open: false,
title: "Enter value",
@@ -60,12 +61,12 @@ export function PromptProvider({ children }: { children: React.ReactNode }) {
return (
<PromptContext.Provider value={contextValue}>
{children}
<Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs"
<Dialog open={state.open} onClose={() => closeWith(null)} aria-describedby={messageId} fullWidth maxWidth="xs"
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180 }} helperText={`${value.length}/180`} />
<Typography id={messageId} sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180, "aria-labelledby": messageId }} helperText={`${value.length}/180`} />
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
@@ -22,7 +22,8 @@ test.each([
const onNavigate = jest.fn();
const onClose = jest.fn();
render(<I18nProvider><QuickCommandDialog open onClose={onClose} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Nordlys" } });
expect(screen.getByRole("dialog", { name: "Quick Search" })).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "Quick Search" }), { target: { value: "Nordlys" } });
fireEvent.click(await screen.findByText(label, { exact: true }));
expect(onNavigate).toHaveBeenCalledWith(destination);
expect(onClose).toHaveBeenCalledTimes(1);
@@ -0,0 +1,38 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import ViewStateNotice from "./components/ViewStateNotice";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
test.each(["en", "nb"] as const)("[PRES-E08] %s loading/error/recovery updates one persistent polite region", language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const error = { kind: "unavailable" as const, message: "Unavailable", retryable: true };
const view = (loading: boolean, failed = false) => <I18nProvider><ViewStateNotice loading={loading} error={failed ? error : null} title="Companies" /></I18nProvider>;
const { rerender } = render(view(true));
const status = screen.getByRole("status");
expect(status).toHaveTextContent(t.loading);
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
rerender(view(false, true));
expect(status).toBeEmptyDOMElement();
expect(screen.getByRole("alert")).toHaveTextContent("Unavailable");
rerender(view(false));
expect(screen.getByRole("status")).toBe(status);
expect(status).toHaveTextContent(language === "en" ? "Content loaded." : "Innhold lastet.");
rerender(view(false));
expect(screen.getByRole("status")).toBe(status);
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
localStorage.clear();
});
test("[PRES-E08] idle/error-only notices do not invent a load completion; later loading still announces", () => {
const view = (loading = false) => <I18nProvider><ViewStateNotice loading={loading} title="Jobs" /></I18nProvider>;
const { rerender } = render(view());
expect(screen.getByRole("status")).toBeEmptyDOMElement();
rerender(view(true));
expect(screen.getByRole("status")).toHaveTextContent("Loading...");
rerender(view());
expect(screen.getByRole("status")).toHaveTextContent("Content loaded.");
});