feat(cv): rebuild professional resume studio

This commit is contained in:
cesnimda
2026-08-24 20:21:23 +02:00
parent dca5daa1a2
commit 367b70681a
27 changed files with 827 additions and 187 deletions
@@ -98,6 +98,7 @@ public sealed class AccountDataExportService(
user.StripeLastEventCreatedUtc,
user.AiEnabled,
user.ExternalAiProcessingAllowed,
user.EmailFollowUpRemindersEnabled,
Roles = roles,
Claims = claims,
ExternalLogins = logins,
+49 -8
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
@@ -64,10 +65,17 @@ public static class CvVariantResolver
};
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
AddContact(model.Contact, "phone", profile.Contact.Phone, null);
AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
AddContact(model.Contact, "location", profile.Contact.Location, null);
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
foreach (var link in profile.Contact.Links)
{
var url = Trim(link.Url);
if (url is null) continue;
model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
}
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
{
@@ -84,6 +92,7 @@ public static class CvVariantResolver
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
["references"] = BulletSection("references", "References", profile.References),
};
ApplyLanguageLabels(built, settings.Language);
// OtherSections from the master profile become body sections keyed other:<n>.
for (var i = 0; i < profile.OtherSections.Count; i++)
@@ -160,7 +169,7 @@ public static class CvVariantResolver
Key = job.Id,
Title = Trim(ov?.Title) ?? Trim(job.Title),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
Meta = DateRange(job.Start, job.End, job.IsCurrent),
Meta = DateRange(job.StartDate ?? job.Start, job.EndDate ?? job.End, job.IsCurrent, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
Tags = Clean(job.Skills),
});
@@ -181,7 +190,7 @@ public static class CvVariantResolver
Key = ed.Id,
Title = Trim(ov?.Title) ?? title,
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
Meta = DateRange(ed.Start, ed.End, false),
Meta = DateRange(ed.StartDate ?? ed.Start, ed.EndDate ?? ed.End, false, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
});
}
@@ -200,7 +209,7 @@ public static class CvVariantResolver
Key = pr.Id,
Title = Trim(ov?.Title) ?? Trim(pr.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
Meta = DateRange(pr.Start, pr.End, false),
Meta = DateRange(pr.StartDate ?? pr.Start, pr.EndDate ?? pr.End, false, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
Tags = Clean(pr.Skills),
});
@@ -279,13 +288,45 @@ public static class CvVariantResolver
return string.IsNullOrWhiteSpace(joined) ? null : joined;
}
private static string? DateRange(string? start, string? end, bool isCurrent)
private static string? DateRange(string? start, string? end, bool isCurrent, CvVariantSettings settings)
{
var s = Trim(start);
var e = Trim(end);
var s = FormatDate(Trim(start), settings);
var e = FormatDate(Trim(end), settings);
if (s is null && e is null) return null;
if (s is null) return e;
return $"{s} {(isCurrent ? "Present" : e ?? "Present")}";
var present = IsNorwegian(settings.Language) ? "nå" : "Present";
return $"{s} {(isCurrent ? present : e ?? present)}";
}
private static string? FormatDate(string? value, CvVariantSettings settings)
{
if (value is null || settings.DateFormat is null) return value;
if (!DateTime.TryParseExact(value, new[] { "yyyy-MM", "yyyy-M", "yyyy-MM-dd", "yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return value;
var culture = IsNorwegian(settings.Language) ? CultureInfo.GetCultureInfo("nb-NO") : CultureInfo.GetCultureInfo("en-GB");
return settings.DateFormat switch
{
"year" => date.ToString("yyyy", culture),
"numeric" => date.ToString("MM/yyyy", culture),
"long" => date.ToString("MMMM yyyy", culture),
_ => date.ToString("MMM yyyy", culture),
};
}
private static bool IsNorwegian(string? language) => language?.StartsWith("no", StringComparison.OrdinalIgnoreCase) == true
|| language?.StartsWith("nb", StringComparison.OrdinalIgnoreCase) == true
|| language?.StartsWith("nn", StringComparison.OrdinalIgnoreCase) == true;
private static void ApplyLanguageLabels(Dictionary<string, CvRenderSection> sections, string? language)
{
if (!IsNorwegian(language)) return;
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["summary"] = "Profil", ["experience"] = "Arbeidserfaring", ["education"] = "Utdanning",
["projects"] = "Prosjekter", ["skills"] = "Ferdigheter", ["certifications"] = "Sertifiseringer",
["languages"] = "Språk", ["interests"] = "Interesser", ["awards"] = "Priser og utmerkelser",
["publications"] = "Publikasjoner", ["organisations"] = "Organisasjoner", ["references"] = "Referanser",
};
foreach (var (key, label) in labels) if (sections.TryGetValue(key, out var section)) section.Title = label;
}
private static List<string> Clean(IEnumerable<string>? items) =>
+39 -4
View File
@@ -4,7 +4,18 @@ using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
public sealed record CvVariantSummary(
int Id,
string Name,
string ThemeId,
string Language,
string PublicSlug,
bool IsPublic,
int Version,
int? JobApplicationId,
DateTimeOffset UpdatedAtUtc,
string? JobTitle = null,
string? CompanyName = null);
public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
// Person + photo needed to render, resolved from ApplicationUser by the controller so this service
@@ -14,6 +25,7 @@ public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl);
public interface ICvVariantService
{
Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct);
Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct);
Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct);
@@ -49,18 +61,26 @@ public sealed class CvVariantService : ICvVariantService
public async Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct)
{
var query = _db.CvVariants.Where(x => x.OwnerUserId == ownerUserId);
var query = _db.CvVariants
.Include(x => x.JobApplication)
.ThenInclude(x => x!.Company)
.Where(x => x.OwnerUserId == ownerUserId);
var variants = _db.Database.IsSqlite()
? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList()
: await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
return variants.Select(Summarize).ToList();
}
public Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct);
public Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
_db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
public async Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct)
{
if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct))
throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId));
var now = DateTimeOffset.UtcNow;
var normalized = CvVariantSettingsJson.Normalize(settings);
var variant = new CvVariant
@@ -110,7 +130,11 @@ public sealed class CvVariantService : ICvVariantService
var source = await GetAsync(ownerUserId, id, ct);
if (source is null) return null;
var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson);
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct);
var associatedJobId = source.JobApplicationId is not null
&& await CanAssociateJobAsync(ownerUserId, source.JobApplicationId.Value, ct)
? source.JobApplicationId
: null;
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, associatedJobId, settings, ct);
}
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
@@ -202,7 +226,18 @@ public sealed class CvVariantService : ICvVariantService
private static CvVariantSummary Summarize(CvVariant v)
{
var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson);
return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc);
return new CvVariantSummary(
v.Id,
v.Name,
settings.ThemeId,
settings.Language ?? "en",
v.PublicSlug,
v.IsPublic,
v.Version,
v.JobApplicationId,
v.UpdatedAtUtc,
v.JobApplication?.JobTitle,
v.JobApplication?.Company?.Name);
}
private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim();
+55 -47
View File
@@ -23,30 +23,34 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
settings = CvVariantSettingsJson.Normalize(settings);
var accent = Override(settings.AccentColor, theme.Accent);
var headerInk = ContrastInk(accent);
var headingColor = theme.HeadingColor ?? accent;
var ink = Override(settings.TextColor, theme.Ink);
var muted = Override(settings.MutedColor, theme.Muted);
var paper = Override(settings.BackgroundColor, theme.Paper);
var headingColor = Override(settings.HeadingColor, theme.HeadingColor ?? accent);
var headingFont = Override(settings.HeadingFont, theme.HeadingFont);
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
var density = DensityScale(settings.Density);
var layout = Override(settings.Layout, theme.Layout);
var headingStyle = Override(settings.HeadingStyle, theme.HeadingStyle);
var headerStyle = Override(settings.HeaderStyle, theme.HeaderStyle);
var sidebarSections = settings.SidebarSections ?? theme.SidebarSections;
var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4";
var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm");
var showIcons = settings.ShowIcons && theme.DefaultIcons;
var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right";
var twoColumn = layout is "sidebar-left" or "sidebar-right";
var (sidebarHtml, mainHtml) = twoColumn
? SplitColumns(model, theme, showIcons)
: (string.Empty, RenderSections(model.Sections, theme));
? SplitColumns(model, theme, sidebarSections, settings)
: (string.Empty, RenderSections(model.Sections, theme, settings));
var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn);
var body = theme.Layout switch
var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
var body = layout switch
{
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons)}</div>",
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}</div>",
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
};
// For two-column themes the header renders inside the sidebar; single/header-band render it on top.
var page = twoColumn ? body : body;
var html = $@"<!DOCTYPE html>
<html lang=""{Attr(settings.Language ?? "en")}"">
<head>
@@ -55,7 +59,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
<style>{css}</style>
</head>
<body>
<main class=""page"">{page}</main>
<main class=""page"">{body}</main>
</body>
</html>";
@@ -63,39 +67,37 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return new ThemedCvRenderResult(theme.Id, fileName, html);
}
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons)
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection<string> sidebarKeys, CvVariantSettings settings)
{
var sidebarKeys = theme.SidebarSections;
var sidebar = new StringBuilder();
var mainSections = new List<CvRenderSection>();
foreach (var section in model.Sections)
{
var baseKey = section.Key.Contains(':') ? section.Key : section.Key;
if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase))
sidebar.Append(RenderSection(section, theme));
if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase))
sidebar.Append(RenderSection(section, theme, settings));
else
mainSections.Add(section);
}
return (sidebar.ToString(), RenderSections(mainSections, theme));
return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
}
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons)
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle)
{
var header = RenderHeader(model, theme, showIcons, twoColumn: true);
var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
: string.Empty;
return $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
}
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn)
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle)
{
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
var kicker = theme.HeaderStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
var kicker = headerStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}";
var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}";
return $@"<header class=""{headerClass}"">{photo}<div class=""head-text"">{kicker}{name}{headline}{contact}</div></header>";
}
@@ -118,19 +120,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return $@"<div class=""contact {(sidebar ? "contact-stacked" : "contact-inline")}"">{items}</div>";
}
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme)
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme, CvVariantSettings settings)
{
var sb = new StringBuilder();
foreach (var section in sections) sb.Append(RenderSection(section, theme));
foreach (var section in sections) sb.Append(RenderSection(section, theme, settings));
return sb.ToString();
}
private static string RenderSection(CvRenderSection section, CvTheme theme)
private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
{
if (section.IsEmpty) return string.Empty;
var inner = section.Kind switch
{
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>",
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
_ => string.Join("", section.Entries.Select(RenderEntry)),
};
@@ -190,21 +193,25 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return s;
}
private static string BuildCss(CvTheme t, string accent, string headerInk, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
private static string BuildCss(CvTheme t, CvVariantSettings settings, string accent, string headerInk, string ink, string muted, string paper, string headingColor, string headingFont, string bodyFont, string headingStyle, double density, (string w, string h) page, string layout, bool twoColumn)
{
var margin = F(t.PageMarginMm * density);
var sectionGap = F(t.SectionGapMm * density);
var entryGap = F(t.EntryGapMm * density);
var headingCss = t.HeadingStyle switch
var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
var bodySize = settings.BaseFontSizePt ?? t.BodySizePt;
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
var lineHeight = settings.LineHeight ?? t.LineHeight;
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
var headingCss = headingStyle switch
{
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
"plain" => ".section-title{letter-spacing:.01em;}",
"bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}",
_ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
_ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
};
var columnTemplate = t.Layout == "sidebar-right"
? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
: $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)";
var columnTemplate = layout == "sidebar-right"
? $"minmax(0,1fr) {F(sidebarWidth)}mm"
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
@@ -225,12 +232,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return $@"
*{{box-sizing:border-box;}}
html,body{{min-width:0;}}
body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
body{{margin:0;background:#e9edf2;color:{ink};font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
h1,h2{{font-family:{headingFont};}}
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;overflow-wrap:anywhere;}}
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{ink};line-height:1.1;overflow-wrap:anywhere;}}
.kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}}
.headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}}
.headline{{margin-top:1.5mm;color:{muted};font-size:{F(bodySize + 0.5)}pt;}}
.head-text,.main,.sidebar,.cols>*{{min-width:0;}}
.head-text{{flex:1;}}
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}}
@@ -238,7 +245,7 @@ h1,h2{{font-family:{headingFont};}}
.photo-rounded{{border-radius:5mm;}}
.photo-circle{{border-radius:50%;}}
.photo img{{width:100%;height:100%;object-fit:cover;display:block;}}
.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}}
.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{muted};font-size:{F(bodySize - 0.5)}pt;margin-top:2.5mm;}}
.contact-stacked{{flex-direction:column;gap:1.8mm;}}
.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;min-width:0;max-width:100%;overflow-wrap:anywhere;}}
.contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}}
@@ -247,18 +254,19 @@ h1,h2{{font-family:{headingFont};}}
.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}}
.section{{margin-top:{sectionGap}mm;}}
.section:first-child{{margin-top:0;}}
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}}
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(headingSize)}pt;font-weight:700;color:{headingColor};}}
{headingCss}
.bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
.tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}}
.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
.skills-text{{margin:0;overflow-wrap:anywhere;}}
.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
.entry{{margin-bottom:{entryGap}mm;}}
.entry:last-child{{margin-bottom:0;}}
.entry-head{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;break-after:avoid-page;page-break-after:avoid;}}
.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-title{{font-weight:700;font-size:{F(bodySize + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and