diff --git a/docs/redesign/README.md b/docs/redesign/README.md index eb106d3..d7dd860 100644 --- a/docs/redesign/README.md +++ b/docs/redesign/README.md @@ -37,6 +37,8 @@ AI should support this flow naturally and contextually. It must never invent fac ## Status +2026-09-11: [P4-01 semantic color/focus checkpoint](phase-4/p4-01/README.md), based on `f696f23`, implements the bounded shared MUI foundation and loaded-state regression fixtures. Final 916 backend / 333 frontend pass; browser 35 actual passes + the unchanged expected failure. [Preservation and visual evidence](phase-4/p4-01/TRACEABILITY.md) records both themes and intentional gaps. P4-02, broader layout/route redesign and B07 remain unstarted; F04 remains open. Earlier status entries below are historical. + 2026-09-10: [Phase 3 UX architecture checkpoint](phase-3/README.md), based on Phase 2 `d6d3dd9`, defines grouped navigation, all 31 route destinations, 57 PRES impacts, workflow/data boundaries and the bounded Phase 4 foundation plan. Documentation only; no visual implementation or B07. F04 remains open. [Phase 2](phase-2/README.md) retains the executed 916 backend / 313 frontend baseline and explicit browser/provider/production gaps. Earlier status entries below are historical. These documents define the intended target state. They should be updated as the remaining functional work is completed. The full redesign implementation should begin only after a stable pre-redesign baseline has been created and protected by tests. diff --git a/docs/redesign/phase-2/TRACEABILITY.md b/docs/redesign/phase-2/TRACEABILITY.md index 67dfce9..a594b50 100644 --- a/docs/redesign/phase-2/TRACEABILITY.md +++ b/docs/redesign/phase-2/TRACEABILITY.md @@ -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. Phase 4 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 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. 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. @@ -55,4 +55,4 @@ Before Phase 4/5 code, require an approved route/preservation plan, targeted reg - Before migrating shared controls, validate EN/NB × light/dark × desktop/mobile, keyboard focus, error/empty/dirty states and representative loaded data. Real screen reader/touch/reflow tests remain manual. - Do not label a release healthy until the previously failed CI browser runner is diagnosed and the required pipeline passes. F04 requires actual production ingress evidence. -Future trace sheets must link here. There are **no Phase 3+ implementation commits or visual sign-offs** in this checkpoint. +Future trace sheets must link here. The original Phase 2 checkpoint contains **no Phase 3+ implementation commits or visual sign-offs**. Subsequent implementation evidence is linked above and does not overwrite this historical baseline. diff --git a/docs/redesign/phase-4/README.md b/docs/redesign/phase-4/README.md new file mode 100644 index 0000000..ba45dd3 --- /dev/null +++ b/docs/redesign/phase-4/README.md @@ -0,0 +1,8 @@ +# Phase 4 checkpoints + +Phase 4 starts from `f696f23` on `codex/ui-redesign-phase4`. The 57 preservation contracts in [Phase 2](../phase-2/PRESERVATION_MATRIX.md) and the [Phase 3 impact matrix](../phase-3/PRESERVATION_IMPACT_MATRIX.md) remain authoritative. + +- [P4-01 semantic colors, focus and loaded fixtures](p4-01/README.md) +- [P4-01 preservation and visual evidence](p4-01/TRACEABILITY.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. diff --git a/docs/redesign/phase-4/p4-01/README.md b/docs/redesign/phase-4/p4-01/README.md new file mode 100644 index 0000000..68112b2 --- /dev/null +++ b/docs/redesign/phase-4/p4-01/README.md @@ -0,0 +1,125 @@ +# P4-01 — semantic colors, focus and loaded-state protection + +Source checkpoint: `f696f23`; dedicated branch: `codex/ui-redesign-phase4`. Requirement: [P4-01 handoff](../../phase-3/PHASE4_IMPLEMENTATION_PLAN.md), [design architecture](../../phase-3/DESIGN_SYSTEM_ARCHITECTURE.md), [accessibility plan](../../phase-3/ACCESSIBILITY_PLAN.md), [responsive strategy](../../phase-3/RESPONSIVE_STRATEGY.md). Implementation SHA is the commit containing this record (`git log -- docs/redesign/phase-4`), avoiding a self-referential SHA. + +## Reproduced causes + +The new palette-pair tests were run against the unchanged theme before editing it: **6 failed, 6 passed**. MUI's contrast calculation measured light success text on the canvas at 3.04:1; light warning/error at 4.13:1 and info at 4.15:1; dark primary text at 3.70:1. The default 3:1 contrast-text selection also allowed the dark error filled-action pair at 3.07:1. These fail the project's ordinary-text target of 4.5:1. These are palette measurements, not an assertion that every old page used each pair. + +The original theme supplied a translucent focus shadow but no consistent shared keyboard outline, and used the decorative divider as the outlined input boundary. Selected list items used the same layer as hover. + +A real loaded CV scan independently found editable names in omitted sections at **4.22:1** (`#7b7b7d` on white). The responsible local style applied 58% opacity to the entire section; omitted entries used 50%. Omission from the PDF does not disable editing. This local contrast cause cannot be repaired by changing a global text token without distorting other consumers. + +## Implementation and changed owners + +| File | Change and scope | +|---|---| +| `job-tracker-ui/src/theme.ts` | Existing MUI CSS-variable theme owns semantic surfaces, borders, focus, navigation and action layers; mode-specific semantic tones, readable filled-action foregrounds and shared focus overrides | +| `job-tracker-ui/src/theme.d.ts` | Small MUI palette/background augmentation for the added roles; no separate token runtime | +| `job-tracker-ui/src/layout/AppShell.tsx` | Replace existing inverse-sidebar color literals with MUI variable references; scope inverse focus color to drawer content. Same colors, groups, widths, routes and collapse behavior | +| `job-tracker-ui/src/views/CvBuilderEditor.tsx` | Remove whole-container opacity for omitted sections/entries only. Visibility icon/action names, inclusion settings and render/save logic retained; transient drag opacity unchanged | +| `job-tracker-ui/src/theme-contrast.test.ts` | 14 pair/essential-boundary contrast cases across light/dark | +| `job-tracker-ui/src/loaded-theme-states.test.tsx` | Six actual CompaniesTable loading/populated/empty/error-retry cases, both themes; no-write and dialog semantics checks | +| `job-tracker-ui/e2e/semantic-foundations.spec.ts` | 12 real-browser cases, synthetic isolated accounts, loaded fixtures, actual focus contrast, state scans and screenshots | + +No dependencies, build scripts, CI, backend contracts, data model, route tree, providers, typography, layout, logo or final brand decision changed. + +## Token architecture + +The existing `#5B5BD6` hue remains **provisional continuity**, not final brand approval. Light primary retains that exact value. Dark primary uses the same hue lightened by 45%, with a dark filled-action foreground. Light success/warning/error/info use darker tones of their existing hues. Their hover tones are darker again; text on filled controls is selected at a 4.5:1 threshold. Browser scans also check the actual hovered primary action. + +| MUI role | Contract | +|---|---| +| `background.default`, `background.paper`, `background.elevated` | Existing canvas/paper retained; elevated overlay surface added and used by dialogs/popovers (menus inherit). Dark elevated surface is `#1C1C24`; light is white | +| `text.primary/secondary/disabled` | Existing text hierarchy retained; no new feature-level text palette | +| `divider`, `border.subtle/default/strong` | Decorative divider stays subtle. Essential input outlines use the stronger `border.default`, measured at ≥3:1 on the three application surfaces | +| `primary`, `secondary`, `success`, `warning`, `error`, `info` | Standard MUI semantic colors and foregrounds; no duplicate `status` token family | +| `action.hover/selected/focus/disabled/disabledBackground` | Explicit mode-specific hover/selection/focus layers; disabled controls remain disabled; focus is additionally outlined | +| `focus.main/inverse` | 3px visible keyboard indicator against application or inverse-navigation surface | +| `navigation.*` | Existing fixed inverse sidebar background, foreground, selection and hover moved intact into the MUI palette | + +`background.paper` serves the proposed surface role; there is no redundant `background.surface` alias. Generated `--palette-*` variables come from MUI. `--app-focus-color` is a scoped alias of `palette.focus.inverse`, not a second source of colors. + +## Focus and states + +Shared ButtonBase covers buttons, icon buttons, tabs, menu items and interactive cards. Links/native focusable controls use `:focus-visible`; MUI controls also use `.Mui-focusVisible`. The outline is solid, 3px with 2px offset. Tabs, menu/list items and CardActionArea use an inset outline to avoid clipping inside scroll/overflow containers. InputBase outlines standard controls, including the underline-free command input. Outlined fields/selects use a **3px notched fieldset border**, preserving MUI's floating-label cutout: visual inspection found that the initial outer outline crossed the label. Focus does not add tab stops to ordinary cards. + +The ring adds a visible boundary rather than relying on a selected color change. Browser tests measure ≥3:1 against the actual adjacent/composited surface, check focus return after dialogs/drawers, and verify a pointer-activated settings button does not acquire keyboard-only focus styling. Outline remains a native CSS outline; no new animation system is added. Existing reduced-motion behavior is preserved. + +Loading and errors already had appropriate primitives: polite named loading status/progress and MUI alert with Retry. These implementations are unchanged. New fixtures deliberately hold a read pending, expose an error, retry into empty data, then load actual owner data; editing never repeats the loading status or writes automatically. They preserve error-versus-empty distinctions. A general completion-announcement strategy and manual announcement/repetition review remain P4-02 work; this slice does not claim screen-reader certification. + +## Status and legacy-color handling + +`pipeline.ts` is unchanged: Saved/Interested/Preparing → neutral; Applied → primary; Waiting/Ghosted → warning; Interview → info; Offer → success; Rejected/Withdrawn → error. Existing labels and visibility/status icons remain, so meaning is not communicated by red/green alone. Unknown/custom statuses keep their raw label and neutral tone. No status schema, color picker or arbitrary custom-color API is added. + +Intentional remaining literals include printed CV page white, template/document accent controls, marketing gradients, illustration/logo artwork and specialized chart palettes. Printed paper is independent of application dark mode. These are not silently claimed as migrated. Only the demonstrated CV omission-opacity cause is changed locally; preview/layout/editor architecture is untouched. + +## Validation results + +The final full frontend/browser runs include the notched-focus adjustment. Raw local logs live in ignored `.artifacts/p4-01-*`; screenshots and a compact scan index are preserved with this package. Browser trace archives contain session details and are not committed. + +| Gate | Phase 3 inherited | P4-01 result | +|---|---:|---:| +| Backend Release tests | 916 passing | 916 passing, 0 failed/skipped | +| Frontend Jest | 313 passing | 333 passing in 70 suites (+20), 0 failed | +| Default browser suite | 23 actual passes + 1 expected failure | 35 actual passes + 1 unchanged expected failure (+12 actual passes) | +| Focused loaded/focus browser | No P4-specific fixture | 12 passing | +| Standalone TypeScript + E2E file type check | Passing | Passing | +| ESLint | Passing | Passing | +| API Release build, warnings as errors | Passing | Passing, 0 warnings/errors | +| Next production export | Passing | Passing | +| Compose validator | 3 passing | 3 passing | +| B04 rate limits / production HTTP / PDF admission probes | 9 / 6 / 1 passing | 9 / 6 / 1 passing | + +The final browser report contains **38 loaded scans and zero color-contrast violations**. It also retains eight `aria-input-field-name` node occurrences: four existing unnamed Customize controls in each theme. Those are P4-02 naming debt. Axe marked checks incomplete in 20 scan states for color contrast, ten for `aria-hidden-focus` and ten for `aria-prohibited-attr`; incomplete checks are not passes. The [scan index](SCAN_RESULTS.json) records these limits. Representative screenshots and keyboard checks supplement the scans; they do not establish whole-product WCAG compliance. The browser runner's total of 36 expected outcomes includes the unchanged P2-D01 failure; it is not 36 successful workflows. + +Exact commands (PowerShell; repository root unless `Set-Location` changes it): + +```powershell +git switch -c codex/ui-redesign-phase4 f696f23 +$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-01.trx' --results-directory .artifacts/redesign-p4-01 -- 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 src/theme-contrast.test.ts +npm test -- --watchAll=false --runInBand +npm run typecheck +npm run lint +node node_modules/typescript/bin/tsc --project ../.artifacts/p4-01-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' +node node_modules/@playwright/test/cli.js test e2e/semantic-foundations.spec.ts --reporter=list,json +$env:PLAYWRIGHT_JSON_OUTPUT_FILE='../.artifacts/p4-01-browser-results.json' +$env:P4_CAPTURE='true' +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 only `e2e/semantic-foundations.spec.ts`, sets `incremental:false`, and resolves Node types from the frontend's node_modules. No permanent CI change is needed: standalone frontend checking already exists from Phase 2. TS2571 does not recur. + +Fixture corrections during validation: use the existing translated `jobTableEdit` action; honor server-persisted UI language as well as browser preference; use the sidebar's current button role; follow MUI roving-tab/option keyboard semantics; locate the three actual document-AI review actions. These were test-assumption errors, not product fixes. The real CV opacity finding above was fixed and reverified. + +Screenshot investigation: some immediate post-axe PNGs omitted sidebar labels/icons. A temporary two-theme browser probe captured the same page before axe, after axe, after keyboard activity and after two animation frames. Text, geometry, opacity `1`, visibility `visible` and computed white selected text stayed unchanged; the post-paint capture restored the labels without changing the DOM, styles or application. The permanent screenshot helper now allows that paint cycle before capture. Some company PNGs still partially omit sidebar glyphs, so their visual claim is limited to the main loaded state; dedicated Jobs navigation captures and keyboard assertions supply navigation evidence. This does not establish the underlying Chromium/scan capture cause or certify every captured sidebar. The two diagnostic cases passed (`node node_modules/@playwright/test/cli.js test --config=../.artifacts/p4-01-nav-probe.config.ts`; local log/paired PNGs in `.artifacts`). The probe's initial server start failed because its temporary config resolved the export directory from `.artifacts`; setting its server working directory to the actual frontend resolved that harness error. No application workaround was introduced. + +## Remaining boundaries / next slice + +**P4-01 is complete within its scoped acceptance criteria:** shared semantic/focus foundations, both themes, loaded-state fixtures, preserved regression baseline and representative visual evidence. Remaining manual accessibility proof and later structural repairs are explicitly excluded below. No claim of full accessibility or production verification is made. + +- P4-02 remains unstarted: shared labels, named Customize controls/command input, modal/async announcement audit. Full axe findings are retained in the scan index; the new loaded tests explicitly gate **contrast**, not all WCAG rules. Existing broad axe tests are unchanged. +- P4-03 remains unstarted: loaded CV preview keyboard-scroll/viewport behavior. The fixture's existing horizontal-overflow warning is retained; this ticket does not hide it or claim printable-layout repair. Drag alternatives/transient drag opacity require their own interaction review. +- All 57 preservation contracts remain; the full product cross-product of routes/data/themes/locales/providers is not claimed covered by these representative fixtures. See [traceability](TRACEABILITY.md). +- Narrator/NVDA, real touch/virtual keyboard, mobile screen reader, zoom/text-spacing and forced-colors manual validation remain explicit gaps. Screenshots and Chromium keyboard tests do not substitute for these. +- P2-D01 remains the narrowly documented browser expected failure. Package/tailoring restructuring remains blocked on native success evidence. +- **F04 remains open**. No deployed Traefik/nginx/firewall evidence was obtained. Feature-branch local tests do not prove remote CI or production release health. +- No P4-02, route/layout migration, dashboard/CV redesign, B07 or production deployment. Recommended next ticket only: P4-02 after review of this checkpoint. diff --git a/docs/redesign/phase-4/p4-01/SCAN_RESULTS.json b/docs/redesign/phase-4/p4-01/SCAN_RESULTS.json new file mode 100644 index 0000000..73ca051 --- /dev/null +++ b/docs/redesign/phase-4/p4-01/SCAN_RESULTS.json @@ -0,0 +1,1084 @@ +{ + "source": "Final P4-01 Playwright JSON attachments; synthetic local API/Chromium; full WCAG A/AA scans, contrast-only new-test gate", + "browserActualPasses": 35, + "browserExpectedFailures": 1, + "expectedFailure": "P2-D01 native SQLite submitted-package capture", + "loadedScans": 38, + "colorContrastViolations": 0, + "remainingViolationNodeOccurrences": { + "aria-input-field-name": 8 + }, + "scans": [ + { + "test": "[PRES-E08 PRES-J08] light/en/1440 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/en/1440 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/en/1440 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_o_" + ], + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] light/en/390 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/en/390 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/en/390 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_o_" + ], + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] light/nb/1440 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/nb/1440 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/nb/1440 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_o_" + ], + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] light/nb/390 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/nb/390 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] light/nb/390 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ], + [ + "#_r_11_" + ] + ] + } + ] + }, + { + "test": "[PRES-E07 PRES-E08 PRES-J05 PRES-J07] light loaded status semantics, navigation and command focus", + "state": "loaded-jobs", + "violations": [], + "incomplete": [ + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 6, + "targets": [ + [ + ".MuiBadge-invisible" + ], + [ + "#_r_c_" + ], + [ + "div[aria-labelledby=\"job-table-status-label\"]" + ], + [ + "div[aria-labelledby=\"job-table-company-label\"]" + ], + [ + "#_r_f_" + ], + [ + "div[aria-labelledby=\"job-table-readiness-label\"]" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings", + "state": "loaded-cv-template-button-hover", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings", + "state": "loaded-cv-content", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 3, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-containedSizeSmall.css-akwpjr.MuiButton-contained" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + "#_r_t_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings", + "state": "loaded-cv-customize", + "violations": [ + { + "rule": "aria-input-field-name", + "impact": "serious", + "nodes": 4, + "targets": [ + [ + ".css-l0tx9m.MuiBox-root:nth-child(4) > .MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(1) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ], + [ + ".css-l0tx9m.MuiBox-root:nth-child(4) > .MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(2) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ], + [ + ".MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(8) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ], + [ + ".MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(9) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ] + ] + } + ], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#menu-" + ] + ] + }, + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 10, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedSizeSmall" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(2)" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + "div[aria-label=\"Skills presentation\"]" + ], + [ + "div[aria-label=\"Page size\"]" + ], + [ + "div[aria-label=\"Density\"]" + ], + [ + "div[aria-label=\"Language\"]" + ], + [ + "div[aria-label=\"Date format\"]" + ], + [ + "li[data-value=\"a4\"]" + ], + [ + "li[data-value=\"letter\"]" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings", + "state": "loaded-cv-ai-locked", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 5, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedSizeSmall" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(2)" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(3)" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + "#_r_3q_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings", + "state": "loaded-cv-history", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 5, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedSizeSmall" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(2)" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(3)" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(4)" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] light loaded CV tabs, selects and settings", + "state": "loaded-settings", + "violations": [], + "incomplete": [ + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 3, + "targets": [ + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + ".MuiButton-containedSizeMedium" + ], + [ + ".css-17t4hgu" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] dark/en/1440 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/en/1440 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/en/1440 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_o_" + ], + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] dark/en/390 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/en/390 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/en/390 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ], + [ + "#_r_11_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] dark/nb/1440 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/nb/1440 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/nb/1440 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_o_" + ], + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-J08] dark/nb/390 loading → error → retry → empty → populated", + "state": "loaded-error", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/nb/390 loading → error → retry → empty → populated", + "state": "loaded-company", + "violations": [], + "incomplete": [] + }, + { + "test": "[PRES-E08 PRES-J08] dark/nb/390 loading → error → retry → empty → populated", + "state": "loaded-company-dialog", + "violations": [], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#root" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 9, + "targets": [ + [ + "#_r_p_" + ], + [ + "#_r_q_" + ], + [ + "#_r_r_" + ], + [ + "#_r_s_" + ], + [ + "#_r_t_" + ], + [ + "#_r_u_" + ], + [ + "#_r_v_" + ], + [ + "#_r_10_" + ], + [ + "#_r_11_" + ] + ] + } + ] + }, + { + "test": "[PRES-E07 PRES-E08 PRES-J05 PRES-J07] dark loaded status semantics, navigation and command focus", + "state": "loaded-jobs", + "violations": [], + "incomplete": [ + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 6, + "targets": [ + [ + ".MuiBadge-invisible" + ], + [ + "#_r_c_" + ], + [ + "div[aria-labelledby=\"job-table-status-label\"]" + ], + [ + "div[aria-labelledby=\"job-table-company-label\"]" + ], + [ + "#_r_f_" + ], + [ + "div[aria-labelledby=\"job-table-readiness-label\"]" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings", + "state": "loaded-cv-template-button-hover", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings", + "state": "loaded-cv-content", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 4, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-containedSizeSmall.css-akwpjr.MuiButton-contained" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + "#_r_s_-label" + ], + [ + "#_r_s_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings", + "state": "loaded-cv-customize", + "violations": [ + { + "rule": "aria-input-field-name", + "impact": "serious", + "nodes": 4, + "targets": [ + [ + ".css-l0tx9m.MuiBox-root:nth-child(4) > .MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(1) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ], + [ + ".css-l0tx9m.MuiBox-root:nth-child(4) > .MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(2) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ], + [ + ".MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(8) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ], + [ + ".MuiFormControl-fullWidth.css-17qa0m8.MuiFormControl-root:nth-child(9) > .MuiOutlinedInput-root.MuiSelect-root.css-1f6076n > .MuiSelect-select.MuiSelect-outlined.MuiOutlinedInput-input" + ] + ] + } + ], + "incomplete": [ + { + "rule": "aria-hidden-focus", + "impact": "serious", + "nodes": 1, + "targets": [ + [ + "#menu-" + ] + ] + }, + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 10, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedSizeSmall" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(2)" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + "div[aria-label=\"Skills presentation\"]" + ], + [ + "div[aria-label=\"Page size\"]" + ], + [ + "div[aria-label=\"Density\"]" + ], + [ + "div[aria-label=\"Language\"]" + ], + [ + "div[aria-label=\"Date format\"]" + ], + [ + "li[data-value=\"a4\"]" + ], + [ + "li[data-value=\"letter\"]" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings", + "state": "loaded-cv-ai-locked", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 5, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedSizeSmall" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(2)" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(3)" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + "#_r_3p_" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings", + "state": "loaded-cv-history", + "violations": [], + "incomplete": [ + { + "rule": "aria-prohibited-attr", + "impact": "serious", + "nodes": 2, + "targets": [ + [ + "span[aria-label=\"Undo\"]" + ], + [ + "span[aria-label=\"Redo\"]" + ] + ] + }, + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 5, + "targets": [ + [ + ".css-1ebacnx > .MuiButton-contained.MuiButton-containedPrimary.MuiButton-containedSizeSmall" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(2)" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(3)" + ], + [ + ".MuiTab-root.MuiTab-textColorPrimary.css-p9swfl:nth-child(4)" + ], + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ] + ] + } + ] + }, + { + "test": "[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] dark loaded CV tabs, selects and settings", + "state": "loaded-settings", + "violations": [], + "incomplete": [ + { + "rule": "color-contrast", + "impact": "serious", + "nodes": 3, + "targets": [ + [ + ".Mui-selected.MuiTab-root.MuiTab-textColorPrimary" + ], + [ + ".MuiButton-containedSizeMedium" + ], + [ + ".css-17t4hgu" + ] + ] + } + ] + } + ] +} diff --git a/docs/redesign/phase-4/p4-01/TRACEABILITY.md b/docs/redesign/phase-4/p4-01/TRACEABILITY.md new file mode 100644 index 0000000..3502261 --- /dev/null +++ b/docs/redesign/phase-4/p4-01/TRACEABILITY.md @@ -0,0 +1,51 @@ +# P4-01 preservation and visual evidence + +Chain: `PRES → original requirement → P4-01 owner change → executed regression → loaded visual/UX evidence`. Resolve the implementation commit with `git log -- docs/redesign/phase-4`; its parent is `f696f23`. All baseline routes, query parameters, write/ownership contracts and provider boundaries are retained. None of the 57 [original preservation rows](../../phase-2/PRESERVATION_MATRIX.md) is replaced by this shorter scope table. + +## Affected contracts + +| PRES → retained requirement | Implementation / location retained | Automated regression and result | Visual/UX evidence / explicit limit | +|---|---|---|---| +| PRES-E08 → existing theme/system and EN/NB state, shared notices/dialogs | `theme.ts`, `theme.d.ts`; existing ThemeProvider/preferences/I18nProvider unchanged | `theme-contrast.test.ts` 14 pairs; `loaded-theme-states.test.tsx` six transitions; existing theme-state/i18n/dialog tests; 12 new browser cases | Both themes, EN/NB, 1440/390 widths on company states; no theme remount/state change. General completion announcements and AT review remain P4-02 | +| PRES-E07 → collapse/drawer, current destination and commands | `AppShell.tsx` only replaces color references and adds inverse focus alias. Same sidebar groups/buttons, routes, widths and storage key | Existing sidebar-active-nav/quick-command-preservation; real command navigation baseline; new nav/command input/item focus and phone drawer return | `jobs-nav-*`, company phone fixtures. No command hierarchy or navigation migration | +| PRES-J05, PRES-J07 → row actions and existing pipeline meanings | Shared colors only; `JobTable`, `KanbanBoard`, `pipeline.ts` unchanged | Existing pipeline/grouped Kanban tests; loaded six-status Jobs native API/browser scans in both modes; inherited dark Kanban browser | `jobs-nav-*`: labelled Saved/Applied/Waiting/Interview/Offer/Rejected, primary/neutral/warning/info/success/error. Custom statuses retain neutral/raw label; no new custom-color picker | +| PRES-J08 → company/contact edits and relationships | CompaniesTable unchanged; shared fields/dialog/action styling | Eight browser matrix cases: named loading → error/Retry → empty → actual populated table/card; keyboard edit/dialog/return; six Jest cases also prove no write on load/edit | `companies-*`: EN/NB × light/dark × desktop/phone. Contact-save/relationship behavior retained in existing tests; no new claim of every date/source edge | +| PRES-C06, PRES-C07 → CV tabs, previews, inclusion and isolated overrides | Shared theme plus two omission-opacity branches in CvBuilderEditor; no data/save/preview change | Loaded Content with canonical source and omitted sections, named override focus/contrast; existing CV Jest coverage and native Phase 2 mobile override/sibling isolation test | `cv-content-*`: omitted sections are editable/readable, visibility indicators retained. Existing overflow warning remains. P4-03 preview keyboard and long print-layout gaps remain | +| PRES-C08, PRES-C09 → capability-aware template/customize/document controls | Existing controls consume shared palette and notched focus | Loaded Template/hovered PDF action, Customize Page size combobox/menu/Escape/focus return; existing CV normalization/template/render tests | EN desktop in both themes; advanced document choices and printed paper colors unchanged. New naming failures are not silently repaired | +| PRES-C11, PRES-S04 → AI review/accept/reject and entitlement locks | AI logic/actions unchanged | Three real Free document-AI Review buttons remain disabled; loaded AI panel contrast; inherited accept/reject/malformed response Jest tests and server entitlement/browser checks | Pro notice/locked controls readable; no model invoked, no claim of live provider factual quality | +| PRES-S03 → preferences and notification settings | Settings owner unchanged, shared tab/button/field styles | Loaded Notifications tab/checkbox/save in both modes, pointer-vs-keyboard focus; existing settings failure/retry and mobile→desktop preference persistence baseline | EN desktop plus inherited responsive settings workflow; no preference schema/UI redesign | +| PRES-E01, PRES-E02 → public plans/auth entry | Shared theme influence only; marketing literals retained | Unchanged public axe, responsive plans/keyboard and real login browser; existing entry tests | Full original browser cases retained. No final brand or external sign-in/reset-delivery approval | +| PRES-E09 → server/session and private-state separation | No authentication, App/provider, cache or API changes | Four existing real account-isolation browser cases plus full backend/session regression baseline | Regression dependency, not a newly claimed visual/privacy mechanism | + +The remaining protected groups have no directly changed workflow owner. Their mapped tests remain in the complete 916 backend / 333 frontend baseline. Shared theme reach does **not** imply a newly verified loaded/visual state for every one of the 57 groups. Original provider-dependent, production, accessibility and untested capability gaps remain in the Phase 2 matrix. + +## New browser fixture boundary + +[semantic-foundations.spec.ts](../../../../job-tracker-ui/e2e/semantic-foundations.spec.ts) uses the existing Playwright app/API topology and a new non-admin synthetic owner per test. Native endpoints create profile, CV, company and status examples using B02 session/CSRF headers. Internet/provider traffic is blocked. Only the Companies GET is deliberately delayed/replaced for deterministic loading/503/empty; the populated state returns to the real API. No public fixture route or component gallery was added. + +The browser's keyboard helper follows Tab/Shift+Tab or menu ArrowDown/ArrowUp, checks the actual focused target, indicator width/style and ≥3:1 composited contrast. CV tabs use their real roving-tab arrow/Enter interaction. The native MUI dialog/drawer focus-return behavior is asserted. A pointer click on the settings save action remains free of a keyboard focus indicator. + +All loaded axe scans run the existing WCAG A/AA tag set without disabling rules. This new file asserts **color-contrast violations only**, as explicitly scoped to P4-01; the complete violation and incomplete results are attached to the local Playwright JSON report. [Scan index](SCAN_RESULTS.json) retains concise rule/node evidence rather than credentials or trace archives. Existing general axe tests remain unchanged. Non-color results are debt, not passes or a certification claim. + +## Visual evidence + +Captured in local Chromium via the actual built Next application and disposable .NET API, 2026-09-10/11. Heights are 900 CSS px. Files contain synthetic accounts/data only. The same [test source](../../../../job-tracker-ui/e2e/semantic-foundations.spec.ts) defines each exact route and keyboard state. `P4_CAPTURE=true` regenerates the committed screenshots; normal test runs write to ignored test-results. + +| Artifact | Route/state/viewport | Review | +|---|---|---| +| [Jobs light](visual/jobs-nav-light.png), [Jobs dark](visual/jobs-nav-dark.png) | `/jobs`, six populated statuses, Companies sidebar item keyboard-focused, EN, 1440×900 | Status labels and filled foregrounds readable; distinct selected page vs focus; unchanged dense table; inverse ring visible | +| [CV light](visual/cv-content-light.png), [CV dark](visual/cv-content-dark.png) | `/career/builder/:id`, loaded Content with canonical profile, omitted sections and headline field keyboard-focused, EN, 1440×900 | Readable omitted rows, field label notch and focus, outline Saved chip, disabled undo/redo and colored alerts. Printed preview stays white. Existing overflow warning retained | +| [Companies light EN desktop](visual/companies-light-en-1440.png), [dark EN desktop](visual/companies-dark-en-1440.png) | `/companies`, populated table, edit icon keyboard-focused, EN, 1440×900 | Essential action/ring and text visible; surfaces/table hierarchy retained | +| [Companies light NB desktop](visual/companies-light-nb-1440.png), [dark NB desktop](visual/companies-dark-nb-1440.png) | Same state, Bokmål, 1440×900 | Norwegian labels and æ/ø/å retained | +| [Companies light EN phone](visual/companies-light-en-390.png), [dark EN phone](visual/companies-dark-en-390.png) | Populated existing mobile card, edit focus, EN, 390×900 | Existing card workflow retained; visible action focus without page restructuring | +| [Companies light NB phone](visual/companies-light-nb-390.png), [dark NB phone](visual/companies-dark-nb-390.png) | Same state, Bokmål, 390×900 | No truncation of fixture labels; ring visible against both surfaces; drawer keyboard/return tested after screenshot | + +No final visual identity or layout approval is implied. Disabled controls retain disabled semantics and existing adjacent explanations; their text is not falsely counted as satisfying active-text contrast. Subtle decorative dividers are not used to claim essential-control contrast. Placeholder-only naming, transient drag opacity, remaining local chart/marketing colors, manual assistive technology and full loaded responsive CV interactions remain separately scoped. + +Capture limitation: some company-page PNGs still partially omit sidebar glyphs despite unchanged visible DOM/computed styles. The paired diagnostic captures described in the completion report show those glyphs returning after painting without an application change. Company PNGs evidence the main loaded state and edit focus; use the dedicated `jobs-nav-*` captures and actual keyboard assertions for navigation evidence. This is not treated as a successful whole-sidebar screenshot comparison. No content was hidden or restyled to produce the evidence. + +## Review and rollback + +Self-review: no action, data, event handler, ownership or route change; no newly focusable ordinary cards; keyboard rings remain distinct from hover/selection; both CSS variable schemes are exercised; B01–B05 suites retained. The one CV local change addresses an independently measured contrast cause. No sensitive traces are committed. + +Rollback is a normal revert of this isolated P4-01 checkpoint; no schema/data migration or infrastructure rollback is needed. Do not start P4-02 or broader migration until Connor has reviewed this checkpoint. F04 and P2-D01 retain their separate closure requirements. diff --git a/docs/redesign/phase-4/p4-01/visual/companies-dark-en-1440.png b/docs/redesign/phase-4/p4-01/visual/companies-dark-en-1440.png new file mode 100644 index 0000000..64db63f Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-dark-en-1440.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-dark-en-390.png b/docs/redesign/phase-4/p4-01/visual/companies-dark-en-390.png new file mode 100644 index 0000000..28f6f80 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-dark-en-390.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-dark-nb-1440.png b/docs/redesign/phase-4/p4-01/visual/companies-dark-nb-1440.png new file mode 100644 index 0000000..0eddad0 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-dark-nb-1440.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-dark-nb-390.png b/docs/redesign/phase-4/p4-01/visual/companies-dark-nb-390.png new file mode 100644 index 0000000..7db00f9 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-dark-nb-390.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-light-en-1440.png b/docs/redesign/phase-4/p4-01/visual/companies-light-en-1440.png new file mode 100644 index 0000000..92bfbbe Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-light-en-1440.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-light-en-390.png b/docs/redesign/phase-4/p4-01/visual/companies-light-en-390.png new file mode 100644 index 0000000..ad2cfc5 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-light-en-390.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-light-nb-1440.png b/docs/redesign/phase-4/p4-01/visual/companies-light-nb-1440.png new file mode 100644 index 0000000..3057e81 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-light-nb-1440.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/companies-light-nb-390.png b/docs/redesign/phase-4/p4-01/visual/companies-light-nb-390.png new file mode 100644 index 0000000..f3508b4 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/companies-light-nb-390.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/cv-content-dark.png b/docs/redesign/phase-4/p4-01/visual/cv-content-dark.png new file mode 100644 index 0000000..71e6ac7 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/cv-content-dark.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/cv-content-light.png b/docs/redesign/phase-4/p4-01/visual/cv-content-light.png new file mode 100644 index 0000000..f3b1b0e Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/cv-content-light.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/jobs-nav-dark.png b/docs/redesign/phase-4/p4-01/visual/jobs-nav-dark.png new file mode 100644 index 0000000..71674e3 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/jobs-nav-dark.png differ diff --git a/docs/redesign/phase-4/p4-01/visual/jobs-nav-light.png b/docs/redesign/phase-4/p4-01/visual/jobs-nav-light.png new file mode 100644 index 0000000..fcde829 Binary files /dev/null and b/docs/redesign/phase-4/p4-01/visual/jobs-nav-light.png differ diff --git a/docs/todo/work.md b/docs/todo/work.md index fa3694d..2b517d5 100644 --- a/docs/todo/work.md +++ b/docs/todo/work.md @@ -6,7 +6,8 @@ - [x] Finalize Phase 1 separately, 2026-09-10: classify all 57 preservation groups, UX improvements, genuinely new/conditional requirements and baseline defects; retain already-implemented CV drag/fit, custom status values, calendar and other protected workflows. Final summary/contract/test limits/Phase 2 starting point: `docs/redesign/audit/PHASE1_SUMMARY.md`. Documentation only; B06/F04 closure remains open. - [ ] Complete the authorized `main` release through required CI/deploy gates. Main `27657f3` is pushed; run 743 passed 916 backend/298 frontend tests, lint, dependency audits and production builds, then the browser gate failed (worker SIGSEGV, retry timeout and closed-browser errors; 6 passed/12 failed). Deploy skipped; live remains version 299. Stop further release retries pending runner-host diagnostics; exact read-only handoff and evidence are in `docs/audits/2026-09-07/validation/b06-completion.md`. No gate was bypassed. - [x] Phase 3 from `d6d3dd9` on `codex/ui-redesign-phase3`: [UX architecture package](../redesign/phase-3/README.md), 31 existing route patterns, 57 PRES impact rows, data-qualified personal/admin dashboards, canonical/CV/job/frozen-document distinctions, contextual AI review, responsive/accessibility/component plans and explicit decisions. Documentation only; no Phase 4/visual implementation or B07. F04 remains open. -- [ ] Next redesign work, not started: [P4-01 semantic state/focus foundation](../redesign/phase-3/PHASE4_IMPLEMENTATION_PLAN.md) using existing MUI/theme and loaded regression fixtures. Keep current brand/routes/providers; coordinate validated A01. Do not begin broad shell/pages before foundation proof. +- [x] P4-01 from `f696f23` on `codex/ui-redesign-phase4`: [semantic colors, focus and loaded-state checkpoint](../redesign/phase-4/p4-01/README.md). Existing MUI foundation, provisional brand, routes/providers and status meanings retained. Final 916 backend / 333 frontend pass; browser 35 actual passes + unchanged P2-D01 expected failure. Both-theme keyboard/loaded scans and visual evidence recorded; no broad layout or route redesign. F04 remains open. +- [ ] Recommended next, not started: [P4-02 shared naming/interaction foundation](../redesign/phase-3/PHASE4_IMPLEMENTATION_PLAN.md). Carry forward unnamed Customize controls and command input, modal/async announcement review and manual accessibility gaps from P4-01. Do not begin before checkpoint review; no B07. - [ ] P3-O01/P3-O02 separately scoped presentation debt: accurate analytics population/period labels and navigation/query/error retention when those surfaces migrate. [Source observations and boundaries](../redesign/phase-3/OPEN_DECISIONS.md); no analytics, route or command behavior fixed during Phase 3. # Expanded production audit — 2026-09-07 diff --git a/job-tracker-ui/e2e/semantic-foundations.spec.ts b/job-tracker-ui/e2e/semantic-foundations.spec.ts new file mode 100644 index 0000000..32ea4fe --- /dev/null +++ b/job-tracker-ui/e2e/semantic-foundations.spec.ts @@ -0,0 +1,228 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test as base, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test"; +import { translations } from "../src/i18n/translations"; + +const apiUrl = "http://localhost:5302/api"; +const password = "E2ePassword123!"; +async function login(api: APIRequestContext, email: string) { + expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); + const me = await (await api.get(`${apiUrl}/auth/me`)).json(); + const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === "XSRF-TOKEN")!.value; + return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string }; +} + +const test = base.extend<{ owner: { headers: Record; companyId: number; cvId: number } }>({ + owner: async ({ playwright, page, context }, provide) => { + const admin = await playwright.request.newContext(); + try { + const adminHeaders = await login(admin, "e2e@example.test"); + const email = `p4-${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); + // Real disposable API, no provider calls. Only the explicit company-state case + // below delays/replaces a GET to make loading/error/empty deterministic. + await context.route("**/*", route => { + const url = new URL(route.request().url()); + return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) + ? route.continue() : route.abort(); + }); + const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS", location: "Tromsø" } }); + expect(company.status()).toBe(201); + expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { + version: "1", contact: { fullName: "Åse Eksempel", headline: "Engineer" }, + jobs: [{ title: "Engineer", company: "Nordlys AS", bullets: ["Built reliable APIs"] }], skills: ["C#", "Norsk"], + } } })).status()).toBe(200); + const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "P4 loaded CV" } }); + expect(cv.status()).toBe(200); + await provide({ headers, companyId: (await company.json()).id, cvId: (await cv.json()).id }); + } finally { await admin.dispose(); } + }, +}); + +async function setup(page: Page, mode: "light" | "dark", language: "en" | "nb", width: number) { + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript(({ mode, language }) => { + localStorage.setItem("jobtracker.themeMode", mode); + localStorage.setItem("uiLanguage", language); + }, { mode, language }); +} + +async function capture(page: Page, info: TestInfo, name: string) { + const path = process.env.P4_CAPTURE === "true" + ? `../docs/redesign/phase-4/p4-01/visual/${name}.png` : info.outputPath(`${name}.png`); + // A post-axe capture can precede Chromium repainting unchanged sidebar text. + // Allow a complete paint cycle; do not alter the page or hide content for capture. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await page.screenshot({ path, fullPage: false }); + await info.attach(name, { path, contentType: "image/png" }); +} + +async function loadedContrast(page: Page, info: TestInfo, state: string) { + const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); + // This slice gates color contrast. Preserve the full scan (including naming and + // preview-scroll findings) for P4-02/03; this is not a full accessibility pass. + await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" }); + expect(result.violations.filter(v => v.id === "color-contrast"), state).toEqual([]); +} + +async function keyboardFocus(page: Page, target: Locator) { + await target.focus(); + const option = await target.getAttribute("role") === "option"; + await page.keyboard.press(option ? "ArrowDown" : "Tab"); + await page.keyboard.press(option ? "ArrowUp" : "Shift+Tab"); + await expect(target).toBeFocused(); + const ring = await target.evaluate(node => { + const control = node.closest(".MuiInputBase-root") ?? node; + const notch = control.querySelector(".MuiOutlinedInput-notchedOutline"); + const css = getComputedStyle(notch ?? control); + const color = notch ? css.borderTopColor : css.outlineColor; + const width = notch ? css.borderTopWidth : css.outlineWidth; + const style = notch ? css.borderTopStyle : css.outlineStyle; + const offset = notch ? "-3px" : css.outlineOffset; + const rgba = (value: string) => value.match(/[\d.]+/g)!.map(Number); + const blend = (front: number[], back: number[]) => front.slice(0, 3).map((v, i) => v * (front[3] ?? 1) + back[i] * (1 - (front[3] ?? 1))); + const chain: Element[] = []; + // Inset rings sit on the control; offset rings sit on its parent surface. + for (let el = Number.parseFloat(offset) < 0 ? control : control.parentElement; el; el = el.parentElement) chain.unshift(el); + const surface = chain.reduce((back, el) => blend(rgba(getComputedStyle(el).backgroundColor), back), [255, 255, 255]); + const luminance = (rgb: number[]) => rgb.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); + const a = luminance(blend(rgba(color), surface)); + const b = luminance(surface); + return { width, style, color, offset, contrast: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }; + }); + expect(ring.width).toBe("3px"); + expect(ring.style).toBe("solid"); + expect(ring.color).not.toBe("rgba(0, 0, 0, 0)"); + expect(ring.contrast).toBeGreaterThanOrEqual(3); + return ring; +} + +for (const mode of ["light", "dark"] as const) { + for (const language of ["en", "nb"] as const) { + for (const width of [1440, 390]) { + test(`[PRES-E08 PRES-J08] ${mode}/${language}/${width} loading → error → retry → empty → populated`, async ({ page, owner }, info) => { + const t = translations[language]; + await setup(page, mode, language, width); + expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true); + let release!: () => void; + const delayed = new Promise(resolve => { release = resolve; }); + let phase: "error" | "empty" | "populated" = "error"; + await page.route(`${apiUrl}/companies`, async route => { + await delayed; + if (phase === "populated") await route.continue(); + else await route.fulfill({ status: phase === "error" ? 503 : 200, contentType: "application/json", body: phase === "error" ? JSON.stringify({ message: t.companiesLoadFailed }) : "[]" }); + }); + await page.goto("/companies"); + await expect(page.locator("main").getByRole("status")).toHaveAttribute("aria-live", "polite"); + await expect(page.locator("main").getByRole("progressbar")).toHaveAccessibleName(t.loading); + release(); + await expect(page.locator("main").getByRole("alert")).toContainText(t.companiesLoadFailed); + await loadedContrast(page, info, "loaded-error"); + const retry = page.getByRole("button", { name: t.retry, exact: true }); + await keyboardFocus(page, retry); + phase = "empty"; + await retry.press("Enter"); + await expect(page.getByText(t.companiesEmpty, { exact: true })).toBeVisible(); + await expect(page.locator("main").getByRole("alert")).toHaveCount(0); + 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("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. + const edit = page.getByRole("button", { name: `${t.jobTableEdit}: Nordlys æøå AS`, exact: true }); + await keyboardFocus(page, edit); + await capture(page, info, `companies-${mode}-${language}-${width}`); + await edit.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toHaveAccessibleName(t.companiesEdit); + const field = dialog.getByRole("textbox").first(); + await keyboardFocus(page, field); + await loadedContrast(page, info, "loaded-company-dialog"); + await page.keyboard.press("Escape"); + await expect(edit).toBeFocused(); + if (width === 390) { + const trigger = page.getByRole("button", { name: t.openNavigation, exact: true }); + await keyboardFocus(page, trigger); + await trigger.press("Enter"); + const destination = page.getByRole("button", { name: t.companies, exact: true }); + await keyboardFocus(page, destination); + await page.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); + } + expect((await page.request.get(`${apiUrl}/companies/${owner.companyId}`)).status()).toBe(200); + }); + } + } + + test(`[PRES-E07 PRES-E08 PRES-J05 PRES-J07] ${mode} loaded status semantics, navigation and command focus`, async ({ page, owner }, info) => { + await setup(page, mode, "en", 1440); + for (const status of ["Saved", "Applied", "Waiting", "Interview", "Offer", "Rejected"]) { + const created = await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: owner.companyId, jobTitle: `P4 ${status}`, status } }); + expect(created.ok()).toBe(true); + } + await page.goto("/jobs"); + await expect(page.getByRole("table").getByText("P4 Offer", { exact: true })).toBeVisible(); + await loadedContrast(page, info, "loaded-jobs"); + const nav = page.getByRole("button", { name: "Companies", exact: true }); + await keyboardFocus(page, nav); + await capture(page, info, `jobs-nav-${mode}`); + await page.keyboard.press("Control+k"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await keyboardFocus(page, dialog.getByRole("textbox")); + await page.keyboard.press("Tab"); + const command = dialog.getByRole("button").first(); + await keyboardFocus(page, command); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + }); + + test(`[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] ${mode} loaded CV tabs, selects and settings`, async ({ page, owner }, info) => { + await setup(page, mode, "en", 1440); + await page.goto(`/career/builder/${owner.cvId}`); + await expect(page.getByLabel("CV name", { exact: true })).toHaveValue("P4 loaded CV"); + await expect(page.getByTitle("CV preview page 1")).toBeVisible(); + const download = page.getByRole("button", { name: translations.en.cvEditorDownloadPdf, exact: true }); + await download.hover(); + await loadedContrast(page, info, "loaded-cv-template-button-hover"); + await page.getByRole("tab", { name: "Content", exact: true }).click(); + await expect(page.getByLabel("Headline override", { exact: true })).toBeVisible(); + await keyboardFocus(page, page.getByLabel("Headline override", { exact: true })); + await loadedContrast(page, info, "loaded-cv-content"); + await capture(page, info, `cv-content-${mode}`); + const customize = page.getByRole("tab", { name: "Customize", exact: true }); + await page.getByRole("tab", { name: "Content", exact: true }).focus(); + await page.keyboard.press("ArrowRight"); + await expect(customize).toBeFocused(); + await customize.press("Enter"); + await keyboardFocus(page, customize); + const select = page.getByRole("combobox", { name: "Page size", exact: true }); + await keyboardFocus(page, select); + await select.press("Enter"); + const option = page.getByRole("option", { name: "A4", exact: true }); + await expect(option).toBeFocused(); + await keyboardFocus(page, option); + await page.keyboard.press("Escape"); + await expect(select).toBeFocused(); + await loadedContrast(page, info, "loaded-cv-customize"); + await page.getByRole("tab", { name: "AI Tools", exact: true }).click(); + const aiReviews = page.getByRole("button", { name: translations.en.cvDocumentAiReview, exact: true }); + await expect(aiReviews).toHaveCount(3); + for (const review of await aiReviews.all()) await expect(review).toBeDisabled(); + await loadedContrast(page, info, "loaded-cv-ai-locked"); + await page.getByRole("tab", { name: "History", exact: true }).click(); + await loadedContrast(page, info, "loaded-cv-history"); + await page.goto("/settings"); + await page.getByRole("tab", { name: "Notifications", exact: true }).click(); + await expect(page.getByLabel("Email reminders for follow-ups", { exact: true })).toBeVisible(); + const save = page.getByRole("button", { name: "Save notification settings", exact: true }); + await save.click(); + // Ordinary pointer activation doesn't leave a keyboard ring on a button. + await expect(save).not.toHaveClass(/Mui-focusVisible/); + expect(await save.evaluate(node => node.matches(":focus-visible"))).toBe(false); + await keyboardFocus(page, save); + await loadedContrast(page, info, "loaded-settings"); + }); +} diff --git a/job-tracker-ui/src/layout/AppShell.tsx b/job-tracker-ui/src/layout/AppShell.tsx index e7a6684..26c3b65 100644 --- a/job-tracker-ui/src/layout/AppShell.tsx +++ b/job-tracker-ui/src/layout/AppShell.tsx @@ -66,15 +66,14 @@ export function activeNavTo(pathname: string, tos: string[]): string | null { return best; } -// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle -- -// a deliberate signature element, not derived from theme tokens. -const SIDEBAR_BG = "#0f172a"; -const SIDEBAR_BORDER = "rgba(255,255,255,0.08)"; -const SIDEBAR_TEXT_MUTED = "#94a3b8"; -const SIDEBAR_TEXT = "#e2e8f0"; -const SIDEBAR_SELECTED_BG = "rgba(99,102,241,0.18)"; -const SIDEBAR_SELECTED_TEXT = "#ffffff"; -const SIDEBAR_SELECTED_ICON = "#a5b4fc"; +// Preserve the existing inverse navigation surface in both themes. +const SIDEBAR_BG = "var(--palette-navigation-background)"; +const SIDEBAR_BORDER = "var(--palette-navigation-border)"; +const SIDEBAR_TEXT_MUTED = "var(--palette-navigation-muted)"; +const SIDEBAR_TEXT = "var(--palette-navigation-text)"; +const SIDEBAR_SELECTED_BG = "var(--palette-navigation-selected)"; +const SIDEBAR_SELECTED_TEXT = "var(--palette-navigation-selectedText)"; +const SIDEBAR_SELECTED_ICON = "var(--palette-navigation-selectedIcon)"; export default function AppShell({ pageTitle, @@ -181,7 +180,7 @@ export default function AppShell({ justifyContent: desktopNavCollapsed ? "center" : "flex-start", border: "1px solid transparent", color: SIDEBAR_TEXT_MUTED, - "&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT }, + "&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT }, "&.Mui-selected": { backgroundColor: SIDEBAR_SELECTED_BG, color: SIDEBAR_SELECTED_TEXT, @@ -210,7 +209,7 @@ export default function AppShell({ ); const drawerContent = ( - + diff --git a/job-tracker-ui/src/loaded-theme-states.test.tsx b/job-tracker-ui/src/loaded-theme-states.test.tsx new file mode 100644 index 0000000..74d61fa --- /dev/null +++ b/job-tracker-ui/src/loaded-theme-states.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { api } from "./api"; +import CompaniesTable from "./components/CompaniesTable"; +import { I18nProvider } from "./i18n/I18nProvider"; +import JobTrackerThemeProvider from "./ThemeProvider"; +import { ToastProvider } from "./toast"; + +jest.mock("./api", () => ({ + api: { get: jest.fn(), put: jest.fn() }, + getApiErrorMessage: (_error: unknown, fallback: string) => fallback, +})); + +describe.each(["light", "dark"] as const)("%s loaded company workflow", mode => { + beforeEach(() => { localStorage.clear(); localStorage.setItem("uiLanguage", "en"); jest.clearAllMocks(); }); + test.each(["populated", "empty", "error"])("loading → %s remains understandable and usable", async outcome => { + let finish!: (value: unknown) => void; + let fail!: (error: unknown) => void; + (api.get as jest.Mock).mockReturnValue(new Promise((resolve, reject) => { finish = resolve; fail = reject; })); + render( + + ); + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + expect(screen.getByRole("progressbar")).toHaveAccessibleName(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + const company = { id: 3, name: "Nordlys æøå AS" }; + await act(async () => { + if (outcome === "error") fail({ response: { status: 503 } }); + else finish({ data: outcome === "empty" ? [] : [company] }); + }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + if (outcome === "error") { + const error = screen.getByRole("alert"); + expect(error).not.toBeEmptyDOMElement(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + (api.get as jest.Mock).mockResolvedValue({ data: [company] }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText(company.name)).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + } + expect(screen.getByRole("table")).toBeVisible(); + if (outcome !== "empty") { + 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(); + } else { + expect(screen.getByRole("table")).toHaveTextContent("No companies"); + } + expect(api.put).not.toHaveBeenCalled(); + }); +}); diff --git a/job-tracker-ui/src/theme-contrast.test.ts b/job-tracker-ui/src/theme-contrast.test.ts new file mode 100644 index 0000000..4be0e98 --- /dev/null +++ b/job-tracker-ui/src/theme-contrast.test.ts @@ -0,0 +1,24 @@ +import { getContrastRatio } from "@mui/material/styles"; +import { getTheme } from "./theme"; + +// Contract tests for text/button pairs, not snapshots of particular brand hex values. +describe.each(["light", "dark"] as const)("%s semantic contrast", mode => { + const palette = getTheme(mode).colorSchemes[mode].palette; + test.each(["primary", "secondary", "success", "warning", "error", "info"])("%s supports text and filled actions", role => { + const color = palette[role]; + for (const surface of [palette.background.default, palette.background.paper, palette.background.elevated ?? palette.background.paper]) { + expect(getContrastRatio(color.main, surface)).toBeGreaterThanOrEqual(4.5); + } + for (const fill of [color.main, color.dark]) { + expect(getContrastRatio(color.contrastText, fill)).toBeGreaterThanOrEqual(4.5); + } + }); + test("focus and essential control boundaries contrast with all application surfaces", () => { + for (const surface of [palette.background.default, palette.background.paper, palette.background.elevated]) { + expect(getContrastRatio(palette.focus.main, surface)).toBeGreaterThanOrEqual(3); + expect(getContrastRatio(palette.border.default, surface)).toBeGreaterThanOrEqual(3); + } + expect(getContrastRatio(palette.focus.inverse, palette.navigation.background)).toBeGreaterThanOrEqual(3); + expect(palette.action.selected).not.toBe(palette.action.hover); + }); +}); diff --git a/job-tracker-ui/src/theme.d.ts b/job-tracker-ui/src/theme.d.ts new file mode 100644 index 0000000..a617a14 --- /dev/null +++ b/job-tracker-ui/src/theme.d.ts @@ -0,0 +1,18 @@ +import "@mui/material/styles"; + +declare module "@mui/material/styles" { + interface TypeBackground { elevated: string } + interface Palette { + border: { subtle: string; default: string; strong: string }; + focus: { main: string; inverse: string }; + navigation: { + background: string; border: string; text: string; muted: string; hover: string; + selected: string; selectedText: string; selectedIcon: string; + }; + } + interface PaletteOptions { + border?: Palette["border"]; + focus?: Palette["focus"]; + navigation?: Palette["navigation"]; + } +} diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index cfbf92d..0d36997 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -2,9 +2,18 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles"; type PaletteLike = Record; -// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout -// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes. +// Existing provisional brand hue. Dark mode uses a lighter tone for readable text; +// this does not settle the Phase 3 brand/identity decision. const ACCENT = "#5B5BD6"; +const DARK_ACCENT = lighten(ACCENT, 0.45); + +// The existing inverse sidebar remains inverse in both modes. These are MUI palette +// roles so feature code does not have to invent colors for this surface. +const navigation = { + background: "#0F172A", border: "rgba(255,255,255,0.08)", + text: "#E2E8F0", muted: "#94A3B8", hover: "rgba(255,255,255,0.06)", + selected: "rgba(99,102,241,0.18)", selectedText: "#FFFFFF", selectedIcon: "#A5B4FC", +}; function buildPrimary(main: string) { return { @@ -28,7 +37,11 @@ function buildLightPalette(): PaletteLike { const disabledBackground = "#E4E1E6"; return { - primary: buildPrimary(ACCENT), + primary: { ...buildPrimary(ACCENT), contrastText: "#FFFFFF" }, + contrastThreshold: 4.5, + navigation, + focus: { main: ACCENT, inverse: "#A5B4FC" }, + border: { subtle: divider, default: "#777680", strong: textSecondary }, secondary: { lighter: "#E0E0FF", light: "#C3C4E4", @@ -39,29 +52,29 @@ function buildLightPalette(): PaletteLike { error: { lighter: "#FFEDEA", light: "#FFDAD6", - main: "#DE3730", - dark: "#BA1A1A", + main: "#BA1A1A", + dark: darken("#BA1A1A", 0.16), darker: "#690005", }, warning: { lighter: "#FFEEE1", light: "#FFDCBE", - main: "#AE6600", - dark: "#8B5000", + main: "#8B5000", + dark: darken("#8B5000", 0.16), darker: "#4A2800", }, success: { lighter: "#DCFCE7", light: "#BBF7D0", - main: "#16A34A", - dark: "#15803D", + main: "#15803D", + dark: darken("#15803D", 0.16), darker: "#14532D", }, info: { lighter: "#D4F7FF", light: "#A1EFFF", - main: "#008394", - dark: "#006876", + main: "#006876", + dark: darken("#006876", 0.16), darker: "#00363E", }, grey: { @@ -84,9 +97,11 @@ function buildLightPalette(): PaletteLike { divider, // Soft grey app background with white paper gives the layered dashboard look // from the product mockups; cards/inputs (paper) sit above it. - background: { default: "#F4F6FB", paper: background }, + background: { default: "#F4F6FB", paper: background, elevated: background }, action: { hover: alpha(ACCENT, 0.05), + selected: alpha(ACCENT, 0.12), + focus: alpha(ACCENT, 0.15), disabled: alpha(disabled, 0.6), disabledBackground: alpha(disabledBackground, 0.9), }, @@ -105,7 +120,11 @@ function buildDarkPalette(): PaletteLike { const disabledBackground = alpha("#FFFFFF", 0.08); return { - primary: buildPrimary(ACCENT), + primary: { ...buildPrimary(DARK_ACCENT), contrastText: paper }, + contrastThreshold: 4.5, + navigation, + focus: { main: DARK_ACCENT, inverse: "#A5B4FC" }, + border: { subtle: divider, default: "#777680", strong: "#B8B8C6" }, secondary: { lighter: alpha(secondaryMain, 0.22), light: alpha(secondaryMain, 0.14), @@ -159,9 +178,11 @@ function buildDarkPalette(): PaletteLike { disabled, }, divider, - background: { default: bg, paper }, + background: { default: bg, paper, elevated: "#1C1C24" }, action: { - hover: alpha(ACCENT, 0.16), + hover: alpha(DARK_ACCENT, 0.10), + selected: alpha(DARK_ACCENT, 0.18), + focus: alpha(DARK_ACCENT, 0.20), disabled: alpha("#FFFFFF", 0.5), disabledBackground, }, @@ -178,7 +199,7 @@ function buildCustomShadows(palette: any) { // panels) -- the old 1px "section" shadow read as flat/bordered rather than elevated. section: `0px 1px 2px 0px ${alpha(shadowColor, 0.04)}, 0px 8px 24px -12px ${alpha(shadowColor, 0.12)}`, tooltip: `0px 12px 16px -4px ${alpha(shadowColor, 0.08)}, 0px 4px 6px -2px ${alpha(shadowColor, 0.03)}`, - focus: `0px 0px 0px 3px ${alpha(primaryColor, 0.2)}`, + focus: `0px 0px 0px 3px ${palette.focus.main}`, // Gradient CTA (mockup's "Tailor my CV" / "See the interface tour" buttons) gets its own // colored glow instead of the flat neutral button shadow. gradientButton: `0px 4px 14px -4px ${alpha(primaryColor, 0.45)}`, @@ -248,6 +269,13 @@ export const getTheme = (_mode: "light" | "dark") => { typography: buildTypography() as any, } as any) as any; + // ButtonBase covers buttons/icons/tabs/menu items/interactive cards. Native links + // need the same rule. An outline survives forced colors and adds a shape, not + // just a color change. Inset variants stay visible inside scrolling tab/menu lists. + const focusRing = { + outline: `3px solid var(--app-focus-color, ${theme.vars.palette.focus.main})`, + outlineOffset: "2px", + }; theme.components = { MuiCssBaseline: { styleOverrides: { @@ -270,6 +298,7 @@ export const getTheme = (_mode: "light" | "dark") => { MozOsxFontSmoothing: "grayscale", overflowX: "hidden", }, + "a:focus-visible, button:focus-visible, [tabindex]:focus-visible, input:not(.MuiInputBase-input):focus-visible, textarea:not(.MuiInputBase-input):focus-visible, select:focus-visible": focusRing, // A global floor under every animation added this session (card hover-lift, // MUI's own Dialog/Menu/Collapse transitions, ripple) -- users who set this OS/ // browser preference get near-instant, non-animated interactions everywhere at @@ -284,6 +313,21 @@ export const getTheme = (_mode: "light" | "dark") => { }, }, }, + MuiButtonBase: { + styleOverrides: { root: { "&.Mui-focusVisible": focusRing } }, + }, + MuiLink: { + styleOverrides: { root: { "&:focus-visible": focusRing } }, + }, + MuiTab: { + styleOverrides: { root: { "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" } } }, + }, + MuiMenuItem: { + styleOverrides: { root: { "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" } } }, + }, + MuiCardActionArea: { + styleOverrides: { root: { "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" } } }, + }, MuiPaper: { defaultProps: { elevation: 0 }, styleOverrides: { @@ -316,6 +360,7 @@ export const getTheme = (_mode: "light" | "dark") => { MuiDialog: { styleOverrides: { paper: ({ theme }: any) => ({ + backgroundColor: theme.vars.palette.background.elevated, border: "none", borderRadius: 20, boxShadow: theme.vars.customShadows.tooltip, @@ -340,6 +385,7 @@ export const getTheme = (_mode: "light" | "dark") => { MuiPopover: { styleOverrides: { paper: ({ theme }: any) => ({ + backgroundColor: theme.vars.palette.background.elevated, border: "none", boxShadow: theme.vars.customShadows.tooltip, }), @@ -382,13 +428,21 @@ export const getTheme = (_mode: "light" | "dark") => { minHeight: 42, display: "flex", alignItems: "center", + // Use the existing label notch: an outer outline crosses floating labels. + "&:has(input:focus-visible, textarea:focus-visible, [role=combobox]:focus-visible)": { + outline: "none", + "& .MuiOutlinedInput-notchedOutline": { + borderWidth: "3px", + borderColor: `var(--app-focus-color, ${theme.vars.palette.focus.main})`, + }, + }, "&.Mui-disabled": { cursor: "not-allowed", input: { cursor: "not-allowed" }, "& .MuiOutlinedInput-notchedOutline": { borderColor: theme.vars.palette.divider }, }, }), - notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.divider }), + notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.border.default }), multiline: { padding: 10, alignItems: "flex-start", @@ -412,6 +466,10 @@ export const getTheme = (_mode: "light" | "dark") => { }, MuiInputBase: { styleOverrides: { + root: { + "&:has(input:focus-visible, textarea:focus-visible, [role=combobox]:focus-visible)": focusRing, + "& input:focus-visible, & textarea:focus-visible, & [role=combobox]:focus-visible": { outline: "none" }, + }, input: { "&::placeholder": { opacity: 0.72, @@ -445,8 +503,9 @@ export const getTheme = (_mode: "light" | "dark") => { styleOverrides: { root: ({ theme }: any) => ({ borderRadius: 8, + "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" }, "&.Mui-selected": { - backgroundColor: theme.vars.palette.action.hover, + backgroundColor: theme.vars.palette.action.selected, }, }), }, diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index d9d2b3d..a714275 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -942,7 +942,9 @@ function SectionRow({ variant="outlined" {...dragProps} sx={{ - opacity: dragging ? 0.4 : row.hidden ? 0.58 : 1, + // Hidden in the document is not disabled in the editor: keep names and + // controls readable. The visibility icon/label still conveys inclusion. + opacity: dragging ? 0.4 : 1, borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined, transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease", overflow: "hidden", "&:hover": { boxShadow: 1 }, @@ -1062,7 +1064,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: { return (