feat(cv): clarify section editing scope (P5-10)
@@ -1,5 +1,7 @@
|
||||
# Redesign traceability
|
||||
|
||||
CV Content successor: [P5-10 scoped editing/source trace](../phase-5/p5-10/README.md#change-impact-contract) maps C01/C02/C05–C12, W05/W07 and E07–E09 to explicit source/CV-only descriptions, independent field reset, omitted-entry editing and confirmed add/delete/reorder focus. Four unit and four native locale/theme journeys supplement existing source/history/frozen-package boundaries; the original sixteen renderer HTML outputs and 36 aligned preview/PDF pages remain unchanged. Settings history is not a historical Career Profile snapshot. Human screen-reader, incomplete checks, pointer/reflow/font/photo qualification and F04 remain open; P5-11 has not begun.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Phase 5 — Representative screens / major UI redesign plan
|
||||
|
||||
**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.
|
||||
**Current successor: [P5-10 CV section editing/source clarity](p5-10/README.md) — accepted locally, stop before P5-11.** Compact source-backed/CV-only forms, field-specific reset, editable omitted content and add/delete/reorder focus reuse the P5-09 frame and existing save/history/source owners. Baseline: **941 backend / 430 frontend / 106 actual browser passes**, security 9/6/1, configuration 3; revision 5/5 and fidelity 16/16 with 36 aligned pages and unchanged renderer HTML. EN/Bokmål, light/dark, desktop/intermediate/320px evidence is retained. Human screen-reader/incomplete checks, broader pointer/reflow/font/photo qualification, F04 and separately recorded debt remain open. No P5-11+, Phase 8 or B07 implementation occurred.
|
||||
|
||||
**Previous implementation: [P5-09 CV editor frame/navigation](p5-09/README.md).** 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 at that checkpoint.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# P5-10 — CV section editing and source/override clarity
|
||||
|
||||
Starts from `44b944e5edd22f9f826663719aa581b5e6db8a83` on `codex/ui-redesign-phase4`. Scope is the existing ContentTab, SectionRow and EntryEditor. The [P5-09 frame](../p5-09/README.md), [revision contract](../p5-09-readiness/README.md) and [pagination proof](../p5-09-pagination-readiness/README.md) remain authoritative. No P5-11+, B07, schema, API or canonical-data mutation is authorised by this slice.
|
||||
|
||||
## Discovered model and preservation boundary
|
||||
|
||||
`GET /api/cv/outline` resolves the current Career Profile with empty settings. It supplies canonical values, including stable entry keys, rather than a variant's effective values. The current preview/export applies CV settings over that source.
|
||||
|
||||
- `settings.headline`, section `title`/`items`, and entry `title`/`subtitle`/`bullets` provide CV-specific wording. Entry dates/meta and tags are not separately editable overrides.
|
||||
- A missing/null/blank title or subtitle inherits the source; missing/null bullets or section items inherit, while an empty array deliberately empties that content. Inclusion, ordering and presentation do not make source wording “customized”.
|
||||
- `settings.customSections` contains CV-only string content, including presets and the existing multiple-entry format. It is not a second canonical structured Experience/Education editor.
|
||||
- Section inclusion and entry `overrides[key].hidden` remain distinct from content overrides. Omitted content is editable; labels/icons communicate omission rather than whole-container dimming.
|
||||
- History preserves **settings revisions**, not Career Profile snapshots. Restoring old settings creates a new current settings version against the current source. Explicit historical overrides remain unchanged. Immutable submitted packages separately freeze effective source/settings/files. This accepted distinction is preserved; the UI must not claim that settings history is an immutable historical PDF.
|
||||
|
||||
## Change-impact contract
|
||||
|
||||
IDs below use the `PRES-` prefix from the 57-contract ledger (for example C07 is PRES-C07). This is scoped traceability, not a claim of exhaustive coverage of all 57 groups.
|
||||
|
||||
| PRES IDs | Existing behaviour retained | P5-10 boundary | Required proof |
|
||||
|---|---|---|---|
|
||||
| C01/C02 | Canonical source and source navigation | Explicit Career Profile wording; no embedded canonical editor | Native canonical/sibling readback, inheritance and override retention after source change |
|
||||
| C05/C06/C07 | Current variant, Content forms, overrides, inclusion, section/item order and custom content | Compact entry hierarchy and field-level scope/reset descriptions | Behavioural edit/reset/hide/reorder/add/delete/focus tests and persisted readback |
|
||||
| C10 | One save queue, undo/redo, conflict/recovery and settings history | Same update callback/transactions; source reset remains different from undo/restore | Complete inherited tests and five native/relational revision cases |
|
||||
| C08/C09/C12 | Template/settings, shared preview/PDF truth and export | Renderer and frame unchanged | Original 16-fixture oracle, 36 aligned preview/PDF pages and preview keyboard cases |
|
||||
| C11 | Grounded section AI review/accept/reject | Existing assistant and CV-only apply target retained | Existing section/whole-document acceptance tests |
|
||||
| W05/W07 | Linked CV, frozen package contents and their ownership | Relationship regression boundary only | Native package/P2-D01 and ownership regressions |
|
||||
| E07/E09 | Existing navigation and account-switch private-state disposal | Frame/routes and session boundary unchanged | Inherited shell, account-isolation and stale-client cases |
|
||||
| E08 | Localisation, themes, confirmation and responsive controls | Named fields/actions, explicit inclusion, focus after destructive/reset actions | EN/Bokmål × light/dark rendered desktop/tablet/phone and scoped axe evidence |
|
||||
|
||||
Builder `/career/builder/:id` and source `/career/profile` compatibility route remain unchanged. No route or information-architecture migration occurs.
|
||||
|
||||
## Scope exclusions and acceptance qualifications
|
||||
|
||||
P5-11 owns preview presentation; P5-12 owns template/document controls; P5-13 owns AI review presentation; P5-14 owns final responsive CV acceptance. The existing controls/rendering owners remain in place.
|
||||
|
||||
Human screen-reader acceptance, incomplete axe/manual checks, pointer/reflow qualification, production font/photo qualification, F04 production evidence, Dashboard preference-save 500, Board scaling/native touch and deferred `followMode` UI remain open. P2-D01 remains an ordinary passing regression, not expected failure.
|
||||
|
||||
## Implemented Content presentation
|
||||
|
||||
Content retains the existing ordered section selector, multi-open disclosures, headline override and source link. Each expanded section now states its CV-only editing scope and Included/Omitted state. Entry groups use their current title as their accessible name, show translated inclusion beside that title, and separate entries with a border instead of another outlined card. Title and Subtitle share a row when space permits and stack on narrow screens; bullet content remains the existing multiline field and Markdown toolbar. Outline dates are not newly displayed here: the source outline currently formats open-ended dates with default document-language settings, which would leak English into Bokmål. Existing document date rendering remains unchanged.
|
||||
|
||||
Fields identify **Using Career Profile**, **Customized for this CV**, or, for an explicitly blank title/subtitle, **Blank uses Career Profile**. This describes the existing resolver rules; optional fields acquire no new required-field validation. Omitted entries keep their fields and actions available. Their inclusion label and visibility action convey omission without reducing editable contrast.
|
||||
|
||||
The existing forms and advanced actions remain available:
|
||||
|
||||
- All twelve standard sections, populated long-tail outline sections, built-in inclusion, custom sections, the four presets and free text.
|
||||
- Section names, inclusion, confirmed removal, section drag and move buttons, disclosure and navigation. Removing a built-in section omits it; confirmed removal of a custom section deletes its CV-only content and its order row.
|
||||
- Rows/Grid/Compact presentation, Bubble where supported and one/two Grid columns.
|
||||
- Profile-backed entry Title, Subtitle and bullet overrides, entry inclusion, item drag and move buttons. Dates/meta remain source information rather than invented editable overrides.
|
||||
- Profile-backed paragraph/list/tag content, and custom paragraphs, bullets or multiple entries with add, edit, move and confirmed deletion.
|
||||
- Existing section/entry AI entry points and explicit review/apply behavior. Their update targets remain CV settings.
|
||||
|
||||
## Source reset, undo and settings history
|
||||
|
||||
Each defined entry wording override has its own **Use Career Profile: {field}** action. It removes only that field from the existing override object; other wording, hidden state, sibling overrides and section/item order remain intact. Profile-backed section content retains its existing reset, now **Use Career Profile content / Bruk innhold fra Karriereprofil**. Resetting an empty content array also returns to inheritance. Effective preview/export then uses the current canonical source through the existing resolver; the editor's outline is refreshed on reload, without adding source polling or snapshots.
|
||||
|
||||
Reset uses the same `update` callback as other edits. Session Undo restores the preceding CV settings, including the removed override; Redo reapplies the reset. History Restore remains a separate confirmed operation that creates a new acknowledged settings revision against the current Career Profile. Neither operation rewrites Career Profile or changes the accepted immutable submitted-package boundary.
|
||||
|
||||
Reset returns focus to the affected field. Custom entry add/reorder focuses the resulting entry; confirmed entry deletion focuses a surviving entry, or Add entry when none remains. Confirmed custom-section deletion focuses Add content. Cancel/Escape retains content and returns focus through the existing confirmation dialog.
|
||||
|
||||
## Localisation and touched owners
|
||||
|
||||
English/Bokmål scope and reset copy use the existing translation owner: **Using Career Profile / Bruker Karriereprofil**, **Customized for this CV / Tilpasset denne CV-en**, **CV-only content / Innhold bare i denne CV-en**, and **Use Career Profile: {field} / Bruk Karriereprofil: {field}**. UI language remains independent of document language and user-authored source/custom content. No content is translated to fit the forms.
|
||||
|
||||
| File | Bounded responsibility |
|
||||
|---|---|
|
||||
| `job-tracker-ui/src/views/CvBuilderEditor.tsx` | Existing Content form hierarchy, scope/inclusion descriptions, independent wording reset and local focus handling; same settings/update/save owners |
|
||||
| `job-tracker-ui/src/components/RichTextField.tsx` | Optional `helperText` passed to the existing MUI TextField so list/bullet fields can expose the same scope descriptions; unchanged storage and Markdown toolbar |
|
||||
| `job-tracker-ui/src/i18n/translations.ts` | Matching English/Bokmål source and field-reset labels within the existing catalogue |
|
||||
| `job-tracker-ui/src/cv-builder-deep-link.test.tsx` | Four focused cases plus stronger custom-content scope/confirmation/focus assertions |
|
||||
| `job-tracker-ui/e2e/cv-section-scope.spec.ts` | Native item wording/reset/inheritance, selected/sibling/canonical readback, inclusion/order, custom confirmation/focus and settings-history journey in the four theme/locale pairs |
|
||||
|
||||
No model, API, schema, save protocol, renderer, template capability, provider or route owner changes are required.
|
||||
|
||||
## Focused regression evidence
|
||||
|
||||
The four new unit cases ran before implementation and **4/4 failed** on the missing named entry groups/reset controls. After implementation, the complete editor file passed **30/30**, with no skipped/failed cases. The four additions cover CV-only Title/Subtitle/bullet writes; independent reset with omission/sibling-order preservation and Undo/Redo; omitted-entry draft retention after save failure; and section-content inheritance while retaining inclusion/presentation. The existing custom-content case additionally proves scope text, Cancel focus and focus after confirmed entry deletion.
|
||||
|
||||
Commands used the repository's `rtk` prefix. Raw focused reports remain ignored at `.artifacts/p5-10-before-jest.json` and `.artifacts/p5-10-after-focused-jest.json`. The initial focused native source/reset/custom-content scenario passed **1/1** against the local disposable API. The final complete run passed all four new native cases plus all 102 inherited cases, as recorded below.
|
||||
|
||||
## Separately scoped drag caveat
|
||||
|
||||
Review identified an inherited boundary in the unchanged `useDragReorder` hook: item drag events can bubble through their enclosing draggable section. A cross-section item drag can therefore reach the section reorder handler; moving an entry between source sections is not a supported operation. This slice preserves within-section native drag and the explicit keyboard move controls. A separate bounded follow-up should define and test nested drag propagation/drop ownership; P5-10 does not silently replace the reorder model or claim cross-section transfer support.
|
||||
|
||||
## Native source, revision and rendering proof
|
||||
|
||||
The four theme/language cases use a fresh ordinary synthetic account, real local cookie authentication and B02 session context, native Career Profile/CV endpoints, and the production static frontend. External browser requests are blocked. Each starts with three source Experience entries, Education, Projects, Skills, a selected CV and an untouched sibling CV.
|
||||
|
||||
The journey edits all three entry wording fields, omits and edits an entry, moves it by keyboard and native pointer drag, resets each field independently, and compares outgoing mutation fields and persisted native readback. Every checkpoint compares the canonical profile and sibling CV. After a separate native source update and reload, a retained Title override stays local while the reset Subtitle/bullets inherit the updated source. Custom content is added, edited, reordered and deleted using the real confirmations at phone width; Cancel/Escape leaves native content unchanged, and successful removal returns focus to a meaningful surviving control. Finally, native History restores version 1 as a new settings version. The updated Career Profile remains unchanged and the inherited fields use that current source, consistent with the accepted settings-only history model.
|
||||
|
||||
The unchanged 16-fixture PDF oracle passes with **36 preview pages = 36 PDF pages**. All sixteen generated `render.html` SHA-256 values match the accepted pagination checkpoint exactly. No settings fixture, renderer, PDF semantics or fidelity tolerance changes were made.
|
||||
|
||||
| Measurement | Declared limit retained | Largest measured variance |
|
||||
|---|---|---|
|
||||
| Page count / marker page membership | Exact | No mismatch |
|
||||
| Page dimensions | 0.5 mm | All fixtures within limit |
|
||||
| Marker X / Y position | 2 mm | X 0.000004 mm / Y 1.493055 mm |
|
||||
| Font size | 0.5 pt | 0.005026 pt |
|
||||
| RGB component | 1/255 | 0.000046 |
|
||||
|
||||
The five relational revision-readiness cases remain the stale-save/restore, canonical-source, settings-history and account-ownership gate. Submitted-file invariants retain their separate inherited package tests. The complete inherited browser run additionally retains stale-editor conflict/draft recovery, P4-03 keyboard preview entry/exit, frozen-package/P2-D01 and existing AI review/accept/reject. These proofs do not certify every content/font/photo or production renderer combination.
|
||||
|
||||
## Candidate review corrections
|
||||
|
||||
Pre-final review corrected the existing Bokmål section-reset label, replaced an accidental new `h6` entry heading with a styled paragraph, and kept whitespace-only Title overrides named from their effective source value. It also removed the newly proposed outline-date caption because `GET /cv/outline` uses default document settings and can synthesize English “Present” in a Bokmål interface. The existing unit case contains that metadata and asserts it is not newly displayed; document date rendering is unchanged.
|
||||
|
||||
Earlier partial browser runs were stopped when those corrections required a new production build. They are not counted as completed passes. The first native pointer fixture targeted the centre of a tall form and landed inside its textarea; the fixture now uses the existing entry-header grip area, retains actual browser drag events and requires native `itemOrder` persistence. No drag handler or assertion was replaced to obtain a pass.
|
||||
|
||||
## Visual, responsive and accessibility review
|
||||
|
||||
The retained [visual manifest](evidence/visual-manifest.json) identifies the final-candidate captures and their hashes. Thirty images cover source, customized and reset/omitted entries; populated CV-only content; the workspace after an invalid document name; and confirmed custom-entry deletion. The invalid-name case proves the existing disabled Save action behaviour separately; its scrolled screenshot is not proof of a new inline error UI or complete header visibility. The evidence uses all four light/dark × English/Bokmål pairs, with 1440px desktop, 768px intermediate and 320px phone checks. Native assertions require no horizontal page overflow and focused fields/actions after reflow.
|
||||
|
||||
Actual rendered captures were inspected, including these representative states:
|
||||
|
||||
| State / review purpose | Evidence |
|
||||
|---|---|
|
||||
| Light/Bokmål source fields and inclusion | [Desktop source entry](evidence/light-nb-desktop-source-entry.png) |
|
||||
| Dark/English reset and omitted content remains editable | [Reset omitted entry](evidence/dark-en-desktop-reset-omitted-entry.png) |
|
||||
| Dark/Bokmål field scope, wrapped reset help and keyboard focus | [320px entry](evidence/dark-nb-scoped-entry-320.png) |
|
||||
| Light/English repeated custom fields and keyboard controls | [CV-only content](evidence/light-en-custom-cv-only-populated.png) |
|
||||
| Dark/Bokmål narrow custom-entry actions and localized labels | [320px custom content](evidence/dark-nb-custom-cv-only-320.png) |
|
||||
| Destructive consequence and phone dialog actions | [Bokmål delete confirmation](evidence/dark-nb-custom-entry-delete-confirmation.png) |
|
||||
|
||||
In the inspected scope, fields remain readable, omission is conveyed without dimming editable text, secondary scope descriptions have light/dark treatments, and action/field focus is visible. Repeated entries use a border-separated hierarchy, not a new card per field. Single-line editable titles retain their full value and scroll within their input; narrow actions and descriptions wrap. No preview container, document geometry, font, final branding or permanent colour decision changed.
|
||||
|
||||
Keyboard behaviour is exercised in the real browser with Enter/Escape, named controls, explicit focus checks and native pointer drag; visual focus inspection complements those assertions. Custom entry actions work at phone width without hover. Field helper text uses MUI's description association. Scope changes do not add a live announcement on every keystroke; reset/add/delete move focus to a meaningful described control. Existing section navigation, preview regions and keyboard entry/exit are regression dependencies, not rewritten here.
|
||||
|
||||
The [axe summary](evidence/axe-summary.json) retains both violations and **incomplete** checks. Scoped automated results do not certify spoken names, live announcements or every dialog/provider state. Human screen-reader acceptance remains required for P5-15, and existing incomplete contrast/ARIA/table findings remain unclosed. Native mobile-touch and broader pointer/reflow qualification are not inferred from desktop Chromium emulation.
|
||||
|
||||
## Final executed validation — 2026-10-07
|
||||
|
||||
The final candidate includes the metadata/whitespace/heading corrections above. All application checks below completed afterward. No production requests or deployments were made.
|
||||
|
||||
| Gate | P5-09 inherited | Final P5-10 | Change |
|
||||
|---|---:|---:|---|
|
||||
| Backend Release | 941 | **941 passed**, zero skipped/failed | No new backend code/tests |
|
||||
| Frontend Jest | 426 | **430 passed**, 80 suites, zero skipped/failed | Four focused Content cases |
|
||||
| Chromium browser | 102 | **106 actual passes**, zero skipped/flaky/failed/expected failures | Four native locale/theme journeys |
|
||||
| Original fidelity oracle | 16/16, 36 pages | **16/16, 36 preview = 36 PDF pages** | Fixtures/tolerances/HTML unchanged |
|
||||
| Revision subset (included in backend total) | 5/5 | **5/5** | Unchanged gate |
|
||||
| Rate-limit / ingress / PDF admission | 9 / 6 / 1 | **9 / 6 / 1 passed** | B03/B04/B06 gates; B01/B02/B05 retain complete backend/browser regressions |
|
||||
| Configuration/Compose fixtures | 3 | **3 passed** | Unchanged repository config gate |
|
||||
| TypeScript / edited browser TypeScript / lint | Passing | **Passing**, lint zero warnings | No TS/CI gate change |
|
||||
| Frontend production / API Release builds | Passing | **Passing**, API zero warnings/errors | No build/CI/dependency change |
|
||||
| Supply-chain / Ollama evaluation / extension fixtures | 3 / 5 / 3 | **3 / 5 / 3 passed** | Ancillary local fixtures |
|
||||
| Secret / whitespace / document links | Passing | **Passing**; 147 current/root Markdown files, zero missing local targets | Historical archives/external URLs/anchors excluded by the inherited local-target check |
|
||||
|
||||
The [browser summary](evidence/browser-summary.json) records every case's actual/expected status and attempt duration. All expected statuses are `passed`; **P2-D01 is ordinary passing**. Browser duration was 17.8 minutes. Frontend duration was 210.814 seconds; backend reported 19 seconds. Raw Jest/TRX/browser/security outputs stay ignored under `.artifacts/p5-10/`; the retained summaries exclude credentials, requests and raw traces.
|
||||
|
||||
The complete run retains **205 axe scans, zero reported violations**. Sixteen are new P5-10 scoped scans. New scans retain `aria-prohibited-attr` and `color-contrast` incomplete findings; inherited scans additionally retain `aria-hidden-focus`, `aria-valid-attr-value` and `th-has-data-cells`. These are incomplete checks, not certified passes or closures of the manual accessibility gate.
|
||||
|
||||
### Reproducible commands
|
||||
|
||||
Commands below are invoked through `rtk proxy` (Git uses `rtk git`). UI commands run in `job-tracker-ui`. Native test runners use `DOTNET_ROOT=C:/Users/Connor/.dotnet` and `DOTNET_HOST_PATH=C:/Users/Connor/.dotnet/dotnet.exe`. Frontend production build uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api`; browser runs use `PLAYWRIGHT_STATIC_EXPORT=true`, `PLAYWRIGHT_PREBUILT_EXPORT=true`, `CV_FIDELITY_OUTPUT=../.artifacts/p5-10/fidelity` and `PLAYWRIGHT_JSON_OUTPUT_NAME=../.artifacts/p5-10/browser.json`. Build the static export before the browser runner; specialized native suites share port 5302 and run sequentially.
|
||||
|
||||
```text
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=p5-10.trx" --results-directory .artifacts/p5-10/backend
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~CvRevisionReadinessTests"
|
||||
dotnet build JobTrackerApi/JobTrackerApi.csproj --configuration Release --no-restore -warnaserror
|
||||
npm.cmd test -- --runInBand --silent --json --outputFile=../.artifacts/p5-10/jest.json
|
||||
npm.cmd run typecheck
|
||||
npm.cmd exec tsc -- --project ../.artifacts/p5-09-readiness-e2e-tsconfig.json --noEmit --incremental false
|
||||
npm.cmd run lint
|
||||
npm.cmd run build
|
||||
npm.cmd run test:e2e -- --output=../.artifacts/p5-10/browser --reporter=line,json
|
||||
python -X utf8 scripts/verify-cv-fidelity.py .artifacts/p5-10/fidelity
|
||||
npm.cmd run test:rate-limits -- --output=../.artifacts/p5-10/rate-limits --reporter=line
|
||||
npm.cmd exec playwright test -- --config=playwright.ingress.config.ts --output=../.artifacts/p5-10/ingress --reporter=line
|
||||
npm.cmd run test:pdf-admission -- --output=../.artifacts/p5-10/pdf-admission --reporter=line
|
||||
python scripts/test-ingress.py
|
||||
python scripts/test-supply-chain.py
|
||||
python scripts/test-ollama-evaluation.py
|
||||
node browser-extension/tests/capture-url.test.mjs
|
||||
python scripts/supply-chain.py scan-secrets
|
||||
python .artifacts/p4-04-check-doc-links.py
|
||||
git diff --check
|
||||
git diff --cached --check
|
||||
```
|
||||
|
||||
Fidelity uses the already configured bundled Python with PDF libraries, not a newly installed dependency. The temporary browser-type/link helpers retain the inherited checkpoint procedures; no CI or package.json change is needed. Evidence summaries and thirty final-candidate screenshots are the only new retained artifacts.
|
||||
|
||||
## Acceptance and remaining P5-11 boundary
|
||||
|
||||
**P5-10 accepted locally.** Career Profile, sibling CV and submitted-package boundaries remain protected; current Content edits write the selected CV only. Revision safety, preview keyboard behaviour and the original measurable fidelity gate pass. No rendering/page-count regression blocks the next preview-presentation slice.
|
||||
|
||||
P5-11 owns live-preview presentation and must preserve this same renderer/geometry/fidelity contract. P5-12 owns template/document controls, P5-13 AI review and P5-14 full responsive CV acceptance. They were not implemented here. The inherited settings-history distinction must remain explicit in future work.
|
||||
|
||||
Keep open: P5-02 human screen-reader acceptance for P5-15, incomplete axe/manual checks, broader pointer/reflow and production font/photo qualification, **F04 production ingress evidence**, Dashboard preference-save 500, Board full-result scaling/native touch, deferred `followMode` compose UI and unchanged inherited timing observations. **P5-10-O01** records the separately scoped nested drag/drop ownership caveat above; it does not authorize unsupported cross-section item transfer or block preview-only P5-11. No unrelated remediation or B07 work occurred.
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"file": "light-en-desktop-source-entry.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "desktop-source-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "7575d25aae327b6ce9793f1edd878b1665b908f6fac834baf95f6ebe85364407"
|
||||
},
|
||||
{
|
||||
"file": "light-en-desktop-reset-omitted-entry.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "desktop-reset-omitted-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "e1aad23f6d9de71ecb696089b4309f24f1771ab005c86319ce6205861d28a9a9"
|
||||
},
|
||||
{
|
||||
"file": "light-en-desktop-customized-entry.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "desktop-customized-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "ebfcefbca8d46d45af1553f409c1176b9e993e8aaab070da08b8357274c414ae"
|
||||
},
|
||||
{
|
||||
"file": "light-en-scoped-entry-320.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "scoped-entry-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "de20b02bda6d63f771222005b06c80c07bb318f23a461071d38e83063eeda4a3"
|
||||
},
|
||||
{
|
||||
"file": "light-en-custom-cv-only-populated.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "custom-cv-only-populated",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "717590183f4384e613455ce405d0fa2c41c357b2c1ce7f4e52b59be6c21ca596"
|
||||
},
|
||||
{
|
||||
"file": "light-en-custom-cv-only-320.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "custom-cv-only-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "062b4bfedc6c024444e005a5272ee21e3f4f704f3c38260cb7c2c0dd0ef78426"
|
||||
},
|
||||
{
|
||||
"file": "light-en-scoped-entry-768.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "scoped-entry-768",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "eead6b9effdc887073f956fe042f1b51ed70d7eb616b3302124a2c4325ff4179"
|
||||
},
|
||||
{
|
||||
"file": "light-en-custom-entry-delete-confirmation.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "custom-entry-delete-confirmation",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "268f8047a43e3afb453d57588d63aca65907e8eb0796958b9fbdda8c6df2d1b0"
|
||||
},
|
||||
{
|
||||
"file": "light-en-name-validation.png",
|
||||
"mode": "light",
|
||||
"language": "en",
|
||||
"state": "name-validation",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-9c368-ry-order-and-custom-content-chromium",
|
||||
"sha256": "856bf78677b73a1d1cbe618feb255fb4cec97b40320b512e82f403a6fea792ed"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-desktop-source-entry.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "desktop-source-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "931a384abad4ae7aa82c7ba40c4a323bbee48e1ccc62038dbc8733042477560a"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-desktop-reset-omitted-entry.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "desktop-reset-omitted-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "fb30b0a143ba03bf8e7a2f4fc1a6602fedb8e31d80289e913aa2718b80b5a61d"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-desktop-customized-entry.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "desktop-customized-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "d81a1c8fa8cc4f1ba4e288efa1304a3120c404fd2f271a82924f02d8b4d6df01"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-scoped-entry-320.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "scoped-entry-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "01bc4275a9f0a95baafe651de978c6501e0f857cb5f42688eda9283781966358"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-custom-cv-only-populated.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "custom-cv-only-populated",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "c2b3d050e4387fc27f8aec58d8711067740ee2b492efdcc89b4ee94cebb22c9f"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-custom-cv-only-320.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "custom-cv-only-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "cc4d3c84baede1885624ceced3bd10b5550f68d5a62bc87457e7f9d6dc80cdf5"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-scoped-entry-768.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "scoped-entry-768",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "f2a7c04ea32c325abb870076cb9d476cef4478cbebd85fc9221ea85645d8ca75"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-custom-entry-delete-confirmation.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "custom-entry-delete-confirmation",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "b043d11004fe83057a9f716c6f3d5844223ddfeb0f08aa2ca338a556e4634c21"
|
||||
},
|
||||
{
|
||||
"file": "dark-nb-name-validation.png",
|
||||
"mode": "dark",
|
||||
"language": "nb",
|
||||
"state": "name-validation",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-4cd24-ry-order-and-custom-content-chromium",
|
||||
"sha256": "6895d54be35bb89dac7d72421a1e1238e611e49aaf685a1e3bbc9d0d654427c5"
|
||||
},
|
||||
{
|
||||
"file": "light-nb-desktop-source-entry.png",
|
||||
"mode": "light",
|
||||
"language": "nb",
|
||||
"state": "desktop-source-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-ebafa-ry-order-and-custom-content-chromium",
|
||||
"sha256": "cecd4da473a62ea2c88027ed516feb4dcf4f10c316d6e3790bbde2f7680b4fba"
|
||||
},
|
||||
{
|
||||
"file": "light-nb-desktop-reset-omitted-entry.png",
|
||||
"mode": "light",
|
||||
"language": "nb",
|
||||
"state": "desktop-reset-omitted-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-ebafa-ry-order-and-custom-content-chromium",
|
||||
"sha256": "c187fc501be27f31e498c74be3576bb8519b6613234e1f2e9069e70bfd767916"
|
||||
},
|
||||
{
|
||||
"file": "light-nb-desktop-customized-entry.png",
|
||||
"mode": "light",
|
||||
"language": "nb",
|
||||
"state": "desktop-customized-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-ebafa-ry-order-and-custom-content-chromium",
|
||||
"sha256": "fe8c4641d5680bb4e1ee1c535849dbf83e9c066144677cdcaa7665fdf45c201d"
|
||||
},
|
||||
{
|
||||
"file": "light-nb-scoped-entry-320.png",
|
||||
"mode": "light",
|
||||
"language": "nb",
|
||||
"state": "scoped-entry-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-ebafa-ry-order-and-custom-content-chromium",
|
||||
"sha256": "7a7eee5bde980b81e0ea54aaff30ce1cd911819409e3dc1e3779f54b96242f9e"
|
||||
},
|
||||
{
|
||||
"file": "light-nb-custom-cv-only-populated.png",
|
||||
"mode": "light",
|
||||
"language": "nb",
|
||||
"state": "custom-cv-only-populated",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-ebafa-ry-order-and-custom-content-chromium",
|
||||
"sha256": "de3d4f2c91f4f2124b39340a6665c20c9a602885c61169e65a733a69a1b47c8e"
|
||||
},
|
||||
{
|
||||
"file": "light-nb-custom-cv-only-320.png",
|
||||
"mode": "light",
|
||||
"language": "nb",
|
||||
"state": "custom-cv-only-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-ebafa-ry-order-and-custom-content-chromium",
|
||||
"sha256": "3fe661850af3ff091b7853523a20a5435aa1abfb196ae5fcb3761f7bc44c4ce8"
|
||||
},
|
||||
{
|
||||
"file": "dark-en-desktop-source-entry.png",
|
||||
"mode": "dark",
|
||||
"language": "en",
|
||||
"state": "desktop-source-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-fc56a-ry-order-and-custom-content-chromium",
|
||||
"sha256": "0b2b8fb9ca3cde7fd21e8b5bc7181edd3499c2f671b58b829d1bd636c97ff761"
|
||||
},
|
||||
{
|
||||
"file": "dark-en-desktop-reset-omitted-entry.png",
|
||||
"mode": "dark",
|
||||
"language": "en",
|
||||
"state": "desktop-reset-omitted-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-fc56a-ry-order-and-custom-content-chromium",
|
||||
"sha256": "8b8e11364f0e3c81f1929de1ee99bd668bb7604e0cf38a289c497992afb1b9cb"
|
||||
},
|
||||
{
|
||||
"file": "dark-en-desktop-customized-entry.png",
|
||||
"mode": "dark",
|
||||
"language": "en",
|
||||
"state": "desktop-customized-entry",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-fc56a-ry-order-and-custom-content-chromium",
|
||||
"sha256": "298bb14605601feaf577e017caf504a0a1d0b59c69f21a5ad76d7531cdf2c0c0"
|
||||
},
|
||||
{
|
||||
"file": "dark-en-scoped-entry-320.png",
|
||||
"mode": "dark",
|
||||
"language": "en",
|
||||
"state": "scoped-entry-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-fc56a-ry-order-and-custom-content-chromium",
|
||||
"sha256": "18f7b0c971e3a7dd99002f806ab5f0644d14e7dab6664efc2fd1530e126f8f6b"
|
||||
},
|
||||
{
|
||||
"file": "dark-en-custom-cv-only-populated.png",
|
||||
"mode": "dark",
|
||||
"language": "en",
|
||||
"state": "custom-cv-only-populated",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-fc56a-ry-order-and-custom-content-chromium",
|
||||
"sha256": "f68e1b4803d3fc6d05e877790509fb902b2d0aa7e6d304f5bbbb423086f059bb"
|
||||
},
|
||||
{
|
||||
"file": "dark-en-custom-cv-only-320.png",
|
||||
"mode": "dark",
|
||||
"language": "en",
|
||||
"state": "custom-cv-only-320",
|
||||
"sourceOutputFolder": "cv-section-scope--PRES-C05-fc56a-ry-order-and-custom-content-chromium",
|
||||
"sha256": "dd1bec574d1a9c4187d49824daafdc54992633916b99414b24748f5c39ec45e7"
|
||||
}
|
||||
],
|
||||
"review": "Actual final-candidate PNGs inspected: light-nb desktop-source-entry; dark-en desktop-reset-omitted-entry; dark-nb scoped-entry-320 and scoped-entry-768; light-en custom-cv-only-populated and name-validation; dark-nb custom-cv-only-320 and custom-entry-delete-confirmation. Review covered hierarchy, readable scope/inclusion, light/dark contrast, narrow reflow, field/action focus and dialog actions. Name-validation is a scrolled workspace capture, not proof of new inline feedback/full header visibility. Remaining retained images have automated native state/focus/overflow assertions; individual manual inspection of every image is not claimed. Human screen-reader, native touch and broader pointer/reflow qualification remain open."
|
||||
}
|
||||
@@ -4,6 +4,9 @@ Status reconciled: 2026-10-07.
|
||||
|
||||
## Current repository work
|
||||
|
||||
- **P5-10 — CV section editing/source clarity complete:** [implementation record](../redesign/phase-5/p5-10/README.md) retains P5-09's frame and existing canonical/settings/history/rendering owners. Scope-labelled fields, independent reset, editable omitted entries and compact custom-entry actions have native persistence/focus evidence. Baseline: **941 backend / 430 frontend (+4) / 106 actual browser passes (+4)**, security 9/6/1, configuration 3; revision 5/5 and fidelity 16/16, 36 aligned pages. EN/Bokmål, light/dark, desktop/intermediate/320px evidence retained; 205 axe scans report zero violations but retain incomplete findings. Stop before P5-11+ or B07. F04 and human screen-reader/font/photo/pointer qualifications stay open.
|
||||
- **P5-10-O01 — inherited nested drag/drop ownership:** unchanged item drag events can bubble to the section reorder handler on cross-section drops. Within-section native drag and keyboard moves remain protected; cross-section transfer is unsupported. Define and test drop ownership in a separate bounded follow-up, without changing the source-section model. This does not block preview-only P5-11. [Evidence](../redesign/phase-5/p5-10/README.md#separately-scoped-drag-caveat).
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import type { CvOutline, CvVariant } from '../src/cvBuilder';
|
||||
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(scope: Locator, info: TestInfo, state: string) {
|
||||
await scope.scrollIntoViewIfNeeded();
|
||||
await scope.screenshot({ path: info.outputPath(`${state}.png`), animations: 'disabled' });
|
||||
}
|
||||
async function scan(page: Page, info: TestInfo, state: string, region: string) {
|
||||
const result = await new AxeBuilder({ page }).include(`[role="region"][aria-label=${JSON.stringify(region)}]`)
|
||||
.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 C07 C10 W05 E08] ${mode}/${language} CV field scope, source resets, entry order and custom content`, async ({ page, context, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const named = (key: keyof typeof t, entry: string) => t[key].replace('{entry}', entry);
|
||||
const sectionLabel = (key: keyof typeof t, section: string) => t[key].replace('{section}', section);
|
||||
const resetLabel = (field: string) => t.cvEditorResetField.replace('{field}', field);
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `p510-${Date.now()}-${mode}-${language}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
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.'] },
|
||||
{ id: 'job-2', title: 'Systems engineer', company: 'Fjord AS', startDate: '2019-01', endDate: '2020-12', bullets: ['Reviewed documented integrations.'] },
|
||||
{ id: 'job-3', title: 'Software developer', company: 'Aurora AS', startDate: '2017-01', endDate: '2018-12', bullets: ['Built source-grounded software.'] },
|
||||
],
|
||||
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2013-08', endDate: '2016-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 outline: CvOutline = await (await page.request.get(`${apiUrl}/cv/outline`)).json();
|
||||
const entries = outline.sections.find(section => section.key === 'experience')!.entries;
|
||||
const first = entries.find(entry => entry.title === 'Platform engineer')!;
|
||||
const second = entries.find(entry => entry.title === 'Systems engineer')!;
|
||||
const third = entries.find(entry => entry.title === 'Software developer')!;
|
||||
expect(entries).toHaveLength(3);
|
||||
const settings = { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience', itemOrder: [first.key!, second.key!, third.key!] }, { key: 'education' }, { key: 'projects' }, { key: 'skills', hidden: true }] };
|
||||
const create = async (name: string): Promise<CvVariant> => {
|
||||
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings } });
|
||||
expect(response.status()).toBe(200); return response.json();
|
||||
};
|
||||
const cv = await create('Åse — this CV only');
|
||||
const sibling = await create('Åse — untouched sibling');
|
||||
const read = async (id = cv.id): Promise<CvVariant> => (await page.request.get(`${apiUrl}/cv/variants/${id}`)).json();
|
||||
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
|
||||
const unchanged = async (expectedSource = source) => {
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(expectedSource);
|
||||
expect(await read(sibling.id)).toEqual(sibling);
|
||||
};
|
||||
const experience = page.getByRole('group', { name: t.cvSectionExperience, exact: true });
|
||||
const entry = (title: string) => experience.getByRole('group', { name: title, exact: true });
|
||||
const openExperience = async () => {
|
||||
await page.getByRole('button', { name: sectionLabel('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click();
|
||||
await expect(entry(first.title!)).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 openExperience(); await saved();
|
||||
await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
|
||||
await expect(experience.getByText(t.cvEditorEntryScopeHelp, { exact: true })).toBeVisible();
|
||||
await capture(entry(first.title!), info, 'desktop-source-entry');
|
||||
await scan(page, info, 'desktop-source-entry', t.cvEditorEditingRegion);
|
||||
|
||||
// The native UI writes only the current variant's lens over Career Profile.
|
||||
await entry(first.title!).getByLabel(t.cvEditorEntryTitle, { exact: true }).fill('CV-specific platform engineer');
|
||||
const tailored = entry('CV-specific platform engineer');
|
||||
await tailored.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).fill('CV-specific employer — æøå');
|
||||
await tailored.getByLabel(t.cvEditorBulletsHelp, { exact: true }).fill('CV-specific evidence.\nSecond tailored bullet.');
|
||||
await expect.poll(async () => (await read()).settings.overrides[first.key!]).toMatchObject({ title: 'CV-specific platform engineer', subtitle: 'CV-specific employer — æøå', bullets: ['CV-specific evidence.', 'Second tailored bullet.'] });
|
||||
await saved(); await unchanged();
|
||||
await expect(tailored.getByText(t.cvEditorCustomized, { exact: true }).first()).toBeVisible();
|
||||
|
||||
const move = tailored.getByRole('button', { name: named('cvEditorMoveNamedEntryDown', 'CV-specific platform engineer'), exact: true });
|
||||
await move.focus(); await page.keyboard.press('Enter');
|
||||
await expect(move).toBeFocused();
|
||||
const movedOrder = [second.key!, first.key!, third.key!];
|
||||
await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual(movedOrder);
|
||||
await saved();
|
||||
await tailored.getByRole('button', { name: named('cvEditorHideEntry', 'CV-specific platform engineer'), exact: true }).click();
|
||||
await expect.poll(async () => (await read()).settings.overrides[first.key!].hidden).toBe(true);
|
||||
await expect(tailored.getByLabel(t.cvEditorEntryTitle, { exact: true })).toBeEditable();
|
||||
await expect(tailored.getByRole('button', { name: named('cvEditorShowEntry', 'CV-specific platform engineer'), exact: true })).toBeVisible();
|
||||
await tailored.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).fill('Edited while omitted — æøå');
|
||||
await expect.poll(async () => (await read()).settings.overrides[first.key!].subtitle).toBe('Edited while omitted — æøå');
|
||||
await saved();
|
||||
|
||||
for (const [field, label, value] of [
|
||||
['bullets', t.cvEditorBulletsHelp, first.bullets.join('\n')],
|
||||
['title', t.cvEditorEntryTitle, first.title!],
|
||||
['subtitle', t.cvEditorEntrySubtitle, first.subtitle ?? ''],
|
||||
] as const) {
|
||||
const currentTitle = field === 'subtitle' ? first.title! : 'CV-specific platform engineer';
|
||||
const before = await read();
|
||||
const reset = entry(currentTitle).getByRole('button', { name: resetLabel(label), exact: true });
|
||||
const resetRequest = page.waitForRequest(request => request.url() === `${apiUrl}/cv/variants/${cv.id}` && request.method() === 'PUT');
|
||||
await reset.focus(); await page.keyboard.press('Enter');
|
||||
await expect(entry(field === 'bullets' ? currentTitle : first.title!).getByLabel(label, { exact: true })).toBeFocused();
|
||||
expect((await resetRequest).postDataJSON().settings.overrides[first.key!]).not.toHaveProperty(field);
|
||||
await expect.poll(async () => (await read()).settings.overrides[first.key!]?.[field] ?? null).toBeNull();
|
||||
await saved();
|
||||
const after = await read();
|
||||
const remaining = { ...before.settings.overrides[first.key!] }; delete remaining[field];
|
||||
const persisted = { ...after.settings.overrides[first.key!] }; delete persisted[field];
|
||||
expect(persisted).toEqual(remaining);
|
||||
expect(after.settings.sections).toEqual(before.settings.sections);
|
||||
await expect(entry(field === 'bullets' ? currentTitle : first.title!).getByLabel(label, { exact: true })).toHaveValue(value);
|
||||
}
|
||||
await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
|
||||
await expect(entry(first.title!).getByRole('button', { name: named('cvEditorShowEntry', first.title!), exact: true })).toBeVisible();
|
||||
await unchanged();
|
||||
await capture(entry(first.title!), info, 'desktop-reset-omitted-entry');
|
||||
|
||||
// An absent field inherits the latest source; a retained override remains local.
|
||||
await entry(first.title!).getByLabel(t.cvEditorEntryTitle, { exact: true }).fill('Retained CV title');
|
||||
await expect.poll(async () => (await read()).settings.overrides[first.key!].title).toBe('Retained CV title');
|
||||
await saved();
|
||||
const changedProfile = { ...profile, jobs: profile.jobs.map(job => job.id === 'job-1' ? { ...job, title: 'Updated source title', company: 'Updated source employer', bullets: ['Updated source evidence — æøå.'] } : job) };
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: changedProfile } })).status()).toBe(200);
|
||||
const changedSource = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const beforeReload = await read();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: sectionLabel('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click();
|
||||
const retained = entry('Retained CV title');
|
||||
await expect(retained.getByLabel(t.cvEditorEntryTitle, { exact: true })).toHaveValue('Retained CV title');
|
||||
await expect(retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toHaveValue('Updated source employer');
|
||||
await expect(retained.getByLabel(t.cvEditorBulletsHelp, { exact: true })).toHaveValue('Updated source evidence — æøå.');
|
||||
await expect(retained.getByRole('button', { name: named('cvEditorShowEntry', 'Retained CV title'), exact: true })).toBeVisible();
|
||||
expect(await read()).toEqual(beforeReload); await unchanged(changedSource);
|
||||
await retained.getByRole('button', { name: named('cvEditorShowEntry', 'Retained CV title'), exact: true }).click();
|
||||
await expect.poll(async () => (await read()).settings.overrides[first.key!].hidden).toBe(false); await saved();
|
||||
await expect(retained.getByRole('button', { name: named('cvEditorHideEntry', 'Retained CV title'), exact: true })).toBeVisible();
|
||||
|
||||
// Actual HTML drag handlers persist the same itemOrder used by keyboard moves.
|
||||
await entry(second.title!).dragTo(retained, { sourcePosition: { x: 8, y: 24 }, targetPosition: { x: 8, y: 24 } });
|
||||
await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual([first.key!, second.key!, third.key!]);
|
||||
await saved(); await unchanged(changedSource);
|
||||
await capture(retained, info, 'desktop-customized-entry');
|
||||
for (const width of [1440, 768, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true }).focus();
|
||||
await expect(retained.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toBeFocused();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(retained, info, `scoped-entry-${width}`);
|
||||
await scan(page, info, `scoped-entry-${width}`, t.cvEditorEditingRegion);
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
// Existing document-name validation remains the validation owner; optional
|
||||
// source fields do not acquire invented required rules in the section UI.
|
||||
const cvName = page.getByLabel(t.cvEditorName, { exact: true });
|
||||
await cvName.fill('');
|
||||
await expect(page.getByRole('button', { name: t.cvEditorSaveNow, exact: true })).toBeDisabled();
|
||||
await page.screenshot({ path: info.outputPath('name-validation.png'), animations: 'disabled' });
|
||||
await cvName.fill(cv.name); await saved();
|
||||
const addContent = page.getByRole('button', { name: t.cvEditorAddContent, exact: true });
|
||||
await addContent.click();
|
||||
const addDialog = page.getByRole('dialog');
|
||||
await addDialog.getByText(t.cvEditorAddCustomHelp, { exact: true }).locator('..').getByRole('button', { name: t.cvEditorAdd, exact: true }).click();
|
||||
await expect(addDialog).toHaveCount(0);
|
||||
await expect.poll(async () => (await read()).settings.customSections.length).toBe(1); await saved();
|
||||
const customSetting = (await read()).settings.customSections[0];
|
||||
const sectionName = page.getByLabel(sectionLabel('cvEditorSectionName', `custom:${customSetting.key}`), { exact: true });
|
||||
await sectionName.fill('CV-only volunteering — æøå');
|
||||
const custom = page.getByRole('group', { name: 'CV-only volunteering — æøå', exact: true });
|
||||
await custom.getByRole('button', { name: sectionLabel('cvEditorExpandSection', 'CV-only volunteering — æøå'), exact: true }).click();
|
||||
await expect(custom.getByText(t.cvEditorCvOnly, { exact: true })).toBeVisible();
|
||||
await custom.getByRole('combobox', { name: new RegExp(`^${t.cvEditorContentFormat}`) }).click();
|
||||
await page.getByRole('option', { name: t.cvEditorMultipleEntries, exact: true }).click();
|
||||
const addEntry = custom.getByRole('button', { name: t.cvEditorAddEntry, exact: true });
|
||||
const customEntry = (number: number) => custom.getByLabel(t.cvEditorEntry.replace('{number}', String(number)), { exact: true });
|
||||
await addEntry.focus(); await page.keyboard.press('Enter');
|
||||
await expect(customEntry(1)).toBeFocused();
|
||||
await customEntry(1).fill('CV-only first contribution — æøå.');
|
||||
await addEntry.click(); await expect(customEntry(2)).toBeFocused();
|
||||
await customEntry(2).fill('CV-only second contribution.');
|
||||
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only first contribution — æøå.', 'CV-only second contribution.']); await saved();
|
||||
await capture(custom, info, 'custom-cv-only-populated');
|
||||
for (const width of [768, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await customEntry(2).focus(); await expect(customEntry(2)).toBeFocused();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(custom, info, `custom-cv-only-${width}`);
|
||||
}
|
||||
const customMove = custom.getByRole('button', { name: t.cvEditorMoveEntryUp.replace('{number}', '2'), exact: true });
|
||||
await customMove.focus(); await page.keyboard.press('Enter');
|
||||
await expect(customEntry(1)).toBeFocused();
|
||||
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only second contribution.', 'CV-only first contribution — æøå.']); await saved();
|
||||
const deleteEntry = custom.getByRole('button', { name: t.cvEditorDeleteEntryNumber.replace('{number}', '1'), exact: true });
|
||||
const beforeCancel = await read();
|
||||
await deleteEntry.click();
|
||||
await expect(page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
|
||||
await page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(deleteEntry).toBeFocused();
|
||||
expect(await read()).toEqual(beforeCancel);
|
||||
await deleteEntry.click();
|
||||
await capture(page.getByRole('dialog'), info, 'custom-entry-delete-confirmation');
|
||||
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteEntry, exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(customEntry(1)).toBeFocused();
|
||||
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual(['CV-only first contribution — æøå.']); await saved();
|
||||
await custom.getByRole('button', { name: t.cvEditorDeleteEntryNumber.replace('{number}', '1'), exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteEntry, exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(addEntry).toBeFocused();
|
||||
await expect.poll(async () => (await read()).settings.customSections[0].items).toEqual([]); await saved();
|
||||
await capture(custom, info, 'custom-cv-only-empty'); await unchanged(changedSource);
|
||||
|
||||
const removeSection = custom.getByRole('button', { name: sectionLabel('cvEditorRemoveSection', 'CV-only volunteering — æøå'), exact: true });
|
||||
const beforeRemove = await read();
|
||||
await removeSection.click(); await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(removeSection).toBeFocused();
|
||||
expect(await read()).toEqual(beforeRemove);
|
||||
await removeSection.click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorDeleteSection, exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0); await expect(addContent).toBeFocused();
|
||||
await expect.poll(async () => (await read()).settings.customSections).toEqual([]); await saved();
|
||||
expect((await read()).settings.sections.some(section => section.key === `custom:${customSetting.key}`)).toBe(false);
|
||||
await unchanged(changedSource);
|
||||
|
||||
// Existing history stores CV settings; it does not snapshot Career Profile.
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const final = await read();
|
||||
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 history.getByRole('button', { name: t.cvEditorRestoreTitle.replace('{version}', '1'), exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorRestoreVersion, exact: true }).click();
|
||||
await expect.poll(async () => (await read()).version).toBe(final.version + 1);
|
||||
expect((await read()).settings).toEqual(cv.settings); await unchanged(changedSource);
|
||||
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
|
||||
const inherited = entry('Updated source title');
|
||||
await expect(inherited.getByLabel(t.cvEditorEntryTitle, { exact: true })).toHaveValue('Updated source title');
|
||||
await expect(inherited.getByLabel(t.cvEditorEntrySubtitle, { exact: true })).toHaveValue('Updated source employer');
|
||||
await expect(inherited.getByLabel(t.cvEditorBulletsHelp, { exact: true })).toHaveValue('Updated source evidence — æøå.');
|
||||
await expect(inherited.getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
|
||||
await unchanged(changedSource);
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export default function RichTextField({
|
||||
placeholder,
|
||||
minRows = 2,
|
||||
ariaLabel,
|
||||
helperText,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
@@ -28,6 +29,7 @@ export default function RichTextField({
|
||||
placeholder?: string;
|
||||
minRows?: number;
|
||||
ariaLabel?: string;
|
||||
helperText?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -63,6 +65,7 @@ export default function RichTextField({
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
helperText={helperText}
|
||||
placeholder={placeholder}
|
||||
multiline
|
||||
minRows={minRows}
|
||||
|
||||
@@ -462,6 +462,7 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
|
||||
const title = screen.getByLabelText(/Section name for custom:/);
|
||||
fireEvent.change(title, { target: { value: 'Selected projects' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Selected projects' }));
|
||||
expect(within(screen.getByRole('group', { name: 'Selected projects' })).getByText('CV-only content')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
|
||||
fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }));
|
||||
@@ -471,16 +472,20 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
|
||||
expect(screen.getByLabelText('Entry 1')).toHaveValue('Second project');
|
||||
expect(screen.getByLabelText('Entry 2')).toHaveValue('First project');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
|
||||
const deleteEntry = screen.getByRole('button', { name: 'Delete custom entry 1' });
|
||||
deleteEntry.focus();
|
||||
fireEvent.click(deleteEntry);
|
||||
let dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(deleteEntry).toHaveFocus();
|
||||
expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete custom entry 1' }));
|
||||
dialog = await screen.findByRole('dialog', { name: 'Delete custom entry' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete entry' }));
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/^Entry /)).toHaveLength(1));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText('Entry 1')).toHaveFocus();
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
@@ -500,6 +505,120 @@ test('custom entries can be added, edited, reordered and deleted with confirmati
|
||||
await waitFor(() => expect(screen.queryByLabelText(/Section name for custom:/)).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
const contentOutline = {
|
||||
sections: [{
|
||||
key: 'experience', title: 'Experience', kind: 'entries', bullets: [], tags: [], entries: [
|
||||
{ key: 'job-1', title: 'Engineer', subtitle: 'First Co', meta: '2021-01 – Present', bullets: ['Built APIs'], tags: [] },
|
||||
{ key: 'job-2', title: 'Lead', subtitle: 'Second Co', bullets: ['Led teams'], tags: [] },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
test('[P5-10 PRES-C01 C07] source-backed entry edits identify their scope and save only this CV', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }), contentOutline);
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
|
||||
const entry = screen.getByRole('group', { name: 'Engineer' });
|
||||
expect(entry).not.toHaveTextContent('2021-01 – Present');
|
||||
expect(within(entry).getAllByText('Using Career Profile')).toHaveLength(3);
|
||||
fireEvent.change(within(entry).getByLabelText('Title'), { target: { value: ' ' } });
|
||||
expect(screen.getByRole('group', { name: 'Engineer' })).toBe(entry);
|
||||
expect(within(entry).getByText('Blank uses Career Profile')).toBeInTheDocument();
|
||||
fireEvent.change(within(entry).getByLabelText('Title'), { target: { value: 'Platform Engineer' } });
|
||||
fireEvent.change(within(entry).getByLabelText('Subtitle'), { target: { value: 'CV-specific employer label' } });
|
||||
fireEvent.change(within(entry).getByLabelText('Bullet points (one per line)'), { target: { value: 'Reviewed CV bullet\nSecond CV bullet' } });
|
||||
expect(within(entry).getAllByText('Customized for this CV')).toHaveLength(3);
|
||||
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({ overrides: {
|
||||
'job-1': { title: 'Platform Engineer', subtitle: 'CV-specific employer label', bullets: ['Reviewed CV bullet', 'Second CV bullet'] },
|
||||
} }),
|
||||
}));
|
||||
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
|
||||
expect(contentOutline.sections[0].entries[0]).toEqual({ key: 'job-1', title: 'Engineer', subtitle: 'First Co', meta: '2021-01 – Present', bullets: ['Built APIs'], tags: [] });
|
||||
});
|
||||
|
||||
test('[P5-10 PRES-C07 C10] resetting one entry field preserves omission, sibling override and order and supports undo', async () => {
|
||||
const settings = { ...variant.settings,
|
||||
sections: [{ key: 'experience', itemOrder: ['job-2', 'job-1'] }],
|
||||
overrides: {
|
||||
'job-1': { title: 'CV Engineer', subtitle: 'CV employer', bullets: ['CV bullet'], hidden: true },
|
||||
'job-2': { title: 'CV Lead', bullets: ['Sibling item override'] },
|
||||
},
|
||||
};
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings } }), contentOutline);
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
|
||||
const entry = screen.getByRole('group', { name: 'CV Engineer' });
|
||||
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Title' }));
|
||||
expect(within(entry).getByLabelText('Title')).toHaveValue('Engineer');
|
||||
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('CV employer');
|
||||
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('CV bullet');
|
||||
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Subtitle' }));
|
||||
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('First Co');
|
||||
fireEvent.click(within(entry).getByRole('button', { name: 'Use Career Profile: Bullet points (one per line)' }));
|
||||
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('Built APIs');
|
||||
expect(within(entry).getAllByText('Using Career Profile')).toHaveLength(3);
|
||||
expect(within(entry).getByRole('button', { name: 'Show Engineer entry' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(within(entry).getByLabelText('Title')).toHaveValue('Engineer');
|
||||
expect(within(entry).getByLabelText('Subtitle')).toHaveValue('First Co');
|
||||
expect(within(entry).getByLabelText('Bullet points (one per line)')).toHaveValue('CV bullet');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
const saved = mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any;
|
||||
expect(saved.settings.overrides).toEqual({ 'job-1': { hidden: true }, 'job-2': settings.overrides['job-2'] });
|
||||
expect(saved.settings.sections[0].itemOrder).toEqual(['job-2', 'job-1']);
|
||||
});
|
||||
|
||||
test('[P5-10 PRES-C07 C10 E08] omitted entries remain editable and retain their draft after a failed save', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings, overrides: { 'job-1': { hidden: true } } } } }), contentOutline);
|
||||
mockedApi.put.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: { ...variant, version: 5 } } as any);
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Experience' }));
|
||||
const entry = screen.getByRole('group', { name: 'Engineer' });
|
||||
expect(within(entry).getByText('Omitted', { exact: true })).toBeInTheDocument();
|
||||
expect(within(entry).getByLabelText('Title')).toBeEnabled();
|
||||
expect(within(entry).getByLabelText('Subtitle')).toBeEnabled();
|
||||
const bullets = within(entry).getByLabelText('Bullet points (one per line)');
|
||||
fireEvent.change(bullets, { target: { value: 'Retained omitted-entry draft' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Save failed');
|
||||
expect(bullets).toHaveValue('Retained omitted-entry draft');
|
||||
expect(within(entry).getByRole('button', { name: 'Show Engineer entry' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(mockedApi.put).toHaveBeenLastCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
settings: expect.objectContaining({ overrides: { 'job-1': { hidden: true, bullets: ['Retained omitted-entry draft'] } } }),
|
||||
}));
|
||||
});
|
||||
|
||||
test('[P5-10 PRES-C01 C07] section content reset inherits source without changing omission or presentation', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
|
||||
sections: [{ key: 'skills', items: ['CV-specific skill'], hidden: true, presentation: 'bubble' }],
|
||||
} } }), { sections: [{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['C#', 'Norsk'], bullets: [], entries: [] }] });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Skills' }));
|
||||
const section = screen.getByRole('group', { name: 'Skills' });
|
||||
expect(within(section).getByLabelText('Items (one per line)')).toHaveValue('CV-specific skill');
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Use Career Profile content' }));
|
||||
expect(within(section).getByLabelText('Items (one per line)')).toHaveValue('C#\nNorsk');
|
||||
expect(within(section).getByRole('button', { name: 'Show Skills section' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
const saved = mockedApi.put.mock.calls[mockedApi.put.mock.calls.length - 1][1] as any;
|
||||
const row = saved.settings.sections.find((item: any) => item.key === 'skills');
|
||||
expect(row).toEqual(expect.objectContaining({ key: 'skills', hidden: true, presentation: 'bubble' }));
|
||||
expect(row.items).toBeUndefined();
|
||||
expect(mockedApi.put.mock.calls.every(([url]) => url === '/cv/variants/3')).toBe(true);
|
||||
});
|
||||
|
||||
test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any), {
|
||||
sections: [{
|
||||
|
||||
@@ -590,12 +590,19 @@ export const translations = {
|
||||
cvEditorDeleteCustomMessage: "Delete the custom section ‘{title}’ and all of its entries?",
|
||||
cvEditorDeleteCustomTitle: "Delete custom section",
|
||||
cvEditorDeleteSection: "Delete section",
|
||||
cvEditorEditMasterProfile: "Edit master profile",
|
||||
cvEditorMasterProfileHelp: "Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.",
|
||||
cvEditorEditMasterProfile: "Open Career Profile",
|
||||
cvEditorMasterProfileHelp: "Career Profile is your source. Editing wording, order or inclusion here changes only this CV.",
|
||||
cvEditorHeadlineOverride: "Headline override",
|
||||
cvEditorHeadlineHelp: "Blank uses the headline from your master profile.",
|
||||
cvEditorHeadlineHelp: "Blank uses your Career Profile headline. Changes here affect only this CV.",
|
||||
cvEditorSections: "Sections",
|
||||
cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Content comes from your master profile.",
|
||||
cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Omitted content stays editable.",
|
||||
cvEditorUsingSource: "Using Career Profile",
|
||||
cvEditorCustomized: "Customized for this CV",
|
||||
cvEditorCvOnly: "CV-only content",
|
||||
cvEditorEntryScopeHelp: "Changes here affect only this CV. Career Profile stays unchanged.",
|
||||
cvEditorBlankUsesSource: "Blank uses Career Profile",
|
||||
cvEditorResetField: "Use Career Profile: {field}",
|
||||
cvEditorResetFieldHelp: "Replaces this CV field with the current Career Profile value. Other fields, order and inclusion stay unchanged.",
|
||||
cvEditorAddSection: "Add a section",
|
||||
cvEditorAddSectionButton: "Add section",
|
||||
cvEditorAddSectionHelp: "Preset sections use the same editable, reorderable card as every other section.",
|
||||
@@ -642,7 +649,7 @@ export const translations = {
|
||||
cvEditorBulletsHelp: "Bullet points (one per line)",
|
||||
cvEditorItemsHelp: "Items (one per line)",
|
||||
cvEditorContentItemsHelp: "Content (one item per line)",
|
||||
cvEditorResetMasterProfile: "Reset to master profile",
|
||||
cvEditorResetMasterProfile: "Use Career Profile content",
|
||||
cvEditorMoveNamedEntryUp: "Move {entry} entry up",
|
||||
cvEditorMoveNamedEntryDown: "Move {entry} entry down",
|
||||
cvEditorShowEntry: "Show {entry} entry",
|
||||
@@ -3137,12 +3144,19 @@ export const translations = {
|
||||
cvEditorDeleteCustomMessage: "Slette den egendefinerte seksjonen «{title}» og alle oppføringene?",
|
||||
cvEditorDeleteCustomTitle: "Slett egendefinert seksjon",
|
||||
cvEditorDeleteSection: "Slett seksjon",
|
||||
cvEditorEditMasterProfile: "Rediger hovedprofil",
|
||||
cvEditorMasterProfileHelp: "Kontaktopplysninger og karrierehistorikk deles fra hovedprofilen. CV-spesifikke overskrifter, formuleringer, rekkefølge og synlighet beholdes i denne versjonen.",
|
||||
cvEditorEditMasterProfile: "Åpne Karriereprofil",
|
||||
cvEditorMasterProfileHelp: "Karriereprofil er kilden din. Endringer i tekst, rekkefølge og synlighet her gjelder bare denne CV-en.",
|
||||
cvEditorHeadlineOverride: "Egen overskrift",
|
||||
cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra hovedprofilen.",
|
||||
cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra Karriereprofil. Endringer her gjelder bare denne CV-en.",
|
||||
cvEditorSections: "Seksjoner",
|
||||
cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Innholdet kommer fra hovedprofilen.",
|
||||
cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Utelatt innhold kan fortsatt redigeres.",
|
||||
cvEditorUsingSource: "Bruker Karriereprofil",
|
||||
cvEditorCustomized: "Tilpasset denne CV-en",
|
||||
cvEditorCvOnly: "Innhold bare i denne CV-en",
|
||||
cvEditorEntryScopeHelp: "Endringer her gjelder bare denne CV-en. Karriereprofil endres ikke.",
|
||||
cvEditorBlankUsesSource: "Tomt felt bruker Karriereprofil",
|
||||
cvEditorResetField: "Bruk Karriereprofil: {field}",
|
||||
cvEditorResetFieldHelp: "Erstatter dette CV-feltet med gjeldende verdi fra Karriereprofil. Andre felt, rekkefølge og synlighet endres ikke.",
|
||||
cvEditorAddSection: "Legg til seksjon",
|
||||
cvEditorAddSectionButton: "Legg til seksjon",
|
||||
cvEditorAddSectionHelp: "Forhåndsdefinerte seksjoner bruker det samme redigerbare kortet som alle andre seksjoner.",
|
||||
@@ -3189,7 +3203,7 @@ export const translations = {
|
||||
cvEditorBulletsHelp: "Punkter (ett per linje)",
|
||||
cvEditorItemsHelp: "Elementer (ett per linje)",
|
||||
cvEditorContentItemsHelp: "Innhold (ett element per linje)",
|
||||
cvEditorResetMasterProfile: "Tilbakestill til hovedprofil",
|
||||
cvEditorResetMasterProfile: "Bruk innhold fra Karriereprofil",
|
||||
cvEditorMoveNamedEntryUp: "Flytt oppføringen {entry} opp",
|
||||
cvEditorMoveNamedEntryDown: "Flytt oppføringen {entry} ned",
|
||||
cvEditorShowEntry: "Vis oppføringen {entry}",
|
||||
|
||||
@@ -818,6 +818,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [addContentOpen, setAddContentOpen] = useState(false);
|
||||
const addContentButton = useRef<HTMLButtonElement | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
|
||||
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
@@ -902,6 +903,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
customSections: settings.customSections.filter((c) => c.key !== section.key),
|
||||
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
|
||||
});
|
||||
requestAnimationFrame(() => addContentButton.current?.focus());
|
||||
};
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
@@ -964,7 +966,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
|
||||
<Button ref={addContentButton} variant="contained" startIcon={<AddIcon />} onClick={() => setAddContentOpen(true)} sx={{ alignSelf: "center", minWidth: 190 }}>{t("cvEditorAddContent")}</Button>
|
||||
<Dialog open={addContentOpen} onClose={() => setAddContentOpen(false)} fullWidth maxWidth="md" aria-labelledby="cv-add-content-title">
|
||||
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
@@ -1029,6 +1031,12 @@ function SectionRow({
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const sectionLabel = customSection?.title ?? row.title ?? sectionName(row.key, SECTION_LABELS[row.key] ?? row.key, t);
|
||||
const customInputs = useRef<Record<number, HTMLTextAreaElement | null>>({});
|
||||
const addEntryButton = useRef<HTMLButtonElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusCustomEntry = (index: number) => requestAnimationFrame(() => {
|
||||
(customInputs.current[index] ?? addEntryButton.current)?.focus();
|
||||
});
|
||||
const sourceItems = outlineSection?.kind === "tags" ? outlineSection.tags : outlineSection?.bullets ?? [];
|
||||
const editableItems = row.items ?? sourceItems;
|
||||
const customText = customSection?.items.join("\n\n") ?? "";
|
||||
@@ -1044,7 +1052,7 @@ function SectionRow({
|
||||
opacity: dragging ? 0.4 : 1,
|
||||
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
|
||||
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
|
||||
overflow: "hidden", "&:hover": { boxShadow: 1 },
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={0.5} sx={{ px: 0.75, py: 0.5 }}>
|
||||
@@ -1053,10 +1061,9 @@ function SectionRow({
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveSectionUp", { section: sectionLabel })} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveSectionDown", { section: sectionLabel })} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
|
||||
</Stack>
|
||||
<TextField variant="standard" fullWidth value={sectionLabel}
|
||||
<TextField variant="standard" fullWidth value={sectionLabel} sx={{ minWidth: 0 }}
|
||||
onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
|
||||
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": t("cvEditorSectionName", { section: row.key }) } }} />
|
||||
{customSection && <Chip size="small" label={t("cvEditorCustom")} variant="outlined" sx={{ height: 20 }} />}
|
||||
<IconButton size="small" aria-label={t(row.hidden ? "cvEditorShowSection" : "cvEditorHideSection", { section: sectionLabel })} onClick={() => onPatch({ hidden: !row.hidden })}>
|
||||
{row.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
@@ -1070,8 +1077,11 @@ function SectionRow({
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Collapse in={expanded} unmountOnExit>
|
||||
<Box sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||
<Paper variant="outlined" sx={{ p: 1, mb: 1.25, bgcolor: "background.paper" }}>
|
||||
<Box ref={contentRef} sx={{ px: 1.5, py: 1.25, borderTop: "1px solid", borderColor: "divider", bgcolor: "action.hover" }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
<span>{customSection ? t("cvEditorCvOnly") : t("cvEditorEntryScopeHelp")}</span> · <span>{t(row.hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</span>
|
||||
</Typography>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800 }}>{t("cvEditorSectionLayout")}</Typography>
|
||||
@@ -1087,20 +1097,22 @@ function SectionRow({
|
||||
{[1, 2].map((columns) => <Button key={columns} size="small" variant={(row.columns ?? 1) === columns ? "contained" : "outlined"} onClick={() => onPatch({ columns: columns as 1 | 2 })}>{columns}</Button>)}
|
||||
</Stack>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
{customSection ? (
|
||||
<Stack spacing={1}>
|
||||
<TextField select size="small" label={t("cvEditorContentFormat")} value={customSection.contentType ?? "bullets"} onChange={(event) => onUpdateCustom({ contentType: event.target.value as CvCustomSectionSetting["contentType"] })}>
|
||||
<MenuItem value="paragraphs">{t("cvEditorParagraphs")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="entries">{t("cvEditorMultipleEntries")}</MenuItem>
|
||||
</TextField>
|
||||
{customSection.contentType === "entries" ? <Stack spacing={1}>
|
||||
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
|
||||
<TextField fullWidth size="small" multiline minRows={2} label={t("cvEditorEntry", { number: itemIndex + 1 })} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveEntryUp", { number: itemIndex + 1 })} disabled={itemIndex === 0} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveEntryDown", { number: itemIndex + 1 })} disabled={itemIndex === customSection.items.length - 1} onClick={() => onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorDeleteEntryNumber", { number: itemIndex + 1 })} onClick={() => void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
{customSection.items.map((item, itemIndex) => <Stack key={`${customSection.key}-${itemIndex}`} spacing={0.5}>
|
||||
<TextField inputRef={(input) => { customInputs.current[itemIndex] = input; }} fullWidth size="small" multiline minRows={2} label={t("cvEditorEntry", { number: itemIndex + 1 })} value={item} onChange={(event) => onUpdateCustom({ items: customSection.items.map((current, index) => index === itemIndex ? event.target.value : current) })} />
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveEntryUp", { number: itemIndex + 1 })} disabled={itemIndex === 0} onClick={() => { onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex - 1) }); focusCustomEntry(itemIndex - 1); }}><ArrowUpwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveEntryDown", { number: itemIndex + 1 })} disabled={itemIndex === customSection.items.length - 1} onClick={() => { onUpdateCustom({ items: moveItem(customSection.items, itemIndex, itemIndex + 1) }); focusCustomEntry(itemIndex + 1); }}><ArrowDownwardIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorDeleteEntryNumber", { number: itemIndex + 1 })} onClick={() => void confirmAction(t("cvEditorDeleteEntryMessage"), { title: t("cvEditorDeleteEntryTitle"), confirmLabel: t("cvEditorDeleteEntry"), destructive: true }).then((confirmed) => { if (confirmed) { onUpdateCustom({ items: customSection.items.filter((_, index) => index !== itemIndex) }); focusCustomEntry(Math.max(0, Math.min(itemIndex, customSection.items.length - 2))); } })}><DeleteOutlineIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
</Stack>)}
|
||||
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => onUpdateCustom({ items: [...customSection.items, ""] })}>{t("cvEditorAddEntry")}</Button>
|
||||
<Button ref={addEntryButton} size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => { onUpdateCustom({ items: [...customSection.items, ""] }); focusCustomEntry(customSection.items.length); }}>{t("cvEditorAddEntry")}</Button>
|
||||
</Stack> : <RichTextField label={customSection.contentType === "paragraphs" ? t("cvEditorParagraphsHelp") : t("cvEditorBulletsHelp")} minRows={4} value={customSection.contentType === "paragraphs" ? customText : customSection.items.join("\n")}
|
||||
onChange={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={customText} documentLanguage={settings.language} context="This is a custom CV section." onApply={(value) => onUpdateCustom({ items: customSection.contentType === "paragraphs" ? value.split(/\n\s*\n/) : value.split("\n") })} />
|
||||
@@ -1109,8 +1121,14 @@ function SectionRow({
|
||||
<EntryEditor section={outlineSection} row={row} settings={settings} onPatch={onPatch} onUpdateSettings={onUpdateSettings} />
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||
{row.items && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => onPatch({ items: undefined })}>{t("cvEditorResetMasterProfile")}</Button>}
|
||||
<RichTextField label={outlineSection?.kind === "tags" ? t("cvEditorItemsHelp") : t("cvEditorContentItemsHelp")} helperText={t(row.items != null ? "cvEditorCustomized" : "cvEditorUsingSource")} minRows={3} value={editableItems.join("\n")} onChange={(value) => onPatch({ items: value.split("\n") })} />
|
||||
{row.items && <Stack spacing={0.5}>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>
|
||||
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => {
|
||||
const field = contentRef.current?.querySelector<HTMLTextAreaElement>("textarea:not([aria-hidden])");
|
||||
onPatch({ items: undefined }); requestAnimationFrame(() => field?.focus());
|
||||
}}>{t("cvEditorResetMasterProfile")}</Button>
|
||||
</Stack>}
|
||||
<AiSectionAssistant sectionName={sectionLabel} text={editableItems.join("\n")} documentLanguage={settings.language} context="Return one item per line when suggesting a list." onApply={(value) => onPatch({ items: value.split("\n") })} />
|
||||
</Stack>
|
||||
)}
|
||||
@@ -1128,6 +1146,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const fieldRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement | null>>({});
|
||||
// Entry order: itemOrder if set, else master order; always covering every entry key.
|
||||
const orderedKeys: string[] = useMemo(() => {
|
||||
const master = Array.from(new Set(section.entries.map((e) => e.key ?? "").filter(Boolean)));
|
||||
@@ -1149,6 +1168,10 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
const overrides = { ...settings.overrides, [key]: { ...settings.overrides[key], ...patch } };
|
||||
onUpdateSettings({ overrides });
|
||||
};
|
||||
const resetField = (key: string, field: "title" | "subtitle" | "bullets") => {
|
||||
setOverride(key, { [field]: undefined });
|
||||
requestAnimationFrame(() => fieldRefs.current[`${key}:${field}`]?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={0.75}>
|
||||
@@ -1157,39 +1180,52 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
if (!entry) return null;
|
||||
const ov = settings.overrides[key] ?? {};
|
||||
const hidden = !!ov.hidden;
|
||||
const entryLabel = ov.title || entry.title || t("cvEditorUntitled");
|
||||
const entryLabel = ov.title?.trim() || entry.title || t("cvEditorUntitled");
|
||||
return (
|
||||
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
|
||||
<Box key={key} {...drag.getItemProps(i)} role="group" aria-label={entryLabel}
|
||||
sx={{
|
||||
p: 0.75, opacity: drag.dragIndex === i ? 0.4 : 1, cursor: "grab",
|
||||
py: 1.25, minWidth: 0, opacity: drag.dragIndex === i ? 0.4 : 1,
|
||||
borderBottom: "1px solid", borderBottomColor: "divider",
|
||||
borderTop: drag.overIndex === i && drag.dragIndex !== i ? "2px solid" : undefined,
|
||||
borderTopColor: "primary.main", transition: "opacity 150ms ease",
|
||||
}}>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<Stack direction="row" alignItems="center" gap={0.5} flexWrap="wrap">
|
||||
<DragIndicatorIcon sx={{ fontSize: 16, color: "text.disabled" }} aria-hidden />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, flex: 1 }}>{entryLabel}</Typography>
|
||||
<Box sx={{ flex: "1 1 10rem", minWidth: 0 }}>
|
||||
<Typography component="p" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{entryLabel}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t(hidden ? "cvEditorOmitted" : "cvEditorIncluded")}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryUp", { entry: entryLabel })} disabled={i === 0} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i - 1) })}><ArrowUpwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorMoveNamedEntryDown", { entry: entryLabel })} disabled={i === orderedKeys.length - 1} onClick={() => onPatch({ itemOrder: moveItem(orderedKeys, i, i + 1) })}><ArrowDownwardIcon sx={{ fontSize: 14 }} /></IconButton>
|
||||
<IconButton size="small" aria-label={t(hidden ? "cvEditorShowEntry" : "cvEditorHideEntry", { entry: entryLabel })} onClick={() => setOverride(key, { hidden: !hidden })}>
|
||||
{hidden ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{!hidden && (
|
||||
<Stack spacing={0.75} sx={{ mt: 0.75 }}>
|
||||
<TextField size="small" fullWidth label={t("cvEditorEntryTitle")} value={ov.title ?? entry.title ?? ""}
|
||||
onChange={(e) => setOverride(key, { title: e.target.value })} />
|
||||
<TextField size="small" fullWidth label={t("cvEditorEntrySubtitle")} value={ov.subtitle ?? entry.subtitle ?? ""}
|
||||
onChange={(e) => setOverride(key, { subtitle: e.target.value })} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1 }}>
|
||||
{(["title", "subtitle"] as const).map((field) => {
|
||||
const fieldLabel = t(field === "title" ? "cvEditorEntryTitle" : "cvEditorEntrySubtitle");
|
||||
return <Box key={field} sx={{ minWidth: 0 }}>
|
||||
<TextField inputRef={(input) => { fieldRefs.current[`${key}:${field}`] = input; }} size="small" fullWidth label={fieldLabel} value={ov[field] ?? entry[field] ?? ""}
|
||||
placeholder={entry[field]} helperText={t(ov[field]?.trim() ? "cvEditorCustomized" : ov[field] != null ? "cvEditorBlankUsesSource" : "cvEditorUsingSource")}
|
||||
onChange={(e) => setOverride(key, { [field]: e.target.value })} />
|
||||
{ov[field] != null && <Button size="small" onClick={() => resetField(key, field)}>{t("cvEditorResetField", { field: fieldLabel })}</Button>}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
<Box ref={(box: HTMLDivElement | null) => { fieldRefs.current[`${key}:bullets`] = box?.querySelector<HTMLTextAreaElement>("textarea:not([aria-hidden])") ?? null; }}>
|
||||
<RichTextField label={t("cvEditorBulletsHelp")} minRows={2}
|
||||
helperText={t(ov.bullets != null ? "cvEditorCustomized" : "cvEditorUsingSource")}
|
||||
value={(ov.bullets ?? entry.bullets).join("\n")}
|
||||
onChange={(v) => setOverride(key, { bullets: v.split("\n") })} />
|
||||
</Box>
|
||||
{ov.bullets != null && <Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resetField(key, "bullets")}>{t("cvEditorResetField", { field: t("cvEditorBulletsHelp") })}</Button>}
|
||||
{(ov.title != null || ov.subtitle != null || ov.bullets != null) && <Typography variant="caption" color="text.secondary">{t("cvEditorResetFieldHelp")}</Typography>}
|
||||
<AiSectionAssistant sectionName={`${section.title}: ${entryLabel}`} text={(ov.bullets ?? entry.bullets).join("\n")} documentLanguage={settings.language} context={`Entry title: ${ov.title ?? entry.title ?? ""}. Subtitle: ${ov.subtitle ?? entry.subtitle ?? ""}. Return one bullet per line.`} onApply={(value) => setOverride(key, { bullets: value.split("\n") })} />
|
||||
{ov.bullets && (
|
||||
<Button size="small" onClick={() => setOverride(key, { bullets: undefined })}>{t("cvEditorResetMasterBullets")}</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||