fix(a11y): close cross-app interaction gaps
CI and Deploy / test (pull_request) Successful in 4m54s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 17:49:42 +02:00
parent deed948183
commit a7c25499bb
23 changed files with 224 additions and 61 deletions
+18 -13
View File
@@ -1,21 +1,26 @@
# Accessibility code-review evidence # Accessibility code-review evidence
Captured: 2026-08-02 Updated: 2026-08-15
Classification: inspected in code and component tests only. Manual browser/assistive-technology verification was blocked by the missing in-app browser client. Classification: code inspection, component tests and local authenticated Chromium. Native assistive-technology and production checks remain external.
## Confirmed code-level issues ## Corrected issues
- Icon-only controls lack programmatic names in `CompaniesTable.tsx:139,186`, `Correspondence.tsx:472`, `JobTable.tsx:493,704,745-748`, `SavedViewsMenu.tsx:93,153`, and `CvBuilderPage.tsx:96`. Tooltips or HTML `title` alone are not a reliable accessible name. - Every frontend `IconButton` now has an explicit programmatic name. This includes shell navigation/user/settings/search, company edit, correspondence delete, table columns and row actions, saved views, attachments, and CV Builder navigation. File-specific actions include the affected record name.
- Attachment preview/download/rename/delete buttons use `title` without `aria-label` at `Attachments.tsx:320-330`. - CV cards expose link semantics, keyboard focus, a visible focus indicator and Enter/Space activation. Their nested action menu remains independent of card navigation.
- CV cards are clickable `Paper` elements with pointer styling but no link/button role, `tabIndex`, or keyboard activation at `CvBuilderPage.tsx:91-98`. - The anonymous public CV no longer puts a fixed 210mm element directly into a narrow page. It retains a full A4 iframe viewport, measures multi-page document height and scales the complete frame to the available width. Local Chromium proves no outer or inner horizontal overflow at 375px.
- The public CV iframe is fixed at 210mm wide at `PublicCvPage.tsx:53-62`; at 375px and 768px this is a code-supported overflow risk, but visual clipping was not manually verified. - Semantic alert surfaces remain theme-owned. Authenticated Chromium measures the dark-mode missing-job Alert foreground against its composited background at or above WCAG AA 4.5:1.
- The public loading state is announced with `role=status` and uses a stronger readable foreground.
## Positive evidence ## Verification
- Forms generally use MUI labels and many dialog close/action buttons have explicit `aria-label` values. - Focused accessibility-related UI: 4 suites, 9/9 tests.
- CV editor move/hide controls and checklist controls carry accessible names. - Full frontend: 54/54 suites, 228/228 tests.
- MUI supplies baseline focus and dialog semantics, though manual focus trapping/return was not checked. - Optimized production frontend build/TypeScript: pass.
- Public CV iframe has a title and sandbox attribute. - Full Playwright: 7/7; final targeted workspace/public-CV rerun: 2/2.
- Static `IconButton` audit: no control without an explicit `aria-label`.
No axe, pa11y, or Lighthouse dependency/configuration is present. No automated accessibility gate runs in CI. ## Remaining external and future gates
- Native screen-reader/switch-control and operating-system high-contrast checks are not available in the local automated environment and remain a production-readiness spot check.
- No axe, pa11y or Lighthouse dependency/configuration is present, so CI does not yet run a general automated accessibility crawler. Adding one requires an approved dependency change and should complement, not replace, the focused semantic and contrast assertions above.
+1
View File
@@ -201,3 +201,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-167 | Failing Career round-trip reproduction; affected/full backend; focused Career/Profile Jest; persistence-consumer and diff review | Repository root / `job-tracker-ui` | Preserve every reviewed Career text value across save/get/version/projection/import use without weakening extraction cleanup or write bounds | PASS — pre-fix location became `Oslo, Norway and` and the test failed; post-fix affected backend 112/112, backend 642/642 and Career/Profile UI 17/17 pass. Website path/query, remote location, free-form date, custom language and incomplete WIP entry round-trip; oversized website is rejected explicitly | Local SQLite/JSDOM only; no real private CV/model/provider/production data. One initial Jest command used nonexistent paths and was corrected; the correct files passed | Reviewed/extracted normalization boundary verified locally | | V-167 | Failing Career round-trip reproduction; affected/full backend; focused Career/Profile Jest; persistence-consumer and diff review | Repository root / `job-tracker-ui` | Preserve every reviewed Career text value across save/get/version/projection/import use without weakening extraction cleanup or write bounds | PASS — pre-fix location became `Oslo, Norway and` and the test failed; post-fix affected backend 112/112, backend 642/642 and Career/Profile UI 17/17 pass. Website path/query, remote location, free-form date, custom language and incomplete WIP entry round-trip; oversized website is rejected explicitly | Local SQLite/JSDOM only; no real private CV/model/provider/production data. One initial Jest command used nonexistent paths and was corrected; the correct files passed | Reviewed/extracted normalization boundary verified locally |
| V-168 | Failing renderer contrast test; focused/full backend; CV Builder/public Jest; real Chromium computed-style/overflow probe; pathological A4 PDF export and text inspection | Repository root / `job-tracker-ui` | Make header/sidebar contact text readable for theme and custom palettes while keeping public renderer settings inert | PASS — renderer/settings 25/25, backend 644/644 and CV UI 22/22. Chromium computed white on Modern blue, black on `#f8fafc`, white on Technical sidebar, with zero element overflow. A 14-role/75-skill fixture produced a 17-page 259,447-byte A4 PDF with 1,685 final-page characters. CSS-like accent/font payloads normalize to null | Synthetic local data/browser only; authenticated application journey and production browser binary remain unverified. A direct PowerShell assembly probe failed to load dependencies before the compiled temporary test probe passed; temporary proof cleanup was blocked by execution policy | Renderer contrast/public-setting boundary verified locally | | V-168 | Failing renderer contrast test; focused/full backend; CV Builder/public Jest; real Chromium computed-style/overflow probe; pathological A4 PDF export and text inspection | Repository root / `job-tracker-ui` | Make header/sidebar contact text readable for theme and custom palettes while keeping public renderer settings inert | PASS — renderer/settings 25/25, backend 644/644 and CV UI 22/22. Chromium computed white on Modern blue, black on `#f8fafc`, white on Technical sidebar, with zero element overflow. A 14-role/75-skill fixture produced a 17-page 259,447-byte A4 PDF with 1,685 final-page characters. CSS-like accent/font payloads normalize to null | Synthetic local data/browser only; authenticated application journey and production browser binary remain unverified. A direct PowerShell assembly probe failed to load dependencies before the compiled temporary test probe passed; temporary proof cleanup was blocked by execution policy | Renderer contrast/public-setting boundary verified locally |
| V-169 | Legacy-vs-dedicated workspace trace; failing package DTO/UI regressions; focused/full backend and frontend; optimized build; authenticated Playwright application journey; diff review | Repository root / `job-tracker-ui` | Close JOBS-002 application-package parity, marker encapsulation, dirty navigation, focus return, responsive/theme/history/error and tenant-safety gaps | PASS — focused backend 30/30, focused frontend 28/28 plus route/focus 6/6, backend 647/647, frontend 54 suites/227 tests, build and Playwright 7/7. Chromium covers keyboard row entry, Back/Forward, saved refresh, dirty-edit cancel, focus return, missing job and long data at 375/768/1440 in explicit light/dark with zero overflow. Cross-owner workspace read and draft write return not found | Synthetic local SQLite/account/browser only; no production/provider/private data. Two targeted browser iterations corrected locators, and a transient SQLite company-create 500 led to bounded idempotent setup retry; behavior assertions were not weakened. Existing GSI and Jest open-handle warnings remain | JOBS-002 repository/browser scope verified; production/native assistive-device gates remain | | V-169 | Legacy-vs-dedicated workspace trace; failing package DTO/UI regressions; focused/full backend and frontend; optimized build; authenticated Playwright application journey; diff review | Repository root / `job-tracker-ui` | Close JOBS-002 application-package parity, marker encapsulation, dirty navigation, focus return, responsive/theme/history/error and tenant-safety gaps | PASS — focused backend 30/30, focused frontend 28/28 plus route/focus 6/6, backend 647/647, frontend 54 suites/227 tests, build and Playwright 7/7. Chromium covers keyboard row entry, Back/Forward, saved refresh, dirty-edit cancel, focus return, missing job and long data at 375/768/1440 in explicit light/dark with zero overflow. Cross-owner workspace read and draft write return not found | Synthetic local SQLite/account/browser only; no production/provider/private data. Two targeted browser iterations corrected locators, and a transient SQLite company-create 500 led to bounded idempotent setup retry; behavior assertions were not weakened. Existing GSI and Jest open-handle warnings remain | JOBS-002 repository/browser scope verified; production/native assistive-device gates remain |
| V-170 | Accessibility code audit; focused/full frontend; optimized build; full and targeted authenticated Playwright; computed-style contrast and nested-frame overflow probes | Repository root / `job-tracker-ui` | Close confirmed icon-name, keyboard CV-card, dark Alert contrast and fixed-width public-CV accessibility defects | PASS — static audit finds no `IconButton` without an explicit name; focused 4 suites/9 tests, full frontend 54 suites/228 tests, build, full Playwright 7/7 and final targeted 2/2. Chromium measures dark missing-job Alert contrast >= 4.5:1 and proves a 375px public CV retains a full A4 inner viewport with no inner or outer horizontal overflow | Local synthetic account/CV only; no native screen reader, operating-system high-contrast mode or production environment. Jest retains the known force-exit/open-handle warning; GSI repeats its existing initialization warning in development | Scoped cross-application accessibility repository/browser work verified; native AT and production remain |
@@ -1,6 +1,6 @@
# Application action verification matrix # Application action verification matrix
Updated: 2026-08-10 Updated: 2026-08-15
This is the rolling action-level evidence index. `PASS (automated/runtime)` is not a browser or production claim. This is the rolling action-level evidence index. `PASS (automated/runtime)` is not a browser or production claim.
@@ -45,6 +45,9 @@ This is the rolling action-level evidence index. `PASS (automated/runtime)` is n
| CV processing | retry provenance, durable refresh state and cancel/retry controls | PASS (backend + component tests) | BLOCKED | NOT RUN | `ai-004-cv-processing-queue.md` | | CV processing | retry provenance, durable refresh state and cancel/retry controls | PASS (backend + component tests) | BLOCKED | NOT RUN | `ai-004-cv-processing-queue.md` |
| Authentication UI | unified username/password and provider alternatives; invalid/cancel/return behavior | PASS (components; mocked providers) | PARTIAL — local dark-theme form at 375/768/1440 | NOT RUN | `ux-001-unified-authentication.md` | | Authentication UI | unified username/password and provider alternatives; invalid/cancel/return behavior | PASS (components; mocked providers) | PARTIAL — local dark-theme form at 375/768/1440 | NOT RUN | `ux-001-unified-authentication.md` |
| Theme state | Light/Dark/System precedence, login/logout scope, refresh/navigation and two-tab synchronization | PASS (state/provider/bootstrap tests) | PASS/PARTIAL — anonymous local browser at 375/768/1440 | NOT RUN | `ux-002-deterministic-theme-state.md` | | Theme state | Light/Dark/System precedence, login/logout scope, refresh/navigation and two-tab synchronization | PASS (state/provider/bootstrap tests) | PASS/PARTIAL — anonymous local browser at 375/768/1440 | NOT RUN | `ux-002-deterministic-theme-state.md` |
| Accessibility | icon control names and keyboard-operable CV cards | PASS (components + static audit) | PASS/PARTIAL — Chromium keyboard activation | NOT RUN | `accessibility-evidence.md`, V-170 |
| Accessibility | dark semantic Alert text contrast | PASS (theme ownership) | PASS — computed Chromium contrast >= 4.5:1 | NOT RUN | `ux-002-deterministic-theme-state.md`, V-170 |
| Public CV | responsive A4/multi-page framing without inner or outer overflow | PASS (component) | PASS — 375px Chromium | NOT RUN | `accessibility-evidence.md`, V-170 |
| Job match terms | bilingual/noisy/short/technology-heavy/filler extraction and honest labels | PASS (seven fixtures + component tests) | NOT RUN | NOT RUN | `qa-001-job-term-quality.md` | | Job match terms | bilingual/noisy/short/technology-heavy/filler extraction and honest labels | PASS (seven fixtures + component tests) | NOT RUN | NOT RUN | `qa-001-job-term-quality.md` |
| Career Workspace | first/returning/incomplete/loading/import processing/review/failure and recent CV actions | PASS (components; approval gate regression) | NOT RUN | NOT RUN | `career-001-career-workspace.md` | | Career Workspace | first/returning/incomplete/loading/import processing/review/failure and recent CV actions | PASS (components; approval gate regression) | NOT RUN | NOT RUN | `career-001-career-workspace.md` |
| CV Builder | edit/collapse/add/delete/reorder/hide, save states/latest-data retry, navigation warning and preview failure/retry | PASS (components) | NOT RUN | NOT RUN | `career-002-cv-builder.md` | | CV Builder | edit/collapse/add/delete/reorder/hide, save states/latest-data retry, navigation warning and preview failure/retry | PASS (components) | NOT RUN | NOT RUN | `career-002-cv-builder.md` |
@@ -31,6 +31,7 @@ No backend, database, dependency, entitlement or production configuration change
- Browser: explicit Light persisted across Settings → Dashboard navigation and refresh; explicit Dark switched without navigation; System selected the browser's dark preference; a second tab inherited Dark and changing it to Light updated the first tab without reload. - Browser: explicit Light persisted across Settings → Dashboard navigation and refresh; explicit Dark switched without navigation; System selected the browser's dark preference; a second tab inherited Dark and changing it to Light updated the first tab without reload.
- Browser widths: 375, 768 and 1440; document width did not exceed the viewport after the tabs correction. Light 375/768 and Dark 1440 evidence is retained. - Browser widths: 375, 768 and 1440; document width did not exceed the viewport after the tabs correction. Light 375/768 and Dark 1440 evidence is retained.
- Browser console warnings/errors after the final interactive pass: none. Development HMR emitted transient module-update messages while source files were being edited; they were not present in the captured tab diagnostics and the clean production build passes. - Browser console warnings/errors after the final interactive pass: none. Development HMR emitted transient module-update messages while source files were being edited; they were not present in the captured tab diagnostics and the clean production build passes.
- Cross-application follow-up: every icon-only frontend control has an explicit accessible name; dark missing-job Alert contrast is measured from computed Chromium styles at WCAG AA 4.5:1 or better; public CV framing has no outer or inner overflow at 375px. Full frontend 54 suites/228 tests, build and Playwright 7/7 pass (V-170).
## Remaining gates ## Remaining gates
@@ -41,7 +42,7 @@ No backend, database, dependency, entitlement or production configuration change
## Evidence ## Evidence
- Screenshots: `docs/audits/evidence/ux-002/` - Screenshots: `docs/audits/evidence/ux-002/`
- Commands/results: `docs/audits/verification-log.md` V-111V-113 - Commands/results: `docs/audits/verification-log.md` V-111V-113 and V-170
- Tests: `job-tracker-ui/src/theme-state.test.tsx` - Tests: `job-tracker-ui/src/theme-state.test.tsx`
- Initial implementation commit: `11734ee`; canonical-store/contrast correction: pending this change set. - Initial implementation commit: `11734ee`; canonical-store/contrast correction: pending this change set.
+10
View File
@@ -709,3 +709,13 @@
- **Consequences:** the dedicated page edits and clears answer/recruiter drafts directly, ordinary notes remain readable, existing rows require no migration, and legacy calls continue to work. A future normalized column/table can migrate behind the same API without another UI change. - **Consequences:** the dedicated page edits and clears answer/recruiter drafts directly, ordinary notes remain readable, existing rows require no migration, and legacy calls continue to work. A future normalized column/table can migrate behind the same API without another UI change.
- **User approval required:** No; this is compatibility-safe implementation of the requested dedicated workspace. - **User approval required:** No; this is compatibility-safe implementation of the requested dedicated workspace.
- **Reversible:** Revert the workspace/API boundary; no schema or stored-data rewrite occurred. - **Reversible:** Revert the workspace/API boundary; no schema or stored-data rewrite occurred.
## DEC-072 — Scale the complete public CV, not its document viewport
- **Date:** 2026-08-15
- **Decision:** Keep the anonymous public CV iframe at its physical A4 rendering width, measure the rendered multi-page height, and scale the entire iframe into the available screen width with a sized clipping container.
- **Reason/evidence:** reducing only the iframe width leaves the renderer's fixed 210mm page wider than the embedded viewport, which can create internal clipping even when the outer page no longer scrolls. Chromium now proves both the outer page and embedded document have no horizontal overflow at 375px.
- **Alternatives considered:** hide outer overflow; make server CV pages fluid; shrink typography; duplicate a mobile-only renderer. These conceal clipping, change PDF geometry/readability or fork the authoritative render path.
- **Consequences:** public browser viewing preserves the same A4 layout used by PDF/export while fitting narrow screens; multi-page content reserves its scaled height. PDF output is unchanged.
- **User approval required:** No; this is a requested responsive/accessibility correction without schema, dependency or production change.
- **Reversible:** Revert the `PublicCvPage` frame wrapper; no stored data changed.
+6 -6
View File
@@ -2,17 +2,17 @@
Updated: 2026-08-15 Updated: 2026-08-15
- **Overall programme status:** Active. Seven packages are locally verified; twenty-two are implemented with verification incomplete; UX-002 is in progress. The prioritized admin-only version indicator and the repository/browser JOBS-002 scope are implemented on the release branch; remote and production verification remain. - **Overall programme status:** Active. Seven packages are locally verified; twenty-three are implemented with verification incomplete; PRODUCT-001 is in progress. The prioritized admin-only version indicator, JOBS-002 workspace and scoped cross-application accessibility pass are implemented on the release branch; remote and production verification remain.
- **Current work package:** `UX-002`cross-application contrast/accessibility completion (`IN PROGRESS`). JOBS-002 now has application-package parity, dirty-navigation protection, tenant regressions and real-Chromium responsive/theme/history/error evidence. - **Current work package:** `PRODUCT-001`honest Free/Pro homepage and contextual upgrade surfaces (`IN PROGRESS`). The prerequisite claim inventory is next; no price, trial or limit will be invented.
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates. - **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`). - **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`).
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001/002 (`IMPLEMENTED — NOT VERIFIED`). JOBS-002 and UX-003 safe local/browser scope is implemented; production/native-device gates remain. - **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001/002 (`IMPLEMENTED — NOT VERIFIED`). JOBS-002, UX-002 and UX-003 safe local/browser scope is implemented; production/native-device gates remain.
- **Production-verified work:** None. - **Production-verified work:** None.
- **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks. - **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks.
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages. - **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Immediate order:** cross-app contrast/accessibility; PRODUCT-001; VER-001; tracking/blocker reconciliation. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`), CV contact contrast (`3b86ea2`) and JOBS-002 repository/browser scope are locally complete. External-only work remains skipped, not allowed to stall this queue. - **Immediate order:** PRODUCT-001; VER-001; tracking/blocker reconciliation. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`), CV contact contrast (`3b86ea2`), JOBS-002 (`deed948`) and the cross-app accessibility pass are locally complete. External-only work remains skipped, not allowed to stall this queue.
- **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 4 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. - **Status counts:** 7 `VERIFIED LOCALLY`; 23 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 3 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend 647/647; frontend 54/54 suites and 227/227 tests; optimized production build/TypeScript pass; Playwright 7/7. The application workspace journey passes keyboard entry, Back/Forward, focus return, unsaved navigation, saved-draft refresh, missing-job handling, long data, and explicit light/dark 375/768/1440 no-overflow checks. CV renderer/settings 25/25, CV Builder/public UI 22/22, AI sidecar 22/22 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded. - **Test status:** backend 647/647; frontend 54/54 suites and 228/228 tests; optimized production build/TypeScript pass; Playwright 7/7 plus final targeted 2/2. The application workspace journey passes keyboard entry, Back/Forward, focus return, unsaved navigation, saved-draft refresh, missing-job handling, long data, explicit light/dark 375/768/1440 no-overflow checks and computed dark-Alert AA contrast. The public CV preserves its full A4 viewport while scaling without inner/outer mobile overflow. CV renderer/settings 25/25, CV Builder/public UI 22/22, AI sidecar 22/22 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default. - **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred. - **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap. - **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
+6 -6
View File
@@ -16,7 +16,7 @@ Allowed statuses are `NOT STARTED`, `IN PROGRESS`, `IMPLEMENTED — NOT VERIFIED
`DONE` requires every applicable acceptance criterion, focused and regression tests, browser/accessibility/theme/mobile checks, tenant and entitlement checks, documentation, migration/rollback evidence, and production verification. Repository-only work that still requires production is at most `VERIFIED LOCALLY`. `DONE` requires every applicable acceptance criterion, focused and regression tests, browser/accessibility/theme/mobile checks, tenant and entitlement checks, documentation, migration/rollback evidence, and production verification. Repository-only work that still requires production is at most `VERIFIED LOCALLY`.
Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **UX-002**. Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **PRODUCT-001**.
## Consolidated dependency order ## Consolidated dependency order
@@ -58,8 +58,8 @@ This queue records the highest-value work that can proceed without production cr
| 2 | Lossless Career field persistence | CAREER-001 | Implemented and locally verified. Manual website/location/contact/date/language values now use a reviewed-data persistence boundary; extraction heuristics remain isolated to extraction. Full remote/production smoke remains. | | 2 | Lossless Career field persistence | CAREER-001 | Implemented and locally verified. Manual website/location/contact/date/language values now use a reviewed-data persistence boundary; extraction heuristics remain isolated to extraction. Full remote/production smoke remains. |
| 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Implemented and locally verified. Header/custom-accent and sidebar palettes own readable foregrounds; real Chromium computed-style/overflow checks and a 17-page A4 PDF proof pass. | | 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Implemented and locally verified. Header/custom-accent and sidebar palettes own readable foregrounds; real Chromium computed-style/overflow checks and a 17-page A4 PDF proof pass. |
| 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | Locally complete. Application answers/recruiter drafts now live on the dedicated page, note markers are encapsulated, edits are lossless, dirty navigation is guarded, focus returns to the row, tenant regressions pass, and Chromium covers 375/768/1440 light/dark/history/error/long data. Production smoke remains. | | 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | Locally complete. Application answers/recruiter drafts now live on the dedicated page, note markers are encapsulated, edits are lossless, dirty navigation is guarded, focus returns to the row, tenant regressions pass, and Chromium covers 375/768/1440 light/dark/history/error/long data. Production smoke remains. |
| 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | In progress. Audit semantic alerts, secondary text, focus, loading/empty/error states and remaining hardcoded colors before documenting larger redesigns. | | 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | Locally complete. All icon controls own programmatic names; CV cards are keyboard links; the A4 public CV scales without inner/outer mobile overflow; dark Alert contrast is measured in Chromium at WCAG AA; full frontend/build/Playwright pass. Native assistive-technology and a general CI crawler remain external/future gates. |
| 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | Not started. Inventory existing claims first; do not invent pricing, limits or trial terms before billing configuration is real. | | 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | In progress. Inventory existing claims first; do not invent pricing, limits or trial terms before billing configuration is real. |
| 7 | Complete application action matrix and full regression | VER-001 | Not started. Populate incrementally, then run the complete backend/frontend/sidecar/E2E gates and accurately classify external production/provider checks. | | 7 | Complete application action matrix and full regression | VER-001 | Not started. Populate incrementally, then run the complete backend/frontend/sidecar/E2E gates and accurately classify external production/provider checks. |
| 8 | Tracking and blocker reconciliation | All | Keep this plan, progress, handoff, verification log and `BLOCKERS.md` aligned after every logical increment; remove stale CI/dependency statements only when current evidence proves them obsolete. | | 8 | Tracking and blocker reconciliation | All | Keep this plan, progress, handoff, verification log and `BLOCKERS.md` aligned after every logical increment; remove stale CI/dependency statements only when current evidence proves them obsolete. |
@@ -594,7 +594,7 @@ This queue records the highest-value work that can proceed without production cr
- **Required production verification:** normal browser smoke after deploy. - **Required production verification:** normal browser smoke after deploy.
- **Status:** `IMPLEMENTED — NOT VERIFIED`. - **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** production and live authenticated multi-user browser environments are unavailable; repository/browser work is complete. - **Blocker:** production and live authenticated multi-user browser environments are unavailable; repository/browser work is complete.
- **Evidence:** `docs/verification/ux-002-deterministic-theme-state.md`; V-111V-113; focused 6/6, full frontend 48/48 suites and 172/172 tests, build, Light/Dark/System/navigation/refresh/two-tab browser checks and 375/768/1440 captures. - **Evidence:** `docs/verification/ux-002-deterministic-theme-state.md`; V-111V-113 and V-170; deterministic-theme coverage plus full frontend 54/54 suites and 228/228 tests, build, Light/Dark/System/navigation/refresh/two-tab browser checks, computed dark-Alert AA contrast and responsive public-CV proof.
- **Commit:** `11734ee` (`fix(theme): make preference state deterministic`). - **Commit:** `11734ee` (`fix(theme): make preference state deterministic`).
- **Remaining work:** live User A/User B preference switching and production browser smoke; retain existing preference keys during rollout. - **Remaining work:** live User A/User B preference switching and production browser smoke; retain existing preference keys during rollout.
@@ -736,11 +736,11 @@ This queue records the highest-value work that can proceed without production cr
- **Required tests:** copy/capability consistency, Free/Pro/expired/downgraded locked states, dismissal/no false generation. - **Required tests:** copy/capability consistency, Free/Pro/expired/downgraded locked states, dismissal/no false generation.
- **Required browser verification:** homepage and contextual prompts at three widths/themes/keyboard/accessibility. - **Required browser verification:** homepage and contextual prompts at three widths/themes/keyboard/accessibility.
- **Required production verification:** configured price text only if actual billing product exists; otherwise no invented values. - **Required production verification:** configured price text only if actual billing product exists; otherwise no invented values.
- **Status:** `NOT STARTED`. - **Status:** `IN PROGRESS`.
- **Blocker:** public plan behavior depends on POL-001 compatibility decision; real billing activation is external. - **Blocker:** public plan behavior depends on POL-001 compatibility decision; real billing activation is external.
- **Evidence:** source requirement. - **Evidence:** source requirement.
- **Commit:** none. - **Commit:** none.
- **Remaining work:** inventory all current contradictory plan claims. - **Remaining work:** inventory all current contradictory plan claims, then implement only centrally supported honest Free/Pro copy and contextual upgrade surfaces; billing values remain external.
### VER-001 — Complete application action matrix and regression pass ### VER-001 — Complete application action matrix and regression pass
+8 -8
View File
@@ -2,17 +2,17 @@
Updated: 2026-08-15 Updated: 2026-08-15
- **Exact current task:** commit/push JOBS-002 application-package parity and navigation-safety closure, then continue the cross-application contrast/accessibility pass. - **Exact current task:** start PRODUCT-001 claim inventory after committing/pushing the completed cross-application accessibility increment.
- **Last completed step:** moved application-answer/recruiter drafts onto the dedicated workspace, encapsulated legacy note markers, preserved answers through ordinary edits, guarded dirty navigation and restored list focus. - **Last completed step:** named all icon controls, made CV cards keyboard-operable, preserved/scaled the full A4 public-CV viewport on mobile and added real dark-Alert contrast proof.
- **Files currently modified:** workspace/draft backend and UI boundaries, route/focus behavior, unit/Chromium regressions, application-workspace architecture and programme evidence. - **Files currently modified:** focused frontend accessibility semantics, public-CV responsive rendering, unit/Chromium regressions and programme evidence.
- **Commands already run:** focused backend 30/30; focused frontend 28/28 plus route/focus 6/6; full backend 647/647; full frontend 54 suites/227 tests; optimized build; full Playwright 7/7; repeated targeted workspace Chromium passes; diff review pending after documentation. - **Commands already run:** accessibility-focused frontend 9/9; full frontend 54 suites/228 tests; optimized build; full Playwright 7/7; final targeted workspace/public-CV Chromium 2/2; static icon-control audit and diff review.
- **Test results:** all repository tests listed above pass. Workspace Chromium covers explicit light/dark at 375/768/1440, keyboard entry, Back/Forward, focus return, refresh, dirty-edit cancel, long data, missing-job handling and zero horizontal overflow. Jest retains the documented force-exit/open-handle notice. - **Test results:** all repository tests listed above pass. Chromium measures dark Alert contrast at WCAG AA, proves the public CV has no inner or outer overflow at 375px, and retains the prior workspace light/dark/history/focus evidence. Jest retains the documented force-exit/open-handle notice.
- **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed. - **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed.
- **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed. - **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed.
- **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred. - **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred.
- **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred. - **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred.
- **Uncommitted changes:** V-169 JOBS-002 parity/navigation increment; no dependency/schema/config/migration change. V-166/V-167/V-168 are pushed as `a6cffe0`/`f0b9b22`/`3b86ea2`. - **Uncommitted changes:** V-170 accessibility/responsive-public-CV increment; no dependency/schema/config/migration change. V-166 through V-169 are pushed as `a6cffe0`, `f0b9b22`, `3b86ea2` and `deed948`.
- **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007. - **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007.
- **Exact next action:** review, commit and push V-169; then execute the scoped cross-application contrast/accessibility inventory and corrections under UX-002/UX-003/VER-001. - **Exact next action:** commit/push V-170, inventory Free/Pro claims and implement the supported PRODUCT-001 surface without invented commercial terms.
- **Work that can continue independently:** cross-app contrast/accessibility, PRODUCT-001, VER-001 and tracking reconciliation. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates. - **Work that can continue independently:** PRODUCT-001, VER-001 and tracking reconciliation. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates.
- **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved. - **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.
+59 -2
View File
@@ -2,6 +2,38 @@ import { expect, test, type Page } from "@playwright/test";
const apiUrl = "http://localhost:5302/api"; const apiUrl = "http://localhost:5302/api";
type Rgba = { red: number; green: number; blue: number; alpha: number };
function parseCssColour(value: string): Rgba {
const parts = value.match(/[\d.]+/g)?.map(Number) ?? [];
expect(parts.length, `expected an rgb/rgba colour but received ${value}`).toBeGreaterThanOrEqual(3);
return { red: parts[0], green: parts[1], blue: parts[2], alpha: parts[3] ?? 1 };
}
function composite(foreground: Rgba, background: Rgba): Rgba {
return {
red: foreground.red * foreground.alpha + background.red * (1 - foreground.alpha),
green: foreground.green * foreground.alpha + background.green * (1 - foreground.alpha),
blue: foreground.blue * foreground.alpha + background.blue * (1 - foreground.alpha),
alpha: 1,
};
}
function relativeLuminance(colour: Rgba) {
const linear = [colour.red, colour.green, colour.blue].map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
}
function contrastRatio(foreground: Rgba, background: Rgba) {
const foregroundLuminance = relativeLuminance(foreground);
const backgroundLuminance = relativeLuminance(background);
return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05)
/ (Math.min(foregroundLuminance, backgroundLuminance) + 0.05);
}
async function login(page: Page) { async function login(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill("e2e@example.test"); await page.getByLabel("Email").fill("e2e@example.test");
@@ -153,7 +185,19 @@ test("the dedicated application workspace survives deep links, long data and uns
await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused(); await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused();
await page.goto("/jobs/2147483647"); await page.goto("/jobs/2147483647");
await expect(page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i })).toBeVisible(); const missingJobAlert = page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i });
await expect(missingJobAlert).toBeVisible();
const alertColours = await missingJobAlert.evaluate((element) => {
const styles = window.getComputedStyle(element);
const parentStyles = window.getComputedStyle(element.parentElement!);
return {
foreground: styles.color,
background: styles.backgroundColor,
parentBackground: parentStyles.backgroundColor,
};
});
const alertBackground = composite(parseCssColour(alertColours.background), parseCssColour(alertColours.parentBackground));
expect(contrastRatio(parseCssColour(alertColours.foreground), alertBackground)).toBeGreaterThanOrEqual(4.5);
await expect(page.getByRole("button", { name: "Back to applications" })).toBeVisible(); await expect(page.getByRole("button", { name: "Back to applications" })).toBeVisible();
}); });
@@ -182,8 +226,21 @@ test("a public CV renders anonymously and downloads as PDF", async ({ page }) =>
const published = await publish.json(); const published = await publish.json();
await page.context().clearCookies(); await page.context().clearCookies();
await page.setViewportSize({ width: 375, height: 900 });
await page.goto(`/cv/${published.publicSlug}`); await page.goto(`/cv/${published.publicSlug}`);
await expect(page.getByTitle("Public CV")).toBeVisible(); const publicCvFrame = page.getByTitle("Public CV");
await expect(publicCvFrame).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
const publicCvMetrics = await publicCvFrame.evaluate((element) => {
const frame = element as HTMLIFrameElement;
return {
renderedWidth: frame.getBoundingClientRect().width,
innerOverflow: (frame.contentDocument?.documentElement.scrollWidth ?? 0) - (frame.contentDocument?.documentElement.clientWidth ?? 0),
};
});
expect(publicCvMetrics.renderedWidth).toBeLessThanOrEqual(343);
expect(publicCvMetrics.innerOverflow).toBeLessThanOrEqual(1);
await expect(page.getByRole("link", { name: "Download PDF" })).toHaveAttribute("href", /\/pdf$/); await expect(page.getByRole("link", { name: "Download PDF" })).toHaveAttribute("href", /\/pdf$/);
const pdf = await page.request.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`); const pdf = await page.request.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`);
expect(pdf.ok()).toBeTruthy(); expect(pdf.ok()).toBeTruthy();
+1
View File
@@ -312,6 +312,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
color="secondary" color="secondary"
size="small" size="small"
title={`${t("quickSearch")} (${shortcutHint})`} title={`${t("quickSearch")} (${shortcutHint})`}
aria-label={`${t("quickSearch")} (${shortcutHint})`}
onClick={() => setQuickOpen(true)} onClick={() => setQuickOpen(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
> >
@@ -74,3 +74,31 @@ test("shows build metadata only when the caller supplies the admin-only badge",
expect(screen.queryByTestId("admin-version-badge")).not.toBeInTheDocument(); expect(screen.queryByTestId("admin-version-badge")).not.toBeInTheDocument();
}); });
test("shell icon controls expose accessible names", () => {
render(
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
<I18nProvider>
<AppShell
pageTitle="Dashboard"
breadcrumbs={["Home"]}
pathname="/dashboard"
nav={[]}
navBottom={[]}
onNavigate={() => undefined}
onToggleDrawer={() => undefined}
drawerOpen={false}
onOpenSettings={() => undefined}
user={{ userName: "Ada", roleLabel: "Administrator" }}
>
<div>Content</div>
</AppShell>
</I18nProvider>
</CssVarsProvider>,
);
expect(screen.getByRole("button", { name: "Collapse sidebar" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "User" })).toBeInTheDocument();
});
+4
View File
@@ -68,6 +68,10 @@ test('attachments metadata controls update purpose and ai usage', async () => {
); );
expect(await screen.findByDisplayValue(/resume/i)).toBeInTheDocument(); expect(await screen.findByDisplayValue(/resume/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview: resume.pdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download: resume.pdf' })).toBeInTheDocument();
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'));
fireEvent.click(await screen.findByRole('option', { name: /portfolio/i })); fireEvent.click(await screen.findByRole('option', { name: /portfolio/i }));
@@ -317,17 +317,17 @@ export default function Attachments({ jobId }: { jobId: number }) {
<TableCell> <TableCell>
<Box sx={{ display: "flex", gap: 0.5, flex: "0 0 auto" }}> <Box sx={{ display: "flex", gap: 0.5, flex: "0 0 auto" }}>
{canPreview ? ( {canPreview ? (
<IconButton size="small" onClick={() => void openPreview(a)} title={t("attachmentsPreview")}> <IconButton size="small" onClick={() => void openPreview(a)} title={t("attachmentsPreview")} aria-label={`${t("attachmentsPreview")}: ${a.fileName}`}>
<VisibilityOutlinedIcon fontSize="small" /> <VisibilityOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
) : null} ) : null}
<IconButton size="small" onClick={() => void download(a)} title={t("attachmentsDownload")}> <IconButton size="small" onClick={() => void download(a)} title={t("attachmentsDownload")} aria-label={`${t("attachmentsDownload")}: ${a.fileName}`}>
<DownloadIcon fontSize="small" /> <DownloadIcon fontSize="small" />
</IconButton> </IconButton>
<IconButton size="small" onClick={() => void rename(a)} title={t("attachmentsRename")}> <IconButton size="small" onClick={() => void rename(a)} title={t("attachmentsRename")} aria-label={`${t("attachmentsRename")}: ${a.fileName}`}>
<DriveFileRenameOutlineIcon fontSize="small" /> <DriveFileRenameOutlineIcon fontSize="small" />
</IconButton> </IconButton>
<IconButton size="small" onClick={() => void remove(a)} title={t("attachmentsDelete")}> <IconButton size="small" onClick={() => void remove(a)} title={t("attachmentsDelete")} aria-label={`${t("attachmentsDelete")}: ${a.fileName}`}>
<DeleteOutlineIcon fontSize="small" /> <DeleteOutlineIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
@@ -136,7 +136,7 @@ export default function CompaniesTable() {
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography> <Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography> <Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
</Box> </Box>
<IconButton size="small" onClick={() => openEdit(c)}> <IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
@@ -183,7 +183,7 @@ export default function CompaniesTable() {
</TableCell> </TableCell>
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell> <TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton size="small" onClick={() => openEdit(c)}> <IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</TableCell> </TableCell>
@@ -479,7 +479,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Typography variant="caption" sx={{ color: "text.secondary" }}> <Typography variant="caption" sx={{ color: "text.secondary" }}>
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} {isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
</Typography> </Typography>
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> <IconButton size="small" aria-label={t("correspondenceDeleteTitle")} onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
<DeleteOutlineIcon fontSize="small" /> <DeleteOutlineIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
+4 -4
View File
@@ -589,7 +589,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
) : null} ) : null}
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null} {mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={applySavedView} /> <SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={applySavedView} />
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null} {!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
</Box> </Box>
</Box> </Box>
)} )}
@@ -839,9 +839,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null} {columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
<TableCell align="right"> <TableCell align="right">
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5, whiteSpace: "nowrap" }}> <Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5, whiteSpace: "nowrap" }}>
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip> <Tooltip title={t("jobTableEdit")}><IconButton size="small" aria-label={`${t("jobTableEdit")}: ${job.jobTitle}`} onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip> <Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" aria-label={`${t("jobTableQuickStatus")}: ${job.jobTitle}`} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>} {(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" aria-label={`${t("jobTableRestore")}: ${job.jobTitle}`} onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" aria-label={`${t("jobTableSoftDelete")}: ${job.jobTitle}`} onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box> </Box>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -90,7 +90,7 @@ export default function SavedViewsMenu({
return ( return (
<> <>
<Tooltip title={t("savedViewsTooltip")}> <Tooltip title={t("savedViewsTooltip")}>
<IconButton size="small" onClick={(e) => setAnchor(e.currentTarget)}> <IconButton size="small" aria-label={t("savedViewsTooltip")} onClick={(e) => setAnchor(e.currentTarget)}>
<BookmarkBorderIcon fontSize="small" /> <BookmarkBorderIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -152,6 +152,7 @@ export default function SavedViewsMenu({
/> />
<IconButton <IconButton
size="small" size="small"
aria-label={`${t("attachmentsDelete")}: ${v.name}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -61,6 +61,11 @@ test('lists existing CVs from the variants API', async () => {
expect(await screen.findByText('Frontend CV')).toBeInTheDocument(); expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument(); expect(screen.getByText('Public')).toBeInTheDocument();
const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' });
cvCard.focus();
expect(cvCard).toHaveFocus();
fireEvent.keyDown(cvCard, { key: 'Enter' });
expect(mockNavigate).toHaveBeenCalledWith('/career/builder/1');
expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants'); expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants');
}); });
+6
View File
@@ -293,6 +293,7 @@ export default function AppShell({
edge="start" edge="start"
size="small" size="small"
color="secondary" color="secondary"
aria-label="Open navigation"
onClick={() => onToggleDrawer(true)} onClick={() => onToggleDrawer(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
> >
@@ -311,6 +312,7 @@ export default function AppShell({
{user ? ( {user ? (
<IconButton <IconButton
size="small" size="small"
aria-label={t("user")}
onClick={(e) => setUserMenuAnchor(e.currentTarget)} onClick={(e) => setUserMenuAnchor(e.currentTarget)}
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }} sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
> >
@@ -337,6 +339,7 @@ export default function AppShell({
color="secondary" color="secondary"
size="small" size="small"
title={t("settings")} title={t("settings")}
aria-label={t("settings")}
onClick={onOpenSettings} onClick={onOpenSettings}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
> >
@@ -366,6 +369,7 @@ export default function AppShell({
color="secondary" color="secondary"
onClick={() => setDesktopNavCollapsed((value) => !value)} onClick={() => setDesktopNavCollapsed((value) => !value)}
title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"} title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
> >
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} /> <MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
@@ -400,6 +404,7 @@ export default function AppShell({
color="secondary" color="secondary"
size="small" size="small"
title={t("settings")} title={t("settings")}
aria-label={t("settings")}
onClick={onOpenSettings} onClick={onOpenSettings}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
> >
@@ -414,6 +419,7 @@ export default function AppShell({
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
<IconButton <IconButton
size="small" size="small"
aria-label={t("user")}
onClick={(e) => setUserMenuAnchor(e.currentTarget)} onClick={(e) => setUserMenuAnchor(e.currentTarget)}
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }} sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
> >
+9 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { api } from './api'; import { api } from './api';
@@ -16,6 +16,8 @@ jest.mock('./api', () => ({
const mockedApi = api as jest.Mocked<typeof api>; const mockedApi = api as jest.Mocked<typeof api>;
test('public CV exposes the rendered CV and PDF download', async () => { test('public CV exposes the rendered CV and PDF download', async () => {
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any); mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
render( render(
@@ -24,7 +26,12 @@ test('public CV exposes the rendered CV and PDF download', async () => {
</MemoryRouter>, </MemoryRouter>,
); );
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Public CV</p>'); const frame = await screen.findByTitle('Public CV');
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf'); expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug'); expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
}); });
+1 -1
View File
@@ -352,7 +352,7 @@ export default function CvBuilderEditor() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}> <Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip> <Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)} <TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined} error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} /> slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
+14 -1
View File
@@ -99,7 +99,20 @@ export default function CvBuilderPage() {
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
{variants.map((v) => ( {variants.map((v) => (
<Paper key={v.id} sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 } }} onClick={() => navigate(`/career/builder/${v.id}`)}> <Paper
key={v.id}
role="link"
tabIndex={0}
aria-label={`Open ${v.name}`}
sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
onClick={() => navigate(`/career/builder/${v.id}`)}
onKeyDown={(event) => {
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
navigate(`/career/builder/${v.id}`);
}
}}
>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between"> <Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography> <Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}> <IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
+29 -8
View File
@@ -3,6 +3,9 @@ import { useParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
const A4_WIDTH_PX = (210 / 25.4) * 96;
const A4_HEIGHT_PX = (297 / 25.4) * 96;
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed // Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here. // iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
export default function PublicCvPage() { export default function PublicCvPage() {
@@ -10,6 +13,8 @@ export default function PublicCvPage() {
const pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`; const pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`;
const [html, setHtml] = useState<string | null>(null); const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [frameScale, setFrameScale] = useState(1);
const [frameHeight, setFrameHeight] = useState(A4_HEIGHT_PX);
useEffect(() => { useEffect(() => {
const meta = document.createElement("meta"); const meta = document.createElement("meta");
@@ -34,6 +39,13 @@ export default function PublicCvPage() {
}; };
}, [slug]); }, [slug]);
useEffect(() => {
const updateScale = () => setFrameScale(Math.min(1, Math.max(0.1, (window.innerWidth - 32) / A4_WIDTH_PX)));
updateScale();
window.addEventListener("resize", updateScale);
return () => window.removeEventListener("resize", updateScale);
}, []);
if (error) { if (error) {
return ( return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}> <div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
@@ -43,23 +55,32 @@ export default function PublicCvPage() {
} }
if (html === null) { if (html === null) {
return ( return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#9ca3af" }}> <div role="status" style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#4b5563" }}>
Loading Loading
</div> </div>
); );
} }
return ( return (
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 0", display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}> <div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 16, overflowX: "hidden" }}>
<a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}> <a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}>
Download PDF Download PDF
</a> </a>
<iframe <div
title="Public CV" data-testid="public-cv-frame-container"
srcDoc={html} style={{ width: A4_WIDTH_PX * frameScale, height: frameHeight * frameScale, maxWidth: "100%", overflow: "hidden", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
sandbox="allow-same-origin" >
style={{ width: "210mm", height: "297mm", border: "none", background: "#fff", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }} <iframe
/> title="Public CV"
srcDoc={html}
sandbox="allow-same-origin"
onLoad={(event) => {
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
setFrameHeight(Math.max(A4_HEIGHT_PX, documentHeight));
}}
style={{ width: A4_WIDTH_PX, height: frameHeight, transform: `scale(${frameScale})`, transformOrigin: "top left", border: "none", background: "#fff", display: "block" }}
/>
</div>
</div> </div>
); );
} }