diff --git a/.agent-state/decisions.ndjson b/.agent-state/decisions.ndjson index 2ebe6836..233b4a97 100644 --- a/.agent-state/decisions.ndjson +++ b/.agent-state/decisions.ndjson @@ -1,2 +1,5 @@ {"ts":"2026-06-09T23:13:09.916Z","sha":"2c9a43adaccebc7bda8227d07a70e0df990c4356","subject":"feat: DOCX-first resume pipeline with visual QC and recruiter-driven restructure","decision":"DOCX compiled from typed TS data via turbodocx, not pandoc or raw docx lib","why":"pandoc drops CSS; docx lib broke Apple Pages previously (fdcd220); turbodocx was the validated path and QC now guards it","resolves":["user directive to make DOCX the sole","properly-styled distributable"],"overrides":[]} {"ts":"2026-06-09T23:31:36.852Z","sha":"c0b9051bfacb93637f99b9d9744051e0552ca15a","subject":"feat: DOCX-first resume pipeline with visual QC and recruiter-driven restructure (#152)","decision":"DOCX compiled from typed TS data via turbodocx, not pandoc or raw docx lib","why":"pandoc drops CSS; docx lib broke Apple Pages previously (fdcd220); turbodocx was the validated path and QC now guards it","resolves":["user directive to make DOCX the sole","properly-styled distributable"],"overrides":[],"source":"gh-pr-merge"} +{"ts":"2026-06-09T23:54:29.544Z","sha":"583aec472f6ec4490e7817385fdccc22ad2f28ed","subject":"chore(main): release 1.5.0 (#153)","decision":null,"why":null,"resolves":[],"overrides":[],"source":"gh-pr-merge"} +{"ts":"2026-06-10T00:02:18.730Z","sha":"b33bdb5356f65b71675237c26e54db9bdb6aa10d","subject":"feat: portfolio-first site — drop Work and Skills sections, résumé carries career history","decision":"site shows only what the résumé cannot; career history starts at Symbiont","why":"user directive (redundancy + pre-2017 one-year roles read better as prose); research-validated","resolves":["'do we NEED a work section' / 'Symbiont should be the start of actual roles'"],"overrides":[]} +{"ts":"2026-06-10T00:14:49.235Z","sha":"295747d07646309af137485c39541eb0bee0d1a7","subject":"fix: OOXML postprocess for turbodocx layout defects + dual-engine DOCX QC","decision":"fix converter defects by post-processing OOXML in our build, not by switching converters","why":"turbodocx hardcodes the values (no options); it remains the only converter validated in both Word-proxy and Pages engines","resolves":["'docx showing weird spacing gaps and alignment issues' + 'need a way to capture the docx as it actually shows up'"],"overrides":[]} diff --git a/public/Jon_Bogaty_Resume.docx b/public/Jon_Bogaty_Resume.docx index 2dca6cee..30529d1e 100644 Binary files a/public/Jon_Bogaty_Resume.docx and b/public/Jon_Bogaty_Resume.docx differ diff --git a/scripts/resume/build-docx.ts b/scripts/resume/build-docx.ts index a81217e4..5d202070 100644 --- a/scripts/resume/build-docx.ts +++ b/scripts/resume/build-docx.ts @@ -3,7 +3,9 @@ * * The DOCX is the canonical distributable resume. No PDF is produced. * Fast local loop: no Astro build required — template.ts renders straight - * from the resume data module. + * from the resume data module. The turbodocx output is post-processed at + * the OOXML level (see postprocess.ts) to fix layout defects the converter + * hardcodes. * * Usage: pnpm resume:build (or: npx tsx scripts/resume/build-docx.ts [outPath]) * Output: public/Jon_Bogaty_Resume.docx @@ -12,7 +14,9 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import HTMLtoDOCX from '@turbodocx/html-to-docx' +import JSZip from 'jszip' +import { postprocessDocumentXml } from './postprocess.ts' import { resumeDocxHtml } from './template.ts' export async function buildResumeDocx(outPath: string): Promise { @@ -26,8 +30,13 @@ export async function buildResumeDocx(outPath: string): Promise { pageSize: { width: 12240, height: 15840 }, // US Letter in twips }) + const zip = await JSZip.loadAsync(Buffer.from(buffer as ArrayBuffer)) + const documentXml = await zip.file('word/document.xml')?.async('string') + if (!documentXml) throw new Error('turbodocx output is missing word/document.xml') + zip.file('word/document.xml', postprocessDocumentXml(documentXml)) + const output = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + mkdirSync(dirname(outPath), { recursive: true }) - const output = Buffer.from(buffer as ArrayBuffer) writeFileSync(outPath, output) console.log(`DOCX generated: ${outPath} (${(output.length / 1024).toFixed(1)} KB)`) } diff --git a/scripts/resume/postprocess.ts b/scripts/resume/postprocess.ts new file mode 100644 index 00000000..388f218b --- /dev/null +++ b/scripts/resume/postprocess.ts @@ -0,0 +1,41 @@ +/** + * OOXML post-processing for the turbodocx output. + * + * @turbodocx/html-to-docx hardcodes two layout defects we cannot configure + * away (verified against v1.21.0 source): + * 1. Every table gets `tblCellMar` of 160 twips left/right + 80 top/bottom — + * our layout tables (section-heading rules, title/date rows) inherit a + * visible indent against body text and inflated vertical padding. + * 2. An empty paragraph is emitted after every table — a dead line between + * each heading rule and its section body. + * + * These transforms run on word/document.xml before the buffer is written. + * They are intentionally narrow string transforms, locked down by the + * structural tests in tests/unit/resume-docx.test.ts. + */ + +const CELL_MARGIN_PATTERN = /.*?<\/w:tblCellMar>/gs + +const ZEROED_CELL_MARGIN = + '' + + '' + + '' + + '' + + '' + + '' + +/** The empty paragraph turbodocx emits after each `` — matched + * whitespace-tolerantly because document.xml is pretty-printed. */ +const EMPTY_PARAGRAPH_AFTER_TABLE = + /(<\/w:tbl>)\s*\s*\s*\s*<\/w:pPr>\s*\s*\s*<\/w:r>\s*<\/w:p>(?=\s*])/g + +export function postprocessDocumentXml(xml: string): string { + let out = xml.replace(CELL_MARGIN_PATTERN, ZEROED_CELL_MARGIN) + + // Drop the dead line after tables — but only when real content follows. + // OOXML requires a trailing paragraph when a table ends the body, so the + // lookahead excludes the one before . + out = out.replace(EMPTY_PARAGRAPH_AFTER_TABLE, '$1') + + return out +} diff --git a/scripts/resume/qc.ts b/scripts/resume/qc.ts index 77cdcdf6..edadc83c 100644 --- a/scripts/resume/qc.ts +++ b/scripts/resume/qc.ts @@ -1,14 +1,18 @@ /** * Visual quality control for the compiled DOCX resume. * - * Renders the actual .docx through LibreOffice (the closest local proxy for - * how Word lays it out) and emits one PNG per page so a human — or an agent - * with vision — can READ the real artifact instead of trusting the HTML - * source. This is the gate that was missing when unstyled pandoc output - * shipped unnoticed. + * A DOCX has no single true appearance — layout differs by engine — so QC + * renders through BOTH engines available locally and emits one PNG per page + * from each, at high DPI, so a human (or an agent with vision) reads the + * real artifact: + * - LibreOffice (headless): the closest local proxy for Microsoft Word, + * and the engine available in CI. + * - Apple Pages (via AppleScript, macOS only, best-effort): a real + * renderer recruiters use, and historically the one that exposed DOCX + * layout bugs LibreOffice hid. * - * Pipeline: build DOCX → soffice --headless → PDF (QC intermediate only, - * never shipped) → pdftoppm → artifacts/resume-qc/page-N.png + * Pipeline: build DOCX → engine → PDF (QC intermediate only, never + * shipped) → pdftoppm → artifacts/resume-qc/-.png * * Requires: LibreOffice (brew install --cask libreoffice) * poppler (brew install poppler) @@ -24,6 +28,7 @@ import { buildResumeDocx } from './build-docx.ts' const root = resolve(import.meta.dirname!, '../..') const artifactsDir = resolve(root, 'artifacts/resume-qc') const docxPath = resolve(root, 'public/Jon_Bogaty_Resume.docx') +const DPI = '200' function findSoffice(): string { const candidates = [ @@ -42,39 +47,92 @@ function findSoffice(): string { } } +function rasterize(pdfPath: string, prefix: string): void { + try { + execFileSync('pdftoppm', ['-png', '-r', DPI, pdfPath, resolve(artifactsDir, prefix)], { + stdio: 'pipe', + timeout: 60_000, + }) + } catch { + console.error('pdftoppm failed or is not installed. Install poppler with: brew install poppler') + process.exit(1) + } + rmSync(pdfPath) // QC intermediate only — no PDF artifact survives +} + +function renderLibreOffice(): void { + console.log('Rendering via LibreOffice (Word proxy)...') + execFileSync( + findSoffice(), + ['--headless', '--convert-to', 'pdf', '--outdir', artifactsDir, docxPath], + { stdio: 'pipe', timeout: 120_000 } + ) + const pdf = resolve(artifactsDir, 'Jon_Bogaty_Resume.pdf') + if (!existsSync(pdf)) { + console.error('LibreOffice did not produce a PDF — check the DOCX for corruption.') + process.exit(1) + } + rasterize(pdf, 'libreoffice') +} + +/** Best-effort: drives the real Pages app, which renders DOCX with its own + * engine — the same one a recruiter on a Mac sees. */ +function renderPages(): void { + if (process.platform !== 'darwin' || !existsSync('/Applications/Pages.app')) { + console.log('Apple Pages not available — skipping Pages render.') + return + } + console.log('Rendering via Apple Pages...') + const pdf = resolve(artifactsDir, 'pages-render.pdf') + try { + execFileSync('osascript', ['-e', `tell application "Pages" to open POSIX file "${docxPath}"`], { + stdio: 'pipe', + timeout: 30_000, + }) + // Pages imports DOCX asynchronously; poll until the document exists. + for (let attempt = 0; attempt < 20; attempt++) { + const count = execFileSync( + 'osascript', + ['-e', 'tell application "Pages" to count documents'], + { + encoding: 'utf-8', + timeout: 10_000, + } + ).trim() + if (count !== '0') break + execFileSync('sleep', ['1']) + } + execFileSync( + 'osascript', + [ + '-e', + `tell application "Pages" + export document 1 to POSIX file "${pdf}" as PDF + close document 1 saving no + end tell`, + ], + { stdio: 'pipe', timeout: 60_000 } + ) + rasterize(pdf, 'pages') + } catch (err) { + console.warn( + `Pages render failed (${(err as Error).message?.split('\n')[0]}) — continuing with LibreOffice only.` + ) + } +} + await buildResumeDocx(docxPath) rmSync(artifactsDir, { recursive: true, force: true }) mkdirSync(artifactsDir, { recursive: true }) -console.log('Rendering DOCX via LibreOffice...') -execFileSync( - findSoffice(), - ['--headless', '--convert-to', 'pdf', '--outdir', artifactsDir, docxPath], - { stdio: 'pipe', timeout: 120_000 } -) - -const qcPdf = resolve(artifactsDir, 'Jon_Bogaty_Resume.pdf') -if (!existsSync(qcPdf)) { - console.error('LibreOffice did not produce a PDF — check the DOCX for corruption.') - process.exit(1) -} - -console.log('Rasterizing pages...') -try { - execFileSync('pdftoppm', ['-png', '-r', '120', qcPdf, resolve(artifactsDir, 'page')], { - stdio: 'pipe', - timeout: 60_000, - }) -} catch { - console.error('pdftoppm failed or is not installed. Install poppler with: brew install poppler') - process.exit(1) -} -rmSync(qcPdf) // QC intermediate only — no PDF artifact survives +renderLibreOffice() +renderPages() const pages = readdirSync(artifactsDir) .filter((f) => f.endsWith('.png')) .sort() console.log(`\n${pages.length} page(s) rendered:`) for (const page of pages) console.log(` ${resolve(artifactsDir, page)}`) -console.log('\nReview each page before shipping. Spec drift is a code bug.') +console.log('\nREAD every page from BOTH engines before shipping — at full size,') +console.log('not thumbnails. Spec drift is a code bug.') diff --git a/src/App.tsx b/src/App.tsx index 80b05a39..bf3b7000 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,14 @@ import { HeroSection } from '@/components/HeroSection' import { SiteFooter } from '@/components/SiteFooter' import { SiteNav } from '@/components/SiteNav' -import { JobList } from '@/components/sections/JobList' import { OpenSource } from '@/components/sections/OpenSource' -import { SkillSheet } from '@/components/sections/SkillSheet' import resume from '@/content/resume' /** - * One opinionated scroll, not tabs: who + proof + action above the fold, - * the Work receipts at depth 1, the open-source package detail at depth 2. - * Each section gets its own layout — no shared shell. + * A lobby, not a brochure: identity + proof + action in the hero, then the + * one thing a résumé can't show — the live open-source portfolio. Career + * history and the skills matrix live in the résumé (one click away, HTML + * and DOCX); duplicating them here was noise. */ export default function App() { return ( @@ -27,26 +26,10 @@ export default function App() { />
-
-

Work

- -
- -
+

Open Source

- -
-

Skills

- -
diff --git a/src/components/BrandIcons.tsx b/src/components/BrandIcons.tsx new file mode 100644 index 00000000..cf75a4ab --- /dev/null +++ b/src/components/BrandIcons.tsx @@ -0,0 +1,36 @@ +/** + * Brand icons inlined as SVGs — lucide-react removed its deprecated brand + * icons in v1; these paths are from simple-icons (CC0). + */ + +interface IconProps { + className?: string +} + +export function GithubIcon({ className }: IconProps) { + return ( + + ) +} + +export function LinkedinIcon({ className }: IconProps) { + return ( + + ) +} diff --git a/src/components/HeroSection.tsx b/src/components/HeroSection.tsx index 400ea701..61f9b75b 100644 --- a/src/components/HeroSection.tsx +++ b/src/components/HeroSection.tsx @@ -1,5 +1,6 @@ -import { Download, FileText, Github, Linkedin, MessageCircle } from 'lucide-react' +import { Download, FileText, MessageCircle } from 'lucide-react' import { motion } from 'motion/react' +import { GithubIcon, LinkedinIcon } from '@/components/BrandIcons' import { Button } from '@/components/ui/button' interface Stat { @@ -81,7 +82,7 @@ export function HeroSection({ name, label, heroLine, status, stats }: HeroProps) asChild > - + - ))} - - -
-

{active.name}

-

- {active.position} · {formatDateRange(active.startDate, active.endDate)} -

- - {(cloud.length > 0 || rest.length > 0) && ( -
- {cloud.map((t) => ( - - {t} - - ))} - {rest.length > 0 && ( - - {rest.join(', ').toLowerCase()} - - )} -
- )} - - {active.summary && ( -

{active.summary}

- )} - - {active.highlights.length > 0 && ( -
    - {active.highlights.map((h, i) => ( -
  • - {i < 2 ? '▸' : '•'} - {h} -
  • - ))} -
- )} -
- - -
-

- Before 2017 -

-

{earlierCareer.summary}

-
- - ) -} diff --git a/src/components/sections/SkillSheet.tsx b/src/components/sections/SkillSheet.tsx deleted file mode 100644 index 6bef7c36..00000000 --- a/src/components/sections/SkillSheet.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { SkillCategory } from '@/content/resume' - -const LEAD = - 'Day to day: AWS and GCP, Terraform and Terragrunt, Kubernetes across EKS/GKE/AKS, Python and Go, and the CI/CD and secrets plumbing that ties them together.' - -/** Spec-sheet rows, not a chip cloud — every skill in plain prose. */ -export function SkillSheet({ categories }: { categories: SkillCategory[] }) { - return ( -
-

{LEAD}

-
- {categories.map((cat) => ( -
-
- {cat.name} -
-
- {cat.keywords.join(', ')} -
-
- ))} -
-
- ) -} diff --git a/src/content/resume.ts b/src/content/resume.ts index d17b53db..f1518798 100644 --- a/src/content/resume.ts +++ b/src/content/resume.ts @@ -194,23 +194,10 @@ const resume: Resume = { 'Built Terraform-based repeatable customer deployment workflows for secure installs of DLT infrastructure into enterprise cloud environments on all three major providers', ], }, - { - // Site-only: on the DOCX this is covered by the Earlier Career - // paragraph (its cost win and early-IaC claim are quoted there). - name: 'ClassPass', - position: 'Senior Systems Operations Engineer', - startDate: '2015-04', - endDate: '2016-04', - onResume: false, - tech: ['AWS', 'Docker', 'Terraform', 'Packer', 'Vagrant'], - summary: null, - highlights: [ - 'Containerized services with Docker and adopted Terraform, Packer, Atlas, and Vagrant in 2015 — infrastructure as code before it was standard practice', - 'Managed 200–300 production AWS instances powering the ClassPass desktop and mobile experience for a major international fitness subscription platform', - 'Reduced cloud costs $20K/month by deploying Netflix OSS Janitor Monkey for automated resource lifecycle management', - ], - }, ], + // ClassPass (2015–16) is intentionally NOT a work entry: everything before + // Symbiont (2017) lives in the Earlier Career paragraph below, which + // already carries its cost win, fleet scale, and early-IaC claim. // Everything before Symbiont is compressed into one prose paragraph: // a decade of short roles reads as a consulting-style arc in prose, but as diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 6543efe2..ec662f07 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -19,9 +19,7 @@ test.describe('Site navigation', () => { test('anchor nav scrolls to sections', async ({ page }) => { await page.goto('/') for (const [label, id] of [ - ['Work', 'work'], ['Open Source', 'open-source'], - ['Skills', 'skills'], ['Contact', 'contact'], ] as const) { const anchor = page.locator(`header a[href="#${id}"]`, { hasText: label }) @@ -31,10 +29,10 @@ test.describe('Site navigation', () => { } }) - test('work section shows master-detail with Flipside', async ({ page }) => { + test('no Work or Skills sections — the résumé carries career history', async ({ page }) => { await page.goto('/') - await page.getByRole('button', { name: /Flipside Crypto/ }).click() - await expect(page.getByText(/Cut AWS spend from ~\$150K/)).toBeVisible() + await expect(page.locator('#work')).toHaveCount(0) + await expect(page.locator('#skills')).toHaveCount(0) }) test('open source tri-panel shows the three flagships with package tables', async ({ page }) => { @@ -46,13 +44,6 @@ test.describe('Site navigation', () => { await expect(oss.getByText('paranoid-passwd-gui')).toBeVisible() }) - test('no badge chip-walls in skills (spec-sheet rows instead)', async ({ page }) => { - await page.goto('/') - const skills = page.locator('#skills') - await expect(skills.getByText('Platform & Reliability')).toBeVisible() - await expect(skills.locator('[data-slot="badge"]')).toHaveCount(0) - }) - test('GitHub link opens in new tab', async ({ page }) => { await page.goto('/') const githubLink = page.getByRole('link', { name: 'GitHub' }).first() diff --git a/tests/e2e/resume.spec.ts b/tests/e2e/resume.spec.ts index 586dc886..5f37f840 100644 --- a/tests/e2e/resume.spec.ts +++ b/tests/e2e/resume.spec.ts @@ -5,21 +5,12 @@ test.describe('Resume content', () => { await page.goto('/') }) - test('Flipside Crypto appears in the Work section', async ({ page }) => { - await expect(page.getByText('Flipside Crypto').first()).toBeVisible() - }) - - test('hero proof line is visible', async ({ page }) => { + test('hero proof line names Flipside', async ({ page }) => { await expect( page.getByText(/sole infrastructure engineer at Flipside Crypto/).first() ).toBeVisible() }) - test('skills section has multiple categories', async ({ page }) => { - await expect(page.getByText('Cloud Platforms').first()).toBeVisible() - await expect(page.getByText('Infrastructure as Code').first()).toBeVisible() - }) - test('education appears in the footer', async ({ page }) => { await expect(page.getByText(/Ivy Tech Community College/).first()).toBeVisible() }) @@ -45,15 +36,13 @@ test.describe('Resume page (print-optimized)', () => { await expect(page.getByText('Education')).toBeVisible() }) - test('resume page has correct job entries', async ({ page }) => { + test('resume page has correct job entries, starting at Symbiont', async ({ page }) => { await page.goto('/resume') await expect(page.getByText('Flipside Crypto').first()).toBeVisible() await expect(page.getByText('GoHealth').first()).toBeVisible() await expect(page.getByText('Symbiont').first()).toBeVisible() - }) - - test('site-only roles stay off the resume page', async ({ page }) => { - await page.goto('/resume') + // Pre-2017 roles live in the Earlier Career paragraph, never as entries await expect(page.getByText('Senior Systems Operations Engineer')).toHaveCount(0) + await expect(page.getByText('Cloud Platforms').first()).toBeVisible() // skills matrix lives here }) }) diff --git a/tests/unit/resume-docx.test.ts b/tests/unit/resume-docx.test.ts index e946a980..57e05f0d 100644 --- a/tests/unit/resume-docx.test.ts +++ b/tests/unit/resume-docx.test.ts @@ -84,6 +84,16 @@ describe('compiled DOCX structure', () => { expect(documentXml).toContain('Calibri') }) + it('has no turbodocx layout defects (postprocess invariants)', () => { + // Hardcoded 160-twip cell margins indent layout tables vs body text + expect(documentXml).not.toContain('w:w="160"') + // The empty paragraph turbodocx emits after tables is a dead line + // between each section-heading rule and its body + expect(documentXml).not.toMatch( + /<\/w:tbl>\s*\s*\s*\s*<\/w:pPr>\s*\s*\s*<\/w:r>\s*<\/w:p>\s*]/ + ) + }) + it('has no template artifacts', () => { for (const artifact of ['undefined', '[object Object]', '[object', 'NaN']) { expect(documentText, `template artifact "${artifact}" leaked`).not.toContain(artifact)