Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .agent-state/decisions.ndjson
Original file line number Diff line number Diff line change
@@ -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":[]}
Binary file modified public/Jon_Bogaty_Resume.docx
Binary file not shown.
13 changes: 11 additions & 2 deletions scripts/resume/build-docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
Expand All @@ -26,8 +30,13 @@ export async function buildResumeDocx(outPath: string): Promise<void> {
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)`)
}
Expand Down
41 changes: 41 additions & 0 deletions scripts/resume/postprocess.ts
Original file line number Diff line number Diff line change
@@ -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>.*?<\/w:tblCellMar>/gs

const ZEROED_CELL_MARGIN =
'<w:tblCellMar>' +
'<w:top w:type="dxa" w:w="0"/>' +
'<w:bottom w:type="dxa" w:w="0"/>' +
'<w:left w:type="dxa" w:w="0"/>' +
'<w:right w:type="dxa" w:w="0"/>' +
'</w:tblCellMar>'

/** The empty paragraph turbodocx emits after each `</w:tbl>` — matched
* whitespace-tolerantly because document.xml is pretty-printed. */
const EMPTY_PARAGRAPH_AFTER_TABLE =
/(<\/w:tbl>)\s*<w:p>\s*<w:pPr>\s*<w:spacing w:lineRule="auto"\/>\s*<\/w:pPr>\s*<w:r>\s*<w:rPr\/>\s*<\/w:r>\s*<\/w:p>(?=\s*<w:(?:p|tbl)[\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 <w:sectPr>.
out = out.replace(EMPTY_PARAGRAPH_AFTER_TABLE, '$1')

return out
}
122 changes: 90 additions & 32 deletions scripts/resume/qc.ts
Original file line number Diff line number Diff line change
@@ -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/<engine>-<page>.png
*
* Requires: LibreOffice (brew install --cask libreoffice)
* poppler (brew install poppler)
Expand All @@ -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 = [
Expand All @@ -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.')
27 changes: 5 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -27,26 +26,10 @@ export default function App() {
/>

<main className="flex-1">
<section id="work" className="mx-auto max-w-5xl px-4 sm:px-6 py-14 scroll-mt-14">
<h2 className="font-heading text-3xl text-foreground mb-8">Work</h2>
<JobList jobs={resume.work} earlierCareer={resume.earlierCareer} />
</section>

<section
id="open-source"
className="border-t border-border/60 mx-auto max-w-5xl px-4 sm:px-6 py-14 scroll-mt-14"
>
<section id="open-source" className="mx-auto max-w-5xl px-4 sm:px-6 py-14 scroll-mt-14">
<h2 className="font-heading text-3xl text-foreground mb-8">Open Source</h2>
<OpenSource items={resume.projects} lead={resume.about.summary[1]} />
</section>

<section
id="skills"
className="border-t border-border/60 mx-auto max-w-5xl px-4 sm:px-6 py-14 scroll-mt-14"
>
<h2 className="font-heading text-3xl text-foreground mb-8">Skills</h2>
<SkillSheet categories={resume.skills} />
</section>
</main>

<SiteFooter />
Expand Down
36 changes: 36 additions & 0 deletions src/components/BrandIcons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
role="img"
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}

export function LinkedinIcon({ className }: IconProps) {
return (
<svg
role="img"
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z" />
</svg>
)
}
7 changes: 4 additions & 3 deletions src/components/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -81,7 +82,7 @@ export function HeroSection({ name, label, heroLine, status, stats }: HeroProps)
asChild
>
<a href="https://github.com/jbcom" target="_blank" rel="noopener noreferrer">
<Github className="size-4" />
<GithubIcon className="size-4" />
</a>
</Button>
<Button
Expand All @@ -96,7 +97,7 @@ export function HeroSection({ name, label, heroLine, status, stats }: HeroProps)
target="_blank"
rel="noopener noreferrer"
>
<Linkedin className="size-4" />
<LinkedinIcon className="size-4" />
</a>
</Button>
<Button
Expand Down
2 changes: 0 additions & 2 deletions src/components/SiteNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

const ANCHORS = [
{ id: 'work', label: 'Work' },
{ id: 'open-source', label: 'Open Source' },
{ id: 'skills', label: 'Skills' },
{ id: 'contact', label: 'Contact' },
] as const

Expand Down
Loading
Loading