Skip to content

Styled PDF resume export (Slice 17)#17

Merged
creativereason merged 15 commits into
mainfrom
feat/styled-pdf-resume-export
Jul 8, 2026
Merged

Styled PDF resume export (Slice 17)#17
creativereason merged 15 commits into
mainfrom
feat/styled-pdf-resume-export

Conversation

@creativereason

Copy link
Copy Markdown
Owner

Summary

  • Implements Slice 17: styled PDF resume export via Playwright + HTML/CSS template, gated by stylePdfEnabled config flag
  • New lib/resume-template.ts (pure HTML generator), lib/resume-pdf.ts (Playwright renderer), POST /api/export/resume-pdf route, wired into the job detail page UI
  • Follow-on fixes: page-break handling for role blocks, null education.graduated React 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 pool

Test plan

  • pnpm test — 166 tests passing
  • pnpm tsc --noEmit — clean
  • pnpm lint — clean (unrelated pre-existing warning in coverage/)
  • Manual: generate a styled PDF export from a job detail page and confirm layout/pagination

🤖 Generated with Claude Code

creativereason and others added 15 commits July 7, 2026 19:35
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>
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
deckhand-ai Ready Ready Preview, Comment Jul 8, 2026 2:13am

@creativereason
creativereason merged commit 138fd74 into main Jul 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant