14 KiB
Plan 007: Extract a shared template page-shell — spike + one-template pilot
Executor instructions: This is a spike + pilot, not a 15-file rewrite. You will build a parity net, refactor exactly ONE template behind it, and then STOP and report. Do NOT migrate the other templates in this plan. Run every verification. Honor "STOP conditions". When done, update the status row in
plans/README.md.Drift check (run first):
git diff --stat 73daf22b2..HEAD -- packages/pdf/src/templates/If the templates changed since this plan, compare "Current state" against live code before proceeding; on mismatch, STOP.
Status
- Priority: P2
- Effort: L
- Risk: HIGH
- Depends on: 001 recommended as the characterization-testing exemplar/ discipline (not a technical blocker — the PDF layer needs its own parity net, which is Step 1 here).
- Category: tech-debt
- Planned at: commit
73daf22b2, 2026-07-08
Why this matters
All 15 resume templates
(packages/pdf/src/templates/<name>/<Name>Page.tsx) independently reimplement
the same page-shell orchestration: compute getTemplateMetrics,
getTemplatePageSize, getTemplatePageMinHeightStyle, shouldShowResumeHeader,
hasTemplatePicture, filterSections(page.main/sidebar), then render
<Page> → <TemplateProvider> → a two-column layout → sidebar/main columns
that .map over <Section>. Git history proves the cost: a single header
line-height change touched 14 template files (commit 1b0bb067b); the free-form
layout feature touched 14 (2cd774dab). Every new template copies this again,
and drift between copies is invisible until someone diffs all 15.
The goal is a shared, prop-/callback-driven TemplatePageShell that owns the
orchestration while each template keeps its own styles and decorative choices.
Because the visual risk is high and no render-parity test net exists for
templates today, this plan proves the abstraction on ONE template (Pikachu)
behind a characterization snapshot, then stops for review before any rollout.
Current state
-
packages/pdf/src/templates/pikachu/PikachuPage.tsx— the pilot. Structure (lines 58–110): thePikachuPagecomponent computes the shared metrics/flags and renders the shell; a localHeadercomponent (lines 112–161) rendersbasics.name/headline+ the shared contact-item components; ausePikachuTemplatehook (lines 163–292) builds the per-templateStyleSheet. The orchestration block, verbatim:export const PikachuPage = ({ page, pageIndex }: TemplatePageProps) => { const data = useRender(); const { metadata, picture } = data; const { colors, styles } = usePikachuTemplate(); const metrics = getTemplateMetrics(metadata.page); const pageSize = getTemplatePageSize(metadata.page.format); const pageMinHeightStyle = getTemplatePageMinHeightStyle(metadata.page.format); const showHeader = shouldShowResumeHeader(data, pageIndex); const showSidebar = !page.fullWidth; const hasPicture = hasTemplatePicture(picture); const mainSections = filterSections(page.main, data); const sidebarSections = filterSections(page.sidebar, data); return ( <Page size={pageSize} style={composeStyles(styles.page, pageMinHeightStyle)}> <TemplateProvider styles={styles} colors={colors} features={pikachuFeatures}> <View style={styles.layout}> {showSidebar && ( <View style={composeStyles(styles.sidebarColumn, { width: `${metadata.layout.sidebarWidth}%`, rowGap: metrics.sectionGap })}> {showHeader && showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />} <View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}> {sidebarSections.map((s) => <Section key={s} section={s} placement="sidebar" />)} </View> </View> )} <View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}> {showHeader && ( <View style={styles.headerRow}> {showHeader && !showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />} <Header styles={styles} colors={colors} /> </View> )} <View style={{ rowGap: metrics.sectionGap }}> {mainSections.map((s) => <Section key={s} section={s} placement="main" />)} </View> </View> </View> </TemplateProvider> </Page> ); };Template-specific variation you must preserve (this is why extraction is risky — the shell cannot assume one layout): Pikachu wraps its
Headerin a colored box and places the picture beside the header in the main column when there's no sidebar, but above the sidebar sections when there is. Other templates differ: Azurill (AzurillPage.tsx) usesflexBasisfor sidebar width instead of awidthpercentage; Onyx (OnyxPage.tsx) is single-column header-on-top; Gengar (GengarPage.tsx) callsgetFeaturedSummaryLayout. The shared shell must expose enough seams (render callbacks / slots) that each template keeps these differences — do not flatten them into one hardcoded layout. -
Shared helpers already exist under
packages/pdf/src/templates/shared/:metrics.ts,page-size.ts,cover-letter.ts(shouldShowResumeHeader),filtering.ts,picture.ts,contact-item.tsx,context.tsx(TemplateProvider),sections.tsx(Section),styles.ts(composeStyles). The extraction consolidates the orchestration that wires these together, not the helpers themselves. -
No render-parity test exists per template. The only per-template test is
templates/scizor/ScizorPage.test.ts, which greps source text — not a structural snapshot. You will build a real parity net in Step 1. -
packages/pdf/src/document.ts— definesTemplatePagePropsandTemplatePage;templates/index.tsmaps template name → page component.
Commands you will need
| Purpose | Command | Expected |
|---|---|---|
| Typecheck | pnpm --filter @reactive-resume/pdf typecheck |
exit 0 |
| PDF tests | pnpm --filter @reactive-resume/pdf test |
all pass |
| Pilot snapshot | pnpm --filter @reactive-resume/pdf test -- pikachu |
all pass |
(Do NOT run pnpm check.)
Scope
In scope:
- A new shared shell, e.g.
packages/pdf/src/templates/shared/page-shell.tsx(create) and its type additions inshared/types.tsif needed. packages/pdf/src/templates/pikachu/PikachuPage.tsx(the ONE pilot migration)- A new parity snapshot test:
packages/pdf/src/templates/pikachu/PikachuPage.test.tsx(create) plans/README.md(status row + a note on the pilot outcome)
Out of scope (hard stop):
- The other 14
*Page.tsxtemplates — do not touch them in this plan. Their migration is explicit follow-up, gated on this pilot's review. - The shared helpers' internals (
metrics.ts,filtering.ts, etc.). templates/index.tsmapping — Pikachu's export name/signature must not change.- Any visual/styling change — this is a pure structural extraction; the pilot's rendered output must be byte-identical to before.
Git workflow
- Branch:
advisor/007-template-shell-spike - Commit style: conventional commits, e.g.
refactor(pdf): extract shared template page-shell; pilot on pikachu. - Do NOT push or open a PR unless instructed.
Steps
Step 1: Build the parity net for Pikachu (BEFORE refactoring)
Create packages/pdf/src/templates/pikachu/PikachuPage.test.tsx that renders
PikachuPage with a fixed sample resume and metadata and snapshots the produced
element tree. Approach:
- Use
react-test-renderer(already available transitively; if not, use the approach the existingpackages/pdf/src/browser.test.tsx/server.test.tsxuse to exercise components) to render the template wrapped in whatever contextuseRender()needs. Readpackages/pdf/src/context.tsxto see how to provide the render context in a test (there may be a provider/helper the existing tests use — reuse it). - Feed it
sampleResumeDatafrom@reactive-resume/schema/resume/sample(used byserver.test.tsx) and a representativepageprop (one with a sidebar, one full-width — two snapshots). - Snapshot the tree with
expect(tree.toJSON()).toMatchSnapshot().
Run it to generate the baseline snapshot against the current, un-refactored PikachuPage.
Verify: pnpm --filter @reactive-resume/pdf test -- pikachu → passes and
writes a __snapshots__ file. Commit this snapshot as the baseline.
If you cannot render the template in a test without excessive/fragile mocking (react-pdf host primitives don't cooperate with the test renderer), STOP and report — a structural refactor with no parity net is exactly what this plan refuses to ship. Do not proceed to Step 2 without a working baseline.
Step 2: Extract the shared shell
Create shared/page-shell.tsx exporting a TemplatePageShell component (or a
hook + component pair) that encapsulates the orchestration from the excerpt:
computing metrics/pageSize/pageMinHeightStyle/showHeader/showSidebar/
hasPicture/mainSections/sidebarSections, and rendering the
<Page><TemplateProvider>...<View layout> scaffold. Expose the per-template
variation through props/render-callbacks, at minimum:
styles,colors,features(passed through toTemplateProviderand used by the scaffold).renderHeader/renderPicturecallbacks (or slot props) so Pikachu can keep its colored-box header and beside/above picture placement.- The sidebar-width strategy as data (Pikachu passes a
width%; Azurill will later passflexBasis) — model it so both fit without the shell hardcoding one.
Keep the shell's public surface minimal and documented with a short comment.
Verify: pnpm --filter @reactive-resume/pdf typecheck → exit 0.
Step 3: Migrate Pikachu onto the shell
Rewrite PikachuPage.tsx so PikachuPage delegates its orchestration to
TemplatePageShell, passing usePikachuTemplate()'s styles/colors, the
pikachuFeatures, and its Header/picture rendering via the callbacks. Keep
usePikachuTemplate and the Header component in the Pikachu file (styles stay
template-owned). The export name and TemplatePageProps signature must not
change.
Verify:
pnpm --filter @reactive-resume/pdf test -- pikachu→ the Step 1 snapshot still matches (identical tree). If the snapshot changed, the refactor altered output — investigate and reconcile; do NOT blindly update the snapshot.pnpm --filter @reactive-resume/pdf test→ the full PDF suite passes.pnpm --filter @reactive-resume/pdf typecheck→ exit 0.
Step 4: STOP and report — do not roll out
Write a short note in plans/README.md's status area (or the PR description)
covering: whether the shell's seams were sufficient for Pikachu without style
regressions, what the shell's public API ended up being, and which of the other
14 templates look like clean fits vs. which have layouts (e.g. Onyx single
column, Gengar featured-summary) that will need extra seams. This note is the
input for the reviewed rollout plans. Do not migrate any other template.
Test plan
- New
PikachuPage.test.tsxwith two snapshots (with-sidebar, full-width), established as a baseline in Step 1 and asserted unchanged after Step 3. - Full
packages/pdfsuite must remain green. - Verification:
pnpm --filter @reactive-resume/pdf test→ all pass; the Pikachu snapshot is unchanged between baseline and post-refactor.
Done criteria
Machine-checkable. ALL must hold:
packages/pdf/src/templates/shared/page-shell.tsxexists and is imported byPikachuPage.tsxpackages/pdf/src/templates/pikachu/PikachuPage.test.tsx+ its__snapshots__existpnpm --filter @reactive-resume/pdf testexits 0 (snapshot unchanged post-refactor)pnpm --filter @reactive-resume/pdf typecheckexits 0git diff --name-onlyshows exactly one*Page.tsxchanged (pikachu); no other template file modifiedplans/README.mdstatus row for 007 updated with the pilot-outcome notegrep -rL page-shell packages/pdf/src/templates/*/[A-Z]*Page.tsxstill lists 14 templates (i.e. only Pikachu adopted it)
STOP conditions
Stop and report back (do not improvise) if:
- You cannot build a working render-parity snapshot in Step 1 (Step 1's own stop clause) — no net, no refactor.
- The Pikachu snapshot changes after Step 3 and you cannot make it identical — the extraction is not behavior-preserving; report the diff instead of updating the snapshot to match.
- Making the shell fit Pikachu forces the abstraction to also encode a second template's layout (you find yourself designing for Onyx/Gengar mid-pilot) — stop; the pilot's job is to prove one clean seam set, not to pre-solve all 15.
- Any change would touch a template other than Pikachu — that's the rollout, which is out of scope.
Maintenance notes
- Rollout is deliberately deferred. After this pilot is reviewed and merged, create follow-up plans that migrate the remaining templates in small reviewed batches (group by layout family: full-width/single-column like Onyx; sidebar-% like Pikachu; flexBasis like Azurill; featured-summary like Gengar). Each batch gets its own baseline snapshot first.
- The shell's public API is load-bearing for 14 future migrations — a reviewer should scrutinize its seam design (are the render callbacks expressive enough?) more than the Pikachu diff itself.
- Prior maintainer guidance: template rewrites should be spike-gated and must not break the react-pdf/DOCX/JSON export pipeline. This plan honors that by keeping styles template-owned and asserting identical render output.