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
+13 -1
View File
@@ -92,7 +92,8 @@ personal Greg Kilmer letterhead, the version without Patiun LLC
branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so branding letterheads; re-copy it when that letterhead changes) with pdf-lib, which keeps both as vectors. It reads top to bottom so
applicant tracking systems take it in order, aims for one page (roles marked 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 `earlier` print as a short list; skills share a row with education and
projects), uses the brand TTFs in projects), and when a one-page resume ends well above the footer it is laid
out again with wider gaps (up to twice as wide) so the page reads as full. It 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 and Selected projects come from the `/resume` Page text note's The highlights row and Selected projects come from the `/resume` Page text note's
`stats` and `projects`. Greg's Resume Interview answers live in `stats` and `projects`. Greg's Resume Interview answers live in
@@ -111,6 +112,17 @@ The highlights row and Selected projects come from the `/resume` Page text note'
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` | | Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` | | Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
## Filters on world pages
- **Shelf**: How it plays, Players, Play time, Owned, Want to play, Favorites
and Has a blog post, plus four sorts. Players and Play time read the
BoardGameGeek sync and only appear once some game has those values. With no
filter narrowing the list, the first 12 games show and "Show all" opens the
rest.
- **Craft pages**: when a craft's makes carry two or more `tags` (Dice, Rings,
and so on; `sword rack` does not count), pills above the grid filter them.
Makes without a tag fall under Other.
## Forms ## Forms
The Inquiries form posts to `POST /api/inquiries` and the order form to The Inquiries form posts to `POST /api/inquiries` and the order form to
+6
View File
@@ -399,6 +399,7 @@ export const productLine = def({
channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }), channel: z.enum(["here", "Etsy", "Patiun.com", "coming soon"]).meta({ title: "Where to buy" }),
channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(), channelUrl: z.url().meta({ title: "Shop link", description: "For Etsy or Patiun.com lines." }).optional(),
summary: line({ title: "Summary" }), summary: line({ title: "Summary" }),
menuName: line({ title: "Menu name", description: "A short name for the Store menu, like Proxy cards." }).optional(),
icon: line({ title: "Icon" }).optional(), icon: line({ title: "Icon" }).optional(),
order: z.number().int().meta({ title: "Order" }).optional(), order: z.number().int().meta({ title: "Order" }).optional(),
slots: z slots: z
@@ -501,6 +502,11 @@ export const page = def({
schema: note("page", { schema: note("page", {
page: line({ title: "Page", description: "The page address this text belongs to, like /about." }), page: line({ title: "Page", description: "The page address this text belongs to, like /about." }),
lede: line({ title: "Lede", description: "The one line under the page title." }).optional(), lede: line({ title: "Lede", description: "The one line under the page title." }).optional(),
portrait: image({ title: "Portrait", description: "Greg's photo on the landing and About pages." }).optional(),
photos: z
.array(z.object({ file: image({ title: "Image" }), alt: line({ title: "Alt text" }), caption: line({ title: "Caption" }).optional() }))
.meta({ title: "Photos", description: "A row of captioned photos, like Kashi and the tea on About." })
.optional(),
quickFacts: z quickFacts: z
.array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) })) .array(z.object({ icon: line({ title: "Icon" }), text: line({ title: "Text" }) }))
.meta({ title: "Quick facts" }) .meta({ title: "Quick facts" })
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Download"><path d="M12 4v11M7 10.5l5 5 5-5M5 19.5h14"/></svg>

After

Width:  |  Height:  |  Size: 239 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 400"><line x1="565.0" y1="34.9" x2="578.5" y2="43.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="591.7" y1="55.4" x2="605.3" y2="72.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="582.8" y1="56.8" x2="567.6" y2="96.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="559.7" y1="112.7" x2="557.7" y2="116.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="552.6" y1="132.8" x2="552.2" y2="140.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="553.1" y1="158.4" x2="561.7" y2="216.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="567.7" y1="233.2" x2="580.0" y2="253.6" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="588.7" y1="269.4" x2="603.1" y2="298.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="601.1" y1="312.7" x2="584.4" y2="330.7" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="570.7" y1="342.2" x2="535.2" y2="365.1" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="519.1" y1="367.2" x2="497.5" y2="360.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="490.2" y1="348.3" x2="490.0" y2="349.4" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><line x1="492.8" y1="331.6" x2="495.0" y2="319.5" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-dasharray="2 5" stroke-linecap="round"/><line x1="505.2" y1="307.9" x2="495.2" y2="311.0" stroke="#2BC2B7" stroke-width="1.2" opacity="0.8" stroke-linecap="round"/><polygon points="557.4,23.0 558.5,28.9 564.4,30.0 558.5,31.1 557.4,37.0 556.3,31.1 550.4,30.0 556.3,28.9" fill="#9A70F5"/><polygon points="586.1,41.4 587.2,47.3 593.1,48.4 587.2,49.6 586.1,55.4 584.9,49.6 579.1,48.4 584.9,47.3" fill="#9A70F5"/><circle cx="611.0" cy="79.1" r="2.4" fill="#F4F2ED"/><circle cx="564.3" cy="104.9" r="2.4" fill="#F4F2ED"/><polygon points="553.1,110.8 555.2,121.7 566.1,123.8 555.2,125.9 553.1,136.8 551.0,125.9 540.1,123.8 551.0,121.7" fill="#C9A24A"/><circle cx="551.8" cy="149.5" r="2.4" fill="#F4F2ED"/><polygon points="563.0,218.5 564.2,224.4 570.0,225.5 564.2,226.7 563.0,232.5 561.9,226.7 556.0,225.5 561.9,224.4" fill="#9A70F5"/><circle cx="584.6" cy="261.3" r="2.4" fill="#F4F2ED"/><circle cx="607.2" cy="306.1" r="2.4" fill="#F4F2ED"/><circle cx="578.3" cy="337.3" r="2.4" fill="#F4F2ED"/><polygon points="527.7,363.0 528.8,368.9 534.7,370.0 528.8,371.1 527.7,377.0 526.6,371.1 520.7,370.0 526.6,368.9" fill="#9A70F5"/><circle cx="489.0" cy="357.2" r="2.4" fill="#F4F2ED"/><circle cx="491.3" cy="340.5" r="2.4" fill="#F4F2ED"/><circle cx="503.8" cy="308.3" r="2.4" fill="#F4F2ED"/><polygon points="496.6,303.6 497.7,309.5 503.6,310.6 497.7,311.7 496.6,317.6 495.5,311.7 489.6,310.6 495.5,309.5" fill="#9A70F5"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -1,14 +1,21 @@
--- ---
/** Sparse, static specks behind a page; about one in five gold. */ /**
* Static specks behind a page; about one in five gold. Two tiles of different
* sizes repeat across the page, so long pages are as starry as short ones and
* the repeat is hard to spot.
*/
import { dust, seedOf } from "../lib/ornaments.ts"; import { dust, seedOf } from "../lib/ornaments.ts";
interface Props { interface Props {
count?: number;
seed?: string; seed?: string;
} }
const { count = 48, seed = Astro.url.pathname } = Astro.props; const { seed = Astro.url.pathname } = Astro.props;
const base = seedOf(seed);
--- ---
<div class="gk-dust" aria-hidden="true" style={`background:${dust(count, seedOf(seed))}`}></div> <div class="gk-dust" aria-hidden="true">
<div class="layer near" style={`background-image:${dust(26, base)}`}></div>
<div class="layer far" style={`background-image:${dust(30, base + 7919)}`}></div>
</div>
<style> <style>
.gk-dust { .gk-dust {
@@ -16,4 +23,16 @@ const { count = 48, seed = Astro.url.pathname } = Astro.props;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
} }
.layer {
position: absolute;
inset: 0;
background-repeat: repeat;
}
.near {
background-size: 520px 520px;
}
.far {
background-size: 790px 790px;
background-position: 213px 157px;
}
</style> </style>
+14 -3
View File
@@ -12,7 +12,7 @@ import ConstellationDust from "./ConstellationDust.astro";
import EdgeSeals from "./EdgeSeals.astro"; import EdgeSeals from "./EdgeSeals.astro";
import SiteFooter from "./SiteFooter.astro"; import SiteFooter from "./SiteFooter.astro";
import SiteHeader from "./SiteHeader.astro"; import SiteHeader from "./SiteHeader.astro";
import type { NavKey } from "./nav.ts"; import type { NavKey, StoreNav } from "./nav.ts";
import type { PageFamily, SealName } from "./seals.ts"; import type { PageFamily, SealName } from "./seals.ts";
interface Props { interface Props {
@@ -22,10 +22,12 @@ interface Props {
/** A craft page's own seal, overriding the family's. */ /** A craft page's own seal, overriding the family's. */
seal?: SealName; seal?: SealName;
current?: NavKey; current?: NavKey;
/** The Store's own header, on Store pages. */
store?: StoreNav;
/** Leave the edge seals off, for a page led by a photo. */ /** Leave the edge seals off, for a page led by a photo. */
noSeals?: boolean; noSeals?: boolean;
} }
const { title, description, family = "landing", seal, current, noSeals } = Astro.props; const { title, description, family = "landing", seal, current, store, noSeals } = Astro.props;
const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`; const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
--- ---
<!doctype html> <!doctype html>
@@ -35,6 +37,10 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0E1522" /> <meta name="theme-color" content="#0E1522" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>{fullTitle}</title> <title>{fullTitle}</title>
{description && <meta name="description" content={description} />} {description && <meta name="description" content={description} />}
<slot name="head" /> <slot name="head" />
@@ -44,7 +50,7 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
<div class="gk-page"> <div class="gk-page">
<ConstellationDust /> <ConstellationDust />
{!noSeals && <EdgeSeals family={family} seal={seal} />} {!noSeals && <EdgeSeals family={family} seal={seal} />}
<SiteHeader current={current}><slot name="hero" /></SiteHeader> <SiteHeader current={current} store={store} hero={Astro.slots.has("hero")}><slot name="hero" /></SiteHeader>
<main id="main" class="gk-wrap gk-main"> <main id="main" class="gk-wrap gk-main">
<slot /> <slot />
</main> </main>
@@ -58,6 +64,11 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
position: relative; position: relative;
min-height: 100vh; min-height: 100vh;
overflow-x: clip; overflow-x: clip;
/* The page's nebula: violet from the top right, teal from the bottom left. */
background:
radial-gradient(ellipse at 100% 0%, rgba(106, 47, 224, 0.28), transparent 50%),
radial-gradient(ellipse at 0% 100%, rgba(20, 163, 154, 0.2), transparent 55%),
var(--gk-sky);
} }
.gk-main { .gk-main {
position: relative; position: relative;
+49 -5
View File
@@ -2,25 +2,47 @@
/** /**
* The site header: nebula and filament banner behind the primary nav, an * The site header: nebula and filament banner behind the primary nav, an
* optional page hero (the `hero` slot), and the header divider. The filament * optional page hero (the `hero` slot), and the header divider. The filament
* banner and divider appear here only, never elsewhere on a page. * banner and divider appear here only, never elsewhere on a page. Store pages
* pass `store` and get the Store's own header: Store.GregKilmer.com, its
* product lines, Order request and a way back to the main site.
*/ */
import monogram from "../assets/brand/gk-monogram.svg?url"; import monogram from "../assets/brand/gk-monogram.svg?url";
import Divider from "./Divider.astro"; import Divider from "./Divider.astro";
import Star from "./Star.astro"; import Star from "./Star.astro";
import { main, tail, world, type NavKey } from "./nav.ts"; import { main, tail, world, type NavKey, type StoreNav } from "./nav.ts";
import { filamentBanner } from "./seals.ts"; import { filamentBanner } from "./seals.ts";
interface Props { interface Props {
/** Which nav entry the page belongs to. */ /** Which nav entry the page belongs to. */
current?: NavKey; current?: NavKey;
store?: StoreNav;
/** Whether the page has a hero; PageShell always fills the slot, so it says. */
hero?: boolean;
} }
const { current } = Astro.props; const { current, store, hero = Astro.slots.has("default") } = Astro.props;
const inWorld = world.some((w) => w.key === current); const inWorld = world.some((w) => w.key === current);
const aria = (key: NavKey) => (key === current ? "page" : undefined); const aria = (key: NavKey) => (key === current ? "page" : undefined);
--- ---
<header class="gk-site-header"> <header class="gk-site-header">
<div class="nebula" aria-hidden="true"></div> <div class="nebula" aria-hidden="true"></div>
<img src={filamentBanner} alt="" aria-hidden="true" class="banner" /> <img src={filamentBanner} alt="" aria-hidden="true" class="banner" />
{
store ? (
<nav aria-label="Store" class="nav">
<a href="/store" class="brand" aria-current={store.current === "/store" ? "page" : undefined}>
<img src={monogram} alt="" width="72" height="72" class="mono" />
<span class="words">
<span class="name store-name">Store.GregKilmer.com</span>
<span class="store-tag">a Patiun LLC workshop</span>
</span>
</a>
<ul class="links">
{store.links.map((l) => <li><a href={l.href} aria-current={l.href === store.current ? "page" : undefined}>{l.label}</a></li>)}
<li class="sep" aria-hidden="true"><Star size={12} /></li>
<li><a href="/" class="dim">Back to GregKilmer.com</a></li>
</ul>
</nav>
) : (
<nav aria-label="Primary" class="nav"> <nav aria-label="Primary" class="nav">
<a href="/" class="brand" aria-current={aria("home")}> <a href="/" class="brand" aria-current={aria("home")}>
<img src={monogram} alt="" width="72" height="72" class="mono" /> <img src={monogram} alt="" width="72" height="72" class="mono" />
@@ -53,14 +75,16 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
{tail.map((l) => <li><a href={l.href} aria-current={aria(l.key)} class="dim">{l.label}</a></li>)} {tail.map((l) => <li><a href={l.href} aria-current={aria(l.key)} class="dim">{l.label}</a></li>)}
</ul> </ul>
</nav> </nav>
)
}
{ {
Astro.slots.has("default") && ( hero && (
<div class="gk-wrap hero"> <div class="gk-wrap hero">
<slot /> <slot />
</div> </div>
) )
} }
<div class="gk-wrap divider"><Divider /></div> {hero && <div class="gk-wrap divider"><Divider /></div>}
</header> </header>
<style> <style>
@@ -114,6 +138,17 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
.mono { .mono {
width: 72px; width: 72px;
height: 72px; height: 72px;
border-radius: 50%;
transition: filter 0.25s ease;
}
.brand:hover .mono,
.brand:focus-visible .mono {
filter: drop-shadow(0 0 6px rgba(216, 179, 90, 0.7)) drop-shadow(0 0 16px rgba(43, 194, 183, 0.45));
}
@media (prefers-reduced-motion: reduce) {
.mono {
transition: none;
}
} }
.words { .words {
display: flex; display: flex;
@@ -126,6 +161,15 @@ const aria = (key: NavKey) => (key === current ? "page" : undefined);
letter-spacing: 0.14em; letter-spacing: 0.14em;
font-size: 17px; font-size: 17px;
} }
.store-name {
font-variant: small-caps;
letter-spacing: 0.06em;
font-size: 19px;
}
.store-tag {
font-size: 12px;
color: var(--gk-soft);
}
.tag { .tag {
display: flex; display: flex;
align-items: center; align-items: center;
+6
View File
@@ -17,3 +17,9 @@ export const tail: { key: NavKey; label: string; href: string }[] = [
{ key: "blog", label: "Blog", href: "/blog" }, { key: "blog", label: "Blog", href: "/blog" },
{ key: "store", label: "Store", href: "/store" }, { key: "store", label: "Store", href: "/store" },
]; ];
/** The Store's own header: its pages in order, and which one is open. */
export interface StoreNav {
links: { label: string; href: string }[];
current?: string;
}
+5
View File
@@ -5,6 +5,11 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* A component's own display never un-hides what a script hid. */
[hidden] {
display: none !important;
}
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }
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 * Greg's portrait in a gold frame, from the Page text note's `portrait`.
* star-chart art stands in; pass `src` once there is one. * Without one, or if it fails to load, the star-chart art stands in.
*/ */
import GiltCorners from "@gk/ui/components/GiltCorners.astro"; 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 StarChartArt from "@gk/ui/components/StarChartArt.astro";
import type { SealName } from "@gk/ui/components/seals.ts"; 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]}> <figure class:list={["portrait", variant, className]}>
<StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" /> <StarChartArt seal={seal} ratio={variant === "hero" ? "188 / 228" : "auto"} src={src} alt={alt} class="art" />
{variant === "panel" && <GiltCorners inset={4} size={30} />} {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>} {!src && <figcaption class="gk-visually-hidden">Portrait coming soon</figcaption>}
</figure> </figure>
@@ -38,6 +41,20 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
.hero :global(.art) { .hero :global(.art) {
border-radius: var(--gk-radius); 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 { .panel {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
@@ -50,4 +67,7 @@ const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", clas
width: 100%; width: 100%;
height: 320px; height: 320px;
} }
.panel :global(.photo) {
object-position: 50% 25%;
}
</style> </style>
@@ -1,9 +1,11 @@
--- ---
/** /**
* The favorites constellation: round covers strung on a dotted gold line, * 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 Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro"; import TickScale from "@gk/ui/components/TickScale.astro";
import { mediaSrc } from "../../lib/site.ts"; import { mediaSrc } from "../../lib/site.ts";
import type { Item } from "../../lib/library.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) => ( items.map((item, i) => (
<li style={`--lift:${lift[i % lift.length]}px`}> <li style={`--lift:${lift[i % lift.length]}px`}>
<a href={`#${item.slug}`}> <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> <span>{item.title}</span>
</a> </a>
</li> </li>
@@ -104,18 +106,32 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
color: var(--gk-text); color: var(--gk-text);
text-decoration: none; text-decoration: none;
} }
img { a :global(.orb) {
flex: none;
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 50%;
object-fit: cover;
background: var(--gk-midnight);
box-shadow: box-shadow:
0 0 0 2px var(--gk-gold), 0 0 0 2px var(--gk-gold),
0 0 22px rgba(201, 162, 74, 0.45); 0 0 22px rgba(201, 162, 74, 0.45);
transition: box-shadow 0.2s; 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: box-shadow:
0 0 0 2px var(--gk-gold-bright), 0 0 0 2px var(--gk-gold-bright),
0 0 30px rgba(201, 162, 74, 0.7); 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 { li {
padding-top: 0; padding-top: 0;
} }
img { a :global(.orb) {
width: 64px; width: 64px;
height: 64px; height: 64px;
} }
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro"; import StatusPill from "@gk/ui/components/StatusPill.astro";
import StatusChip from "../StatusChip.astro"; import StatusChip from "../StatusChip.astro";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts"; 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 { interface Props {
game: Game; game: Game;
@@ -24,6 +24,8 @@ const plays = d.howItPlays ?? [];
const status = site.statusFor(game); const status = site.statusFor(game);
// Players and play time sync from BoardGameGeek; nothing shows until they have. // Players and play time sync from BoardGameGeek; nothing shows until they have.
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean); const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
const seats = playersOf(game);
const hasPost = site.backlinks(game, { type: "post" }).length > 0;
--- ---
<li <li
id={game.slug} id={game.slug}
@@ -34,6 +36,9 @@ const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
data-f={d.favorite ? "1" : undefined} data-f={d.favorite ? "1" : undefined}
data-lp={d.lastPlayed} data-lp={d.lastPlayed}
data-c={d.created} data-c={d.created}
data-pl={seats?.join("-")}
data-t={minutesOf(game)}
data-b={hasPost ? "1" : undefined}
hidden={hidden} hidden={hidden}
> >
<Card as="div" class="game-card"> <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 { interface Props {
title: string; title: string;
note?: string; note?: string;
@@ -7,25 +10,32 @@ interface Props {
const { title, note } = Astro.props; const { title, note } = Astro.props;
--- ---
<aside class="cta" aria-label={title}> <aside class="cta" aria-label={title}>
<div class="inner">
<div class="words"> <div class="words">
<p class="t">{title}</p> <p class="t">{title}</p>
{note && <p class="n">{note}</p>} {note && <p class="n">{note}</p>}
</div> </div>
<div class="btns"><slot /></div> <div class="btns"><slot /></div>
</div>
</aside> </aside>
<style> <style>
.cta { .cta {
margin-inline: calc(50% - 50vw);
margin-top: 48px; 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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
} }
.words { .words {
display: flex; display: flex;
@@ -50,8 +60,8 @@ const { title, note } = Astro.props;
gap: 12px; gap: 12px;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.cta { .inner {
padding: 24px 20px; padding-block: 28px;
} }
} }
</style> </style>
+57 -2
View File
@@ -128,6 +128,7 @@ export const sorts = [
{ key: "played", label: "Last played" }, { key: "played", label: "Last played" },
{ key: "az", label: "A to Z" }, { key: "az", label: "A to Z" },
{ key: "added", label: "Recently added" }, { key: "added", label: "Recently added" },
{ key: "time", label: "Play time" },
] as const; ] as const;
export type SortKey = (typeof sorts)[number]["key"]; 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), played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
az, az,
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b), 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]); return [...games].sort(cmp[sort]);
} }
@@ -148,9 +151,29 @@ export interface ShelfFilter {
owned: boolean; owned: boolean;
want: boolean; want: boolean;
favorites: boolean; favorites: boolean;
blog: boolean;
players: PlayersKey | "";
time: TimeKey | "";
sort: SortKey; 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 { export function shelfFilter(params: URLSearchParams): ShelfFilter {
const submitted = params.has("f"); const submitted = params.has("f");
return { return {
@@ -159,19 +182,27 @@ export function shelfFilter(params: URLSearchParams): ShelfFilter {
owned: submitted ? params.get("owned") === "1" : true, owned: submitted ? params.get("owned") === "1" : true,
want: params.get("want") === "1", want: params.get("want") === "1",
favorites: params.get("fav") === "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", 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 * 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 owned = !!g.data.owned;
const want = !!g.data.wantToPlay; const want = !!g.data.wantToPlay;
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false; if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
if (f.favorites && !g.data.favorite) 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; if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
return !f.q || text.includes(f.q); return !f.q || text.includes(f.q);
} }
@@ -185,6 +216,30 @@ interface GameSynced {
maxPlayTime?: number; 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 || "")); 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. */ /** "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; 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({ const doc = new PDFDocument({
size: PAGE.size, size: PAGE.size,
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right }, 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 full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.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) => { let pages = 1;
doc.on("end", () => resolve(Buffer.concat(chunks))); let end = 0;
const done = new Promise<Body>((resolve, reject) => {
doc.on("end", () => resolve({ pdf: Buffer.concat(chunks), pages, end }));
doc.on("error", reject); doc.on("error", reject);
}); });
@@ -73,7 +89,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const section = (title: string, c: Col = full) => { const section = (title: string, c: Col = full) => {
room(50); room(50);
doc.moveDown(0.4); gapDown(0.4);
const y = doc.y; const y = doc.y;
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w }); 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; const ruleY = doc.y + 1;
@@ -85,16 +101,16 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const indent = 11; const indent = 11;
const w = c.w - indent; const w = c.w - indent;
doc.font("body").fontSize(TEXT); 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; const y = doc.y;
diamond(c.x + 3.5, y + 5.5, 2.1); diamond(c.x + 3.5, y + 5.5, 2.1);
if (lead) { if (lead) {
doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, 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: 1 }); doc.font("body").text(` ${text}`, { lineGap: lg(1) });
} else { } 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. */ /** 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 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 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); doc.font("body").fontSize(8).fillColor(MUTED);
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false }); 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(" · "); const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
doc.text(links, full.x, y, { width: full.w - 120 }); doc.text(links, full.x, y, { width: full.w - 120 });
} }
doc.moveDown(0.6); gapDown(0.6);
if (r.summary) { 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) { if (r.stats.length) {
doc.moveDown(0.6); gapDown(0.6);
const cols = Math.min(r.stats.length, 4); const cols = Math.min(r.stats.length, 4);
const colW = full.w / cols; const colW = full.w / cols;
const top = doc.y; const top = doc.y;
@@ -156,14 +172,14 @@ function renderBody(r: ResumeData): Promise<Buffer> {
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.5); if (i > 0) gapDown(0.5);
titled(d.role, period(role), 10.5); titled(d.role, period(role), 10.5);
const at = [d.company, d.location].filter(Boolean).join(" · "); 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 }); doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w }); if (d.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. // 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 }); 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); for (const b of d.bullets ?? []) bullet(b);
doc.x = full.x; doc.x = full.x;
}); });
@@ -173,7 +189,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
section("Earlier"); section("Earlier");
r.earlier.forEach((role, i) => { r.earlier.forEach((role, i) => {
const d = role.data; const d = role.data;
if (i > 0) doc.moveDown(0.3); if (i > 0) gapDown(0.3);
titled(d.role, period(role), 9.5); 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 }); 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 }); 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"); doc.font("strong");
const lh = doc.heightOfString(g.label, { width: labelW }); const lh = doc.heightOfString(g.label, { width: labelW });
doc.font("body"); 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); }, 24);
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0); const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
doc.font("body"); doc.font("body");
const rightH = const rightH =
(entries ? 24 + entries * 26 : 0) + (entries ? 24 + entries * 26 : 0) +
(r.projects.length ? 24 : 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(); if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
const start = doc.y; const start = doc.y;
let leftEnd = start; let leftEnd = start;
@@ -211,7 +227,7 @@ function renderBody(r: ResumeData): Promise<Buffer> {
const y = doc.y; const y = doc.y;
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW }); doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
const afterLabel = doc.y; 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; doc.y = Math.max(doc.y, afterLabel) + 2;
} }
leftEnd = doc.y; leftEnd = doc.y;
@@ -224,12 +240,12 @@ function renderBody(r: ResumeData): Promise<Buffer> {
for (const dg of edu?.degrees ?? []) { for (const dg of edu?.degrees ?? []) {
titled(dg.credential, dg.period, 9.5, rightCol); 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.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 ?? []) { for (const c of edu?.certifications ?? []) {
titled(c.name, c.year ?? "", 9.5, rightCol); 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 }); 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) { if (r.projects.length) {
@@ -239,8 +255,10 @@ function renderBody(r: ResumeData): Promise<Buffer> {
doc.x = full.x; doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd); 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. // Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
const range = doc.bufferedPageRange(); const range = doc.bufferedPageRange();
pages = range.count;
if (range.count > 1) { if (range.count > 1) {
for (let i = range.start; i < range.start + range.count; i++) { for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i); doc.switchToPage(i);
@@ -257,9 +275,29 @@ function renderBody(r: ResumeData): Promise<Buffer> {
return done; 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. */ /** 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> { 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(); const out = await PdfLib.create();
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true }); out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
out.setAuthor(r.name); 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 { export function lineIcon(line: Line): string {
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]); 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 GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Icon from "@gk/ui/components/Icon.astro"; import Icon from "@gk/ui/components/Icon.astro";
import PageShell from "@gk/ui/components/PageShell.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 SectionHeading from "@gk/ui/components/SectionHeading.astro";
import Star from "@gk/ui/components/Star.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 TickScale from "@gk/ui/components/TickScale.astro";
import PageHero from "../components/PageHero.astro"; import PageHero from "../components/PageHero.astro";
import DoorLink from "../components/home/DoorLink.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 { splitIntro, viewStatus } from "../lib/home.ts";
import { brandIcon } from "../lib/site.ts"; import { brandIcon } from "../lib/site.ts";
import { site } from "../lib/page.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 s = await site();
const text = s.pageText("/about"); const text = s.pageText("/about");
@@ -25,6 +26,7 @@ const settings = s.settings();
const { intro, sections } = splitIntro(text?.body ?? ""); const { intro, sections } = splitIntro(text?.body ?? "");
const introHtml = s.render(intro); const introHtml = s.render(intro);
const facts = text?.data.quickFacts ?? []; const facts = text?.data.quickFacts ?? [];
const photos = text?.data.photos ?? [];
const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st)); const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st));
const nowUpdated = now.map((v) => v.date).sort().pop(); const nowUpdated = now.map((v) => v.date).sort().pop();
const socials = settings?.socials ?? []; 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>} {introHtml ? <blockquote class="iam" set:html={introHtml} /> : <blockquote class="iam"><p>[PLACEHOLDER]</p></blockquote>}
</div> </div>
<div class="intro-portrait"> <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> </div>
</section> </section>
@@ -60,6 +62,18 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
</section> </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> </div>
<aside aria-label="Quick facts" class="facts"> <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 Two wings of the same house
</SectionHeading> </SectionHeading>
<div class="wing-wrap"> <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-row">
<div class="wing featured"> <div class="wing featured">
<GiltCorners inset={6} size={30} /> <GiltCorners inset={6} size={30} />
@@ -226,6 +240,27 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
margin-bottom: 0; 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 { .facts {
flex: 1 1 280px; flex: 1 1 280px;
max-width: 340px; max-width: 340px;
@@ -361,7 +396,8 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
position: relative; position: relative;
isolation: isolate; isolation: isolate;
} }
.wing-seal { /* Scorpius, from catalog positions, showing through the 70% opaque wings. */
.wing-stars {
position: absolute; position: absolute;
inset: -70px 0; inset: -70px 0;
z-index: -1; z-index: -1;
@@ -369,9 +405,14 @@ const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefca
align-items: center; align-items: center;
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
opacity: 0.12; opacity: 0.55;
overflow: hidden; overflow: hidden;
} }
.wing-stars img {
width: 1100px;
max-width: none;
height: 400px;
}
.wing-row { .wing-row {
display: flex; display: flex;
flex-wrap: wrap; 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 Portrait from "../components/home/Portrait.astro";
import { currentRole, viewStatus, worldPhoto } from "../lib/home.ts"; import { currentRole, viewStatus, worldPhoto } from "../lib/home.ts";
import { site } from "../lib/page.ts"; import { site } from "../lib/page.ts";
import { mediaSrc } from "../lib/site.ts";
const s = await site(); const s = await site();
const settings = s.settings(); 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>} {intro ? <div class="lede" set:html={intro} /> : <p class="lede">[PLACEHOLDER]</p>}
</div> </div>
<div class="portrait-wrap"> <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>
</div> </div>
+3 -7
View File
@@ -4,7 +4,6 @@
* constellation, then the list grouped Now / Up next / Finished, filtered by * constellation, then the list grouped Now / Up next / Finished, filtered by
* medium, state, favorites and blog posts. * medium, state, favorites and blog posts.
*/ */
import Divider from "@gk/ui/components/Divider.astro";
import PageShell from "@gk/ui/components/PageShell.astro"; import PageShell from "@gk/ui/components/PageShell.astro";
import SearchField from "@gk/ui/components/SearchField.astro"; import SearchField from "@gk/ui/components/SearchField.astro";
import Constellation from "../../components/library/Constellation.astro"; import Constellation from "../../components/library/Constellation.astro";
@@ -45,10 +44,10 @@ const typeHref = (key?: string) => {
const FIRST = 12; 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."> <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> <Fragment slot="hero">
<LibraryHero current="library" lede={lede} />
<Constellation items={constellation(items)} /> <Constellation items={constellation(items)} />
<div class="divide"><Divider seed="library-list" /></div> </Fragment>
<FilterLayout action="/library" formId="lib-filters"> <FilterLayout action="/library" formId="lib-filters">
<Fragment slot="rail"> <Fragment slot="rail">
@@ -88,9 +87,6 @@ const FIRST = 12;
</PageShell> </PageShell>
<style> <style>
.divide {
padding: 24px 0 4px;
}
.types { .types {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+101 -12
View File
@@ -6,7 +6,6 @@
* counts, play times and box art sync from BoardGameGeek (a cover set in the * counts, play times and box art sync from BoardGameGeek (a cover set in the
* note wins); no filter for them yet. * note wins); no filter for them yet.
*/ */
import Divider from "@gk/ui/components/Divider.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro"; import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro"; import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import PageShell from "@gk/ui/components/PageShell.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 GameCard from "../../components/library/GameCard.astro";
import LibraryHero from "../../components/library/LibraryHero.astro"; import LibraryHero from "../../components/library/LibraryHero.astro";
import RailGroup from "../../components/library/RailGroup.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 { site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.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 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 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. // 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 }) => ({ const table: { label: string; game: Game; note?: string; live: boolean }[] = onTheTable(s).map(({ status, game }) => ({
@@ -50,8 +58,8 @@ 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."> <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> <Fragment slot="hero">
<LibraryHero current="shelf" lede={lede} />
<section aria-labelledby="sh-table" class="top"> <section aria-labelledby="sh-table" class="top">
<div class="count gk-featured"> <div class="count gk-featured">
<StarChartArt class="count-art" ratio="auto" seal="tria-prima" /> <StarChartArt class="count-art" ratio="auto" seal="tria-prima" />
@@ -85,8 +93,7 @@ const sortHref = (key: string) => {
} }
</div> </div>
</section> </section>
</Fragment>
<div class="divide"><Divider seed="shelf-list" /></div>
<FilterLayout action="/library/shelf" formId="shelf-filters"> <FilterLayout action="/library/shelf" formId="shelf-filters">
<Fragment slot="rail"> <Fragment slot="rail">
@@ -99,11 +106,29 @@ const sortHref = (key: string) => {
</RailGroup> </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"> <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="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="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> <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> </RailGroup>
{(synced.players || synced.time) && <p class="rail-note">Player counts and play times come from BoardGameGeek.</p>}
</Fragment> </Fragment>
<section aria-label="Games" class="games"> <section aria-label="Games" class="games">
@@ -115,8 +140,9 @@ const sortHref = (key: string) => {
</div> </div>
</div> </div>
<ul class="grid" data-grid> <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> </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> <p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
</section> </section>
</FilterLayout> </FilterLayout>
@@ -252,8 +278,36 @@ const sortHref = (key: string) => {
font-size: 13px; font-size: 13px;
color: var(--gk-muted); color: var(--gk-muted);
} }
.divide { .rail-note {
padding: 24px 0 4px; 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 { .games {
display: flex; display: flex;
@@ -322,7 +376,13 @@ const sortHref = (key: string) => {
played: desc("lp"), played: desc("lp"),
az: (a, b) => title(a).localeCompare(title(b)), az: (a, b) => title(a).localeCompare(title(b)),
added: desc("c"), 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 apply = () => {
const data = new FormData(form); const data = new FormData(form);
@@ -331,16 +391,29 @@ const sortHref = (key: string) => {
const owned = data.has("owned"); const owned = data.has("owned");
const want = data.has("want"); const want = data.has("want");
const fav = data.has("fav"); 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; let n = 0;
for (const c of cards) { for (const c of [...grid.children] as HTMLElement[]) {
const d = c.dataset; const d = c.dataset;
const [lo, hi] = (d.pl ?? "").split("-").map(Number);
const t = d.t ? Number(d.t) : NaN;
const ok = const ok =
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) && (!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
(!fav || !!d.f) && (!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))) && (!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
(!q || text.get(c)!.includes(q)); (!q || text.get(c)!.includes(q));
c.hidden = !ok;
if (ok) n++; 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 (count) count.textContent = String(n);
if (noun) noun.textContent = n === 1 ? "game" : "games"; if (noun) noun.textContent = n === 1 ? "game" : "games";
@@ -350,8 +423,24 @@ const sortHref = (key: string) => {
const sort = (key: string) => { const sort = (key: string) => {
sortInput.value = key; sortInput.value = key;
grid.append(...[...cards].sort(orders[key] ?? orders.played)); 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("input", apply);
form.addEventListener("submit", (e) => { form.addEventListener("submit", (e) => {
e.preventDefault(); 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} /> <TickScale width={120} count={24} />
</span> </span>
<Button href="/resume.pdf" download="Greg-Kilmer-Resume.pdf"> <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> </Button>
{r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>} {r.updated && <span class="pdf-date">PDF · updated {formatDate(r.updated)}</span>}
<Button variant="secondary" href="/inquiries?type=job">Start an inquiry</Button> <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 Terms from "../../components/store/Terms.astro";
import { site } from "../../lib/page.ts"; import { site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.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 s = await site();
const slug = Astro.params.slug ?? ""; const slug = Astro.params.slug ?? "";
@@ -40,7 +40,7 @@ const groups = optionGroups(line);
const body = s.render(line.body); const body = s.render(line.body);
const terms = [...(d.terms ?? []), ...st.terms]; 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"> <div class="crumb-row">
<Breadcrumb here={d.category} /> <Breadcrumb here={d.category} />
</div> </div>
+2 -2
View File
@@ -19,7 +19,7 @@ import Swatches from "../../components/store/Swatches.astro";
import Terms from "../../components/store/Terms.astro"; import Terms from "../../components/store/Terms.astro";
import { site } from "../../lib/page.ts"; import { site } from "../../lib/page.ts";
import { formatDate } from "../../lib/site.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 s = await site();
const st = storeState(s); const st = storeState(s);
@@ -38,7 +38,7 @@ const colorStrips = selling.flatMap((l) =>
.filter((x) => x.opts.length), .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"> <Fragment slot="hero">
<div class="hero"> <div class="hero">
<div class="hero-main"> <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 OrderForm from "../../components/store/OrderForm.astro";
import { site } from "../../lib/page.ts"; import { site } from "../../lib/page.ts";
import { formatDate } from "../../lib/site.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 { errorMessages, parseDraft, parseErrorCode } from "../../lib/forms.ts";
import { turnstileSiteKey } from "../../lib/forms-server.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 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)}.`); 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>} {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"> <section class="intro" aria-labelledby="order-h1">
<Breadcrumb here="Order request" /> <Breadcrumb here="Order request" />
@@ -13,7 +13,7 @@ import TickScale from "@gk/ui/components/TickScale.astro";
import Breadcrumb from "../../../components/store/Breadcrumb.astro"; import Breadcrumb from "../../../components/store/Breadcrumb.astro";
import { parseOrderSent } from "../../../lib/forms.ts"; import { parseOrderSent } from "../../../lib/forms.ts";
import { site } from "../../../lib/page.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); const sent = parseOrderSent(Astro.cookies.get("gk-order")?.value);
if (sent) Astro.cookies.delete("gk-order", { path: "/" }); 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 s = await site().catch(() => null);
const replyTime = s?.settings()?.replyTime ?? "a few days"; const replyTime = s?.settings()?.replyTime ?? "a few days";
const line = sent?.lineSlug && s ? storeState(s).lines.find((l) => l.slug === sent.lineSlug) : undefined; const st = s ? storeState(s) : undefined;
const turnaround = line && s ? storeState(s).category(line.data.category).turnaround : 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 = [ const rows = [
...(sent?.line ? [{ label: "Line", value: sent.line }] : []), ...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
...(sent?.rows ?? []), ...(sent?.rows ?? []),
@@ -35,7 +36,7 @@ const steps = [
{ title: "I start once you say yes", text: turnaround ?? "Nothing is made until we agree." }, { 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" /> <meta slot="head" name="robots" content="noindex" />
<section class="intro" aria-labelledby="sent-h1"> <section class="intro" aria-labelledby="sent-h1">
<Breadcrumb here="Request sent" /> <Breadcrumb here="Request sent" />
+1 -1
View File
@@ -269,7 +269,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
.cards { .cards {
display: grid; 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; gap: 16px;
margin: 0; margin: 0;
padding: 0; padding: 0;
+30 -1
View File
@@ -1,6 +1,7 @@
--- ---
import Button from "@gk/ui/components/Button.astro"; import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.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 PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro"; import Seal from "@gk/ui/components/Seal.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.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 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); 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}> <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 ? ( makes.length > 0 ? (
<ul class="makes"> <ul class="makes">
@@ -105,7 +117,7 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
const photos = makePhotos(m); const photos = makePhotos(m);
const log = s.backlinks(m, { type: "post" })[0]; const log = s.backlinks(m, { type: "post" })[0];
return ( 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" /> <StarChartArt seal={artSeal} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
{photos.length > 1 && ( {photos.length > 1 && (
<span class="m-more"> <span class="m-more">
@@ -170,6 +182,23 @@ const postCover = (p: Note<"post">) => mediaSrc((p.data as { cover?: string }).c
</CtaBand> </CtaBand>
</PageShell> </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> <style>
.crumb { .crumb {
font-size: 11px; 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);
});
});
+12 -2
View File
@@ -78,8 +78,17 @@ export function build(legacyDir: string): BuildResult {
add({ type: "site-settings", publish: true, ...seed.site }); add({ type: "site-settings", publish: true, ...seed.site });
add({ type: "status-kinds", title: "Status kinds", publish: true, kinds: seed.statusKinds }); add({ type: "status-kinds", title: "Status kinds", publish: true, kinds: seed.statusKinds });
for (const p of seed.pages) { for (const p of seed.pages) {
const { body, source: _source, ...rest } = p; const { body, source: _source, portrait, photos, ...rest } = p as typeof p & { portrait?: string; photos?: { file: string; alt: string; caption?: string }[] };
add({ type: "page", created: today, ...rest }, body); add(
{
type: "page",
created: today,
...rest,
portrait: portrait ? attach(portrait, "Pages") : undefined,
photos: photos?.map((ph) => ({ ...ph, file: attach(ph.file, "Pages") })),
},
body,
);
} }
// ------------------------------------------------------------ Library: reading // ------------------------------------------------------------ Library: reading
@@ -340,6 +349,7 @@ export function build(legacyDir: string): BuildResult {
mode: "made to order", mode: "made to order",
channel: "here", channel: "here",
summary: p.summary, summary: p.summary,
menuName: ({ cards: "Proxy cards", deckBoxes: "Deck boxes" } as Record<string, string>)[p.slotKey],
icon: p.icon, icon: p.icon,
order: i + 1, order: i + 1,
// Greg (2026-10-11): orders stay closed until he opens them, so no slot shows as open. // Greg (2026-10-11): orders stay closed until he opens them, so no slot shows as open.
+6
View File
@@ -49,6 +49,7 @@ export const pages = [
title: "Landing", title: "Landing",
page: "/", page: "/",
publish: true, publish: true,
portrait: "/images/hero-portrait.jpg",
body: "I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico Inc., run D&D campaigns that span years, and fill my shelves with Brandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.", body: "I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico Inc., run D&D campaigns that span years, and fill my shelves with Brandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.",
source: "legacy/src/pages/index.astro", source: "legacy/src/pages/index.astro",
}, },
@@ -56,6 +57,11 @@ export const pages = [
title: "About me", title: "About me",
page: "/about", page: "/about",
publish: true, publish: true,
portrait: "/images/about-portrait.jpg",
photos: [
{ file: "/images/kashi_at_work.jpg", alt: "Kashi, a mini dachshund, at work with his dog dad", caption: "Kashi, on the job" },
{ file: "/images/tea-gaiwan.jpg", alt: "A tea gaiwan set on a mushroom towel", caption: "Loose-leaf tea, the other fuel" },
],
quickFacts: [ quickFacts: [
{ icon: "map-marker", text: "Philadelphia, PA" }, { icon: "map-marker", text: "Philadelphia, PA" },
{ icon: "briefcase", text: "BTP, Cloud, and Mobile App Lead at Implico Inc." }, { icon: "briefcase", text: "BTP, Cloud, and Mobile App Lead at Implico Inc." },
@@ -5,6 +5,7 @@ publish: false # Only notes set to true appear on the site.
created: "{{date:YYYY-MM-DD}}" # optional created: "{{date:YYYY-MM-DD}}" # optional
page: # The page address this text belongs to, like /about. page: # The page address this text belongs to, like /about.
lede: # optional. The one line under the page title. lede: # optional. The one line under the page title.
portrait: # optional. Greg's photo on the landing and About pages.
--- ---
<!-- The page's introduction. --> <!-- The page's introduction. -->
@@ -8,6 +8,7 @@ mode: # one of: made to order, in stock
channel: # one of: here, Etsy, Patiun.com, coming soon channel: # one of: here, Etsy, Patiun.com, coming soon
channelUrl: # optional. For Etsy or Patiun.com lines. channelUrl: # optional. For Etsy or Patiun.com lines.
summary: summary:
menuName: # optional. A short name for the Store menu, like Proxy cards.
icon: # optional icon: # optional
order: # optional order: # optional
slots: # optional. Made to order lines only. slots: # optional. Made to order lines only.