From b5249357c702166d786a90a4954fc68aa30309fd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 27 Aug 2026 15:27:54 +0200 Subject: [PATCH] feat(cv): rebuild section editing workflow --- docs/architecture/cv-theme-engine.md | 19 +- docs/todo/work.md | 44 ++- job-tracker-ui/e2e/smoke.spec.ts | 147 +++++++- job-tracker-ui/package.json | 1 + job-tracker-ui/playwright.config.ts | 10 +- job-tracker-ui/scripts/serve-export.mjs | 52 +++ .../src/components/cv/AiSectionAssistant.tsx | 90 +++++ .../src/cv-builder-deep-link.test.tsx | 24 +- job-tracker-ui/src/cvBuilder.ts | 23 +- job-tracker-ui/src/views/CvBuilderEditor.tsx | 336 ++++++++---------- 10 files changed, 525 insertions(+), 221 deletions(-) create mode 100644 job-tracker-ui/scripts/serve-export.mjs create mode 100644 job-tracker-ui/src/components/cv/AiSectionAssistant.tsx diff --git a/docs/architecture/cv-theme-engine.md b/docs/architecture/cv-theme-engine.md index d81c47e..7965acb 100644 --- a/docs/architecture/cv-theme-engine.md +++ b/docs/architecture/cv-theme-engine.md @@ -20,9 +20,10 @@ which is retained only for the legacy tailored-draft flow and is not used by the | Identity | `Id`, `Name`, `Category`, `Description` | | Layout | `Layout` (`single` \| `sidebar-left` \| `sidebar-right` \| `header-band`), `SidebarWidthMm`, `SidebarSections` | | Palette | `Accent`, `Ink`, `Muted`, `Line`, `Paper`, `SidebarBg`, `SidebarInk`, `HeadingColor` | -| Typography | `HeadingFont`, `BodyFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` | +| Typography | `HeadingFont`, `BodyFont`, `UtilityFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` | | Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` | -| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons` | +| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons`, `NumberSections` | +| Capabilities | `SupportedSettings` controls which editor panels the template exposes | 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 @@ -33,19 +34,21 @@ the structured career content. ## Adding a theme 1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the - fields that differ from the defaults. -2. Nothing else. It appears in `GET /api/cv/themes`, the Template tab and dashboard create flow. + 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. +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 theme is smoke-tested automatically. -## Shipped themes (8) +## Shipped themes (9) `modern` (header-band), `minimal` (single), `executive` (single, serif), `technical` (sidebar-left, dense), `ats-classic` (single, no graphics), `nordic` (sidebar-right), `elegant` (single, editorial), -`creative` (sidebar-left, bold). +`creative` (sidebar-left, bold), `code` (single-column developer/editorial layout with numbered +sections and monospaced utility details). ## Rich text, ATS, and print (2026-07-18) @@ -60,6 +63,10 @@ dense), `ats-classic` (single, no graphics), `nordic` (sidebar-right), `elegant` block. Section headings avoid a following break, list items carry widow/orphan rules, and long names/titles/contact values/URLs/tags wrap within `minmax(0, …)` columns. Typography is not scaled down to mask overflow. +- **Builder pagination**: the screen preview runs the same keep-together policy over the rendered + section/entry/list classes before cropping real A4 or Letter frames. It uses deterministic internal + padding for unused page space (avoiding collapsing margins), while PDF export remains native + Chromium paged media. A long structured Code-theme browser/PDF test guards both paths. ## Deliberately not here (yet) diff --git a/docs/todo/work.md b/docs/todo/work.md index 0c629c5..2933d89 100644 --- a/docs/todo/work.md +++ b/docs/todo/work.md @@ -1,4 +1,28 @@ -# Active implementation checklist — 2026-08-24 +# Career / CV improvement work — 2026-08-26 + +## Current + +- [ ] Run the complete backend, frontend, browser, migration, PDF, Compose and diff verification matrix; fix any regressions. + +## Next + +- [ ] Audit remaining TODOs/dead CV code after the full regression run and document any genuine environment-only blockers. + +## Completed + +- [x] Rechecked branch/status, recent CV work, existing tests/build tooling, and current builder architecture. +- [x] Traced builder persistence, shared preview/PDF rendering, AI endpoint, extraction pipeline, confidence/diff review, and reference CV assets. +- [x] Rendered and inspected the supplied two-page reference PDF and reviewed its HTML typography/theme tokens without modifying the originals. +- [x] Extended the backward-compatible CV settings/document model for item visibility/order, custom section types, grouped skills and compact/bullet/grouped skill rendering. +- [x] Rebuilt content editing as independently expandable section cards with keyboard reorder controls, hide/show, guarded deletion, preset/free-text sections and reusable per-section AI review. +- [x] Centralised template capabilities and accent tokens, added the Code theme through the registry, and made actual rendered sample thumbnails drive selection. +- [x] Added shared A4/Letter page grouping for browser preview and Chromium PDF, including regression coverage for long multi-page Code CVs and searchable linked PDF output. +- [x] Improved DOCX structural extraction, Norwegian headings/languages/dates, contact and website parsing, summary/skills/education fallbacks, and confidence-preserving import output. +- [x] Benchmarked both supplied reference DOCX variants without committing personal data: 100% structural coverage and consistency, with zero suspicious locations. + +--- + +# Previous implementation checklist — 2026-08-24 ## Current @@ -959,3 +983,21 @@ When all possible work is complete, provide: 12. Recommended next work package Do not claim a workflow works unless it was genuinely browser-tested, mock-tested or otherwise clearly identified by verification type. +# Career / CV improvement work (2026-08-26) + +## Current + +- [ ] Refactor the CV document/settings model and shared renderer for section content, skill formats, template capabilities, accent tokens, and safer page breaks. + +## Next + +- [ ] Rebuild content editing as independently expandable section cards, including preset/free-text custom sections and per-section AI review. +- [ ] Add the Code template through the central template registry and redesign existing template metadata/styles. +- [ ] Strengthen extraction normalization, confidence, custom-section handling, and sanitized regression tests. +- [ ] Run browser, PDF, responsive, build, lint, unit, integration, and migration verification; fix regressions. + +## Completed + +- [x] Rechecked branch/status, recent CV work, existing tests/build tooling, and current builder architecture. +- [x] Traced builder persistence, shared preview/PDF rendering, AI endpoint, extraction pipeline, confidence/diff review, and reference CV assets. +- [x] Rendered and inspected the supplied two-page reference PDF and reviewed its HTML typography/theme tokens without modifying the originals. diff --git a/job-tracker-ui/e2e/smoke.spec.ts b/job-tracker-ui/e2e/smoke.spec.ts index cc37a2d..44c6c7b 100644 --- a/job-tracker-ui/e2e/smoke.spec.ts +++ b/job-tracker-ui/e2e/smoke.spec.ts @@ -315,6 +315,26 @@ test("Career Workspace loads from the authenticated application shell", async ({ await expect(page).toHaveURL(/\/career\/builder\/\d+$/); await expect(page.getByLabel("CV name")).toBeVisible(); + await page.getByRole("button", { name: "Expand Professional Summary" }).click(); + await page.getByRole("button", { name: "Expand Experience" }).click(); + await expect(page.getByRole("button", { name: "Collapse Professional Summary" })).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByRole("button", { name: "Collapse Experience" })).toHaveAttribute("aria-expanded", "true"); + + await page.getByLabel("Section type").click(); + await page.getByRole("option", { name: "Additional Experience" }).click(); + await page.getByRole("button", { name: "Add section" }).click(); + await page.getByRole("button", { name: "Expand Additional Experience" }).click(); + await page.getByRole("button", { name: "Add entry" }).click(); + await page.getByRole("textbox", { name: "Entry 1", exact: true }).fill("Maintained a community software project."); + + await page.getByRole("tab", { name: "Template" }).click(); + await page.getByRole("button").filter({ hasText: /^CodeTechnical/ }).click(); + await page.getByRole("tab", { name: "Design" }).click(); + await page.getByLabel("Skills presentation").click(); + await page.getByRole("option", { name: "Bullet list" }).click(); + await page.locator('input[type="color"]').fill("#126b55"); + await expect(page.getByRole("tab", { name: "AI" })).toHaveCount(0); + await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark")); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark"); @@ -331,7 +351,7 @@ test("Career Workspace loads from the authenticated application shell", async ({ } }); -test("a public CV renders anonymously and downloads as PDF", async ({ page }) => { +test("a public CV renders anonymously and downloads as PDF", async ({ page }, testInfo) => { await login(page); const headers = await csrfHeader(page); const create = await page.request.post(`${apiUrl}/cv/variants`, { @@ -368,5 +388,128 @@ test("a public CV renders anonymously and downloads as PDF", async ({ page }) => const pdf = await page.request.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`); expect(pdf.ok()).toBeTruthy(); expect(pdf.headers()["content-type"]).toContain("application/pdf"); - expect((await pdf.body()).subarray(0, 5).toString()).toBe("%PDF-"); + const pdfBody = await pdf.body(); + expect(pdfBody.length).toBeGreaterThan(1_000); + expect(pdfBody.subarray(0, 5).toString()).toBe("%PDF-"); + await testInfo.attach("public-cv.pdf", { body: pdfBody, contentType: "application/pdf" }); +}); + +test("the Code template exports a long structured CV as searchable multi-page PDF", async ({ page }, testInfo) => { + await login(page); + const headers = await csrfHeader(page); + const longBullets = [ + "Designed resilient .NET services that processed high-volume workloads while preserving clear operational ownership and measurable service health.", + "Improved deployment safety through automated checks, progressive delivery, actionable telemetry, and documented incident-response procedures.", + "Collaborated with product, security, and support teams across Norway and the United Kingdom to turn ambiguous requirements into maintainable releases.", + ]; + const profile = { + version: "1", + contact: { + fullName: "Alex Eksempel", + headline: "Principal Software Engineer", + email: "alex.eksempel+career-verification@example.test", + phone: "+47 412 34 567", + location: "Oslo, Norge", + website: "https://example.test/portfolio/with/a/deliberately/long/path", + linkedIn: "https://linkedin.com/in/alex-eksempel", + gitHub: "https://github.com/alex-eksempel", + }, + summary: ["Teknisk leder med erfaring fra robuste plattformer, tilgjengelige tjenester og tverrfaglig produktutvikling."], + jobs: Array.from({ length: 9 }, (_, index) => ({ + title: `Senior Platform Engineer ${index + 1} With A Deliberately Long Role Name`, + company: `Example Technology Organisation ${index + 1} With A Long Employer Name`, + location: index % 2 === 0 ? "Oslo, Norge" : "Warwickshire, United Kingdom", + start: `${2015 + index}`, + end: index === 8 ? "Present" : `${2016 + index}`, + isCurrent: index === 8, + bullets: longBullets, + skills: ["C#", ".NET", "PostgreSQL", "Docker", "Azure DevOps"], + })), + education: [{ qualification: "BSc Software Engineering", institution: "Example University", start: "2011", end: "2015", details: ["Distributed systems and human-computer interaction"] }], + certifications: [{ name: "Cloud Architecture Professional", issuer: "Example Institute", date: "2025" }], + projects: [{ name: "Open Source Reliability Toolkit", role: "Maintainer", start: "2022", end: "Present", bullets: longBullets.slice(0, 2), skills: ["TypeScript", "Playwright"] }], + skills: ["C# / .NET", "Python", "SQL", "Docker", "Azure DevOps", "Linux", "Observability", "Incident response"], + languages: [{ name: "English", level: "Native" }, { name: "Norsk", level: "Profesjonelt arbeidsnivå" }], + interests: ["Tilgjengelighet", "Open source"], + otherSections: [{ title: "Additional Experience", items: ["Mentored early-career engineers", "Organised community technology workshops"] }], + }; + + const profileResponse = await page.request.put(`${apiUrl}/career/profile`, { + headers, + data: { profile, cvText: "Long sanitized CV fixture for PDF verification." }, + }); + expect(profileResponse.ok()).toBeTruthy(); + + const create = await page.request.post(`${apiUrl}/cv/variants`, { + headers, + data: { + name: "Code PDF Verification", + settings: { + themeId: "code", + accent: "#0b7a63", + pageSize: "a4", + skillsStyle: "bullets", + language: "nb", + }, + }, + }); + const createBody = await create.text(); + expect(create.ok(), `CV create failed: ${create.status()} ${createBody}`).toBeTruthy(); + const variant = JSON.parse(createBody); + + const preview = await page.request.get(`${apiUrl}/cv/variants/${variant.id}/preview`); + expect(preview.ok()).toBeTruthy(); + const previewBody = await preview.json(); + expect(previewBody.themeId).toBe("code"); + expect(previewBody.html).toContain("Alex Eksempel"); + expect(previewBody.html).toContain("--cv-accent-color:#0b7a63"); + + await page.goto(`/career/builder/${variant.id}`); + await expect(page.getByLabel("CV name")).toHaveValue("Code PDF Verification"); + await expect(page.getByTitle("CV preview page 2")).toHaveCount(1, { timeout: 10_000 }); + const pagination = await page.getByTitle("CV preview", { exact: true }).evaluate((element) => { + const frame = element as HTMLIFrameElement; + const doc = frame.contentDocument; + const height = 297 * 96 / 25.4; + if (!doc || height <= 0) return { scriptRan: false, crossings: ["document unavailable"] }; + const topOf = (node: Element) => { + let top = 0; + let current = node as HTMLElement | null; + while (current && current !== doc.body) { + top += current.offsetTop || 0; + current = current.offsetParent as HTMLElement | null; + } + return top; + }; + const candidates = Array.from(doc.querySelectorAll( + ".section:not(.section-flow),.section-flow > .entry:not(.entry-flow),.section-flow > .skill-groups > .skill-group,.section-flow > .bullets > li:not(.item-flow),.entry-flow .bullets > li:not(.item-flow)", + )); + const crossings = candidates.filter((candidate) => { + const paddingTop = Number.parseFloat(getComputedStyle(candidate).paddingTop) || 0; + const top = topOf(candidate) + paddingTop; + const contentHeight = candidate.offsetHeight - paddingTop; + const bottom = top + contentHeight; + return contentHeight < height - 2 + && Math.floor((top + 1) / height) !== Math.floor((bottom - 1) / height); + }).map((candidate) => ({ + className: candidate.className || candidate.tagName, + top: topOf(candidate) + (Number.parseFloat(getComputedStyle(candidate).paddingTop) || 0), + height: candidate.offsetHeight, + paddingTop: getComputedStyle(candidate).paddingTop, + })); + return { + scriptRan: !!doc.querySelector("script[data-cv-preview-pagination]"), + crossings, + }; + }); + expect(pagination.scriptRan).toBeTruthy(); + expect(pagination.crossings).toEqual([]); + + const pdf = await page.request.post(`${apiUrl}/cv/variants/${variant.id}/export-pdf`, { headers }); + expect(pdf.ok()).toBeTruthy(); + expect(pdf.headers()["content-type"]).toContain("application/pdf"); + const pdfBody = await pdf.body(); + expect(pdfBody.length).toBeGreaterThan(20_000); + expect(pdfBody.subarray(0, 5).toString()).toBe("%PDF-"); + await testInfo.attach("code-long-cv.pdf", { body: pdfBody, contentType: "application/pdf" }); }); diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index e4f527e..e5313ec 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -31,6 +31,7 @@ "scripts": { "dev": "next dev", "start": "next dev", + "serve:export": "node ./scripts/serve-export.mjs", "build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build", "test": "jest", "test:e2e": "dotnet build ../JobTrackerApi/JobTrackerApi.csproj --configuration Release && playwright test" diff --git a/job-tracker-ui/playwright.config.ts b/job-tracker-ui/playwright.config.ts index d0fd204..6a05721 100644 --- a/job-tracker-ui/playwright.config.ts +++ b/job-tracker-ui/playwright.config.ts @@ -5,6 +5,8 @@ import path from "node:path"; const apiUrl = "http://localhost:5302"; const appUrl = "http://localhost:3300"; const dataRoot = path.join(os.tmpdir(), `jobtracker-e2e-${process.pid}-${Date.now().toString(36)}`); +const reuseServers = process.env.PLAYWRIGHT_REUSE_SERVERS === "true"; +const useStaticExport = process.env.PLAYWRIGHT_STATIC_EXPORT === "true"; export default defineConfig({ testDir: "./e2e", @@ -23,7 +25,7 @@ export default defineConfig({ command: "dotnet run --no-build --configuration Release --project ../JobTrackerApi/JobTrackerApi.csproj --urls http://localhost:5302", url: `${apiUrl}/health`, timeout: 120_000, - reuseExistingServer: false, + reuseExistingServer: reuseServers, env: { ASPNETCORE_ENVIRONMENT: "Development", Auth__Require: "true", @@ -40,10 +42,12 @@ export default defineConfig({ }, }, { - command: "npm run dev -- --hostname localhost --port 3300", + command: useStaticExport + ? "npm run build && npm run serve:export" + : "npm run dev -- --hostname localhost --port 3300", url: appUrl, timeout: 120_000, - reuseExistingServer: false, + reuseExistingServer: reuseServers, env: { NEXT_PUBLIC_API_BASE_URL: `${apiUrl}/api` }, }, ], diff --git a/job-tracker-ui/scripts/serve-export.mjs b/job-tracker-ui/scripts/serve-export.mjs new file mode 100644 index 0000000..f9d54b7 --- /dev/null +++ b/job-tracker-ui/scripts/serve-export.mjs @@ -0,0 +1,52 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve, sep } from "node:path"; + +const port = Number.parseInt(process.env.PORT ?? "3300", 10); +const root = resolve(process.cwd(), "out"); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".webmanifest": "application/manifest+json", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +if (!existsSync(join(root, "index.html"))) { + throw new Error("Static export not found. Run `npm run build` first."); +} + +createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname); + const relativePath = normalize(pathname).replace(/^([/\\])+/, ""); + let filePath = resolve(root, relativePath || "index.html"); + + if (filePath !== root && !filePath.startsWith(`${root}${sep}`)) { + response.writeHead(400).end("Bad request"); + return; + } + + if (existsSync(filePath) && statSync(filePath).isDirectory()) { + filePath = join(filePath, "index.html"); + } + + // React Router owns application routes. Assets and exported Next files are still + // served directly, while deep links fall back to the application shell. + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + filePath = join(root, "index.html"); + } + + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": contentTypes[extname(filePath)] ?? "application/octet-stream", + }); + createReadStream(filePath).pipe(response); +}).listen(port, "localhost", () => { + process.stdout.write(`Static export listening on http://localhost:${port}\n`); +}); diff --git a/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx b/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx new file mode 100644 index 0000000..d93fcb5 --- /dev/null +++ b/job-tracker-ui/src/components/cv/AiSectionAssistant.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; + +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; +import { Alert, Box, Button, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material"; + +import { getApiErrorMessage } from "../../api"; +import { useAccountPlan } from "../../accountPlan"; +import { AI_ACTIONS, cvBuilderApi } from "../../cvBuilder"; +import { useToast } from "../../toast"; +import ProFeatureNotice from "../ProFeatureNotice"; + +type Props = { + sectionName: string; + text: string; + context?: string; + onApply: (suggestion: string) => void; +}; + +const SECTION_ACTIONS = new Set(["improve", "professional", "shorten", "grammar", "impact", "bullets"]); + +export default function AiSectionAssistant({ sectionName, text, context, onApply }: Props) { + const { toast } = useToast(); + const { canUseAi } = useAccountPlan(); + const [open, setOpen] = useState(false); + const [action, setAction] = useState("improve"); + const [suggestion, setSuggestion] = useState(""); + const [busy, setBusy] = useState(false); + + const run = async () => { + if (!text.trim() || !canUseAi) return; + setBusy(true); + try { + const response = await cvBuilderApi.aiAssist({ + action, + text, + context: [`Section: ${sectionName}. Preserve facts and do not invent achievements.`, context].filter(Boolean).join("\n"), + }); + setSuggestion(response.result); + } catch (error) { + toast(getApiErrorMessage(error, "AI suggestion failed."), "error"); + } finally { + setBusy(false); + } + }; + + if (!open) { + return ( + + ); + } + + if (!canUseAi) { + return ( + + + AI only proposes wording; it never changes CV content without your approval. + + + + ); + } + + return ( + + + + setAction(event.target.value)} sx={{ minWidth: 190 }}> + {AI_ACTIONS.filter((item) => SECTION_ACTIONS.has(item.key)).map((item) => {item.label})} + + + + + {suggestion && <> + + Current + {text} + + setSuggestion(event.target.value)} /> + Check every fact before applying this suggestion. + + + + + } + + + ); +} 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 9252050..53e5ab4 100644 --- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx +++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx @@ -177,8 +177,12 @@ test('custom entries can be added, edited, reordered and deleted with confirmati renderAt(3); await screen.findByLabelText('Headline override'); - fireEvent.click(screen.getByRole('button', { name: 'Add' })); - fireEvent.change(screen.getByLabelText('Custom section title'), { target: { value: 'Selected projects' } }); + fireEvent.mouseDown(screen.getByLabelText('Section type')); + fireEvent.click(await screen.findByRole('option', { name: 'Additional Experience' })); + fireEvent.click(screen.getByRole('button', { name: 'Add section' })); + const title = screen.getByLabelText(/Section name for custom:/); + fireEvent.change(title, { target: { value: 'Selected projects' } }); + fireEvent.click(screen.getByRole('button', { name: 'Expand Selected projects' })); fireEvent.click(screen.getByRole('button', { name: 'Add entry' })); fireEvent.change(screen.getByLabelText('Entry 1'), { target: { value: 'First project' } }); fireEvent.click(screen.getByRole('button', { name: 'Add entry' })); @@ -207,15 +211,15 @@ test('custom entries can be added, edited, reordered and deleted with confirmati }), })); - fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' })); dialog = await screen.findByRole('dialog', { name: 'Delete custom section' }); fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByLabelText('Custom section title')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Remove custom section' })); + expect(screen.getByLabelText(/Section name for custom:/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Selected projects section from this CV' })); dialog = await screen.findByRole('dialog', { name: 'Delete custom section' }); fireEvent.click(within(dialog).getByRole('button', { name: 'Delete section' })); - await waitFor(() => expect(screen.queryByLabelText('Custom section title')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByLabelText(/Section name for custom:/)).not.toBeInTheDocument()); }); test('profile-backed sections expand, reorder, hide and persist variant-only overrides', async () => { @@ -230,8 +234,8 @@ test('profile-backed sections expand, reorder, hide and persist variant-only ove mockedApi.put.mockResolvedValue({ data: variant } as any); renderAt(3); - fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience entries' })); - expect(screen.getByRole('button', { name: 'Collapse Experience entries' })).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(await screen.findByRole('button', { name: 'Expand Experience' })); + expect(screen.getByRole('button', { name: 'Collapse Experience' })).toHaveAttribute('aria-expanded', 'true'); fireEvent.click(screen.getByRole('button', { name: 'Move Engineer entry down' })); fireEvent.click(screen.getByRole('button', { name: 'Hide Lead entry' })); fireEvent.click(screen.getByRole('button', { name: 'Move Experience section down' })); @@ -253,7 +257,7 @@ test('preview failure is visible and retryable without leaving the editor', asyn expect(await screen.findByText('Preview unavailable')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Retry preview' })); - await waitFor(() => expect(screen.getByTitle('CV preview')).toHaveAttribute('srcdoc', '

retry

')); - expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin'); + await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('

retry

')); + expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin allow-scripts'); await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument()); }); diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts index 4dce21a..7bf8242 100644 --- a/job-tracker-ui/src/cvBuilder.ts +++ b/job-tracker-ui/src/cvBuilder.ts @@ -1,9 +1,17 @@ import { api } from "./api"; // Mirrors the backend CvVariantSettings (the lens over the master career profile). -export type CvSectionSetting = { key: string; hidden?: boolean; title?: string; itemOrder?: string[] }; +export type CvSectionSetting = { key: string; hidden?: boolean; title?: string; itemOrder?: string[]; items?: string[] }; export type CvItemOverride = { hidden?: boolean; title?: string; subtitle?: string; bullets?: string[] }; -export type CvCustomSectionSetting = { key: string; title?: string; items: string[]; hidden?: boolean }; +export type CvCustomSectionSetting = { + key: string; + title?: string; + items: string[]; + hidden?: boolean; + presetKey?: string | null; + contentType?: "paragraphs" | "bullets" | "entries"; +}; +export type CvSkillGroupSetting = { name: string; items: string[] }; export type CvVariantSettings = { themeId: string; @@ -27,7 +35,8 @@ export type CvVariantSettings = { entryGapMm?: number | null; headingStyle?: "caps-rule" | "underline" | "plain" | "bar" | null; headerStyle?: "plain" | "band" | "centered" | "kicker" | null; - skillsStyle?: "tags" | "text" | null; + skillsStyle?: "tags" | "text" | "bullets" | "grouped" | null; + skillGroups?: CvSkillGroupSetting[] | null; layout?: "single" | "sidebar-left" | "sidebar-right" | "header-band" | null; sidebarWidthMm?: number | null; sidebarSections?: string[] | null; @@ -52,6 +61,7 @@ export type CvTheme = { requiresPro: boolean; available: boolean; swatches: string[]; + supportedSettings?: string[]; }; // Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits. @@ -121,6 +131,13 @@ export const SECTION_LABELS: Record = { references: "References", }; +export const CUSTOM_SECTION_PRESETS = [ + { key: "courses", title: "Courses", contentType: "entries" as const }, + { key: "volunteer", title: "Volunteer Experience", contentType: "entries" as const }, + { key: "additional-experience", title: "Additional Experience", contentType: "entries" as const }, + { key: "declaration", title: "Declaration", contentType: "paragraphs" as const }, +] as const; + const CSS_PIXELS_PER_MM = 96 / 25.4; export type CvPageMetrics = { diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 7b1f5eb..4fb34ac 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -15,7 +15,6 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import PublicIcon from "@mui/icons-material/Public"; import AddIcon from "@mui/icons-material/Add"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; -import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; @@ -27,14 +26,14 @@ import RedoIcon from "@mui/icons-material/Redo"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import RichTextField from "../components/RichTextField"; +import AiSectionAssistant from "../components/cv/AiSectionAssistant"; +import CvTemplateThumbnail from "../components/CvTemplateThumbnail"; import { useDragReorder } from "../hooks/useDragReorder"; import { - AI_ACTIONS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, + CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, } from "../cvBuilder"; -import { useAccountPlan } from "../accountPlan"; -import ProFeatureNotice from "../components/ProFeatureNotice"; import { useDialogActions } from "../dialogs"; const FONTS = [ @@ -49,10 +48,15 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", " const MIN_PREVIEW_ZOOM = 0.32; type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error"; -function previewPageHtml(html: string, pageIndex: number, pageHeightPx: number): string { - const offset = pageIndex * pageHeightPx; - const previewCss = ``; - return html.includes("") ? html.replace("", `${previewCss}`) : `${previewCss}${html}`; +function previewDocumentHtml(html: string, pageHeightPx: number, pageIndex = 0): string { + const height = Math.max(1, Math.round(pageHeightPx * 100) / 100); + const offset = Math.max(0, pageIndex) * height; + const previewCss = ``; + // Screen media does not apply Chromium's paged-media fragmentation. Reproduce the important + // keep-together decisions before each preview frame crops the selected physical page. + const previewScript = ``; + const withCss = html.includes("") ? html.replace("", `${previewCss}`) : `${previewCss}${html}`; + return withCss.includes("") ? withCss.replace("", `${previewScript}`) : `${withCss}${previewScript}`; } export default function CvBuilderEditor() { @@ -362,23 +366,28 @@ export default function CvBuilderEditor() { // Measure rendered content to drive page count + break indicators. const onIframeLoad = () => { - try { - const doc = iframeRef.current?.contentDocument; - const h = Math.max( - pageMetrics.heightPx, - doc?.body?.scrollHeight ?? 0, - doc?.documentElement?.scrollHeight ?? 0, - ); - const pageCount = getCvPageCount(h, pageMetrics.heightPx); - setPages(pageCount); - setPage((current) => Math.min(current, pageCount)); - const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx; - const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0); - setPreviewOverflow(contentWidth > viewportWidth + 2); - } catch { - setPages(1); - setPreviewOverflow(false); - } + const win = iframeRef.current?.contentWindow; + const measure = () => { + try { + const doc = iframeRef.current?.contentDocument; + const h = Math.max( + pageMetrics.heightPx, + doc?.body?.scrollHeight ?? 0, + doc?.documentElement?.scrollHeight ?? 0, + ); + const pageCount = getCvPageCount(h, pageMetrics.heightPx); + setPages(pageCount); + setPage((current) => Math.min(current, pageCount)); + const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx; + const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0); + setPreviewOverflow(contentWidth > viewportWidth + 2); + } catch { + setPages(1); + setPreviewOverflow(false); + } + }; + if (win) win.requestAnimationFrame(() => win.requestAnimationFrame(measure)); + else measure(); }; const goToPage = (p: number) => { @@ -436,12 +445,11 @@ export default function CvBuilderEditor() { - { setTab(v); if (v === 5) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}> + { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}> - @@ -449,8 +457,7 @@ export default function CvBuilderEditor() { {tab === 1 && } {tab === 2 && } {tab === 3 && } - {tab === 4 && } - {tab === 5 && } + {tab === 4 && } @@ -475,7 +482,7 @@ export default function CvBuilderEditor() { {previewOverflow && The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.} {pages >= 3 && This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.} -