cv-builder.md (variant model, rendering pipeline, API, builder workflow, extension points, known deep-link limitation) + cv-theme-engine.md (how a theme is data and how to add one). Roadmap Phase 4 marked foundation-shipped with the remaining polish itemised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4.7 KiB
CV Builder (Career Workspace Builder)
Phase 4 (2026-07-18). The builder that turns the master career profile into themed, exportable, optionally-public CVs. Companion to
docs/architecture/cv-theme-engine.md,docs/architecture/career-profile-model.md, andMASTER_IMPLEMENTATION_GUIDE.md. Verified against code and a running container.
Source-of-truth chain
CareerProfile (the only editable career source)
↓ read (never copied)
CvVariant — a LENS: section order/visibility, per-item overrides, theme + builder settings
↓ resolve
CvRenderModel — presentation projection (CvVariantResolver)
↓ render (one path, theme = data)
HTML — ThemedCvRenderer
↓ export / share
PDF | Public CV (/cv/{slug})
The master profile is never written by the builder. A variant references career items by their stable
ItemKey; overriding a bullet stores the override on the variant, leaving the profile untouched
(CvBuilderTests.Resolver_applies_item_override_bullets_without_touching_the_master).
Variant model
CvVariant (Models/CvVariant.cs) + CvVariantVersion (autosave history). The whole lens lives in one
SettingsJson blob (CvVariantSettings, Models/CvVariantSettings.cs) because it is edited and saved
as a unit — never queried field-by-field. A variant stores:
ThemeId+ overrides: accent, heading/body font, density, page size, photo/icons/page-numbers.Sections: ordered list with per-sectionHidden+ optional renamedTitle.Overrides: keyed byItemKey→{ Hidden, Title, Subtitle, Bullets }(per-item, job-specific).CustomSections: variant-only sections not in the master profile.
PublicSlug (unguessable, globally unique) backs the public URL. IsPublic is off by default.
Rendering pipeline
CvVariantResolver.Build(profile, settings, fallbackName, photoDataUrl)→CvRenderModel. Applies order, visibility, item overrides and custom sections. Pure projection, owns nothing.ThemedCvRenderer.Render(model, theme, settings)→ HTML. One render path; the theme (data) drives layout/palette/typography/spacing. Seecv-theme-engine.md.PlaywrightCvPdfExporter(reused unchanged) turns the HTML into a PDF.
API
Authenticated (/api/cv, CvVariantController): GET themes; GET/POST variants;
GET/PUT/DELETE variants/{id}; POST variants/{id}/duplicate; PUT variants/{id}/public;
GET variants/{id}/versions, POST …/versions/{v}/restore; GET variants/{id}/preview and
POST preview (live preview of unsaved settings); POST variants/{id}/export-pdf; POST ai/assist.
Anonymous (/api/public-cv/{slug}, PublicCvController): serves a public variant read-only,
X-Robots-Tag: noindex. Loads the owner's profile via LoadStructuredForOwnerAsync (bypasses the
tenant filter — there is no current user). Unknown/private slug → 404
(CvBuilderTests.Private_variant_is_not_served_publicly).
Builder workflow (frontend)
/career/builder lists variants (CvBuilderPage); the editor (CvBuilderEditor) is three tabs —
Content, Customize, AI Tools (plus History) — beside an always-on live preview that
re-renders through POST /api/cv/preview on a 350 ms debounce. Edits autosave on an 800 ms debounce
(source: autosave), appending a version each save. /cv/:slug (PublicCvPage) renders a public CV
in a sandboxed iframe.
AI
POST /api/cv/ai/assist reuses the existing ISummarizerService provider abstraction. It returns a
suggestion the user copies in themselves — it never mutates the profile or the variant. Every prompt
carries "preserve every factual claim — never invent" (MASTER_IMPLEMENTATION_GUIDE AI philosophy).
Extension points
- New theme: append a
CvThemeto the catalog (cv-theme-engine.md). No renderer change. - New section kind: add a builder in
CvVariantResolver+ aKindbranch inThemedCvRenderer. - DOCX export: add an
IDocxExporterconsuming the sameCvRenderModel; the model is format-neutral. - Rich text / page numbers: see
cv-theme-engine.md"Deliberately not here (yet)".
Known limitation
Direct external loads of any deep link (including /cv/{slug}) currently bounce to the app root — the
CSR lift-and-shift Next.js static-export setup only prerenders /, so a hard navigation to a deep path
doesn't reach the react-router route (in-app client navigation works). This affects the whole SPA, not
just public CVs, but public CVs are the first feature that depends on external direct links working.
Fix path: serve index.html for all non-/api paths so react-router owns routing (nginx already has
try_files $uri /index.html; the redirect is client-side in the Next shell). Tracked as a follow-up —
see docs/architecture/frontend.md / the Next.js migration note.