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} />