fix(cv): restore distinct responsive templates

This commit is contained in:
cesnimda
2026-09-01 08:54:03 +02:00
parent 9252e43ad4
commit c8b4c33acd
8 changed files with 161 additions and 23 deletions
+17
View File
@@ -211,6 +211,23 @@ public sealed class CvBuilderTests
Assert.DoesNotContain("transform:scale", html); 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] [Fact]
public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page() public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page()
{ {
+6 -5
View File
@@ -53,6 +53,7 @@ public sealed class CvTheme
public bool AtsFriendly { get; init; } public bool AtsFriendly { get; init; }
public bool Premium { get; init; } public bool Premium { get; init; }
public bool NumberSections { 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 // Builder options this template intentionally exposes. The renderer remains generic; the
// registry tells the editor which controls make sense for a selected template. // 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", Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain",
Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280", Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280",
HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif", 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() new()
{ {
@@ -95,7 +96,7 @@ public static class CvThemeCatalog
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline", Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917", Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif", 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() new()
{ {
@@ -104,7 +105,7 @@ public static class CvThemeCatalog
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff", Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff",
HeadingFont = "'Roboto', Arial, sans-serif", BodyFont = "'Roboto', Arial, sans-serif", 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" }, SidebarSections = new() { "contact", "skills", "languages", "certifications" },
}, },
new() new()
@@ -114,7 +115,7 @@ public static class CvThemeCatalog
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule", Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
Accent = "#334155", Ink = "#111827", Muted = "#374151", Accent = "#334155", Ink = "#111827", Muted = "#374151",
HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif", HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif",
PhotoShape = "none", AtsFriendly = true, PhotoShape = "none", AtsFriendly = true, DefaultSkillsStyle = "bullets",
}, },
new() new()
{ {
@@ -153,7 +154,7 @@ public static class CvThemeCatalog
HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif", 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, 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, 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" }, SupportedSettings = new() { "accent", "typography", "spacing", "skills", "page" },
}, },
}; };
+3 -2
View File
@@ -130,13 +130,14 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null) private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
{ {
if (section.IsEmpty) return string.Empty; if (section.IsEmpty) return string.Empty;
var skillsStyle = settings?.SkillsStyle ?? theme.DefaultSkillsStyle;
var inner = section.Kind switch var inner = section.Kind switch
{ {
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>", "bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
"paragraphs" => $@"<div class=""paragraphs"">{string.Join("", section.Bullets.Select(item => $@"<p>{Inline(item)}</p>"))}</div>", "paragraphs" => $@"<div class=""paragraphs"">{string.Join("", section.Bullets.Select(item => $@"<p>{Inline(item)}</p>"))}</div>",
"skill-groups" => $@"<div class=""skill-groups"">{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}</div>", "skill-groups" => $@"<div class=""skill-groups"">{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}</div>",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</ul>", "tags" when section.Key == "skills" && skillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</ul>",
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>", "tags" when section.Key == "skills" && skillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>",
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>", "tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
_ => string.Join("", section.Entries.Select(RenderEntry)), _ => string.Join("", section.Entries.Select(RenderEntry)),
}; };
+4 -2
View File
@@ -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` | | Palette | `Accent`, `Ink`, `Muted`, `Line`, `Paper`, `SidebarBg`, `SidebarInk`, `HeadingColor` |
| Typography | `HeadingFont`, `BodyFont`, `UtilityFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` | | Typography | `HeadingFont`, `BodyFont`, `UtilityFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` |
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` | | 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 | | Capabilities | `SupportedSettings` controls which editor panels the template exposes |
The renderer computes CSS from these plus normalized variant overrides: printable palette, curated 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 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 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 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 ## Adding a theme
@@ -172,6 +172,50 @@ test('professional editor starts with templates and combines design and layout c
expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument(); 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: '<p>preview</p>', 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 () => { test('editor sections, design controls and document settings render in Bokmål', async () => {
window.localStorage.setItem('uiLanguage', 'nb'); window.localStorage.setItem('uiLanguage', 'nb');
routeGet(() => Promise.resolve({ data: variant } as any)); routeGet(() => Promise.resolve({ data: variant } as any));
+38 -1
View File
@@ -1,4 +1,4 @@
import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder"; import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
describe("moveItem", () => { describe("moveItem", () => {
test("moves an item forward", () => { test("moves an item forward", () => {
@@ -59,4 +59,41 @@ describe("CV language compatibility", () => {
expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO"); expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO");
expect(normalizeCvLanguage("en")).toBe("en"); 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" }],
});
});
}); });
+21
View File
@@ -176,6 +176,27 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx)); 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 { export function normalizeCvLanguage(language?: string | null): string {
const normalized = language?.trim().toLowerCase(); const normalized = language?.trim().toLowerCase();
return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en"; return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
+28 -13
View File
@@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import { import {
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, applyCvTheme, cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
} from "../cvBuilder"; } from "../cvBuilder";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
@@ -84,6 +84,7 @@ export default function CvBuilderEditor() {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [html, setHtml] = useState(""); const [html, setHtml] = useState("");
const [zoom, setZoom] = useState(0.62); const [zoom, setZoom] = useState(0.62);
const [autoFitPreview, setAutoFitPreview] = useState(true);
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);
@@ -409,10 +410,24 @@ export default function CvBuilderEditor() {
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" }); if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
}; };
const fitPreview = () => { const fitPreview = useCallback(() => {
const availableWidth = Math.max(1, (scrollRef.current?.clientWidth ?? pageMetrics.widthPx) - 24); setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 24, MIN_PREVIEW_ZOOM));
setZoom(Math.min(1, Math.max(MIN_PREVIEW_ZOOM, availableWidth / pageMetrics.widthPx))); }, [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) { if (loadError) {
return ( return (
@@ -486,11 +501,11 @@ export default function CvBuilderEditor() {
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button> <Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button>
</Stack> </Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} /> <Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2))); }}><ZoomOutIcon fontSize="small" /></IconButton>
<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={t("cvEditorZoom")} /> <Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => { setAutoFitPreview(false); setZoom(v as number); }} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
<IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton> <IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2))); }}><ZoomInIcon fontSize="small" /></IconButton>
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography> <Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
<Button size="small" onClick={fitPreview}>{t("cvEditorFit")}</Button> <Button size="small" variant={autoFitPreview ? "outlined" : "text"} onClick={() => { setAutoFitPreview(true); fitPreview(); }}>{t("cvEditorFit")}</Button>
</Stack> </Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>} {previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>} {pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
@@ -1137,16 +1152,16 @@ function CustomizeTab({ mode, settings, update, themes }: {
{mode === "template" && <Box> {mode === "template" && <Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}> <Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
{themes.map((theme) => { {themes.map((theme) => {
const active = theme.id === settings.themeId; const active = theme.id === settings.themeId;
const locked = theme.available === false; const locked = theme.available === false;
return ( return (
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked} <Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
onClick={() => { if (!locked) update({ themeId: theme.id }); }} onClick={() => { if (!locked) update(applyCvTheme(settings, theme.id)); }}
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: 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 } }}> 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 } }}>
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={110} /></Box> <Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={170} /></Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{theme.name}</Typography> <Typography variant="body2" sx={{ fontWeight: 700 }}>{theme.name}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{theme.description}</Typography> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{theme.description}</Typography>