Resume from Greg's interview, one-page PDF, store slots closed

The experience notes, skills, education and a new /resume Page text now come
from Greg's Resume Interview answers (tools/migrate/src/seed.ts). Comcast and
Drexel move to a short Earlier list, and the page and PDF gain Selected
projects. Carrier Connect's numbers are Greg's corrected ones.

The PDF fits on one page and carries the email from site settings. Store
lines migrate with no open slots until Greg opens orders.

Schema: experience earlier, page projects, site-settings email.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 21:58:21 +00:00
parent 99ecdc2113
commit 963ad0df23
9 changed files with 396 additions and 116 deletions
+143 -96
View File
@@ -1,6 +1,7 @@
/**
* The printable resume. Built from the same ResumeData as the /resume page,
* laid out as one plain column so applicant tracking systems read it in order.
* laid out top to bottom (skills share a row with education and projects) so
* applicant tracking systems read it in order.
* Paper colors: ink on white, with the brand gold and teal darkened for print.
*/
import PDFDocument from "pdfkit";
@@ -9,7 +10,7 @@ import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline";
import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline";
import { month, period, type ResumeData } from "./resume.ts";
import { listOf, month, period, type ResumeData } from "./resume.ts";
const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
const FONTS = {
@@ -26,8 +27,15 @@ const GOLD = "#a8842f";
const TEAL = "#0f7f78";
const RULE = "#d9cfb8";
const PAGE = { size: "LETTER" as const, margin: 50 };
const PAGE = { size: "LETTER" as const, margin: 36 };
const SITE = "gregkilmer.com";
const TEXT = 9;
/** A column of the page: where text starts and how wide it runs. */
interface Col {
x: number;
w: number;
}
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const doc = new PDFDocument({
@@ -40,8 +48,7 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
});
for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
const left = doc.page.margins.left;
const width = doc.page.width - left - doc.page.margins.right;
const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
@@ -60,137 +67,177 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
/** A thin gold rule with a small star at its center. */
const starRule = (y: number) => {
const mid = left + width / 2;
const mid = full.x + full.w / 2;
doc.save().lineWidth(0.6).strokeColor(GOLD);
doc.moveTo(left, y).lineTo(mid - 10, y).stroke();
doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke();
doc.moveTo(full.x, y).lineTo(mid - 10, y).stroke();
doc.moveTo(mid + 10, y).lineTo(full.x + full.w, y).stroke();
doc.restore();
diamond(mid, y, 4);
};
const section = (title: string) => {
room(60);
doc.moveDown(0.75);
const section = (title: string, c: Col = full) => {
room(50);
doc.moveDown(0.5);
const y = doc.y;
doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 });
const ruleY = doc.y + 2;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore();
doc.y = ruleY + 8;
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
const ruleY = doc.y + 1;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(c.x, ruleY).lineTo(c.x + c.w, ruleY).stroke().restore();
doc.y = ruleY + 6;
};
const bullet = (text: string, indent = 12) => {
doc.font("body").fontSize(9.5);
const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 });
room(h + 4);
const bullet = (text: string, c: Col = full, lead?: string) => {
const indent = 11;
const w = c.w - indent;
doc.font("body").fontSize(TEXT);
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1.2 }) + 3);
const y = doc.y;
diamond(left + 3.5, y + 6, 2.2);
doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 });
doc.moveDown(0.15);
diamond(c.x + 3.5, y + 5.5, 2.1);
if (lead) {
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1.2 });
doc.font("body").text(` ${text}`, { lineGap: 1.2 });
} else {
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1.2 });
}
doc.moveDown(0.12);
};
/** A title on the left with its dates set flush right on the same line. */
const titled = (title: string, when: string, size: number, c: Col = full) => {
room(30);
const y = doc.y;
doc.font("medium").fontSize(8.5).fillColor(GOLD);
const whenW = when ? doc.widthOfString(when) : 0;
if (when) doc.text(when, c.x + c.w - whenW, y + (size - 8.5) / 2 + 0.5, { lineBreak: false });
doc.font("strong").fontSize(size).fillColor(INK).text(title, c.x, y, { width: c.w - whenW - 12 });
};
// Header
doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 });
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width });
doc.font("title").fontSize(24).fillColor(INK).text(r.name.toUpperCase(), full.x, doc.y, { characterSpacing: 2 });
if (r.availability) {
const y = doc.y;
doc.font("medium").fontSize(8.5).fillColor(INK);
const w = doc.widthOfString(r.availability);
doc.text(r.availability, full.x + full.w - w, y + 2.5, { lineBreak: false });
doc.x = full.x;
doc.y = y;
}
if (r.headline) doc.font("medium").fontSize(10.5).fillColor(TEAL).text(r.headline, full.x, doc.y, { width: full.w - 160 });
// Only the professional profiles belong on paper; the hobby links stay on the site.
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
const contact = [r.location, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · ");
doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width });
if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width });
const contact = [r.location, r.email, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))]
.filter(Boolean)
.join(" · ");
doc.moveDown(0.2).font("body").fontSize(8).fillColor(MUTED).text(contact, { width: full.w });
doc.moveDown(0.7);
starRule(doc.y);
doc.moveDown(0.6);
doc.moveDown(0.5);
if (r.summary) {
doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 });
doc.font("body").fontSize(9.5).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.6 });
}
if (r.stats.length) {
section("Highlights");
doc.moveDown(0.7);
const cols = Math.min(r.stats.length, 4);
const colW = width / cols;
let top = doc.y;
const colW = full.w / cols;
const top = doc.y;
let bottom = top;
r.stats.forEach((st, i) => {
if (i > 0 && i % cols === 0) top = doc.y + 6;
const x = left + (i % cols) * colW;
doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 });
doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 });
if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
const x = full.x + (i % cols) * colW;
const y = top + Math.floor(i / cols) * 44;
doc.font("title").fontSize(15).fillColor(GOLD).text(st.value, x, y, { width: colW - 10, lineBreak: false });
doc.font("strong").fontSize(8.5).fillColor(INK).text(st.label, x, y + 18, { width: colW - 10 });
if (st.detail) doc.font("body").fontSize(8).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
bottom = Math.max(bottom, doc.y);
});
doc.x = left;
doc.x = full.x;
doc.y = bottom;
}
if (r.roles.length) {
section("Experience");
r.roles.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.6);
room(64);
const y = doc.y;
const when = period(role);
doc.font("medium").fontSize(9).fillColor(GOLD);
const whenW = doc.widthOfString(when);
doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false });
doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 });
if (i > 0) doc.moveDown(0.5);
titled(d.role, period(role), 10.5);
const at = [d.company, d.location].filter(Boolean).join(" · ");
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width });
if (d.previousRoles?.length) {
doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width });
}
if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 });
doc.moveDown(0.3);
doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
if (d.focus) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
doc.moveDown(0.25);
for (const b of d.bullets ?? []) bullet(b);
if (d.tech?.length) {
doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 });
}
doc.x = left;
doc.x = full.x;
});
}
if (r.skills?.stack.length) {
section("Skills");
for (const g of r.skills.stack) {
const items = g.items.join(", ");
doc.font("body").fontSize(9.5);
room(doc.heightOfString(items, { width: width - 110 }) + 4);
if (r.earlier.length) {
section("Earlier");
r.earlier.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.3);
titled(d.role, period(role), 9.5);
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w });
if (d.focus) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
});
}
// Skills on the left; education, certifications and projects on the right.
const gap = 22;
const leftCol: Col = { x: full.x, w: full.w * 0.55 };
const rightCol: Col = { x: full.x + leftCol.w + gap, w: full.w - leftCol.w - gap };
const labelW = 64;
const skillRows = (r.skills?.stack ?? []).map((g) => ({ label: g.label, items: g.items.join(", ") }));
// Measure both columns first: the block moves to a new page whole rather than splitting.
doc.fontSize(8.5);
const leftH = skillRows.reduce((h, g) => {
doc.font("strong");
const lh = doc.heightOfString(g.label, { width: labelW });
doc.font("body");
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.8 })) + 2;
}, 30);
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
doc.font("body");
const rightH =
(entries ? 26 + entries * 28 : 0) +
(r.projects.length ? 26 : 0) +
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1.2 }) + 3, 0);
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
const start = doc.y;
let leftEnd = start;
if (skillRows.length) {
section("Skills", leftCol);
for (const { label, items } of skillRows) {
const y = doc.y;
doc.font("strong").fontSize(9.5).fillColor(INK).text(g.label, left, y, { width: 100 });
doc.font("strong").fontSize(8.5).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
const afterLabel = doc.y;
doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 });
doc.y = Math.max(doc.y, afterLabel) + 3;
doc.font("body").fontSize(8.5).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.8 });
doc.y = Math.max(doc.y, afterLabel) + 2;
}
if (r.skills.core?.length) {
room(30);
const y = doc.y;
doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 });
doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 });
}
doc.x = left;
leftEnd = doc.y;
}
if (r.education?.degrees.length) {
section("Education");
for (const dg of r.education.degrees) {
room(40);
const y = doc.y;
doc.font("medium").fontSize(9).fillColor(GOLD);
const w = doc.widthOfString(dg.period);
doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false });
doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 });
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width });
if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width });
doc.moveDown(0.4);
doc.y = start;
const edu = r.education;
if (edu?.degrees.length || edu?.certifications?.length) {
section("Education", rightCol);
for (const dg of edu?.degrees ?? []) {
titled(dg.credential, dg.period, 9.5, rightCol);
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
doc.moveDown(0.3);
}
for (const c of edu?.certifications ?? []) {
titled(c.name, c.year ?? "", 9.5, rightCol);
if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w });
doc.moveDown(0.3);
}
}
if (r.education?.certifications?.length) {
section("Certifications");
for (const c of r.education.certifications) {
room(20);
const line = [c.issuer, c.year].filter(Boolean).join(" · ");
doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line });
if (line) doc.font("body").fillColor(MUTED).text(` ${line}`);
}
if (r.projects.length) {
section("Selected projects", rightCol);
for (const p of r.projects) bullet(p.detail, rightCol, p.title);
}
doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd);
// Footer on every page.
const range = doc.bufferedPageRange();
@@ -200,12 +247,12 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const y = doc.page.height - PAGE.margin + 4;
const bottom = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, y - 8).lineTo(left + width, y - 8).stroke().restore();
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(full.x, y - 8).lineTo(full.x + full.w, y - 8).stroke().restore();
doc.font("body").fontSize(8).fillColor(MUTED);
doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), left, y, { lineBreak: false });
doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), full.x, y, { lineBreak: false });
if (range.count > 1) {
const label = `${i - range.start + 1} of ${range.count}`;
doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false });
doc.text(label, full.x + full.w - doc.widthOfString(label), y, { lineBreak: false });
}
doc.page.margins.bottom = bottom;
}
+12 -1
View File
@@ -8,8 +8,13 @@ export interface ResumeData {
location?: string;
summary?: string;
availability?: string;
email?: string;
stats: { value: string; label: string; detail?: string }[];
/** Roles shown in full, current first. */
roles: Note<"experience">[];
/** Roles marked earlier: a short list of title, company, dates and focus. */
earlier: Note<"experience">[];
projects: { title: string; detail: string; link?: string }[];
skills?: Note<"skills">["data"];
education?: Note<"education">["data"];
socials: { label: string; handle: string; url: string }[];
@@ -26,6 +31,9 @@ export const month = (ym: string | undefined) => {
export const period = (r: Note<"experience">) => `${month(r.data.start)} – ${month(r.data.end)}`;
/** "A, B and C". */
export const listOf = (items: string[]) => new Intl.ListFormat("en-US", { style: "long", type: "conjunction" }).format(items);
export function resumeData(s: SiteIndex): ResumeData {
const settings = s.settings();
const page = s.pageText("/resume");
@@ -39,8 +47,11 @@ export function resumeData(s: SiteIndex): ResumeData {
location: settings?.location,
summary: page?.body?.trim() || undefined,
availability: settings?.availability,
email: settings?.email,
stats: page?.data.stats ?? [],
roles,
roles: roles.filter((r) => !r.data.earlier),
earlier: roles.filter((r) => r.data.earlier),
projects: page?.data.projects ?? [],
skills: s.one("skills")?.data,
education: s.one("education")?.data,
socials: settings?.socials ?? [],
+96 -2
View File
@@ -15,7 +15,7 @@ import StarPath from "@gk/ui/components/StarPath.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { site } from "../lib/page.ts";
import { period, resumeData } from "../lib/resume.ts";
import { listOf, period, resumeData } from "../lib/resume.ts";
import { formatDate } from "../lib/site.ts";
const s = await site();
@@ -167,7 +167,7 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
</div>
<span class="company">{role.data.company}{role.data.location ? ` · ${role.data.location}` : ""}</span>
{role.data.previousRoles?.length ? (
<span class="previously">Previously {role.data.previousRoles.join(" and ")} at {role.data.company}</span>
<span class="previously">Previously {listOf(role.data.previousRoles)}</span>
) : null}
{role.data.focus && <span class="focus">{role.data.focus}</span>}
<ul>
@@ -189,6 +189,43 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
))
}
</div>
{
r.earlier.length > 0 && (
<section aria-labelledby="earlier-h" class="earlier">
<h3 id="earlier-h" class="sub-head">Earlier</h3>
<ul>
{r.earlier.map((role) => (
<li id={role.slug}>
<div class="role-top">
<span class="earlier-role">{role.data.role}</span>
<span class="dates">{period(role)}</span>
</div>
<span class="company">{role.data.company}</span>
{role.data.focus && <span class="focus">{role.data.focus}</span>}
</li>
))}
</ul>
</section>
)
}
{
r.projects.length > 0 && (
<section aria-labelledby="projects-h" class="projects">
<h3 id="projects-h" class="sub-head">Selected projects</h3>
<ul>
{r.projects.map((p) => (
<li>
<Star size={10} />
<span>
{p.link ? <a href={p.link}>{p.title}</a> : <strong>{p.title}</strong>}
<span class="muted"> · {p.detail}</span>
</span>
</li>
))}
</ul>
</section>
)
}
{caseStudies.length > 0 && <Button variant="text" href="/work" arrow>See the case studies on the Work page</Button>}
</section>
</div>
@@ -556,4 +593,61 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
.role li :global(svg) {
margin-top: 7px;
}
.earlier,
.projects {
display: flex;
flex-direction: column;
gap: 12px;
}
.sub-head {
margin: 8px 0 0;
font-size: 16px;
letter-spacing: 0.08em;
color: var(--gk-gold-bright);
}
.earlier ul,
.projects ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.earlier li {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 18px;
border: 1px solid var(--gk-lines);
border-radius: 10px;
background: var(--gk-midnight);
scroll-margin-top: 24px;
}
.earlier-role {
font-family: var(--gk-font-display);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--gk-text);
}
.earlier .company {
font-size: 14px;
}
.projects li {
display: flex;
gap: 10px;
font-size: 15px;
line-height: 1.55;
color: var(--gk-soft);
}
.projects li :global(svg) {
flex: none;
margin-top: 6px;
}
.projects strong,
.projects a {
color: var(--gk-text);
font-weight: 600;
}
</style>