diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b626c440..f40166f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,3 +36,7 @@ jobs:
- name: TypeScript
run: bun run typecheck
+
+
+ - name: Tests
+ run: bun run test
diff --git a/CLAUDE.md b/CLAUDE.md
index d6e5f54c..ff0c4a6e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,14 +7,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash
bun run dev # Start development server (Vite HMR)
bun run build # Type-check (tsc -b) then bundle for production
-bun run typecheck # Type-check only (tsc -b)
+bun run typecheck # Type-check only (tsc -b) — covers src, scripts, and tests
+bun run test # Fast checks: PDF invariants, colour contrast, sitemap
+bun run test:browser # Route smoke test (needs Chromium; not run in CI)
+bun run resume:pdf # Regenerate public/zm-resume.pdf from the résumé data
+bun run screenshots # Recapture the light/dark BioSite screenshots
bun run lint # Lint with Biome
bun run format # Format with Biome
bun run check # Biome check + auto-fix
bun run preview # Preview production build locally
```
-Package manager is **Bun** (`bun.lock`). Linting/formatting is **Biome** (`biome.json`). No test runner is configured.
+Package manager is **Bun** (`bun.lock`). Linting/formatting is **Biome** (`biome.json`). Tests use Bun's built-in runner — no separate test framework.
+
+The browser tests and `resume:pdf` need Chromium, which Playwright does not install automatically: run `bunx playwright install chromium` once per machine. CI never needs it, because `bun run test` covers only the browser-free suites.
## Architecture
@@ -30,7 +36,7 @@ Recurring details — email, phone, résumé path, social URLs, role, location
**Update categories** are the keys of `categoryTones` in `UpdateCard.tsx`; `UpdateCategory` is derived from them, so a typo in `updateEntries.ts` is a type error and the Updates filter row is generated from the same list.
-**ProjectCard**: Accepts an optional `deprecated` boolean that renders a "Deprecated" badge on the card. The `projects` array is typed by an explicit `Project` interface, so `imageDark`, `githubUrl`, `liveUrl`, and `deprecated` are plain optional properties. The featured project hero swaps in `imageDark` for dark mode (`dark:hidden` / `hidden dark:block` handle the toggle).
+**ProjectCard**: Accepts an optional `deprecated` boolean that renders a "Deprecated" badge. Its exported `Props` type is what `Projects.tsx` types the `projects` array with, so the two can't drift. Both the card and the featured hero honor an optional `imageDark`: the light image gets `dark:hidden` and the dark one `not-dark:hidden`, only when a dark variant exists — otherwise the light image would vanish in dark mode. Both render, so the theme toggle is instant.
**Theme system**: Dark/light mode uses a CSS custom property design token system defined in `src/styles/styles.css` (HSL channel triplets like `--background`, `--foreground`, `--accent`, so every token composes with `/ alpha`). The `dark` class on the `` element switches palettes. Theme is persisted to `localStorage` under the `theme` key and applied by an inline script in `index.html` before first paint to prevent a flash of the wrong theme.
@@ -44,6 +50,18 @@ Tokens are pruned to what's actually consumed. The `primary` / `secondary` / `ac
Note `@source not "../../**/*.md"` at the top of `styles.css`: Tailwind v4 auto-scans every project file for class names, and prose in this file was emitting real rules into the bundle. Keep that exclusion if you write class names in Markdown.
+**Résumé PDF** (`public/zm-resume.pdf`): generated, not hand-made. `src/pages/Resume/ResumePrint.tsx` is a Letter-sized print sheet that `scripts/generate-resume-pdf.ts` renders in headless Chromium via `page.pdf()`. Résumé content lives in `src/pages/Resume/resumeData.ts` + `jobEntries.ts`, so the page and the PDF can't disagree.
+
+- The print route is registered **only when `import.meta.env.DEV`**, so it's absent from production builds (the module tree-shakes out). That's why the generator renders against the dev server rather than a preview of `dist`.
+- The sheet clips overflow so nothing can spill onto page two, which means an overflow would silently *lose* content. The generator measures both axes and fails with the exact pixel overrun instead. It prints the fill percentage of each column — keep the main column under ~90% so bullets can be added later.
+- Only current roles appear; `olderExperience` entries are excluded by design.
+- After editing résumé data, run `bun run resume:pdf` and commit both the PDF and `scripts/zm-resume.hash`. `tests/resume-pdf.test.ts` compares that hash against the current sources, so CI fails if you forget. The hash covers typography too — `styles.css` and `index.html` — since a font change alters the render without touching any résumé text.
+- `tests/resume-pdf.test.ts` guards the properties that matter: one Letter page, real font resources, and that the file hasn't regressed to an image-only export (the version this replaced was a single raster with zero extractable text, invisible to ATS parsers).
+
+**Tests**: there are deliberately few, and none on component markup — the content is static and type-checked, so snapshots would churn on every design tweak and catch little. What exists covers things nothing else can: the PDF invariants above, `tests/contrast.test.ts` (asserts the token pairings clear WCAG AA in both themes, since the light-mode accent/cyan values are deliberately dark and easy to "fix" by lightening), `tests/sitemap.test.ts` (schema validation plus parity with the router's actual routes), and `tests/browser/routes.test.ts` (every route renders with no console errors and exactly one `h1`).
+
+Tests live in `tests/`, owned by `tsconfig.node.json` rather than the app project. Anything needing a browser goes in `tests/browser/` so the default `bun run test` — and therefore CI — needs no Chromium download; `bun run test` globs `tests/*.test.ts`, so a new fast test is picked up without editing package.json.
+
**Components vs Pages**: Shared primitives live in `src/components/ui/` — `Reveal` (scroll-reveal wrapper; put grid layout classes like `h-full` on it, since it becomes the grid child), `SectionHeading` (eyebrow + `h2` + description) which also exports `Eyebrow` for page heads that render their own `h1`, `Tag` (pill; also exports `toneText(tone)` for text-only tone colors), `Button` (a `Link` for `to`, an anchor for `href` — exactly one is required), `Timeline` (`TimelineRail` + `TimelineNode`, shared by the Resume experience list and the Updates feed), and `Clause` (+ `Item`, shared by the Terms and Privacy pages). Reuse these rather than re-implementing them. Other reusable display components live in `src/components/`; route-specific page components live in `src/pages/` and own local UI state (e.g. mobile menu open/closed, category filter selection).
**Conventions**: section eyebrows are numbered (`01 — About`) while page-head eyebrows are not; content page titles are `text-4xl md:text-6xl` (the Home hero and the 404 are deliberately larger); major sections use `py-20 md:py-28` and page heads `pt-14 md:pt-20`; Home and Projects are `max-w-7xl`, Resume/Updates/Terms/Privacy/404 are `max-w-4xl`; `Reveal` staggers step by 80ms; `Button` icons are `size={16}`; decorative layers carry `aria-hidden="true"`, plus `pointer-events-none` whenever they overlay content.
diff --git a/biome.json b/biome.json
index aa6c23aa..c5d7352b 100644
--- a/biome.json
+++ b/biome.json
@@ -6,7 +6,7 @@
"useIgnoreFile": true
},
"files": {
- "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
+ "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.css"],
"ignoreUnknown": true
},
"formatter": {
@@ -31,5 +31,10 @@
"semicolons": "always",
"trailingCommas": "all"
}
+ },
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
}
}
diff --git a/bun.lock b/bun.lock
index 6cb4529d..fc932237 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,9 +13,12 @@
"devDependencies": {
"@biomejs/biome": "^2.5.7",
"@tailwindcss/vite": "^4.3.3",
+ "@types/bun": "^1.3.14",
+ "@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
+ "playwright": "^1.62.1",
"tailwindcss": "^4.2.4",
"typescript": "~7.0.2",
"vite": "^8.2.1",
@@ -113,6 +116,10 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
+ "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
+
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
@@ -159,6 +166,8 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -169,7 +178,7 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -207,6 +216,10 @@
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+ "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
+
+ "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
+
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -235,6 +248,8 @@
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
+
"vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -251,6 +266,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
diff --git a/package.json b/package.json
index f613a7f2..ff963da7 100644
--- a/package.json
+++ b/package.json
@@ -6,11 +6,15 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "resume:pdf": "bun scripts/generate-resume-pdf.ts",
+ "screenshots": "bun scripts/capture-screenshots.ts",
"typecheck": "tsc -b",
+ "test": "bun test tests/*.test.ts",
"lint": "biome lint .",
"format": "biome format --write .",
"check": "biome check --write .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test:browser": "bun test tests/browser"
},
"dependencies": {
"react": "^19.2.8",
@@ -21,9 +25,12 @@
"devDependencies": {
"@biomejs/biome": "^2.5.7",
"@tailwindcss/vite": "^4.3.3",
+ "@types/bun": "^1.3.14",
+ "@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
+ "playwright": "^1.62.1",
"tailwindcss": "^4.2.4",
"typescript": "~7.0.2",
"vite": "^8.2.1"
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index fca20f25..40a95d4a 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/robots.txt b/public/robots.txt
index cee9ff21..83f7e8fe 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1,14 +1,5 @@
-User-agent: Googlebot
-Allow: /
-
-User-agent: Bingbot
-Allow: /
-
-User-agent: Twitterbot
-Allow: /
-
-User-agent: facebookexternalhit
+User-agent: *
Allow: /
+Disallow: /resume/print
-User-agent: *
-Allow: /
\ No newline at end of file
+Sitemap: https://zm1.org/sitemap.xml
diff --git a/public/zm-resume.pdf b/public/zm-resume.pdf
index 964ecffb..3ce0dc51 100644
Binary files a/public/zm-resume.pdf and b/public/zm-resume.pdf differ
diff --git a/scripts/capture-screenshots.ts b/scripts/capture-screenshots.ts
new file mode 100644
index 00000000..260f6e9a
--- /dev/null
+++ b/scripts/capture-screenshots.ts
@@ -0,0 +1,64 @@
+/**
+ * Captures the light and dark screenshots used by the BioSite card on the Projects page.
+ *
+ * bun run screenshots
+ *
+ * These go stale on every design change — the pair this replaced predated an entire redesign and
+ * was advertising a site that no longer existed. Re-run this instead of cropping by hand.
+ */
+///
+// The addInitScript and evaluate callbacks run in the browser, so this file needs DOM types
+// alongside Node's. This widens the whole tsconfig.node.json program — no per-file lib scoping.
+import { chromium } from 'playwright';
+import { createServer } from 'vite';
+import { fromRoot } from './resume-sources.ts';
+
+/**
+ * 16:9 to match the aspect-video frame the cards render in, and wide enough to stay above the lg
+ * breakpoint so the hero captures in its desktop layout rather than stacked. deviceScaleFactor
+ * stays at 1: the featured hero renders about 620px wide, so 1600px is already 2.5x — capturing at
+ * 2x tripled the file size for no visible gain.
+ */
+const VIEWPORT = { width: 1600, height: 900 };
+
+const SHOTS = [
+ { theme: 'light', file: 'src/pages/Projects/imgs/bioSite.jpg' },
+ { theme: 'dark', file: 'src/pages/Projects/imgs/bioSite_dark.jpg' },
+] as const;
+
+const server = await createServer({ server: { port: 4371, strictPort: false } });
+await server.listen();
+const base = server.resolvedUrls?.local[0];
+if (!base) throw new Error('vite dev server did not report a local URL');
+
+let browser: Awaited> | undefined;
+try {
+ browser = await chromium.launch();
+
+ for (const shot of SHOTS) {
+ const page = await browser.newPage({
+ viewport: VIEWPORT,
+ colorScheme: shot.theme,
+ // styles.css forces every [data-reveal] visible under reduced motion, so the capture can't
+ // race the IntersectionObserver, and the aurora drift freezes for a repeatable frame.
+ reducedMotion: 'reduce',
+ });
+ // The site reads its theme from localStorage before first paint.
+ await page.addInitScript(`localStorage.setItem('theme', '${shot.theme}')`);
+
+ await page.goto(base, { waitUntil: 'networkidle' });
+ await page.evaluate(() => document.fonts.ready);
+
+ const isDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
+ if (isDark !== (shot.theme === 'dark')) {
+ throw new Error(`expected ${shot.theme} theme, but html.dark is ${isDark}`);
+ }
+
+ await page.screenshot({ path: fromRoot(shot.file), type: 'jpeg', quality: 82 });
+ await page.close();
+ console.log(` wrote ${shot.file}`);
+ }
+} finally {
+ await browser?.close().catch(() => {});
+ await server.close().catch(() => {});
+}
diff --git a/scripts/generate-resume-pdf.ts b/scripts/generate-resume-pdf.ts
new file mode 100644
index 00000000..0daf394f
--- /dev/null
+++ b/scripts/generate-resume-pdf.ts
@@ -0,0 +1,128 @@
+/**
+ * Renders /resume/print in headless Chromium and writes public/zm-resume.pdf.
+ *
+ * bun run resume:pdf
+ *
+ * The output is a real vector PDF: selectable text, Letter size, parseable by the ATS software that
+ * ignores an image-only résumé. Re-run it whenever résumé data changes — `bun run test` compares a
+ * hash of that data against the one recorded here and fails if the PDF has gone stale.
+ *
+ * Renders against the DEV server, not a production preview, because /resume/print is registered
+ * only when import.meta.env.DEV. Layout is identical either way: the sheet sizes in inches and
+ * points, and the fonts come from the same stylesheet.
+ */
+///
+///
+// page.evaluate() callbacks run in the browser, so this file needs DOM types alongside Node's, and
+// dom.iterable for spreading `.children`. This widens the whole tsconfig.node.json program, not
+// just this file — TypeScript has no per-file lib scoping.
+import { writeFileSync } from 'node:fs';
+import { chromium } from 'playwright';
+import { createServer } from 'vite';
+import { HASH_FILE, PDF_FILE, sourceHash } from './resume-sources.ts';
+
+const server = await createServer({ server: { port: 4319, strictPort: false } });
+await server.listen();
+const base = server.resolvedUrls?.local[0];
+if (!base) throw new Error('vite dev server did not report a local URL');
+
+let browser: Awaited> | undefined;
+try {
+ browser = await chromium.launch();
+ const page = await browser.newPage({
+ colorScheme: 'light',
+ viewport: { width: 1100, height: 1500 },
+ });
+
+ await page.goto(new URL('resume/print', base).href, { waitUntil: 'networkidle' });
+ // Measure in print media, which is what page.pdf() lays out with.
+ await page.emulateMedia({ media: 'print', colorScheme: 'light' });
+ await page.evaluate(() => document.fonts.ready);
+
+ // Fonts first: fallback metrics would make every measurement below meaningless, and the run would
+ // still stamp a fresh hash. `fonts.check()` returns true when no faces are loaded at all, so the
+ // offline case needs the separate size check.
+ const fonts = await page.evaluate(() => ({
+ faceCount: document.fonts.size,
+ missing: [
+ '700 24pt "Space Grotesk"',
+ '600 11pt "Space Grotesk"',
+ '400 9.4pt "Inter"',
+ '400 8.2pt "JetBrains Mono"',
+ ].filter((face) => !document.fonts.check(face)),
+ }));
+ if (fonts.faceCount === 0 || fonts.missing.length > 0) {
+ throw new Error(
+ `web fonts unavailable (${fonts.missing.join(', ') || 'no @font-face rules loaded at all'}). ` +
+ 'The PDF would be laid out in fallback fonts. Check network access to fonts.googleapis.com.',
+ );
+ }
+
+ // The sheet clips overflow so nothing spills onto a second page, which means an overflow would
+ // silently lose content. Measure each column's real content extent — its last child's bottom
+ // edge, since the columns themselves stretch to full height — and fail instead.
+ const fit = await page.evaluate(() => {
+ const sheet = document.querySelector('.resume-sheet');
+ if (!sheet) return null;
+ return [...sheet.children].map((col) => {
+ const top = col.getBoundingClientRect().top;
+ const kids = [...col.children].filter((el) => !el.hasAttribute('aria-hidden'));
+ const last = kids[kids.length - 1];
+ const style = getComputedStyle(col);
+ return {
+ tag: col.tagName.toLowerCase(),
+ content: Math.round((last?.getBoundingClientRect().bottom ?? top) - top),
+ available: Math.round(col.getBoundingClientRect().height - parseFloat(style.paddingBottom)),
+ // Columns clip horizontally too, so a long unbreakable run would vanish silently.
+ overflowX: Math.max(0, col.scrollWidth - col.clientWidth),
+ };
+ });
+ });
+
+ if (!fit) throw new Error('no .resume-sheet found on /resume/print');
+
+ for (const col of fit) {
+ const slack = col.available - col.content;
+ const fill = Math.round((col.content / col.available) * 100);
+ console.log(
+ ` ${col.tag.padEnd(6)} ${col.content}px of ${col.available}px (${fill}% full,` +
+ ` ${slack >= 0 ? `${slack}px slack` : `${-slack}px OVER`})`,
+ );
+ }
+
+ const over = fit.filter((col) => col.content > col.available);
+ if (over.length > 0) {
+ throw new Error(
+ `résumé content overflows the page: ${over
+ .map((c) => `${c.tag} by ${c.content - c.available}px`)
+ .join(', ')}. Trim content or tighten the sheet, then regenerate.`,
+ );
+ }
+
+ const wide = fit.filter((col) => col.overflowX > 0);
+ if (wide.length > 0) {
+ throw new Error(
+ `résumé content is too wide and would be clipped: ${wide
+ .map((c) => `${c.tag} by ${c.overflowX}px`)
+ .join(', ')}. Usually an unbreakable run with no space to wrap at.`,
+ );
+ }
+
+ await page.evaluate(() => {
+ document.title = 'Zane Myers — Résumé';
+ });
+
+ await page.pdf({
+ path: PDF_FILE,
+ format: 'Letter',
+ printBackground: true,
+ margin: { top: '0', right: '0', bottom: '0', left: '0' },
+ });
+
+ writeFileSync(HASH_FILE, `${sourceHash()}\n`);
+ console.log(`\n wrote ${PDF_FILE}`);
+} finally {
+ // Swallow cleanup failures: a close() error must not replace the diagnostic above.
+ await browser?.close().catch(() => {});
+ await server.close().catch(() => {});
+}
diff --git a/scripts/resume-sources.ts b/scripts/resume-sources.ts
new file mode 100644
index 00000000..166fc436
--- /dev/null
+++ b/scripts/resume-sources.ts
@@ -0,0 +1,37 @@
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+/**
+ * The files the résumé PDF is derived from. Separate from the generator so the staleness test can
+ * import it without launching a browser. Typography counts: the sheet renders with the `--font-*`
+ * stacks from styles.css and the webfont request in index.html, so a change to either alters the
+ * PDF even though no résumé text moved.
+ */
+export const SOURCES = [
+ 'index.html',
+ 'src/styles/styles.css',
+ 'src/siteConfig.ts',
+ 'src/pages/Resume/resumeData.ts',
+ 'src/pages/Resume/jobEntries.ts',
+ 'src/pages/Resume/ResumePrint.tsx',
+];
+
+/** Repo root, so these paths hold wherever the script is invoked from. */
+const ROOT = join(import.meta.dir, '..');
+
+export const fromRoot = (path: string) => join(ROOT, path);
+
+/** In scripts/, not public/, so this build metadata isn't published with the site. */
+export const HASH_FILE = fromRoot('scripts/zm-resume.hash');
+export const PDF_FILE = fromRoot('public/zm-resume.pdf');
+
+export function sourceHash() {
+ const hash = createHash('sha256');
+ for (const file of SOURCES) {
+ // Hash the name too, so bytes moving between files don't hash the same.
+ hash.update(file);
+ hash.update(readFileSync(fromRoot(file)));
+ }
+ return hash.digest('hex').slice(0, 16);
+}
diff --git a/scripts/zm-resume.hash b/scripts/zm-resume.hash
new file mode 100644
index 00000000..c460be59
--- /dev/null
+++ b/scripts/zm-resume.hash
@@ -0,0 +1 @@
+d8946ce780783121
diff --git a/sitemap.xsd b/sitemap.xsd
index d0ff32c6..dc584b86 100644
--- a/sitemap.xsd
+++ b/sitemap.xsd
@@ -6,7 +6,7 @@
XML Schema for Sitemap files.
- Last Modifed 2008-03-26
+ Last Modified 2008-03-26
diff --git a/src/App.tsx b/src/App.tsx
index bdb4393b..fc802b23 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -7,6 +7,7 @@ import NotFound from './pages/NotFound';
import Privacy from './pages/Privacy';
import Projects from './pages/Projects/Projects';
import Resume from './pages/Resume/Resume';
+import ResumePrint from './pages/Resume/ResumePrint';
import Terms from './pages/Terms';
import Updates from './pages/Updates/Updates';
@@ -14,6 +15,11 @@ const App: React.FC = () => {
return (
+ {/* Dev only, and outside Layout: the print sheet carries no header, footer, or backdrop.
+ `import.meta.env.DEV` becomes a literal at build time, so this branch and the
+ ResumePrint module both drop out of the production bundle. */}
+ {import.meta.env.DEV && } />}
+
}>
} />
} />
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx
index 2dd89bb7..f4952a88 100644
--- a/src/components/ProjectCard.tsx
+++ b/src/components/ProjectCard.tsx
@@ -1,10 +1,12 @@
import { FiExternalLink, FiGithub } from 'react-icons/fi';
import Tag from './ui/Tag';
-interface Props {
+export interface Props {
title: string;
description: string;
image: string;
+ /** Optional dark-mode variant, swapped in CSS so both are preloaded and the toggle is instant. */
+ imageDark?: string;
technologies: string[];
githubUrl?: string;
liveUrl?: string;
@@ -15,6 +17,7 @@ export default function ProjectCard({
title,
description,
image,
+ imageDark,
technologies,
githubUrl,
liveUrl,
@@ -27,8 +30,18 @@ export default function ProjectCard({
src={image}
alt={`Screenshot of ${title}`}
loading="lazy"
- className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.06]"
+ className={`h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.06] ${
+ imageDark ? 'dark:hidden' : ''
+ }`}
/>
+ {imageDark && (
+
+ )}
{/* Scrim melts the screenshot into the card body instead of butting against it. */}
{featured.imageDark && (
!job.olderExperience);
-const olderJobs = jobs.filter((job) => job.olderExperience);
-
const contactShell =
'group flex items-center gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3';
@@ -178,10 +132,7 @@ export default function Resume() {
>
- Software developer with 2+ years of full-stack experience building web applications
- using Python, Django, Vue.js, React, and Node.js. Experienced in automating workflows,
- integrating Al tools, and maintaining CI/CD pipelines. Proven collaborator with a
- passion for clean, maintainable code, mentorship, and user-focused design.
+ {summary}
diff --git a/src/pages/Resume/ResumePrint.tsx b/src/pages/Resume/ResumePrint.tsx
new file mode 100644
index 00000000..691a66d7
--- /dev/null
+++ b/src/pages/Resume/ResumePrint.tsx
@@ -0,0 +1,263 @@
+import { Fragment } from 'react';
+import { FiGithub, FiGlobe, FiLinkedin, FiMail, FiMapPin, FiPhone } from 'react-icons/fi';
+import { site } from '../../siteConfig';
+import { volunteering } from './jobEntries';
+import { currentJobs, education, interests, skillGroups, summary } from './resumeData';
+
+/**
+ * The print sheet the PDF is generated from — see scripts/generate-resume-pdf.ts. Not linked from
+ * anywhere, and disallowed in robots.txt.
+ *
+ * Sized in inches and points rather than the site's rem scale, because the output is a physical
+ * Letter page and everything has to land inside one. Colors are literal rather than tokens so the
+ * sheet renders identically whichever theme the browser happens to be in. Layout follows the résumé
+ * this replaced, minus the older experience the site keeps behind a disclosure.
+ *
+ * The main column is deliberately kept well under full so bullets can be added to the current role
+ * later. `bun run resume:pdf` prints the fill for both columns and fails on overflow.
+ */
+
+const INK = '#0f1729';
+const MUTED = '#4a5568';
+const ACCENT = '#1659e9';
+const RULE = '#d8dee9';
+const SIDEBAR = '#0e1320';
+
+/** Strips the scheme and any `www.` so the printed handle stays short. */
+const bare = (url: string) => url.replace(/^https?:\/\/(www\.)?/, '');
+
+const contact = [
+ { icon: FiPhone, value: site.phone },
+ { icon: FiMail, value: site.email },
+ { icon: FiGlobe, value: site.website },
+ { icon: FiGithub, value: bare(site.github) },
+ { icon: FiLinkedin, value: bare(site.linkedin) },
+ { icon: FiMapPin, value: site.locationShort },
+];
+
+/** Sidebar section label: tracked caps over a hairline. */
+function SideHeading({ children }: { children: string }) {
+ return (
+
+ {/* Flat fill: a decorative gradient here was verified invisible — `aside` creates no stacking
+ context, so negatively-stacked children paint behind the sheet's own background. Add
+ `isolate` here first if reintroducing one. */}
+
+
+
+
+
+ Software Engineer
+
+ {/* Compact wrapped row — the stacked list this replaced was eating the sidebar. */}
+