feat(cv): prioritize ATS template choices

This commit is contained in:
cesnimda
2026-09-01 09:08:16 +02:00
parent 928822d73b
commit 694fdb870a
9 changed files with 95 additions and 31 deletions
+4 -3
View File
@@ -39,7 +39,8 @@ content and deliberate colour/typography/spacing choices remain untouched.
fields that differ from the defaults and declare its `SupportedSettings` when the defaults do not apply.
2. Nothing else is wired manually. It appears in `GET /api/cv/themes`, the Template tab and dashboard create flow.
`GET /api/cv/themes/{id}/preview` renders isolated in-memory sample data for a real visual thumbnail;
preview data is never persisted.
preview data is never persisted. The shared template gallery is ATS-first by default, can reveal
every design, and is reused by both the creation flow and editor.
3. Add a focused visual/PDF regression only when the template introduces a genuinely new layout behaviour.
Add a `CvBuilderTests.Every_catalog_theme_renders_valid_html` already loops the whole catalog, so a new
@@ -58,8 +59,8 @@ sections and monospaced utility details).
`[text](url)` via `ThemedCvRenderer.Inline` — everything is HTML-escaped first, then a whitelist is
re-applied, so no user tag survives. Storage stays plain text (no HTML, no sanitiser dependency).
- **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
flagged, as sidebar layouts can trip naive resume parsers.
surfaced in `GET /api/cv/themes`, sorted first and badged in both template choosers. Two-column
(sidebar) themes are not flagged, as sidebar layouts can trip naive resume parsers.
- **Print quality**: ordinary entries use `break-inside: avoid-page`; content classified as too tall
for a page is allowed to flow between bullets/paragraphs so Chromium cannot clip an unsplittable
block. Section headings avoid a following break, list items carry widow/orphan rules, and long
+5 -2
View File
@@ -1,11 +1,13 @@
# JobTracker master programme progress
Updated: 2026-08-31
Updated: 2026-09-01
## 2026-08-29 audit implementation programme
### Completed
- Restored visibly distinct CV templates by clearing stale structural overrides when a template changes while preserving CV content and deliberate colour, typography, spacing and document settings. The live A4/Letter preview now fits its pane automatically and responds to pane and viewport resizing; manual zoom remains available.
- Consolidated both template choosers into one ATS-first gallery backed by real rendered sample documents. ATS-safe designs are prioritised and explained, decorative sidebar layouts are not falsely labelled ATS-friendly, existing non-ATS selections remain visible, and all designs remain one click away.
- Diagnosed Gitea Actions run 696 from its authenticated job log: application tests passed, but repeated truncated npm/Playwright downloads were followed by a native exit 139 before browser assertions ran. CI now invokes the lockfile-installed Playwright CLI directly, extends the bounded download timeout, retries only SIGSEGV, and does not redownload the same dependency tree after a transient runner crash.
- Removed the unreachable legacy `JobDetailsDialog`, its duplicate insight tabs/panels, and tests coupled only to that retired surface. Live correspondence and routed-workspace contracts retain focused coverage; the cleanup removed 2,239 lines without changing the active Job Workspace.
- Began the controller-responsibility split by moving job statistics, monthly analytics, tag counts, overview analytics, and tag trends into one dedicated authenticated analytics controller. Existing `api/jobapplications/*` routes and response contracts are unchanged; the shared tag parser replaces a byte-for-byte duplicate local parser.
@@ -73,11 +75,12 @@ Updated: 2026-08-31
### In progress
- Complete the career evidence bank regression gate, then continue the prioritized product-value roadmap with saved job searches. SEC-006/SEC-007 still await a running Linux Docker daemon for image/runtime proof; production activation remains separately gated.
- Verify the already-authorised main-branch production deployment and health probes, then continue the next safe backlog package. The newer saved-search/calendar/browser-extension/CV-template feature branch remains separately gated from production.
### Remaining
- Finish production deployment/health verification and revalidate the production-only blockers.
- Obtain explicit production approval before merging and deploying the newer scheduled-search, calendar, browser-extension and CV-template feature branch.
- Continue visual hierarchy/responsive polish only when a concrete browser regression or reviewed design target identifies a remaining defect; the current automated desktop/mobile journeys pass.
- Continue maintainability packages, accessibility automation, mobile regression coverage, and the prioritized product-value roadmap after the foundations are stable.
@@ -0,0 +1,56 @@
import React, { useMemo, useState } from "react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { Box, Button, Chip, Paper, Stack, Typography } from "@mui/material";
import { CvTheme, getVisibleCvThemes } from "../cvBuilder";
import { useI18n } from "../i18n/I18nProvider";
import CvTemplateThumbnail from "./CvTemplateThumbnail";
type Props = {
themes: CvTheme[];
selectedId: string;
onSelect: (theme: CvTheme) => void;
previewHeight?: number;
showDescriptions?: boolean;
};
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true }: Props) {
const { t } = useI18n();
const [atsOnly, setAtsOnly] = useState(true);
const visibleThemes = useMemo(() => getVisibleCvThemes(themes, atsOnly, selectedId), [atsOnly, selectedId, themes]);
return (
<Stack spacing={1.25}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Button size="small" variant={atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(true)}>{t("cvEditorAtsTemplates")}</Button>
<Button size="small" variant={!atsOnly ? "contained" : "outlined"} onClick={() => setAtsOnly(false)}>{t("cvEditorAllTemplates")}</Button>
{atsOnly && <Typography variant="caption" color="text.secondary">{t("cvEditorAtsTemplateHelp")}</Typography>}
</Stack>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.25 }}>
{visibleThemes.map((theme) => {
const active = theme.id === selectedId;
const locked = theme.available === false;
return (
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked} aria-pressed={active}
onClick={() => { if (!locked) onSelect(theme); }}
onKeyDown={(event) => { if (!locked && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", position: "relative", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, transition: "border-color 120ms ease, transform 120ms ease", "&:hover": locked ? undefined : { borderColor: "primary.main", transform: "translateY(-1px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}>
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
<Typography variant="body2" sx={{ fontWeight: 750 }}>{theme.name}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
{showDescriptions && <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{theme.description}</Typography>}
<Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
<Stack direction="row" spacing={0.35} sx={{ ml: "auto" }} aria-hidden>
{theme.swatches.slice(0, 3).map((colour, index) => <Box key={`${colour}-${index}`} sx={{ width: 10, height: 10, borderRadius: "50%", bgcolor: colour, border: "1px solid", borderColor: "divider" }} />)}
</Stack>
</Stack>
</Paper>
);
})}
</Box>
</Stack>
);
}
@@ -200,6 +200,7 @@ test('choosing a template restores its own structure instead of inheriting the p
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('button', { name: 'All templates' }));
fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
+11 -1
View File
@@ -1,4 +1,4 @@
import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, getVisibleCvThemes, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
describe("moveItem", () => {
test("moves an item forward", () => {
@@ -96,4 +96,14 @@ describe("CV template selection", () => {
sections: [{ key: "experience", title: "Career" }],
});
});
test("shows ATS templates first and keeps an active non-ATS template visible", () => {
const themes = [
{ id: "creative", atsFriendly: false },
{ id: "classic", atsFriendly: true },
{ id: "minimal", atsFriendly: true },
] as never[];
expect(getVisibleCvThemes(themes, true, "creative").map((theme) => theme.id)).toEqual(["classic", "minimal", "creative"]);
expect(getVisibleCvThemes(themes, true).map((theme) => theme.id)).toEqual(["classic", "minimal"]);
});
});
+6
View File
@@ -197,6 +197,12 @@ export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVa
};
}
export function getVisibleCvThemes(themes: CvTheme[], atsOnly: boolean, selectedId?: string): CvTheme[] {
return themes
.filter((theme) => !atsOnly || theme.atsFriendly || theme.id === selectedId)
.sort((left, right) => Number(right.atsFriendly) - Number(left.atsFriendly));
}
export function normalizeCvLanguage(language?: string | null): string {
const normalized = language?.trim().toLowerCase();
return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
+8
View File
@@ -626,6 +626,10 @@ export const translations = {
cvEditorTheme: "Theme",
cvEditorThemeHelp: "Templates change presentation only. Your content and hidden-section choices stay intact.",
cvEditorAtsFriendly: "ATS-friendly",
cvEditorAtsTemplates: "ATS templates",
cvEditorAllTemplates: "All templates",
cvEditorAtsTemplateHelp: "Recommended for reliable parsing and a clear reading order.",
cvEditorSelectedTemplate: "Selected template",
cvEditorPro: "Pro",
cvEditorProUnlocked: "Pro unlocked",
cvEditorColour: "Colour",
@@ -3042,6 +3046,10 @@ export const translations = {
cvEditorTheme: "Tema",
cvEditorThemeHelp: "Maler endrer bare presentasjonen. Innholdet og valgene for skjulte seksjoner beholdes.",
cvEditorAtsFriendly: "ATS-vennlig",
cvEditorAtsTemplates: "ATS-maler",
cvEditorAllTemplates: "Alle maler",
cvEditorAtsTemplateHelp: "Anbefalt for pålitelig tolking og tydelig leserekkefølge.",
cvEditorSelectedTemplate: "Valgt mal",
cvEditorPro: "Pro",
cvEditorProUnlocked: "Pro låst opp",
cvEditorColour: "Farge",
+2 -22
View File
@@ -32,7 +32,7 @@ import { useAccountPlan } from "../accountPlan";
import RichTextField from "../components/RichTextField";
import AiSectionAssistant from "../components/cv/AiSectionAssistant";
import ProFeatureNotice from "../components/ProFeatureNotice";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
import CvTemplateGallery from "../components/CvTemplateGallery";
import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
@@ -1152,27 +1152,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
{mode === "template" && <Box>
<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>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
{themes.map((theme) => {
const active = theme.id === settings.themeId;
const locked = theme.available === false;
return (
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
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 } }}>
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={170} /></Box>
<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", mt: 0.25, lineHeight: 1.3 }}>{theme.description}</Typography>
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 18, fontSize: 10 }} />}
</Stack>
</Paper>
);
})}
</Box>
<CvTemplateGallery themes={themes} selectedId={settings.themeId} onSelect={(theme) => update(applyCvTheme(settings, theme.id))} />
</Box>}
{mode === "customize" && <>
+2 -3
View File
@@ -13,6 +13,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import CvTemplateGallery from "../components/CvTemplateGallery";
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
import { useI18n } from "../i18n/I18nProvider";
@@ -189,9 +190,7 @@ export default function CvBuilderPage() {
<Typography color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
<TextField autoFocus fullWidth label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" }, gap: 1 }}>
{(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => <Paper key={theme.id} role="button" tabIndex={0} aria-pressed={newTheme === theme.id} onClick={() => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}><CvTemplateThumbnail theme={theme} height={105} /><Typography variant="body2" sx={{ fontWeight: 700, mt: 0.75 }}>{theme.name}</Typography><Typography variant="caption" color="text.secondary">{theme.category}</Typography></Paper>)}
</Box>
<CvTemplateGallery themes={themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => setNewTheme(theme.id)} previewHeight={125} showDescriptions={false} />
</DialogContent>
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
</Dialog>