feat(cv): redesign editor frame and section navigation

This commit is contained in:
cesnimda
2026-10-07 12:42:16 +02:00
parent efc9e7bdd4
commit 44b944e5ed
45 changed files with 19230 additions and 55 deletions
+2
View File
@@ -1,5 +1,7 @@
# Redesign traceability # Redesign traceability
CV frame/navigation successor: [P5-09 scoped frame trace](../phase-5/p5-09/README.md#approved-change-and-preservation-mapping) maps C05–C10, W05 and E07–E09 to the existing editor owner, Content-first studio, named section navigation, pane entry, visible save/version state and native actions. C01/C02, C11/C12 and W07 remain source/AI/export/frozen regression boundaries. The implementation record owns executed results and visual evidence; historical readiness findings below are retained. Human screen-reader acceptance, F04 and production font/photo qualification remain open.
Pagination successor: [P5-09 pagination-fidelity trace](../phase-5/p5-09-pagination-readiness/README.md#preservation-and-changed-owners) preserves C01/C02/C06–C12, W05/W07 and E05/E08/E09 through unchanged source/revision/export owners, native screen fragmentation, deterministic readiness and the original 16-fixture PDF oracle. The former six failures now pass the same tolerances. Human screen-reader, F04 and broader font/photo/fragment qualification remain separately recorded. Pagination successor: [P5-09 pagination-fidelity trace](../phase-5/p5-09-pagination-readiness/README.md#preservation-and-changed-owners) preserves C01/C02/C06–C12, W05/W07 and E05/E08/E09 through unchanged source/revision/export owners, native screen fragmentation, deterministic readiness and the original 16-fixture PDF oracle. The former six failures now pass the same tolerances. Human screen-reader, F04 and broader font/photo/fragment qualification remain separately recorded.
CV readiness: [P5-09 integrity/fidelity trace](../phase-5/p5-09-readiness/README.md#regression-and-preservation-trace) maps C01/C02/C05–C12, W05/W07 and E05/E08/E09 to the existing canonical/lens/history/frozen boundaries, server expected-version/atomic comparison, recoverable editor state and native PDF measurements. Five backend, four editor and three native cases supplement the inherited evidence. G-CV-REV passes locally; six material multi-page fidelity failures keep G-CV-RENDER open and P5-09 NO-GO. Historical ledger gaps below are not erased; human screen-reader and F04 remain open. CV readiness: [P5-09 integrity/fidelity trace](../phase-5/p5-09-readiness/README.md#regression-and-preservation-trace) maps C01/C02/C05–C12, W05/W07 and E05/E08/E09 to the existing canonical/lens/history/frozen boundaries, server expected-version/atomic comparison, recoverable editor state and native PDF measurements. Five backend, four editor and three native cases supplement the inherited evidence. G-CV-REV passes locally; six material multi-page fidelity failures keep G-CV-RENDER open and P5-09 NO-GO. Historical ledger gaps below are not erased; human screen-reader and F04 remain open.
+4 -2
View File
@@ -1,6 +1,8 @@
# Phase 5 — Representative screens / major UI redesign plan # Phase 5 — Representative screens / major UI redesign plan
**Current successor: [CV pagination-fidelity repair](p5-09-pagination-readiness/README.md) — GO for P5-09.** The original 16 fixtures now pass the unchanged PDF oracle, including all six former page-count/placement failures. Preview uses native fragmentation, renderer-owned geometry/print rules and deterministic settled measurement; PDF output is unchanged. [Revision readiness](p5-09-readiness/README.md) remains accepted. Baseline: **941 backend / 422 frontend / 98 actual browser passes**, security 9/6/1, configuration 3. Broader font/photo/wrapped-line qualification remains explicit; no CV redesign or B07 has begun. P2-D01 remains an ordinary passing test; F04 and human screen-reader acceptance remain open. **Current successor: [P5-09 CV editor frame/navigation](p5-09/README.md) — accepted locally, stop before P5-10.** Content-first studio, compact CV identity/scope/save/actions, ordered section navigation and keyboard pane entry retain the existing forms, revision owner and native renderer. Baseline: **941 backend / 426 frontend / 102 actual browser passes**, security 9/6/1, configuration 3; revision 5/5 and original fidelity 16/16, 36 aligned pages. Actual EN/Bokmål, light/dark, desktop/intermediate/phone evidence and incomplete accessibility findings are recorded. Production font/photo qualification, F04 and human screen-reader acceptance remain open. No P5-10+, Phase 8 or B07 implementation occurred.
**Previous readiness: [CV pagination-fidelity repair](p5-09-pagination-readiness/README.md) — GO for P5-09.** The original 16 fixtures passed the unchanged PDF oracle, including all six former page-count/placement failures. Preview uses native fragmentation, renderer-owned geometry/print rules and deterministic settled measurement; PDF output is unchanged. [Revision readiness](p5-09-readiness/README.md) remains accepted. That checkpoint's baseline was **941 backend / 422 frontend / 98 actual browser passes**, security 9/6/1, configuration 3. No CV redesign or B07 had begun at that readiness checkpoint. P2-D01 remains ordinary passing; broader font/photo/wrapped-line qualification and production/manual acceptance remain explicit.
**Previous implementation: [P5-08b Job sections](p5-08b/README.md).** Seven URL-backed sections and Application/Timeline subsections recompose the existing owners on the Phase 4 foundations. [Native readiness proof](p5-08b-readiness/README.md) and the ordinary passing P2-D01 regression remain authoritative for frozen packages/ZIPs. P5-08a header/Overview and all compatibility routes remain. `followMode` metadata is preserved; mode-specific UI stays deferred to the mail phase by Connor's decision. [P5-07 Analytics](p5-07/README.md), [P5-06 Dashboard](p5-06/README.md) and [P5-04b Jobs integration](p5-04b/README.md) remain authoritative for their areas. P5-02 manual screen-reader acceptance remains mandatory for P5-15. F04, incomplete accessibility checks, native touch, Board scaling and recorded timing/preference-save observations remain open. No CV redesign slice, Phase 8 or B07 has begun. **Previous implementation: [P5-08b Job sections](p5-08b/README.md).** Seven URL-backed sections and Application/Timeline subsections recompose the existing owners on the Phase 4 foundations. [Native readiness proof](p5-08b-readiness/README.md) and the ordinary passing P2-D01 regression remain authoritative for frozen packages/ZIPs. P5-08a header/Overview and all compatibility routes remain. `followMode` metadata is preserved; mode-specific UI stays deferred to the mail phase by Connor's decision. [P5-07 Analytics](p5-07/README.md), [P5-06 Dashboard](p5-06/README.md) and [P5-04b Jobs integration](p5-04b/README.md) remain authoritative for their areas. P5-02 manual screen-reader acceptance remains mandatory for P5-15. F04, incomplete accessibility checks, native touch, Board scaling and recorded timing/preference-save observations remain open. No CV redesign slice, Phase 8 or B07 has begun.
@@ -66,7 +68,7 @@ Fresh local planning checks (2026-09-13):
The two `.artifacts` validators are ignored local review tools, not new CI/application files. They validate documentation structure/targets, not future implementation behavior. Full application suites were not rerun for prose; their last actual evidence is the unchanged baseline above. Git push is limited to the requested feature branch; remote SHA and clean-tree status are verified and reported with the final commit. No production deployment or external provider check is claimed. The two `.artifacts` validators are ignored local review tools, not new CI/application files. They validate documentation structure/targets, not future implementation behavior. Full application suites were not rerun for prose; their last actual evidence is the unchanged baseline above. Git push is limited to the requested feature branch; remote SHA and clean-tree status are verified and reported with the final commit. No production deployment or external provider check is claimed.
## Recommended next instruction ## Historical planning next instruction
“Implement **P5-00 — Shell readiness gates only** from the documentation checkpoint, following `READINESS_GATES.md` and `PHASE5_PLAN.md`. Reproduce/protect each prerequisite against the current UI, preserve application behavior, run the full baseline, record real visual/accessibility evidence, commit/push the bounded readiness checkpoint, and stop before P5-01.” “Implement **P5-00 — Shell readiness gates only** from the documentation checkpoint, following `READINESS_GATES.md` and `PHASE5_PLAN.md`. Reproduce/protect each prerequisite against the current UI, preserve application behavior, run the full baseline, record real visual/accessibility evidence, commit/push the bounded readiness checkpoint, and stop before P5-01.”
+156
View File
@@ -0,0 +1,156 @@
# P5-09 — CV editor frame and navigation
Implementation starts from `efc9e7bdd481a7edf67a9a8d0b545888ee89a670` on `codex/ui-redesign-phase4`. This checkpoint owns the document frame/navigation only; section forms, preview rendering, template controls, AI review and the final responsive migration remain P5-10–P5-14.
## Approved change and preservation mapping
| Contracts | Existing behaviour | Bounded frame change | Required evidence |
| --- | --- | --- | --- |
| PRES-C05, PRES-C06 | Named variant, five studio tabs, duplicate/share/PDF | Content-first tabs; compact identity/actions; secondary actions in a named menu | Editor/action regressions; native lifecycle; EN/NB light/dark screenshots |
| PRES-C07 | Ordered, included/omitted and custom sections; pointer drag and move buttons | Section jump navigation opens/focuses the existing form; retained multi-open sections and stable identities | Selection/order/focus/persistence tests; native pointer and keyboard movement |
| PRES-C10 | One save queue, undo/redo, lazy history, restore-as-new-version, conflict recovery | Retain owner/handlers; visible save/version state and editing actions | Complete existing revision/recovery/dirty-guard tests; pending-save tab/resize controls |
| PRES-C08, PRES-C09, PRES-C11, PRES-C12 | Settings, AI review, physical preview and private/public PDF | Same tab consumers and document renderer; no document CSS/geometry changes | All controls/AI tests; 16-fixture fidelity and preview keyboard proof |
| PRES-W05 | Optional job association | Read-only link to the existing associated Job route; no package/tailoring changes | Deep-link and native frozen-package regressions |
| PRES-E07, PRES-E08, PRES-E09 | Existing routing, theme/locale, modal/focus, account boundary | Existing providers/routes, semantic tokens and focus system | Full browser/frontend/security baseline |
PRES-C01/C02 source data and PRES-W07 submitted capture are regression boundaries: no canonical data, revision or submission semantics change. No new save, undo, template or preview state system is introduced.
## Implementation decisions
- Keep `CvBuilderEditor` as the single editing/save owner. Reorder its existing studio tabs so Content is primary; connect each tab to its corresponding panel.
- Keep Content mounted when another studio tab is selected so section navigation/expanded forms retain their editing context. Other advanced consumers retain their existing conditional lifetime.
- Use an ordered, named section selector rather than a permanent third sidebar that would squeeze the existing editor. Existing accordion forms and multi-open behaviour remain; navigation opens/focuses the requested section without editing data.
- Retain the current below-900px Edit/Preview modes and at-1024px split-pane transition. Do not impose the global shell breakpoint on the document workspace.
- Generated PDF remains authoritative. The accepted native-column preview, page geometry, font/layout readiness and fidelity tolerances remain unchanged.
## Frame and retained owners
The compact document header contains the editable CV name, **This CV only**, the acknowledged server version, optional associated-application link, save state and PDF export. Publication is explicitly indicated when enabled. Duplicate, publish/unpublish and copy-link use a named MUI overflow menu and the existing handlers. Escape returns focus to the trigger. Invalid-name/busy conditions and save-before-copy/export/publication behaviour remain in the original owner.
The editing pane has a named h2, session undo/redo, and **Content / Template / Customize / AI Tools / History**. These are the existing five capabilities, reordered rather than replaced. Each tab identifies its panel. History still loads on selection; restore still confirms and uses the accepted expected-version protocol. The header's version identifies the current **settings revision**, not an immutable historical Career Profile snapshot. Only submitted packages capture frozen effective data/files; their native proof remains a regression boundary.
Content begins with ordered section navigation. Options use the existing section/custom titles plus explicit Included/Omitted text. Choosing a section opens its existing form, focuses the named disclosure control and scrolls it into view; choosing the same collapsed section opens it again. Focusing any section updates the navigation context. More than one section can remain open. Stable section keys retain editing/focus identity across reordering, and navigation follows the same stored order used by the document. Pointer drag and the existing move-up/down buttons still call `useDragReorder`/`moveItem`; no separate document-order model exists.
Keeping Content mounted across studio tabs retains current section/expanded context. Advanced consumers remain conditionally mounted; their fields/settings, source-reset behaviour, AI validators/review and template capabilities are unchanged. No detailed section forms were redesigned. Omitted sections retain readable controls and existing visibility labels; whole-container opacity remains removed as established in P4-01.
The default Content pane adds many legitimate keyboard stops. Native testing reproduced the inherited long-document fixture's 80-Tab traversal ending before the preview. A **Go to preview** pane-navigation action now follows the section selector on non-compact screens. Keyboard traversal from the studio navigation reaches the selector, then this action; Enter focuses and reveals the existing preview region. The P4-03 fixture now tests that real keyboard path, retaining its focus-contrast, PageDown, Home/End, forward/backward exit, iframe entry, resize and failure/retry assertions. Its mobile path remains unchanged. No numeric traversal allowance was enlarged to hide the problem.
The physical measurement iframe, page iframes, HTML helper, PDF generator, zoom/fit calculation and pagination readiness lifecycle are unchanged. The desktop grid shares available width between editor and preview rather than capping the editor at 480px; preview fit observes that width without changing the physical document geometry. There is no new zoom or print setting.
## Responsive, theme and localisation contract
- Below **900px**, existing Edit/Preview modes remain. Buttons now expose `aria-pressed`; both panes stay mounted and focus capture retains the focused pane across subsequent resizing.
- **900–1023px** retains the existing stacked editor/preview behaviour; at **1024px** the two-pane grid applies. No global shell breakpoint change.
- Native pending-save checks cover **899 / 900 / 1024px** without draft/focus loss. Native editing/preview checks cover **768 / 390 / 320px**; P4-03 also checks 1440px and preview scroll/exit.
- English and Bokmål labels use the existing translation owner. User-authored names/custom titles remain unchanged. At 320px, native Bokmål axe checks exposed horizontal overflow inside the existing profile-help Alert message beside its action. Its local flex wrapping/message wrapping now allows the action to reflow; no text is removed and no non-interactive text is made focusable to mask the issue.
- Existing MUI/Emotion semantic surfaces, borders, typography and focus-visible treatment are reused. No brand, font, dependency, global theme, shell or route change.
## Files and tests
| File | Responsibility |
| --- | --- |
| `job-tracker-ui/src/views/CvBuilderEditor.tsx` | Single existing owner; frame/actions/tab panels, section navigation/expanded context, pane entry and local responsive help wrapping |
| `job-tracker-ui/src/i18n/translations.ts` | Nine matching EN/Bokmål navigation/scope/action labels |
| `job-tracker-ui/src/cv-builder-deep-link.test.tsx` | Four new behavioural cases; intentional Content-first expectation and explicit Template selection |
| `job-tracker-ui/e2e/cv-editor-frame.spec.ts` | Four native theme/locale cases, persisted pointer/keyboard order, hidden/custom navigation, pending save through tabs/widths, owner isolation, public revoke/PDF/copy lifecycle and narrow states |
| `job-tracker-ui/e2e/builder-preview-keyboard.spec.ts` | Named keyboard pane-entry path; inherited scrolling/focus/resize/error checks retained |
| `job-tracker-ui/e2e/semantic-foundations.spec.ts` | Keyboard traversal explicitly verifies Content → Template → Customize in the approved new order; all focus/contrast/control assertions retained |
| `job-tracker-ui/e2e/smoke.spec.ts` | Intentional Content-first default; immediate first-disclosure assertion and keyboard activation of the second; both-open/native PDF assertions retained |
| This record/evidence, Phase 5 index, Phase 2 trace link and `docs/roadmap/todo.md` | Preservation, validation and checkpoint status |
The four new frontend cases protect: omitted/custom section navigation without writes (including reopening and retaining multi-open state across tabs), keyboard move focus/persisted ordered navigation, save-before-duplicate from the secondary menu, and preview pane entry without modifying an unsaved draft. They supplement the existing acknowledged-save queue, restore races, conflict/draft recovery, dirty navigation, undo/redo, section CRUD, controls and AI accept/reject tests.
The new native fixtures use disposable synthetic accounts and the real authenticated API. Only a current save response is deliberately held to test pending editing; profile/CV/job/public/PDF operations and readback are native. Initial fixture corrections (204 preference response, selected MUI combobox accessible name, automatic-save sequencing and a scoped locator typo) are not counted as passing validation. The final full run passes after those corrections and the actual keyboard/narrow-message fixes. Native touch/pointer variants beyond the exercised Chromium drag remain qualified.
The first complete final-code browser attempt recorded **99/102 passing**: two inherited focus assertions still assumed Content → Customize adjacency, and the long Code-template fixture failed its two-open-section assertion after consecutive pointer clicks during expansion. The same Code-template case passed **1/1 in isolation without an application change**. This does not prove the original timing observation nonexistent. The fixture now asserts the first open state immediately and opens the second disclosure using its existing named keyboard control, retaining both-open assertions and native document checks. Pointer drag remains separately exercised in all four new frame cases; animated disclosure pointer/reflow timing remains qualified for the responsive acceptance slice rather than silently claimed fixed. The complete suite is rerun with the corrected fixtures; no retry/expected-failure marking suppresses a result.
## Revision and rendering gates retained
The complete backend suite remains **941/941**. The explicit `CvRevisionReadinessTests` subset remains **5/5**, including independent stale save/restore, stale expected-version rejection before history append, settings-history/restore/override reset, and foreign-owner read/history/save/restore denial. Existing frontend queue/race/recovery cases pass; the native browser suite proves concurrent-client rejection, retained/downloadable unsaved content and reviewed reload. The existing native frozen-package/source-change/account archive case, including ordinary passing P2-D01, remains green. This proves the accepted settings/history and immutable submitted-file contract; it does not turn settings revisions into historical Career Profile snapshots.
The unchanged Python oracle compares all **16/16** fixtures, **36 preview pages / 36 native PDF pages**, at the original tolerances: exact page count and marker membership; page dimensions ≤0.5mm; marker position ≤2mm; font size ≤0.5pt; RGB component ≤1/255. There are no page-count, membership or measured-placement failures. Maximum measured absolute deviations are x **0.00000369mm**, y **1.493055mm**, font **0.005026pt**, RGB **0.00004510**. These tolerances are inherited, not widened for P5-09.
| Fixture | Preview pages | PDF pages | Result |
| --- | ---: | ---: | --- |
| default-modern | 1 | 1 | Pass |
| default-minimal | 1 | 1 | Pass |
| default-executive | 1 | 1 | Pass |
| default-technical | 1 | 1 | Pass |
| default-ats-classic | 1 | 1 | Pass |
| default-nordic | 1 | 1 | Pass |
| default-elegant | 1 | 1 | Pass |
| default-creative | 1 | 1 | Pass |
| default-code | 1 | 1 | Pass |
| long-en-minimal | 4 | 4 | Pass |
| long-nb-code | 4 | 4 | Pass |
| compact-8mm-letter | 4 | 4 | Pass |
| roomy-28mm-nb | 5 | 5 | Pass |
| grid-and-bubbles | 1 | 1 | Pass |
| long-sidebar-nb | 5 | 5 | Pass |
| unbreakable-and-boundary | 4 | 4 | Pass |
[Numerical results](evidence/fidelity-results.json), [native measurements](evidence/measurements.json) and [renderer-source hashes](evidence/renderer-source-hashes.json) are retained. All 16 `render.html` sources are byte-identical to the accepted pagination checkpoint. Representative native comparisons are [Modern page 1](evidence/default-modern-comparison-1.png), [long English page 4](evidence/long-en-minimal-comparison-4.png) and [28mm Bokmål page 5](evidence/roomy-28mm-nb-comparison-5.png). Visual inspection found no new clipping, missing sections or changed pagination. The marker oracle is bounded evidence, not a claim of every wrapped line/photo/font being production-qualified.
The final native measurement durations are 22–71ms, median 41.25ms; the previous checkpoint recorded 22.7–54.1ms, median 40.65ms. These are single local observations, not a benchmark or a performance guarantee. No renderer/update measurement algorithm changed and no additional per-keystroke measurement loop was introduced.
## Browser, visual and accessibility evidence
Reviewed on **2026-10-07**, using actual rendered Chromium views and synthetic native data at `/career/builder/:id`. The [permanent case summary](evidence/browser-summary.json) records **102 actual passes**, 20.5 minutes, zero skips/flaky/unexpected/expected-failure cases. **98 inherited cases + 4 P5-09 cases** explains the count change. The corrected inherited expectations retain their functional assertions; no case is hidden or renamed to bypass a failure.
| Trace / state | Representative visual evidence | Behavioural oracle |
| --- | --- | --- |
| PRES-C05/C06.frame — identity, essential actions, content/navigation/preview | [Light EN](evidence/light-en-desktop-content.png), [Light NB](evidence/light-nb-desktop-content.png), [Dark EN](evidence/dark-en-desktop-content.png), [Dark NB](evidence/dark-nb-desktop-content.png) | Four native frame cases; all five tabs; private/public PDF, publish/revoke and duplicate readback |
| PRES-C07.navigation — omitted/custom sections and persisted order | [Omitted Skills](evidence/light-en-omitted-section-focus.png), [reordered keyboard focus](evidence/dark-nb-reordered-section-focus.png) | Selector keyboard entry, native pointer drag, named move controls, native stored order and unchanged canonical profile |
| PRES-C10.save — unsaved/pending/conflict | [Unsaved](evidence/light-en-unsaved.png), [Saving](evidence/light-en-pending-save.png), [native conflict](evidence/light-en-native-conflict.png) | Existing queue/dirty/conflict tests; held native PUT through tabs/widths; native 409 retains and downloads draft before reviewed reload |
| PRES-C10.history — current revision and existing restoration entry | [Light EN history](evidence/light-en-history.png), [Dark NB history](evidence/dark-nb-history.png) | Lazy native revision read, five backend integrity cases and existing restore/undo/redo frontend cases |
| PRES-C06/E08.responsive — deliberate modes and reflow | [768px edit](evidence/light-en-edit-768.png), [900px pending](evidence/dark-nb-pending-900.png), [1024px pending](evidence/light-en-pending-1024.png), [320px NB edit](evidence/dark-nb-edit-320.png) | 899/900/1024 draft/focus checks; 768/390/320 no document overflow; all four P4-03 preview keyboard cases |
| PRES-C06/E08/E09.mobile — theme/locale edit and preview | [Light EN edit](evidence/light-en-edit-390.png), [Light NB preview](evidence/light-nb-preview-390.png), [Dark EN edit](evidence/dark-en-edit-390.png), [Dark NB preview](evidence/dark-nb-preview-390.png) | Explicit pressed mode, preserved input, named scroll region, keyboard entry/exit, PageDown/Home/End, iframe entry and focused-pane resizing |
The other EN/NB × light/dark edit/preview/history captures are retained alongside these. Long authored CV/custom section names remain intact; input/select text may truncate in narrow controls with its complete accessible value retained. User-authored content and the CV's document language remain independent from UI language. No data is translated, removed or replaced to fit the frame.
The stable screenshot replay uses an ignored copy of the same four native cases with screenshot-only `animations: "disabled"`, so indicator/menu transitions are captured at their endpoints. A fifth supplemental fixture pauses only the browser clock to capture the existing Unsaved debounce state, then resumes and proves the real persisted save. [Supplemental summary](evidence/supplemental-browser-summary.json): **5/5 passed**, separate from the permanent 102-case baseline. Command: `npm.cmd run test:e2e -- --config=../.artifacts/p5-09-save-state-capture.config.ts --output=../.artifacts/p5-09/visual-capture --reporter=line,json`, using the same local environment below. No app timing, notification lifetime, animations or persistence semantics were changed for capture.
The [axe summary](evidence/axe-summary.json) preserves **189 scoped scans with zero reported violations**, including **12 new-frame scans** across populated Content, History and 320px Edit. This does **not** mean every check passed. New-frame incomplete findings include `color-contrast` (badge/input/tab/iframe/scroll states) and `aria-prohibited-attr` on the existing Tooltip-generated Undo/Redo wrapper spans. Across inherited scans, incomplete `aria-hidden-focus`, `aria-valid-attr-value` and `th-has-data-cells` also remain. They are recorded with targets, not reclassified as passes. Buttons retain independent accessible names; existing focus/contrast browser checks and screenshot inspection supplement these results. This is not human screen-reader or native touch certification.
Native keyboard evidence covers studio arrow/Enter behaviour, section selector activation/focus, move controls, overflow Escape/focus return, pane entry/exit, history and mobile modes. Visual review covers useful pane space, readable semantic surfaces/secondary text in both themes, focus visibility, omitted/included indication, long labels and narrow reflow. Detailed section-form hierarchy and broader manual spoken-name/live-announcement acceptance remain later work.
## Final validation
Commands below ran against the final application candidate. Shell invocations use the repository's `rtk` prefix (`rtk proxy` for runners). Logs, raw browser reports/traces/auth state and native current-run PDFs remain ignored under `.artifacts/p5-09`; only safe summaries, synthetic visual evidence and measurements are committed.
Browser environment: `DOTNET_ROOT=C:/Users/Connor/.dotnet`, `DOTNET_HOST_PATH=C:/Users/Connor/.dotnet/dotnet.exe`, `PLAYWRIGHT_STATIC_EXPORT=true`, `PLAYWRIGHT_PREBUILT_EXPORT=true`, `CV_FIDELITY_OUTPUT=../.artifacts/p5-09/fidelity`, `PLAYWRIGHT_JSON_OUTPUT_NAME=../.artifacts/p5-09/browser.json`. The test harness supplies disposable development API/data and existing test authentication configuration; no production service/provider was used.
| Command | Result / baseline comparison |
| --- | --- |
| `dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=p5-09.trx" --results-directory .artifacts/p5-09/backend` | **941/941**, zero skipped/failed; unchanged |
| `npm.cmd test -- --runInBand --silent --json --outputFile=../.artifacts/p5-09/jest.json` | **426/426**, 80 suites; **+4** focused editor behaviours from 422 |
| `npm.cmd run test:e2e -- --output=../.artifacts/p5-09/browser --reporter=line,json` | **102/102 actual**, **+4** native frame cases from 98; no expected failures/skips/flaky cases |
| `npm.cmd run typecheck` | Passed |
| `npm.cmd exec tsc -- --project ../.artifacts/p5-09-readiness-e2e-tsconfig.json --noEmit --incremental false` | All permanent browser fixtures type-check; existing ignored validation config, no CI change |
| `npm.cmd run lint` | Passed, zero warnings allowed |
| `npm.cmd run build` with `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api` | Passed, production static export; final recorded rerun exit 0 |
| `dotnet.exe build JobTrackerApi/JobTrackerApi.csproj --configuration Release --no-restore` | Passed, zero warnings/errors |
| `dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build --filter FullyQualifiedName~CvRevisionReadinessTests` | **5/5**, included in 941; unchanged revision contract |
| `npm.cmd run test:rate-limits -- --output=../.artifacts/p5-09/rate-limits --reporter=line` | **9/9**, unchanged |
| `npm.cmd exec playwright test -- --config=playwright.ingress.config.ts --output=../.artifacts/p5-09/ingress --reporter=line` | **6/6**, unchanged; local/application proof only |
| `npm.cmd run test:pdf-admission -- --output=../.artifacts/p5-09/pdf-admission --reporter=line` | **1/1**, unchanged |
| `python scripts/test-ingress.py` | **3/3**, unchanged configuration/Compose validation |
| `python scripts/test-supply-chain.py` / `python scripts/test-ollama-evaluation.py` / `node browser-extension/tests/capture-url.test.mjs` | **3 / 5 / 3 passed**, local ancillary fixtures |
| Bundled Python `-X utf8 scripts/verify-cv-fidelity.py .artifacts/p5-09/fidelity` | **16/16**, unchanged oracle/tolerances, **36/36** pages |
| `python scripts/supply-chain.py scan-secrets` / `git diff --check` / `git diff --cached --check` | Passed against the final staged candidate; no high-confidence credential patterns or whitespace errors |
| `python .artifacts/p4-04-check-doc-links.py` | **146** current/root Markdown documents, zero missing local file targets; historical archive/external URLs/anchors excluded |
No backend, DTO, renderer, template consumer, provider tree, router, global theme, dependency, package script or CI file changed; a scoped `git diff --exit-code` against the source checkpoint confirms those owners unchanged. No infrastructure changes were required.
## Acceptance and remaining scope
**P5-09 frame/navigation accepted locally; stop before P5-10.** Revision safety and the measurable rendering gate remain green. No preserved capability is removed and all existing CV tabs/forms/actions remain accessible. This does not claim exhaustive coverage of all 57 contracts, complete CV accessibility or production release acceptance.
- **P5-10:** detailed section editing, canonical source versus CV-specific override presentation and form hierarchy. Current form owners/data boundaries are retained for that migration.
- **P5-11:** live-preview presentation; preserve the native pagination/PDF truth contract.
- **P5-12/P5-13:** template/document-control and grounded AI review presentation. Existing owners and review/accept/reject behaviour remain unchanged here.
- **P5-14/P5-15:** full responsive/touch/pointer-reflow qualification and final cross-screen acceptance, including human screen-reader spoken names/live/state announcements and incomplete automated findings.
- **Production font/photo qualification and F04 production ingress evidence remain open.** Local rendering and ingress tests do not prove deployed fonts, photos, host/firewall/DNS/TLS.
- Dashboard preference-save 500, Board full-result scaling/native touch and deferred `followMode` compose UI remain separate open debt. P2-D01 remains ordinary passing and is not carried as an expected failure.
No P5-10+, Phase 6–8 or B07 implementation occurred. The implementation checkpoint is the commit containing this record (`git log -1 -- docs/redesign/phase-5/p5-09/README.md`); remote equality and clean working-tree state are verified after pushing that checkpoint.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
[
{
"fixture": "default-modern",
"sha256": "8e768156c7ffd4ad8dae6914a6a57baa5114668dc53a861f3925e0be8cffb8aa",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-minimal",
"sha256": "26bd74195f0f5ed917751ec5aeb0321ef156f93f105e889cea3fa67b2952bb1c",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-executive",
"sha256": "d57da767c7c6cf446fe351fd381d33b95b555423db21feb84c9c1346775d8c48",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-technical",
"sha256": "681554ff60e68efa848e0a9e0ea6c2776526ea6a2017a05952ef724b08fc9e95",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-ats-classic",
"sha256": "f290eeef533983ea8a46c536e9747c0d88ee2cb7275f94366561c89645dc947b",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-nordic",
"sha256": "361c0d55d3596a6e56d03b02bdecd9c9dbf6b48b7cb0a4add7d75382ca3e2958",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-elegant",
"sha256": "816163e97d55be1fd726228fad0f7da471c707ca7879f0fbb036a1324dbdea47",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-creative",
"sha256": "1cd723eb101ac3d06f87b4c2f8fa28e53252075a0f3ff5aa6002d6f6c2be6799",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "default-code",
"sha256": "579dbd0e1c14afdee09f29f652e7742f5082c22e2de5f7c7ed06b954a2568d35",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "long-en-minimal",
"sha256": "2506bfc9473a1096e29f643b6d2958ff941e226e91ee67b7f05323dd28646a6a",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "long-nb-code",
"sha256": "48bcca5d8713960e7543d4f150bf1c7cc96e7227d9b04b69ed063241dbbc68df",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "compact-8mm-letter",
"sha256": "515fbd6d8e0d031d86fa4cf08dc71c8c508fb84ea385aa19116f6cf13c9c7567",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "roomy-28mm-nb",
"sha256": "cc0052318c874c2600ea0c137bd377411c0f6cd1c0115c1ed65c0d1173a3f1b9",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "grid-and-bubbles",
"sha256": "1e5fc270a8ecf02a6e6bd7ac7b0d1d50c9f15baa00fb4c37a39b7ab3c141de37",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "long-sidebar-nb",
"sha256": "823db293254304db0d8ca2a9bf249af6dd343de959d4a965e741ae4cde633590",
"sameAsPaginationCheckpoint": true
},
{
"fixture": "unbreakable-and-boundary",
"sha256": "3faf425eb9ecf0be7e5294b5f60859133f24276df9a9d6ed2e569c3cd7069978",
"sameAsPaginationCheckpoint": true
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

@@ -0,0 +1,48 @@
{
"procedure": "Four existing frame fixtures replayed with screenshot animations disabled, plus one browser-clock-paused native Unsaved capture followed by clock resume and successful persisted save. Separate from the permanent 102-case baseline.",
"stats": {
"startTime": "2026-10-07T10:23:58.430Z",
"duration": 97098.671,
"expected": 5,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"cases": [
{
"title": "[PRES-C05 C06 C07 C10 W05 E08] light/en CV frame navigation, actions and pending editing",
"status": "expected",
"attempts": [
"passed"
]
},
{
"title": "[PRES-C05 C06 C07 C10 W05 E08] dark/nb CV frame navigation, actions and pending editing",
"status": "expected",
"attempts": [
"passed"
]
},
{
"title": "[PRES-C05 C06 C07 C10 W05 E08] light/nb CV frame navigation, actions and pending editing",
"status": "expected",
"attempts": [
"passed"
]
},
{
"title": "[PRES-C05 C06 C07 C10 W05 E08] dark/en CV frame navigation, actions and pending editing",
"status": "expected",
"attempts": [
"passed"
]
},
{
"title": "supplemental native Unsaved frame capture",
"status": "expected",
"attempts": [
"passed"
]
}
]
}
+2
View File
@@ -4,6 +4,8 @@ Status reconciled: 2026-10-07.
## Current repository work ## Current repository work
- **P5-09 — CV editor frame/navigation complete:** [implementation record](../redesign/phase-5/p5-09/README.md). Content-first studio, compact document identity/scope/actions, ordered section navigation and explicit keyboard preview entry reuse the existing forms/save/history/render owners. Baseline: **941 backend / 426 frontend (+4) / 102 actual browser passes (+4)**, security 9/6/1, configuration 3; revision 5/5 and fidelity 16/16 with 36 aligned pages. EN/Bokmål, light/dark and desktop/intermediate/phone evidence retained; incomplete checks and pointer/reflow qualification are explicit. Stop before P5-10+ and B07. F04, production font/photo and human screen-reader acceptance stay open.
- **CV pagination-fidelity repair complete — GO for P5-09:** [native pagination evidence](../redesign/phase-5/p5-09-pagination-readiness/README.md) resolves all six original failures, **16/16** at unchanged tolerances, 36 preview/native PDF pages. PDF and accepted [revision protection](../redesign/phase-5/p5-09-readiness/README.md) remain unchanged. Baseline: **941 backend / 422 frontend / 98 actual browser passes (+2)**, security 9/6/1, configuration 3. Broader photo/wrapped-line/deployment-font qualification and native print-quality/control debt remain recorded. Stop before P5-09+ or B07; F04 and human screen-reader acceptance stay open. P2-D01 is ordinary passing. - **CV pagination-fidelity repair complete — GO for P5-09:** [native pagination evidence](../redesign/phase-5/p5-09-pagination-readiness/README.md) resolves all six original failures, **16/16** at unchanged tolerances, 36 preview/native PDF pages. PDF and accepted [revision protection](../redesign/phase-5/p5-09-readiness/README.md) remain unchanged. Baseline: **941 backend / 422 frontend / 98 actual browser passes (+2)**, security 9/6/1, configuration 3. Broader photo/wrapped-line/deployment-font qualification and native print-quality/control debt remain recorded. Stop before P5-09+ or B07; F04 and human screen-reader acceptance stay open. P2-D01 is ordinary passing.
- **P5-08b — Job sections complete:** [section architecture/preservation record](../redesign/phase-5/p5-08b/README.md) owns seven URL-backed sections, Application and Timeline subsections, current versus frozen documents, readable Timeline/Notes/tasks/correspondence/interview presentation and local read-error handling. Existing native packages, legacy/numeric links, query/history, Jobs Back focus and all domain owners remain. Final baseline: **936 backend / 418 frontend (+6) / 93 actual browser passes (+5)**, zero expected failures; security 9/6/1, configuration 3. Eight scoped mounted-page scans have no violations but retain incomplete findings; human screen-reader acceptance is not passed. Stop before P5-09: revision/rendering gates remain; F04 stays open. Historical checkpoint counts below are not current expected failures. - **P5-08b — Job sections complete:** [section architecture/preservation record](../redesign/phase-5/p5-08b/README.md) owns seven URL-backed sections, Application and Timeline subsections, current versus frozen documents, readable Timeline/Notes/tasks/correspondence/interview presentation and local read-error handling. Existing native packages, legacy/numeric links, query/history, Jobs Back focus and all domain owners remain. Final baseline: **936 backend / 418 frontend (+6) / 93 actual browser passes (+5)**, zero expected failures; security 9/6/1, configuration 3. Eight scoped mounted-page scans have no violations but retain incomplete findings; human screen-reader acceptance is not passed. Stop before P5-09: revision/rendering gates remain; F04 stays open. Historical checkpoint counts below are not current expected failures.
@@ -55,9 +55,20 @@ async function scan(page: Page, info: TestInfo, state: string) {
} }
async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) { async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) {
// Traverse the real tab order from a preceding control, not a DOM focus call on the region. // Content is primary in P5-09. Reach the named pane-navigation action through
// the real tab order, rather than traversing every collapsed section control.
await start.focus(); await start.focus();
for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab"); const previewEntry = page.getByRole("button", { name: /^(Go to preview|Gå til forhåndsvisning)$/ });
if (await previewEntry.isVisible()) {
await page.keyboard.press("Tab");
await expect(page.getByRole("combobox", { name: /^(Go to section|Gå til seksjon)/ })).toBeFocused();
await page.keyboard.press("Tab");
await expect(previewEntry).toBeFocused();
await page.keyboard.press("Enter");
} else {
// Mobile retains its existing Edit/Preview button and native tab entry.
for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab");
}
await expect(region).toBeFocused(); await expect(region).toBeFocused();
const ring = await region.evaluate(node => { const ring = await region.evaluate(node => {
const css = getComputedStyle(node); const css = getComputedStyle(node);
+179
View File
@@ -0,0 +1,179 @@
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
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();
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
}
async function capture(page: Page, info: TestInfo, state: string) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path: info.outputPath(`${state}.png`) });
}
async function scan(page: Page, info: TestInfo, state: string) {
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
expect(result.violations).toEqual([]);
}
for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb'], ['dark', 'en']] as const) {
test(`[PRES-C05 C06 C07 C10 W05 E08] ${mode}/${language} CV frame navigation, actions and pending editing`, async ({ page, context, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const label = (key: keyof typeof t, section: string) => t[key].replace('{section}', section);
const admin = await playwright.request.newContext();
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p509-${Date.now()}-${mode}-${language}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
await admin.dispose();
const headers = await login(page.request, email);
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
});
await context.addInitScript(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
const profile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical career information', location: 'Oslo, Norge' },
summary: ['Verified source experience.'], jobs: [{ id: 'job-1', title: 'Platform engineer', company: 'Nordlys AS', startDate: '2021-01', isCurrent: true, bullets: ['Maintained verified, accessible workflows.'] }],
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }],
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: ['Documented source-grounded reviews.'] }], skills: ['C#', 'React', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys fixture AS' } });
expect(company.status()).toBe(201);
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: (await company.json()).id, jobTitle: 'Application-linked CV fixture', status: 'Saved' } });
expect(job.status()).toBe(201); const jobId = (await job.json()).id;
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Åse — dokumenterte kvalifikasjoner og erfaring', jobApplicationId: jobId, settings: {
themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }],
customSections: [{ key: 'volunteer', title: 'Frivillig arbeid og dokumenterte bidrag', contentType: 'paragraphs', items: ['Eksisterende innhold — æøå.'] }],
} } });
expect(created.status()).toBe(200); const cv = await created.json();
const cvUrl = `${apiUrl}/cv/variants/${cv.id}`;
const read = async () => (await page.request.get(cvUrl)).json();
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`/career/builder/${cv.id}`);
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name);
await expect(page.getByRole('tab', { name: t.cvEditorContent, exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel', { name: t.cvEditorContent, exact: true })).toBeVisible();
await expect(page.getByText(t.cvEditorScope, { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: t.cvEditorLinkedApplication })).toHaveAttribute('href', `/jobs/${jobId}`);
await expect(page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true })).toBeVisible();
await saved(); await capture(page, info, 'desktop-content'); await scan(page, info, 'desktop-content');
const sectionSelect = page.getByRole('combobox', { name: new RegExp(`^${t.cvEditorGoToSection}`) });
await sectionSelect.click();
await page.getByRole('option', { name: `${t.cvSectionSkills} · ${t.cvEditorOmitted}`, exact: true }).click();
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', t.cvSectionSkills), exact: true })).toBeFocused();
await expect(page.getByRole('button', { name: label('cvEditorShowSection', t.cvSectionSkills), exact: true })).toBeVisible();
expect((await read()).version).toBe(cv.version);
await capture(page, info, 'omitted-section-focus');
// Native HTML drag path and the existing keyboard alternative both change the
// persisted document order; no simulated request or alternative order model.
await page.getByRole('group', { name: t.cvSectionProjects, exact: true }).dragTo(page.getByRole('group', { name: t.cvSectionExperience, exact: true }));
await saved();
await expect.poll(async () => (await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'experience', 'education']);
const move = page.getByRole('button', { name: label('cvEditorMoveSectionDown', t.cvSectionExperience), exact: true });
await move.focus(); await page.keyboard.press('Enter');
await expect(move).toBeFocused(); await saved();
expect((await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'education', 'experience']);
await capture(page, info, 'reordered-section-focus');
await sectionSelect.focus(); await page.keyboard.press('Space');
await page.keyboard.press('End'); await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeFocused();
let release!: () => void;
const hold = new Promise<void>(resolve => { release = resolve; });
let pending = false;
await page.route(cvUrl, async route => {
if (route.request().method() === 'PUT') { pending = true; await hold; }
await route.continue();
});
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true });
try {
await headline.fill('Local CV draft — æøå');
await expect.poll(() => pending).toBe(true);
await expect(page.getByText(t.cvEditorSaving, { exact: true })).toBeVisible();
await capture(page, info, 'pending-save');
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await expect(page.getByLabel(t.cvEditorPageSize, { exact: true })).toBeVisible();
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await expect(page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true })).toBeVisible();
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
await expect(headline).toHaveValue('Local CV draft — æøå');
await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag');
for (const width of [899, 900, 1024]) {
await headline.focus(); await page.setViewportSize({ width, height: 900 });
await expect(headline).toBeFocused(); await expect(editing).toBeVisible();
await expect(headline).toHaveValue('Local CV draft — æøå');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `pending-${width}`);
}
} finally { release(); }
await saved(); await page.unroute(cvUrl);
expect((await read()).settings.headline).toBe('Local CV draft — æøå');
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
await page.getByRole('button', { name: t.cvEditorUndo, exact: true }).click(); await saved();
await expect(headline).toHaveValue('');
await page.getByRole('button', { name: t.cvEditorRedo, exact: true }).click(); await saved();
await expect(headline).toHaveValue('Local CV draft — æøå');
// Publication and PDF stay in the existing client handlers. Menu Escape returns
// focus; public state is explicit, and unpublishing revokes anonymous access.
const more = page.getByRole('button', { name: t.cvEditorMoreActions, exact: true });
await more.click(); await page.keyboard.press('Escape'); await expect(more).toBeFocused();
await more.click(); await page.getByRole('menuitem', { name: t.cvEditorShare, exact: true }).click();
await expect(page.getByText(t.cvEditorShared, { exact: true })).toBeVisible();
const publicCv = await read();
const anonymous = await playwright.request.newContext();
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(200);
await more.click(); await expect(page.getByRole('menuitem', { name: t.cvEditorCopyLink, exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: t.cvEditorStopSharing, exact: true }).click();
await expect(page.getByText(t.cvEditorShared, { exact: true })).toHaveCount(0);
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(404); await anonymous.dispose();
const pdfResponse = page.waitForResponse(response => response.url() === `${cvUrl}/export-pdf` && response.request().method() === 'POST');
const download = page.waitForEvent('download');
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
expect((await pdfResponse).status()).toBe(200); expect((await download).suggestedFilename()).toMatch(/\.pdf$/i);
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click();
const history = page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true });
await expect(history.getByText(t.cvEditorVersion.replace('{version}', '1'), { exact: true })).toBeVisible();
await capture(page, info, 'history'); await scan(page, info, 'history');
await page.getByRole('tab', { name: t.cvEditorAiTools, exact: true }).click();
await expect(page.getByRole('tabpanel', { name: t.cvEditorAiTools, exact: true })).toBeVisible();
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
await expect(sectionSelect).toContainText('Frivillig arbeid og dokumenterte bidrag');
await expect(page.getByRole('button', { name: label('cvEditorCollapseSection', 'Frivillig arbeid og dokumenterte bidrag'), exact: true })).toBeVisible();
for (const width of [768, 390, 320]) {
await headline.focus(); await page.setViewportSize({ width, height: 844 });
await expect(editing).toBeVisible();
await expect(page.getByRole('button', { name: t.cvEditorEdit, exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByLabel(t.cvEditorName, { exact: true }).scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `edit-${width}`);
await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
await expect(page.getByRole('button', { name: t.cvEditorPreview, exact: true })).toHaveAttribute('aria-pressed', 'true');
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
await expect(preview).toBeVisible(); await preview.focus(); await page.keyboard.press('End');
await capture(page, info, `preview-${width}`);
await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
await expect(headline).toHaveValue('Local CV draft — æøå');
}
await scan(page, info, 'phone-edit');
await page.setViewportSize({ width: 1440, height: 900 });
await more.click();
const copiedResponse = page.waitForResponse(response => response.url() === `${cvUrl}/duplicate`);
await page.getByRole('menuitem', { name: t.cvEditorDuplicate, exact: true }).click();
const copied = await (await copiedResponse).json();
await expect(page).toHaveURL(new RegExp(`/career/builder/${copied.id}$`));
expect((await (await page.request.get(`${apiUrl}/cv/variants/${copied.id}`)).json()).settings).toEqual((await read()).settings);
});
}
@@ -211,6 +211,8 @@ for (const mode of ["light", "dark"] as const) {
const customize = page.getByRole("tab", { name: "Customize", exact: true }); const customize = page.getByRole("tab", { name: "Customize", exact: true });
await page.getByRole("tab", { name: "Content", exact: true }).focus(); await page.getByRole("tab", { name: "Content", exact: true }).focus();
await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Template", exact: true })).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(customize).toBeFocused(); await expect(customize).toBeFocused();
await customize.press("Enter"); await customize.press("Enter");
await keyboardFocus(page, customize); await keyboardFocus(page, customize);
+6 -2
View File
@@ -509,10 +509,14 @@ test("the Code template exports a long structured CV as searchable multi-page PD
await page.goto(`/career/builder/${variant.id}`); await page.goto(`/career/builder/${variant.id}`);
await expect(page.getByLabel("CV name")).toHaveValue("Code PDF Verification"); await expect(page.getByLabel("CV name")).toHaveValue("Code PDF Verification");
await expect(page.getByRole("tab", { name: "Template" })).toHaveAttribute("aria-selected", "true"); await expect(page.getByRole("tab", { name: "Content" })).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Content" }).click(); await page.getByRole("tab", { name: "Content" }).click();
await page.getByRole("button", { name: "Expand Experience" }).click(); await page.getByRole("button", { name: "Expand Experience" }).click();
await page.getByRole("button", { name: "Expand Education" }).click(); await expect(page.getByRole("button", { name: "Collapse Experience" })).toBeVisible();
// Open the second disclosure with its keyboard contract while the long first
// section expands, avoiding a pointer target moving during the Collapse reflow.
await page.getByRole("button", { name: "Expand Education" }).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "Collapse Experience" })).toBeVisible(); await expect(page.getByRole("button", { name: "Collapse Experience" })).toBeVisible();
await expect(page.getByRole("button", { name: "Collapse Education" })).toBeVisible(); await expect(page.getByRole("button", { name: "Collapse Education" })).toBeVisible();
@@ -81,6 +81,93 @@ async function openContentTab() {
fireEvent.click(screen.getByRole('tab', { name: 'Content' })); fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
} }
test('section navigation opens and focuses omitted/custom sections without changing their data', async () => {
const settings = { ...variant.settings,
sections: [{ key: 'skills', hidden: true }, { key: 'custom:sample', hidden: false }],
customSections: [{ key: 'sample', title: 'Volunteer work', items: ['Bokmål: frivillig arbeid'], hidden: false }],
};
routeGet(() => Promise.resolve({ data: { ...variant, settings } }));
renderAt(3);
await openContentTab();
const navigation = screen.getByRole('navigation', { name: 'CV section navigation' });
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: 'Go to section' }));
fireEvent.click(await screen.findByRole('option', { name: 'Skills · Omitted' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveFocus());
expect(screen.getByRole('button', { name: 'Show Skills section' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Collapse Skills' }));
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: /Go to section/ }));
fireEvent.click(await screen.findByRole('option', { name: 'Skills · Omitted' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveFocus());
fireEvent.mouseDown(within(navigation).getByRole('combobox', { name: 'Go to section' }));
fireEvent.click(await screen.findByRole('option', { name: 'Volunteer work · Included' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Collapse Volunteer work' })).toHaveFocus());
expect(screen.getByDisplayValue('Bokmål: frivillig arbeid')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
expect(within(navigation).getByRole('combobox')).toHaveTextContent('Volunteer work');
expect(screen.getByRole('button', { name: 'Collapse Skills' })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('button', { name: 'Collapse Volunteer work' })).toHaveAttribute('aria-expanded', 'true');
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('keyboard move controls retain focus and persist the same section order shown by navigation', async () => {
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills', hidden: true }],
} } }));
renderAt(3);
await openContentTab();
const move = screen.getByRole('button', { name: 'Move Experience section down' });
move.focus();
fireEvent.click(move);
expect(move).toHaveFocus();
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Go to section' }));
expect(screen.getAllByRole('option').slice(0, 3).map(option => option.textContent)).toEqual([
'Professional Summary · Included', 'Skills · Omitted', 'Experience · Included',
]);
fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
await screen.findByText('Saved');
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
expectedVersion: 4, settings: expect.objectContaining({ sections: expect.arrayContaining([
{ key: 'summary' }, { key: 'skills', hidden: true }, { key: 'experience' },
]) }),
}));
expect((mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any).settings.sections.slice(0, 3).map((row: any) => row.key)).toEqual(['summary', 'skills', 'experience']);
});
test('secondary duplicate action saves the current draft before creating the copy', async () => {
routeGet(() => Promise.resolve({ data: variant }));
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/duplicate')
? { ...variant, id: 8, name: 'Backend CV copy' } : { html: '<p>CV</p>' } } as any));
renderAt(3);
await openContentTab();
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Draft before copy' } });
fireEvent.click(screen.getByRole('button', { name: 'More document actions' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Duplicate' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/duplicate', expect.anything()));
expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
expectedVersion: 4, settings: expect.objectContaining({ headline: 'Draft before copy' }),
}));
expect(mockedApi.put.mock.invocationCallOrder[0]).toBeLessThan(mockedApi.post.mock.invocationCallOrder[
mockedApi.post.mock.calls.findIndex(call => call[0] === '/cv/variants/3/duplicate')
]);
});
test('pane navigation focuses the existing preview without changing the pending draft', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Retained draft' } });
const preview = screen.getByRole('region', { name: 'Live preview' });
preview.scrollIntoView = jest.fn();
fireEvent.click(screen.getByRole('button', { name: 'Go to preview' }));
expect(preview).toHaveFocus();
expect(headline).toHaveValue('Retained draft');
expect(screen.getByText('Unsaved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('deep link loads the exact variant named in the route', async () => { test('deep link loads the exact variant named in the route', async () => {
routeGet(() => Promise.resolve({ data: variant } as any)); routeGet(() => Promise.resolve({ data: variant } as any));
@@ -256,12 +343,17 @@ test('restoring history uses the current version and never replaces edits made d
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled();
}); });
test('professional editor starts with templates and combines design and layout controls', async () => { test('Content is the primary studio panel and all advanced tabs retain their controls', async () => {
routeGet(() => Promise.resolve({ data: variant } as any)); routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3); renderAt(3);
await screen.findByDisplayValue('Backend CV'); await screen.findByDisplayValue('Backend CV');
expect(screen.getByRole('tab', { name: 'Template' })).toHaveAttribute('aria-selected', 'true'); const content = screen.getByRole('tab', { name: 'Content' });
expect(content).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tablist', { name: 'CV studio sections' }).firstElementChild).toBe(content);
expect(screen.getByRole('tabpanel', { name: 'Content' })).toBeInTheDocument();
expect(screen.getByText('This CV only')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Linked application' })).toHaveAttribute('href', '/jobs/7');
fireEvent.click(screen.getByRole('tab', { name: 'Customize' })); fireEvent.click(screen.getByRole('tab', { name: 'Customize' }));
expect(screen.getByText('Typography')).toBeInTheDocument(); expect(screen.getByText('Typography')).toBeInTheDocument();
expect(screen.getByLabelText('Body size')).toBeInTheDocument(); expect(screen.getByLabelText('Body size')).toBeInTheDocument();
@@ -304,6 +396,7 @@ test('choosing a template restores its own structure instead of inheriting the p
renderAt(3); renderAt(3);
await screen.findByDisplayValue('Backend CV'); await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Template' }));
fireEvent.click(screen.getByRole('button', { name: 'All templates' })); fireEvent.click(screen.getByRole('button', { name: 'All templates' }));
fireEvent.click(screen.getByRole('button', { name: /Technical/ })); fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
fireEvent.click(screen.getByRole('button', { name: 'Save now' })); fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
+18
View File
@@ -528,6 +528,15 @@ export const translations = {
cvEditorEdit: "Edit", cvEditorEdit: "Edit",
cvEditorEditingRegion: "CV editing controls", cvEditorEditingRegion: "CV editing controls",
cvEditorStudioNavigation: "CV studio sections", cvEditorStudioNavigation: "CV studio sections",
cvEditorScope: "This CV only",
cvEditorLinkedApplication: "Linked application",
cvEditorShared: "Shared publicly",
cvEditorMoreActions: "More document actions",
cvEditorSectionNavigation: "CV section navigation",
cvEditorGoToSection: "Go to section",
cvEditorFocusPreview: "Go to preview",
cvEditorIncluded: "Included",
cvEditorOmitted: "Omitted",
cvEditorPreview: "Preview", cvEditorPreview: "Preview",
cvEditorDuplicate: "Duplicate", cvEditorDuplicate: "Duplicate",
cvEditorUpdating: "Updating…", cvEditorUpdating: "Updating…",
@@ -3066,6 +3075,15 @@ export const translations = {
cvEditorEdit: "Rediger", cvEditorEdit: "Rediger",
cvEditorEditingRegion: "Redigeringskontroller for CV", cvEditorEditingRegion: "Redigeringskontroller for CV",
cvEditorStudioNavigation: "Seksjoner i CV-studioet", cvEditorStudioNavigation: "Seksjoner i CV-studioet",
cvEditorScope: "Bare denne CV-en",
cvEditorLinkedApplication: "Tilknyttet søknad",
cvEditorShared: "Delt offentlig",
cvEditorMoreActions: "Flere dokumenthandlinger",
cvEditorSectionNavigation: "Navigasjon mellom CV-seksjoner",
cvEditorGoToSection: "Gå til seksjon",
cvEditorFocusPreview: "Gå til forhåndsvisning",
cvEditorIncluded: "Inkludert",
cvEditorOmitted: "Utelatt",
cvEditorPreview: "Forhåndsvis", cvEditorPreview: "Forhåndsvis",
cvEditorDuplicate: "Dupliser", cvEditorDuplicate: "Dupliser",
cvEditorUpdating: "Oppdaterer…", cvEditorUpdating: "Oppdaterer…",
+110 -47
View File
@@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom"; import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
import { import {
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, IconButton, Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton,
MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography, Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material"; } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -20,6 +20,7 @@ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import UndoIcon from "@mui/icons-material/Undo"; import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo"; import RedoIcon from "@mui/icons-material/Redo";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import TranslateIcon from "@mui/icons-material/Translate"; import TranslateIcon from "@mui/icons-material/Translate";
import SpellcheckIcon from "@mui/icons-material/Spellcheck"; import SpellcheckIcon from "@mui/icons-material/Spellcheck";
@@ -59,7 +60,9 @@ export default function CvBuilderEditor() {
const [outline, setOutline] = useState<CvOutline | null>(null); const [outline, setOutline] = useState<CvOutline | null>(null);
const [isPublic, setIsPublic] = useState(false); const [isPublic, setIsPublic] = useState(false);
const [publicSlug, setPublicSlug] = useState(""); const [publicSlug, setPublicSlug] = useState("");
const [tab, setTab] = useState(0); const [jobApplicationId, setJobApplicationId] = useState<number | null>(null);
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
const [tab, setTab] = useState(1);
const [html, setHtml] = useState(""); const [html, setHtml] = useState("");
const [zoom, setZoom] = useState(0.62); const [zoom, setZoom] = useState(0.62);
const [previewing, setPreviewing] = useState(false); const [previewing, setPreviewing] = useState(false);
@@ -127,6 +130,7 @@ export default function CvBuilderEditor() {
latestSettings.current = variant.settings; latestSettings.current = variant.settings;
setIsPublic(variant.isPublic); setIsPublic(variant.isPublic);
setPublicSlug(variant.publicSlug); setPublicSlug(variant.publicSlug);
setJobApplicationId(variant.jobApplicationId ?? null);
setSaveState("saved"); setSaveState("saved");
undoStack.current = []; undoStack.current = [];
redoStack.current = []; redoStack.current = [];
@@ -456,47 +460,68 @@ export default function CvBuilderEditor() {
<Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button> <Button size="small" onClick={() => void reloadSavedVariant()}>{t("cvEditorConflictReload")}</Button>
</Stack> </Stack>
</Alert>} </Alert>}
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}> <Paper component="header" variant="outlined" sx={{ p: 1.5 }}>
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap"> <Stack direction={{ xs: "column", sm: "row" }} alignItems={{ sm: "center" }} gap={1.5}>
<Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip> <Stack direction="row" alignItems="flex-start" gap={1} sx={{ flex: 1, minWidth: 0 }}>
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)} <Tooltip title={t("cvEditorBack")}><IconButton size="small" aria-label={t("cvEditorBack")} onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined} <Box sx={{ flex: 1, minWidth: 0 }}>
sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }} <TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)} fullWidth
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": t("cvEditorName") } }} /> error={!name.trim()} helperText={!name.trim() ? t("cvEditorNameRequired") : undefined}
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} /> sx={{ "& input": { typography: "sectionTitle" } }}
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} /> slotProps={{ htmlInput: { "aria-label": t("cvEditorName") } }} />
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip> <Stack direction="row" gap={1} alignItems="center" flexWrap="wrap" sx={{ mt: 0.5 }}>
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip> <Typography variant="caption" color="text.secondary">{t("cvEditorScope")}</Typography>
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}> <Typography variant="caption" color="text.secondary">{t("cvEditorVersion", { version: serverVersion.current })}</Typography>
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button> {jobApplicationId && <Button component={RouterLink} to={`/jobs/${jobApplicationId}`} size="small">{t("cvEditorLinkedApplication")}</Button>}
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button> {isPublic && <Chip size="small" icon={<PublicIcon />} label={t("cvEditorShared")} variant="outlined" />}
</Stack>} <SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
<Box sx={{ flex: { sm: 1 } }} /> </Stack>
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>{t("cvEditorDuplicate")}</Button> </Box>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}> </Stack>
{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")} <Stack direction="row" alignItems="center" gap={0.5}>
</Button> <Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} sx={{ flex: { xs: 1, sm: "none" } }} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>{t("cvEditorCopyLink")}</Button>} <Tooltip title={t("cvEditorMoreActions")}><IconButton size="small" aria-label={t("cvEditorMoreActions")} aria-haspopup="menu" aria-expanded={!!actionsAnchor} aria-controls={actionsAnchor ? "cv-document-actions" : undefined} onClick={(event) => setActionsAnchor(event.currentTarget)}><MoreHorizIcon /></IconButton></Tooltip>
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button> </Stack>
</Stack> </Stack>
<Menu id="cv-document-actions" anchorEl={actionsAnchor} open={!!actionsAnchor} onClose={() => setActionsAnchor(null)} slotProps={{ list: { "aria-label": t("cvEditorMoreActions") } }}>
<MenuItem disabled={!name.trim()} onClick={() => { setActionsAnchor(null); void duplicateVariant(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorDuplicate")}</MenuItem>
<MenuItem disabled={publishing || exporting || !name.trim()} onClick={() => { setActionsAnchor(null); void togglePublic(); }}><PublicIcon fontSize="small" sx={{ mr: 1 }} />{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}</MenuItem>
{isPublic && <MenuItem onClick={() => { setActionsAnchor(null); copyPublicLink(); }}><ContentCopyIcon fontSize="small" sx={{ mr: 1 }} />{t("cvEditorCopyLink")}</MenuItem>}
</Menu>
{compactEditor && <Stack direction="row" gap={0.5} sx={{ mt: 1.5 }}>
<Button fullWidth size="small" aria-pressed={mobilePane === "edit"} variant={mobilePane === "edit" ? "contained" : "outlined"} onClick={() => setMobilePane("edit")}>{t("cvEditorEdit")}</Button>
<Button fullWidth size="small" aria-pressed={mobilePane === "preview"} variant={mobilePane === "preview" ? "contained" : "outlined"} onClick={() => setMobilePane("preview")}>{t("cvEditorPreview")}</Button>
</Stack>}
</Paper> </Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}> <Paper variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}> <Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
<Tab label={t("cvEditorTemplate")} /> <Stack direction="row">
<Tab label={t("cvEditorContent")} /> <Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tab label={t("cvEditorCustomize")} /> <Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tab label={t("cvEditorAiTools")} /> </Stack>
<Tab label={t("cvEditorHistory")} /> </Stack>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
</Tabs> </Tabs>
<Box sx={{ p: { xs: 1, sm: 1.5 } }}>
{tab === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />} <Box role="tabpanel" id="cv-studio-panel-1" aria-labelledby="cv-studio-tab-1" hidden={tab !== 1}>
{tab === 1 && <ContentTab settings={settings} update={update} outline={outline} />} <ContentTab settings={settings} update={update} outline={outline} showPreviewEntry={!compactEditor} onFocusPreview={() => {
{tab === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />} scrollRef.current?.focus({ preventScroll: true });
{tab === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />} scrollRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />} }} />
</Box>
{[0, 2, 3, 4].map((value) => <Box key={value} role="tabpanel" id={`cv-studio-panel-${value}`} aria-labelledby={`cv-studio-tab-${value}`} hidden={tab !== value}>
{tab === value && <>
{value === 0 && <CvCustomizeTab mode="template" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
{value === 2 && <CvCustomizeTab mode="customize" settings={settings} update={update} themes={themes} sectionLabel={(key, fallback) => sectionName(key, fallback, t)} />}
{value === 3 && <DocumentAiTab settings={settings} outline={outline} update={update} />}
{value === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</>}
</Box>)}
</Box>
</Paper> </Paper>
<Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}> <Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
@@ -562,8 +587,8 @@ function SaveBadge({ state, canRetry, onRetry }: { state: SaveState; canRetry: b
const m = map[state]; const m = map[state];
if (!m.label) return null; if (!m.label) return null;
return ( return (
<Stack spacing={0.5} alignItems="flex-end" aria-live="polite"> <Stack direction="row" gap={0.5} alignItems="center" aria-live="polite">
<Chip size="small" label={m.label} color={m.color} variant="outlined" /> {state === "saved" ? <Typography variant="caption" color="text.secondary">{m.label}</Typography> : <Chip size="small" label={m.label} color={m.color} variant="outlined" />}
{(state === "unsaved" || state === "error") ? ( {(state === "unsaved" || state === "error") ? (
<Button size="small" disabled={!canRetry} onClick={onRetry}>{t("cvEditorSaveNow")}</Button> <Button size="small" disabled={!canRetry} onClick={onRetry}>{t("cvEditorSaveNow")}</Button>
) : null} ) : null}
@@ -783,14 +808,19 @@ function presetLabel(key: string, fallback: string, t: Translate) {
return labels[key] ?? fallback; return labels[key] ?? fallback;
} }
function ContentTab({ settings, update, outline }: { function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPreview }: {
settings: CvVariantSettings; settings: CvVariantSettings;
update: (p: Partial<CvVariantSettings>) => void; update: (p: Partial<CvVariantSettings>) => void;
outline: CvOutline | null; outline: CvOutline | null;
showPreviewEntry: boolean;
onFocusPreview: () => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
const [addContentOpen, setAddContentOpen] = useState(false); const [addContentOpen, setAddContentOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
// Full section list = configured order (once touched) else default, always including every known key. // Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => { const sectionRows: CvSectionSetting[] = useMemo(() => {
const populated = new Set((outline?.sections ?? []).map((section) => section.key)); const populated = new Set((outline?.sections ?? []).map((section) => section.key));
@@ -813,6 +843,19 @@ function ContentTab({ settings, update, outline }: {
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows }); const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to))); const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
const toggleSection = (key: string) => setExpandedSections((previous) => {
const next = new Set(previous);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
const goToSection = (key: string) => {
setActiveSection(key);
setExpandedSections((previous) => new Set(previous).add(key));
requestAnimationFrame(() => {
sectionButtons.current[key]?.focus({ preventScroll: true });
sectionButtons.current[key]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
});
};
const patchSection = (key: string, patch: Partial<CvSectionSetting>) => const patchSection = (key: string, patch: Partial<CvSectionSetting>) =>
writeSections(sectionRows.map((r) => (r.key === key ? { ...r, ...patch } : r))); writeSections(sectionRows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
@@ -862,7 +905,19 @@ function ContentTab({ settings, update, outline }: {
}; };
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}> <Stack direction="row" gap={1} alignItems="center">
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: 1, minWidth: 0 }}>
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
value={sectionRows.some((row) => row.key === activeSection) ? activeSection : ""}
onChange={(event) => setActiveSection(event.target.value)}>
{sectionRows.map((row) => <MenuItem key={row.key} value={row.key} onClick={() => goToSection(row.key)}>
{customBySectionKey[row.key]?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t)} · {t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}
</MenuItem>)}
</TextField>
</Box>
{showPreviewEntry && <Button size="small" onClick={onFocusPreview}>{t("cvEditorFocusPreview")}</Button>}
</Stack>
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career/profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
{t("cvEditorMasterProfileHelp")} {t("cvEditorMasterProfileHelp")}
</Alert> </Alert>
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""} <TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
@@ -885,6 +940,10 @@ function ContentTab({ settings, update, outline }: {
outlineSection={outlineByKey[row.key]} outlineSection={outlineByKey[row.key]}
customSection={customBySectionKey[row.key]} customSection={customBySectionKey[row.key]}
settings={settings} settings={settings}
expanded={expandedSections.has(row.key)}
onToggle={() => toggleSection(row.key)}
onSectionFocus={() => setActiveSection(row.key)}
toggleRef={(button) => { sectionButtons.current[row.key] = button; }}
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))} onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
onPatch={(p) => patchSection(row.key, p)} onPatch={(p) => patchSection(row.key, p)}
onRenameCustom={(title) => { onRenameCustom={(title) => {
@@ -945,7 +1004,7 @@ function AddContentCard({ title, description, added, onAdd }: { title: string; d
} }
function SectionRow({ function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings, row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, expanded, onToggle, onSectionFocus, toggleRef, onMove, onPatch, onRenameCustom, onUpdateCustom, onRemoveCustom, onUpdateSettings,
}: { }: {
row: CvSectionSetting; row: CvSectionSetting;
index: number; index: number;
@@ -956,6 +1015,10 @@ function SectionRow({
outlineSection?: CvOutlineSection; outlineSection?: CvOutlineSection;
customSection?: CvCustomSectionSetting; customSection?: CvCustomSectionSetting;
settings: CvVariantSettings; settings: CvVariantSettings;
expanded: boolean;
onToggle: () => void;
onSectionFocus: () => void;
toggleRef: (button: HTMLButtonElement | null) => void;
onMove: (delta: number) => void; onMove: (delta: number) => void;
onPatch: (patch: Partial<CvSectionSetting>) => void; onPatch: (patch: Partial<CvSectionSetting>) => void;
onRenameCustom: (title: string) => void; onRenameCustom: (title: string) => void;
@@ -965,7 +1028,6 @@ function SectionRow({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
const [expanded, setExpanded] = useState(false);
const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t); const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? []; const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
const editableItems = row.items ?? sourceItems; const editableItems = row.items ?? sourceItems;
@@ -975,6 +1037,7 @@ function SectionRow({
<Paper <Paper
variant="outlined" variant="outlined"
{...dragProps} {...dragProps}
role="group" aria-label={sectionLabel} onFocusCapture={onSectionFocus}
sx={{ sx={{
// Hidden in the document is not disabled in the editor: keep names and // Hidden in the document is not disabled in the editor: keep names and
// controls readable. The visibility icon/label still conveys inclusion. // controls readable. The visibility icon/label still conveys inclusion.
@@ -1001,7 +1064,7 @@ function SectionRow({
if (customSection) onRemoveCustom(); if (customSection) onRemoveCustom();
else void confirmAction(t("cvEditorRemoveSectionMessage", { section: sectionLabel }), { title: t("cvEditorRemoveSectionTitle"), confirmLabel: t("cvEditorRemoveFromCv"), destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); }); else void confirmAction(t("cvEditorRemoveSectionMessage", { section: sectionLabel }), { title: t("cvEditorRemoveSectionTitle"), confirmLabel: t("cvEditorRemoveFromCv"), destructive: true }).then((confirmed) => { if (confirmed) onPatch({ hidden: true }); });
}}><DeleteOutlineIcon fontSize="small" /></IconButton> }}><DeleteOutlineIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)} <IconButton ref={toggleRef} size="small" aria-label={t(expanded ? "cvEditorCollapseSection" : "cvEditorExpandSection", { section: sectionLabel })} aria-expanded={expanded} onClick={onToggle}
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}> sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
<ExpandMoreIcon fontSize="small" /> <ExpandMoreIcon fontSize="small" />
</IconButton> </IconButton>