feat(cv): prioritize ATS template choices
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" && <>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user