Match the site to the approved designs

Denser star field, the page nebula, a favicon package, the filament banner
without its box, the About portrait, Kashi and tea photos and Scorpius, the
Store's own header, a header logo glow, a resume PDF that fills its page,
one divider on the Library and Shelf, star-chart art for coverless
favorites, Shelf Players, Play time and blog filters with a Show all fold,
make kind pills on craft pages, and full-width closing bands.

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-12 03:33:15 +00:00
parent 3f6167455d
commit 151bed230a
45 changed files with 671 additions and 166 deletions
+57 -2
View File
@@ -128,6 +128,7 @@ export const sorts = [
{ key: "played", label: "Last played" },
{ key: "az", label: "A to Z" },
{ key: "added", label: "Recently added" },
{ key: "time", label: "Play time" },
] as const;
export type SortKey = (typeof sorts)[number]["key"];
@@ -138,6 +139,8 @@ export function sortGames(games: Game[], sort: SortKey): Game[] {
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
az,
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
// Shortest first; games BoardGameGeek has not timed go last.
time: (a, b) => (minutesOf(a) ?? Infinity) - (minutesOf(b) ?? Infinity) || az(a, b),
};
return [...games].sort(cmp[sort]);
}
@@ -148,9 +151,29 @@ export interface ShelfFilter {
owned: boolean;
want: boolean;
favorites: boolean;
blog: boolean;
players: PlayersKey | "";
time: TimeKey | "";
sort: SortKey;
}
/** The Players pills: who a game has to seat. */
export const playerOptions = [
{ key: "1", label: "1", lo: 1, hi: 1 },
{ key: "2", label: "2", lo: 2, hi: 2 },
{ key: "3-4", label: "3–4", lo: 3, hi: 4 },
{ key: "5+", label: "5+", lo: 5, hi: Infinity },
] as const;
export type PlayersKey = (typeof playerOptions)[number]["key"];
/** The Play time pills, in minutes. */
export const timeOptions = [
{ key: "short", label: "Under 30 min", lo: 0, hi: 29 },
{ key: "mid", label: "30–60", lo: 30, hi: 60 },
{ key: "long", label: "60+", lo: 61, hi: Infinity },
] as const;
export type TimeKey = (typeof timeOptions)[number]["key"];
export function shelfFilter(params: URLSearchParams): ShelfFilter {
const submitted = params.has("f");
return {
@@ -159,19 +182,27 @@ export function shelfFilter(params: URLSearchParams): ShelfFilter {
owned: submitted ? params.get("owned") === "1" : true,
want: params.get("want") === "1",
favorites: params.get("fav") === "1",
blog: params.get("blog") === "1",
players: playerOptions.find((o) => o.key === params.get("players"))?.key ?? "",
time: timeOptions.find((o) => o.key === params.get("time"))?.key ?? "",
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
};
}
/**
* Owned and Want to play add games to the list (either one); with neither
* ticked, every game shows. Favorites only and How it plays narrow it.
* ticked, every game shows. Favorites only, Has a blog post, How it plays,
* Players and Play time narrow it. A game BoardGameGeek has not synced yet
* drops out while Players or Play time is set.
*/
export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
export function gameShows(g: Game, f: ShelfFilter, text: string, hasPost = false): boolean {
const owned = !!g.data.owned;
const want = !!g.data.wantToPlay;
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
if (f.favorites && !g.data.favorite) return false;
if (f.blog && !hasPost) return false;
if (f.players && !seats(g, playerOptions.find((o) => o.key === f.players)!)) return false;
if (f.time && !lasts(g, timeOptions.find((o) => o.key === f.time)!)) return false;
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
return !f.q || text.includes(f.q);
}
@@ -185,6 +216,30 @@ interface GameSynced {
maxPlayTime?: number;
}
/** The player range BoardGameGeek gives, when it has synced. */
export function playersOf(g: Game): [number, number] | undefined {
const d = g.data as GameSynced;
const lo = d.minPlayers || d.maxPlayers;
const hi = d.maxPlayers || d.minPlayers;
return lo && hi ? [lo, hi] : undefined;
}
/** A game's usual length in minutes: the listed play time, else the longest. */
export function minutesOf(g: Game): number | undefined {
const d = g.data as GameSynced;
return d.playTime || d.maxPlayTime || d.minPlayTime || undefined;
}
/** Whether a game plays at some count in the option's range. */
const seats = (g: Game, o: { lo: number; hi: number }) => {
const r = playersOf(g);
return !!r && r[0] <= o.hi && r[1] >= o.lo;
};
const lasts = (g: Game, o: { lo: number; hi: number }) => {
const m = minutesOf(g);
return m !== undefined && m >= o.lo && m <= o.hi;
};
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
+60 -22
View File
@@ -43,7 +43,21 @@ interface Col {
w: number;
}
function renderBody(r: ResumeData): Promise<Buffer> {
interface Body {
pdf: Buffer;
pages: number;
/** Where the last line ended on the last page, in points from the top. */
end: number;
}
/**
* Lays the resume out with every gap between blocks multiplied by `spread`
* (and line spacing by a little less), so a short resume can be opened up to
* fill its one page instead of leaving the bottom empty.
*/
function renderBody(r: ResumeData, spread = 1): Promise<Body> {
const gapDown = (n: number) => doc.moveDown(n * spread);
const lg = (n: number) => n + (spread - 1) * 1.2;
const doc = new PDFDocument({
size: PAGE.size,
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right },
@@ -57,8 +71,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
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) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
let pages = 1;
let end = 0;
const done = new Promise<Body>((resolve, reject) => {
doc.on("end", () => resolve({ pdf: Buffer.concat(chunks), pages, end }));
doc.on("error", reject);
});
@@ -73,7 +89,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const section = (title: string, c: Col = full) => {
room(50);
doc.moveDown(0.4);
gapDown(0.4);
const y = doc.y;
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;
@@ -85,16 +101,16 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const indent = 11;
const w = c.w - indent;
doc.font("body").fontSize(TEXT);
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1 }) + 3);
room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: lg(1) }) + 3);
const y = doc.y;
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 });
doc.font("body").text(` ${text}`, { lineGap: 1 });
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: lg(1) });
doc.font("body").text(` ${text}`, { lineGap: lg(1) });
} else {
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1 });
doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: lg(1) });
}
doc.moveDown(0.12);
gapDown(0.12);
};
/** A title on the left with its dates set flush right on the same line. */
@@ -122,20 +138,20 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
{
const y = doc.moveDown(0.15).y;
const y = gapDown(0.15).y;
doc.font("body").fontSize(8).fillColor(MUTED);
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
doc.text(links, full.x, y, { width: full.w - 120 });
}
doc.moveDown(0.6);
gapDown(0.6);
if (r.summary) {
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.4 });
doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: lg(1.4) });
}
if (r.stats.length) {
doc.moveDown(0.6);
gapDown(0.6);
const cols = Math.min(r.stats.length, 4);
const colW = full.w / cols;
const top = doc.y;
@@ -156,14 +172,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
section("Experience");
r.roles.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.5);
if (i > 0) gapDown(0.5);
titled(d.role, period(role), 10.5);
const at = [d.company, d.location].filter(Boolean).join(" · ");
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 });
// The focus line repeats what the bullets say, so paper skips it when there are bullets.
if (d.focus && !d.bullets?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
doc.moveDown(0.25);
gapDown(0.25);
for (const b of d.bullets ?? []) bullet(b);
doc.x = full.x;
});
@@ -173,7 +189,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
section("Earlier");
r.earlier.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.3);
if (i > 0) gapDown(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).fillColor(MUTED).text(d.focus, { width: full.w });
@@ -193,14 +209,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
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.6 })) + 2;
return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: lg(0.6) })) + 2;
}, 24);
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
doc.font("body");
const rightH =
(entries ? 24 + entries * 26 : 0) +
(r.projects.length ? 24 : 0) +
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1 }) + 3, 0);
r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: lg(1) }) + 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;
@@ -211,7 +227,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const y = doc.y;
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
const afterLabel = doc.y;
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.6 });
doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: lg(0.6) });
doc.y = Math.max(doc.y, afterLabel) + 2;
}
leftEnd = doc.y;
@@ -224,12 +240,12 @@ function renderBody(r: ResumeData): Promise<Buffer> {
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);
gapDown(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);
gapDown(0.3);
}
}
if (r.projects.length) {
@@ -239,8 +255,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd);
end = doc.y;
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
const range = doc.bufferedPageRange();
pages = range.count;
if (range.count > 1) {
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
@@ -257,9 +275,29 @@ function renderBody(r: ResumeData): Promise<Buffer> {
return done;
}
/** The page's lowest writing line, in points from the top. */
const LIMIT = 792 - PAGE.bottom;
/**
* A one-page resume that ends well above the footer is laid out again with
* wider gaps, up to twice as wide, so the page reads as full; the widest
* spread that still fits on one page wins. Longer resumes are left as they are.
*/
export async function fitBody(r: ResumeData): Promise<Body> {
const first = await renderBody(r);
if (first.pages > 1 || LIMIT - first.end < 24) return first;
let best = first;
for (let spread = 1.1; spread <= 2.001; spread += 0.1) {
const next = await renderBody(r, spread);
if (next.pages > 1 || next.end > LIMIT - 4) break;
best = next;
}
return best;
}
/** The resume laid over the letterhead: each page draws the letterhead, then the resume on top, both as vectors. */
export async function renderResumePdf(r: ResumeData): Promise<Buffer> {
const body = await PdfLib.load(await renderBody(r));
const body = await PdfLib.load((await fitBody(r)).pdf);
const out = await PdfLib.create();
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
out.setAuthor(r.name);
+9
View File
@@ -164,3 +164,12 @@ const CATEGORY_ICONS: Record<Category, string> = { Tabletop: "dice", Paper: "boo
export function lineIcon(line: Line): string {
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
}
/**
* The Store's own header: the Store, each line sold here (by its menu name),
* and Order request. Lines sold elsewhere stay on the Store page only.
*/
export function storeNav(st: StoreState, current: string): { links: { label: string; href: string }[]; current: string } {
const lines = st.lines.filter((l) => channelOf(l) === "here").map((l) => ({ label: l.data.menuName ?? l.title, href: `/store/${l.slug}` }));
return { links: [{ label: "Store", href: "/store" }, ...lines, { label: "Order request", href: "/store/order" }], current };
}