/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 byContainer; keyboard + focus handling;prefers-reduced-motionrespected. - 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
| File | Responsibility |
|---|---|
public/resume.pdf | The PDF served at /resume.pdf and attached to emails (moved from src/assets/documents/). |
src/content/resume.ts | Owner-supplied resume-only data: contact line, education. |
src/lib/resume-request/schema.ts | zod schema + parseResumeRequest. |
src/lib/resume-request/rate-limit.ts | Best-effort in-memory per-IP limiter. |
src/lib/resume-request/emails.ts | Pure builders for the owner and visitor email payloads. |
src/lib/resume-request/handle.ts | handleResumeRequest(deps, input): orchestrates verify, limit, send. |
src/app/api/resume-request/route.ts | Thin POST wrapper wiring real deps. |
src/config/env/server.ts | Adds optional Resend/Turnstile/notify env vars. |
src/app/resume/page.tsx, Resume.css.ts | The page and print stylesheet. |
src/partials/ResumeDownload/ | Client button + dialog, styles, stories. |
vitest.config.ts | Adds 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(addunitproject),src/config/env/server.ts - Create:
src/lib/resume-request/{schema,rate-limit,emails,handle}.tsandhandle.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 }): EmailMessageandbuildVisitorEmail(args: { name?: string; to: string; from: string; replyTo: string; pdf: Buffer }): EmailMessage, whereEmailMessage = { 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>withDeps = { 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
unitVitest project tovitest.config.tsprojectsarray:
{
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
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=MetaListwith Email (mailto:), Website, GitHub fromresumeContact, then a Skills block: for eachstackLabelskey, a<h3>label and a comma-separated list ofskills.filter(s => s.stack === key).map(s => s.name). No icons, no proficiency. -
Main =
PageSection title="Experience"with one<article>perexperienceTimelineitem:<h3>=PageSectionLink href={/experience/${slug}}, label-face linecompany · period.start – period.end · label, thenPageHighlights. ThenPageSection title="Education"listingresumeEducation. -
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
orderon the aside). -
Resume.css.tsexportsRoleStyle(breakInside: "avoid") and@media printrules: 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>viashowModal()(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">andclick(), 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 whenNEXT_PUBLIC_TURNSTILE_SITE_KEYis 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
focusRingandhairlinefromPageShell.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(usescratchpad/serve.sh+ a newv-resume.mjs):/resume200 at 1440 and 390 widths with no horizontal scroll; oneh1; Download opens the dialog, Skip and download triggers a download event forresume.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; pushfeat/resume-page; open a PR “feat: add /resume page, PDF download and optional email capture” withCloses #46; wait forCheck 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,handleResumeRequestsignatures match between T2 and T3;RESUME_PDF_PATHdefined in T1, used in T5.