Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
27e25bb
fix: correct Ministry Intern location and compress dad.jpg
zanemyers Aug 13, 2026
b8bd845
feat(resume): add a print sheet rendered from the résumé data
zanemyers Aug 13, 2026
3ccc650
feat(resume): generate the PDF instead of hand-making it
zanemyers Aug 13, 2026
5a58155
test: add narrow checks for the things nothing else catches
zanemyers Aug 13, 2026
d76ef1e
chore: wire up the résumé and test tooling
zanemyers Aug 13, 2026
8593be5
fix(resume): correct "AI tools" and "(iOS)", regenerate the PDF
zanemyers Aug 14, 2026
348bbae
chore: drop the dead rollupOptions.onwarn handler
zanemyers Aug 14, 2026
547fbe3
style(resume): normalise print-sheet spacing to Tailwind's scale
zanemyers Aug 14, 2026
9e6b538
chore: lint and format CSS with Biome
zanemyers Aug 14, 2026
cbcc324
fix(tests): restore two technical terms a spell-check changed
zanemyers Aug 14, 2026
a98ed60
docs: fix "Modifed" typo in the vendored sitemap schema
zanemyers Aug 14, 2026
f1737be
test: validate the sitemap against its schema and the router
zanemyers Aug 14, 2026
c1b2174
fix: make the Apple touch icon a full-bleed opaque square
zanemyers Aug 14, 2026
3f11cf9
feat: bring the fading rim to the Apple touch icon
zanemyers Aug 14, 2026
0c95051
feat(ProjectCard): support a dark-mode screenshot
zanemyers Aug 14, 2026
5ffa9e2
feat: script the BioSite screenshots
zanemyers Aug 14, 2026
a0f07f8
feat(projects): refresh the BioSite card with light and dark screenshots
zanemyers Aug 14, 2026
334631b
fix(projects): don't hide the featured image when there is no dark va…
zanemyers Aug 14, 2026
dcc9f69
refactor: drop checks that measure nothing or duplicate another
zanemyers Aug 14, 2026
4286601
chore: drop inert config and correct CLAUDE.md
zanemyers Aug 14, 2026
22372c0
perf: compress uganda.jpg
zanemyers Aug 14, 2026
38e2aca
chore: silence two false-positive CSS inspections
zanemyers Aug 14, 2026
d91b032
docs: honour -> honor
zanemyers Aug 14, 2026
4ef0b78
docs: condense the comments this branch added
zanemyers Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ jobs:

- name: TypeScript
run: bun run typecheck


- name: Tests
run: bun run test
24 changes: 21 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 `<html>` 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.

Expand All @@ -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.
7 changes: 6 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"useIgnoreFile": true
},
"files": {
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.css"],
"ignoreUnknown": true
},
"formatter": {
Expand All @@ -31,5 +31,10 @@
"semicolons": "always",
"trailingCommas": "all"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
19 changes: 18 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
Binary file modified public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 3 additions & 12 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -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: /
Sitemap: https://zm1.org/sitemap.xml
Binary file modified public/zm-resume.pdf
Binary file not shown.
64 changes: 64 additions & 0 deletions scripts/capture-screenshots.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
/// <reference lib="dom" />
// 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<ReturnType<typeof chromium.launch>> | 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(() => {});
}
Loading
Loading