feat(cv): harden multi-page builder
CI and Deploy / test (pull_request) Failing after 3m0s
CI and Deploy / deploy (pull_request) Has been skipped

Wrap pathological content, paginate oversized entries, measure A4 and Letter previews correctly, unify section ordering, and gate stored-output actions on saved state.
This commit is contained in:
cesnimda
2026-08-15 14:09:51 +02:00
parent d424633f95
commit 5203ddea72
19 changed files with 508 additions and 206 deletions
+46
View File
@@ -96,6 +96,27 @@ public sealed class CvBuilderTests
Assert.Contains(model.Sections, s => s.Title == "Volunteering" && s.Bullets.Contains("Coached juniors")); Assert.Contains(model.Sections, s => s.Title == "Volunteering" && s.Bullets.Contains("Coached juniors"));
} }
[Fact]
public void Resolver_places_custom_sections_in_the_shared_section_order()
{
var settings = new CvVariantSettings
{
Sections =
{
new CvSectionSetting { Key = "custom:vol" },
new CvSectionSetting { Key = "summary" },
},
CustomSections =
{
new CvCustomSectionSetting { Key = "vol", Title = "Volunteering", Items = { "Coached juniors" } },
},
};
var model = CvVariantResolver.Build(Rich(), settings, "F", null);
Assert.True(model.Sections.FindIndex(section => section.Key == "custom:vol")
< model.Sections.FindIndex(section => section.Key == "summary"));
}
// ---- Renderer (one path, every theme is data) ---- // ---- Renderer (one path, every theme is data) ----
[Fact] [Fact]
@@ -164,6 +185,31 @@ public sealed class CvBuilderTests
Assert.False(CvThemeCatalog.Resolve("technical").AtsFriendly); // sidebar = not ATS-safe Assert.False(CvThemeCatalog.Resolve("technical").AtsFriendly); // sidebar = not ATS-safe
} }
[Fact]
public void Long_content_wraps_and_can_flow_across_pages_without_shrinking_typography()
{
var profile = Rich();
profile.Contact.FullName = new string('N', 180);
profile.Contact.Email = $"{new string('e', 180)}@example.com";
profile.Jobs[0].Title = new string('T', 220);
profile.Jobs[0].Company = new string('C', 220);
profile.Jobs[0].Bullets = Enumerable.Range(1, 7)
.Select(index => index == 1 ? new string('x', 500) : $"Detailed achievement {index} with readable typography.")
.ToList();
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(profile, new CvVariantSettings(), "F", null);
var html = renderer.Render(model, CvThemeCatalog.Resolve("technical"), new CvVariantSettings { ThemeId = "technical" }).Html;
Assert.Contains("class=\"entry entry-flow\"", html);
Assert.Contains("class=\"item-flow\"", html);
Assert.Contains("overflow-wrap:anywhere", html);
Assert.Contains("grid-template-columns:62mm minmax(0,1fr)", html);
Assert.Contains("overflow:visible", html);
Assert.Contains("white-space:normal", html);
Assert.DoesNotContain("transform:scale", html);
}
[Fact] [Fact]
public void Accent_override_reaches_the_css() public void Accent_override_reaches_the_css()
{ {
@@ -449,6 +449,36 @@ public sealed class JobApplicationsApplicationPackageTests
Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase); Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public void Template_renderer_wraps_long_content_and_escapes_sidebar_values()
{
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
Headline = new string('H', 180),
SelectedSkills = new List<string> { $"<script>alert(1)</script>{new string('s', 400)}" },
Experience = new List<TailoredCvExperienceItem>
{
new()
{
Title = new string('T', 220),
Company = new string('C', 220),
Start = "2020",
End = "Present",
Bullets = Enumerable.Range(1, 7).Select(index => index == 1 ? new string('x', 500) : $"Achievement {index}").ToList(),
},
},
});
var html = new CvTemplateRenderer().Render(document, "auckland", new string('N', 180), "Engineer", "Acme", null).Html;
Assert.Contains("class=\"entry entry-flow\"", html);
Assert.Contains("class=\"item-flow\"", html);
Assert.Contains("grid-template-columns:34% minmax(0,66%)", html);
Assert.Contains("overflow-wrap:anywhere", html);
Assert.Contains("&lt;script&gt;alert(1)&lt;/script&gt;", html);
Assert.DoesNotContain("<script>", html);
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null) private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null)
{ {
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId); var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
+32 -21
View File
@@ -93,11 +93,34 @@ public static class CvVariantResolver
built[key] = new CvRenderSection { Key = key, Title = Trim(other.Title) ?? "Additional", Kind = "bullets", Bullets = Clean(other.Items) }; built[key] = new CvRenderSection { Key = key, Title = Trim(other.Title) ?? "Additional", Kind = "bullets", Bullets = Clean(other.Items) };
} }
// Determine order + visibility from settings, falling back to the default order then any extras. // Custom sections use the same order list as profile-backed sections. Older variants that do
var settingByKey = settings.Sections.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase); // not yet contain custom:<key> rows still append them in their stored custom-section order.
var ordered = settings.Sections.Count > 0 var customHiddenByKey = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
? settings.Sections.Select(s => s.Key).ToList() foreach (var custom in settings.CustomSections)
: DefaultOrder.ToList(); {
if (string.IsNullOrWhiteSpace(custom.Key)) continue;
var key = $"custom:{custom.Key}";
customHiddenByKey[key] = custom.Hidden;
built[key] = new CvRenderSection
{
Key = key,
Title = Trim(custom.Title) ?? "Additional",
Kind = "bullets",
Bullets = Clean(custom.Items),
};
}
// Determine order + visibility from settings, falling back to the default order then any
// extras. Tolerate malformed/legacy duplicate keys instead of failing the whole render.
var settingByKey = new Dictionary<string, CvSectionSetting>(StringComparer.OrdinalIgnoreCase);
var ordered = new List<string>();
foreach (var section in settings.Sections)
{
if (string.IsNullOrWhiteSpace(section.Key)) continue;
settingByKey[section.Key] = section;
if (!ordered.Contains(section.Key, StringComparer.OrdinalIgnoreCase)) ordered.Add(section.Key);
}
if (ordered.Count == 0) ordered.AddRange(DefaultOrder);
foreach (var key in built.Keys) foreach (var key in built.Keys)
{ {
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key); if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
@@ -105,7 +128,6 @@ public static class CvVariantResolver
foreach (var key in ordered) foreach (var key in ordered)
{ {
if (key.StartsWith("custom:", StringComparison.OrdinalIgnoreCase)) continue; // handled below
if (!built.TryGetValue(key, out var section)) continue; if (!built.TryGetValue(key, out var section)) continue;
if (settingByKey.TryGetValue(key, out var cfg)) if (settingByKey.TryGetValue(key, out var cfg))
{ {
@@ -116,22 +138,11 @@ public static class CvVariantResolver
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder); section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
} }
} }
if (!section.IsEmpty) model.Sections.Add(section); else if (customHiddenByKey.TryGetValue(key, out var customHidden) && customHidden)
}
// Variant-only custom sections, placed by their position in the order list if present.
foreach (var custom in settings.CustomSections)
{
if (custom.Hidden) continue;
var items = Clean(custom.Items);
if (items.Count == 0) continue;
model.Sections.Add(new CvRenderSection
{ {
Key = $"custom:{custom.Key}", continue;
Title = Trim(custom.Title) ?? "Additional", }
Kind = "bullets", if (!section.IsEmpty) model.Sections.Add(section);
Bullets = items,
});
} }
return model; return model;
+45 -21
View File
@@ -65,7 +65,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
* {{ box-sizing:border-box; }} * {{ box-sizing:border-box; }}
body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }} .page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
.header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }} .header {{ display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
.name {{ margin:0; font-size:25pt; letter-spacing:.02em; }} .name {{ margin:0; font-size:25pt; letter-spacing:.02em; }}
.headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }} .headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }}
.meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }} .meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }}
@@ -140,9 +140,9 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
var sidebarSections = new StringBuilder(); var sidebarSections = new StringBuilder();
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[] sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
{ {
$"Name\n{Encode(candidateName)}", $"Name\n{candidateName}",
$"Target role\n{Encode(jobTitle)}", $"Target role\n{jobTitle}",
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}" string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{companyName}"
})); }));
if (document.CustomSections.Count > 0) if (document.CustomSections.Count > 0)
{ {
@@ -170,7 +170,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
:root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }} :root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }}
* {{ box-sizing:border-box; }} * {{ box-sizing:border-box; }}
body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% 66%; }} .page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% minmax(0,66%); }}
.sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }} .sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }}
.hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }} .hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }}
.hero.curved {{ border-bottom-right-radius:28mm; }} .hero.curved {{ border-bottom-right-radius:28mm; }}
@@ -224,7 +224,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }} .page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }} .monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }} .monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }} .monarch-header {{ display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }} .monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }} .monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }} .monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
@@ -274,7 +274,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
* {{ box-sizing:border-box; }} * {{ box-sizing:border-box; }}
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }} body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }} .page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }} .fjord-grid {{ display:grid; grid-template-columns:72mm minmax(0,1fr); min-height:297mm; }}
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }} .fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }} .fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }} .fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
@@ -335,22 +335,29 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
}; };
return $@" return $@"
.page,.page *{{min-width:0;}}
.page{{overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
.name,.headline,.meta,.monarch-name,.monarch-headline,.monarch-company,.fjord-name,.fjord-headline,.fjord-meta,.sidebar-item{{overflow-wrap:anywhere;word-break:break-word;}}
.section{{margin-top:6mm;}} .section{{margin-top:6mm;}}
{headingCss} {headingCss}
.summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}} .summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}}
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}} .summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;overflow-wrap:anywhere;break-inside:avoid-page;page-break-inside:avoid;orphans:2;widows:2;}}
.summary li.item-flow,.custom-list li.item-flow,.education-list li.item-flow,.experience-bullets li.item-flow{{break-inside:auto;page-break-inside:auto;}}
.skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}} .skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}}
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}} .skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;max-width:100%;overflow-wrap:anywhere;}}
.entry{{margin-bottom:4.8mm;}} .entry{{margin-bottom:4.8mm;break-inside:avoid-page;page-break-inside:avoid;}}
.entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}} .entry.entry-flow{{break-inside:auto;page-break-inside:auto;}}
.entry-title{{font-weight:700;font-size:11pt;}} .entry-header{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;margin-bottom:1.2mm;break-after:avoid-page;page-break-after:avoid;}}
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}} .entry-title{{font-weight:700;font-size:11pt;flex:1 1 50mm;overflow-wrap:anywhere;}}
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}"; .entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:normal;max-width:100%;overflow-wrap:anywhere;}}
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;overflow-wrap:anywhere;}}
.section-title{{break-after:avoid-page;page-break-after:avoid;}}
@media print{{body{{background:transparent;}}}}";
} }
private static string RenderSidebarMetaSection(string title, IEnumerable<string?> items) private static string RenderSidebarMetaSection(string title, IEnumerable<string?> items)
{ {
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{item}</p>")); var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{Encode(item)}</p>"));
if (string.IsNullOrWhiteSpace(content)) return string.Empty; if (string.IsNullOrWhiteSpace(content)) return string.Empty;
return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>"; return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>";
} }
@@ -359,7 +366,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
{ {
if (items.Count == 0) return string.Empty; if (items.Count == 0) return string.Empty;
var tag = bulletList ? "summary" : "custom-list"; var tag = bulletList ? "summary" : "custom-list";
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>"; return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(RenderListItem))}</ul></section>";
} }
private static string RenderSkillSection(IReadOnlyCollection<string> skills) private static string RenderSkillSection(IReadOnlyCollection<string> skills)
@@ -376,10 +383,12 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
{ {
var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode)); var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode));
var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent); var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent);
items.Append("<article class=\"entry\">"); items.Append(IsFlowingEntry(entry.Title, subtitle, dateRange, entry.Bullets)
? "<article class=\"entry entry-flow\">"
: "<article class=\"entry\">");
items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>"); items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>"); if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(bullet => $"<li>{Encode(bullet)}</li>"))}</ul>"); if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(RenderListItem))}</ul>");
items.Append("</article>"); items.Append("</article>");
} }
return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>"; return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>";
@@ -394,13 +403,15 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) } var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) }
.Where(x => !string.IsNullOrWhiteSpace(x)) .Where(x => !string.IsNullOrWhiteSpace(x))
.Select(Encode)); .Select(Encode));
items.Append("<article class=\"entry\">"); items.Append(IsFlowingEntry(entry.Qualification, subtitle, null, entry.Details)
? "<article class=\"entry entry-flow\">"
: "<article class=\"entry\">");
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel) var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
? entry.Qualification ? entry.Qualification
: $"{entry.Qualification} ({entry.QualificationLevel})"; : $"{entry.Qualification} ({entry.QualificationLevel})";
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>"); items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>"); if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>"); if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(RenderListItem))}</ul>");
items.Append("</article>"); items.Append("</article>");
} }
return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>"; return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>";
@@ -409,9 +420,22 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
private static string RenderCustomSection(TailoredCvCustomSection section) private static string RenderCustomSection(TailoredCvCustomSection section)
{ {
if (section.Items.Count == 0) return string.Empty; if (section.Items.Count == 0) return string.Empty;
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>"; return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(RenderListItem))}</ul></section>";
} }
private static string RenderListItem(string? value) =>
$"<li{(IsFlowingItem(value) ? " class=\"item-flow\"" : string.Empty)}>{Encode(value)}</li>";
private static bool IsFlowingEntry(string? title, string? subtitle, string? meta, IEnumerable<string> items)
{
var values = items.ToList();
var textLength = (title?.Length ?? 0) + (subtitle?.Length ?? 0) + (meta?.Length ?? 0)
+ values.Sum(item => item?.Length ?? 0);
return values.Count > 5 || values.Any(IsFlowingItem) || textLength > 900;
}
private static bool IsFlowingItem(string? value) => (value?.Length ?? 0) > 360;
private static string FormatDateRange(string? start, string? end, bool isCurrent) private static string FormatDateRange(string? start, string? end, bool isCurrent)
{ {
var normalizedStart = (start ?? string.Empty).Trim(); var normalizedStart = (start ?? string.Empty).Trim();
+38 -15
View File
@@ -139,7 +139,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
private static string RenderEntry(CvRenderEntry entry) private static string RenderEntry(CvRenderEntry entry)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append(@"<article class=""entry"">"); sb.Append(IsFlowingEntry(entry) ? @"<article class=""entry entry-flow"">" : @"<article class=""entry"">");
var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta); var hasMeta = !string.IsNullOrWhiteSpace(entry.Meta);
sb.Append(@"<div class=""entry-head"">"); sb.Append(@"<div class=""entry-head"">");
sb.Append($@"<div class=""entry-title"">{Enc(entry.Title)}</div>"); sb.Append($@"<div class=""entry-title"">{Enc(entry.Title)}</div>");
@@ -153,7 +153,22 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
} }
private static string Items(IEnumerable<string> items) => private static string Items(IEnumerable<string> items) =>
string.Join("", items.Select(i => $"<li>{Inline(i)}</li>")); string.Join("", items.Select(i => $"<li{(IsFlowingItem(i) ? " class=\"item-flow\"" : string.Empty)}>{Inline(i)}</li>"));
// Short entries stay together. Large entries must be allowed to paginate between bullets or
// paragraphs; forcing an entry taller than the printable area to remain whole clips content in
// Chromium. The renderer only selects the pagination strategy—it never shrinks the text.
private static bool IsFlowingEntry(CvRenderEntry entry)
{
var textLength = (entry.Title?.Length ?? 0)
+ (entry.Subtitle?.Length ?? 0)
+ (entry.Meta?.Length ?? 0)
+ entry.Bullets.Sum(item => item?.Length ?? 0)
+ entry.Tags.Sum(item => item?.Length ?? 0);
return entry.Bullets.Count > 5 || entry.Bullets.Any(IsFlowingItem) || textLength > 900;
}
private static bool IsFlowingItem(string? item) => (item?.Length ?? 0) > 360;
// Safe inline rich text for bullets/summary. HTML-escape EVERYTHING first (so any user markup is // Safe inline rich text for bullets/summary. HTML-escape EVERYTHING first (so any user markup is
// inert), then re-introduce a tiny whitelist: **bold**, *italic*, __underline__, [text](url) with // inert), then re-introduce a tiny whitelist: **bold**, *italic*, __underline__, [text](url) with
@@ -186,8 +201,11 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
"bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}", "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(t.HeadingSizePt * 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 layoutCss = twoColumn var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{(t.Layout == "sidebar-right" ? $"1fr {F(t.SidebarWidthMm)}mm" : $"{F(t.SidebarWidthMm)}mm 1fr")};min-height:{page.h};}} ? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}} .sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
.sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}} .sidebar .section-title{{color:{t.SidebarInk};border-color:rgba(255,255,255,.35);}}
.sidebar .tag{{border-color:rgba(255,255,255,.4);}} .sidebar .tag{{border-color:rgba(255,255,255,.4);}}
@@ -204,12 +222,14 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return $@" return $@"
*{{box-sizing:border-box;}} *{{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;}} 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:hidden;}} .page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
h1,h2{{font-family:{headingFont};}} h1,h2{{font-family:{headingFont};}}
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;}} .name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.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;}} .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:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}}
.head-text,.main,.sidebar,.cols>*{{min-width:0;}}
.head-text{{flex:1;}} .head-text{{flex:1;}}
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}} .photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}}
.photo-square{{border-radius:2mm;}} .photo-square{{border-radius:2mm;}}
@@ -218,8 +238,8 @@ h1,h2{{font-family:{headingFont};}}
.photo img{{width:100%;height:100%;object-fit:cover;display:block;}} .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:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}}
.contact-stacked{{flex-direction:column;gap:1.8mm;}} .contact-stacked{{flex-direction:column;gap:1.8mm;}}
.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;}} .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;}} .contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}}
.contact svg{{width:3.2mm;height:3.2mm;flex:0 0 auto;opacity:.85;}} .contact svg{{width:3.2mm;height:3.2mm;flex:0 0 auto;opacity:.85;}}
.hero{{margin-bottom:{sectionGap}mm;}} .hero{{margin-bottom:{sectionGap}mm;}}
.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}} .hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}}
@@ -230,21 +250,24 @@ h1,h2{{font-family:{headingFont};}}
.bullets{{margin:0;padding-left:4.5mm;}} .bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}} .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;}} .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;}} .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;}}
.entry{{margin-bottom:{entryGap}mm;}} .entry{{margin-bottom:{entryGap}mm;}}
.entry:last-child{{margin-bottom:0;}} .entry:last-child{{margin-bottom:0;}}
.entry-head{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;}} .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;}} .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:nowrap;}} .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-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}} .entry-tags{{margin-top:1.4mm;}}
{layoutCss} {layoutCss}
/* Print quality: keep an entry whole across a page break, keep a heading with its content, and /* Print quality: keep normal entries whole, but allow intentionally classified long entries and
avoid single dangling lines. Chromium honours these in the Playwright PDF pass. */ long list items to flow. An unsplittable block taller than a page is otherwise clipped. */
.entry{{break-inside:avoid;page-break-inside:avoid;}} .entry{{break-inside:avoid-page;page-break-inside:avoid;}}
.entry-flow{{break-inside:auto;page-break-inside:auto;}}
.section-title{{break-after:avoid;page-break-after:avoid;}} .section-title{{break-after:avoid;page-break-after:avoid;}}
.tag,.contact-item{{break-inside:avoid;}} .tag,.contact-item{{break-inside:avoid;}}
.bullets li{{orphans:2;widows:2;}} .bullets li{{break-inside:avoid-page;page-break-inside:avoid;orphans:2;widows:2;overflow-wrap:anywhere;}}
.bullets li.item-flow{{break-inside:auto;page-break-inside:auto;}}
@media print{{body{{background:transparent;}}}}
@page{{size:{page.w} {page.h};margin:0;}} @page{{size:{page.w} {page.h};margin:0;}}
"; ";
} }
+9 -2
View File
@@ -76,8 +76,15 @@ variant, never on the profile. Each entry exposes hide, title/subtitle override,
editing (`RichTextField` — a markdown toolbar over a textarea; storage stays plain text, the server editing (`RichTextField` — a markdown toolbar over a textarea; storage stays plain text, the server
renderer converts the `**bold** *italic* __underline__ [text](url)` whitelist to safe HTML). renderer converts the `**bold** *italic* __underline__ [text](url)` whitelist to safe HTML).
**Preview** has zoom presets (±, slider, Fit), a measured page count with prev/next page navigation **Preview** has zoom presets (±, slider, measured Fit), physical A4/Letter dimensions, a ceiling-based
and dashed page-break indicators, and an "updating…" chip. **Customize** badges ATS-friendly themes. page count with prev/next navigation and page-break indicators, and an "updating…" chip. Three-page
and longer documents receive content-focus guidance rather than automatic font shrinking. Preview
requests and autosaves are ordered so stale responses cannot replace newer edits; PDF/public actions
save the current variant before consuming the stored render. **Customize** badges ATS-friendly themes.
Profile-backed and custom sections share one section order. Custom-section content remains stored in
`CustomSections`, while `Sections` holds its `custom:<key>` position and visibility. Legacy variants
without those order rows still append custom sections and acquire the shared order on their next edit.
## AI ## AI
+5 -3
View File
@@ -51,9 +51,11 @@ dense), `ats-classic` (single, no graphics), `nordic` (sidebar-right), `elegant`
- **ATS-friendliness** is a data flag: `CvTheme.AtsFriendly` (set on the single-column themes), - **ATS-friendliness** is a data flag: `CvTheme.AtsFriendly` (set on the single-column themes),
surfaced in `GET /api/cv/themes` and badged in the Customize tab. Two-column (sidebar) themes are not surfaced in `GET /api/cv/themes` and badged in the Customize tab. Two-column (sidebar) themes are not
flagged, as sidebar layouts can trip naive resume parsers. flagged, as sidebar layouts can trip naive resume parsers.
- **Print quality**: the renderer emits `break-inside: avoid` on entries, `break-after: avoid` on - **Print quality**: ordinary entries use `break-inside: avoid-page`; content classified as too tall
section headings, and widow/orphan control, so entries don't split across a page in the Playwright for a page is allowed to flow between bullets/paragraphs so Chromium cannot clip an unsplittable
PDF pass. block. Section headings avoid a following break, list items carry widow/orphan rules, and long
names/titles/contact values/URLs/tags wrap within `minmax(0, …)` columns. Typography is not scaled
down to mask overflow.
## Deliberately not here (yet) ## Deliberately not here (yet)
+1
View File
@@ -196,3 +196,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-162 | Focused workspace/table/workflow Jest; `ApplicationWorkspaceTests`; production frontend build; standalone TypeScript audit; route/native-popup search | Repository root / `job-tracker-ui` | Verify canonical dedicated job workspace, whole-row navigation, independent controls, list-state return, contextual section routes and richer owner-scoped details | PASS — frontend 8/8 and backend 9/9; optimized build passes; direct `/jobs/:id`, section route, return state, missing job and control isolation pass. Standalone TypeScript found only pre-existing test-prop/target errors, with no new application-source error | JSDOM/InMemory backend only; browser widths/themes/refresh and production remain. Legacy dialog source retained for rollback but is no longer reachable from the list | Repository increment verified | | V-162 | Focused workspace/table/workflow Jest; `ApplicationWorkspaceTests`; production frontend build; standalone TypeScript audit; route/native-popup search | Repository root / `job-tracker-ui` | Verify canonical dedicated job workspace, whole-row navigation, independent controls, list-state return, contextual section routes and richer owner-scoped details | PASS — frontend 8/8 and backend 9/9; optimized build passes; direct `/jobs/:id`, section route, return state, missing job and control isolation pass. Standalone TypeScript found only pre-existing test-prop/target errors, with no new application-source error | JSDOM/InMemory backend only; browser widths/themes/refresh and production remain. Legacy dialog source retained for rollback but is no longer reachable from the list | Repository increment verified |
| V-163 | Focused notification-popover/AppShell/Operations Jest; production frontend build; direct-navigation review | `job-tracker-ui` | Verify the header bell opens notification UI instead of routing to Reminders/Operations, while preserving global activity access | PASS — 3 suites and 6/6 tests; optimized TypeScript build passes. Popover fetch, count exposure, read, dismiss, notification-owned navigation and empty state are covered; Operations remains reachable through explicit “View all activity” | JSDOM/mocked API only; browser positioning/focus/theme and production remain | Repository increment verified | | V-163 | Focused notification-popover/AppShell/Operations Jest; production frontend build; direct-navigation review | `job-tracker-ui` | Verify the header bell opens notification UI instead of routing to Reminders/Operations, while preserving global activity access | PASS — 3 suites and 6/6 tests; optimized TypeScript build passes. Popover fetch, count exposure, read, dismiss, notification-owned navigation and empty state are covered; Operations remains reachable through explicit “View all activity” | JSDOM/mocked API only; browser positioning/focus/theme and production remain | Repository increment verified |
| V-164 | Career/Profile focused Jest; CV extraction/diff backend tests; AI-sidecar pytest; production frontend build; ingestion execution-path review | Repository root / `job-tracker-ui` / `tools/summarizer` | Reproduce and fix career-field resets while assessing the proposed Ollama accuracy pipeline | PASS — Career 17/17 including active-poll preservation; backend 8/8; sidecar 22/22; build passes. Polling now fetches run status only. Existing pipeline is confirmed as local parser/OCR → Ollama-first normalize/classify → deterministic C# validation/diff/review | Synthetic/JSDOM/fake model only; no real private CV, live Ollama model comparison, provider or production call. Repository `.venv` lacked pytest; global Python passed | Repository correction verified; model benchmark remains external/runtime work | | V-164 | Career/Profile focused Jest; CV extraction/diff backend tests; AI-sidecar pytest; production frontend build; ingestion execution-path review | Repository root / `job-tracker-ui` / `tools/summarizer` | Reproduce and fix career-field resets while assessing the proposed Ollama accuracy pipeline | PASS — Career 17/17 including active-poll preservation; backend 8/8; sidecar 22/22; build passes. Polling now fetches run status only. Existing pipeline is confirmed as local parser/OCR → Ollama-first normalize/classify → deterministic C# validation/diff/review | Synthetic/JSDOM/fake model only; no real private CV, live Ollama model comparison, provider or production call. Repository `.venv` lacked pytest; global Python passed | Repository correction verified; model benchmark remains external/runtime work |
| V-165 | CV renderer/resolver/template tests; Builder helper/editor/list Jest; optimized frontend build; real headless Chromium DOM/PDF probe; diff check | Repository root / `job-tracker-ui` | Verify professional multi-page CV rendering, physical preview metrics, custom-section ordering and stored-output safety under pathological content | PASS — backend 25/25; frontend 21/21; build passes. A 14-role/75-skill fixture with oversized name/email/URL produced zero horizontal offenders and a 9-page 173,196-byte PDF with extractable final-page text. Custom sections share persisted order; partial pages use ceiling count; stale preview/save/export races are gated | Synthetic data and local Chromium only; authenticated 375/768/1440 app journey, real private CV, production browser binary and DOCX remain unverified | Renderer/PDF repository scope verified; application-browser and production gates remain |
+22 -43
View File
@@ -1,52 +1,31 @@
# CV Builder Patterns # CV Builder interaction patterns
## Recommended Architecture Updated: 2026-08-15
Career Data ## Research scope
Current public product/help material was reviewed for [Reactive Resume](https://docs.rxresu.me/guides/fitting-content-on-a-page), [Resume.io](https://help.resume.io/en/articles/3785216), [Enhancv](https://help.enhancv.com/en/articles/14432262-how-to-add-a-new-section-to-your-resume-in-the-new-editor-toolbox-on-top), [Novorésumé](https://novoresume.com/career-blog/novoresume-templates-science), [FlowCV](https://flowcv.com/) and [Canva](https://www.canva.com/create/resumes/). This is pattern research, not a claim that authenticated/private product flows were inspected.
CV Builder ## Repeated useful patterns
| Pattern | Product signal | JobTracker decision |
|---|---|---|
| Structured content beside live output | Resume.io, FlowCV and Reactive Resume emphasise immediate preview rather than editing a raw document | Keep Career Profile as factual source and the variant editor as a presentation lens beside one server-rendered preview |
| Defaults first, advanced controls second | Novorésumé documents safe-zone typography/spacing choices; FlowCV emphasises guided creation | Preserve professional theme defaults; expose theme, font, density, page format and visibility without pixel-level design controls |
| Reorder at section and entry level | Enhancv exposes add-section/add-entry actions; Reactive Resume exposes drag ordering | Keep drag plus named arrow controls; place custom and profile-backed sections in one order |
| Pagination is visible and actionable | Resume.io documents page navigation and line spacing; Reactive Resume documents content fitting and page formats | Count partial pages with `ceil`, respect A4/Letter, show boundaries/navigation and warn—not auto-shrink—at 3+ pages |
| Export reuses preview content | Reactive Resume documents one content/render path | Preserve one `CvRenderModel`/`ThemedCvRenderer` path for preview, PDF and public CV |
| Free-form design is optional, not the default | Canva is strong for visual freedom but less constrained around semantic resume structure | Do not reproduce a canvas editor; keep content portable, accessible and ATS-aware |
Theme ## Rendering rules
- Long unbroken values must wrap inside the page, including names, titles, employers, email addresses, URLs and skill chips.
- Ordinary entries stay together across a page boundary. Entries or list items too large for one page may flow; an unsplittable over-height block is a clipping bug.
- A section heading stays with following content where Chromium can honour paged-media rules.
- Typography is never globally reduced to hide overflow. Density remains an explicit user choice.
- A4 and Letter use their actual physical width and height in both the renderer and preview controls.
- Export first saves the current variant, so PDF/public output cannot silently use stale settings.
Output ## Product boundary
Career Profile owns facts. A CV variant owns ordering, visibility, wording overrides, custom sections and appearance. Templates remain data. PDF, public CV and browser preview consume the same resolved render tree. This avoids duplicate sources of truth and makes future DOCX output an exporter concern rather than a second editor.
---
# Good Patterns
## Structured Content
User edits:
- Experience.
- Skills.
- Education.
Not raw documents.
---
## Live Preview
Changes immediately visible.
---
## Template Independence
Content should work with any template.
---
# Avoid
Canva-style complexity.
Users should not manually design every pixel.
+20 -7
View File
@@ -1,8 +1,8 @@
# CAREER-002 CV Builder redesign # CAREER-002 CV Builder redesign
Updated: 2026-08-09 Updated: 2026-08-15
Status: `IMPLEMENTED — NOT VERIFIED`. Repository interactions and automated/build gates are complete; browser, external research and production gates remain. Status: `IMPLEMENTED — NOT VERIFIED`. Repository, automated and pathological Chromium/PDF gates are complete; authenticated application-browser and production gates remain.
## Revalidated capability matrix ## Revalidated capability matrix
@@ -20,6 +20,18 @@ Status: `IMPLEMENTED — NOT VERIFIED`. Repository interactions and automated/bu
| Preview | Debounced live server render beside editor; visible failure/retry; below editor on narrow layout | Implemented; responsive/browser review pending | | Preview | Debounced live server render beside editor; visible failure/retry; below editor on narrow layout | Implemented; responsive/browser review pending |
| Versions/public/PDF | Existing history/restore, public toggle/link and PDF export | Preserved; regression/production gates remain | | Versions/public/PDF | Existing history/restore, public toggle/link and PDF export | Preserved; regression/production gates remain |
## Multi-page/rendering increment
- Removed page-level clipping and added wrapping/min-width protections for names, titles, employers, contact values, URLs, tags and two-column content.
- Short entries remain intact. Large entries and large list items flow at safe internal boundaries instead of becoming unsplittable blocks taller than a page.
- The legacy job-specific CV templates receive the same wrapping/pagination rules; sidebar values are now HTML-encoded consistently.
- Preview measures both document/body height, uses the selected A4 or Letter dimensions, applies ceiling-based page counts, exposes real Fit/percentage controls, and reports horizontal overflow.
- Three-page and longer CVs receive content-density guidance. Text size is not silently reduced.
- Custom sections now participate in the same section order as master-profile sections.
- Autosaves are serialized; stale preview responses are ignored; export/public actions save pending edits first.
- CV deletion and version restore use the shared application dialog system.
- The non-functional page-number switch is no longer advertised; `ShowPageNumbers` remains a backward-compatible exporter extension point until the Chromium CLI path supports controlled PDF footers.
## Save-integrity increment ## Save-integrity increment
- Latest settings/name are retained independently of render closures. - Latest settings/name are retained independently of render closures.
@@ -38,18 +50,19 @@ Status: `IMPLEMENTED — NOT VERIFIED`. Repository interactions and automated/bu
## Verification to date ## Verification to date
- Focused Builder list/helper/deep-link/save/navigation: 3 suites, 17/17 tests; editor deep-link/interaction 9/9. - Focused Builder list/helper/deep-link/save/navigation: 3 suites, 21/21 tests; editor deep-link/interaction 9/9.
- Focused renderer/template backend: 25/25 tests.
- Full frontend: 49/49 suites, 184/184 tests. - Full frontend: 49/49 suites, 184/184 tests.
- Production build/TypeScript and `git diff --check`: pass. - Production build/TypeScript and `git diff --check`: pass.
- Implementation commits: `b58cc19`, `a5b74e0`, `2043349`. - Pathological Chromium render: 14 long roles, 75 long skills, oversized name/email/URL, zero horizontal-overflow elements, nine-page PDF (173,196 bytes) with extractable final-page content.
- Implementation commits: `b58cc19`, `a5b74e0`, `2043349` plus the V-165 checkpoint.
## Research limitation ## Product research
The programme asks for authenticated FlowCV browser research. Browser tooling was finalized earlier in this session and no authorized FlowCV session was inspected. Existing repository research documents were read as prior context, but this is not reported as fresh browser evidence. Implementation continues from confirmed JobTracker gaps. Public current material for Reactive Resume, Resume.io, Enhancv, Novorésumé, FlowCV and Canva was reviewed. Repeated patterns and adopted/rejected decisions are recorded in `docs/research/cv-builder-patterns.md`. No authenticated/private competitor session was used or claimed.
## Remaining verification ## Remaining verification
- Decide and verify narrow-screen editor/preview navigation from rendered behavior.
- Run 375/768/1440, Light/Dark, keyboard/focus, empty/error and production synthetic-variant checks. - Run 375/768/1440, Light/Dark, keyboard/focus, empty/error and production synthetic-variant checks.
- Recheck DOCX status honestly: the current architecture documents it as an extension point, while PDF/public rendering are implemented. - Recheck DOCX status honestly: the current architecture documents it as an extension point, while PDF/public rendering are implemented.
+10
View File
@@ -689,3 +689,13 @@
- **Consequences:** all career fields retain unsaved edits while processing status changes. AI reconstruction remains explicit, local-first and review-gated; accuracy work can be benchmarked per model without changing the ingestion boundary. - **Consequences:** all career fields retain unsaved edits while processing status changes. AI reconstruction remains explicit, local-first and review-gated; accuracy work can be benchmarked per model without changing the ingestion boundary.
- **User approval required:** No; this implements the requested behavior within the existing approved local-AI architecture. - **User approval required:** No; this implements the requested behavior within the existing approved local-AI architecture.
- **Reversible:** Rejoin run/profile loads, though that would restore the confirmed data-loss UX defect; no schema/config/data migration changed. - **Reversible:** Rejoin run/profile loads, though that would restore the confirmed data-loss UX defect; no schema/config/data migration changed.
## DEC-070 — Let long CV content paginate instead of shrinking or clipping
- **Date:** 2026-08-15
- **Decision:** Keep normal entries together, classify over-height entries/list items as flowable, wrap every user-controlled text boundary, and use physical A4/Letter metrics in the editor. Put custom and profile-backed sections in one persisted order. Serialize autosaves and save before export/public rendering.
- **Reason/evidence:** the renderer used `overflow:hidden`, fixed `1fr` columns and `break-inside:avoid` on every entry; the editor hardcoded A4 and rounded page counts. Together these could hide partial pages, clip unbroken values or make a block taller than the printable page impossible to paginate. Pathological Chromium/PDF proof produced nine readable pages with zero horizontal offenders without reducing font sizes.
- **Alternatives considered:** globally shrink text; truncate content; make every entry freely splittable; create a free-form canvas editor; maintain a separate custom-section order. These reduce readability, damage content, or duplicate state.
- **Consequences:** preview, public HTML and PDF retain one render path; normal entries avoid awkward splits while large content can cross pages safely. Existing variants remain compatible and acquire shared custom ordering on edit.
- **User approval required:** No; this implements the requested CV rework without schema, dependency or production changes.
- **Reversible:** Revert the renderer/editor/resolver checkpoint; stored settings remain compatible because the existing `Sections` and `custom:<key>` contract is used.
+4 -4
View File
@@ -2,17 +2,17 @@
Updated: 2026-08-15 Updated: 2026-08-15
- **Overall programme status:** Active. Seven packages are locally verified; twenty-two packages through UX-003 are implemented with automated/runtime evidence but blocked from applicable live/provider/production gates; JOBS-002 is now in progress. Gitea run 609 passes the prior complete pull-request CI; DEP-001 awaits approved merge-to-main and production verification. - **Overall programme status:** Active. Seven packages are locally verified; twenty-two packages through UX-003 are implemented with automated/runtime evidence but blocked from applicable live/provider/production gates; CAREER-002 is now in progress. Gitea run 609 passes the prior complete pull-request CI; DEP-001 awaits approved merge-to-main and production verification.
- **Current work package:** `CAREER-002` — professional CV Builder and robust rendering (`IN PROGRESS`). JOBS-002 repository scope and the Career edit-persistence correction are locally verified; CV editor/rendering rework is next. - **Current work package:** `CAREER-002` — professional CV Builder and robust rendering (`IN PROGRESS`). Renderer/editor/custom-order work and pathological Chromium/PDF proof pass; authenticated multi-width/theme application-browser regression is next.
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates. - **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`). - **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`).
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001 (`IMPLEMENTED — NOT VERIFIED`). UX-003 safe local/browser scope is implemented; production/native-device gates remain. - **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001 (`IMPLEMENTED — NOT VERIFIED`). UX-003 safe local/browser scope is implemented; production/native-device gates remain.
- **Production-verified work:** None. - **Production-verified work:** None.
- **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks. - **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks.
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages. - **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Next five work packages:** JOBS-002 applications/workspace; PRODUCT-001 homepage/Pro claims; VER-001 action matrix; production-blocked SEC-006/007 when package-index permission is available; REL-001 after prerequisites. - **Next five work packages:** finish CAREER-002 application-browser regression; finish JOBS-002 browser regression; PRODUCT-001 homepage/Pro claims; VER-001 action matrix; production-blocked SEC-006/007 when package-index permission is available.
- **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 5 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. - **Status counts:** 7 `VERIFIED LOCALLY`; 22 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 5 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend baseline 631/631 plus admin safety 4/4, workspace 9/9 and CV extraction/diff 8/8; frontend baseline 51/51 suites and 207/207 plus theme/confirm/admin 11/11, JOBS-002 8/8, notifications 6/6 and Career 17/17; AI sidecar 22/22; Playwright 6/6; npm audit 0 vulnerabilities; production build passes. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded. - **Test status:** backend baseline 631/631 plus admin safety 4/4, workspace 9/9, CV extraction/diff 8/8 and CV renderer/templates 25/25; frontend baseline 51/51 suites and 207/207 plus theme/confirm/admin 11/11, JOBS-002 8/8, notifications 6/6, Career 17/17 and CV Builder 21/21; AI sidecar 22/22; Playwright 6/6; pathological Chromium/PDF 9 pages with zero horizontal offenders; npm audit 0 vulnerabilities; production build passes. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default. - **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred. - **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap. - **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
+4 -4
View File
@@ -632,10 +632,10 @@ Ordering differences from the suggested list:
- **Required browser verification:** authorized FlowCV research if accessible, never bypass auth; original JobTracker design at three widths/themes/keyboard/focus. - **Required browser verification:** authorized FlowCV research if accessible, never bypass auth; original JobTracker design at three widths/themes/keyboard/focus.
- **Required production verification:** existing variants/edit/export/public render smoke. - **Required production verification:** existing variants/edit/export/public render smoke.
- **Status:** `IMPLEMENTED — NOT VERIFIED`. - **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** fresh FlowCV inspection and JobTracker three-width/theme/keyboard checks require a new browser session; production synthetic-variant smoke requires access. - **Blocker:** authenticated JobTracker three-width/theme/keyboard checks and production synthetic-variant smoke require their runtime environments.
- **Evidence:** `docs/verification/career-002-cv-builder.md`; V-120V-125. Save, navigation, custom entries, named controls, persistence and preview failure/retry pass 17/17 focused, 49/49 suites and 184/184 full plus build. - **Evidence:** `docs/verification/career-002-cv-builder.md`; V-120V-125 and V-165. Builder 21/21 and renderer/templates 25/25 pass plus build. Pathological Chromium output has zero horizontal offenders and produces a readable nine-page PDF without text shrinking.
- **Commit:** `b58cc19`, `a5b74e0`, `2043349`. - **Commit:** `b58cc19`, `a5b74e0`, `2043349` plus the V-165 checkpoint.
- **Remaining work:** browser/FlowCV/production gates and honest deployed DOCX capability check only. Avoid rewriting already-working features. - **Remaining work:** authenticated application-browser/production gates and honest deployed DOCX capability check only. Public competitor-pattern research is complete; no authenticated competitor session is claimed.
### MAIL-001 — Consolidated job-email hub and explicit sending ### MAIL-001 — Consolidated job-email hub and explicit sending
+7 -7
View File
@@ -2,17 +2,17 @@
Updated: 2026-08-15 Updated: 2026-08-15
- **Exact current task:** continue CAREER-002 with CV Builder editing and multi-page rendering rework, then run combined browser regression. - **Exact current task:** finish CAREER-002 authenticated/multi-width browser regression, then run combined repository gates and continue the remaining programme.
- **Last completed step:** separated extraction-run polling from profile loading, added unsaved-state safety, and verified the existing Python/Ollama/C# hybrid ingestion contract. - **Last completed step:** hardened both CV renderers for long/multi-page content, corrected A4/Letter preview measurement, unified custom-section ordering, serialized stored-output actions, and completed public competitor-pattern research.
- **Files currently modified:** CareerProfilePage, its focused test and Career/tracking evidence. - **Files currently modified:** CV renderer/resolver/template, Builder editor/list/helpers/tests and CV architecture/research/verification/tracking documentation.
- **Commands already run:** Career Jest 17/17; CV extraction/diff backend 8/8; AI sidecar 22/22; production frontend build. - **Commands already run:** CV renderer/templates backend 25/25; Builder frontend 21/21; optimized frontend build; real Chromium DOM/PDF pathological fixture.
- **Test results:** Career 17/17, extraction/diff 8/8, sidecar 22/22 and build pass. The first `.venv` pytest attempt failed environmentally because pytest is absent; `py -m pytest` passed. Repository-wide standalone TypeScript baseline remains as previously recorded. - **Test results:** focused backend/frontend and build pass. The pathological 14-role/75-skill fixture had zero horizontal overflow offenders and produced a nine-page 173,196-byte PDF with extractable final-page text. Full-suite and authenticated application-browser gates remain next.
- **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed. - **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed.
- **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed. - **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed.
- **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred. - **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred.
- **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred. - **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred.
- **Uncommitted changes:** V-164 Career polling/edit safety and tracking; no dependency/schema/config change. - **Uncommitted changes:** V-165 CV Builder/rendering/research checkpoint; no dependency/schema/config change.
- **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007. - **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007.
- **Exact next action:** commit/push V-164; implement CAREER-002 renderer overflow/page-boundary and editor workflow corrections. - **Exact next action:** commit/push V-165; run authenticated/mocked 375/768/1440 light/dark CV and combined JOBS/theme/admin/browser regression, then full repository gates.
- **Work that can continue independently:** JOBS-002, PRODUCT-001 and VER-001. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates. - **Work that can continue independently:** JOBS-002, PRODUCT-001 and VER-001. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates.
- **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved. - **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.
+29 -1
View File
@@ -7,6 +7,8 @@ import CvBuilderPage from './views/CvBuilderPage';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { api } from './api'; import { api } from './api';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
const mockNavigate = jest.fn(); const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
@@ -33,7 +35,11 @@ function renderPage() {
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ToastProvider> <ToastProvider>
<I18nProvider> <I18nProvider>
<CvBuilderPage /> <ConfirmProvider>
<PromptProvider>
<CvBuilderPage />
</PromptProvider>
</ConfirmProvider>
</I18nProvider> </I18nProvider>
</ToastProvider> </ToastProvider>
</MemoryRouter>, </MemoryRouter>,
@@ -71,3 +77,25 @@ test('shows the empty state and creates a CV then navigates to the editor', asyn
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' }))); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' })));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
}); });
test('CV deletion requires confirmation and supports cancellation', async () => {
mockedApi.get.mockResolvedValueOnce({ data: [
{ id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() },
] } as any);
mockedApi.delete.mockResolvedValue({ data: null } as any);
renderPage();
await screen.findByText('Frontend CV');
fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' }));
expect(await screen.findByRole('dialog', { name: 'Delete CV' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(mockedApi.delete).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Actions for Frontend CV' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'Delete' }));
fireEvent.click(await screen.findByRole('button', { name: 'Delete CV' }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith('/cv/variants/1'));
await waitFor(() => expect(screen.queryByText('Frontend CV')).not.toBeInTheDocument());
});
+15 -1
View File
@@ -1,4 +1,4 @@
import { moveItem, wrapSelection } from "./cvBuilder"; import { getCvPageCount, getCvPageMetrics, moveItem, wrapSelection } from "./cvBuilder";
describe("moveItem", () => { describe("moveItem", () => {
test("moves an item forward", () => { test("moves an item forward", () => {
@@ -37,3 +37,17 @@ describe("wrapSelection", () => {
expect(r.text.slice(r.selStart, r.selEnd)).toBe("docs"); expect(r.text.slice(r.selStart, r.selEnd)).toBe("docs");
}); });
}); });
describe("CV page measurement", () => {
test("uses the selected physical page size", () => {
expect(getCvPageMetrics("a4")).toMatchObject({ widthMm: 210, heightMm: 297 });
expect(getCvPageMetrics("letter")).toMatchObject({ widthMm: 215.9, heightMm: 279.4 });
});
test("rounds layout pixels without hiding partial pages", () => {
const { heightPx } = getCvPageMetrics("a4");
expect(getCvPageCount(Math.ceil(heightPx), heightPx)).toBe(1);
expect(getCvPageCount(heightPx + 20, heightPx)).toBe(2);
expect(getCvPageCount(heightPx * 2 + 20, heightPx)).toBe(3);
});
});
+29
View File
@@ -96,6 +96,35 @@ export const SECTION_LABELS: Record<string, string> = {
interests: "Interests", interests: "Interests",
}; };
const CSS_PIXELS_PER_MM = 96 / 25.4;
export type CvPageMetrics = {
widthMm: number;
heightMm: number;
widthPx: number;
heightPx: number;
};
export function getCvPageMetrics(pageSize?: string | null): CvPageMetrics {
const letter = pageSize?.trim().toLowerCase() === "letter";
const widthMm = letter ? 215.9 : 210;
const heightMm = letter ? 279.4 : 297;
return {
widthMm,
heightMm,
widthPx: widthMm * CSS_PIXELS_PER_MM,
heightPx: heightMm * CSS_PIXELS_PER_MM,
};
}
// Browser layout dimensions are rounded to whole pixels. The small tolerance prevents a page whose
// min-height rounds up by one pixel from being reported as two pages, while still using ceil for any
// genuine spill onto the next page.
export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): number {
if (!Number.isFinite(contentHeightPx) || !Number.isFinite(pageHeightPx) || pageHeightPx <= 0) return 1;
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx));
}
export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings { export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
return { return {
themeId, themeId,
+152 -76
View File
@@ -28,7 +28,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import { import {
AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, moveItem, cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
} from "../cvBuilder"; } from "../cvBuilder";
import { useAccountPlan } from "../accountPlan"; import { useAccountPlan } from "../accountPlan";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
@@ -42,7 +42,7 @@ const FONTS = [
"'Poppins', 'Segoe UI', Arial, sans-serif", "'Poppins', 'Segoe UI', Arial, sans-serif",
]; ];
const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"]; const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"];
const A4_PAGE_PX = (297 / 25.4) * 96; // one A4 page height in CSS px at 96dpi const MIN_PREVIEW_ZOOM = 0.32;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error"; type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
export default function CvBuilderEditor() { export default function CvBuilderEditor() {
@@ -64,21 +64,27 @@ export default function CvBuilderEditor() {
const [previewing, setPreviewing] = useState(false); const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState(false); const [previewError, setPreviewError] = useState(false);
const [previewRevision, setPreviewRevision] = useState(0); const [previewRevision, setPreviewRevision] = useState(0);
const [previewHeight, setPreviewHeight] = useState(() => getCvPageMetrics("a4").heightPx);
const [previewOverflow, setPreviewOverflow] = useState(false);
const [pages, setPages] = useState(1); const [pages, setPages] = useState(1);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [saveState, setSaveState] = useState<SaveState>("idle"); const [saveState, setSaveState] = useState<SaveState>("idle");
const [exporting, setExporting] = useState(false);
const [publishing, setPublishing] = useState(false);
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]); const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveRevision = useRef(0); const saveRevision = useRef(0);
const saveQueue = useRef<Promise<boolean>>(Promise.resolve(true));
const latestSettings = useRef<CvVariantSettings | null>(null); const latestSettings = useRef<CvVariantSettings | null>(null);
const latestName = useRef(""); const latestName = useRef("");
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewRequest = useRef(0);
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const contentHeight = useRef(A4_PAGE_PX);
const blockerPromptOpen = useRef(false); const blockerPromptOpen = useRef(false);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -118,17 +124,20 @@ export default function CvBuilderEditor() {
// Debounced live preview. // Debounced live preview.
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
const request = ++previewRequest.current;
setPreviewing(true); setPreviewing(true);
if (previewTimer.current) clearTimeout(previewTimer.current); if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(async () => { previewTimer.current = setTimeout(async () => {
try { try {
const render = await cvBuilderApi.previewSettings(settings); const render = await cvBuilderApi.previewSettings(settings);
if (previewRequest.current !== request) return;
setHtml(render.html); setHtml(render.html);
setPreviewError(false); setPreviewError(false);
} catch { } catch {
if (previewRequest.current !== request) return;
setPreviewError(true); setPreviewError(true);
} finally { } finally {
setPreviewing(false); if (previewRequest.current === request) setPreviewing(false);
} }
}, 300); }, 300);
return () => { return () => {
@@ -137,15 +146,20 @@ export default function CvBuilderEditor() {
}, [settings, previewRevision]); }, [settings, previewRevision]);
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => { const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
setSaveState("saving"); const save = async () => {
try { if (saveRevision.current === revision) setSaveState("saving");
await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" }); try {
if (saveRevision.current === revision) setSaveState("saved"); await cvBuilderApi.save(variantId, { name: nextName, settings: next, source: "autosave" });
return true; if (saveRevision.current === revision) setSaveState("saved");
} catch { return true;
if (saveRevision.current === revision) setSaveState("error"); } catch {
return false; if (saveRevision.current === revision) setSaveState("error");
} return false;
}
};
const queued = saveQueue.current.then(save, save);
saveQueue.current = queued;
return queued;
}, [variantId]); }, [variantId]);
const scheduleSave = useCallback( const scheduleSave = useCallback(
@@ -221,6 +235,11 @@ export default function CvBuilderEditor() {
}, []); }, []);
const togglePublic = async () => { const togglePublic = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
toast("Save the current CV before changing its public link.", "error");
return;
}
setPublishing(true);
try { try {
const updated = await cvBuilderApi.setPublic(variantId, !isPublic); const updated = await cvBuilderApi.setPublic(variantId, !isPublic);
setIsPublic(updated.isPublic); setIsPublic(updated.isPublic);
@@ -228,6 +247,8 @@ export default function CvBuilderEditor() {
toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success"); toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success");
} catch (err) { } catch (err) {
toast(getApiErrorMessage(err, "Could not change visibility."), "error"); toast(getApiErrorMessage(err, "Could not change visibility."), "error");
} finally {
setPublishing(false);
} }
}; };
@@ -237,6 +258,11 @@ export default function CvBuilderEditor() {
}; };
const exportPdf = async () => { const exportPdf = async () => {
if (hasUnsavedChanges && !(await retrySave())) {
toast("Save the current CV before exporting it.", "error");
return;
}
setExporting(true);
try { try {
const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob" }); const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob" });
const url = URL.createObjectURL(res.data as Blob); const url = URL.createObjectURL(res.data as Blob);
@@ -247,6 +273,8 @@ export default function CvBuilderEditor() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
toast(getApiErrorMessage(err, "PDF export failed."), "error"); toast(getApiErrorMessage(err, "PDF export failed."), "error");
} finally {
setExporting(false);
} }
}; };
@@ -259,6 +287,14 @@ export default function CvBuilderEditor() {
}; };
const restore = async (version: number) => { const restore = async (version: number) => {
if (!(await confirmAction(`Restore version ${version}? Your current saved CV remains in the version history.`, {
title: `Restore version ${version}`,
confirmLabel: "Restore version",
}))) return;
if (hasUnsavedChanges && !(await retrySave())) {
toast("Save the current CV before restoring an older version.", "error");
return;
}
try { try {
const updated = await cvBuilderApi.restore(variantId, version); const updated = await cvBuilderApi.restore(variantId, version);
applyVariant(updated); applyVariant(updated);
@@ -273,19 +309,33 @@ export default function CvBuilderEditor() {
const onIframeLoad = () => { const onIframeLoad = () => {
try { try {
const doc = iframeRef.current?.contentDocument; const doc = iframeRef.current?.contentDocument;
const h = doc?.body?.scrollHeight ?? A4_PAGE_PX; const h = Math.max(
contentHeight.current = h; pageMetrics.heightPx,
if (iframeRef.current) iframeRef.current.style.height = `${h}px`; doc?.body?.scrollHeight ?? 0,
setPages(Math.max(1, Math.round(h / A4_PAGE_PX))); doc?.documentElement?.scrollHeight ?? 0,
);
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
setPreviewHeight(h);
setPages(pageCount);
setPage((current) => Math.min(current, pageCount));
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0);
setPreviewOverflow(contentWidth > viewportWidth + 2);
} catch { } catch {
setPages(1); setPages(1);
setPreviewOverflow(false);
} }
}; };
const goToPage = (p: number) => { const goToPage = (p: number) => {
const clamped = Math.min(Math.max(1, p), pages); const clamped = Math.min(Math.max(1, p), pages);
setPage(clamped); setPage(clamped);
scrollRef.current?.scrollTo({ top: (clamped - 1) * A4_PAGE_PX * zoom, behavior: "smooth" }); scrollRef.current?.scrollTo({ top: (clamped - 1) * pageMetrics.heightPx * zoom, behavior: "smooth" });
};
const fitPreview = () => {
const availableWidth = Math.max(1, (scrollRef.current?.clientWidth ?? pageMetrics.widthPx) - 24);
setZoom(Math.min(1, Math.max(MIN_PREVIEW_ZOOM, availableWidth / pageMetrics.widthPx)));
}; };
if (loadError) { if (loadError) {
@@ -300,7 +350,7 @@ export default function CvBuilderEditor() {
return ( return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12 }}> <Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip> <Tooltip title="Back to CVs"><IconButton size="small" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)} <TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
@@ -309,14 +359,14 @@ export default function CvBuilderEditor() {
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} /> <SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
</Stack> </Stack>
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}> <Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} onClick={exportPdf}>PDF</Button> <Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Export PDF"}</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} onClick={togglePublic}> <Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
{isPublic ? "Public" : "Private"} {publishing ? "Updating…" : isPublic ? "Public" : "Private"}
</Button> </Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>} {isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
</Stack> </Stack>
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="fullWidth" sx={{ mb: 1.5 }}> <Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
<Tab label="Content" /> <Tab label="Content" />
<Tab label="Customize" /> <Tab label="Customize" />
<Tab label="AI Tools" /> <Tab label="AI Tools" />
@@ -329,43 +379,45 @@ export default function CvBuilderEditor() {
{tab === 3 && <HistoryTab versions={versions} onRestore={restore} />} {tab === 3 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper> </Paper>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "#e9edf2" }}> <Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography> <Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
{previewing && <Chip size="small" label="updating…" variant="outlined" />} {previewing && <Chip size="small" label="updating…" variant="outlined" />}
{previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />} {previewError && <Chip size="small" label="Preview unavailable" color="error" variant="outlined" />}
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>} {previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>Retry preview</Button>}
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
{pages > 1 && ( {pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={`${pages}-page CV`} />}
<Stack direction="row" alignItems="center" spacing={0.5}> <Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button> <Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>Prev</Button>
<Typography variant="caption">Page {page}/{pages}</Typography> <Typography variant="caption">Page {page} of {pages}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button> <Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>Next</Button>
</Stack> </Stack>
)}
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} /> <Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(0.4, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label="Zoom out" onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
<Slider size="small" value={zoom} min={0.4} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" /> <Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label="Zoom" />
<IconButton size="small" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label="Zoom in" onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
<Button size="small" onClick={() => setZoom(0.62)}>Fit</Button> <Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
<Button size="small" onClick={fitPreview}>Fit</Button>
</Stack> </Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}> <Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
<Box sx={{ position: "relative", width: `calc(210mm * ${zoom})`, height: `calc(${contentHeight.current}px * ${zoom})`, flex: "0 0 auto" }}> <Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `${previewHeight * zoom}px`, flex: "0 0 auto" }}>
<iframe <iframe
ref={iframeRef} ref={iframeRef}
title="CV preview" title="CV preview"
srcDoc={html} srcDoc={html}
onLoad={onIframeLoad} onLoad={onIframeLoad}
style={{ style={{
width: "210mm", height: `${contentHeight.current}px`, border: "none", width: `${pageMetrics.widthMm}mm`, height: `${previewHeight}px`, border: "none",
transform: `scale(${zoom})`, transformOrigin: "top left", transform: `scale(${zoom})`, transformOrigin: "top left",
boxShadow: "0 8px 30px rgba(0,0,0,0.18)", background: "#fff", display: "block", boxShadow: "0 8px 30px rgba(0,0,0,0.24)", background: "#fff", display: "block",
}} }}
/> />
{Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => ( {Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
<Box key={i} aria-hidden sx={{ <Box key={i} aria-hidden sx={{
position: "absolute", left: 0, right: 0, top: `calc(${(i + 1) * A4_PAGE_PX}px * ${zoom})`, position: "absolute", left: 0, right: 0, top: `${(i + 1) * pageMetrics.heightPx * zoom}px`,
borderTop: "2px dashed rgba(220,38,38,0.55)", pointerEvents: "none", borderTop: "2px dashed", borderColor: "error.main", opacity: 0.72, pointerEvents: "none",
}} /> }} />
))} ))}
</Box> </Box>
@@ -384,7 +436,7 @@ function EditorSkeleton() {
<Skeleton variant="rounded" height={44} sx={{ mt: 2 }} /> <Skeleton variant="rounded" height={44} sx={{ mt: 2 }} />
{[0, 1, 2, 3, 4].map((i) => <Skeleton key={i} variant="rounded" height={40} sx={{ mt: 1 }} />)} {[0, 1, 2, 3, 4].map((i) => <Skeleton key={i} variant="rounded" height={40} sx={{ mt: 1 }} />)}
</Paper> </Paper>
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "#e9edf2", display: "flex", justifyContent: "center" }}> <Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", display: "flex", justifyContent: "center" }}>
<Skeleton variant="rounded" width="70%" height={620} /> <Skeleton variant="rounded" width="70%" height={620} />
</Paper> </Paper>
</Box> </Box>
@@ -421,11 +473,18 @@ function ContentTab({ settings, update, outline }: {
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
// Full section list = configured order (once touched) else default, always including every known key. // Full section list = configured order (once touched) else default, always including every known key.
const sectionRows: CvSectionSetting[] = useMemo(() => { const sectionRows: CvSectionSetting[] = useMemo(() => {
const base = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key })); const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
const have = new Set(base.map((s) => s.key)); const have = new Set(base.map((s) => s.key));
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key }); for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
for (const custom of settings.customSections) {
const key = `custom:${custom.key}`;
if (!have.has(key)) {
base.push({ key, hidden: custom.hidden });
have.add(key);
}
}
return base; return base;
}, [settings.sections]); }, [settings.customSections, settings.sections]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows }); const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to))); const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
@@ -439,9 +498,22 @@ function ContentTab({ settings, update, outline }: {
return m; return m;
}, [outline]); }, [outline]);
const customBySectionKey = useMemo(() => Object.fromEntries(
settings.customSections.map((section) => [`custom:${section.key}`, section]),
), [settings.customSections]);
const orderedCustomSections = useMemo(() => {
const rank = new Map(sectionRows.map((section, index) => [section.key, index]));
return [...settings.customSections].sort((a, b) =>
(rank.get(`custom:${a.key}`) ?? Number.MAX_SAFE_INTEGER) - (rank.get(`custom:${b.key}`) ?? Number.MAX_SAFE_INTEGER));
}, [sectionRows, settings.customSections]);
const addCustom = () => { const addCustom = () => {
const key = `c${Date.now().toString(36)}`; const key = `c${Date.now().toString(36)}`;
update({ customSections: [...settings.customSections, { key, title: "New section", items: [] }] }); update({
customSections: [...settings.customSections, { key, title: "New section", items: [] }],
sections: [...sectionRows, { key: `custom:${key}` }],
});
}; };
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) => const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) }); update({ customSections: settings.customSections.map((c) => (c.key === key ? { ...c, ...patch } : c)) });
@@ -451,10 +523,11 @@ function ContentTab({ settings, update, outline }: {
confirmLabel: "Delete section", confirmLabel: "Delete section",
destructive: true, destructive: true,
}))) return; }))) return;
update({ customSections: settings.customSections.filter((c) => c.key !== section.key) }); update({
customSections: settings.customSections.filter((c) => c.key !== section.key),
sections: sectionRows.filter((row) => row.key !== `custom:${section.key}`),
});
}; };
const moveCustom = (index: number, delta: number) =>
update({ customSections: moveItem(settings.customSections, index, index + delta) });
const updateCustomItem = (key: string, index: number, value: string) => { const updateCustomItem = (key: string, index: number, value: string) => {
const section = settings.customSections.find((item) => item.key === key); const section = settings.customSections.find((item) => item.key === key);
if (!section) return; if (!section) return;
@@ -490,9 +563,14 @@ function ContentTab({ settings, update, outline }: {
dragging={sectionDrag.dragIndex === i} dragging={sectionDrag.dragIndex === i}
over={sectionDrag.overIndex === i && sectionDrag.dragIndex !== i} over={sectionDrag.overIndex === i && sectionDrag.dragIndex !== i}
outlineSection={outlineByKey[row.key]} outlineSection={outlineByKey[row.key]}
customSection={customBySectionKey[row.key]}
settings={settings} settings={settings}
onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))} onMove={(d) => writeSections(moveItem(sectionRows, i, i + d))}
onPatch={(p) => patchSection(row.key, p)} onPatch={(p) => patchSection(row.key, p)}
onRenameCustom={(title) => {
const custom = customBySectionKey[row.key];
if (custom) updateCustom(custom.key, { title });
}}
onUpdateSettings={update} onUpdateSettings={update}
/> />
))} ))}
@@ -509,39 +587,35 @@ function ContentTab({ settings, update, outline }: {
Add sections unique to this CV (e.g. a portfolio note) without changing your master profile. Add sections unique to this CV (e.g. a portfolio note) without changing your master profile.
</Typography> </Typography>
)} )}
{settings.customSections.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
Reorder and show or hide custom sections in the section list above.
</Typography>
)}
<Stack spacing={1} sx={{ mt: 1 }}> <Stack spacing={1} sx={{ mt: 1 }}>
{settings.customSections.map((c, sectionIndex) => ( {orderedCustomSections.map((c) => (
<Paper key={c.key} variant="outlined" sx={{ p: 1 }}> <Paper key={c.key} variant="outlined" sx={{ p: 1 }}>
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title" <TextField variant="standard" fullWidth value={c.title ?? ""} placeholder="Section title"
error={!c.title?.trim()} helperText={!c.title?.trim() ? "Enter a section title." : undefined} error={!c.title?.trim()} helperText={!c.title?.trim() ? "Enter a section title." : undefined}
onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} /> onChange={(e) => updateCustom(c.key, { title: e.target.value })} slotProps={{ htmlInput: { "aria-label": "Custom section title" } }} />
<IconButton size="small" aria-label="Move custom section up" disabled={sectionIndex === 0} onClick={() => moveCustom(sectionIndex, -1)}><ArrowUpwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label="Move custom section down" disabled={sectionIndex === settings.customSections.length - 1} onClick={() => moveCustom(sectionIndex, 1)}><ArrowDownwardIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={c.hidden ? "Show custom section" : "Hide custom section"} onClick={() => updateCustom(c.key, { hidden: !c.hidden })}>
{c.hidden ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
<IconButton size="small" aria-label="Remove custom section" onClick={() => void removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label="Remove custom section" onClick={() => void removeCustom(c)}><DeleteOutlineIcon fontSize="small" /></IconButton>
</Stack> </Stack>
{!c.hidden ? ( <Stack spacing={1} sx={{ mt: 1 }}>
<Stack spacing={1} sx={{ mt: 1 }}> {c.items.map((item, itemIndex) => (
{c.items.map((item, itemIndex) => ( <Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}>
<Stack key={`${c.key}-${itemIndex}`} direction="row" alignItems="flex-start" spacing={0.5}> <TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item}
<TextField fullWidth size="small" multiline minRows={2} label={`Entry ${itemIndex + 1}`} value={item} error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined}
error={!item.trim()} helperText={!item.trim() ? "Enter content or delete this entry." : undefined} onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} />
onChange={(event) => updateCustomItem(c.key, itemIndex, event.target.value)} /> <IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0}
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} up`} disabled={itemIndex === 0} onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton>
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex - 1) })}><ArrowUpwardIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1}
<IconButton size="small" aria-label={`Move custom entry ${itemIndex + 1} down`} disabled={itemIndex === c.items.length - 1} onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton>
onClick={() => updateCustom(c.key, { items: moveItem(c.items, itemIndex, itemIndex + 1) })}><ArrowDownwardIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton>
<IconButton size="small" aria-label={`Delete custom entry ${itemIndex + 1}`} onClick={() => void removeCustomItem(c, itemIndex)}><DeleteOutlineIcon fontSize="small" /></IconButton> </Stack>
</Stack> ))}
))} <Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button>
<Button size="small" startIcon={<AddIcon />} sx={{ alignSelf: "flex-start" }} onClick={() => updateCustom(c.key, { items: [...c.items, ""] })}>Add entry</Button> </Stack>
</Stack>
) : (
<Typography variant="caption" color="text.secondary">Hidden from this CV.</Typography>
)}
</Paper> </Paper>
))} ))}
</Stack> </Stack>
@@ -551,7 +625,7 @@ function ContentTab({ settings, update, outline }: {
} }
function SectionRow({ function SectionRow({
row, index, total, dragProps, dragging, over, outlineSection, settings, onMove, onPatch, onUpdateSettings, row, index, total, dragProps, dragging, over, outlineSection, customSection, settings, onMove, onPatch, onRenameCustom, onUpdateSettings,
}: { }: {
row: CvSectionSetting; row: CvSectionSetting;
index: number; index: number;
@@ -560,14 +634,16 @@ function SectionRow({
dragging: boolean; dragging: boolean;
over: boolean; over: boolean;
outlineSection?: CvOutlineSection; outlineSection?: CvOutlineSection;
customSection?: CvCustomSectionSetting;
settings: CvVariantSettings; settings: CvVariantSettings;
onMove: (delta: number) => void; onMove: (delta: number) => void;
onPatch: (patch: Partial<CvSectionSetting>) => void; onPatch: (patch: Partial<CvSectionSetting>) => void;
onRenameCustom: (title: string) => void;
onUpdateSettings: (p: Partial<CvVariantSettings>) => void; onUpdateSettings: (p: Partial<CvVariantSettings>) => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0; const editable = outlineSection?.kind === "entries" && (outlineSection?.entries.length ?? 0) > 0;
const sectionLabel = row.title ?? SECTION_LABELS[row.key] ?? row.key; const sectionLabel = customSection?.title ?? row.title ?? SECTION_LABELS[row.key] ?? row.key;
return ( return (
<Paper <Paper
@@ -585,9 +661,10 @@ function SectionRow({
<IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton> <IconButton size="small" aria-label={`Move ${sectionLabel} section up`} disabled={index === 0} onClick={() => onMove(-1)}><ArrowUpwardIcon sx={{ fontSize: 15 }} /></IconButton>
<IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton> <IconButton size="small" aria-label={`Move ${sectionLabel} section down`} disabled={index === total - 1} onClick={() => onMove(1)}><ArrowDownwardIcon sx={{ fontSize: 15 }} /></IconButton>
</Stack> </Stack>
<TextField variant="standard" fullWidth value={row.title ?? SECTION_LABELS[row.key] ?? row.key} <TextField variant="standard" fullWidth value={sectionLabel}
onChange={(e) => onPatch({ title: e.target.value })} onChange={(e) => customSection ? onRenameCustom(e.target.value) : onPatch({ title: e.target.value })}
slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} /> slotProps={{ input: { disableUnderline: true }, htmlInput: { "aria-label": `Section name for ${row.key}` } }} />
{customSection && <Chip size="small" label="Custom" variant="outlined" sx={{ height: 20 }} />}
{editable && ( {editable && (
<IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel} entries`} aria-expanded={expanded} onClick={() => setExpanded((e) => !e)} <IconButton size="small" aria-label={`${expanded ? "Collapse" : "Expand"} ${sectionLabel} entries`} aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}
sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}> sx={{ transform: expanded ? "rotate(180deg)" : "none", transition: "transform 150ms" }}>
@@ -757,7 +834,6 @@ function CustomizeTab({ settings, update, themes }: {
<Divider /> <Divider />
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" /> <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" /> <FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
<FormControlLabel control={<Switch checked={settings.showPageNumbers} onChange={(e) => update({ showPageNumbers: e.target.checked })} />} label="Page numbers" />
</Stack> </Stack>
); );
} }
+10 -1
View File
@@ -12,10 +12,12 @@ import PublicIcon from "@mui/icons-material/Public";
import { getApiErrorMessage } from "../api"; import { getApiErrorMessage } from "../api";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder"; import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
export default function CvBuilderPage() { export default function CvBuilderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { confirmAction } = useDialogActions();
const [variants, setVariants] = useState<CvVariantSummary[]>([]); const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -56,6 +58,13 @@ export default function CvBuilderPage() {
}; };
const remove = async (id: number) => { const remove = async (id: number) => {
const variant = variants.find((item) => item.id === id);
setMenu(null);
if (!(await confirmAction(`Delete "${variant?.name ?? "this CV"}" and its saved version history?`, {
title: "Delete CV",
confirmLabel: "Delete CV",
destructive: true,
}))) return;
try { try {
await cvBuilderApi.remove(id); await cvBuilderApi.remove(id);
setVariants((v) => v.filter((x) => x.id !== id)); setVariants((v) => v.filter((x) => x.id !== id));
@@ -93,7 +102,7 @@ export default function CvBuilderPage() {
<Paper key={v.id} sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 } }} onClick={() => navigate(`/career/builder/${v.id}`)}> <Paper key={v.id} sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 } }} onClick={() => navigate(`/career/builder/${v.id}`)}>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between"> <Stack direction="row" alignItems="flex-start" justifyContent="space-between">
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography> <Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}> <IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />
</IconButton> </IconButton>
</Stack> </Stack>