From 694fdb870ab1fddcd06212b0e80686eeb4b710ef Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 1 Sep 2026 09:08:16 +0200 Subject: [PATCH] feat(cv): prioritize ATS template choices --- docs/architecture/cv-theme-engine.md | 7 ++- docs/work-programmes/master-progress.md | 7 ++- .../src/components/CvTemplateGallery.tsx | 56 +++++++++++++++++++ .../src/cv-builder-deep-link.test.tsx | 1 + job-tracker-ui/src/cvBuilder.test.ts | 12 +++- job-tracker-ui/src/cvBuilder.ts | 6 ++ job-tracker-ui/src/i18n/translations.ts | 8 +++ job-tracker-ui/src/views/CvBuilderEditor.tsx | 24 +------- job-tracker-ui/src/views/CvBuilderPage.tsx | 5 +- 9 files changed, 95 insertions(+), 31 deletions(-) create mode 100644 job-tracker-ui/src/components/CvTemplateGallery.tsx diff --git a/docs/architecture/cv-theme-engine.md b/docs/architecture/cv-theme-engine.md index 4aa4feb..a090ba6 100644 --- a/docs/architecture/cv-theme-engine.md +++ b/docs/architecture/cv-theme-engine.md @@ -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 diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index fbecc13..f0b11ca 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -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. diff --git a/job-tracker-ui/src/components/CvTemplateGallery.tsx b/job-tracker-ui/src/components/CvTemplateGallery.tsx new file mode 100644 index 0000000..20d7bd0 --- /dev/null +++ b/job-tracker-ui/src/components/CvTemplateGallery.tsx @@ -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 ( + + + + + {atsOnly && {t("cvEditorAtsTemplateHelp")}} + + + {visibleThemes.map((theme) => { + const active = theme.id === selectedId; + const locked = theme.available === false; + return ( + { 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 && } + + {theme.name} + {theme.category} + {showDescriptions && {theme.description}} + + {theme.atsFriendly && } + {theme.requiresPro && } + + {theme.swatches.slice(0, 3).map((colour, index) => )} + + + + ); + })} + + + ); +} 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 ce443e1..aa6b954 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -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' })); diff --git a/job-tracker-ui/src/cvBuilder.test.ts b/job-tracker-ui/src/cvBuilder.test.ts index 608b43b..908e377 100644 --- a/job-tracker-ui/src/cvBuilder.test.ts +++ b/job-tracker-ui/src/cvBuilder.test.ts @@ -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"]); + }); }); diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index 2b3f6fa..2209ac3 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -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"; diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 2bf4556..dd6f27b 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index a18f06a..db894dc 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -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" && {t("cvEditorTheme")} {t("cvEditorThemeHelp")} - - {themes.map((theme) => { - const active = theme.id === settings.themeId; - const locked = theme.available === false; - return ( - { 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} - - {theme.atsFriendly && } - {theme.requiresPro && } - - - ); - })} - + update(applyCvTheme(settings, theme.id))} /> } {mode === "customize" && <> diff --git a/job-tracker-ui/src/views/CvBuilderPage.tsx b/job-tracker-ui/src/views/CvBuilderPage.tsx index 1e93bf4..1d00a6f 100644 --- a/job-tracker-ui/src/views/CvBuilderPage.tsx +++ b/job-tracker-ui/src/views/CvBuilderPage.tsx @@ -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() { {t("cvDashboardCreateHelp")} setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2 }} /> {t("cvDashboardChooseTemplate")} - - {(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => 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" } }}>{theme.name}{theme.category})} - + 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} />