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
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"name": "Greg Kilmer",
"short_name": "Greg Kilmer",
"description": "Greg Kilmer: developer, maker, storyteller.",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#0E1522",
"background_color": "#0E1522",
"display": "standalone",
"start_url": "/"
}
@@ -1,9 +1,10 @@
---
/**
* Greg's portrait in a gold frame. No portrait file exists yet, so the
* star-chart art stands in; pass `src` once there is one.
* Greg's portrait in a gold frame, from the Page text note's `portrait`.
* Without one, or if it fails to load, the star-chart art stands in.
*/
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
@@ -20,6 +21,8 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
<figure class:list={["portrait", variant, className]}>
<StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" />
{variant === "panel" && <GiltCorners inset={4} size={30} />}
{variant === "hero" && <Star size={34} class="spark tl" />}
{variant === "hero" && <Star size={24} class="spark br" />}
{!src && <figcaption class="gk-visually-hidden">Portrait coming soon</figcaption>}
</figure>
@@ -38,6 +41,20 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
.hero :global(.art) {
border-radius: var(--gk-radius);
}
.hero :global(.photo) {
object-position: 50% 20%;
}
.hero :global(.spark) {
position: absolute;
}
.hero :global(.tl) {
left: -17px;
top: -17px;
}
.hero :global(.br) {
right: -12px;
bottom: -12px;
}
.panel {
width: 100%;
padding: 10px;
@@ -50,4 +67,7 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
width: 100%;
height: 320px;
}
.panel :global(.photo) {
object-position: 50% 25%;
}
</style>
@@ -1,9 +1,11 @@
---
/**
* The favorites constellation: round covers strung on a dotted gold line,
* each linking to its row in the list.
* each linking to its row in the list. A favorite without a cover gets
* star-chart art in its circle.
*/
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { mediaSrc } from "../../lib/site.ts";
import type { Item } from "../../lib/library.ts";
@@ -35,7 +37,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
items.map((item, i) => (
<li style={`--lift:${lift[i % lift.length]}px`}>
<a href={`#${item.slug}`}>
<img src={mediaSrc(item.data.cover)} alt="" width="80" height="80" decoding="async" />
<StarChartArt class="orb" ratio="1 / 1" seal="zodiac" src={mediaSrc(item.data.cover)} />
<span>{item.title}</span>
</a>
</li>
@@ -104,18 +106,32 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
color: var(--gk-text);
text-decoration: none;
}
img {
a :global(.orb) {
flex: none;
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--gk-midnight);
box-shadow:
0 0 0 2px var(--gk-gold),
0 0 22px rgba(201, 162, 74, 0.45);
transition: box-shadow 0.2s;
}
a:hover img {
a :global(.orb .mat) {
display: none;
}
a :global(.orb .seal) {
width: 54px;
height: 54px;
opacity: 0.8;
}
a :global(.orb .photo) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
a:hover :global(.orb) {
box-shadow:
0 0 0 2px var(--gk-gold-bright),
0 0 30px rgba(201, 162, 74, 0.7);
@@ -137,7 +153,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
li {
padding-top: 0;
}
img {
a :global(.orb) {
width: 64px;
height: 64px;
}
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import StatusChip from "../StatusChip.astro";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
import { gamePlayers, gamePlayTime, type Game } from "../../lib/library.ts";
import { gamePlayers, gamePlayTime, minutesOf, playersOf, type Game } from "../../lib/library.ts";
interface Props {
game: Game;
@@ -24,6 +24,8 @@ const plays = d.howItPlays ?? [];
const status = site.statusFor(game);
// Players and play time sync from BoardGameGeek; nothing shows until they have.
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
const seats = playersOf(game);
const hasPost = site.backlinks(game, { type: "post" }).length > 0;
---
<li
id={game.slug}
@@ -34,6 +36,9 @@ const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
data-f={d.favorite ? "1" : undefined}
data-lp={d.lastPlayed}
data-c={d.created}
data-pl={seats?.join("-")}
data-t={minutesOf(game)}
data-b={hasPost ? "1" : undefined}
hidden={hidden}
>
<Card as="div" class="game-card">
@@ -1,5 +1,8 @@
---
/** The closing band on a world page: a line, a note and one or two buttons. */
/**
* The closing band on a world page: a full-width strip with a line, a note and
* one or two buttons, like the one on Work.
*/
interface Props {
title: string;
note?: string;
@@ -7,25 +10,32 @@ interface Props {
const { title, note } = Astro.props;
---
<aside class="cta" aria-label={title}>
<div class="words">
<p class="t">{title}</p>
{note && <p class="n">{note}</p>}
<div class="inner">
<div class="words">
<p class="t">{title}</p>
{note && <p class="n">{note}</p>}
</div>
<div class="btns"><slot /></div>
</div>
<div class="btns"><slot /></div>
</aside>
<style>
.cta {
margin-inline: calc(50% - 50vw);
margin-top: 48px;
padding: 32px 36px;
background: var(--gk-midnight);
border-top: 1px solid var(--gk-lines);
border-bottom: 1px solid var(--gk-lines);
}
.inner {
max-width: var(--gk-page-max);
margin: 0 auto;
padding: 36px var(--gk-gutter);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.words {
display: flex;
@@ -50,8 +60,8 @@ const { title, note } = Astro.props;
gap: 12px;
}
@media (max-width: 640px) {
.cta {
padding: 24px 20px;
.inner {
padding-block: 28px;
}
}
</style>
+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 };
}
+47 -6
View File
@@ -7,9 +7,9 @@
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Icon from "@gk/ui/components/Icon.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import SectionHeading from "@gk/ui/components/SectionHeading.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import PageHero from "../components/PageHero.astro";
import DoorLink from "../components/home/DoorLink.astro";
@@ -17,7 +17,8 @@ import Portrait from "../components/home/Portrait.astro";
import { splitIntro, viewStatus } from "../lib/home.ts";
import { brandIcon } from "../lib/site.ts";
import { site } from "../lib/page.ts";
import { formatDate } from "../lib/site.ts";
import { formatDate, mediaSrc } from "../lib/site.ts";
import scorpius from "@gk/ui/assets/motifs/scorpius.svg?url";
const s = await site();
const text = s.pageText("/about");
@@ -25,6 +26,7 @@ const settings = s.settings();
const { intro, sections } = splitIntro(text?.body ?? "");
const introHtml = s.render(intro);
const facts = text?.data.quickFacts ?? [];
const photos = text?.data.photos ?? [];
const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st));
const nowUpdated = now.map((v) => v.date).sort().pop();
const socials = settings?.socials ?? [];
@@ -43,7 +45,7 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
{introHtml ? <blockquote class="iam" set:html={introHtml} /> : <blockquote class="iam"><p>[PLACEHOLDER]</p></blockquote>}
</div>
<div class="intro-portrait">
<Portrait variant="panel" seal="astrolabe" />
<Portrait variant="panel" seal="astrolabe" src={mediaSrc(text?.data.portrait)} alt="Greg, a candid portrait" />
</div>
</section>
@@ -60,6 +62,18 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
</section>
))
}
{
photos.length > 0 && (
<div class="photos">
{photos.map((ph) => (
<figure class="photo">
<StarChartArt seal="astrolabe" ratio="4 / 3" src={mediaSrc(ph.file)} alt={ph.alt} class="photo-art" />
{ph.caption && <figcaption>{ph.caption}</figcaption>}
</figure>
))}
</div>
)
}
</div>
<aside aria-label="Quick facts" class="facts">
@@ -127,7 +141,7 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
Two wings of the same house
</SectionHeading>
<div class="wing-wrap">
<div class="wing-seal" aria-hidden="true"><Seal name="astrolabe" size={360} /></div>
<div class="wing-stars" aria-hidden="true"><img src={scorpius} alt="" width="1100" height="400" /></div>
<div class="wing-row">
<div class="wing featured">
<GiltCorners inset={6} size={30} />
@@ -226,6 +240,27 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
margin-bottom: 0;
}
.photos {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.photo {
flex: 1 1 240px;
min-width: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.photo :global(.photo-art) {
border: 1px solid rgba(201, 162, 74, 0.55);
}
.photo figcaption {
font-size: 12px;
color: var(--gk-muted);
}
.facts {
flex: 1 1 280px;
max-width: 340px;
@@ -361,7 +396,8 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
position: relative;
isolation: isolate;
}
.wing-seal {
/* Scorpius, from catalog positions, showing through the 70% opaque wings. */
.wing-stars {
position: absolute;
inset: -70px 0;
z-index: -1;
@@ -369,9 +405,14 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0.12;
opacity: 0.55;
overflow: hidden;
}
.wing-stars img {
width: 1100px;
max-width: none;
height: 400px;
}
.wing-row {
display: flex;
flex-wrap: wrap;
+2 -1
View File
@@ -17,6 +17,7 @@ import NowCard from "../components/home/NowCard.astro";
import Portrait from "../components/home/Portrait.astro";
import { currentRole, viewStatus, worldPhoto } from "../lib/home.ts";
import { site } from "../lib/page.ts";
import { mediaSrc } from "../lib/site.ts";
const s = await site();
const settings = s.settings();
@@ -50,7 +51,7 @@ const listFormat = new Intl.ListFormat("en", { style: "long", type: "conjunction
{intro ? <div class="lede" set:html={intro} /> : <p class="lede">[PLACEHOLDER]</p>}
</div>
<div class="portrait-wrap">
<Portrait variant="hero" seal="transmutation-star-paths" />
<Portrait variant="hero" seal="transmutation-star-paths" src={mediaSrc(text?.data.portrait)} alt={name} />
</div>
</div>
+4 -8
View File
@@ -4,7 +4,6 @@
* constellation, then the list grouped Now / Up next / Finished, filtered by
* medium, state, favorites and blog posts.
*/
import Divider from "@gk/ui/components/Divider.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import SearchField from "@gk/ui/components/SearchField.astro";
import Constellation from "../../components/library/Constellation.astro";
@@ -45,10 +44,10 @@ const typeHref = (key?: string) => {
const FIRST = 12;
---
<PageShell title="Library" family="library" current="library" description="What Greg is reading, watching and playing now, what's next, and what stuck with him.">
<Fragment slot="hero"><LibraryHero current="library" lede={lede} /></Fragment>
<Constellation items={constellation(items)} />
<div class="divide"><Divider seed="library-list" /></div>
<Fragment slot="hero">
<LibraryHero current="library" lede={lede} />
<Constellation items={constellation(items)} />
</Fragment>
<FilterLayout action="/library" formId="lib-filters">
<Fragment slot="rail">
@@ -88,9 +87,6 @@ const FIRST = 12;
</PageShell>
<style>
.divide {
padding: 24px 0 4px;
}
.types {
display: flex;
flex-direction: column;
+133 -44
View File
@@ -6,7 +6,6 @@
* counts, play times and box art sync from BoardGameGeek (a cover set in the
* note wins); no filter for them yet.
*/
import Divider from "@gk/ui/components/Divider.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
@@ -18,7 +17,7 @@ import FilterLayout from "../../components/library/FilterLayout.astro";
import GameCard from "../../components/library/GameCard.astro";
import LibraryHero from "../../components/library/LibraryHero.astro";
import RailGroup from "../../components/library/RailGroup.astro";
import { gameShows, onTheTable, playStyles, searchText, shelfFilter, sortGames, sorts, type Game } from "../../lib/library.ts";
import { gameShows, minutesOf, onTheTable, playerOptions, playersOf, playStyles, searchText, shelfFilter, sortGames, sorts, timeOptions, type Game } from "../../lib/library.ts";
import { site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.ts";
@@ -31,7 +30,16 @@ const owned = all.filter((g) => g.data.owned).length;
const lede = s.pageText("/library/shelf")?.data.lede ?? "My board game collection. Filter by how it plays, sort by what hit the table last.";
const text = (g: Game) => searchText([g.title, g.data.blurb, ...(g.data.howItPlays ?? [])]);
const visible = new Set(games.filter((g) => gameShows(g, f, text(g))).map((g) => g.slug));
const hasPost = (g: Game) => s.backlinks(g, { type: "post" }).length > 0;
const visible = new Set(games.filter((g) => gameShows(g, f, text(g), hasPost(g))).map((g) => g.slug));
// Players and play time come from BoardGameGeek; their pills wait for a sync.
const synced = { players: all.some((g) => playersOf(g)), time: all.some((g) => minutesOf(g) !== undefined) };
const anyPost = all.some(hasPost);
/** Games shown before "Show all", while no filter narrows the list. */
const FIRST = 12;
const narrowed = !!(f.q || f.plays.size || f.want || !f.owned || f.favorites || f.blog || f.players || f.time);
const folded = !narrowed && visible.size > FIRST;
const firstShown = new Set(games.filter((g) => visible.has(g.slug)).slice(0, FIRST).map((g) => g.slug));
// On the table: board-game statuses, then the most recent play not already listed.
const table: { label: string; game: Game; note?: string; live: boolean }[] = onTheTable(s).map(({ status, game }) => ({
@@ -50,43 +58,42 @@ const sortHref = (key: string) => {
};
---
<PageShell title="The Shelf" family="shelf" current="library" description="Greg's board game collection: what is on the table, how each one plays, and what hit the table last.">
<Fragment slot="hero"><LibraryHero current="shelf" lede={lede} /></Fragment>
<section aria-labelledby="sh-table" class="top">
<div class="count gk-featured">
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
<GiltCorners />
<p class="count-text"><span class="num">{owned}</span><span>{owned === 1 ? "game" : "games"} on the shelf</span></p>
</div>
<div class="table">
<div class="kick">
<h2 id="sh-table"><Star size={14} />On the table</h2>
<TickScale width={150} height={10} count={30} />
<Fragment slot="hero">
<LibraryHero current="shelf" lede={lede} />
<section aria-labelledby="sh-table" class="top">
<div class="count gk-featured">
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
<GiltCorners />
<p class="count-text"><span class="num">{owned}</span><span>{owned === 1 ? "game" : "games"} on the shelf</span></p>
</div>
{
table.length > 0 ? (
<ul class="table-list">
{table.map((t) => (
<li>
<a href={`#${t.game.slug}`} class:list={["gk-card", "table-card", { live: t.live }]}>
<StarChartArt class="table-art" ratio="1 / 1" seal="tria-prima" src={mediaSrc(t.game.data.cover)} />
<span class="table-body">
<span class="table-label">{t.label}</span>
<span class="table-title">{t.game.title}</span>
{t.note && <span class="table-note">{t.note}</span>}
</span>
</a>
</li>
))}
</ul>
) : (
<p class="quiet">Nothing on the table right now.</p>
)
}
</div>
</section>
<div class="divide"><Divider seed="shelf-list" /></div>
<div class="table">
<div class="kick">
<h2 id="sh-table"><Star size={14} />On the table</h2>
<TickScale width={150} height={10} count={30} />
</div>
{
table.length > 0 ? (
<ul class="table-list">
{table.map((t) => (
<li>
<a href={`#${t.game.slug}`} class:list={["gk-card", "table-card", { live: t.live }]}>
<StarChartArt class="table-art" ratio="1 / 1" seal="tria-prima" src={mediaSrc(t.game.data.cover)} />
<span class="table-body">
<span class="table-label">{t.label}</span>
<span class="table-title">{t.game.title}</span>
{t.note && <span class="table-note">{t.note}</span>}
</span>
</a>
</li>
))}
</ul>
) : (
<p class="quiet">Nothing on the table right now.</p>
)
}
</div>
</section>
</Fragment>
<FilterLayout action="/library/shelf" formId="shelf-filters">
<Fragment slot="rail">
@@ -99,11 +106,29 @@ const sortHref = (key: string) => {
</RailGroup>
)
}
{
synced.players && (
<RailGroup legend="Players" pills>
<label class="gk-filter pill"><input type="radio" name="players" value="" checked={!f.players} class="sr" /><span class="gk-fs star"><Star size={9} /></span>Any</label>
{playerOptions.map((o) => <label class="gk-filter pill"><input type="radio" name="players" value={o.key} checked={f.players === o.key} class="sr" /><span class="gk-fs star"><Star size={9} /></span>{o.label}</label>)}
</RailGroup>
)
}
{
synced.time && (
<RailGroup legend="Play time" pills>
<label class="gk-filter pill"><input type="radio" name="time" value="" checked={!f.time} class="sr" /><span class="gk-fs star"><Star size={9} /></span>Any</label>
{timeOptions.map((o) => <label class="gk-filter pill"><input type="radio" name="time" value={o.key} checked={f.time === o.key} class="sr" /><span class="gk-fs star"><Star size={9} /></span>{o.label}</label>)}
</RailGroup>
)
}
<RailGroup legend="Show">
<label class="check"><input type="checkbox" name="owned" value="1" checked={f.owned} />Owned</label>
<label class="check"><input type="checkbox" name="want" value="1" checked={f.want} />Want to play</label>
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
{anyPost && <label class="check"><input type="checkbox" name="blog" value="1" checked={f.blog} />Has a blog post</label>}
</RailGroup>
{(synced.players || synced.time) && <p class="rail-note">Player counts and play times come from BoardGameGeek.</p>}
</Fragment>
<section aria-label="Games" class="games">
@@ -115,8 +140,9 @@ const sortHref = (key: string) => {
</div>
</div>
<ul class="grid" data-grid>
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug)} />)}
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug) || (folded && !firstShown.has(g.slug))} />)}
</ul>
<button type="button" class="gk-text more" data-more hidden={!folded}>Show all {visible.size}</button>
<p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
</section>
</FilterLayout>
@@ -252,8 +278,36 @@ const sortHref = (key: string) => {
font-size: 13px;
color: var(--gk-muted);
}
.divide {
padding: 24px 0 4px;
.rail-note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--gk-muted);
}
.sr {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.pill {
position: relative;
}
.pill:has(:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.pill:has(:checked) .star {
display: inline-flex;
}
.pill:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
.more {
align-self: center;
}
.games {
display: flex;
@@ -322,7 +376,13 @@ const sortHref = (key: string) => {
played: desc("lp"),
az: (a, b) => title(a).localeCompare(title(b)),
added: desc("c"),
time: (a, b) => Number(a.dataset.t || Infinity) - Number(b.dataset.t || Infinity) || title(a).localeCompare(title(b)),
};
const FIRST = 12;
const more = document.querySelector<HTMLButtonElement>("[data-more]");
const players: Record<string, [number, number]> = { "1": [1, 1], "2": [2, 2], "3-4": [3, 4], "5+": [5, Infinity] };
const times: Record<string, [number, number]> = { short: [0, 29], mid: [30, 60], long: [61, Infinity] };
let expanded = false;
const apply = () => {
const data = new FormData(form);
@@ -331,16 +391,29 @@ const sortHref = (key: string) => {
const owned = data.has("owned");
const want = data.has("want");
const fav = data.has("fav");
const blog = data.has("blog");
const seat = players[String(data.get("players") ?? "")];
const time = times[String(data.get("time") ?? "")];
const narrowed = !!(q || plays.length || want || !owned || fav || blog || seat || time);
let n = 0;
for (const c of cards) {
for (const c of [...grid.children] as HTMLElement[]) {
const d = c.dataset;
const [lo, hi] = (d.pl ?? "").split("-").map(Number);
const t = d.t ? Number(d.t) : NaN;
const ok =
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
(!fav || !!d.f) &&
(!blog || !!d.b) &&
(!seat || (!!d.pl && lo! <= seat[1] && hi! >= seat[0])) &&
(!time || (t >= time[0] && t <= time[1])) &&
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
(!q || text.get(c)!.includes(q));
c.hidden = !ok;
if (ok) n++;
c.hidden = !ok || (!narrowed && !expanded && n > FIRST);
}
if (more) {
more.hidden = narrowed || expanded || n <= FIRST;
more.textContent = `Show all ${n}`;
}
if (count) count.textContent = String(n);
if (noun) noun.textContent = n === 1 ? "game" : "games";
@@ -350,8 +423,24 @@ const sortHref = (key: string) => {
const sort = (key: string) => {
sortInput.value = key;
grid.append(...[...cards].sort(orders[key] ?? orders.played));
apply();
};
more?.addEventListener("click", () => {
expanded = true;
apply();
});
// Opening an anchor to a folded-away card unfolds the grid first.
const reveal = () => {
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (target && target.hidden && cards.includes(target)) {
expanded = true;
apply();
target.scrollIntoView();
}
};
addEventListener("hashchange", reveal);
reveal();
form.addEventListener("input", apply);
form.addEventListener("submit", (e) => {
e.preventDefault();
+1 -1
View File
@@ -38,7 +38,7 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c
<TickScale width={120} count={24} />
</span>
<Button href="/resume.pdf" download="Greg-Kilmer-Resume.pdf">
<Icon name="arrow-right" size={18} />Download PDF
<Icon name="download" size={18} />Download PDF
</Button>
{r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>}
<Button variant="secondary" href="/inquiries?type=job">Start an inquiry</Button>
+2 -2
View File
@@ -17,7 +17,7 @@ import Swatches from "../../components/store/Swatches.astro";
import Terms from "../../components/store/Terms.astro";
import { site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.ts";
import { canOrder, channelOf, hasCapacity, optionGroups, priceSummary, shopLink, storeState } from "../../lib/store.ts";
import { canOrder, channelOf, hasCapacity, optionGroups, priceSummary, shopLink, storeNav, storeState } from "../../lib/store.ts";
const s = await site();
const slug = Astro.params.slug ?? "";
@@ -40,7 +40,7 @@ const groups = optionGroups(line);
const body = s.render(line.body);
const terms = [...(d.terms ?? []), ...st.terms];
---
<PageShell title={line.title} family="store" current="store" description={d.summary}>
<PageShell title={line.title} family="store" current="store" store={storeNav(st, `/store/${line.slug}`)} description={d.summary}>
<div class="crumb-row">
<Breadcrumb here={d.category} />
</div>
+2 -2
View File
@@ -19,7 +19,7 @@ import Swatches from "../../components/store/Swatches.astro";
import Terms from "../../components/store/Terms.astro";
import { site } from "../../lib/page.ts";
import { formatDate } from "../../lib/site.ts";
import { channelOf, optionGroups, storeState } from "../../lib/store.ts";
import { channelOf, optionGroups, storeNav, storeState } from "../../lib/store.ts";
const s = await site();
const st = storeState(s);
@@ -38,7 +38,7 @@ const colorStrips = selling.flatMap((l) =>
.filter((x) => x.opts.length),
);
---
<PageShell title="Store" family="store" current="store" description={st.settings?.capacityNote ?? "Things I make, in small batches."}>
<PageShell title="Store" family="store" current="store" store={storeNav(st, "/store")} description={st.settings?.capacityNote ?? "Things I make, in small batches."}>
<Fragment slot="hero">
<div class="hero">
<div class="hero-main">
+2 -2
View File
@@ -14,7 +14,7 @@ import LineStatus from "../../components/store/LineStatus.astro";
import OrderForm from "../../components/store/OrderForm.astro";
import { site } from "../../lib/page.ts";
import { formatDate } from "../../lib/site.ts";
import { canOrder, channelOf, storeState } from "../../lib/store.ts";
import { canOrder, channelOf, storeNav, storeState } from "../../lib/store.ts";
import { errorMessages, parseDraft, parseErrorCode } from "../../lib/forms.ts";
import { turnstileSiteKey } from "../../lib/forms-server.ts";
@@ -35,7 +35,7 @@ const shown = orderable.length ? orderable : selling;
const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround).filter((t): t is string => !!t))];
const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens ${formatDate(c.reopens)}.`);
---
<PageShell title="Order request" family="store" current="store" description="Send an order request for something from the Store.">
<PageShell title="Order request" family="store" current="store" store={storeNav(st, "/store/order")} description="Send an order request for something from the Store.">
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
<section class="intro" aria-labelledby="order-h1">
<Breadcrumb here="Order request" />
@@ -13,7 +13,7 @@ import TickScale from "@gk/ui/components/TickScale.astro";
import Breadcrumb from "../../../components/store/Breadcrumb.astro";
import { parseOrderSent } from "../../../lib/forms.ts";
import { site } from "../../../lib/page.ts";
import { storeState } from "../../../lib/store.ts";
import { storeNav, storeState } from "../../../lib/store.ts";
const sent = parseOrderSent(Astro.cookies.get("gk-order")?.value);
if (sent) Astro.cookies.delete("gk-order", { path: "/" });
@@ -21,8 +21,9 @@ Astro.response.headers.set("Cache-Control", "private, no-store");
const s = await site().catch(() => null);
const replyTime = s?.settings()?.replyTime ?? "a few days";
const line = sent?.lineSlug && s ? storeState(s).lines.find((l) => l.slug === sent.lineSlug) : undefined;
const turnaround = line && s ? storeState(s).category(line.data.category).turnaround : undefined;
const st = s ? storeState(s) : undefined;
const line = sent?.lineSlug ? st?.lines.find((l) => l.slug === sent.lineSlug) : undefined;
const turnaround = line ? st?.category(line.data.category).turnaround : undefined;
const rows = [
...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
...(sent?.rows ?? []),
@@ -35,7 +36,7 @@ const steps = [
{ title: "I start once you say yes", text: turnaround ?? "Nothing is made until we agree." },
];
---
<PageShell title="Order request sent" family="store" current="store" description="Your order request is on its way.">
<PageShell title="Order request sent" family="store" current="store" store={st ? storeNav(st, "/store/order") : undefined} description="Your order request is on its way.">
<meta slot="head" name="robots" content="noindex" />
<section class="intro" aria-labelledby="sent-h1">
<Breadcrumb here="Request sent" />
+1 -1
View File
@@ -269,7 +269,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 16px;
margin: 0;
padding: 0;
+30 -1
View File
@@ -1,6 +1,7 @@
---
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
@@ -52,6 +53,16 @@ const nowItems: NowItem[] = s
};
});
// Kinds of make, from their tags (the sword rack tag is the Workshop's own):
// pills over the grid once there are two or more to choose between.
const kindsOf = (m: Note<"make">) => (m.data.tags ?? []).filter((t) => t.toLowerCase() !== "sword rack");
const kinds = [...new Set(makes.flatMap(kindsOf))].sort((a, b) => a.localeCompare(b));
const untagged = makes.some((m) => kindsOf(m).length === 0);
const kindPills =
kinds.length + (untagged ? 1 : 0) >= 2
? [{ label: "All", value: "", active: true }, ...kinds.map((k) => ({ label: k, value: k.toLowerCase() })), ...(untagged ? [{ label: "Other", value: "-" }] : [])]
: [];
const meta = [plural(makes.length, "make"), logs.length > 0 && plural(logs.length, "build log")].filter(Boolean).join(" · ");
const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).cover);
---
@@ -98,6 +109,7 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
}
<WorldSection id="cr-makes" title="Makes" intro={makes.length > 1 ? "Newest first." : undefined}>
{kindPills.length > 0 && <div slot="action" class="kinds" data-kinds hidden><FilterGroup label="Kind of make" items={kindPills} /></div>}
{
makes.length > 0 ? (
<ul class="makes">
@@ -105,7 +117,7 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
const photos = makePhotos(m);
const log = s.backlinks(m, { type: "post" })[0];
return (
<li id={m.slug} class="make">
<li id={m.slug} class="make" data-k={kindsOf(m).map((k) => k.toLowerCase()).join("|") || "-"}>
<StarChartArt seal={artSeal} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
{photos.length > 1 && (
<span class="m-more">
@@ -170,6 +182,23 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
</CtaBand>
</PageShell>
<script>
// The kind pills filter the makes grid; without a script every make shows.
const box = document.querySelector<HTMLElement>("[data-kinds]");
if (box) {
const pills = [...box.querySelectorAll<HTMLButtonElement>("button[data-value]")];
const makes = [...document.querySelectorAll<HTMLElement>(".makes > li[data-k]")];
box.hidden = false;
for (const b of pills) {
b.addEventListener("click", () => {
const v = b.dataset.value ?? "";
pills.forEach((p) => p.setAttribute("aria-pressed", String(p === b)));
for (const m of makes) m.hidden = !!v && !(m.dataset.k ?? "").split("|").includes(v);
});
}
}
</script>
<style>
.crumb {
font-size: 11px;
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { fitBody } from "../src/lib/resume-pdf.ts";
import type { ResumeData } from "../src/lib/resume.ts";
const role = (n: number) =>
({
path: `Resume/Role ${n}.md`,
title: `Role ${n}`,
data: { type: "experience", title: `Role ${n}`, publish: true, company: "Example Co", role: `Role ${n}`, start: "2019-03", bullets: ["Built a thing that did something useful for people every day.", "Led a small team."] },
}) as unknown as ResumeData["roles"][number];
const base: ResumeData = {
name: "Greg Kilmer",
headline: "Team Lead",
summary: "A short summary.",
stats: [],
roles: [role(1)],
earlier: [],
projects: [],
socials: [],
updated: "2026-10-12",
};
describe("resume PDF fit", () => {
it("opens a short resume up to fill its one page", async () => {
const medium = { ...base, roles: Array.from({ length: 6 }, (_, i) => role(i)) };
const fitted = await fitBody(medium);
expect(fitted.pages).toBe(1);
expect(fitted.end).toBeLessThanOrEqual(792 - 70);
// At normal spacing six short roles end well above the middle of the page.
expect(fitted.end).toBeGreaterThan(560);
});
it("leaves a resume that runs past one page alone", async () => {
const long = await fitBody({ ...base, roles: Array.from({ length: 12 }, (_, i) => role(i)) });
expect(long.pages).toBeGreaterThan(1);
});
});