diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 7cb5273..387d36f 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -211,6 +211,23 @@ public sealed class CvBuilderTests Assert.DoesNotContain("transform:scale", html); } + [Fact] + public void Ats_themes_keep_plain_text_content_but_use_distinct_structures_and_skill_treatments() + { + var atsThemes = CvThemeCatalog.Themes.Where(theme => theme.AtsFriendly).ToList(); + + Assert.All(atsThemes, theme => Assert.Contains(theme.Layout, new[] { "single", "header-band" })); + Assert.Contains(atsThemes, theme => theme.DefaultSkillsStyle == "text"); + Assert.Contains(atsThemes, theme => theme.DefaultSkillsStyle == "bullets"); + Assert.Contains(atsThemes, theme => theme.DefaultSkillsStyle == "tags"); + Assert.True(atsThemes.Select(theme => (theme.Layout, theme.HeaderStyle, theme.HeadingStyle, theme.NumberSections)).Distinct().Count() >= 5); + + var renderer = new ThemedCvRenderer(); + var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null); + Assert.Contains("class=\"skills-text\"", renderer.Render(model, CvThemeCatalog.Resolve("minimal"), new CvVariantSettings { ThemeId = "minimal" }).Html); + Assert.Contains("class=\"bullets skill-bullets\"", renderer.Render(model, CvThemeCatalog.Resolve("ats-classic"), new CvVariantSettings { ThemeId = "ats-classic" }).Html); + } + [Fact] public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page() { diff --git a/JobTrackerApi/Models/CvTheme.cs b/JobTrackerApi/Models/CvTheme.cs index 154b272..ae6ab30 100644 --- a/JobTrackerApi/Models/CvTheme.cs +++ b/JobTrackerApi/Models/CvTheme.cs @@ -53,6 +53,7 @@ public sealed class CvTheme public bool AtsFriendly { get; init; } public bool Premium { get; init; } public bool NumberSections { get; init; } + public string DefaultSkillsStyle { get; init; } = "tags"; // tags | text | bullets // Builder options this template intentionally exposes. The renderer remains generic; the // registry tells the editor which controls make sense for a selected template. @@ -86,7 +87,7 @@ public static class CvThemeCatalog Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain", Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280", HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif", - SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", AtsFriendly = true, + SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", AtsFriendly = true, DefaultSkillsStyle = "text", }, new() { @@ -95,7 +96,7 @@ public static class CvThemeCatalog Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline", Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917", HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif", - NameSizePt = 27, PhotoShape = "square", AtsFriendly = true, + NameSizePt = 27, PhotoShape = "square", AtsFriendly = true, DefaultSkillsStyle = "text", }, new() { @@ -104,7 +105,7 @@ public static class CvThemeCatalog Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff", HeadingFont = "'Roboto', Arial, sans-serif", BodyFont = "'Roboto', Arial, sans-serif", - BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true, + BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true, DefaultSkillsStyle = "bullets", SidebarSections = new() { "contact", "skills", "languages", "certifications" }, }, new() @@ -114,7 +115,7 @@ public static class CvThemeCatalog Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule", Accent = "#334155", Ink = "#111827", Muted = "#374151", HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif", - PhotoShape = "none", AtsFriendly = true, + PhotoShape = "none", AtsFriendly = true, DefaultSkillsStyle = "bullets", }, new() { @@ -153,7 +154,7 @@ public static class CvThemeCatalog HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif", UtilityFont = "Consolas, 'Courier New', monospace", NameSizePt = 25.5, HeadingSizePt = 9.5, BodySizePt = 9.4, LineHeight = 1.43, PageMarginMm = 15.24, SectionGapMm = 5.6, EntryGapMm = 4.3, - PhotoShape = "none", DefaultIcons = false, AtsFriendly = true, NumberSections = true, + PhotoShape = "none", DefaultIcons = false, AtsFriendly = true, NumberSections = true, DefaultSkillsStyle = "bullets", SupportedSettings = new() { "accent", "typography", "spacing", "skills", "page" }, }, }; diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index 591a80b..6acbc14 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -130,13 +130,14 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null) { if (section.IsEmpty) return string.Empty; + var skillsStyle = settings?.SkillsStyle ?? theme.DefaultSkillsStyle; var inner = section.Kind switch { "bullets" => $@"", "paragraphs" => $@"
{string.Join("", section.Bullets.Select(item => $@"

{Inline(item)}

"))}
", "skill-groups" => $@"
{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}
", - "tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"", - "tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"

{string.Join(" · ", section.Tags.Select(Enc))}

", + "tags" when section.Key == "skills" && skillsStyle == "bullets" => $@"", + "tags" when section.Key == "skills" && skillsStyle == "text" => $@"

{string.Join(" · ", section.Tags.Select(Enc))}

", "tags" => $@"", _ => string.Join("", section.Entries.Select(RenderEntry)), }; diff --git a/docs/architecture/cv-theme-engine.md b/docs/architecture/cv-theme-engine.md index 7965acb..4aa4feb 100644 --- a/docs/architecture/cv-theme-engine.md +++ b/docs/architecture/cv-theme-engine.md @@ -22,14 +22,16 @@ which is retained only for the legacy tailored-draft flow and is not used by the | Palette | `Accent`, `Ink`, `Muted`, `Line`, `Paper`, `SidebarBg`, `SidebarInk`, `HeadingColor` | | Typography | `HeadingFont`, `BodyFont`, `UtilityFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` | | Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` | -| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons`, `NumberSections` | +| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `DefaultSkillsStyle` (`tags`\|`text`\|`bullets`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons`, `NumberSections` | | Capabilities | `SupportedSettings` controls which editor panels the template exposes | The renderer computes CSS from these plus normalized variant overrides: printable palette, curated fonts, body/heading size, line height, spacing, page size, layout, sidebar width/content, header and heading treatments, skills presentation, photo and icons. `SidebarSections` decides which section keys move to the sidebar for the two-column layouts. Changing a template or override never transforms -the structured career content. +the structured career content. Selecting a different template clears previous structural overrides +(`Layout`, header/heading treatment and sidebar geometry) so its own design is immediately visible; +content and deliberate colour/typography/spacing choices remain untouched. ## Adding a theme diff --git a/job-tracker-ui/src/cv-builder-deep-link.test.tsx b/job-tracker-ui/src/cv-builder-deep-link.test.tsx index 2bfe4b8..ce443e1 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -172,6 +172,50 @@ test('professional editor starts with templates and combines design and layout c expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument(); }); +test('choosing a template restores its own structure instead of inheriting the previous template layout', async () => { + const customizedVariant = { + ...variant, + settings: { + ...emptyCvVariantSettings(), + themeId: 'minimal', + layout: 'single', + headerStyle: 'plain', + headingStyle: 'plain', + sidebarWidthMm: 72, + sidebarSections: ['skills'], + accentColor: '#123456', + }, + }; + mockedApi.get.mockImplementation((url: string) => { + if (url === '/cv/variants/3') return Promise.resolve({ data: customizedVariant } as any); + if (url === '/cv/themes') return Promise.resolve({ data: [ + { id: 'minimal', name: 'Minimal', category: 'ATS', description: 'Quiet single column', layout: 'single', available: true, swatches: ['#111827', '#fff', '#fff'] }, + { id: 'technical', name: 'Technical', category: 'Technical', description: 'Structured sidebar', layout: 'sidebar-left', available: true, swatches: ['#0f4c5c', '#0f4c5c', '#fff'] }, + ] } as any); + if (url === '/cv/outline') return Promise.resolve({ data: { sections: [] } } as any); + if (url.startsWith('/cv/themes/')) return Promise.resolve({ data: { themeId: url.includes('technical') ? 'technical' : 'minimal', html: '

preview

', suggestedFileName: 'preview.pdf' } } as any); + return Promise.resolve({ data: [] } as any); + }); + mockedApi.put.mockResolvedValue({ data: customizedVariant } as any); + renderAt(3); + + await screen.findByDisplayValue('Backend CV'); + fireEvent.click(screen.getByRole('button', { name: /Technical/ })); + fireEvent.click(screen.getByRole('button', { name: 'Save now' })); + + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({ + settings: expect.objectContaining({ + themeId: 'technical', + layout: null, + headerStyle: null, + headingStyle: null, + sidebarWidthMm: null, + sidebarSections: null, + accentColor: '#123456', + }), + }))); +}); + test('editor sections, design controls and document settings render in Bokmål', async () => { window.localStorage.setItem('uiLanguage', 'nb'); routeGet(() => Promise.resolve({ data: variant } as any)); diff --git a/job-tracker-ui/src/cvBuilder.test.ts b/job-tracker-ui/src/cvBuilder.test.ts index 5cb20be..608b43b 100644 --- a/job-tracker-ui/src/cvBuilder.test.ts +++ b/job-tracker-ui/src/cvBuilder.test.ts @@ -1,4 +1,4 @@ -import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder"; +import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder"; describe("moveItem", () => { test("moves an item forward", () => { @@ -59,4 +59,41 @@ describe("CV language compatibility", () => { expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO"); expect(normalizeCvLanguage("en")).toBe("en"); }); + + test("fits the physical page to the available preview width", () => { + expect(getCvPreviewFitZoom(500, 800)).toBeCloseTo(0.595); + expect(getCvPreviewFitZoom(1200, 800)).toBe(1); + expect(getCvPreviewFitZoom(120, 800)).toBe(0.32); + }); +}); + +describe("CV template selection", () => { + test("restores the selected template structure without discarding user content or design choices", () => { + const settings = { + themeId: "minimal", + layout: "single" as const, + headerStyle: "plain" as const, + headingStyle: "plain" as const, + sidebarWidthMm: 70, + sidebarSections: ["skills"], + accentColor: "#123456", + showPhoto: false, + showPageNumbers: false, + showIcons: true, + sections: [{ key: "experience", title: "Career" }], + overrides: {}, + customSections: [], + }; + + expect(applyCvTheme(settings, "technical")).toMatchObject({ + themeId: "technical", + layout: null, + headerStyle: null, + headingStyle: null, + sidebarWidthMm: null, + sidebarSections: null, + accentColor: "#123456", + sections: [{ key: "experience", title: "Career" }], + }); + }); }); diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index 8930b40..2b3f6fa 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -176,6 +176,27 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx)); } +export function getCvPreviewFitZoom(containerWidthPx: number, pageWidthPx: number, gutterPx = 24, minZoom = 0.32): number { + if (!Number.isFinite(containerWidthPx) || !Number.isFinite(pageWidthPx) || pageWidthPx <= 0) return minZoom; + const usableWidth = Math.max(1, containerWidthPx - gutterPx); + return Math.min(1, Math.max(minZoom, usableWidth / pageWidthPx)); +} + +// Selecting a template is a structural choice. Clear only the presentation overrides that can +// flatten every template into the previous template's layout; keep content, colour, typography, +// spacing and document settings intact. +export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVariantSettings { + return { + ...settings, + themeId, + layout: null, + headerStyle: null, + headingStyle: null, + sidebarWidthMm: null, + sidebarSections: null, + }; +} + export function normalizeCvLanguage(language?: string | null): string { const normalized = language?.trim().toLowerCase(); return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en"; diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 418c79f..a18f06a 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder"; import { CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, - cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, + applyCvTheme, cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, } from "../cvBuilder"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; @@ -84,6 +84,7 @@ export default function CvBuilderEditor() { const [tab, setTab] = useState(0); const [html, setHtml] = useState(""); const [zoom, setZoom] = useState(0.62); + const [autoFitPreview, setAutoFitPreview] = useState(true); const [previewing, setPreviewing] = useState(false); const [previewError, setPreviewError] = useState(false); const [previewRevision, setPreviewRevision] = useState(0); @@ -409,10 +410,24 @@ export default function CvBuilderEditor() { if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), 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))); - }; + const fitPreview = useCallback(() => { + setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 24, MIN_PREVIEW_ZOOM)); + }, [pageMetrics.widthPx]); + + useEffect(() => { + if (!autoFitPreview) return; + const container = scrollRef.current; + if (!container) return; + const resize = () => fitPreview(); + resize(); + window.addEventListener("resize", resize); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize); + observer?.observe(container); + return () => { + window.removeEventListener("resize", resize); + observer?.disconnect(); + }; + }, [autoFitPreview, compactEditor, fitPreview, mobilePane]); if (loadError) { return ( @@ -486,11 +501,11 @@ export default function CvBuilderEditor() { - setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}> - setZoom(v as number)} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} /> - setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}> + { setAutoFitPreview(false); setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2))); }}> + { setAutoFitPreview(false); setZoom(v as number); }} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} /> + { setAutoFitPreview(false); setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2))); }}> {Math.round(zoom * 100)}% - + {previewOverflow && {t("cvEditorOverflowWarning")}} {pages >= 3 && {t("cvEditorLongDocument", { pages })}} @@ -1137,16 +1152,16 @@ function CustomizeTab({ mode, settings, update, themes }: { {mode === "template" && {t("cvEditorTheme")} {t("cvEditorThemeHelp")} - + {themes.map((theme) => { const active = theme.id === settings.themeId; const locked = theme.available === false; return ( { if (!locked) update({ themeId: theme.id }); }} - onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: theme.id }); } }} + onClick={() => { if (!locked) update(applyCvTheme(settings, theme.id)); }} + onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update(applyCvTheme(settings, theme.id)); } }} sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}> - + {theme.name} {theme.category} {theme.description}