feat(ui): migrate public CV entry and recovery
This commit is contained in:
@@ -37,4 +37,8 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
Exact direct receipt is retained at `.artifacts/phase7/p7-03/import-history/final/push-receipt.json`; the portable results include its raw hash and observation. No runtime bytes were replaced by this metadata successor.
|
||||
|
||||
| P7-03 observed push receipt / P7-04 entry | `5680c0031f377b0caaae79c0cec900f58e409886` | Records the actual preceding 5d6e3235 clean normal push, closes implemented P7-03 conditionally and establishes exact library/public entry baselines. Entry static PASS (four commands0,11docs/400 local links0errors/57groups/32routes); normal push directly verified2026-10-12 17:35:00.882169UTC: localHEAD=origin=live5680c00, clean worktree, local/origin/live main27657f3 unchanged. No product/runtime/count change |
|
||||
| P7-04 CV library | Pending normal commit/push after final staged gate | [Library report](p7-04/README.md#cv-library-implemented-result), [finite results](p7-04/evidence/library-results.json): affected38/4/native8/editor4/backend72/types/lint/build PASS;19 actually reviewed PNGs;19 all-enabled scans0/64 plus12 retained editor scans0/122 with manual conditions. Current10 sources/75-file export verified against exact frozen final; +12/0 gives660/93 and +8 gives241 derived only. Public entry remains pending; no main/renderer/backend/AI activation |
|
||||
| P7-04 CV library | `fcacae7384b4a38c2675e6527a33cc1d09f4b505` | [Library report](p7-04/README.md#cv-library-implemented-result), [finite results](p7-04/evidence/library-results.json): affected38/4/native8/editor4/backend72/types/lint/build PASS;19 actually reviewed PNGs;19 all-enabled scans0/64 plus12 retained editor scans0/122 with manual conditions. Current10 sources/75-file export verified against exact frozen final; +12/0 gives660/93 and +8 gives241 derived only. Final staged hygiene PASS18:03:12UTC (four commands0,11docs406links0errors/57+32); normal push directly verified18:03:41.481787UTC localHEAD=origin=livefcacae7, clean worktree/main27657f3 unchanged. Actual receipt recorded by public successor. Public entry remains pending; no main/renderer/backend/AI activation |
|
||||
|
||||
| P7-04 public CV entry | Actual hash follows observed normal push | [Public results](p7-04/evidence/public-results.json): fresh38/3/native8/types/lint/build/fidelity16/36+8/24 PASS;18 actual image reviews;18 all-enabled scans0/12 sandbox-frame incompletes and12 separate settings scans0/223 retained. Current7 source snapshots/74-file export verified. +13/0 gives673/93 and native+8 gives249 derived only. Final staged hygiene/normal push pending; no main/schema/renderer/model activation |
|
||||
|
||||
Peer review found no product defect. The native geometry test now additionally asserts rendered width equals min(A4, viewport minus32px) within1px; focused existing eight-sample journey passes after that assertion, with fresh browser types. Product source/build/fidelity remain unchanged. Seven new screenshots exactly match previously reviewed SHA bytes; the differing1920 capture was displayed/reviewed. Extra eight scans0/8 frame-tested are repeated evidence, not additional cases. The first targeted runner grep matched0 tests/exit1; raw failure retained and exact-title grep corrected, no skip or expected failure.
|
||||
|
||||
@@ -130,3 +130,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### P7-04 library finite disposition
|
||||
|
||||
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
|
||||
|
||||
### P7-04 public evidence
|
||||
|
||||
Public18 top-document all-enabled scans0/12 retain sandbox frame-tested occurrences: generated document context is not fully scanned or human accepted. Keep scripts withheld; M09/M11 remain. Final settings12 WCAG-tagged scans0/223 retain24 disabled Undo/Redo span labels,12 short-text badges,161 overlap and26 partially obscured contexts. Explicit reload observes owner revocation; no live cross-client revocation claim. Actual18 public images and four final PDF comparisons reviewed, not all60 pages. Fresh fidelity16/36+8/24 preserves exact tolerances, no production font/photo/print or provider clearance. RD21/old orphan/concurrency/partial-write/production obligations unchanged; library server-template metadata localisation remains P7-11 residual.
|
||||
|
||||
@@ -148,3 +148,7 @@ Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5
|
||||
### P7-04 library finite disposition
|
||||
|
||||
[Library results](p7-04/evidence/library-results.json) record fresh affected38/4 (+12/0), ordinary native8, existing editor/gallery consumer4 with actual PDF200, unchanged-binary backend72 and types/lint/build PASS on _fYf-IvM_ULd3ZPdUW-8h. All19 final PNGs actually reviewed; finite root overflow0. Library19 all-enabled scans0/64 and editor12 retained-scope scans0/122 remain contextually qualified: badge/overlap/modal portals, disabled Undo/Redo wrappers and obscured print headings retain M09/M11/manual requirements. Server template names/categories remain English metadata in NB and are P7-11 localisation residual. Meaningful unit/native RED and peer fixes preserve draft/error/current-locale/managed-focus behavior; no assertion/rule waiver. Implemented library CONDITIONAL GO with final hygiene/push pending; public entry/full P7-04 pending. All57 statuses/32 routes unchanged; frontend660/93/browser241/backend1,034 derived only, full573/89/backend1,033/sidecar49 inherited until P7-12. RD21/canonical concurrency, old orphan/frozen/output/provider/human/release debt remains; P5-13 withheld/main NO-GO/Phase8 separate.
|
||||
|
||||
### P7-04 public finite disposition
|
||||
|
||||
[Public results](p7-04/evidence/public-results.json) establish C05/C12/E09 anonymous sharing/download/revocation/rotated-slug/private-boundary and unchanged source/settings/version/sibling safeguards. Fresh38/3/native8/fidelity16/36+8/24/types/lint/build pass;18 PNGs actually inspected. Sandbox without scripts retains12 frame-tested incompletes; final settings12 WCAG-tagged scans0/223 remain qualified. All57 whole statuses stay56 PARTIAL/C11 DEFERRED,32 routes retained. Derived673/93 and249 are not fresh integrated totals. M09/M11/human/device/output/provider/release obligations remain; production NO-GO/P5-13 withheld/Phase8 separate. Public final hygiene and normal push pending; library fcacae7 verified.
|
||||
|
||||
@@ -12,7 +12,7 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
| [P7-01](p7-01/README.md) | Authentication and account-security presentation | Public sub-slice CONDITIONAL GO, pushed8ad720d with direct receipt; account implementation/runtime/actual8-image review CONDITIONAL GO (573/89 +6/22/10); pushedad7b419 with final static PASS/direct receipt19:16:15UTC; main unchanged |
|
||||
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections, hygiene PASS and d21501aa push verified23:55:12UTC. Separate SQLite evidence-list repair91 guards/HTTP1/Release passed, pushed01281f52/direct receipt00:06:47UTC with unfinished Career work preserved. Career affected46/3/native14/preservation2/types/lint/build and actual22-image review pass; implemented checkpoint CONDITIONAL GO with99 incompletes/manual gates. Career final hygiene PASS (four commands0,9docs347links0errors); normally pushed8b4d5c2/direct localHEAD=origin=live receipt05:51:04UTC, clean worktree/main unchanged |
|
||||
| [P7-03](p7-03/README.md) | Career structured editing and import review | Source-ID and structured-editor commits pushed; final import/history runtime/design CONDITIONAL GO: affected76/6, native35, backend98/seeder24, types/lint/build PASS;33/54 final images inspected. Import17 scans0/302, editor13 scans0/377, Career22 scans0/99 retained. Final staged hygiene PASS; normal push5d6e3235 directly verified17:28:54UTC with clean worktree/main unchanged. RD21/human/provider/output gates remain;57/32 unchanged |
|
||||
| [P7-04](p7-04/README.md) | CV library and public entry | P7-03 implemented CONDITIONAL GO/push verified; approved library then public-entry boundaries; entry recorded |
|
||||
| [P7-04](p7-04/README.md) | CV library and public entry | Implemented CONDITIONAL GO; library fcacae7 push verified. Public affected38/3/native8/types/lint/build and fresh fidelity16/36+8/24 PASS;18 images actually reviewed, frame-tested12/manual gates retained. Public final hygiene/normal push pending |
|
||||
| P7-05 | Discovery, Companies, Offers and Reminders | Existing domain owners and Jobs return context; planned |
|
||||
| P7-06 | Remaining Application forms and global correspondence | Existing seven-section workspace, immutable packages and draft/send owners; planned |
|
||||
| P7-06F | Deferred `followMode` reviewed composer | Optional separately reviewed mail slice; **DEFERRED** until mode→draft/review action and fallback are explicitly decided |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# P7-04 — CV library and public entry
|
||||
|
||||
**Entry CONDITIONAL GO; implementation and acceptance PENDING.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): one CV library commit, then one public-entry commit. Reuse the existing gallery, editor, confirmation/prompt, theme and feedback owners.
|
||||
**Implemented scope CONDITIONAL GO; library pushed, public final hygiene PASS and normal push pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries): one CV library commit, then one public-entry commit. Reuse the existing gallery, editor, confirmation/prompt, theme and feedback owners.
|
||||
|
||||
[Entry evidence](evidence/entry.json) records the actual clean P7-03 normal push5d6e3235, direct development equality/main unchanged and eight exact committed Git-blob baselines. P7-03 affected76/6/native35/backend98/seeder24 and static checks pass with finite visual/axe/manual qualifications. Latest whole frontend573/89/backend1,033/sidecar49 remain inherited;648/93 frontend,233 ordinary browser and1,034 backend are derived expectations. P7-12 must run fresh integrated regression.
|
||||
|
||||
@@ -24,3 +24,25 @@ All **19 final PNGs were displayed and reviewed** across a finite EN/Bokmål/lig
|
||||
New library **19 all-enabled scans0 violations/64 incomplete occurrences** retain19 invisible-badge short-text,17 modal/menu hidden-focus and28 background overlap. Existing editor **12 retained-scope scans0/122** retain24 disabled Undo/Redo wrapper aria labels,12 invisible-badge short-text,78 overlap and8 obscured iframe-heading occurrences. Full vectors remain; neither scope establishes human screen-reader/modal/contrast acceptance. M09/M11 and all M01–M12 device/zoom/output/provider gates remain. RD21 canonical concurrency/partial-write risk and historical frozen/orphan/output debt are unchanged.
|
||||
|
||||
Twelve new cases in the existing suite give **660/93 frontend derived only**; eight new ordinary browser cases give **241 derived only**. Latest whole frontend573/89, backend1,033 and sidecar49 are inherited; backend1,034 is derived. P7-12 must run fresh integrated regression. All57 statuses remain56 PARTIAL/C11 DEFERRED,32 routes retained; P5-13 stays withheld. No backend/schema/renderer/PDF geometry or Phase8 work. Production NO-GO/main unchanged.
|
||||
|
||||
### Observed library push / public-entry start
|
||||
|
||||
Library implemented scope **CONDITIONAL GO** is normally pushed as `fcacae7384b4a38c2675e6527a33cc1d09f4b505`. Direct verification2026-10-12 18:03:41.481787UTC proves localHEAD=origin=live development, clean worktree and unchanged local/origin/live main27657f3. Final staged hygiene passes (four commands0,11docs406links0errors/57groups32routes); affected38/4/native8/editor4/backend72/types/lint/build and actual19-image review pass with manual/axe/localisation/production qualifications retained. [Public entry](evidence/public-entry.json) records exact preceding Git-blob baselines and this observed receipt. Public migration/final acceptance is pending; completing the library does not qualify Phase7 or production.
|
||||
|
||||
### Public CV implemented result
|
||||
|
||||
**P7-04 implemented scope CONDITIONAL GO; public final hygiene/normal push pending.** [Public results](evidence/public-results.json) retain final source, raw commands/reports, failed attempts and exact **74-file** `CzJrqceuLUrp0d6604-dJ` export. Fresh affected **38/3**, eight new ordinary anonymous browser cases, app/browser types, lint and production build PASS. Existing public suite1→14 (+13/0), browser+8 gives **673/93 frontend and249 browser derived only**; whole573/89/backend1,033/sidecar49 remain inherited until P7-12. The bundler emits53 JS files versus library54; other file-type counts match. Routes remain32, separately checked; output count is not a fixed capability budget.
|
||||
|
||||
PageHeader and shared feedback provide localized EN/Bokmål loading, missing/revoked-link and transient error states with explicit keyboard Retry. Atomic slug-owned read state hides prior HTML/PDF/error/name/height before the next effect and ignores late requests. Slugs remain one encoded API/PDF segment. Locale changes leave source facts and request counts unchanged. Existing Next metadata keeps Jobbjakt during loading; loaded name changes the title and owned cleanup restores the prior title without overwriting a newer route. No new saving, publication or canonical-editing owner is introduced.
|
||||
|
||||
Real HTTP anonymous public HTML/PDF200 with `%PDF` bytes, noindex, private401/numeric404 and revoked/rotated-link404 are verified. Owner source/settings/revisions and private sibling remain unchanged. Explicit reload observes revocation; live cross-client invalidation is not claimed. Harness history exercises slug/back/forward state without adding product navigation. All **18 final public PNGs were displayed and reviewed**, including balanced320/360/390/768/1024/1280-landscape/1440/1920 EN/Bokmål/light/dark and two long-document samples. Chrome wraps without horizontal overflow; white A4 canvas, original scaling/measurement/sandbox remain.
|
||||
|
||||
[Fresh final fidelity](evidence/public-fidelity-results.json) passes **16 fixtures/36 pages plus8/24** after the final source/build; native corpus1/settings4 also pass. Exact membership and existing dimension0.5mm/position2mm/font0.5pt/RGB1/255 tolerances are unchanged.236 exact corpus files are frozen; four final comparison PNGs (long-nb-code first/last and dark-nb-after first/last) were actually reviewed. Not all60 pages or44 native settings PNGs were visually inspected; production font/photo/print qualification remains.
|
||||
|
||||
Public **18 all-enabled top-document scans0 violations/12 incomplete occurrences** are entirely `frame-tested`: axe could not fully inspect the sandboxed document. This is an open document accessibility qualification, not full CV conformance; do not enable scripts to quiet the scan. Final settings **12 WCAG-tagged scans0/223** separately retain24 disabled Undo/Redo span labels,12 badge short-text,161 overlapping and26 partially obscured contexts. All node vectors are retained. M09/M11/human screen-reader and all M01–M12 device/zoom/output/provider gates remain unverified.
|
||||
|
||||
Meaningful RED4/6 and title RED13/1 are frozen separately. Initial native6/2 reflects a30s second-scan timeout plus an exact initial-title fixture mismatch with the verified Next metadata owner. The fixture now asserts Jobbjakt and uses90s for that two-scan case; no assertion/rule relaxation or skip. An intermediate38/3 passed but lint failed a captured-ref cleanup warning; final captured originalTitle fixes the warning without suppression. Final eight ordinary cases pass with retries0. All57 whole-group statuses remain56 PARTIAL/C11 DEFERRED; C05/C12/E09 have finite sharing/revocation/source/export proof. RD21 concurrency/partial writes, historical output/orphan debt and release gates remain. P5-13 withheld, main unchanged/production NO-GO, Phase8 separate.
|
||||
|
||||
Peer review found no product defect. The native geometry test now additionally asserts rendered width equals min(A4, viewport minus32px) within1px; focused existing eight-sample journey passes after that assertion, with fresh browser types. Product source/build/fidelity remain unchanged. Seven new screenshots exactly match previously reviewed SHA bytes; the differing1920 capture was displayed/reviewed. Extra eight scans0/8 frame-tested are repeated evidence, not additional cases. The first targeted runner grep matched0 tests/exit1; raw failure retained and exact-title grep corrected, no skip or expected failure.
|
||||
|
||||
Final public [staged hygiene](evidence/public-static-checks.json) PASS at2026-10-12 22:24:04UTC: four commands0,11docs/412 local links0errors,57groups/32routes. Normal development push pending; actual hash and direct remote receipt follow in the next checkpoint.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"checkpoint": "P7-04",
|
||||
"slice": "CV library",
|
||||
"recordedUtc": "2026-10-12T18:03:01.494389+00:00",
|
||||
"acceptance": "IMPLEMENTED_SCOPE_CONDITIONAL_GO_STATIC_AND_NORMAL_PUSH_PENDING",
|
||||
"acceptance": "IMPLEMENTED_SCOPE_CONDITIONAL_GO_NORMAL_PUSH_VERIFIED",
|
||||
"entryReceipt": {
|
||||
"observedUtc": "2026-10-12T17:35:00.882169+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
@@ -3273,5 +3273,106 @@
|
||||
"freshIntegratedAcceptance": "P7-12 pending"
|
||||
},
|
||||
"preservation": "All57 remain56 PARTIAL/C11 DEFERRED;32 routes;C05/C12/E09 plus source/version/frozen/export guards supported by finite proof. No source/settings/API/schema/renderer/model/provider/save-contract/production activation.",
|
||||
"remaining": "Public entry boundary pending; full P7-04 pending. Shared gallery editor consumer default disabled=false and original frame4 pass."
|
||||
"remaining": "Public entry boundary pending; full P7-04 pending. Shared gallery editor consumer default disabled=false and original frame4 pass.",
|
||||
"normalPushReceipt": {
|
||||
"observedUtc": "2026-10-12T18:03:41.481787+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"originDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"liveDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"finalStaticChecks": "docs/redesign/phase-7/p7-04/evidence/library-static-checks.json"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"checkpoint": "P7-04",
|
||||
"slice": "Public entry",
|
||||
"entry": "CONDITIONAL GO; migration pending",
|
||||
"libraryPushReceipt": {
|
||||
"observedUtc": "2026-10-12T18:03:41.481787+00:00",
|
||||
"branch": "codex/ui-redesign-phase4",
|
||||
"head": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"originDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"liveDevelopment": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": true,
|
||||
"pendingPaths": [],
|
||||
"commands": [
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"branch",
|
||||
"--show-current"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "codex/ui-redesign-phase4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"HEAD"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/codex/ui-redesign-phase4"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"rev-parse",
|
||||
"origin/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "27657f31131cd95d42f0e1290853ec9c9b3959a4\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"ls-remote",
|
||||
"origin",
|
||||
"refs/heads/codex/ui-redesign-phase4",
|
||||
"refs/heads/main"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "fcacae7384b4a38c2675e6527a33cc1d09f4b505\trefs/heads/codex/ui-redesign-phase4\n27657f31131cd95d42f0e1290853ec9c9b3959a4\trefs/heads/main\n",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
},
|
||||
{
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain=v1"
|
||||
],
|
||||
"exitCode": 0,
|
||||
"stdout": "",
|
||||
"stderr": "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"baselines": [
|
||||
{
|
||||
"source": "job-tracker-ui/src/views/PublicCvPage.tsx",
|
||||
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/views/PublicCvPage.tsx",
|
||||
"sha256": "9b32e53f92359052e69583a90526fdebce2f2df02c80369d0d9e6748ad1a6210",
|
||||
"bytes": 3574,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/public-cv-page.test.tsx",
|
||||
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/public-cv-page.test.tsx",
|
||||
"sha256": "f9f96d2cea8689ce795f998553db7741f4672e32c542d9b1b920be3b893fad1c",
|
||||
"bytes": 1581,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/App.tsx",
|
||||
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/App.tsx",
|
||||
"sha256": "b7032f52def046a6f6104839a0acb6e6bd8c85b9cdfb73b6912a0923a85a0b16",
|
||||
"bytes": 27290,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/components/ViewStateNotice.tsx",
|
||||
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/components/ViewStateNotice.tsx",
|
||||
"sha256": "7b6c3e6c311860897eb5327936dbb129e860781929cfd9a4d4e17f81c607c70c",
|
||||
"bytes": 2312,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
},
|
||||
{
|
||||
"source": "job-tracker-ui/src/layout/PageLayout.tsx",
|
||||
"blobCommit": "fcacae7384b4a38c2675e6527a33cc1d09f4b505",
|
||||
"path": ".artifacts/phase7/p7-04/public/entry-source/job-tracker-ui/src/layout/PageLayout.tsx",
|
||||
"sha256": "31c7510446382039cd24b96e5709d5d8dfd00a0b84fd319a53183d1da6c2efa4",
|
||||
"bytes": 2280,
|
||||
"hashPolicy": "Exact Git blob bytes"
|
||||
}
|
||||
],
|
||||
"libraryAcceptance": "Conditional with human/context/provider/output/production qualifications",
|
||||
"dependencies": "Keep exact fixedA4/white iframe/sandbox/noindex/anonymous public HTML+PDF guards; prescribed16/36+8/24 when preview touched; no backend/model/save/Phase8/main work"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-04",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-12T22:24:04.886934+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:24:01.388438+00:00",
|
||||
"finishedUtc": "2026-10-12T22:24:04.240173+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T222401Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:24:04.240675+00:00",
|
||||
"finishedUtc": "2026-10-12T22:24:04.325036+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T222401Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:24:04.325036+00:00",
|
||||
"finishedUtc": "2026-10-12T22:24:04.415611+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T222401Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-12T22:24:04.415611+00:00",
|
||||
"finishedUtc": "2026-10-12T22:24:04.546953+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T222401Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 11,
|
||||
"relativeLinks": 412,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 32
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -86,7 +86,7 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
- [x] P7-01 public authentication and separate account/security presentation (implemented scope CONDITIONAL GO; account pushedad7b419/direct receipt19:16:15UTC): public sub-slice CONDITIONAL GO, pushed `8ad720d`, direct remote=HEAD verified2026-10-11 18:50:24UTC, main unchanged; affected61/native22/isolated SMTP2/helper6/auth subset87 pass. [Checkpoint record](../redesign/phase-7/p7-01/README.md) retains manual/provider/production gates. Fresh account full573/89, native6/public22/auth-boundaries10 and types/lint/build pass; all eight final post-copy PNGs actually inspected and source/context evidence frozen. Account85 and public31 current axe incompletes remain qualified; final staged finite hygiene passed; normal account pushad7b419 directly verified2026-10-11 19:16:15UTC with localHEAD=origin=live equality and clean worktree at observation, main unchanged.
|
||||
- [x] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push8b4d5c2 directly verified05:51:04UTC, localHEAD=origin=live/clean worktree/main unchanged. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
|
||||
- [x] P7-03 Career structured editor and import/history: Implemented P7-03 scope is **CONDITIONAL GO**. The normal import/history push `5d6e32358c79cc8cfe505013fc79995940079767` was directly verified at 2026-10-12 17:28:54.912933UTC: local HEAD=origin=live development, clean worktree, local/origin/live main unchanged `27657f31131cd95d42f0e1290853ec9c9b3959a4`. Fresh affected76/6, ordinary native35, backend98/seeder24, types/lint/build and staged hygiene pass; 33/54 retained final images were inspected. Import17 scans0/302, editor13 scans0/377 and Career22 scans0/99 remain qualified. RD21 concurrency/partial-write recovery, old orphan records and human/device/provider/output requirements remain open. All57 groups remain56 PARTIAL/C11 DEFERRED,32 routes retained. 648/93 frontend and233 browser are derived only; latest whole573/89 remains inherited. Production NO-GO, P5-13 withholding and Phase8 separation remain unchanged.
|
||||
- [ ] P7-04 CV library and public entry: Library finite implementation CONDITIONAL GO (38/4 unit,8 native,4 editor,72 backend,19 actual PNG reviews; types/lint/build pass); staged hygiene/normal push and separate public entry pending. Manual/M09/M11/production qualifications retained.
|
||||
- [x] P7-04 CV library and public entry implemented scope CONDITIONAL GO: library fcacae7 pushed/verified; public38/3 affected/native8/fidelity16/36+8/24/types/lint/build PASS,18 actual public images reviewed. Public final hygiene/normal push pending; sandbox frame-tested12 and human/device/production qualifications retained.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders.
|
||||
- [ ] P7-06 remaining Application forms and global correspondence.
|
||||
- [ ] P7-06F optional reviewed followMode composer: retain deferral unless its required action/fallback decision is established.
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Page, type Response, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { CvVariant } from '../src/cvBuilder';
|
||||
import { emptyStructuredCv } from '../src/profileCv';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
const a4Width = 210 / 25.4 * 96;
|
||||
const a4Height = 297 / 25.4 * 96;
|
||||
type Owner = { api: APIRequestContext; id: string; headers: Record<string, string> };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type Documents = { published: CvVariant; sibling: CvVariant };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> {
|
||||
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
||||
expect(csrf).toBeDefined();
|
||||
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
|
||||
const test = base.extend<{ owner: Owner }>({
|
||||
owner: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
|
||||
expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const admin = await playwright.request.newContext();
|
||||
const api = await playwright.request.newContext();
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const email = `p7-public-cv-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: administrator.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const owner = { api, ...await login(api, email) };
|
||||
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
|
||||
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 provide(owner);
|
||||
} finally { await api.dispose(); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
async function seed(owner: Owner, long = false): Promise<Documents> {
|
||||
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
|
||||
fullName: 'Åse Nordlys — recorded owner-reviewed identity', headline: 'Recorded canonical professional context',
|
||||
email: 'reviewed-professional-contact@example.test', location: 'Trondheim, Norge' },
|
||||
summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'],
|
||||
skills: ['C#', 'React', 'Norsk'],
|
||||
jobs: Array.from({ length: long ? 14 : 1 }, (_, index) => ({ id: `public-source-job-${index}`, title: `Recorded professional role ${index + 1}`,
|
||||
company: `Recorded Norwegian organisation ${index + 1}`, start: 'Jan 2020', end: 'Dec 2023',
|
||||
bullets: long ? ['Recorded implementation responsibility with owner-reviewed professional context across reliable services and accessible interfaces.',
|
||||
'Recorded collaboration with colleagues on documented delivery and operational responsibilities across Norwegian organisations.',
|
||||
'Recorded review and maintenance of existing services, preserving factual source material without inventing metrics or qualifications.'] : ['Recorded owner-reviewed source evidence.'], skills: ['C#'] })),
|
||||
};
|
||||
await json(await owner.api.put(`${apiUrl}/career/profile`, { headers: owner.headers,
|
||||
data: { profile, cvText: 'Exact owner-reviewed original source — æøå\nRecorded work and qualifications.\n' } }));
|
||||
const variant = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
|
||||
data: { name: 'Recorded shared CV', settings: { themeId: 'minimal', language: 'en', showPhoto: false, showPageNumbers: false, showIcons: true,
|
||||
sections: [], customSections: [], headline: 'Recorded CV-specific shared presentation',
|
||||
overrides: { 'public-source-job-0': { title: 'CV-specific reviewed public title', bullets: ['CV-specific reviewed public evidence.'] } } } } }));
|
||||
const sibling = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
|
||||
data: { name: 'Recorded private sibling', settings: { themeId: 'minimal', showPhoto: false, showPageNumbers: false, showIcons: true,
|
||||
sections: [], overrides: {}, customSections: [], headline: 'Recorded alternate CV presentation' } } }));
|
||||
const published = await publish(owner, variant.id, true);
|
||||
return { published, sibling };
|
||||
}
|
||||
|
||||
async function publish(owner: Owner, id: number, isPublic: boolean) {
|
||||
return json<CvVariant>(await owner.api.put(`${apiUrl}/cv/variants/${id}/public`, { headers: owner.headers, data: { isPublic } }));
|
||||
}
|
||||
|
||||
async function preserved(owner: Owner, documents: Documents) {
|
||||
const current = await json<CvVariant>(await owner.api.get(`${apiUrl}/cv/variants/${documents.published.id}`));
|
||||
return { canonical: await json<unknown>(await owner.api.get(`${apiUrl}/career/profile`)),
|
||||
sourceHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/career/profile/versions`)),
|
||||
selected: { name: current.name, settings: current.settings, version: current.version, jobApplicationId: current.jobApplicationId },
|
||||
selectedHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/cv/variants/${documents.published.id}/versions`)),
|
||||
sibling: await json<CvVariant>(await owner.api.get(`${apiUrl}/cv/variants/${documents.sibling.id}`)),
|
||||
siblingHistory: await json<unknown[]>(await owner.api.get(`${apiUrl}/cv/variants/${documents.sibling.id}/versions`)) };
|
||||
}
|
||||
|
||||
async function enter(page: Page, slug: string, sample: Sample = desktop) {
|
||||
await page.addInitScript(({ language, theme }) => {
|
||||
if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
|
||||
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/cv/${slug}?source=phase7-public#preserved`);
|
||||
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
|
||||
if (stored.language !== sample.language || stored.theme !== sample.theme) {
|
||||
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample);
|
||||
await page.reload();
|
||||
}
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
||||
await expect(page.getByRole('heading', { level: 1, name: translations[sample.language].publicCvTitle, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function ready(page: Page, language: Sample['language']) {
|
||||
const frame = page.getByTitle(translations[language].publicCvTitle, { exact: true });
|
||||
await expect(frame).toBeVisible();
|
||||
await expect.poll(() => frame.evaluate(element => {
|
||||
const doc = (element as HTMLIFrameElement).contentDocument;
|
||||
return doc?.readyState === 'complete' && !!doc.body.textContent?.includes('CV-specific reviewed public title');
|
||||
})).toBe(true);
|
||||
await expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
|
||||
await expect(page.getByRole('link', { name: translations[language].publicCvDownloadPdf, exact: true })).toBeVisible();
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow');
|
||||
return frame;
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, documentExpected = true) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
|
||||
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
overflowElements: [...document.querySelectorAll('body *')]
|
||||
.filter(element => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden' && !element.closest('[hidden],[aria-hidden="true"]'))
|
||||
.map(element => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: (element.textContent ?? '').slice(0, 100), left: rect.left, right: rect.right, width: rect.width }; })
|
||||
.filter(element => element.width > 0 && (element.left < -1 || element.right > innerWidth + 1)).slice(0, 20),
|
||||
}));
|
||||
const frame = page.getByTitle(translations[sample.language].publicCvTitle, { exact: true });
|
||||
const documentMetrics = documentExpected ? await frame.evaluate(async element => {
|
||||
const iframe = element as HTMLIFrameElement;
|
||||
const doc = iframe.contentDocument!;
|
||||
await doc.fonts.ready;
|
||||
const style = getComputedStyle(iframe);
|
||||
const rect = iframe.getBoundingClientRect();
|
||||
const wrapper = iframe.parentElement!;
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
return { cssWidth: parseFloat(style.width), cssHeight: parseFloat(style.height), renderedWidth: rect.width, renderedHeight: rect.height,
|
||||
left: rect.left, right: rect.right, scale: new DOMMatrix(style.transform).a, frameBackground: style.backgroundColor,
|
||||
wrapperWidth: wrapperRect.width, wrapperHeight: wrapperRect.height, wrapperLeft: wrapperRect.left, wrapperRight: wrapperRect.right,
|
||||
documentLang: doc.documentElement.lang, documentWidth: doc.documentElement.clientWidth,
|
||||
documentScrollWidth: doc.documentElement.scrollWidth, contentHeight: doc.body.scrollHeight,
|
||||
documentScrollHeight: doc.documentElement.scrollHeight, reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches };
|
||||
}) : null;
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-public-cv-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, documentMetrics, ariaSnapshot: await page.locator('body').ariaSnapshot(),
|
||||
axe: { scope: 'All enabled rules plus target-size; complete iframe/incomplete contexts retained', violations: axe.violations, incomplete: axe.incomplete,
|
||||
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Automated local Chromium; no human screen reader, physical touch/software keyboard, actual browser zoom or production font/photo qualification.',
|
||||
'Document locale is separate from app locale. CSS-pixel A4/white-canvas measurements are not manual print, Linux-font or PDF pagination-fidelity acceptance.',
|
||||
'Same-origin sandbox and noindex are retained; any untestable iframe axe contexts remain incomplete rather than being hidden.'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow).toBe(0);
|
||||
if (documentMetrics) {
|
||||
expect(Math.abs(documentMetrics.cssWidth - a4Width)).toBeLessThan(0.05);
|
||||
expect(Math.abs(documentMetrics.renderedWidth - Math.min(a4Width, geometry.width - 32))).toBeLessThan(1);
|
||||
expect(documentMetrics.cssHeight).toBeGreaterThanOrEqual(a4Height - 0.05);
|
||||
expect(documentMetrics.documentScrollWidth - documentMetrics.documentWidth).toBeLessThanOrEqual(1);
|
||||
expect(documentMetrics.left).toBeGreaterThanOrEqual(0);
|
||||
expect(documentMetrics.right).toBeLessThanOrEqual(geometry.width + 1);
|
||||
expect(documentMetrics.left).toBeGreaterThanOrEqual(documentMetrics.wrapperLeft - 1);
|
||||
expect(documentMetrics.right).toBeLessThanOrEqual(documentMetrics.wrapperRight + 1);
|
||||
expect(Math.abs(documentMetrics.renderedWidth - documentMetrics.cssWidth * documentMetrics.scale)).toBeLessThan(1);
|
||||
expect(Math.abs(documentMetrics.wrapperWidth - documentMetrics.renderedWidth)).toBeLessThan(1);
|
||||
expect(Math.abs(documentMetrics.wrapperHeight - documentMetrics.renderedHeight)).toBeLessThan(1);
|
||||
expect(documentMetrics.cssHeight).toBeGreaterThanOrEqual(documentMetrics.contentHeight - 1);
|
||||
expect(documentMetrics.frameBackground).toBe('rgb(255, 255, 255)');
|
||||
expect(documentMetrics.reducedMotion).toBe(true);
|
||||
} else await expect(frame).toHaveCount(0);
|
||||
expect(axe.violations).toEqual([]);
|
||||
return documentMetrics;
|
||||
}
|
||||
|
||||
test('[P7-04 PRES-C05 C07 C12 A02] anonymous shared CV keeps sandbox noindex private denial and actual keyboard PDF download', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const documents = await seed(owner);
|
||||
const before = await preserved(owner, documents);
|
||||
const response = await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`);
|
||||
const shared = await json<{ html: string; name: string }>(response);
|
||||
expect(response.headers()['x-robots-tag']).toBe('noindex, nofollow');
|
||||
expect(shared.html).toContain('CV-specific reviewed public title');
|
||||
expect((await page.request.get(`${apiUrl}/cv/variants/${documents.published.id}`)).status()).toBe(401);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${documents.sibling.publicSlug}`)).status()).toBe(404);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.id}`)).status()).toBe(404);
|
||||
await enter(page, documents.published.publicSlug);
|
||||
const frame = await ready(page, 'en');
|
||||
expect(await frame.getAttribute('srcdoc')).toBe(shared.html);
|
||||
expect(await page.title()).toContain(shared.name);
|
||||
const downloadResponse = page.waitForResponse(result => result.url() === `${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`);
|
||||
const downloadEvent = page.waitForEvent('download');
|
||||
const link = page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true });
|
||||
await expect(link).toHaveAttribute('href', `${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`);
|
||||
await link.focus(); await page.keyboard.press('Enter');
|
||||
const downloaded = await downloadEvent;
|
||||
const pdfResponse = await downloadResponse;
|
||||
expect(pdfResponse.status()).toBe(200); expect(pdfResponse.headers()['content-type']).toContain('application/pdf');
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
const downloadedPath = await downloaded.path(); expect(downloadedPath).not.toBeNull();
|
||||
const pdf = await readFile(downloadedPath!);
|
||||
expect(pdf.length).toBeGreaterThan(1_000); expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
await info.attach('public-cv-actual-keyboard-download.pdf', { body: pdf, contentType: 'application/pdf' });
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
await capture(page, info, desktop, 'anonymous-download');
|
||||
await info.attach('public-cv-anonymous-contract', { body: JSON.stringify({ privateStatus: 401, unknownNumericSlugStatus: 404,
|
||||
privateSiblingSlugStatus: 404, pdfStatus: pdfResponse.status(), pdfBytes: pdf.length, fileName: downloaded.suggestedFilename(),
|
||||
qualification: 'Actual anonymous HTML and local Chromium PDF bytes, triggered by keyboard Enter on the real download link. This does not establish print pagination, deployed Linux fonts/photos or production acceptance.' }), contentType: 'application/json' });
|
||||
await page.evaluate(() => {
|
||||
history.pushState(null, '', '/login');
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
await expect(page.getByTitle(translations.en.publicCvTitle, { exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveTitle('Jobbjakt');
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C05 C12] owner revocation removes anonymous view and PDF; republishing rotates the revoked link', async ({ page, owner }, info) => {
|
||||
const documents = await seed(owner);
|
||||
const before = await preserved(owner, documents);
|
||||
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
|
||||
await enter(page, documents.published.publicSlug, sample); await ready(page, 'nb');
|
||||
await publish(owner, documents.published.id, false);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`)).status()).toBe(404);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`)).status()).toBe(404);
|
||||
await page.reload();
|
||||
await expect(page.getByText(translations.nb.publicCvUnavailableTitle, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(translations.nb.publicCvUnavailableBody, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: translations.nb.retry, exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: translations.nb.publicCvDownloadPdf, exact: true })).toHaveCount(0);
|
||||
await capture(page, info, sample, 'revoked', false);
|
||||
const republished = await publish(owner, documents.published.id, true);
|
||||
expect(republished.publicSlug).not.toBe(documents.published.publicSlug);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}`)).status()).toBe(404);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${documents.published.publicSlug}/pdf`)).status()).toBe(404);
|
||||
await enter(page, republished.publicSlug, sample); await ready(page, 'nb');
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
await info.attach('public-cv-revocation-readback', { body: JSON.stringify({ oldSlug: documents.published.publicSlug, newSlug: republished.publicSlug,
|
||||
qualification: 'Actual owner publication PUTs and anonymous old-link HTML/PDF404; settings, versions, source and private sibling are unchanged. Existing open content is refreshed explicitly to observe revocation; no claim of live cross-client revocation events.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C12 E08] transient error retries by keyboard with held real read polite progress and stable H1 focus', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const documents = await seed(owner);
|
||||
const before = await preserved(owner, documents);
|
||||
const sample: Sample = { language: 'nb', theme: 'dark', width: 390, height: 844 };
|
||||
let reads = 0;
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`**/api/public-cv/${documents.published.publicSlug}`, async route => {
|
||||
reads++;
|
||||
if (reads === 1) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled public CV read unavailable' }) });
|
||||
if (reads === 2) await held;
|
||||
return route.continue();
|
||||
});
|
||||
await enter(page, documents.published.publicSlug, sample);
|
||||
await expect(page.getByText(translations.nb.publicCvLoadFailedTitle, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(translations.nb.publicCvLoadFailedBody, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: translations.nb.publicCvDownloadPdf, exact: true })).toHaveCount(0);
|
||||
await capture(page, info, sample, 'transient-failure', false);
|
||||
const title = page.getByRole('heading', { level: 1, name: translations.nb.publicCvTitle, exact: true });
|
||||
try {
|
||||
await page.getByRole('button', { name: translations.nb.retry, exact: true }).focus(); await page.keyboard.press('Enter');
|
||||
await expect.poll(() => reads).toBe(2);
|
||||
await expect(title).toBeFocused();
|
||||
await expect(page.getByRole('progressbar', { name: translations.nb.loading, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toHaveText(translations.nb.loading);
|
||||
await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
await page.keyboard.press('Enter'); expect(reads).toBe(2);
|
||||
} finally { release(); }
|
||||
await ready(page, 'nb'); await expect(title).toBeFocused();
|
||||
await expect(page.getByText(translations.nb.publicCvLoadFailedTitle, { exact: true })).toHaveCount(0);
|
||||
expect(reads).toBe(2); expect(await preserved(owner, documents)).toEqual(before);
|
||||
await capture(page, info, sample, 'explicit-recovery');
|
||||
await info.attach('public-cv-controlled-retry', { body: JSON.stringify({ reads,
|
||||
qualification: 'First GET is a controlled500; only keyboard Retry reaches the held then continued actual HTTP read. Successful document HTML is not fabricated; no automatic retry or source mutation.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C12 E08] initial held anonymous read announces loading without a document or download', async ({ page, owner }, info) => {
|
||||
const documents = await seed(owner);
|
||||
const before = await preserved(owner, documents);
|
||||
const sample: Sample = { language: 'en', theme: 'dark', width: 360, height: 844 };
|
||||
let reads = 0;
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`**/api/public-cv/${documents.published.publicSlug}`, async route => { reads++; await held; return route.continue(); });
|
||||
try {
|
||||
await enter(page, documents.published.publicSlug, sample);
|
||||
await expect.poll(() => reads).toBe(1);
|
||||
await expect(page.getByRole('progressbar', { name: translations.en.loading, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toHaveText(translations.en.loading);
|
||||
await expect(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveCount(0);
|
||||
expect(await page.title()).toBe('Jobbjakt');
|
||||
await capture(page, info, sample, 'initial-held-read', false);
|
||||
} finally { release(); }
|
||||
await ready(page, 'en'); expect(reads).toBe(1);
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C12 E08] unknown and numeric slugs use localized unavailable states without Retry or PDF controls', async ({ page, owner }, info) => {
|
||||
const documents = await seed(owner);
|
||||
const before = await preserved(owner, documents);
|
||||
const samples: Sample[] = [
|
||||
{ language: 'en', theme: 'light', width: 320, height: 844 },
|
||||
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
|
||||
];
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
const sample = samples[index];
|
||||
const slug = index === 0 ? `missing-${owner.id}` : String(documents.published.id);
|
||||
expect((await page.request.get(`${apiUrl}/public-cv/${slug}`)).status()).toBe(404);
|
||||
await enter(page, slug, sample);
|
||||
const t = translations[sample.language];
|
||||
await expect(page.getByText(t.publicCvUnavailableTitle, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t.publicCvUnavailableBody, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: t.publicCvDownloadPdf, exact: true })).toHaveCount(0);
|
||||
await capture(page, info, sample, 'unavailable', false);
|
||||
}
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C12] same-document slug change removes prior HTML title and download before the next actual response', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const documents = await seed(owner);
|
||||
const alternate = await publish(owner, documents.sibling.id, true);
|
||||
const before = await preserved(owner, documents);
|
||||
await enter(page, documents.published.publicSlug); await ready(page, 'en');
|
||||
let reads = 0;
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`**/api/public-cv/${alternate.publicSlug}`, async route => { reads++; await held; return route.continue(); });
|
||||
try {
|
||||
await page.evaluate(slug => {
|
||||
history.pushState(null, '', `/cv/${slug}?source=phase7-public-route#preserved`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, alternate.publicSlug);
|
||||
await expect.poll(() => reads).toBe(1);
|
||||
await expect(page.getByTitle(translations.en.publicCvTitle, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveCount(0);
|
||||
expect(await page.title()).toBe('Jobbjakt');
|
||||
await capture(page, info, desktop, 'changed-slug-held', false);
|
||||
} finally { release(); }
|
||||
const alternateFrame = page.getByTitle(translations.en.publicCvTitle, { exact: true });
|
||||
await expect(alternateFrame).toBeVisible();
|
||||
await expect.poll(() => alternateFrame.getAttribute('srcdoc')).toContain('Recorded alternate CV presentation');
|
||||
expect(await alternateFrame.getAttribute('srcdoc')).not.toContain('Recorded CV-specific shared presentation');
|
||||
await expect(page.getByRole('link', { name: translations.en.publicCvDownloadPdf, exact: true })).toHaveAttribute('href', `${apiUrl}/public-cv/${alternate.publicSlug}/pdf`);
|
||||
await page.goBack(); await ready(page, 'en');
|
||||
await page.goForward();
|
||||
await expect.poll(() => page.getByTitle(translations.en.publicCvTitle, { exact: true }).getAttribute('srcdoc')).toContain('Recorded alternate CV presentation');
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
await info.attach('public-cv-same-document-transition', { body: JSON.stringify({ originalSlug: documents.published.publicSlug, alternateSlug: alternate.publicSlug, reads,
|
||||
qualification: 'A test-harness browser History pushState/popstate establishes a same-document route change; no product navigation is added. Back/forward then use actual browser history. Both documents and GETs are real; late-response isolation also requires focused unit evidence.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C12 E08] finite public width locale theme matrix preserves A4 white document and scaled wrapper boundaries', async ({ page, owner }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const documents = await seed(owner);
|
||||
const before = await preserved(owner, documents);
|
||||
const samples: Sample[] = [
|
||||
{ language: 'nb', theme: 'light', width: 320, height: 844 },
|
||||
{ language: 'en', theme: 'dark', width: 360, height: 844 },
|
||||
{ language: 'nb', theme: 'dark', width: 390, height: 844 },
|
||||
{ language: 'en', theme: 'light', width: 768, height: 1024 },
|
||||
{ language: 'nb', theme: 'light', width: 1024, height: 768 },
|
||||
{ language: 'en', theme: 'dark', width: 1280, height: 320 },
|
||||
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
|
||||
{ language: 'en', theme: 'light', width: 1920, height: 1080 },
|
||||
];
|
||||
for (const sample of samples) {
|
||||
await enter(page, documents.published.publicSlug, sample); await ready(page, sample.language);
|
||||
const link = page.getByRole('link', { name: translations[sample.language].publicCvDownloadPdf, exact: true });
|
||||
await page.getByRole('heading', { level: 1 }).focus(); await page.keyboard.press('Tab');
|
||||
await expect(link).toBeFocused();
|
||||
await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded();
|
||||
await capture(page, info, sample, 'loaded');
|
||||
}
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
await info.attach('public-cv-finite-matrix', { body: JSON.stringify({ samples,
|
||||
qualification: 'Eight balanced viewport/locale/theme samples, not the complete Cartesian product. Keyboard Tab reaches the PDF link; A4 CSS pixels and transformed wrapper boundaries are measured separately. Renderer settings and document locale remain unchanged.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-04 PRES-C12 C07] long public CV height remains reachable without shrinking A4 geometry or mutating source', async ({ page, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const documents = await seed(owner, true);
|
||||
const before = await preserved(owner, documents);
|
||||
const samples: Sample[] = [
|
||||
{ language: 'nb', theme: 'dark', width: 1440, height: 900 },
|
||||
{ language: 'en', theme: 'light', width: 320, height: 844 },
|
||||
];
|
||||
for (const sample of samples) {
|
||||
await enter(page, documents.published.publicSlug, sample); const frame = await ready(page, sample.language);
|
||||
await expect.poll(() => frame.evaluate(element => parseFloat(getComputedStyle(element).height))).toBeGreaterThan(a4Height);
|
||||
const metrics = await capture(page, info, sample, 'long-document');
|
||||
expect(metrics!.cssHeight).toBeGreaterThan(a4Height);
|
||||
expect(metrics!.documentScrollHeight).toBeLessThanOrEqual(metrics!.cssHeight + 1);
|
||||
}
|
||||
expect(await preserved(owner, documents)).toEqual(before);
|
||||
});
|
||||
@@ -784,6 +784,13 @@ export const translations = {
|
||||
cvEditorRestoredVersion: "Restored version {version}.",
|
||||
cvEditorRestoreFailed: "Restore failed.",
|
||||
cvDashboardTitle: "CV Builder",
|
||||
publicCvTitle: "Public CV",
|
||||
publicCvDescription: "A read-only CV shared by its owner. Download the PDF to keep a copy.",
|
||||
publicCvDownloadPdf: "Download PDF",
|
||||
publicCvUnavailableTitle: "CV unavailable",
|
||||
publicCvUnavailableBody: "This CV isn’t available. The link may be wrong, or the owner has made it private.",
|
||||
publicCvLoadFailedTitle: "Could not load CV",
|
||||
publicCvLoadFailedBody: "This CV can’t be loaded right now. Please try again in a moment.",
|
||||
cvDashboardLibraryTitle: "Your CVs",
|
||||
cvDashboardSubtitle: "Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.",
|
||||
cvDashboardUntitled: "Untitled CV",
|
||||
@@ -3418,6 +3425,13 @@ export const translations = {
|
||||
cvEditorRestoredVersion: "Versjon {version} er gjenopprettet.",
|
||||
cvEditorRestoreFailed: "Gjenoppretting mislyktes.",
|
||||
cvDashboardTitle: "CV-bygger",
|
||||
publicCvTitle: "Offentlig CV",
|
||||
publicCvDescription: "En CV som eieren har delt. Du kan lese den og laste ned en kopi som PDF.",
|
||||
publicCvDownloadPdf: "Last ned PDF",
|
||||
publicCvUnavailableTitle: "CV-en er utilgjengelig",
|
||||
publicCvUnavailableBody: "Denne CV-en er ikke tilgjengelig. Lenken kan være feil, eller eieren kan ha gjort CV-en privat.",
|
||||
publicCvLoadFailedTitle: "Kunne ikke laste CV",
|
||||
publicCvLoadFailedBody: "CV-en kan ikke lastes akkurat nå. Prøv igjen om litt.",
|
||||
cvDashboardLibraryTitle: "Dine CV-er",
|
||||
cvDashboardSubtitle: "Lag profesjonelle, jobbtilpassede CV-er fra én pålitelig karriereprofil. Hver versjon beholder sin egen mal, sine innholdsvalg og sin historikk.",
|
||||
cvDashboardUntitled: "CV uten tittel",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from './api';
|
||||
import PublicCvPage from './views/PublicCvPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -14,24 +15,254 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalTitle = document.title;
|
||||
|
||||
function RouteControls() {
|
||||
const navigate = useNavigate();
|
||||
return <><button onClick={() => navigate('/cv/slug-a')}>Open A</button><button onClick={() => navigate('/cv/slug-b')}>Open B</button></>;
|
||||
}
|
||||
|
||||
function renderPage(path = '/cv/public-slug') {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<RouteControls />
|
||||
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function switchLanguage(language: 'en' | 'nb') {
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockReset();
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
document.title = originalTitle;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
document.title = originalTitle;
|
||||
});
|
||||
|
||||
test('public CV exposes the rendered CV and PDF download', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 });
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/cv/public-slug']}>
|
||||
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
renderPage();
|
||||
|
||||
const frame = await screen.findByTitle('Public CV');
|
||||
expect(frame).toHaveAttribute('srcdoc', '<p>Public CV</p>');
|
||||
expect(frame).toHaveAttribute('sandbox', 'allow-same-origin');
|
||||
expect(Number.parseFloat(frame.style.width)).toBeGreaterThan(790);
|
||||
await waitFor(() => expect(frame.style.transform).not.toBe('scale(1)'));
|
||||
expect(Number.parseFloat(screen.getByTestId('public-cv-frame-container').style.width)).toBeLessThanOrEqual(343);
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/public-cv/public-slug');
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth });
|
||||
});
|
||||
|
||||
test('a public slug containing path and URL delimiters stays one encoded API and PDF path segment', async () => {
|
||||
const slug = 'owner/document?mode=preview#section%';
|
||||
const encodedSlug = encodeURIComponent(slug);
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => read.promise);
|
||||
renderPage(`/cv/${encodedSlug}`);
|
||||
expect(mockedApi.get).toHaveBeenCalledWith(`/public-cv/${encodedSlug}`);
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
await act(async () => { read.resolve({ data: { html: '<p>Encoded slug document</p>', name: 'Candidate' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Encoded slug document</p>');
|
||||
expect(screen.getByTitle('Public CV')).toHaveAttribute('sandbox', 'allow-same-origin');
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', `/api/public-cv/${encodedSlug}/pdf`);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a held public CV read has a named page and loading announcement without a document or download', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => read.promise);
|
||||
renderPage();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { read.resolve({ data: { html: '<p>Current public document</p>', name: 'Ada' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current public document</p>');
|
||||
});
|
||||
|
||||
test('a revoked or missing public slug shows an unavailable state without retry or download', async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } });
|
||||
renderPage();
|
||||
expect(await screen.findByText('CV unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/link|owner|private/i);
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a transient failure retries only when requested and keeps focus on the page heading during its held read', async () => {
|
||||
const retry = deferred<any>();
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } }).mockImplementationOnce(() => retry.promise);
|
||||
renderPage();
|
||||
expect(await screen.findByText('Could not load CV')).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
const retryButton = screen.getByRole('button', { name: 'Retry' });
|
||||
retryButton.focus();
|
||||
fireEvent.click(retryButton);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Public CV' })).toHaveFocus();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
await act(async () => { retry.resolve({ data: { html: '<p>Recovered CV</p>', name: 'Recovered candidate' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Recovered CV</p>');
|
||||
expect(screen.queryByText('Could not load CV')).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('changing public slugs immediately hides the previous document, name and PDF and resets its measured height', async () => {
|
||||
const second = deferred<any>();
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate A</p>', name: 'Candidate A' } } as any).mockImplementationOnce(() => second.promise);
|
||||
renderPage('/cv/slug-a');
|
||||
const firstFrame = await screen.findByTitle('Public CV') as HTMLIFrameElement;
|
||||
Object.defineProperty(firstFrame.contentDocument!.documentElement, 'scrollHeight', { configurable: true, value: 3000 });
|
||||
fireEvent.load(firstFrame);
|
||||
expect(firstFrame.style.height).toBe('3000px');
|
||||
expect(document.title).toBe('Candidate A — CV');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(document.title).not.toContain('Candidate A');
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
await act(async () => { second.resolve({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } }); });
|
||||
const secondFrame = await screen.findByTitle('Public CV');
|
||||
expect(secondFrame).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
expect(Number.parseFloat(secondFrame.style.height)).toBeCloseTo((297 / 25.4) * 96);
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
|
||||
expect(document.title).toBe('Candidate B — CV');
|
||||
});
|
||||
|
||||
test('a failed public slug cannot hide a subsequently loaded different public CV', async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 404 } }).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
|
||||
renderPage('/cv/slug-a');
|
||||
await screen.findByText(/link.*wrong|wrong.*link/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-b/pdf');
|
||||
});
|
||||
|
||||
test.each(['success', 'failure'])('a late %s from the previous slug cannot replace the current CV or its title', async (outcome) => {
|
||||
const previous = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => previous.promise).mockResolvedValueOnce({ data: { html: '<p>Candidate B</p>', name: 'Candidate B' } } as any);
|
||||
renderPage('/cv/slug-a');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
await act(async () => {
|
||||
if (outcome === 'success') previous.resolve({ data: { html: '<p>Late candidate A</p>', name: 'Late candidate A' } });
|
||||
else previous.reject({ response: { status: 404 } });
|
||||
});
|
||||
expect(screen.getByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Candidate B</p>');
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(document.title).toBe('Candidate B — CV');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('language changes translate loading, failure and loaded chrome without refetching anonymous CV content', async () => {
|
||||
const first = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => first.promise).mockResolvedValueOnce({ data: { html: '<p>Untranslated candidate facts</p>', name: 'Candidate' } } as any);
|
||||
renderPage();
|
||||
switchLanguage('nb');
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Offentlig CV' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Laster/i);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { first.reject({ response: { status: 500 } }); });
|
||||
expect(await screen.findByText('Kunne ikke laste CV')).toBeInTheDocument();
|
||||
switchLanguage('en');
|
||||
expect(screen.getByText('Could not load CV')).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
|
||||
switchLanguage('nb');
|
||||
expect(screen.getByTitle('Offentlig CV')).toHaveAttribute('srcdoc', '<p>Untranslated candidate facts</p>');
|
||||
expect(screen.getByRole('link', { name: 'Last ned PDF' })).toHaveAttribute('href', '/api/public-cv/public-slug/pdf');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('the public page removes only its own noindex metadata and ignores a response after unmount', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => read.promise);
|
||||
const unrelated = document.createElement('meta');
|
||||
unrelated.name = 'robots';
|
||||
unrelated.content = 'index, follow';
|
||||
document.head.appendChild(unrelated);
|
||||
try {
|
||||
const page = renderPage();
|
||||
const owned = Array.from(document.head.querySelectorAll('meta[name="robots"]')).find((item) => item !== unrelated)!;
|
||||
expect(owned).toHaveAttribute('content', 'noindex, nofollow');
|
||||
expect(unrelated).toHaveAttribute('content', 'index, follow');
|
||||
page.unmount();
|
||||
expect(owned).not.toBeInTheDocument();
|
||||
expect(unrelated).toBeInTheDocument();
|
||||
document.title = 'Destination after unmount';
|
||||
await act(async () => { read.resolve({ data: { html: '<p>Late response</p>', name: 'Late candidate' } }); });
|
||||
expect(document.title).toBe('Destination after unmount');
|
||||
expect(unrelated).toHaveAttribute('content', 'index, follow');
|
||||
} finally { unrelated.remove(); }
|
||||
});
|
||||
|
||||
test('leaving a loaded public CV restores the prior page title when its candidate title is still current', async () => {
|
||||
document.title = 'Jobs dashboard — Jobbjakt';
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
|
||||
const page = renderPage();
|
||||
await screen.findByTitle('Public CV');
|
||||
expect(document.title).toBe('Candidate identity — CV');
|
||||
page.unmount();
|
||||
expect(document.title).toBe('Jobs dashboard — Jobbjakt');
|
||||
});
|
||||
|
||||
test('leaving a loaded public CV preserves a page title already replaced by another owner', async () => {
|
||||
document.title = 'Jobs dashboard — Jobbjakt';
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Candidate document</p>', name: 'Candidate identity' } } as any);
|
||||
const page = renderPage();
|
||||
await screen.findByTitle('Public CV');
|
||||
expect(document.title).toBe('Candidate identity — CV');
|
||||
document.title = 'Destination page — Jobbjakt';
|
||||
page.unmount();
|
||||
expect(document.title).toBe('Destination page — Jobbjakt');
|
||||
});
|
||||
|
||||
test('returning from A through B to A ignores both earlier held reads until the current A response arrives', async () => {
|
||||
const originalA = deferred<any>();
|
||||
const previousB = deferred<any>();
|
||||
const currentA = deferred<any>();
|
||||
mockedApi.get.mockImplementationOnce(() => originalA.promise)
|
||||
.mockImplementationOnce(() => previousB.promise)
|
||||
.mockImplementationOnce(() => currentA.promise);
|
||||
renderPage('/cv/slug-a');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open B' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open A' }));
|
||||
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/public-cv/slug-a', '/public-cv/slug-b', '/public-cv/slug-a']);
|
||||
await act(async () => { originalA.resolve({ data: { html: '<p>Stale original A</p>', name: 'Stale original A' } }); });
|
||||
expect(screen.queryByTitle('Public CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Download PDF' })).not.toBeInTheDocument();
|
||||
expect(document.title).not.toContain('Stale original A');
|
||||
await act(async () => { previousB.reject({ response: { status: 404 } }); });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/Loading/i);
|
||||
await act(async () => { currentA.resolve({ data: { html: '<p>Current A document</p>', name: 'Current A candidate' } }); });
|
||||
expect(await screen.findByTitle('Public CV')).toHaveAttribute('srcdoc', '<p>Current A document</p>');
|
||||
expect(screen.getByRole('link', { name: 'Download PDF' })).toHaveAttribute('href', '/api/public-cv/slug-a/pdf');
|
||||
expect(document.title).toBe('Current A candidate — CV');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -1,43 +1,78 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Box, Button } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
|
||||
const A4_WIDTH_PX = (210 / 25.4) * 96;
|
||||
const A4_HEIGHT_PX = (297 / 25.4) * 96;
|
||||
|
||||
type PublicCvRead = {
|
||||
slug?: string;
|
||||
html?: string;
|
||||
name?: string;
|
||||
error?: "missing" | "transient";
|
||||
loading: boolean;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
|
||||
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
|
||||
export default function PublicCvPage() {
|
||||
const { slug } = useParams();
|
||||
const pdfUrl = `${api.defaults.baseURL}/public-cv/${slug}/pdf`;
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
const pdfUrl = `${api.defaults.baseURL}/public-cv/${encodeURIComponent(slug ?? "")}/pdf`;
|
||||
const [read, setRead] = useState<PublicCvRead>({ slug, loading: true, height: A4_HEIGHT_PX });
|
||||
const [retry, setRetry] = useState(0);
|
||||
const pending = useRef(false);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const previousTitle = useRef("");
|
||||
const ownedTitle = useRef<string | null>(null);
|
||||
const [frameScale, setFrameScale] = useState(1);
|
||||
const [frameHeight, setFrameHeight] = useState(A4_HEIGHT_PX);
|
||||
// Hide the old document on the first render of a new route, before its effect runs.
|
||||
const current = read.slug === slug ? read : null;
|
||||
const loading = !current || current.loading;
|
||||
const frameHeight = current?.height ?? A4_HEIGHT_PX;
|
||||
const title = t("publicCvTitle");
|
||||
const unavailable = current?.error === "missing";
|
||||
const errorTitle = t(unavailable ? "publicCvUnavailableTitle" : "publicCvLoadFailedTitle");
|
||||
|
||||
useEffect(() => {
|
||||
const originalTitle = document.title;
|
||||
previousTitle.current = originalTitle;
|
||||
const meta = document.createElement("meta");
|
||||
meta.name = "robots";
|
||||
meta.content = "noindex, nofollow";
|
||||
document.head.appendChild(meta);
|
||||
return () => {
|
||||
meta.remove();
|
||||
if (document.title === ownedTitle.current) document.title = originalTitle;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
|
||||
.then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } })
|
||||
pending.current = true;
|
||||
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
|
||||
api.get<{ html: string; name: string }>(`/public-cv/${encodeURIComponent(slug ?? "")}`)
|
||||
.then((r) => { if (alive) setRead({ slug, html: r.data.html, name: r.data.name, loading: false, height: A4_HEIGHT_PX }); })
|
||||
.catch((err) => {
|
||||
if (!alive) return;
|
||||
// 404 = unknown slug or the owner made it private again. Anything else is a transient error.
|
||||
setError(err?.response?.status === 404
|
||||
? "This CV isn’t available. The link may be wrong, or the owner has made it private."
|
||||
: "This CV can’t be loaded right now. Please try again in a moment.");
|
||||
});
|
||||
setRead({ slug, error: err?.response?.status === 404 ? "missing" : "transient", loading: false, height: A4_HEIGHT_PX });
|
||||
})
|
||||
.finally(() => { if (alive) pending.current = false; });
|
||||
return () => { alive = false; };
|
||||
}, [slug, retry]);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
document.head.removeChild(meta);
|
||||
};
|
||||
}, [slug]);
|
||||
useEffect(() => {
|
||||
const nextTitle = current?.name ? `${current.name} — CV` : previousTitle.current;
|
||||
document.title = nextTitle;
|
||||
ownedTitle.current = nextTitle;
|
||||
}, [current?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScale = () => setFrameScale(Math.min(1, Math.max(0.1, (window.innerWidth - 32) / A4_WIDTH_PX)));
|
||||
@@ -46,41 +81,38 @@ export default function PublicCvPage() {
|
||||
return () => window.removeEventListener("resize", updateScale);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (html === null) {
|
||||
return (
|
||||
<div role="status" style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#4b5563" }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 16, overflowX: "hidden" }}>
|
||||
<a href={pdfUrl} download style={{ padding: "10px 16px", borderRadius: 8, background: "#1d4ed8", color: "#fff", font: "600 14px system-ui", textDecoration: "none" }}>
|
||||
Download PDF
|
||||
</a>
|
||||
<div
|
||||
<Box component="main" sx={{ minHeight: "100vh", bgcolor: "background.default", color: "text.primary", py: 3, px: "16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 2, minWidth: 0 }}>
|
||||
<Box sx={{ width: "100%", maxWidth: A4_WIDTH_PX, minWidth: 0 }}>
|
||||
<PageHeader title={title} titleRef={headingRef} description={t("publicCvDescription")}
|
||||
actions={current?.html !== undefined ? <Button component="a" variant="contained" href={pdfUrl} download>{t("publicCvDownloadPdf")}</Button> : undefined} />
|
||||
<ViewStateNotice compact loading={loading} title={errorTitle}
|
||||
error={current?.error ? { kind: "unavailable", message: errorTitle, retryable: !unavailable } : null}
|
||||
description={t(unavailable ? "publicCvUnavailableBody" : "publicCvLoadFailedBody")}
|
||||
onRetry={() => {
|
||||
if (pending.current || loading) return;
|
||||
pending.current = true;
|
||||
headingRef.current?.focus();
|
||||
setRead({ slug, loading: true, height: A4_HEIGHT_PX });
|
||||
setRetry((value) => value + 1);
|
||||
}} />
|
||||
</Box>
|
||||
{current?.html !== undefined && <div
|
||||
data-testid="public-cv-frame-container"
|
||||
style={{ width: A4_WIDTH_PX * frameScale, height: frameHeight * frameScale, maxWidth: "100%", overflow: "hidden", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
|
||||
>
|
||||
<iframe
|
||||
title="Public CV"
|
||||
srcDoc={html}
|
||||
title={title}
|
||||
srcDoc={current.html}
|
||||
sandbox="allow-same-origin"
|
||||
onLoad={(event) => {
|
||||
const documentHeight = event.currentTarget.contentDocument?.documentElement.scrollHeight ?? A4_HEIGHT_PX;
|
||||
setFrameHeight(Math.max(A4_HEIGHT_PX, documentHeight));
|
||||
setRead((previous) => previous.slug === slug && previous.html === current.html && !previous.loading
|
||||
? { ...previous, height: Math.max(A4_HEIGHT_PX, documentHeight) } : previous);
|
||||
}}
|
||||
style={{ width: A4_WIDTH_PX, height: frameHeight, transform: `scale(${frameScale})`, transformOrigin: "top left", border: "none", background: "#fff", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user