2026-09-23·by Sijie Wang#software#project#standmeet

resume-composer-one-renderer

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 stays resume_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:67jobsuc.DraftResume, jobsuc/resume.go:51) takes a job from the Redis pool and writes a resume_drafts row (backend/db/schema.sql:891) holding the job snapshot and Claude-curated content.
  • By hand. POST /api/admin/drafts (jobsadmin/routes.go:71jobsuc.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.tsx mounts PuckComposer (action bar, app/src/components/admin/composer/PuckComposer.tsx) around PuckResumeEditor. The legacy composer, its autosave hook, row drag and live-preview spec were deleted in 756c0eb1d (3,557 lines).
  • Projection. toPuckData (app/src/lib/admin/resume-puck.ts:53) and fromPuckData (:83) map ResumeContent ⇄ Puck data. resume-puck.test.ts:42 asserts fromPuckData(toPuckData(rc)) preserves every section and setting exactly — the projection is lossless, so resume_content can stay canonical.
  • Unique ids. toPuckData stamps props.id = <type>-<index> (resume-puck.ts:64). Puck keys its zones by props.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 by resume-puck.test.ts:71,108,124 and the browser spec resume-composer-sections.spec.ts:33, which counts each section rendered separately and captures pageerror.
  • Persisted editor state. resume_drafts.puck_data jsonb (schema.sql:905), added by migration 2026-09-07-resume-draft-puck-data.sql as ADD 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-72 derives dirty by comparing the projected content, arms beforeunload, and intercepts the in-app back button with a discard modal; Save calls savePuckDraft (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:72 builds <printBase>/print/application/<id>?t=<token>; app/src/app/print/application/[id]/page.tsx (server component, force-dynamic) renders ResumePuckRender = 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 by GET /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.commit renders the PDF before the irreversible commit (jobsuc/applications.go:121-144) and the composer's preview.pdf streams 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:40 reads the committed PDF's text layer, NFKC-normalised (compatibility normal form), and expects the glyphs. dac85c336 first 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 fieldsperiod: { start, end } with end nullable (resume-puck.ts:132), joined at display time (resume-puck-config.tsx:65) — f4e764361.
  • No footer. c49a1b4ec stripped 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; no undefined or [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:27 renders preview.pdf for 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.tsx still 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, not puck_data. ResumePuckRender re-derives the document with toPuckData; 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 template column (schema.sql:906) and GET /drafts/templates (routes.go:60,74) survive with an empty list (boot_deps.go:241); .gitignore:48-50 still ignores app/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).

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →