Résumé composer: one renderer draws the canvas and the PDF
Parent: key-designs · Sibling: embed-credential-never-carries-the-code
Verdict (2026-09-07): the résumé has one renderer — the Puck component config in
app/src/lib/admin/resume-puck-config.tsx. The owner edits on it (the canvas at/admin/edit-resume/[id]), and the PDF (Portable Document Format) is that same config rendered by Puck's<Render>on a print route and printed by gotenberg's Chromium. The Typst renderer that shipped 2026-09-01 (340ffabf9) was removed 2026-09-07 (1b1ebed2b) because two renderers meant two truths: what the owner saw was not what the recruiter got. The canonical document staysresume_content; Puck data is a projection of it, round-trip tested.
Motivating problem. The résumé is the outbound half of chain-hiring: the PDF carries a QR (quick-response) code whose URL admits the recruiter under an access code (access-control). A recruiter judges the PDF, never the editor. With Typst drawing the PDF and React drawing the canvas, every layout rule existed twice, and fidelity meant keeping two implementations in step by hand — a class of defect no test can enumerate. One renderer makes "what you see" and "what they get" the same code path.
Where a draft comes from
- From the job loop. The MCP tool
resume.draft(backend/internal/owner/jobs/jobsmcp/cap_resume.go:67→jobsuc.DraftResume,jobsuc/resume.go:51) takes a job from the Redis pool and writes aresume_draftsrow (backend/db/schema.sql:891) holding the job snapshot and Claude-curated content. - By hand.
POST /api/admin/drafts(jobsadmin/routes.go:71→jobsuc.CreateManualDraft,resume.go:89) seeds a draft from the owner's profile (seedResumeContent,resume.go:115) with no job attached. Shipped 2026-09-05 (c611152b3). - Both paths land in the same table and open in the same editor; a draft is a draft.
The composer
- Route.
app/src/app/admin/edit-resume/[id]/page.tsxmountsPuckComposer(action bar,app/src/components/admin/composer/PuckComposer.tsx) aroundPuckResumeEditor. The legacy composer, its autosave hook, row drag and live-preview spec were deleted in756c0eb1d(3,557 lines). - Projection.
toPuckData(app/src/lib/admin/resume-puck.ts:53) andfromPuckData(:83) mapResumeContent⇄ Puck data.resume-puck.test.ts:42assertsfromPuckData(toPuckData(rc))preserves every section and setting exactly — the projection is lossless, soresume_contentcan stay canonical. - Unique ids.
toPuckDatastampsprops.id = <type>-<index>(resume-puck.ts:64). Puck keys its zones byprops.id; duplicate ids collapse every item into one section and the editor throws a page error (white screen). Found in use 2026-09-06 (0a805488f); guarded byresume-puck.test.ts:71,108,124and the browser specresume-composer-sections.spec.ts:33, which counts each section rendered separately and capturespageerror. - Persisted editor state.
resume_drafts.puck_data jsonb(schema.sql:905), added by migration2026-09-07-resume-draft-puck-data.sqlasADD COLUMN IF NOT EXISTS— reentrant, so an instance upgraded from a pre-Puck version gets the column without a fresh volume (41cb0cd8c). - Save is explicit. No autosave (
PuckResumeEditor.tsx:3-4: nothing persists inside the editor).PuckComposer.tsx:46-72derivesdirtyby comparing the projected content, armsbeforeunload, and intercepts the in-app back button with a discard modal; Save callssavePuckDraft(app/src/lib/admin/save-draft.ts:8).fb26c1c4d,75898e102.
The paper
The canvas is a fixed A4 sheet — .sm-resume-paper w-[794px] max-w-full aspect-[210/297] (resume-puck-config.tsx:153) — with its own light palette in app/src/app/sm-atoms.css:1529, independent of the admin's day/night theme. The print branch (:143) drops the aspect box so @page can flow. resume-composer-mobile-a4.spec.ts:30 reads the computed aspect-ratio and asserts 210 / 297 on a phone viewport (5826d44e5, eb07cb42c, 5f3ee57d1).
One config, two outputs
backend/cmd/server/boot_pdf.go:72builds<printBase>/print/application/<id>?t=<token>;app/src/app/print/application/[id]/page.tsx(server component,force-dynamic) rendersResumePuckRender= Puck<Render config={resumePuckConfig} metadata={{ qrURL, print: true }}>.- The print session lives in Redis for 60 s (
backend/internal/owner/jobs/printsess/store.go:32) and is consumed once byGET /internal/print-session/{token}(routes/sys/print_session.go:32) — a one-shot token, so the print route is not a public reader of drafts. - Two callers, one renderer:
applications.commitrenders the PDF before the irreversible commit (jobsuc/applications.go:121-144) and the composer'spreview.pdfstreams it (jobsadmin/drafts_edit.go:114-128).
The QR
The Header component draws <QRCode value={qrURL} size={46} /> (resume-puck-config.tsx:189, component atoms/QRCode.tsx). The code picker HeaderCodeField (:99-110) is a custom field of the Header panel, present even with zero codes (composer-code-picker.spec.ts:28). The URL is BuildQRURL = <public_url>/?code=<code> (jobsuc/applications.go:320-322, asserted by resume-qr-host.spec.ts:44-46): the QR carries the plaintext code, because a recruiter with no session needs a credential; the landing page then absorbs it into /c/<slug> (coded-landing-and-code-rotation). In the editor the QR is a placeholder frame (:190); the real image is drawn only when metadata.qrURL is set, that is, at print. A preview of a draft with no code picked encodes the sentinel preview://standmeet/draft (drafts_edit.go:28).
Rendering details that were bugs
- CJK (Chinese-Japanese-Korean) glyphs come from the gotenberg sidecar's Chromium fonts (
backend/Dockerfile:107-108);composer-cjk-renders.spec.ts:40reads the committed PDF's text layer, NFKC-normalised (compatibility normal form), and expects the glyphs.dac85c336first fixed this in the Typst font fallbacks; the guard outlived the renderer. - Custom sections (
CustomProps,resume-puck-config.tsx:49, component:299, divider kind:314) —6a9127155. - Period is two fields —
period: { start, end }withendnullable (resume-puck.ts:132), joined at display time (resume-puck-config.tsx:65) —f4e764361. - No footer.
c49a1b4ecstripped the page footer; the Puck config has no footer or page-number render.
Tests — the fidelity rule
Render the artifact and read the PDF's text layer; never take the editor's DOM (Document Object Model) as proof of the PDF. e2e/fixtures/pdf-inspect.ts:20 (inspectPDF, pdf-parse getText()) is the reader.
composer-pdf-fidelity.spec.ts:93— a sentinel in every field reaches the PDF text; both period forms; CJK; noundefinedor[object Object]; an empty draft prints no orphan headings (:112).composer-cjk-renders.spec.ts:40;resume-pdf-render.spec.ts:76,121,139(page counts, US-Letter, cover-letter break);draft-puck-section-order.spec.ts(section order survives into the PDF).- Canvas side:
resume-composer-mobile-a4.spec.ts,resume-composer-sections.spec.ts,resume-composer-fidelity.spec.ts:29,composer-qr-and-chrome.spec.ts:27,composer-code-picker.spec.ts:28;resume-look.spec.ts:27renderspreview.pdffor a human to look at (status only).
The honest ceiling
- "One renderer" holds for the PDF path, not for every thumbnail.
app/src/components/admin/resume-page/ResumePage.tsxstill draws the draft thumbnail (DraftThumb.tsx:37) and the application detail modal (ApplicationDetailModal.tsx:208). It feeds no PDF; retiring it is the remaining step. - The print route reads
resume_content, notpuck_data.ResumePuckRenderre-derives the document withtoPuckData; the two agree only because the projection round-trips. Anything Puck stores that the projection drops will not print — the round-trip test is the guard. - Typst residue. The
templatecolumn (schema.sql:906) andGET /drafts/templates(routes.go:60,74) survive with an empty list (boot_deps.go:241);.gitignore:48-50still ignoresapp/public/typst/. Dead weight, not a second path. - The QR is a plaintext credential on paper. Leak recovery is
codes.rotate(coded-landing-and-code-rotation), which kills every printed copy at once.
Built 2026-09-01 → 2026-09-07. Puck composer 397bb98b0, 1e7b2b863; legacy retired 756c0eb1d; projection ids 0a805488f; puck_data 41cb0cd8c + migration 2026-09-07-resume-draft-puck-data.sql; Save semantics fb26c1c4d, 75898e102; paper 5826d44e5, eb07cb42c, 5f3ee57d1; Typst in 340ffabf9, out 1b1ebed2b (backend/cmd/server/boot_pdf.go, backend/internal/owner/jobs/printsess/store.go, app/src/app/print/application/[id]/page.tsx); QR + picker 3f164dc76, e41db95b6, 93954b0f3; manual drafts c611152b3. Related: ephemeral-over-stateful (the print session is a 60 s one-shot and the job pool a 1-day pool; only the application row is durable) and builder-claim-skip-locked (the other place a worker claims a one-shot row).