Skip to Content
How it was builtImplementation plans/resume Page Implementation Plan

/resume Page Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship /resume: an open, printable page with a Download PDF action whose dialog collects an optional name and email, then emails the owner and (if an address was given) the visitor with the PDF attached.

Architecture: A server-rendered page built from the existing content modules plus a new src/content/resume.ts. A client dialog starts the download immediately and posts the optional details in the background to POST /api/resume-request. All request logic (validation, rate limit, Turnstile, Resend) lives in a pure module with unit tests; the route handler is a thin wrapper.

Tech Stack: Next.js 15 App Router, React 19, vanilla-extract + sprinkles, zod (already installed), Vitest (new unit project), Storybook 10 stories as browser tests, Resend REST API via fetch (no new dependency), Cloudflare Turnstile.

Spec: docs/superpowers/specs/2026-10-06-resume-page-design.md. Deviation from the spec: the owner supplied the PDF, so there is no resume:pdf generation script and no CI drift check. The PDF is the committed file public/resume.pdf.

Global Constraints

  • Nothing invented: omit sections without owner-provided data. Education and the contact line come from the supplied PDF; there is no summary and no certifications.
  • Downloads never wait on, or fail because of, the email request.
  • Names/emails are never stored or logged.
  • Single h1; <main> is provided by Container; keyboard + focus handling; prefers-reduced-motion respected.
  • Exact-pinned dependencies (save-exact), none are added by this plan.
  • Conventional commits ending with Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>; PR body ends with 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  • CI must pass: yarn type-check, yarn lint, yarn format:ci, yarn build, story tests, commitlint.

File Structure

FileResponsibility
public/resume.pdfThe PDF served at /resume.pdf and attached to emails (moved from src/assets/documents/).
src/content/resume.tsOwner-supplied resume-only data: contact line, education.
src/lib/resume-request/schema.tszod schema + parseResumeRequest.
src/lib/resume-request/rate-limit.tsBest-effort in-memory per-IP limiter.
src/lib/resume-request/emails.tsPure builders for the owner and visitor email payloads.
src/lib/resume-request/handle.tshandleResumeRequest(deps, input): orchestrates verify, limit, send.
src/app/api/resume-request/route.tsThin POST wrapper wiring real deps.
src/config/env/server.tsAdds optional Resend/Turnstile/notify env vars.
src/app/resume/page.tsx, Resume.css.tsThe page and print stylesheet.
src/partials/ResumeDownload/Client button + dialog, styles, stories.
vitest.config.tsAdds a node unit project for src/**/*.test.ts.

Task 1: PDF asset and resume content module

Files:

  • Move: src/assets/documents/resume.pdf → public/resume.pdf
  • Delete: src/assets/documents/Anthony Mejia - Resume (1).pdf:Zone.Identifier (Windows download marker, not content)
  • Create: src/content/resume.ts

Interfaces:

  • Produces: resumeContact: { email: string; website: string; github: string }, resumeEducation: { school: string; program: string; date: string }[], RESUME_PDF_PATH = "/resume.pdf".

  • Step 1: Move the asset

git mv -k src/assets/documents/resume.pdf public/resume.pdf 2>/dev/null || mv src/assets/documents/resume.pdf public/resume.pdf rm -f "src/assets/documents/Anthony Mejia - Resume (1).pdf:Zone.Identifier" rmdir src/assets/documents 2>/dev/null || true
  • Step 2: Create src/content/resume.ts (values copied from the PDF)
/** Resume-only content (everything else on /resume comes from experience.ts and skills.ts). Supplied by the owner. */ export const RESUME_PDF_PATH = "/resume.pdf"; export const resumeContact = { email: "me@antmejia.com", website: "antmejia.com", github: "github.com/ant-mejia", }; export const resumeEducation: { school: string; program: string; date: string }[] = [ { school: "General Assembly", program: "Web Development Immersive", date: "March 2018" }, ];
  • Step 3: Verify the PDF is served and commit

Run: yarn dev then curl -sI localhost:3000/resume.pdf | head -3 → 200 and content-type: application/pdf.

git add public/resume.pdf src/content/resume.ts git commit -m "feat: add resume PDF and resume content module"

Task 2: Request logic with unit tests (TDD)

Files:

  • Modify: vitest.config.ts (add unit project), src/config/env/server.ts
  • Create: src/lib/resume-request/{schema,rate-limit,emails,handle}.ts and handle.test.ts

Interfaces:

  • Produces parseResumeRequest(input: unknown): { ok: true; data: { name?: string; email?: string; token?: string } } | { ok: false }.

  • Produces createRateLimiter({ max, windowMs, now? }): { allow(key: string): boolean }.

  • Produces buildOwnerEmail(args: { name?: string; email?: string; origin?: string; to: string; from: string }): EmailMessage and buildVisitorEmail(args: { name?: string; to: string; from: string; replyTo: string; pdf: Buffer }): EmailMessage, where EmailMessage = { from: string; to: string[]; reply_to?: string; subject: string; html: string; text: string; attachments?: { filename: string; content: string }[] }.

  • Produces handleResumeRequest(deps: Deps, req: { ip: string; origin?: string; body: unknown }): Promise<204 | 400 | 429> with Deps = { verifyToken(token: string | undefined, ip: string): Promise<boolean>; send(msg: EmailMessage): Promise<void>; limiter: { allow(key: string): boolean }; readPdf(): Promise<Buffer>; config: { from: string; notifyTo: string } | null }.

  • Step 1: Add the unit Vitest project to vitest.config.ts projects array:

{ extends: true, test: { name: "unit", environment: "node", include: ["src/**/*.test.ts"] }, },
  • Step 2: Write the failing tests src/lib/resume-request/handle.test.ts:
import { describe, expect, it, vi } from "vitest"; import { handleResumeRequest } from "./handle"; import { createRateLimiter } from "./rate-limit"; import type { Deps } from "./handle"; const make = (over: Partial<Deps> = {}): Deps => ({ verifyToken: vi.fn().mockResolvedValue(true), send: vi.fn().mockResolvedValue(undefined), limiter: createRateLimiter({ max: 5, windowMs: 60_000 }), readPdf: vi.fn().mockResolvedValue(Buffer.from("%PDF")), config: { from: "Anthony <resume@send.antmejia.com>", notifyTo: "me@antmejia.com" }, ...over, }); const req = (body: unknown) => ({ ip: "1.1.1.1", origin: "https://antmejia.com", body }); describe("handleResumeRequest", () => { it("emails owner and visitor when an address is given", async () => { const deps = make(); expect(await handleResumeRequest(deps, req({ name: "Ada", email: "ada@example.com", token: "t" }))).toBe(204); const msgs = vi.mocked(deps.send).mock.calls.map(([m]) => m); expect(msgs.map((m) => m.to[0])).toEqual(["me@antmejia.com", "ada@example.com"]); expect(msgs[1]?.attachments?.[0]?.filename).toBe("Anthony-Mejia-Resume.pdf"); }); it("emails only the owner when no address is given", async () => { const deps = make(); expect(await handleResumeRequest(deps, req({ token: "t" }))).toBe(204); expect(deps.send).toHaveBeenCalledTimes(1); }); it("rejects a failed Turnstile check without sending", async () => { const deps = make({ verifyToken: vi.fn().mockResolvedValue(false) }); expect(await handleResumeRequest(deps, req({ email: "ada@example.com" }))).toBe(400); expect(deps.send).not.toHaveBeenCalled(); }); it("rejects an invalid email and an overlong name", async () => { expect(await handleResumeRequest(make(), req({ email: "nope" }))).toBe(400); expect(await handleResumeRequest(make(), req({ name: "x".repeat(101) }))).toBe(400); }); it("rate limits per IP", async () => { const deps = make({ limiter: createRateLimiter({ max: 1, windowMs: 60_000 }) }); expect(await handleResumeRequest(deps, req({ token: "t" }))).toBe(204); expect(await handleResumeRequest(deps, req({ token: "t" }))).toBe(429); }); it("still returns 204 when sending fails, and does nothing when unconfigured", async () => { const failing = make({ send: vi.fn().mockRejectedValue(new Error("boom")) }); expect(await handleResumeRequest(failing, req({ token: "t" }))).toBe(204); const unconfigured = make({ config: null }); expect(await handleResumeRequest(unconfigured, req({ token: "t" }))).toBe(204); expect(unconfigured.send).not.toHaveBeenCalled(); }); it("escapes the name in HTML", async () => { const deps = make(); await handleResumeRequest(deps, req({ name: "<script>x</script>", token: "t" })); expect(vi.mocked(deps.send).mock.calls[0]?.[0].html).not.toContain("<script>"); }); });
  • Step 3: Run to confirm failure. yarn vitest run --project unit → fails (modules missing).

  • Step 4: Implement.

schema.ts:

import { z } from "zod/v4"; const schema = z.object({ name: z.string().trim().max(100).optional(), email: z.string().trim().max(254).pipe(z.email()).optional(), token: z.string().max(2048).optional(), }); export type ResumeRequest = z.infer<typeof schema>; export const parseResumeRequest = (input: unknown): { ok: true; data: ResumeRequest } | { ok: false } => { // Empty strings from the form mean "not provided". const cleaned = input && typeof input === "object" ? Object.fromEntries(Object.entries(input).filter(([, v]) => v !== "")) : input; const result = schema.safeParse(cleaned); return result.success ? { ok: true, data: result.data } : { ok: false }; };

rate-limit.ts:

/** Best-effort, per-instance limiter. Turnstile is the real abuse control; this only blunts bursts. */ export const createRateLimiter = ({ max, windowMs, now = Date.now, }: { max: number; windowMs: number; now?: () => number; }) => { const hits = new Map<string, number[]>(); return { allow(key: string) { const t = now(); const recent = (hits.get(key) ?? []).filter((h) => t - h < windowMs); if (recent.length >= max) { hits.set(key, recent); return false; } recent.push(t); hits.set(key, recent); return true; }, }; };

emails.ts:

export type EmailMessage = { from: string; to: string[]; reply_to?: string; subject: string; html: string; text: string; attachments?: { filename: string; content: string }[]; }; const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); export const buildOwnerEmail = ({ name, email, origin, to, from, }: { name?: string; email?: string; origin?: string; to: string; from: string; }): EmailMessage => { const who = name || email || "Someone"; const lines = [`Name: ${name ?? "(not given)"}`, `Email: ${email ?? "(not given)"}`, `From: ${origin ?? "unknown"}`]; return { from, to: [to], ...(email ? { reply_to: email } : {}), subject: `Resume downloaded: ${who}`.slice(0, 150), text: lines.join("\n"), html: `<p>${lines.map(escapeHtml).join("<br>")}</p>`, }; }; export const buildVisitorEmail = ({ name, to, from, replyTo, pdf, }: { name?: string; to: string; from: string; replyTo: string; pdf: Buffer; }): EmailMessage => { const greeting = name ? `Hi ${name},` : "Hi,"; const text = `${greeting}\n\nThanks for your interest. My resume is attached.\n\nAnthony Mejia\nantmejia.com`; return { from, to: [to], reply_to: replyTo, subject: "Anthony Mejia: resume", text, html: `<p>${escapeHtml(greeting)}</p><p>Thanks for your interest. My resume is attached.</p><p>Anthony Mejia<br>antmejia.com</p>`, attachments: [{ filename: "Anthony-Mejia-Resume.pdf", content: pdf.toString("base64") }], }; };

handle.ts:

import { buildOwnerEmail, buildVisitorEmail } from "./emails"; import { parseResumeRequest } from "./schema"; import type { EmailMessage } from "./emails"; export type Deps = { verifyToken(token: string | undefined, ip: string): Promise<boolean>; send(msg: EmailMessage): Promise<void>; limiter: { allow(key: string): boolean }; readPdf(): Promise<Buffer>; config: { from: string; notifyTo: string } | null; }; export const handleResumeRequest = async ( deps: Deps, { ip, origin, body }: { ip: string; origin?: string; body: unknown } ): Promise<204 | 400 | 429> => { if (!deps.limiter.allow(ip)) return 429; const parsed = parseResumeRequest(body); if (!parsed.ok) return 400; const { name, email, token } = parsed.data; if (!(await deps.verifyToken(token, ip))) return 400; // Unconfigured (no keys yet): the download still works, nothing is sent. if (!deps.config) return 204; const { from, notifyTo } = deps.config; try { await deps.send(buildOwnerEmail({ name, email, origin, to: notifyTo, from })); if (email) { await deps.send(buildVisitorEmail({ name, to: email, from, replyTo: notifyTo, pdf: await deps.readPdf() })); } } catch (error) { console.error("resume-request: send failed", error instanceof Error ? error.message : "unknown"); } return 204; };

Add to src/config/env/server.ts server: (all optional, so builds and CI never need them) and experimental__runtimeEnv is empty so pass-through is automatic for server vars:

RESEND_API_KEY: z.string().optional(), RESUME_FROM: z.string().optional(), RESUME_NOTIFY_TO: z.string().optional(), TURNSTILE_SECRET_KEY: z.string().optional(),
  • Step 5: Run tests. yarn vitest run --project unit → all pass.
  • Step 6: Commit feat: add resume request handling with tests.

Task 3: Route handler

Files: Create src/app/api/resume-request/route.ts.

Interfaces: Consumes handleResumeRequest, createRateLimiter, serverEnv.

  • Step 1: Implement
import { readFile } from "node:fs/promises"; import path from "node:path"; import { serverEnv } from "@/config/env/server"; import { handleResumeRequest } from "@/lib/resume-request/handle"; import { createRateLimiter } from "@/lib/resume-request/rate-limit"; import type { EmailMessage } from "@/lib/resume-request/emails"; export const runtime = "nodejs"; const limiter = createRateLimiter({ max: 5, windowMs: 10 * 60_000 }); const verifyToken = async (token: string | undefined, ip: string) => { const secret = serverEnv.TURNSTILE_SECRET_KEY; // Fail closed in production when the secret is missing; allow in development so the flow can be tried locally. if (!secret) return serverEnv.NODE_ENV !== "production"; if (!token) return false; const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body: new URLSearchParams({ secret, response: token, remoteip: ip }), }); return res.ok && ((await res.json()) as { success?: boolean }).success === true; }; const send = async (msg: EmailMessage) => { const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${serverEnv.RESEND_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(msg), }); if (!res.ok) throw new Error(`Resend responded ${res.status}`); }; export async function POST(request: Request) { const body: unknown = await request.json().catch(() => null); const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; const { RESEND_API_KEY, RESUME_FROM, RESUME_NOTIFY_TO } = serverEnv; const status = await handleResumeRequest( { verifyToken, send, limiter, readPdf: () => readFile(path.join(process.cwd(), "public", "resume.pdf")), config: RESEND_API_KEY && RESUME_FROM && RESUME_NOTIFY_TO ? { from: RESUME_FROM, notifyTo: RESUME_NOTIFY_TO } : null, }, { ip, origin: request.headers.get("origin") ?? undefined, body } ); return new Response(null, { status }); }
  • Step 2: Verify. yarn dev; curl -s -o /dev/null -w "%{http_code}" -X POST localhost:3000/api/resume-request -H 'content-type: application/json' -d '{"email":"nope"}' → 400; with {} → 204 (unconfigured, development).
  • Step 3: Commit feat: add resume request route.

Task 4: /resume page and print stylesheet

Files: Create src/app/resume/page.tsx, src/app/resume/Resume.css.ts; Modify src/app/sitemap.ts (add "/resume").

Interfaces: Consumes PageShell exports, experienceTimeline, skills/stackLabels, resumeContact, resumeEducation, ResumeDownload (Task 5, rendered in the header).

Structure (follow src/app/experience/[slug]/page.tsx):

  • PageShell backHref="/" backLabel="Home"; PageHeader title="Anthony Mejia" subtitle="Senior Software Engineer · New York, NY"; <ResumeDownload /> directly under the header.

  • PageBody aside = MetaList with Email (mailto:), Website, GitHub from resumeContact, then a Skills block: for each stackLabels key, a <h3> label and a comma-separated list of skills.filter(s => s.stack === key).map(s => s.name). No icons, no proficiency.

  • Main = PageSection title="Experience" with one <article> per experienceTimeline item: <h3> = PageSectionLink href={/experience/${slug}}, label-face line company · period.start – period.end · label, then PageHighlights. Then PageSection title="Education" listing resumeEducation.

  • Metadata: pageMetadata({ title: "Resume", description: "Resume of Anthony Mejia, senior software engineer in New York, NY.", path: "/resume" }).

  • Mobile order: experience before skills (render the skills block after the main column on narrow screens via CSS order on the aside).

  • Resume.css.ts exports RoleStyle (breakInside: "avoid") and @media print rules: hide the nav/footer/back link/download button ([data-print="hide"]), black on white at 10.5pt, @page { size: auto; margin: 14mm }, links unstyled.

  • Step 1: Write the files per the above, using only existing PageShell styles plus the print additions.

  • Step 2: Verify. yarn type-check && yarn lint; curl -s -o /dev/null -w "%{http_code}" localhost:3000/resume → 200; exactly one <h1> in the HTML.

  • Step 3: Commit feat: add /resume page.


Task 5: Download button, dialog and Storybook tests

Files: Create src/partials/ResumeDownload/index.tsx, ResumeDownload.css.ts, ResumeDownload.stories.tsx.

Interfaces: Produces ResumeDownload: FC (client component). Consumes RESUME_PDF_PATH, clientEnv (add optional NEXT_PUBLIC_TURNSTILE_SITE_KEY to src/config/env/client.ts, both schema and experimental__runtimeEnv).

Behavior:

  • Button “Download PDF” (data-print="hide") opens a native <dialog> via showModal() (gives focus trap, Esc, top layer); on close, focus returns to the button.
  • Fields: Name (autocomplete="name"), Email (type="email", autocomplete="email"), both optional, labelled. Short privacy line: “Optional. I use this to know who is reading and to email you a copy.”
  • Actions: Download (submits) and Skip and download (secondary, equal weight). Both call startDownload(): create a temporary <a href={RESUME_PDF_PATH} download="Anthony-Mejia-Resume.pdf"> and click(), close the dialog, set the button label to “Downloaded” for 2.5 s.
  • If the email field is non-empty and fails checkValidity(), show an inline message and do not submit; “Skip and download” always works.
  • On a valid submit, fire-and-forget fetch("/api/resume-request", { method: "POST", headers, body: JSON.stringify({ name, email, token }), keepalive: true }).catch(() => {}). The Turnstile script (https://challenges.cloudflare.com/turnstile/v0/api.js) and widget load only when NEXT_PUBLIC_TURNSTILE_SITE_KEY is set; the token is passed along. Without a key, no token is sent.
  • Reduced motion: no dialog animation under prefers-reduced-motion: reduce.

Stories (play functions using @storybook/test already used by PageShell.stories.tsx as the pattern): Default (opens the dialog; asserts both buttons exist and Esc closes), InvalidEmail (types nope, clicks Download, asserts the inline message and the dialog still open), Skip (clicks Skip and download; asserts the dialog closes and the button reads “Downloaded”). Stub fetch and the anchor click in the story decorator so no network or file download happens.

  • Step 1: Write the stories first and run yarn vitest run --project storybook (fails: component missing).
  • Step 2: Implement the component and styles (use focusRing and hairline from PageShell.css.ts; button styled as the secondary-page link language, navy border, no shadow).
  • Step 3: Re-run the story tests; all pass.
  • Step 4: Commit feat: add resume download dialog.

Task 6: Verification, docs, PR

  • Step 1: Playwright probe against yarn build && yarn start (use scratchpad/serve.sh + a new v-resume.mjs): /resume 200 at 1440 and 390 widths with no horizontal scroll; one h1; Download opens the dialog, Skip and download triggers a download event for resume.pdf; page.emulateMedia({ media: "print" }) screenshots on Letter and A4 show nav/footer/button hidden and no role split across pages.
  • Step 2: Run the Impeccable detector once: node /home/antmejia/.claude/plugins/cache/impeccable/impeccable/4.1.1/skills/impeccable/scripts/detect.mjs --json src/app/resume src/partials/ResumeDownload; fix findings in one batch.
  • Step 3: README: add a Resume note under Deployment/Configuration listing RESEND_API_KEY, RESUME_FROM, RESUME_NOTIFY_TO, TURNSTILE_SECRET_KEY, NEXT_PUBLIC_TURNSTILE_SITE_KEY (all optional; without them the page and download work and no email is sent).
  • Step 4: yarn type-check && yarn lint && yarn format:ci && yarn build; commit docs; push feat/resume-page; open a PR “feat: add /resume page, PDF download and optional email capture” with Closes #46; wait for Check PR; merge on green.

Self-review

  • Spec coverage: page (T4), dialog + optional fields + skip (T5), route with Turnstile/rate limit/validation/Resend/no storage (T2–T3), PDF (T1, deviation noted), config (T2/T6), testing (T2 unit, T5 stories, T6 probe), sitemap (T4), privacy line (T5).
  • Placeholders: none; UI tasks specify structure, behavior and tests, and the logic tasks carry full code.
  • Type consistency: EmailMessage, Deps, handleResumeRequest signatures match between T2 and T3; RESUME_PDF_PATH defined in T1, used in T5.