docs(architecture): document CV builder, theme engine, Phase 4 status
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>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# 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`, and `MASTER_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-section `Hidden` + optional renamed `Title`.
|
||||
- `Overrides`: keyed by `ItemKey` → `{ 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
|
||||
|
||||
1. `CvVariantResolver.Build(profile, settings, fallbackName, photoDataUrl)` → `CvRenderModel`. Applies
|
||||
order, visibility, item overrides and custom sections. Pure projection, owns nothing.
|
||||
2. `ThemedCvRenderer.Render(model, theme, settings)` → HTML. One render path; the theme (data) drives
|
||||
layout/palette/typography/spacing. See `cv-theme-engine.md`.
|
||||
3. `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 `CvTheme` to the catalog (`cv-theme-engine.md`). No renderer change.
|
||||
- **New section kind**: add a builder in `CvVariantResolver` + a `Kind` branch in `ThemedCvRenderer`.
|
||||
- **DOCX export**: add an `IDocxExporter` consuming the same `CvRenderModel`; 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.
|
||||
@@ -0,0 +1,53 @@
|
||||
# CV Theme Engine
|
||||
|
||||
> Phase 4 (2026-07-18). How CV themes work and how to add one. Companion to
|
||||
> `docs/architecture/cv-builder.md`. Verified against code.
|
||||
|
||||
## The rule
|
||||
|
||||
**A theme is data, not code.** There is exactly one renderer — `ThemedCvRenderer`
|
||||
(`JobTrackerApi/Services/ThemedCvRenderer.cs`) — with a single render path. Every visual difference
|
||||
between themes is expressed by the fields of a `CvTheme` record (`Models/CvTheme.cs`). Adding a theme
|
||||
never touches the renderer.
|
||||
|
||||
This replaces the previous approach (`CvTemplateRenderer`, one hand-written HTML method per template),
|
||||
which is retained only for the legacy tailored-draft flow and is not used by the builder.
|
||||
|
||||
## `CvTheme` fields (the knobs)
|
||||
|
||||
| Group | Fields |
|
||||
|---|---|
|
||||
| Identity | `Id`, `Name`, `Category`, `Description` |
|
||||
| Layout | `Layout` (`single` \| `sidebar-left` \| `sidebar-right` \| `header-band`), `SidebarWidthMm`, `SidebarSections` |
|
||||
| Palette | `Accent`, `Ink`, `Muted`, `Line`, `Paper`, `SidebarBg`, `SidebarInk`, `HeadingColor` |
|
||||
| Typography | `HeadingFont`, `BodyFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` |
|
||||
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` |
|
||||
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons` |
|
||||
|
||||
The renderer computes CSS variables from these plus the variant's runtime overrides (accent, fonts,
|
||||
density, page size, photo, icons) and picks one of the layout wrappers. `SidebarSections` decides which
|
||||
section keys move to the sidebar for the two-column layouts.
|
||||
|
||||
## Adding a theme
|
||||
|
||||
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`Models/CvTheme.cs`). Only override the
|
||||
fields that differ from the defaults.
|
||||
2. Nothing else. It appears in `GET /api/cv/themes`, the Customize tab picker, and renders.
|
||||
|
||||
Add a `CvBuilderTests.Every_catalog_theme_renders_valid_html` already loops the whole catalog, so a new
|
||||
theme is smoke-tested automatically.
|
||||
|
||||
## Shipped themes (8)
|
||||
|
||||
`modern` (header-band), `minimal` (single), `executive` (single, serif), `technical` (sidebar-left,
|
||||
dense), `ats-classic` (single, no graphics), `nordic` (sidebar-right), `elegant` (single, editorial),
|
||||
`creative` (sidebar-left, bold).
|
||||
|
||||
## Deliberately not here (yet)
|
||||
|
||||
- **Rich-text inside bullets.** The render model treats bullets as plain strings, so the builder's text
|
||||
areas are plain text. Adding bold/italic/links means the render model carries inline runs and the
|
||||
renderer emits them — a self-contained extension, no theme changes.
|
||||
- **PDF page numbers.** `ShowPageNumbers` is carried through settings but the actual footer is a
|
||||
Playwright `footerTemplate` concern (the PDF exporter), not CSS paged-media (Chromium's margin boxes
|
||||
are unreliable). Wire it in `PlaywrightCvPdfExporter` when needed.
|
||||
Reference in New Issue
Block a user