fix(ui): preserve CV preview keyboard access across viewports

This commit is contained in:
cesnimda
2026-09-12 00:55:04 +02:00
parent ebd4a3e245
commit 806eee4e1e
35 changed files with 721 additions and 8 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Redesign traceability
Successor planning checkpoint: [Phase 3 impact matrix and architecture](../phase-3/PRESERVATION_IMPACT_MATRIX.md). It maps all 57 contracts to proposed destinations/validation, without changing their requirements or claiming implemented/visual parity. The subsequent [P4-01 trace sheet](../phase-4/p4-01/TRACEABILITY.md) and [P4-02 named-control/modal record](../phase-4/p4-02/README.md) link shared foundation changes to regression and representative visual evidence. P4-03 remains unstarted.
Successor planning checkpoint: [Phase 3 impact matrix and architecture](../phase-3/PRESERVATION_IMPACT_MATRIX.md). It maps all 57 contracts to proposed destinations/validation, without changing their requirements or claiming implemented/visual parity. The subsequent [P4-01 trace sheet](../phase-4/p4-01/TRACEABILITY.md), [P4-02 named-control/modal record](../phase-4/p4-02/README.md) and [P4-03 preview keyboard record](../phase-4/p4-03/README.md) link shared foundation changes to regression and representative visual evidence. P4-04 remains unstarted.
Phase 2 starts at `2d0f131`. The [preservation matrix](PRESERVATION_MATRIX.md) is the stable baseline: **PRES-E01…PRES-A03**, in the original ledger's groups (not one contiguous numeric range). Old references such as C07 remain valid aliases. Subdivide a later workflow with a suffix, for example `PRES-C07.override-reset`; do not reuse an ID or silently change its meaning.
+2 -1
View File
@@ -5,5 +5,6 @@ Phase 4 starts from `f696f23` on `codex/ui-redesign-phase4`. The 57 preservation
- [P4-01 semantic colors, focus and loaded fixtures](p4-01/README.md)
- [P4-01 preservation and visual evidence](p4-01/TRACEABILITY.md)
- [P4-02 named controls and modal state](p4-02/README.md)
- [P4-03 builder preview keyboard boundary](p4-03/README.md)
P4-02 is locally validated after `115441a` and the separate documentation restructure `682a302` on the same Phase 4 branch. Its approved scope is named controls and modal/loading state; P4-03 remains unstarted. No route/layout redesign, B07, backend/schema behaviour changes or production deployment is included. F04 remains open pending actual production ingress evidence.
P4-02 is locally validated after `115441a` and the separate documentation restructure `682a302` on the same Phase 4 branch. P4-03 follows the P4-02 checkpoint `ebd4a3e`, adding preview keyboard access and preserving focused panes through viewport transitions. P4-04 remains unstarted. No route/layout redesign, CV margin/template rendering correction, B07, backend/schema behaviour changes or production deployment is included. F04 remains open pending actual production ingress evidence.
+133
View File
@@ -0,0 +1,133 @@
# P4-03 — Builder preview keyboard boundary
Implementation and local checkpoint validation complete on 2026-09-12, from `ebd4a3e24541dabd7ff3b732c07fa972ab843f96` on `codex/ui-redesign-phase4`. Approved scope: [P4-03](../../phase-3/PHASE4_IMPLEMENTATION_PLAN.md), coordinated with archived audit A02/F14. No concurrent D01 work was present in the clean starting tree. No save/revision refactor, renderer replacement, route/layout redesign, P4-04 or B07. F04 remains open. The commit containing this record is the implementation checkpoint (`git log -- docs/redesign/phase-4/p4-03`).
## Reconciliation before implementation
The actual `CvBuilderEditor` owns preview fitting, measurement, pagination, mobile panes, settings history and saves. Its overflowing page container has neither a name nor an explicit keyboard tab stop. Visible titled page iframes and the hidden measurement iframe are separate from that container. Keep their document rendering/links and the existing P4-01 focus CSS; do not add focusability to every Box or replace the preview engine.
Phase 3's future independent editor panes, manual zoom and broader content architecture are not added by this ticket. Existing compact presentation changes at 900px, while the MUI grid changes at 1024px; preserve and test both boundaries rather than silently unifying them. Prior records retain the CV overflow warning, not a requirement to repair document pagination/print layout in this keyboard slice. Phase 2's F15 shorthand for preview focus is superseded by Phase 1/3's corrected F14/A02 attribution.
## Change-impact mapping before application changes
| PRES contracts | Retained behaviour → P4-03 change | Existing automated protection | New automated / visual evidence required |
|---|---|---|---|
| PRES-C06, PRES-E08 | Server-rendered pages, fit and mobile Edit/Preview → named keyboard-scrollable preview | cv-builder-deep-link; redesign-preservation; semantic-foundations | Real one/multipage keyboard entry, scroll, exit; EN/NB, themes, phone/tablet/desktop transitions; visible shared focus |
| PRES-C07, PRES-C10, PRES-C11 | CV-only overrides, local undo/redo, autosave/draft and AI-review lifetime → unchanged through preview focus/resize | cv-builder-deep-link; ai-section-assistant; canonical/sibling browser isolation | Edit/Preview/resize/undo/redo retains current CV settings; native API persistence and canonical/sibling isolation; inherited AI apply/reject tests |
| PRES-C08, PRES-C09, PRES-C12 | Template settings, document metrics/pagination, shared preview/PDF → rendering retained | cvBuilder; CvBuilder backend; long searchable PDF and public PDF browser; B03 admission | Native loaded pages and preview retry; full existing render/PDF suite; no template/printed-paper change |
| PRES-E09 | Account-owned preview/settings and private-tree lifetime → unchanged | session-boundary; account-isolation; backend ownership/session tests | Full inherited security/browser checks; no new preview network path |
These scoped rows extend the [57-group preservation matrix](../../phase-2/PRESERVATION_MATRIX.md) and [Phase 3 impact matrix](../../phase-3/PRESERVATION_IMPACT_MATRIX.md). They do not replace any contract or claim exhaustive visual coverage. Tests were added before the corresponding application changes; executed results follow below.
## Reproduction and implementation
The initial native Chromium regression against P4-02 loaded a real one-page CV and failed because there was no `Live preview` region. After adding that boundary, a second regression narrowed a focused desktop preview to 390px and reproduced its disappearance: the stored mobile preference still selected Edit. These match the archived A02 requirement for a named scroll container and focus preservation across viewport transitions. The before logs are retained locally as `.artifacts/p4-03-before.log` and `p4-03-transition-before.log`.
Only `job-tracker-ui/src/views/CvBuilderEditor.tsx` changes application behaviour:
- The existing scroll Box becomes a named `region`, using the existing translated `cvEditorLivePreview` string, with one keyboard tab stop. Native Page Down and Ctrl+Home/End operate on this existing scroll container. No custom key handler, focus trap or preview component is added.
- Focus in either existing pane records that pane in `mobilePane`. A focused preview stays visible when the viewport narrows; a focused editor field stays in Edit. This is a deliberate presentation-preference correction. Existing Edit/Preview buttons and component instances remain, without touching settings, undo stacks, save queues or resource identity.
- P4-01's existing `[tabindex]:focus-visible` rule supplies the 3px semantic outline. No new colour, spacing, theme override or visual component system is added. Both iframe types, sandboxing, document HTML, page measurement/cropping, fit arithmetic and PDF paths remain unchanged.
Retained implementations: `CvBuilderEditor`, `CvCustomizeTab`, native iframes, MUI theme, translations, server renderer and all editor persistence handlers. Refactored/replaced implementations: none. Added capability: explicit keyboard access to the existing preview region and focus-aware reuse of the existing responsive preference; no new CV product feature.
Changed-file inventory: the editor file above; new `job-tracker-ui/e2e/builder-preview-keyboard.spec.ts`; this `p4-03` record, scan evidence and screenshots; successor links in `docs/redesign/phase-2/TRACEABILITY.md` and `docs/redesign/phase-4/README.md`; current status/deferred work in `docs/roadmap/todo.md`. No existing test cases or production files outside the editor are changed.
## Behavioural regression coverage
`job-tracker-ui/e2e/builder-preview-keyboard.spec.ts` adds four real-API workflows: light/English, dark/Bokmål, light/Bokmål and dark/English. Every workflow creates a disposable non-admin account, a canonical profile, a one-page CV and a longer CV. Browser requests cannot leave localhost. Authentication, B02 context, source/variant persistence and rendering use the real local API; one preview POST is deliberately delayed and failed with 503 to test recovery. No model/provider or production request is made.
Each workflow verifies:
1. Real loaded one-page and multipage documents; named region reached through Tab, visible inherited focus and measured outline contrast of at least 3:1 against the adjacent application surface.
2. Page Down changes the preview scroll position; Ctrl+End reaches the bottom and Ctrl+Home returns to the top. Shift+Tab leaves the region, Tab returns, and forward traversal through existing page iframes exits. Titled iframes are retained in the tab sequence.
3. CV-only headline edit/autosave; keyboard Edit/Preview and preview entry at 390, 768 and 899px, then populated scans at 900, 1024 and 1440px. The same editor field/value survives these transitions.
4. Resizing while the preview owns focus retains visible preview focus; resizing while the editor owns focus retains that visible field. Local Undo/Redo still works after the full sequence.
5. A held preview update retains the previous document. Failure keeps the focused region and old content; Retry renders the current settings. Subsequent Redo/save succeeds. Native API reads prove the canonical profile and sibling CV are unchanged.
Existing Jest tests already protect variant loading/denial, preview retry, dirty save, Undo/Redo, AI review and related state. No redundant DOM-attribute-only Jest case was added: browser behaviour is the new missing evidence. Frontend count remains 345, while browser actual passes increase by four from 39 to 43. The existing P2-D01 expected failure remains unchanged and is not counted as a successful frozen-package workflow.
The first forward-traversal test pressed a fixed number of Tab keys and could re-enter after successfully exiting. The corrected test observes each step and stops on exit; recorded traversal includes page 1, page 2, then the outer body for the synthetic no-link document. No iframe was removed from keyboard access to make that test pass.
## Visual and accessibility evidence
Screenshots cover seven states for all four theme/language combinations, with a 1440×900 desktop and 390/768/899×844 narrow viewports. The browser also exercises 900 and 1024px. This preserves the distinct 900px pane and 1024px grid boundaries. Reduced motion is active. Captures wait for a paint cycle; no screenshot-only application styling is applied.
| Combination | Representative screenshots |
|---|---|
| Light / English | [One page](visual/one-page-light-en.png), [last page](visual/multipage-end-light-en.png), [phone](visual/preview-390-light-en.png), [loading](visual/loading-light-en.png), [error](visual/error-light-en.png) |
| Dark / Bokmål | [One page](visual/one-page-dark-nb.png), [last page](visual/multipage-end-dark-nb.png), [phone](visual/preview-390-dark-nb.png), [tablet](visual/preview-768-dark-nb.png), [error](visual/error-dark-nb.png) |
| Light / Bokmål | [One page](visual/one-page-light-nb.png), [phone](visual/preview-390-light-nb.png), [near breakpoint](visual/preview-899-light-nb.png), [error](visual/error-light-nb.png) |
| Dark / English | [One page](visual/one-page-dark-en.png), [phone](visual/preview-390-dark-en.png), [near breakpoint](visual/preview-899-dark-en.png), [error](visual/error-dark-en.png) |
The scope is keyboard access to the document viewport. It is not certification of rendered margins, page breaks, theme/template fidelity or all print content. Printed paper remains white in both application themes. Existing small scaled text on phone, long editor tabs, fixed-shell occlusion during page scrolling and overflow warnings remain visible in evidence; the region's focus sides remain visible. No overflow clipping workaround or page-height/layout change was introduced.
Manual Narrator/NVDA, mobile screen reader, real touch/software keyboard, 200%/400% browser zoom, 320px reflow and forced-colors testing remain unverified. DOM naming, real Chromium keyboard traversal and axe do not substitute for that evidence. Page iframe links/rendered content retain their previous behaviour; the synthetic focus-exit fixture is not an exhaustive screen-reader/link traversal of every template.
[SCAN_RESULTS.json](SCAN_RESULTS.json) records the general-run totals and the final focused run's nodes, incomplete-rule counts and forward Tab traversals. The general run has **74 scans** (38 P4-01, 20 P4-02 and 16 P4-03), with the existing command `list` finding in four scans, mobile workspace `scrollable-region-focusable` in two and light Tooltip `color-contrast` in two. The final focused rerun has 16 scans: 14 without violations and the two explicit light Tooltip findings. No new preview-region violation is reported. This focused rerun repeats the four added cases; it does not add another four cases to the baseline count. All 28 committed screenshots were refreshed during that final run and representative rendered states were inspected in both modes/languages and at desktop/phone/tablet widths.
## Existing defects intentionally deferred
- **P4-03-D01 — shared Tooltip contrast:** a full loaded scan exposed the existing light Redo tooltip at 3.89:1 (white on composite `#81808a`). Its MUI/default colour path and `theme.ts` are unchanged from P4-02. Full findings are retained; the test narrowly recognizes only a `color-contrast` finding targeting `.MuiTooltip-tooltip` in the final post-Redo scan. Every preview rule and other contrast target remains blocking. Repair in a separately scoped shared-theme follow-up; this is not an all-accessibility pass.
- **Existing CV rendering accuracy debt:** Connor reiterated that margins and theme/template output remain incorrect. The minimal one-page fixture also retains the existing horizontal-overflow warning. The future CV rendering work needs concrete expected margin/page/template output and preview/PDF comparison across preserved settings (PRES-C06/C08/C09/C12). This ticket does not fix or claim correctness for that renderer. The earlier statement that the preview will be overhauled is a direction for a dedicated later scope, not authorization to fold it into P4-03 or P4-04.
- **Existing preview state announcements:** updating/unavailable chips and retry remain the current implementation. Their visual recovery and retained focus are tested; a comprehensive preview announcement design/manual AT audit is not added here.
- P4-02-D01–D04, P2-D01, stale-record concurrency and **F04 remain open**. The audit's D01 and redesign P2-D01 are different issues; neither is addressed by pane focus.
## Validation and handoff
The inherited baseline is 916 backend, 345 frontend, 39 browser actual passes plus P2-D01 expected failure, rate-limit 9, ingress 6, PDF admission 1 and Compose/configuration 3. The final application change precedes all complete suite/build checks below. A subsequent capture-only test adjustment scrolls the existing loading/error label into view for its screenshot; it does not change application behaviour or remove assertions.
| Gate | P4-02 baseline | P4-03 final result |
|---|---:|---|
| Backend Release xUnit | 916 pass | 916 pass, 0 failed/skipped |
| Frontend Jest | 345 pass / 73 suites | 345 pass / 73 suites; no cases removed |
| General Chromium | 39 actual pass + P2-D01 expected failure | 43 actual pass + unchanged P2-D01 expected failure; +4 cases |
| Focused preview Chromium | No dedicated fixture | 4 pass; final capture rerun, 3.2 minutes |
| Standalone TypeScript / affected E2E type check / lint | pass | pass; no TS2571 recurrence |
| Frontend production / API Release build | pass | pass; API warnings-as-errors, 0 warnings/errors |
| Account/IP rate-limit HTTP checks | 9 pass | 9 pass |
| Production-mode ingress HTTP checks | 6 pass | 6 pass |
| Shared PDF admission HTTP probe | 1 pass | 1 pass |
| Effective Compose / adversarial configuration | 3 pass | 3 pass |
Reproduction commands, from repository root unless changed below:
```powershell
$env:DOTNET_ROOT='C:/Users/Connor/.dotnet'
& C:/Users/Connor/.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger 'trx;LogFileName=p4-03.trx' --results-directory .artifacts/redesign-p4-03 -- xUnit.parallelizeTestCollections=false xUnit.maxParallelThreads=1
& C:/Users/Connor/.dotnet/dotnet.exe build JobTrackerApi/JobTrackerApi.csproj --configuration Release --no-restore -warnaserror
python scripts/test-ingress.py
Set-Location job-tracker-ui
npm test -- --watchAll=false --runInBand
npm run typecheck
node node_modules/typescript/bin/tsc --project ../.artifacts/p4-03-e2e-tsconfig.json
npm run lint
$env:NEXT_PUBLIC_API_BASE_URL='http://localhost:5302/api'
$env:NEXT_TELEMETRY_DISABLED='1'
npm run build
$env:DOTNET_HOST_PATH='C:/Users/Connor/.dotnet/dotnet.exe'
$env:PLAYWRIGHT_STATIC_EXPORT='true'
$env:PLAYWRIGHT_PREBUILT_EXPORT='true'
$env:P4_03_CAPTURE='true'
$env:PLAYWRIGHT_JSON_OUTPUT_FILE='../.artifacts/p4-03-browser-results.json'
node node_modules/@playwright/test/cli.js test --reporter=list,json
$env:PLAYWRIGHT_JSON_OUTPUT_FILE='../.artifacts/p4-03-final-focused.json'
node node_modules/@playwright/test/cli.js test e2e/builder-preview-keyboard.spec.ts --reporter=list,json
node node_modules/@playwright/test/cli.js test --config=playwright.rate-limits.config.ts
node node_modules/@playwright/test/cli.js test --config=playwright.ingress.config.ts
node node_modules/@playwright/test/cli.js test --config=playwright.pdf.config.ts
Set-Location ..
python scripts/supply-chain.py scan-secrets
git diff --check
git diff --cached --check
```
The ignored E2E type config extends the existing frontend tsconfig, includes only `e2e/builder-preview-keyboard.spec.ts`, disables incremental caching and resolves frontend Node types. No package, dependency, CI, infrastructure, backend, schema or theme change was needed. Logs, isolated SQLite data and authenticated browser traces remain ignored; committed screenshots contain only synthetic data.
The complete general run took 6.7 minutes. Its console reports “44 passed” because the unchanged expected failure is an expected outcome. JSON distinguishes **43 `passed → passed` plus one `failed → failed`**, with no unexpected result. Actual passes comprise 23 pre-P4-01 + 12 P4-01 + 4 P4-02 + 4 P4-03. The full backend suite includes B01–B05 guards; the independent HTTP probes additionally exercise B02/B04 principal/session boundaries, B06 forwarded trust and B03 shared resource admission. None of this is evidence of deployed F04 closure.
Final repository review: the staged tracked-secret scan passed; `git diff --check` and `git diff --cached --check` passed; 117 current/root Markdown documents were checked with zero missing local link targets (historical archive snapshots excluded). The staged non-document diff contains only the existing editor and its new browser fixture. No test skip, expected-failure rename, package/CI modification or unrelated application change is included.
The scoped acceptance criteria are satisfied locally: the existing preview has a named keyboard boundary, native scrolling and exit work, focused panes survive resize, and the existing edit/save/Undo/Redo and ownership regressions remain healthy. The retained tooltip defect and manual/rendering/production gaps above are not claimed closed. Rollback is limited to the three editor attribute/handler changes; there is no data migration or setting-format change.
Recommended next ticket only: **P4-04 — page layout and typography primitives**, under its separate approved consumer scope. No P4-04 implementation, broader CV rendering work, B07 or production deployment is included in this checkpoint.
@@ -0,0 +1,386 @@
{
"generalRun": {
"outcomes": {
"failed -> failed": 1,
"passed -> passed": 43
},
"scanCounts": {
"named-controls-modal-state.spec.ts": 20,
"semantic-foundations.spec.ts": 38,
"builder-preview-keyboard.spec.ts": 16
},
"violationCounts": {
"list": 4,
"color-contrast": 2,
"scrollable-region-focusable": 2
}
},
"finalFocusedRun": {
"scanCounts": {
"builder-preview-keyboard.spec.ts": 16
},
"traversals": [
{
"steps": [
{
"outer": "IFRAME",
"title": "CV preview page 1",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(0px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only æøå</d"
},
{
"outer": "IFRAME",
"title": "CV preview page 2",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(-1122.52px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only"
},
{
"outer": "BODY",
"title": null,
"inner": null
}
],
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/en preview keyboard, pagination and responsive state"
},
{
"steps": [
{
"outer": "IFRAME",
"title": "CV-forhåndsvisning side 1",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(0px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only æøå</d"
},
{
"outer": "IFRAME",
"title": "CV-forhåndsvisning side 2",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(-1122.52px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only"
},
{
"outer": "BODY",
"title": null,
"inner": null
}
],
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/nb preview keyboard, pagination and responsive state"
},
{
"steps": [
{
"outer": "IFRAME",
"title": "CV-forhåndsvisning side 1",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(0px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only æøå</d"
},
{
"outer": "IFRAME",
"title": "CV-forhåndsvisning side 2",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(-1122.52px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only"
},
{
"outer": "BODY",
"title": null,
"inner": null
}
],
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/nb preview keyboard, pagination and responsive state"
},
{
"steps": [
{
"outer": "IFRAME",
"title": "CV preview page 1",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(0px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only æøå</d"
},
{
"outer": "IFRAME",
"title": "CV preview page 2",
"inner": "<body>\n<main class=\"page\" style=\"transform: translateY(-1122.52px);\"><header class=\"header header-plain\"><div class=\"head-text\"><h1 class=\"name\">Åse Eksempel</h1><div class=\"headline\">Reviewed CV-only"
},
{
"outer": "BODY",
"title": null,
"inner": null
}
],
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/en preview keyboard, pagination and responsive state"
}
],
"outcomes": {
"passed -> passed": 4
},
"violationCounts": {
"color-contrast": 2
},
"previewScans": [
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-900-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 9,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1024-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 15,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1440-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 14,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/en preview keyboard, pagination and responsive state",
"violations": [
{
"impact": "serious",
"nodes": [
{
"html": "<div class=\"MuiTooltip-tooltip MuiTooltip-tooltipPlacementBottom css-6mictg\" style=\"opacity: 1; transform: none; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1), transform 133ms cubic-bezier(0.4, 0, 0.2, 1);\">Redo</div>",
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.89 (foreground color: #ffffff, background color: #81808a, font size: 9.0pt (12px), font weight: normal). Expected contrast ratio of 4.5:1",
"target": [
".MuiTooltip-tooltip"
]
}
],
"id": "color-contrast"
}
],
"state": "loaded-final-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 14,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-900-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 9,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1024-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 16,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1440-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 16,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "loaded-final-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 16,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-900-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 9,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1024-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 14,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/nb preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1440-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 14,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] light/nb preview keyboard, pagination and responsive state",
"violations": [
{
"impact": "serious",
"nodes": [
{
"html": "<div class=\"MuiTooltip-tooltip MuiTooltip-tooltipPlacementBottom css-6mictg\" style=\"opacity: 1; transform: none; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1), transform 133ms cubic-bezier(0.4, 0, 0.2, 1);\">Gjør om</div>",
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.89 (foreground color: #ffffff, background color: #81808a, font size: 9.0pt (12px), font weight: normal). Expected contrast ratio of 4.5:1",
"target": [
".MuiTooltip-tooltip"
]
}
],
"id": "color-contrast"
}
],
"state": "loaded-final-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 14,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-900-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 9,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1024-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 16,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "transition-1440-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 16,
"id": "color-contrast"
}
]
},
{
"test": "[PRES-C06 PRES-C07 PRES-C10 PRES-E08] dark/en preview keyboard, pagination and responsive state",
"violations": [],
"state": "loaded-final-axe",
"incomplete": [
{
"nodes": 2,
"id": "aria-prohibited-attr"
},
{
"nodes": 16,
"id": "color-contrast"
}
]
}
]
},
"limits": [
"General run includes the unchanged P2-D01 expected failure.",
"Full scans retain known baseline findings; only the final Redo Tooltip target is narrowly accepted by the new tests.",
"Incomplete rules are not passes. Real screen readers, touch, zoom and rendering accuracy remain separate validation."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+4 -3
View File
@@ -1,13 +1,14 @@
# TODO
Status reconciled: 2026-09-11.
Status reconciled: 2026-09-12.
## Current repository work
- **P4-03 — not started:** review the completed [P4-02 named-control/modal checkpoint](../redesign/phase-4/p4-02/README.md) before authorizing the builder preview keyboard boundary. Preserve its regression coverage and the existing integrity coordination gates.
- **P4-04 — not started:** review the completed [P4-03 preview keyboard checkpoint](../redesign/phase-4/p4-03/README.md) before the separately scoped page-layout/typography work. Preserve existing integrity coordination gates and regression coverage.
- **P2-D01 — open:** repair the SQLite `DateTimeOffset` ordering baseline before package/tailoring restructuring; retain the expected-failure browser oracle until native capture and historical-binary preservation are proven. P2-O01's initial rules-500 behavior also remains unconfirmed.
- **P3-O01/P3-O02 — open:** address analytics population/period labels and navigation/query/error retention when those surfaces are next changed. Evidence is in the current [redesign decision records](../redesign/phase-3/OPEN_DECISIONS.md).
- **P4-02-D01–D04 — existing accessibility/content debt:** command-list semantic children, company save-error feedback outside the modal's accessible subtree, the mobile application-progress scroll region, and the literal AI context `{name}` placeholder. Evidence and owning future slices are in the [P4-02 record](../redesign/phase-4/p4-02/README.md). Do not disable modal background isolation or hide findings.
- **P4-03-D01 — existing shared Tooltip contrast debt:** the light Redo tooltip fails the text contrast target. Evidence and the narrow regression exception are in the [P4-03 record](../redesign/phase-4/p4-03/README.md); repair in a scoped shared-theme follow-up.
- **F04/B06 — open:** complete the authorized main release and production ingress/runtime verification; the browser gate and runner diagnostics remain unresolved. No gate should be bypassed.
## Delivery and feature gates
@@ -21,6 +22,6 @@ Status reconciled: 2026-09-11.
The current builder already includes expandable sections, preset/free-text custom sections, section AI review, Code and other capability-aware templates, accent/typography controls, shared preview/PDF rendering, and extraction confidence handling. Preserve these under PRES-C02/C07/C08/C09/C12; they are not new backlog requirements. See the [functionality inventory](../redesign/audit/FUNCTIONALITY_INVENTORY.md) and [57-group preservation matrix](../redesign/phase-2/PRESERVATION_MATRIX.md).
P4-03's future scope is the loaded preview keyboard/viewport boundary, after review of P4-02. Broader editor changes require their own approved slice and preservation evidence; do not restart already-completed builder work from archived plans.
CV preview margin, pagination and theme/template accuracy still require a dedicated rendering/PDF validation slice, as reiterated by Connor during P4-03. P4-03 only addresses the keyboard/viewport boundary. Define expected output and preserve current settings, templates and preview/PDF relationships before broader editor changes; do not restart already-completed builder features from archived plans.
Completed phase items are intentionally omitted here; their evidence remains recoverable in the pre-cleanup backup and existing archive.
@@ -0,0 +1,192 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test as base, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test";
import { translations } from "../src/i18n/translations";
const apiUrl = "http://localhost:5302/api";
const password = "E2ePassword123!";
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(c => c.name === "XSRF-TOKEN")!.value;
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
}
const test = base.extend<{ owner: { headers: Record<string, string>; shortId: number; longId: number } }>({
owner: async ({ playwright, page, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, "e2e@example.test");
const email = `p403-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = await login(page.request, email);
await context.route("**/*", route => {
const url = new URL(route.request().url());
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
});
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: {
version: "1", contact: { fullName: "Åse Eksempel", headline: "Canonical engineer" }, skills: ["Norsk", "C#"],
} } })).status()).toBe(200);
const ids: number[] = [];
for (const long of [false, true]) {
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: long ? "Erfaring og kvalifikasjoner — æøå" : "One page CV" } });
expect(created.status()).toBe(200);
const cv = await created.json();
const customSections = long ? [{ key: "keyboard-evidence", title: "Erfaring og kvalifikasjoner", contentType: "bullets", items: Array.from({ length: 45 }, (_, i) => `Erfaring ${i + 1}: Samarbeid om tilgjengelige løsninger og pålitelig dokumentasjon for norske arbeidsgivere.`) }] : [];
expect((await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers, data: { name: cv.name, settings: { ...cv.settings, themeId: "minimal", customSections } } })).status()).toBe(200);
ids.push(cv.id);
}
await provide({ headers, shortId: ids[0], longId: ids[1] });
} finally { await admin.dispose(); }
},
});
async function capture(page: Page, info: TestInfo, state: string) {
const path = process.env.P4_03_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-03/visual/${state}.png` : info.outputPath(`${state}.png`);
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path });
await info.attach(state, { path, contentType: "image/png" });
}
async function scan(page: Page, info: TestInfo, state: string) {
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
// P4-03-D01: unchanged light Tooltip foreground/background fails on Redo.
// Retain the full finding; no preview rule or other contrast failure is exempted.
expect(result.violations.filter(v => !(state === "loaded-final" && v.id === "color-contrast" && v.nodes.every(n => n.target.length === 1 && n.target[0] === ".MuiTooltip-tooltip")))).toEqual([]);
}
async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) {
// Traverse the real tab order from a preceding control, not a DOM focus call on the region.
await start.focus();
for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab");
await expect(region).toBeFocused();
const ring = await region.evaluate(node => {
const css = getComputedStyle(node);
const rgba = (value: string) => value.match(/[\d.]+/g)!.map(Number);
const blend = (front: number[], back: number[]) => front.slice(0, 3).map((v, i) => v * (front[3] ?? 1) + back[i] * (1 - (front[3] ?? 1)));
const ancestors: Element[] = [];
for (let el = node.parentElement; el; el = el.parentElement) ancestors.unshift(el);
const surface = ancestors.reduce((back, el) => blend(rgba(getComputedStyle(el).backgroundColor), back), [255, 255, 255]);
const luminance = (rgb: number[]) => rgb.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
const a = luminance(blend(rgba(css.outlineColor), surface));
const b = luminance(surface);
return { width: css.outlineWidth, style: css.outlineStyle, visible: node.matches(":focus-visible"), contrast: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) };
});
expect(ring).toMatchObject({ width: "3px", style: "solid", visible: true });
expect(ring.contrast).toBeGreaterThanOrEqual(3);
}
for (const [mode, language] of [["light", "en"], ["dark", "nb"], ["light", "nb"], ["dark", "en"]] as const) {
test(`[PRES-C06 PRES-C07 PRES-C10 PRES-E08] ${mode}/${language} preview keyboard, pagination and responsive state`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[language];
const suffix = `${mode}-${language}`;
await page.setViewportSize({ width: 1440, height: 900 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
await page.goto(`/career/builder/${owner.shortId}`);
const frame = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace("{page}", "1")}"]`);
await expect(frame.getByText("Åse Eksempel", { exact: true })).toBeVisible();
await expect(page.locator("[data-cv-page]")).toHaveCount(1);
const region = page.getByRole("region", { name: t.cvEditorLivePreview, exact: true });
await expect(region).toBeVisible();
await enterPreview(page, region);
await capture(page, info, `one-page-${suffix}`);
const profileBefore = (await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile;
const siblingBefore = await (await page.request.get(`${apiUrl}/cv/variants/${owner.shortId}`)).json();
await page.goto(`/career/builder/${owner.longId}`);
await expect(frame.getByText("Åse Eksempel", { exact: true })).toBeVisible();
await expect.poll(() => page.locator("[data-cv-page]").count()).toBeGreaterThan(1);
await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click();
const headline = page.getByRole("textbox", { name: t.cvEditorHeadlineOverride, exact: true });
await headline.fill("Reviewed CV-only æøå");
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
await enterPreview(page, region);
const top = await region.evaluate(node => node.scrollTop);
await page.keyboard.press("PageDown");
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(top + 100);
await page.keyboard.press("Control+End");
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
await capture(page, info, `multipage-end-${suffix}`);
await page.keyboard.press("Control+Home");
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0);
await page.keyboard.press("Shift+Tab");
expect(await region.evaluate(node => node.contains(document.activeElement))).toBe(false);
await page.keyboard.press("Tab");
await expect(region).toBeFocused();
// Native iframe content remains reachable. Forward traversal must eventually leave the preview.
const frames = await page.locator("[data-cv-page]").count();
const traversal = [];
for (let i = 0; i < frames * 3 + 5 && await region.evaluate(node => node.contains(document.activeElement)); i++) {
await page.keyboard.press("Tab");
traversal.push(await page.evaluate(() => {
const active = document.activeElement;
return { outer: active?.tagName, title: active?.getAttribute("title"), inner: active instanceof HTMLIFrameElement ? active.contentDocument?.activeElement?.outerHTML.slice(0, 200) : null };
}));
}
await info.attach("forward-tab-traversal", { body: JSON.stringify(traversal, null, 2), contentType: "application/json" });
expect(await region.evaluate(node => node.contains(document.activeElement))).toBe(false);
for (const width of [390, 768, 899, 900, 1024, 1440]) {
await page.setViewportSize({ width, height: width < 900 ? 844 : 900 });
if (width < 900) {
const previewButton = page.getByRole("button", { name: t.cvEditorPreview, exact: true });
await previewButton.focus();
await previewButton.press("Enter");
await expect(region).toBeVisible();
await enterPreview(page, region, previewButton);
await page.keyboard.press("PageDown");
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(0);
await capture(page, info, `preview-${width}-${suffix}`);
await page.getByRole("button", { name: t.cvEditorEdit, exact: true }).press("Enter");
await expect(region).not.toBeVisible();
} else {
await expect(region).toBeVisible();
await enterPreview(page, region);
await scan(page, info, `transition-${width}`);
}
await expect(headline).toHaveValue("Reviewed CV-only æøå");
}
// Resizing while the preview owns focus must not hide that focused pane.
await enterPreview(page, region);
await page.setViewportSize({ width: 390, height: 844 });
await expect(region).toBeVisible();
await expect(region).toBeFocused();
await page.setViewportSize({ width: 1440, height: 900 });
await headline.focus();
await page.setViewportSize({ width: 390, height: 844 });
await expect(headline).toBeVisible();
await expect(headline).toBeFocused();
await page.setViewportSize({ width: 1440, height: 900 });
let failPreview!: () => void;
const heldPreview = new Promise<void>(resolve => { failPreview = resolve; });
await page.route(`${apiUrl}/cv/preview`, async route => {
await heldPreview;
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic preview unavailable" }) });
}, { times: 1 });
await page.getByRole("button", { name: t.cvEditorUndo, exact: true }).click();
await expect(headline).toHaveValue("");
await expect(page.getByText(t.cvEditorUpdatingPreview, { exact: true })).toBeVisible();
await enterPreview(page, region);
await page.getByText(t.cvEditorUpdatingPreview, { exact: true }).evaluate(node => node.scrollIntoView({ block: "center" }));
await capture(page, info, `loading-${suffix}`);
failPreview();
await expect(page.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toBeVisible();
await expect(region).toBeFocused();
await expect(frame.getByText("Reviewed CV-only æøå", { exact: true })).toBeVisible();
await page.getByText(t.cvEditorPreviewUnavailable, { exact: true }).evaluate(node => node.scrollIntoView({ block: "center" }));
await capture(page, info, `error-${suffix}`);
await page.getByRole("button", { name: t.cvEditorRetryPreview, exact: true }).click();
await expect(frame.getByText("Canonical engineer", { exact: true })).toBeVisible();
await expect(page.getByText(t.cvEditorPreviewUnavailable, { exact: true })).not.toBeVisible();
await page.getByRole("button", { name: t.cvEditorRedo, exact: true }).click();
await expect(headline).toHaveValue("Reviewed CV-only æøå");
await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible();
expect((await (await page.request.get(`${apiUrl}/cv/variants/${owner.longId}`)).json()).settings.headline).toBe("Reviewed CV-only æøå");
expect(await (await page.request.get(`${apiUrl}/cv/variants/${owner.shortId}`)).json()).toEqual(siblingBefore);
expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(profileBefore);
await scan(page, info, "loaded-final");
});
}
+3 -3
View File
@@ -449,7 +449,7 @@ export default function CvBuilderEditor() {
</Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Paper onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" }, border: "1px solid", borderColor: "divider" }}>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
<Tab label={t("cvEditorTemplate")} />
@@ -466,7 +466,7 @@ export default function CvBuilderEditor() {
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper>
<Box sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
<Box onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>{t("cvEditorLivePreview")}</Typography>
{previewing && <Chip size="small" label={t("cvEditorUpdatingPreview")} variant="outlined" />}
@@ -478,7 +478,7 @@ export default function CvBuilderEditor() {
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Stack spacing={2} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>