Styled PDF resume export (Slice 17)#17
Merged
Merged
Conversation
Zero (no override defaults false), One (explicit true wins), Boundary (explicit false matches absent field) — proves resolveExportStyle merges the new flag correctly before wiring it into the Settings > Export UI. Adds a Styled PDF resume toggle to app/settings/export alongside the existing DOCX style controls, gated separately from ENABLE_PLAYWRIGHT_FALLBACK (deployment capability vs user preference).
Zero (empty string), One (single word), Many (punctuation-heavy name), Boundary (no leading/trailing hyphen), plus date-range Boundary (Dec/Jan year rollover) and null-end "Present" case — first real coverage for logic that previously lived unexported and untested inside docx-resume.ts. Needed by both the DOCX export and the new styled PDF template so the two paths don't drift on date/slug formatting.
Zero (minimal profile omits every optional section), One (single role renders title/employer/dates/bullet), Many (roles preserve order), Boundary (HTML-significant characters escaped, break-inside: avoid present on role blocks), Exception (missing education/awards/skills omit their sections without throwing), Simple (full profile renders all five sections in Profile/Skills/Experience/Awards/Education order). Pure function, no I/O — reuses ProfileData from docx-resume.ts and the formatDateRange/companySlug helpers extracted in the prior commit. Design tokens are fixed per the approved style spec, not exposed via ExportStyle, since this is an opinionated second export path rather than a user-configurable one.
Boundary case: confirms @font-face + base64 woff2 data is present, not just a bare font-family reference that would silently fall back to a system font at render time. Matches what the Playwright POC validated (real /FontFile2 embedding, not substitution). Commits Inter Regular + SemiBold woff2 (~48KB total, subset via @fontsource/inter) to assets/fonts/inter/, read once per process and cached rather than re-encoded on every request.
Zero (unset returns false), One (exact 'true' returns true), Boundary (any other truthy-looking value like '1' still returns false, not just literal 'false') — first standalone coverage for a check that previously lived unexported inside job-fetcher.ts. Both job-fetcher.ts and the upcoming resume-pdf.ts gate Playwright usage on the same underlying constraint (Chromium can't run on Vercel). One source of truth instead of two copies that could drift.
Zero (fallback flag off short-circuits without touching the loader), One (flag on but Playwright unavailable returns null), Many (two sequential calls each launch and close their own browser, no leak), Boundary (empty HTML string still reaches page.pdf() — validation is the caller's job, not this layer's), Exception (a page.pdf() failure propagates to the caller rather than being swallowed, browser still closes via finally), Simple (happy path returns the PDF buffer). Mirrors lib/job-fetcher.ts's exact DI shape (dynamic import wrapped in try/catch, injectable loadPlaywrightImpl) so it stays testable without a real browser, and shares the ENABLE_PLAYWRIGHT_FALLBACK gate via lib/env.ts rather than re-deciding Playwright availability on its own.
Route contract tests (mocking config-repository/github/resume-pdf, same convention as jobs.test.ts): config flag off -> 400, config on but renderer returns null -> 503, success -> 200 with application/pdf and a matching Content-Disposition filename, malformed profile.json -> 500 with an error body. Separate file from app/api/export/resume/route.ts rather than a format branch — keeps the shipping DOCX path untouched and keeps the two independently-gated code paths (stylePdfEnabled config preference vs ENABLE_PLAYWRIGHT_FALLBACK deployment capability) from coupling in one handler. Explicit `runtime = "nodejs"` since Playwright needs Node, not edge. Also adds two resume-template.ts tests (portfolio_url company slug, tailoredProfileBullets branch) that were exercised by this route but untested at the unit level, closing the coverage gap before wiring them together. Verified end to end outside the mocked test suite: real Playwright, real profile data, real embedded fonts (/FontFile2), extractable text. Closes Slice 17 in CLAUDE.md's backlog.
Slice D built the API route but never gave the UI a way to call it — the Settings > Export toggle had nothing downstream to control. Adds "Resume .pdf" next to "Resume .docx" in AIGenerationCard's export row (both the standard footer and the tailor-resume tab), gated on config.export.stylePdfEnabled fetched the same way the component already self-fetches profile data. Hidden entirely when the flag is off, so nothing changes for deployments that haven't opted in. Verified against the running dev server with a real session cookie and a headless Chromium driver script: both buttons render, clicking Resume .pdf downloads a real PDF with real embedded Inter fonts and extractable text, no console errors.
…ames The Playwright PDF had a doubled first-page margin — the template's body padding (0.7in) stacked on top of the page.pdf() margin (0.7in) from lib/resume-pdf.ts. Page margin now owns the 0.7in per the style spec; the template no longer pads. The job detail JD link had no width constraint, so long URLs pushed past the card edge instead of truncating. Resume PDF/DOCX filenames now read name-company-role (e.g. jordan-rivera-acme-corp-design-engineer.pdf) instead of resume-company-role.
Adds Profile.strengthGroups — an ordered list of {label, items} groups
(e.g. "UX & Design", "Tools", "Technical") rendered one per line in
both the PDF and DOCX resume exports, matching the style spec's
Skills section pattern. Falls back to the existing flat strengths
list when no groups are set, so nothing breaks for existing profiles.
Settings UI replaces the single strengths textarea with a group
editor (add/remove groups, comma-separated items per group) and
migrates an existing flat strengths list into one group on load.
…graph Pasted summaries formatted with • bullets were stored as a single Profile.summary string and rendered into one <p>, collapsing the line breaks and leaving literal • characters in the output. Adds Profile.summaryBullets (parallel to the existing tailoredBullets pattern) plus lib/resume-format.ts#linesToBullets, which strips leading •/·/-/* markers and blank lines so pasted bullet text becomes a clean array. Both PDF and DOCX resume exports render it as a real <ul>/bulleted list, matching the style spec's "Profile: 4 bullets max" rule. Falls back to the legacy summary paragraph when no bullets are set. Settings UI replaces the summary textarea with a one-bullet-per-line editor and migrates an existing pasted summary into bullets on load.
EducationEntry.graduated was typed as a required string, but real profile data can have it null — the settings page passed that null straight into an <input value=...>, which React warns is invalid for a controlled input. Widened the type to string | null (matching docx-resume.ts's ProfileData already) and guarded the three call sites: the settings input (?? ""), and the AI system prompt's education formatter, which was interpolating the literal string "null" into generated documents when graduated was unset.
break-inside: avoid was applied to the whole .role-block, including its bullet list. When a role near the page boundary didn't fully fit, the entire block — title, employer, and every bullet — got pushed to page 2, leaving a large (~2in) blank gap at the bottom of page 1 instead of the intended 0.7in margin. Per the style spec, only the title + employer/dates row needs to stay together; the bullet list is fine splitting across a page break. Wrapped just that row in a new .role-head element carrying the break-inside: avoid rule, verified against a synthetic multi-page resume rendered through Playwright.
…s and line heights Co-authored-by: Cursor <cursoragent@cursor.com>
AI tailoring was picking from only the 1-2 line summary per role instead of the full bullet pool, and rendered every bullet from profile data with no cap — long resumes blew past one page. Recent two roles now select the best 4 bullets from their pool; older roles cap at 2, matching the non-tailored render path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
stylePdfEnabledconfig flaglib/resume-template.ts(pure HTML generator),lib/resume-pdf.ts(Playwright renderer),POST /api/export/resume-pdfroute, wired into the job detail page UIeducation.graduatedReact warning, Profile section as bullet list, labeled skill groups in Core Strengths, template spacing, and capping tailored/rendered experience bullets to 4 (recent two roles) / 2 (older roles) selected from the full bullet poolTest plan
pnpm test— 166 tests passingpnpm tsc --noEmit— cleanpnpm lint— clean (unrelated pre-existing warning incoverage/)🤖 Generated with Claude Code