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
+7 -3
View File
@@ -86,10 +86,14 @@ or admin token. Never set it where the public can reach the site.
## The resume PDF ## The resume PDF
`/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume` `/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume`
(`src/lib/resume.ts`), so the two never disagree. It is one plain column so (`src/lib/resume.ts`), so the two never disagree. It reads top to bottom so
applicant tracking systems read it in order, uses the brand TTFs in applicant tracking systems take it in order, aims for one page (roles marked
`earlier` print as a short list; skills share a row with education and
projects), uses the brand TTFs in
`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change. `src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change.
The highlights row comes from the `/resume` Page text note's `stats`. The highlights row and Selected projects come from the `/resume` Page text note's
`stats` and `projects`. Greg's Resume Interview answers live in
`tools/migrate/src/seed.ts`.
## Where each page lives ## Where each page lives
+15
View File
@@ -281,6 +281,10 @@ export const experience = def({
focus: line({ title: "Focus", description: "One line under the role." }).optional(), focus: line({ title: "Focus", description: "One line under the role." }).optional(),
bullets: strings({ title: "Bullets" }), bullets: strings({ title: "Bullets" }),
tech: strings({ title: "Tech" }).optional(), tech: strings({ title: "Tech" }).optional(),
earlier: z
.boolean()
.meta({ title: "Earlier role", description: "Shows in the short Earlier list: title, company, dates and the focus line only." })
.optional(),
}), }),
}); });
@@ -470,6 +474,7 @@ export const siteSettings = def({
tagline: line({ title: "Tagline" }), tagline: line({ title: "Tagline" }),
lockup: line({ title: "Lockup line" }), lockup: line({ title: "Lockup line" }),
location: line({ title: "Location" }).optional(), location: line({ title: "Location" }).optional(),
email: line({ title: "Email", description: "Printed on the resume PDF." }).optional(),
availability: line({ title: "Availability", description: "Shown on the resume and Inquiries, like Open to freelance web work." }).optional(), availability: line({ title: "Availability", description: "Shown on the resume and Inquiries, like Open to freelance web work." }).optional(),
socials: z socials: z
.array(z.object({ label: line({ title: "Label" }), handle: line({ title: "Handle" }), url: z.url().meta({ title: "Link" }) })) .array(z.object({ label: line({ title: "Label" }), handle: line({ title: "Handle" }), url: z.url().meta({ title: "Link" }) }))
@@ -501,6 +506,16 @@ export const page = def({
) )
.meta({ title: "Highlights", description: "Big numbers on the page, like the resume highlights. Only real, checkable numbers." }) .meta({ title: "Highlights", description: "Big numbers on the page, like the resume highlights. Only real, checkable numbers." })
.optional(), .optional(),
projects: z
.array(
z.object({
title: line({ title: "Project" }),
detail: line({ title: "Detail", description: "One line with a result or number worth stating." }),
link: line({ title: "Link", description: "A page on this site, like /work/cosmos, or a full address." }).optional(),
}),
)
.meta({ title: "Selected projects", description: "Personal projects the resume lists, like the homelab or this site." })
.optional(),
}), }),
}); });
+143 -96
View File
@@ -1,6 +1,7 @@
/** /**
* The printable resume. Built from the same ResumeData as the /resume page, * 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. * Paper colors: ink on white, with the brand gold and teal darkened for print.
*/ */
import PDFDocument from "pdfkit"; 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 montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline"; import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline";
import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.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 fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
const FONTS = { const FONTS = {
@@ -26,8 +27,15 @@ const GOLD = "#a8842f";
const TEAL = "#0f7f78"; const TEAL = "#0f7f78";
const RULE = "#d9cfb8"; const RULE = "#d9cfb8";
const PAGE = { size: "LETTER" as const, margin: 50 }; const PAGE = { size: "LETTER" as const, margin: 36 };
const SITE = "gregkilmer.com"; 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> { export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const doc = new PDFDocument({ 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); for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
const left = doc.page.margins.left; const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
const width = doc.page.width - left - doc.page.margins.right;
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c)); doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => { 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. */ /** A thin gold rule with a small star at its center. */
const starRule = (y: number) => { const starRule = (y: number) => {
const mid = left + width / 2; const mid = full.x + full.w / 2;
doc.save().lineWidth(0.6).strokeColor(GOLD); doc.save().lineWidth(0.6).strokeColor(GOLD);
doc.moveTo(left, y).lineTo(mid - 10, y).stroke(); doc.moveTo(full.x, y).lineTo(mid - 10, y).stroke();
doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke(); doc.moveTo(mid + 10, y).lineTo(full.x + full.w, y).stroke();
doc.restore(); doc.restore();
diamond(mid, y, 4); diamond(mid, y, 4);
}; };
const section = (title: string) => { const section = (title: string, c: Col = full) => {
room(60); room(50);
doc.moveDown(0.75); doc.moveDown(0.5);
const y = doc.y; const y = doc.y;
doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 }); doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
const ruleY = doc.y + 2; const ruleY = doc.y + 1;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore(); doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(c.x, ruleY).lineTo(c.x + c.w, ruleY).stroke().restore();
doc.y = ruleY + 8; doc.y = ruleY + 6;
}; };
const bullet = (text: string, indent = 12) => { const bullet = (text: string, c: Col = full, lead?: string) => {
doc.font("body").fontSize(9.5); const indent = 11;
const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 }); const w = c.w - indent;
room(h + 4); doc.font("body").fontSize(TEXT);
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1.2 }) + 3);
const y = doc.y; const y = doc.y;
diamond(left + 3.5, y + 6, 2.2); diamond(c.x + 3.5, y + 5.5, 2.1);
doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 }); if (lead) {
doc.moveDown(0.15); 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 // Header
doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 }); doc.font("title").fontSize(24).fillColor(INK).text(r.name.toUpperCase(), full.x, doc.y, { characterSpacing: 2 });
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width }); 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. // 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 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(" · "); const contact = [r.location, r.email, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))]
doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width }); .filter(Boolean)
if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width }); .join(" · ");
doc.moveDown(0.2).font("body").fontSize(8).fillColor(MUTED).text(contact, { width: full.w });
doc.moveDown(0.7); doc.moveDown(0.7);
starRule(doc.y); starRule(doc.y);
doc.moveDown(0.6); doc.moveDown(0.5);
if (r.summary) { 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) { if (r.stats.length) {
section("Highlights"); doc.moveDown(0.7);
const cols = Math.min(r.stats.length, 4); const cols = Math.min(r.stats.length, 4);
const colW = width / cols; const colW = full.w / cols;
let top = doc.y; const top = doc.y;
let bottom = top;
r.stats.forEach((st, i) => { r.stats.forEach((st, i) => {
if (i > 0 && i % cols === 0) top = doc.y + 6; const x = full.x + (i % cols) * colW;
const x = left + (i % cols) * colW; const y = top + Math.floor(i / cols) * 44;
doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 }); doc.font("title").fontSize(15).fillColor(GOLD).text(st.value, x, y, { width: colW - 10, lineBreak: false });
doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 }); 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.5).fillColor(MUTED).text(st.detail, x, doc.y, { 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) { if (r.roles.length) {
section("Experience"); section("Experience");
r.roles.forEach((role, i) => { r.roles.forEach((role, i) => {
const d = role.data; const d = role.data;
if (i > 0) doc.moveDown(0.6); if (i > 0) doc.moveDown(0.5);
room(64); titled(d.role, period(role), 10.5);
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 });
const at = [d.company, d.location].filter(Boolean).join(" · "); const at = [d.company, d.location].filter(Boolean).join(" · ");
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width }); doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
if (d.previousRoles?.length) { if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width }); if (d.focus) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
} doc.moveDown(0.25);
if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 });
doc.moveDown(0.3);
for (const b of d.bullets ?? []) bullet(b); for (const b of d.bullets ?? []) bullet(b);
if (d.tech?.length) { doc.x = full.x;
doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 });
}
doc.x = left;
}); });
} }
if (r.skills?.stack.length) { if (r.earlier.length) {
section("Skills"); section("Earlier");
for (const g of r.skills.stack) { r.earlier.forEach((role, i) => {
const items = g.items.join(", "); const d = role.data;
doc.font("body").fontSize(9.5); if (i > 0) doc.moveDown(0.3);
room(doc.heightOfString(items, { width: width - 110 }) + 4); 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; 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; const afterLabel = doc.y;
doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 }); 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) + 3; doc.y = Math.max(doc.y, afterLabel) + 2;
} }
if (r.skills.core?.length) { leftEnd = doc.y;
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;
} }
if (r.education?.degrees.length) { doc.y = start;
section("Education"); const edu = r.education;
for (const dg of r.education.degrees) { if (edu?.degrees.length || edu?.certifications?.length) {
room(40); section("Education", rightCol);
const y = doc.y; for (const dg of edu?.degrees ?? []) {
doc.font("medium").fontSize(9).fillColor(GOLD); titled(dg.credential, dg.period, 9.5, rightCol);
const w = doc.widthOfString(dg.period); doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false }); doc.moveDown(0.3);
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 }); for (const c of edu?.certifications ?? []) {
if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width }); titled(c.name, c.year ?? "", 9.5, rightCol);
doc.moveDown(0.4); 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.projects.length) {
if (r.education?.certifications?.length) { section("Selected projects", rightCol);
section("Certifications"); for (const p of r.projects) bullet(p.detail, rightCol, p.title);
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}`);
}
} }
doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd);
// Footer on every page. // Footer on every page.
const range = doc.bufferedPageRange(); const range = doc.bufferedPageRange();
@@ -200,12 +247,12 @@ export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const y = doc.page.height - PAGE.margin + 4; const y = doc.page.height - PAGE.margin + 4;
const bottom = doc.page.margins.bottom; const bottom = doc.page.margins.bottom;
doc.page.margins.bottom = 0; 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.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) { if (range.count > 1) {
const label = `${i - range.start + 1} of ${range.count}`; 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; doc.page.margins.bottom = bottom;
} }
+12 -1
View File
@@ -8,8 +8,13 @@ export interface ResumeData {
location?: string; location?: string;
summary?: string; summary?: string;
availability?: string; availability?: string;
email?: string;
stats: { value: string; label: string; detail?: string }[]; stats: { value: string; label: string; detail?: string }[];
/** Roles shown in full, current first. */
roles: Note<"experience">[]; 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"]; skills?: Note<"skills">["data"];
education?: Note<"education">["data"]; education?: Note<"education">["data"];
socials: { label: string; handle: string; url: string }[]; 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)}`; 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 { export function resumeData(s: SiteIndex): ResumeData {
const settings = s.settings(); const settings = s.settings();
const page = s.pageText("/resume"); const page = s.pageText("/resume");
@@ -39,8 +47,11 @@ export function resumeData(s: SiteIndex): ResumeData {
location: settings?.location, location: settings?.location,
summary: page?.body?.trim() || undefined, summary: page?.body?.trim() || undefined,
availability: settings?.availability, availability: settings?.availability,
email: settings?.email,
stats: page?.data.stats ?? [], 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, skills: s.one("skills")?.data,
education: s.one("education")?.data, education: s.one("education")?.data,
socials: settings?.socials ?? [], 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 TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro"; import TickScale from "@gk/ui/components/TickScale.astro";
import { site } from "../lib/page.ts"; 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"; import { formatDate } from "../lib/site.ts";
const s = await site(); const s = await site();
@@ -167,7 +167,7 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
</div> </div>
<span class="company">{role.data.company}{role.data.location ? ` · ${role.data.location}` : ""}</span> <span class="company">{role.data.company}{role.data.location ? ` · ${role.data.location}` : ""}</span>
{role.data.previousRoles?.length ? ( {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} ) : null}
{role.data.focus && <span class="focus">{role.data.focus}</span>} {role.data.focus && <span class="focus">{role.data.focus}</span>}
<ul> <ul>
@@ -189,6 +189,43 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
)) ))
} }
</div> </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>} {caseStudies.length > 0 && <Button variant="text" href="/work" arrow>See the case studies on the Work page</Button>}
</section> </section>
</div> </div>
@@ -556,4 +593,61 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
.role li :global(svg) { .role li :global(svg) {
margin-top: 7px; 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> </style>
+10 -2
View File
@@ -263,13 +263,15 @@ export function build(legacyDir: string): BuildResult {
}; };
for (const file of readdirSync(expDir).sort()) { for (const file of readdirSync(expDir).sort()) {
const { frontmatter: e, body } = splitNote(readFileSync(join(expDir, file), "utf8")) as { frontmatter: any; body: string }; const { frontmatter: e, body } = splitNote(readFileSync(join(expDir, file), "utf8")) as { frontmatter: any; body: string };
// Greg's Resume Interview answers replace the legacy wording field by field.
const answers = seed.experience[basename(file, ".md")] ?? {};
const [start, end] = String(e.period).split(" - "); const [start, end] = String(e.period).split(" - ");
const bullets = body.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim()); const bullets = body.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim());
const extra = body.split("\n").filter((l) => l.trim() && !l.startsWith("- ") && !/^Key contributions:?$/.test(l.trim())).join("\n"); const extra = body.split("\n").filter((l) => l.trim() && !l.startsWith("- ") && !/^Key contributions:?$/.test(l.trim())).join("\n");
add( add(
{ {
type: "experience", type: "experience",
title: `${e.title} at ${e.company}`, title: `${(answers.role as string | undefined) ?? e.title} at ${e.company}`,
publish: true, publish: true,
company: e.company, company: e.company,
role: e.title, role: e.title,
@@ -278,10 +280,15 @@ export function build(legacyDir: string): BuildResult {
focus: e.focus, focus: e.focus,
bullets, bullets,
created: today, created: today,
...answers,
}, },
extra, extra,
); );
} }
{
const { body, ...rest } = seed.resumePage;
add({ type: "page", created: today, ...rest }, body);
}
add({ type: "skills", title: "Skills", publish: true, ...seed.skills }); add({ type: "skills", title: "Skills", publish: true, ...seed.skills });
add({ type: "education", title: "Education", publish: true, ...seed.education }); add({ type: "education", title: "Education", publish: true, ...seed.education });
@@ -335,7 +342,8 @@ export function build(legacyDir: string): BuildResult {
summary: p.summary, summary: p.summary,
icon: p.icon, icon: p.icon,
order: i + 1, order: i + 1,
slots: store.slots[p.slotKey], // Greg (2026-10-11): orders stay closed until he opens them, so no slot shows as open.
slots: { ...store.slots[p.slotKey], open: 0 },
images: p.images.map((im: any) => ({ file: attach(im.src, "Store"), alt: im.alt, label: im.label })), images: p.images.map((im: any) => ({ file: attach(im.src, "Store"), alt: im.alt, label: im.label })),
specs: p.specs, specs: p.specs,
priceTiers: p.pricing, priceTiers: p.pricing,
+103 -10
View File
@@ -16,6 +16,10 @@ export const site = {
tagline: "Developer. Maker. Storyteller.", tagline: "Developer. Maker. Storyteller.",
lockup: "Web Development · a Patiun LLC company", lockup: "Web Development · a Patiun LLC company",
location: "Philadelphia, PA", location: "Philadelphia, PA",
/** legacy contact pages; Greg confirmed it for the resume PDF (Resume Interview, 2026-10-11). */
email: "greg@gregkilmer.com",
/** Resume Interview: open to offers but not looking, so the page says this. */
availability: "Open to freelance web work",
socials: [ socials: [
{ label: "GitHub", handle: "Patiun", url: "https://github.com/patiun" }, { label: "GitHub", handle: "Patiun", url: "https://github.com/patiun" },
{ label: "LinkedIn", handle: "Gregory Kilmer", url: "https://www.linkedin.com/in/gregory-kilmer-36646bb5/" }, { label: "LinkedIn", handle: "Gregory Kilmer", url: "https://www.linkedin.com/in/gregory-kilmer-36646bb5/" },
@@ -222,11 +226,15 @@ export const favoriteBoardGames = ["Spirit Island", "Wyrmspan"];
// ---------------------------------------------------------------- Resume // ---------------------------------------------------------------- Resume
/** legacy/src/components/professional/SkillGrid.astro */ /**
* legacy/src/components/professional/SkillGrid.astro, with the stack Greg added
* in the Resume Interview (2026-10-11). Core items are the ones he named for
* Implico's main stack.
*/
export const skills = { export const skills = {
core: [ core: [
"Cloud Architecture & Development", "Software Architecture & System Design",
"Mobile Application Development", "Cloud & Mobile Application Development",
"SAP Business Technology Platform (BTP)", "SAP Business Technology Platform (BTP)",
"Team Leadership & Technical Mentorship", "Team Leadership & Technical Mentorship",
"Cross-functional Communication", "Cross-functional Communication",
@@ -237,14 +245,89 @@ export const skills = {
"Advanced automation and workflow engineering", "Advanced automation and workflow engineering",
], ],
stack: [ stack: [
{ label: "Languages", items: ["JavaScript", "TypeScript", "Java", "Python", "C#", "C", "SQL", "HTML", "CSS"] }, {
{ label: "Frameworks", items: ["React", "Vue", "Astro", "Flask"] }, label: "Languages",
{ label: "Cloud Platforms", items: ["SAP BTP", "Google Cloud Platform", "AWS"] }, items: ["JavaScript", "TypeScript", "Python", "Java", "C#", "C", "SQL", "Bash", "Awk", "HTML", "CSS"],
{ label: "Tools", items: ["PostgreSQL", "VSCode", "Unity", "XCode", "Android Studio", "Git", "Gitea", "CI/CD pipelines"] }, core: ["JavaScript", "Python"],
},
{ label: "Frameworks", items: ["React", "React Native", "Node.js", "SAP CAP", "Vue", "Astro", "Flask"], core: ["React", "React Native", "SAP CAP"] },
{
label: "Cloud Platforms",
items: ["SAP BTP (Cloud Foundry)", "SAP Integration Suite", "SAP Build Apps", "SAP Build Work Zone", "Google Cloud Platform", "AWS"],
core: ["SAP BTP (Cloud Foundry)", "SAP Integration Suite"],
},
{ label: "Mobile", items: ["iOS", "Android", "Xcode", "Android Studio"] },
{ label: "Data", items: ["PostgreSQL", "NumPy", "Pandas"] },
{ label: "Tools", items: ["Docker", "Git", "Gitea", "CI/CD pipelines", "VS Code", "Unity"] },
{ label: "AI/ML", items: ["Machine learning frameworks", "LLM integration", "Claude Code", "Hermes", "OpenClaw"] }, { label: "AI/ML", items: ["Machine learning frameworks", "LLM integration", "Claude Code", "Hermes", "OpenClaw"] },
], ],
}; };
/**
* Greg's Resume Interview answers (2026-10-11), written up as the experience
* notes. Keyed by the legacy file in src/content/experience; these fields
* replace the legacy ones. Numbers are his, as he gave them.
*/
export const experience: Record<string, Record<string, unknown>> = {
implico: {
role: "Team Lead, BTP Cloud & Mobile Apps and US Managed Services",
previousRoles: ["BTP Cloud and Mobile App Lead", "Lead Developer", "Software Developer"],
location: "Remote",
focus: "Leading the team behind Carrier Connect, an SAP logistics platform for third-party carriers.",
bullets: [
"Architected Carrier Connect, a suite of integration and management tools built for SDM for SAP that lets third-party carriers connect to a central purchase order system, track loading and delivery events, and streamline their documents.",
"Designed several integration paths into one delivery interface (REST APIs, Excel and CSV file transfer, and the companion Driver App), and personally built the delivery interface, the file transfer interfaces and the inventory polling and delivery system.",
"Carrier Connect runs at 99.999% uptime and handles 2,000+ orders a day for 500+ active users across 50+ companies, saving about 72 hours per delivery.",
"Lead a development team of 4, plus 6 extended members on the US Managed Services team.",
"Set the team's coding standards on Google's published best practices, along with code review, pull request, testing and code coverage standards.",
"Brought React and AI-assisted development to the team, and was the main author of its responsible AI guidelines: factual accuracy, reputation and transparency, human oversight, data security, and consequences for misuse.",
],
tech: ["React Native", "React", "Python", "SAP CAP", "SAP BTP (Cloud Foundry)", "SAP Build Work Zone", "SAP Build Apps", "SAP Integration Suite", "iOS", "Android"],
},
drexel: {
role: "Assistant System Administrator, then Computer Lab Help Desk",
end: "2018-06",
earlier: true,
focus: "Maintained 4 Linux servers for the College of Computing and Informatics and automated every workstation in 3 labs and the laptop carts with Ansible.",
bullets: [
"Maintained the college's Linux server network (4 servers serving the whole College of Computing and Informatics) with Ansible, Bash, Python and MySQL.",
"Automated deployment and maintenance of every workstation in the 3 computer labs and the mobile laptop carts with Ansible.",
"Protected college-wide data with Ceph storage backups.",
"Stayed on at the Computer Lab help desk through June 2018, supporting faculty, staff and students.",
],
},
comcast_1: {
role: "Junior Developer (co-op), then Senior Intern",
earlier: true,
focus: "Led a team of 10 co-ops building smart remote, call center and inventory tools, and built an NLP sentiment pipeline for leadership.",
bullets: [
"Hired back after the co-op as Senior Intern to run a team of 10 co-ops building smart remote, call center and inventory tools.",
"Built an NLP system that gathered remote speech input and reviews so leadership teams could gauge customer sentiment (Java, Groovy, Grails, Elasticsearch, MySQL).",
"Built call center and inventory features on the internal logistics platform for techs and warehouse teams (PHP, MySQL).",
"Worked in weeklong Agile sprints.",
],
},
};
/** The /resume page's own text, from the Resume Interview (2026-10-11). */
export const resumePage = {
title: "Resume",
page: "/resume",
publish: true,
lede: "Team Lead, SAP BTP Cloud & Mobile Apps",
stats: [
{ value: "99.999%", label: "Uptime", detail: "Carrier Connect" },
{ value: "2,000+", label: "Orders a day", detail: "Through Carrier Connect" },
{ value: "500+", label: "Active users", detail: "Across 50+ companies" },
{ value: "10", label: "People led", detail: "Development and Managed Services" },
],
projects: [
{ title: "Cosmos homelab", detail: "20+ Docker containers working with 17 AI agents on a private network." },
{ title: "GregKilmer.com", detail: "This site, run from an Obsidian vault through a rule-checked content service and MCP server." },
],
body: `I have a strong eye for software design and architecture, and a broad range of experience across cloud, app and game development. I stay current with the cutting edge of AI and use AI tools to speed up my workflow, but I'm skilled enough to do the work without them.`,
};
/** legacy/src/components/professional/EducationHistory.astro and Certifications.astro */ /** legacy/src/components/professional/EducationHistory.astro and Certifications.astro */
export const education = { export const education = {
degrees: [ degrees: [
@@ -255,14 +338,24 @@ export const education = {
detail: "Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.", detail: "Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.",
}, },
], ],
certifications: [{ name: "Planet 9 Development", issuer: "Neptune", year: "2019", status: "Active" as const }], // Resume Interview: Neptune Software Planet 9, earned 2019.
certifications: [{ name: "Planet 9 Development", issuer: "Neptune Software", year: "2019", status: "Active" as const }],
}; };
/** Employer and facts for the legacy projects; facts from the Implico and Cosmos entries. */ /** Employer and facts for the legacy projects; facts from the Implico and Cosmos entries. */
export const caseStudies: Record<string, { employer?: string; facts?: { label: string; value: string }[] }> = { export const caseStudies: Record<string, { employer?: string; facts?: { label: string; value: string }[] }> = {
"carrier-connect": { employer: "Implico Inc.", facts: [{ label: "Active users", value: "1,000+" }, { label: "Companies", value: "100" }] }, // Resume Interview (2026-10-11): Greg's corrected numbers.
"carrier-connect": {
employer: "Implico Inc.",
facts: [
{ label: "Active users", value: "500+" },
{ label: "Companies", value: "50+" },
{ label: "Orders a day", value: "2,000+" },
{ label: "Uptime", value: "99.999%" },
],
},
"driver-app": { employer: "Implico Inc." }, "driver-app": { employer: "Implico Inc." },
"cosmos-homelab": { facts: [{ label: "AI agents", value: "17" }] }, "cosmos-homelab": { facts: [{ label: "Docker containers", value: "20+" }, { label: "AI agents", value: "17" }] },
"placeholder-project": {}, "placeholder-project": {},
}; };
+9 -2
View File
@@ -41,12 +41,19 @@ describe("migration", () => {
expect(find("library-item", "Cosmere")?.data).toMatchObject({ creator: "Brandon Sanderson", medium: "book" }); expect(find("library-item", "Cosmere")?.data).toMatchObject({ creator: "Brandon Sanderson", medium: "book" });
}); });
it("keeps the store's real prices and slots", () => { it("keeps the store's real prices, with no slot open until Greg opens orders", () => {
const proxies = find("product-line", "Proxy MTG Cards")!.data as any; const proxies = find("product-line", "Proxy MTG Cards")!.data as any;
expect(proxies.slots).toEqual({ open: 1, total: 3 }); expect(proxies.slots).toEqual({ open: 0, total: 3 });
expect(proxies.priceTiers).toContainEqual({ band: "100 card deck", price: "$40.00", note: "per set of cards" }); expect(proxies.priceTiers).toContainEqual({ band: "100 card deck", price: "$40.00", note: "per set of cards" });
}); });
it("takes the resume from Greg's interview answers", () => {
const implico = find("experience", "Team Lead, BTP Cloud & Mobile Apps and US Managed Services at Implico Inc.")!.data as any;
expect(implico.bullets.join(" ")).toContain("500+ active users across 50+ companies");
expect(result.notes.filter((n) => n.data.type === "experience" && (n.data as any).earlier)).toHaveLength(2);
expect(find("page", "Resume")?.data).toMatchObject({ page: "/resume", stats: expect.any(Array), projects: expect.any(Array) });
});
it("logs status for everything in progress", () => { it("logs status for everything in progress", () => {
const kinds = result.notes.filter((n) => n.data.type === "status").map((n) => `${n.data.kind}: ${n.data.title}`); const kinds = result.notes.filter((n) => n.data.type === "status").map((n) => `${n.data.kind}: ${n.data.title}`);
expect(kinds).toContain("Playing: Slay the Spire 2"); expect(kinds).toContain("Playing: Slay the Spire 2");
@@ -12,6 +12,7 @@ location: # optional
focus: # optional. One line under the role. focus: # optional. One line under the role.
bullets: [] bullets: []
tech: [] # optional tech: [] # optional
earlier: false # optional. Shows in the short Earlier list: title, company, dates and the focus line only.
--- ---
<!-- Optional longer notes; the resume uses the bullets. --> <!-- Optional longer notes; the resume uses the bullets. -->