feat(career): CV builder backend — data-driven theme engine + variant model
Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).
- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
running container), 16 tests (resolver/renderer/service), 296 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
// A theme is DATA, not code. The renderer (ThemedCvRenderer) has one render path; every visual
|
||||
// difference between themes is expressed by the fields below. Adding a theme = adding one CvTheme to
|
||||
// CvThemeCatalog — no renderer changes. This is the core Phase 4 architectural rule
|
||||
// ("Do NOT hardcode HTML templates"). docs/architecture/cv-theme-engine.md.
|
||||
public sealed class CvTheme
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Category { get; init; } = string.Empty;
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
// single | sidebar-left | sidebar-right | header-band
|
||||
public string Layout { get; init; } = "single";
|
||||
public double SidebarWidthMm { get; init; } = 62;
|
||||
|
||||
// Palette
|
||||
public string Accent { get; init; } = "#2563eb";
|
||||
public string Ink { get; init; } = "#111827";
|
||||
public string Muted { get; init; } = "#4b5563";
|
||||
public string Line { get; init; } = "#d1d5db";
|
||||
public string Paper { get; init; } = "#ffffff";
|
||||
public string SidebarBg { get; init; } = "#f3f4f6";
|
||||
public string SidebarInk { get; init; } = "#111827";
|
||||
public string? HeadingColor { get; init; } // null => accent
|
||||
|
||||
// Typography
|
||||
public string HeadingFont { get; init; } = "Georgia, 'Times New Roman', serif";
|
||||
public string BodyFont { get; init; } = "Arial, Helvetica, sans-serif";
|
||||
public double NameSizePt { get; init; } = 24;
|
||||
public double HeadingSizePt { get; init; } = 12;
|
||||
public double BodySizePt { get; init; } = 10;
|
||||
public double LineHeight { get; init; } = 1.42;
|
||||
|
||||
// Spacing (mm)
|
||||
public double PageMarginMm { get; init; } = 16;
|
||||
public double SectionGapMm { get; init; } = 6;
|
||||
public double EntryGapMm { get; init; } = 4.5;
|
||||
|
||||
// plain | band | centered | kicker
|
||||
public string HeaderStyle { get; init; } = "plain";
|
||||
// caps-rule | underline | plain | bar
|
||||
public string HeadingStyle { get; init; } = "caps-rule";
|
||||
// none | square | rounded | circle
|
||||
public string PhotoShape { get; init; } = "rounded";
|
||||
|
||||
public bool DefaultIcons { get; init; }
|
||||
|
||||
// Which section keys render in the sidebar for two-column layouts (ignored for single/header-band).
|
||||
public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" };
|
||||
}
|
||||
|
||||
public static class CvThemeCatalog
|
||||
{
|
||||
// 8 professional themes, all data. To add one: append here.
|
||||
public static readonly IReadOnlyList<CvTheme> Themes = new List<CvTheme>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "modern", Name = "Modern", Category = "Modern Professional",
|
||||
Description = "Clean SaaS-style layout with a confident accent header.",
|
||||
Layout = "header-band", HeaderStyle = "band", HeadingStyle = "caps-rule",
|
||||
Accent = "#2563eb", HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
|
||||
PhotoShape = "rounded", DefaultIcons = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "minimal", Name = "Minimal", Category = "Minimal",
|
||||
Description = "Quiet, spacious, typography-first. Nothing competes with the content.",
|
||||
Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain",
|
||||
Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280",
|
||||
HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif",
|
||||
SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "executive", Name = "Executive", Category = "Executive",
|
||||
Description = "High-contrast, serif, leadership-weighted. For senior and client-facing roles.",
|
||||
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
|
||||
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
|
||||
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif",
|
||||
NameSizePt = 27, PhotoShape = "square",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "technical", Name = "Technical", Category = "Technical",
|
||||
Description = "Dense two-column layout tuned for engineering CVs — skills and projects up front.",
|
||||
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
|
||||
Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff",
|
||||
HeadingFont = "'Roboto', Arial, sans-serif", BodyFont = "'Roboto', Arial, sans-serif",
|
||||
BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true,
|
||||
SidebarSections = new() { "contact", "skills", "languages", "certifications" },
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "ats-classic", Name = "ATS Classic", Category = "ATS Professional",
|
||||
Description = "Single column, no graphics, maximum parser compatibility. The safe default.",
|
||||
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
|
||||
Accent = "#334155", Ink = "#111827", Muted = "#374151",
|
||||
HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif",
|
||||
PhotoShape = "none",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "nordic", Name = "Nordic", Category = "Modern Professional",
|
||||
Description = "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.",
|
||||
Layout = "sidebar-right", HeaderStyle = "plain", HeadingStyle = "caps-rule",
|
||||
Accent = "#3b6ea5", Ink = "#1f2937", Muted = "#4b5563", SidebarBg = "#eef3f8", SidebarInk = "#1f2937",
|
||||
HeadingFont = "'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif",
|
||||
PhotoShape = "circle", DefaultIcons = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "elegant", Name = "Elegant", Category = "Creative",
|
||||
Description = "Editorial serif headings over sans body, premium spacing and a plum accent.",
|
||||
Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline",
|
||||
Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563",
|
||||
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "'Segoe UI', Arial, sans-serif",
|
||||
NameSizePt = 26, SectionGapMm = 7, PhotoShape = "circle",
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "creative", Name = "Creative", Category = "Creative",
|
||||
Description = "Bold accent sidebar and photo-forward header for design and product roles.",
|
||||
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
|
||||
Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#db2777", SidebarInk = "#ffffff",
|
||||
HeadingFont = "'Poppins', 'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif",
|
||||
PhotoShape = "circle", DefaultIcons = true,
|
||||
SidebarSections = new() { "contact", "skills", "languages", "interests" },
|
||||
},
|
||||
};
|
||||
|
||||
public static CvTheme Resolve(string? id)
|
||||
{
|
||||
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return Themes.FirstOrDefault(t => t.Id == key) ?? Themes[0];
|
||||
}
|
||||
|
||||
public static bool Exists(string? id)
|
||||
{
|
||||
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return Themes.Any(t => t.Id == key);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user