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 && ( + {`Screenshot + )} {/* Scrim melts the screenshot into the card body instead of butting against it. */}