Phase 5: every page of the site, the resume PDF and RSS

Landing, About, Now, Resume (with a generated PDF), Work and case studies,
Inquiries and the sent page, Library and the Shelf, Workshop and craft pages,
Tabletop, Blog with topics and posts, RSS, the Store, product lines, the
order form and the 404, all rendered from the content service's notes.

Schema: experience previousRoles, skills stack core items, page stats,
make tags and deck favorite. Icon names from notes map to brand icons in one
place, and HTML comments are dropped when Markdown renders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 21:33:49 +00:00
parent 7025e95f89
commit 99ecdc2113
83 changed files with 11681 additions and 14 deletions
+26
View File
@@ -79,3 +79,29 @@ SITE_DIR=… DATA_DIR=… CONTENT_TOKENS="reader:<24+ chars>" npm run content
# the site # the site
CONTENT_URL=http://localhost:4310 CONTENT_TOKEN=<reader token> npm run site # http://localhost:4330 CONTENT_URL=http://localhost:4310 CONTENT_TOKEN=<reader token> npm run site # http://localhost:4330
``` ```
For a local preview of drafts, set `CONTENT_INCLUDE_DRAFTS=true` with an editor
or admin token. Never set it where the public can reach the site.
## The resume PDF
`/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume`
(`src/lib/resume.ts`), so the two never disagree. It is one plain column so
applicant tracking systems read it in order, uses the brand TTFs in
`src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change.
The highlights row comes from the `/resume` Page text note's `stats`.
## Where each page lives
| Page | File | Helpers |
| --- | --- | --- |
| Landing, About, Now | `pages/index.astro`, `about.astro`, `now.astro` | `lib/home.ts`, `components/home` |
| Resume and PDF | `pages/resume.astro`, `resume.pdf.ts` | `lib/resume.ts`, `lib/resume-pdf.ts` |
| Work, case studies | `pages/work/` | `lib/work.ts`, `components/work`, `components/blog/PostLayout` |
| Inquiries, sent, 404 | `pages/inquiries/`, `404.astro` | `lib/work.ts` |
| Library, Shelf | `pages/library/` | `lib/library.ts`, `components/library` |
| Workshop, crafts, Tabletop | `pages/workshop/`, `tabletop.astro` | `lib/world.ts`, `components/world` |
| Blog, topics, posts, RSS | `pages/blog/`, `rss.xml.ts` | `lib/blog.ts`, `components/blog` |
| Store, lines, order | `pages/store/` | `lib/store.ts`, `components/store` |
Forms post to `/api/inquiries` and `/api/orders`, which arrive in phase 6.
+186 -2
View File
@@ -1741,6 +1741,30 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oslojs/encoding": { "node_modules/@oslojs/encoding": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2102,6 +2126,15 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.4", "version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@@ -2182,6 +2215,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -2625,6 +2668,26 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -2668,6 +2731,15 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2810,6 +2882,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3082,6 +3163,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
@@ -3513,6 +3600,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -3561,6 +3654,23 @@
"fontkitten": "^1.0.2" "fontkitten": "^1.0.2"
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkitten": { "node_modules/fontkitten": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz",
@@ -4231,6 +4341,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.3", "version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
@@ -4710,6 +4839,12 @@
"integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -4745,6 +4880,20 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/pdfkit": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"fflate": "^0.8.3",
"fontkit": "^2.0.4",
"linebreak": "^1.1.0",
"png-js": "^2.0.0"
}
},
"node_modules/piccolore": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -4778,6 +4927,14 @@
"node": ">=16.20.0" "node": ">=16.20.0"
} }
}, },
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.29", "version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
@@ -4980,6 +5137,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/retext-smartypants": { "node_modules/retext-smartypants": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
@@ -5522,7 +5685,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": { "node_modules/tsx": {
@@ -5638,6 +5800,26 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -6641,10 +6823,12 @@
"@gk/schema": "*", "@gk/schema": "*",
"@gk/ui": "*", "@gk/ui": "*",
"astro": "7.3.5", "astro": "7.3.5",
"marked": "18.0.14" "marked": "18.0.14",
"pdfkit": "^0.20.2"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "0.9.10", "@astrojs/check": "0.9.10",
"@types/pdfkit": "^0.17.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
} }
}, },
+27 -2
View File
@@ -193,6 +193,10 @@ export const make = def({
date: isoDate({ title: "Finished" }).optional(), date: isoDate({ title: "Finished" }).optional(),
photos: z.array(image({ title: "Photo" })).meta({ title: "Photos" }).optional(), photos: z.array(image({ title: "Photo" })).meta({ title: "Photos" }).optional(),
blurb: line({ title: "Blurb" }).optional(), blurb: line({ title: "Blurb" }).optional(),
tags: z
.array(line({ title: "Tag" }))
.meta({ title: "Tags", description: "Like sword rack, which puts the make on the Workshop's sword rack." })
.optional(),
}), }),
}); });
@@ -251,6 +255,7 @@ export const deck = def({
archidektId: z.number().int().meta({ title: "Archidekt deck id" }).optional(), archidektId: z.number().int().meta({ title: "Archidekt deck id" }).optional(),
listUrl: z.url().meta({ title: "Deck list link", description: "For games not on Archidekt." }).optional(), listUrl: z.url().meta({ title: "Deck list link", description: "For games not on Archidekt." }).optional(),
take: line({ title: "Your take", description: "One line on the deck." }).optional(), take: line({ title: "Your take", description: "One line on the deck." }).optional(),
favorite: z.boolean().meta({ title: "Favorite", description: "Shows first among the decks." }).optional(),
}), }),
}); });
@@ -265,7 +270,11 @@ export const experience = def({
body: "Optional longer notes; the resume uses the bullets.", body: "Optional longer notes; the resume uses the bullets.",
schema: note("experience", { schema: note("experience", {
company: line({ title: "Company" }), company: line({ title: "Company" }),
role: line({ title: "Role" }), role: line({ title: "Role", description: "The current or last title at this company." }),
previousRoles: strings({
title: "Earlier titles",
description: "Earlier titles at the same company, newest first, shown as Previously.",
}).optional(),
start: isoMonth({ title: "Start" }), start: isoMonth({ title: "Start" }),
end: isoMonth({ title: "End", description: "Leave out while it is your current role." }).optional(), end: isoMonth({ title: "End", description: "Leave out while it is your current role." }).optional(),
location: line({ title: "Location" }).optional(), location: line({ title: "Location" }).optional(),
@@ -286,7 +295,13 @@ export const skills = def({
core: strings({ title: "Core strengths" }), core: strings({ title: "Core strengths" }),
learning: strings({ title: "Learning now" }).optional(), learning: strings({ title: "Learning now" }).optional(),
stack: z stack: z
.array(z.object({ label: line({ title: "Group" }), items: strings({ title: "Items" }) })) .array(
z.object({
label: line({ title: "Group" }),
items: strings({ title: "Items" }),
core: strings({ title: "Core items", description: "Items from this group outlined as the core stack." }).optional(),
}),
)
.meta({ title: "Technical stack" }), .meta({ title: "Technical stack" }),
}), }),
}); });
@@ -476,6 +491,16 @@ export const page = def({
.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" })
.optional(), .optional(),
stats: z
.array(
z.object({
value: line({ title: "Value", description: "Like 1,000+." }),
label: line({ title: "Label", description: "Like Active users." }),
detail: line({ title: "Detail" }).optional(),
}),
)
.meta({ title: "Highlights", description: "Big numbers on the page, like the resume highlights. Only real, checkable numbers." })
.optional(),
}), }),
}); });
+2 -1
View File
@@ -11,7 +11,8 @@ export default defineConfig({
devToolbar: { enabled: false }, devToolbar: { enabled: false },
server: { host: true, port: Number(process.env.PORT ?? 4330) }, server: { host: true, port: Number(process.env.PORT ?? 4330) },
vite: { vite: {
ssr: { noExternal: ["@gk/ui"] }, // cookie is bundled so the server never picks up express's older copy hoisted to the root.
ssr: { noExternal: ["@gk/ui", "cookie"] },
// See packages/ui/astro.config.mjs: astro needs its own cookie, not express's. // See packages/ui/astro.config.mjs: astro needs its own cookie, not express's.
environments: { prerender: { resolve: { noExternal: ["cookie"] } } }, environments: { prerender: { resolve: { noExternal: ["cookie"] } } },
}, },
+3 -1
View File
@@ -15,10 +15,12 @@
"@gk/schema": "*", "@gk/schema": "*",
"@gk/ui": "*", "@gk/ui": "*",
"astro": "7.3.5", "astro": "7.3.5",
"marked": "18.0.14" "marked": "18.0.14",
"pdfkit": "^0.20.2"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "0.9.10", "@astrojs/check": "0.9.10",
"@types/pdfkit": "^0.17.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
} }
} }
Binary file not shown.
@@ -0,0 +1,10 @@
# Fonts
Subset to Latin and served as WOFF2 from the static TTFs in the brand source
(`branding/source/patiun-brand-source.zip`, `fonts/static`).
- **Cinzel**, copyright 2012 The Cinzel Project Authors (Natanael Gama).
- **Montserrat**, copyright 2011 The Montserrat Project Authors.
Both are licensed under the SIL Open Font License, Version 1.1:
https://openfontlicense.org/open-font-license-official-text/
@@ -0,0 +1,41 @@
---
/** The two-column body of the Blog and topic pages: the posts, then the sidebar. */
interface Props {
aside: string;
}
const { aside } = Astro.props;
---
<div class="cols">
<div class="main"><slot /></div>
<aside aria-label={aside} class="side"><slot name="aside" /></aside>
</div>
<style>
.cols {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
padding: 24px 0 48px;
}
.main {
flex: 3 1 620px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.side {
flex: 1 1 280px;
max-width: 340px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
@media (max-width: 1000px) {
.side {
max-width: none;
}
}
</style>
@@ -0,0 +1,75 @@
---
/** The Connected pages panel on a post: see connectedPages() in lib/blog.ts. */
import type { Connected } from "../../lib/blog.ts";
import PostThumb from "./PostThumb.astro";
import SidePanel from "./SidePanel.astro";
interface Props {
items: Connected[];
}
const { items } = Astro.props;
---
{
items.length > 0 && (
<SidePanel title="Connected pages" seal="layered">
<ul class="cp">
{items.map((i) => (
<li>
<a href={i.href}>
<PostThumb src={i.img} width={52} height={52} class="ct" />
<span class="w">
{i.kicker && <span class="k">{i.kicker}</span>}
<span class="t">{i.title}</span>
{i.sub && <span class="s">{i.sub}</span>}
</span>
</a>
</li>
))}
</ul>
</SidePanel>
)
}
<style>
.cp {
margin: 0;
padding: 0;
list-style: none;
}
.cp a {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 0 10px 3px;
border-top: 1px solid var(--gk-lines);
text-decoration: none;
}
.cp :global(.ct) {
outline-offset: 2px;
}
.w {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.k {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.t {
font-size: 14px;
font-weight: 600;
color: var(--gk-text);
}
.cp a:hover .t {
color: var(--gk-teal-bright);
}
.s {
font-size: 12px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,71 @@
---
/**
* A hero whose eyebrow is a breadcrumb ("Blog / Making"), for topic, post
* and case study pages. Extra content (the meta line) goes in the slot.
*/
interface Props {
crumbs: { label: string; href?: string }[];
title: string;
lede?: string;
/** Long titles (posts) wrap at 22 characters and run slightly smaller. */
long?: boolean;
}
const { crumbs, title, lede, long } = Astro.props;
---
<div class="hero">
<nav class="crumbs" aria-label="Breadcrumb">
{
crumbs.map((c, i) => (
<>
{i > 0 && <span aria-hidden="true"> / </span>}
{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}
</>
))
}
</nav>
<h1 class:list={["title", { long }]}>{title}</h1>
{lede && <p class="lede">{lede}</p>}
<slot />
</div>
<style>
.hero {
display: flex;
flex-direction: column;
gap: 12px;
}
.crumbs {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.crumbs a {
color: var(--gk-gold-bright);
text-decoration: none;
}
.crumbs a:hover {
text-decoration: underline;
}
.title {
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.08em;
line-height: 1.05;
overflow-wrap: anywhere;
}
.title.long {
font-size: clamp(30px, 5.5vw, 46px);
letter-spacing: 0.06em;
line-height: 1.1;
max-width: 22ch;
}
.lede {
margin: 0;
font-size: 17px;
color: var(--gk-soft);
max-width: 62ch;
line-height: 1.6;
}
</style>
@@ -0,0 +1,111 @@
---
/** A case study's facts: employer, role, period, key numbers and tech. */
import Chip from "@gk/ui/components/Chip.astro";
import Kicker from "@gk/ui/components/Kicker.astro";
import type { Post } from "../../lib/blog.ts";
interface Props {
post: Post;
}
const d = Astro.props.post.data;
const cells = [
{ label: "Employer", value: d.employer ?? "Personal project" },
d.role && { label: "Role", value: d.role },
d.period && { label: "Period", value: d.period },
].filter((c): c is { label: string; value: string } => !!c);
const facts = d.facts ?? [];
---
<section class="facts" aria-label="Project facts">
<dl class="grid">
{
cells.map((c) => (
<div class="cell">
<dt>{c.label}</dt>
<dd>{c.value}</dd>
</div>
))
}
{
facts.map((f) => (
<div class="cell num">
<dt>{f.label}</dt>
<dd>{f.value}</dd>
</div>
))
}
</dl>
{
d.tech && d.tech.length > 0 && (
<div class="tech">
<Kicker ticks={false}>Tech</Kicker>
<ul>
{d.tech.map((t) => (
<li>
<Chip core>{t}</Chip>
</li>
))}
</ul>
</div>
)
}
</section>
<style>
.facts {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 26px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.grid {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 16px 24px;
}
.cell {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 14px;
border-left: 1px solid rgba(201, 162, 74, 0.45);
}
dt {
font-size: 11px;
letter-spacing: 0.2em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
dd {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gk-text);
line-height: 1.4;
}
.num dd {
font-family: var(--gk-font-display);
font-size: 24px;
color: var(--gk-gold-bright);
}
.tech {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding-top: 16px;
border-top: 1px solid var(--gk-lines);
}
.tech ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>
@@ -0,0 +1,232 @@
---
/**
* The Blog's top slot: the featured or latest post in the one featured
* panel. The cover fills the left side when the post has one; otherwise the
* blog's seal art stands in.
*/
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import StarPath from "@gk/ui/components/StarPath.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { filamentBanner } from "@gk/ui/components/seals.ts";
import { dateLine, nowStatuses, pageLinks, type Post } from "../../lib/blog.ts";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
interface Props {
post: Post;
label: "Featured" | "Latest";
site: SiteIndex;
}
const { post, label, site: s } = Astro.props;
const href = s.href(post) ?? "#";
const cover = mediaSrc(post.data.cover);
const links = pageLinks(s, post);
const now = nowStatuses(s, post);
---
<article class="lead" aria-labelledby="lead-title">
<div class:list={["art", { photo: cover }]}>
{
cover ? (
<>
<img src={cover} alt={post.title} class="cover" onerror="this.remove()" />
<span class="shade" aria-hidden="true" />
<span class="mat" aria-hidden="true" />
</>
) : (
<>
<img src={filamentBanner} alt="" aria-hidden="true" class="banner" />
<StarPath strength="wild" steps={7} centerStar={false} class="path" seed={post.slug} />
<TickRing size={196} class="ring">
<Seal name="transmutation" size={150} class="seal" />
</TickRing>
</>
)
}
<span class="no" aria-hidden="true">
<TickScale width={150} height={10} count={30} />
<span>No. 01 · {label}</span>
</span>
</div>
<div class="text">
<span class="meta">
<Star size={12} />
<span class="kicker">{label} · {post.data.topic}</span>
<span class="date">{dateLine(post)}</span>
{now.map((st) => <StatusPill>Now · {st.data.kind}</StatusPill>)}
</span>
<h2 id="lead-title" class="title"><a href={href}>{post.title}</a></h2>
<span class="underline" aria-hidden="true"></span>
{post.data.summary && <p class="summary">{post.data.summary}</p>}
{links.length > 0 && <span class="chips">{links.map((l) => <Chip href={l.href}>{l.label}</Chip>)}</span>}
<Button href={href} arrow class="go">{post.data.postType === "build log" ? "Read the build log" : "Read the post"}</Button>
</div>
<GiltCorners />
</article>
<style>
.lead {
position: relative;
display: flex;
flex-wrap: wrap;
overflow: hidden;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
border-radius: var(--gk-radius);
box-shadow:
inset 0 0 0 4px var(--gk-midnight),
inset 0 0 0 5px rgba(201, 162, 74, 0.35);
}
.art {
flex: 1 1 440px;
min-height: 320px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.28), transparent 70%),
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(20, 163, 154, 0.18), transparent 70%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 40px),
var(--gk-sky);
}
.art.photo {
min-height: 340px;
background: var(--gk-sky);
}
.banner {
position: absolute;
width: 150%;
max-width: none;
opacity: 0.32;
pointer-events: none;
}
.art :global(.path) {
position: absolute;
left: -10%;
width: 120%;
top: 50%;
transform: translateY(-50%) rotate(-16deg);
opacity: 0.8;
}
.art :global(.seal) {
filter: drop-shadow(0 0 16px rgba(201, 162, 74, 0.35));
}
.cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.shade {
position: absolute;
inset: 0;
background:
linear-gradient(to top, rgba(14, 21, 34, 0.88), transparent 42%),
linear-gradient(to bottom, rgba(14, 21, 34, 0.45), transparent 30%);
}
.mat {
position: absolute;
inset: 14px;
border: 1px solid rgba(201, 162, 74, 0.5);
border-radius: 6px;
}
.no {
position: absolute;
left: 24px;
bottom: 20px;
display: flex;
flex-direction: column;
gap: 6px;
font-family: var(--gk-font-display);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--gk-gold-bright);
}
.text {
flex: 1 1 420px;
min-width: 0;
padding: 36px;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}
.kicker {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.date {
font-size: 12px;
color: var(--gk-muted);
}
.title {
margin: 0;
font-size: 32px;
line-height: 1.2;
letter-spacing: 0.03em;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-gold-bright);
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.summary {
margin: 4px 0 0;
font-size: 15px;
color: var(--gk-soft);
line-height: 1.6;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.text :global(.go) {
align-self: flex-start;
margin-top: 6px;
}
@media (max-width: 640px) {
.art {
flex-basis: 100%;
min-height: 240px;
}
.art.photo {
min-height: 240px;
}
.text {
padding: 26px 22px 30px;
}
.title {
font-size: 26px;
}
}
</style>
@@ -0,0 +1,462 @@
---
/**
* The all-dark reading layout shared by blog posts and case studies: hero
* with the meta line, the article panel (cover, body, tags), the sidebar (On
* this page, Connected pages), previous and next, related posts and a call
* to action. A case study passes its facts strip in the `facts` slot.
*/
import PageShell from "@gk/ui/components/PageShell.astro";
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import type { NavKey } from "@gk/ui/components/nav.ts";
import type { PageFamily } from "@gk/ui/components/seals.ts";
import { connectedPages, neighbours, nowStatuses, pageLinks, readMinutes, relatedPosts, withHeadings, type Post } from "../../lib/blog.ts";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
import ConnectedPages from "./ConnectedPages.astro";
import CrumbHero from "./CrumbHero.astro";
import PostList from "./PostList.astro";
import SidePanel from "./SidePanel.astro";
import StarHeading from "./StarHeading.astro";
interface Props {
post: Post;
site: SiteIndex;
family: PageFamily;
current: NavKey;
crumbs: { label: string; href?: string }[];
/** The newest-first list this post sits in, for previous and next. */
list: Post[];
/** The posts Related may draw from. */
pool: Post[];
/** Words for previous and next, like "post" or "case study". */
noun?: string;
}
const { post, site: s, family, current, crumbs, list, pool, noun = "post" } = Astro.props;
const d = post.data;
const body = post.body ?? "";
const { html, headings } = withHeadings(s.render(body));
const cover = mediaSrc(d.cover);
const links = pageLinks(s, post);
const now = nowStatuses(s, post);
const { previous, next } = neighbours(list, post);
let related = relatedPosts(s, post, pool);
let relatedText = "Posts that share pages with this one.";
if (!related.length) {
related = pool.filter((p) => p.path !== post.path && p.data.topic === d.topic).slice(0, 3);
relatedText = `More from ${d.topic}, newest first.`;
}
const connected = connectedPages(s, post);
const isCase = d.postType === "case study";
const making = d.topic === "Making";
---
<PageShell title={post.title} family={family} current={current} description={d.summary}>
<Fragment slot="head">
<meta property="og:type" content="article" />
<meta property="og:title" content={post.title} />
{d.summary && <meta property="og:description" content={d.summary} />}
{cover && <meta property="og:image" content={new URL(cover, Astro.site ?? Astro.url.origin).href} />}
<link rel="alternate" type="application/rss+xml" title="Greg Kilmer" href="/rss.xml" />
</Fragment>
<Fragment slot="hero">
<CrumbHero crumbs={crumbs} title={post.title} lede={d.summary} long>
<div class="meta">
<time datetime={d.date}>{formatDate(d.date)}</time>
<span aria-hidden="true">·</span>
<span>{readMinutes(body)} min read</span>
{(links.length > 0 || now.length > 0) && <span aria-hidden="true">·</span>}
{now.map((st) => <StatusPill>Now · {st.data.kind}</StatusPill>)}
{links.map((l) => <Chip href={l.href}>{l.label}</Chip>)}
</div>
</CrumbHero>
</Fragment>
{Astro.slots.has("facts") && <div class="facts"><slot name="facts" /></div>}
<div class="cols">
<article class="paper" aria-label={post.title}>
<GiltCorners />
{
cover && (
<figure class="cover">
<img src={cover} alt={post.title} onerror="this.parentElement.remove()" />
</figure>
)
}
{html ? <div class="prose" set:html={html} /> : <p class="prose-empty">[PLACEHOLDER]</p>}
{
d.tags && d.tags.length > 0 && (
<div class="tags">
<span class="tl">Tagged</span>
{d.tags.map((t) => <Chip>{t}</Chip>)}
</div>
)
}
</article>
{(headings.length > 1 || connected.length > 0) && (
<aside aria-label={`About this ${noun}`} class="side">
{
headings.length > 1 && (
<SidePanel title="On this page" seal="zodiac">
<ul class="toc">
{headings.map((h) => (
<li>
<a href={`#${h.id}`}>{h.text}</a>
</li>
))}
</ul>
</SidePanel>
)
}
<ConnectedPages items={connected} />
</aside>
)}
</div>
{
(previous || next) && (
<nav class="pn" aria-label={`Previous and next ${noun}`}>
{previous ? (
<a href={s.href(previous) ?? "#"} class="gk-card pc" rel="prev">
<span class="pk">Previous {noun}</span>
<span class="pt">{previous.title}</span>
</a>
) : (
<span class="spacer" />
)}
{next && (
<a href={s.href(next) ?? "#"} class="gk-card pc next" rel="next">
<span class="pk">Next {noun}</span>
<span class="pt">{next.title}</span>
</a>
)}
</nav>
)
}
{
related.length > 0 && (
<section class="more" aria-labelledby="keep-reading">
<div class="mh">
<StarHeading id="keep-reading">Keep reading</StarHeading>
<p class="ms">{relatedText}</p>
</div>
<PostList posts={related} site={s} />
</section>
)
}
<section class="cta" aria-labelledby="cta-title">
<div class="ci">
<div class="cw">
<h2 id="cta-title" class="ct">{isCase ? "Have a project like this?" : "Want something like this?"}</h2>
<p class="cs">
{isCase ? "Tell me about it in an inquiry, or see the rest of my work." : making ? "Some of it is in the Store. Anything else can start as an inquiry." : "Anything you want to ask or start can begin as an inquiry."}
</p>
</div>
<div class="cb">
{isCase ? (
<>
<Button href="/inquiries">Start an inquiry</Button>
<Button variant="secondary" href="/work">All case studies</Button>
</>
) : making ? (
<>
<Button href="/store">Visit the Store</Button>
<Button variant="secondary" href="/inquiries">Start an inquiry</Button>
</>
) : (
<>
<Button href="/inquiries">Start an inquiry</Button>
<Button variant="secondary" href="/blog">Back to the Blog</Button>
</>
)}
</div>
</div>
</section>
</PageShell>
<style>
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
font-size: 13px;
color: var(--gk-muted);
}
.facts {
padding-top: 36px;
}
.cols {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
padding-top: 36px;
}
.paper {
position: relative;
flex: 3 1 640px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
padding: 48px 56px;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
border-radius: var(--gk-radius);
box-shadow:
inset 0 0 0 4px var(--gk-midnight),
inset 0 0 0 5px rgba(201, 162, 74, 0.3);
}
.cover {
margin: 0;
}
.cover img {
display: block;
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 8px;
}
.prose,
.prose-empty {
display: flex;
flex-direction: column;
gap: 18px;
margin: 0;
font-size: 17px;
line-height: 1.8;
color: var(--gk-soft);
}
.prose > :global(*) {
margin: 0;
}
.prose :global(p),
.prose :global(li) {
max-width: 68ch;
}
.prose :global(h2) {
margin-top: 16px;
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
letter-spacing: 0.05em;
color: var(--gk-text);
scroll-margin-top: 24px;
}
.prose :global(h2)::before {
content: "";
flex: none;
width: 13px;
height: 13px;
background: var(--gk-gold);
clip-path: polygon(50% 0, 58% 42%, 100% 50%, 58% 58%, 50% 100%, 42% 58%, 0 50%, 42% 42%);
}
.prose :global(h3) {
margin-top: 8px;
font-size: 20px;
color: var(--gk-text);
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 22px;
}
.prose :global(blockquote) {
margin: 8px 0;
padding: 4px 0 4px 20px;
border-left: 3px solid var(--gk-gold);
font-family: var(--gk-font-display);
font-size: 22px;
line-height: 1.5;
color: var(--gk-text);
}
.prose :global(blockquote p) {
margin: 0;
}
.prose :global(img) {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
.prose :global(code) {
font-size: 0.9em;
padding: 1px 6px;
border-radius: 4px;
background: var(--gk-sky);
border: 1px solid var(--gk-lines);
}
.prose :global(pre) {
overflow-x: auto;
padding: 16px;
border-radius: 8px;
background: var(--gk-sky);
border: 1px solid var(--gk-lines);
font-size: 14px;
line-height: 1.6;
}
.prose :global(pre code) {
padding: 0;
border: 0;
background: none;
}
.prose :global(hr) {
width: 100%;
border: 0;
border-top: 1px solid var(--gk-lines);
}
.prose :global(table) {
border-collapse: collapse;
font-size: 15px;
display: block;
overflow-x: auto;
}
.prose :global(th),
.prose :global(td) {
padding: 8px 12px;
border-bottom: 1px solid var(--gk-lines);
text-align: left;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--gk-lines);
}
.tl {
align-self: center;
font-size: 13px;
color: var(--gk-muted);
}
.side {
flex: 1 1 260px;
max-width: 320px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
position: sticky;
top: 24px;
}
.toc {
margin: 0;
padding: 0;
list-style: none;
}
.toc a {
display: block;
padding: 6px 0 6px 12px;
border-left: 2px solid var(--gk-lines);
font-size: 14px;
color: var(--gk-muted);
text-decoration: none;
}
.toc a:hover,
.toc a:focus-visible {
color: var(--gk-text);
border-left-color: var(--gk-gold);
}
.pn {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 32px;
}
.pc,
.spacer {
flex: 1 1 300px;
}
.pc {
gap: 4px;
padding: 18px 20px;
}
.pc.next {
text-align: right;
}
.pk {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.pt {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 17px;
}
.more {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 32px;
}
.mh {
display: flex;
flex-direction: column;
gap: 6px;
}
.ms {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
.cta {
margin: 40px calc(50% - 50vw) 0;
background: var(--gk-midnight);
border-top: 1px solid var(--gk-lines);
border-bottom: 1px solid var(--gk-lines);
}
.ci {
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;
}
.cw {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct {
margin: 0;
font-size: 24px;
letter-spacing: 0.05em;
}
.cs {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
.cb {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 1000px) {
.side {
position: static;
max-width: none;
}
}
@media (max-width: 640px) {
.paper {
padding: 32px 22px;
}
.prose :global(h2) {
font-size: 22px;
}
.prose :global(blockquote) {
font-size: 19px;
}
}
</style>
@@ -0,0 +1,91 @@
---
/**
* Posts as fixed-height rows, or an empty state when there are none.
*/
import Button from "@gk/ui/components/Button.astro";
import Seal from "@gk/ui/components/Seal.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import type { Post } from "../../lib/blog.ts";
import type { SiteIndex } from "../../lib/site.ts";
import PostRow from "./PostRow.astro";
interface Props {
posts: Post[];
site: SiteIndex;
/** Kicker per post, e.g. the topic and its linked craft. */
kicker?: (p: Post) => string | undefined;
/** Empty state text. */
emptyTitle?: string;
emptyText?: string;
emptyAction?: { label: string; href: string };
as?: "h2" | "h3";
}
const { posts, site: s, kicker, emptyTitle = "Nothing written here yet", emptyText, emptyAction, as } = Astro.props;
---
{
posts.length ? (
<ul class="list">
{posts.map((p) => (
<PostRow post={p} site={s} kicker={kicker?.(p)} as={as} />
))}
</ul>
) : (
<div class="empty">
<TickRing size={88} medallion>
<Seal name="transmutation" size={60} />
</TickRing>
<div class="words">
<p class="t">{emptyTitle}</p>
{emptyText && <p class="d">{emptyText}</p>}
{emptyAction && (
<Button variant="secondary" size="compact" href={emptyAction.href} class="act">
{emptyAction.label}
</Button>
)}
</div>
</div>
)
}
<style>
.list {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.empty {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 24px;
padding: 28px;
border: 1px dashed rgba(201, 162, 74, 0.45);
border-radius: var(--gk-radius);
background: rgba(19, 28, 43, 0.6);
}
.words {
flex: 1 1 260px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.t {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
color: var(--gk-text);
}
.d {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-soft);
max-width: 56ch;
}
.words :global(.act) {
margin-top: 8px;
}
</style>
@@ -0,0 +1,139 @@
---
/**
* One post as a fixed-height row: picture, topic and date, title, a
* two-line summary, Now chips for linked notes and page-link chips.
*/
import Chip from "@gk/ui/components/Chip.astro";
import Star from "@gk/ui/components/Star.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import { dateLine, nowStatuses, pageLinks, type Post } from "../../lib/blog.ts";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
import PostThumb from "./PostThumb.astro";
interface Props {
post: Post;
site: SiteIndex;
/** The kicker over the title; the topic by default. */
kicker?: string;
/** Heading level of the title. */
as?: "h2" | "h3";
}
const { post, site: s, kicker, as: H = "h3" } = Astro.props;
const href = s.href(post) ?? "#";
const links = pageLinks(s, post);
const now = nowStatuses(s, post);
---
<li class="row">
<span class="mark" aria-hidden="true"><Star size={11} tone="violet" /></span>
<a href={href} class="pic" tabindex="-1" aria-hidden="true">
<PostThumb src={mediaSrc(post.data.cover)} class="thumb" />
</a>
<div class="body">
<span class="meta">
<span class="kicker">{kicker ?? post.data.topic}</span>
<span class="date">{dateLine(post)}</span>
{now.map((st) => <StatusPill>Now · {st.data.kind}</StatusPill>)}
</span>
<H class="title"><a href={href}>{post.title}</a></H>
{post.data.summary && <p class="summary">{post.data.summary}</p>}
<span class="chips">
{links.length ? links.map((l) => <Chip href={l.href}>{l.label}</Chip>) : <span class="none">Not tied to a page</span>}
</span>
</div>
</li>
<style>
.row {
position: relative;
display: flex;
gap: 20px;
padding: 22px 0 20px;
border-top: 1px dashed rgba(244, 242, 237, 0.16);
}
.mark {
position: absolute;
left: 0;
top: -6px;
display: flex;
padding-right: 6px;
background: var(--gk-sky);
}
.pic {
display: flex;
flex: none;
align-self: flex-start;
margin: 4px 0 0 4px;
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}
.kicker {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.date,
.none {
font-size: 12px;
color: var(--gk-muted);
}
.title {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
line-height: 1.3;
letter-spacing: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-gold-bright);
}
.summary {
margin: 0;
font-size: 14px;
color: var(--gk-soft);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
@media (max-width: 640px) {
.row {
gap: 14px;
}
.pic :global(.thumb) {
--w: 96px !important;
--h: 76px !important;
}
.title {
font-size: 17px;
}
}
</style>
@@ -0,0 +1,53 @@
---
/**
* A post's picture in a row or panel: the cover over star-chart art with the
* monogram, which also stands in when there is no cover or it fails to load.
*/
import monogram from "@gk/ui/assets/brand/gk-monogram.svg?url";
interface Props {
src?: string;
alt?: string;
width?: number;
height?: number;
class?: string;
}
const { src, alt = "", width = 176, height = 120, class: className } = Astro.props;
const mark = Math.round(Math.min(width, height) / 2);
---
<span class:list={["thumb", className]} style={`--w:${width}px;--h:${height}px`}>
<img src={monogram} alt="" width={mark} height={mark} class="mono" aria-hidden="true" />
{src && <img src={src} alt={alt} loading="lazy" decoding="async" class="photo" onerror="this.remove()" />}
</span>
<style>
.thumb {
position: relative;
flex: 0 0 var(--w);
width: var(--w);
height: var(--h);
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
outline: 1px solid rgba(201, 162, 74, 0.4);
outline-offset: 3px;
border: 1px solid var(--gk-lines);
background:
radial-gradient(ellipse at 30% 20%, rgba(106, 47, 224, 0.3), transparent 60%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 22px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 22px),
var(--gk-sky);
}
.mono {
opacity: 0.5;
}
.photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -0,0 +1,83 @@
---
/**
* The newest statuses, each with its subject and how many posts link to it.
*/
import Button from "@gk/ui/components/Button.astro";
import { plural } from "../../lib/page.ts";
import type { SiteIndex } from "../../lib/site.ts";
import SidePanel from "./SidePanel.astro";
interface Props {
site: SiteIndex;
limit?: number;
}
const { site: s, limit = 4 } = Astro.props;
const items = s.statuses().slice(0, limit).map((st) => {
const subject = st.data.subject ? s.byName(st.data.subject) : undefined;
const posts = subject ? s.backlinks(subject, { type: "post" }).filter((p) => (p as { data: { postType?: string } }).data.postType !== "case study").length : 0;
return {
kind: st.data.kind,
title: subject?.title ?? st.title,
href: (subject && s.href(subject)) ?? s.href(st) ?? "/now",
sub: posts ? plural(posts, "post") : st.data.note,
};
});
---
{
items.length > 0 && (
<SidePanel title="Right now" seal="zodiac">
<ul class="now">
{items.map((i) => (
<li>
<a href={i.href}>
<span class="k">{i.kind}</span>
<span class="t">{i.title}</span>
{i.sub && <span class="s">{i.sub}</span>}
</a>
</li>
))}
</ul>
<Button variant="text" href="/now" class="now-all">Everything on the Now page</Button>
</SidePanel>
)
}
<style>
.now {
margin: 0;
padding: 0;
list-style: none;
}
.now a {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
border-top: 1px solid var(--gk-lines);
text-decoration: none;
}
.k {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-teal-bright);
text-transform: uppercase;
}
.t {
font-size: 14px;
font-weight: 600;
color: var(--gk-text);
}
.now a:hover .t {
color: var(--gk-teal-bright);
}
.s {
font-size: 12px;
color: var(--gk-muted);
}
:global(.now-all) {
align-self: flex-start;
font-size: 13px;
padding-top: 6px;
}
</style>
@@ -0,0 +1,49 @@
---
/**
* Plain rows in a sidebar panel: a label and a count, with the current one
* in teal. Used for Topics and the Archive.
*/
interface Props {
items: { label: string; href: string; count?: number | string; current?: boolean }[];
}
const { items } = Astro.props;
---
<ul class="rows">
{
items.map((i) => (
<li>
<a href={i.href} aria-current={i.current ? "page" : undefined}>
<span>{i.label}</span>
{i.count !== undefined && <span class="n">{i.count}</span>}
</a>
</li>
))
}
</ul>
<style>
.rows {
margin: 0;
padding: 0;
list-style: none;
}
a {
display: flex;
justify-content: space-between;
gap: 8px;
min-height: 32px;
align-items: center;
padding: 4px 0;
font-size: 14px;
color: var(--gk-text);
text-decoration: none;
border-top: 1px solid var(--gk-lines);
}
a:hover,
a[aria-current] {
color: var(--gk-teal-bright);
}
.n {
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,47 @@
---
/**
* A sidebar panel on the blog pages: Midnight box, a faint seal in the
* corner, a starred kicker over a tick scale, then its rows.
*/
import Kicker from "@gk/ui/components/Kicker.astro";
import Seal from "@gk/ui/components/Seal.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
interface Props {
title: string;
seal?: SealName;
/** Heading level for the title, so the outline stays correct. */
as?: "h2" | "h3";
}
const { title, seal, as: H = "h2" } = Astro.props;
---
<section class="panel">
{seal && <Seal name={seal} size={104} class="mark" />}
<H class="head"><Kicker star>{title}</Kicker></H>
<slot />
</section>
<style>
.panel {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.panel :global(.mark) {
position: absolute;
right: -26px;
top: -26px;
opacity: 0.14;
pointer-events: none;
}
.head {
margin: 0;
font: inherit;
}
</style>
@@ -0,0 +1,41 @@
---
/** A section heading led by a gold star, with the filament underline. */
import Star from "@gk/ui/components/Star.astro";
interface Props {
id?: string;
level?: 2 | 3;
}
const { id, level = 2 } = Astro.props;
const H = `h${level}` as "h2" | "h3";
---
<div class="sh">
<H id={id} class="h"><Star size={14} /><slot /></H>
<span class="u" aria-hidden="true"></span>
</div>
<style>
.sh {
display: flex;
flex-direction: column;
gap: 6px;
}
.h {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
letter-spacing: 0.06em;
}
.u {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
</style>
@@ -0,0 +1,18 @@
---
/** The Topics panel: every topic with its post count, linking to its page. */
import { topicHref, topics } from "../../lib/blog.ts";
import type { SiteIndex } from "../../lib/site.ts";
import RowList from "./RowList.astro";
import SidePanel from "./SidePanel.astro";
interface Props {
site: SiteIndex;
title?: string;
current?: string;
}
const { site: s, title = "Topics", current } = Astro.props;
const items = topics.map((t) => ({ label: t, href: topicHref(t), count: s.posts({ topic: t }).length, current: t === current }));
---
<SidePanel title={title} seal="layered">
<RowList items={items} />
</SidePanel>
@@ -0,0 +1,209 @@
---
/**
* The landing's "From the blog": one featured post (pinned, else newest) with
* its image and topic, and the two most recent beside it. Renders nothing when
* there are no posts.
*/
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import { slugify } from "@gk/schema";
import { blogStrip, readMinutes } from "../../lib/home.ts";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
interface Props {
site: SiteIndex;
}
const { site: s } = Astro.props;
const { featured, recent } = blogStrip(s);
const topicHref = (topic: string) => `/blog/topic/${slugify(topic)}`;
---
{
featured && (
<div class="strip">
<div class="head">
<div class="gk-heading-sm">
<h3 id="from-blog">From the blog</h3>
<span class="underline" aria-hidden="true" />
</div>
<a href="/blog" class="gk-text all">All articles</a>
</div>
<div class="posts">
<article class="featured gk-card gk-linked">
<StarChartArt
seal="seven-metals"
ratio="auto"
src={mediaSrc(featured.data.cover)}
alt={featured.data.cover ? featured.title : ""}
class="art"
/>
<div class="body">
<div class="tags">
<span class="label">{featured.data.pinned ? "Featured" : "Latest"}</span>
<a class="gk-chip" href={topicHref(featured.data.topic)}>{featured.data.topic}</a>
</div>
<h4 class="ftitle">
<a href={s.href(featured) ?? "/blog"}>{featured.title}</a>
</h4>
<p class="summary">{featured.data.summary}</p>
<span class="date">
<time datetime={featured.data.date}>{formatDate(featured.data.date)}</time> · {readMinutes(featured.body)} min read
</span>
</div>
</article>
{
recent.length > 0 && (
<div class="recent">
{recent.map((p) => (
<article class="gk-card gk-linked small">
<span class="tags">
<span class="label">New</span>
<a class="gk-chip" href={topicHref(p.data.topic)}>{p.data.topic}</a>
</span>
<h4 class="rtitle">
<a href={s.href(p) ?? "/blog"}>{p.title}</a>
</h4>
<span class="date">
<time datetime={p.data.date}>{formatDate(p.data.date)}</time> · {readMinutes(p.body)} min read
</span>
</article>
))}
</div>
)
}
</div>
</div>
)
}
<style>
.strip {
display: flex;
flex-direction: column;
gap: 22px;
margin-top: 16px;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.gk-heading-sm {
display: flex;
flex-direction: column;
gap: 8px;
}
.gk-heading-sm h3 {
margin: 0;
font-size: 22px;
letter-spacing: 0.08em;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.all {
font-size: 14px;
}
.posts {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.featured {
flex: 2 1 560px;
min-width: 0;
padding: 0;
overflow: hidden;
flex-direction: row;
flex-wrap: wrap;
gap: 0;
}
.featured :global(.art) {
flex: 1 1 240px;
min-width: 0;
height: 260px;
border-radius: 0;
border-right: 2px solid var(--gk-gold);
}
.body {
flex: 2 1 300px;
min-width: 0;
padding: 26px;
display: flex;
flex-direction: column;
gap: 10px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.label {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
h4 {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.2;
}
h4 a {
color: var(--gk-text);
text-decoration: none;
}
h4 a::after {
content: "";
position: absolute;
inset: 0;
}
.tags a {
position: relative;
z-index: 1;
}
.ftitle {
font-size: 24px;
}
.rtitle {
font-size: 17px;
}
.summary {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-muted);
}
.date {
margin-top: auto;
font-size: 12px;
color: var(--gk-muted);
}
.recent {
flex: 1 1 280px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.small {
padding: 18px 20px;
}
@media (max-width: 640px) {
.featured :global(.art) {
border-right: 0;
border-bottom: 2px solid var(--gk-gold);
height: 200px;
flex-basis: 100%;
}
}
</style>
@@ -0,0 +1,67 @@
---
/** A row link inside a landing or About door: a title, an optional line, and an arrow. */
import Icon from "@gk/ui/components/Icon.astro";
interface Props {
href: string;
title: string;
line?: string;
size?: "large" | "small";
}
const { href, title, line, size = "large" } = Astro.props;
---
<li>
<a href={href} class:list={["door-link", size]}>
<span class="words">
<span class="t">{title}</span>
{line && <span class="l">{line}</span>}
</span>
<Icon name="arrow-right" size={size === "large" ? 22 : 16} />
</a>
</li>
<style>
li {
list-style: none;
}
.door-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--gk-starlight);
text-decoration: none;
transition: color var(--gk-ease);
}
.door-link:hover {
color: var(--gk-gold-bright);
}
.large {
min-height: 62px;
padding: 8px 0;
border-bottom: 1px solid rgba(244, 242, 237, 0.35);
}
.small {
min-height: 48px;
border-top: 1px solid var(--gk-lines);
font-weight: 600;
}
.words {
display: flex;
flex-direction: column;
min-width: 0;
}
.large .t {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
letter-spacing: 0.05em;
}
.l {
font-size: 14px;
color: var(--gk-soft);
}
.door-link :global(svg) {
flex: none;
}
</style>
@@ -0,0 +1,87 @@
---
/**
* One status on the landing's Now strip: kind, subject, a meta line, the
* note and when it was updated. The card links to the status on /now.
*/
import Star from "@gk/ui/components/Star.astro";
import { formatDate } from "../../lib/site.ts";
import type { StatusView } from "../../lib/home.ts";
interface Props {
view: StatusView;
}
const { view: v } = Astro.props;
---
<li class="now-card">
<span class="kind">
{v.kind}
{v.pinned && <span class="pin"><Star size={9} /><span class="gk-visually-hidden">Pinned</span></span>}
</span>
<h3 class="title">
{v.href ? <a href={v.href}>{v.title}</a> : v.title}
</h3>
{v.meta && <span class="meta">{v.meta}</span>}
{v.note && <p class="note">{v.note}</p>}
<a class="when" href={`/now#${v.slug}`}>Updated <time datetime={v.date}>{formatDate(v.date)}</time></a>
</li>
<style>
.now-card {
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: var(--gk-radius);
padding: 22px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.kind {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.pin {
display: inline-flex;
}
.title {
margin: 0;
font-size: 18px;
letter-spacing: 0.04em;
overflow-wrap: anywhere;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-teal-bright);
}
.meta {
font-size: 13px;
color: var(--gk-muted);
}
.note {
margin: 4px 0 0;
padding-left: 12px;
border-left: 2px solid var(--gk-gold);
font-size: 14px;
line-height: 1.55;
}
.when {
margin-top: auto;
padding-top: 8px;
font-size: 12px;
color: var(--gk-muted);
text-decoration: none;
}
.when:hover {
color: var(--gk-text);
}
</style>
@@ -0,0 +1,53 @@
---
/**
* 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.
*/
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
interface Props {
/** "hero": the thick gold frame on the landing; "panel": the framed panel on About. */
variant?: "hero" | "panel";
src?: string;
alt?: string;
seal?: SealName;
class?: string;
}
const { variant = "hero", src, alt = "Greg Kilmer", seal = "transmutation", class: className } = Astro.props;
---
<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} />}
{!src && <figcaption class="gk-visually-hidden">Portrait coming soon</figcaption>}
</figure>
<style>
.portrait {
position: relative;
margin: 0;
}
.hero {
width: 200px;
border: 3px solid var(--gk-gold);
border-radius: var(--gk-radius-lg);
padding: 6px;
background: rgba(106, 47, 224, 0.12);
}
.hero :global(.art) {
border-radius: var(--gk-radius);
}
.panel {
width: 100%;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(201, 162, 74, 0.55);
background: var(--gk-midnight);
}
.panel :global(.art) {
border-radius: 6px;
width: 100%;
height: 320px;
}
</style>
@@ -0,0 +1,181 @@
---
/**
* One status on the Now page, an anchor at its slug: a thumbnail, the kind
* (and Pinned), the subject linked to its page, the note, a chip to the page
* the kind belongs to, a post about the subject when there is one, and the date.
*/
import monogram from "@gk/ui/assets/brand/gk-monogram.svg?url";
import Chip from "@gk/ui/components/Chip.astro";
import Icon from "@gk/ui/components/Icon.astro";
import Star from "@gk/ui/components/Star.astro";
import type { StatusView } from "../../lib/home.ts";
import { formatDate, type SiteIndex } from "../../lib/site.ts";
interface Props {
view: StatusView;
site: SiteIndex;
}
const { view: v, site: s } = Astro.props;
const post = v.subject ? s.backlinks(v.subject, { type: "post" })[0] : undefined;
const postHref = post ? s.href(post) : null;
---
<li id={v.slug} class="row">
{
v.cover ? (
<img src={v.cover} alt="" class="thumb photo" loading="lazy" decoding="async" width="56" height="56" />
) : (
<span class="thumb tile" aria-hidden="true">
<img src={monogram} alt="" width="28" height="28" />
</span>
)
}
<div class="main">
<span class="labels">
<span class="kind"><Icon name={v.icon} size={14} />{v.kind}</span>
{v.pinned && <span class="pinned"><Star size={9} />Pinned</span>}
</span>
<h3 class="title">{v.href ? <a href={v.href}>{v.title}</a> : v.title}</h3>
{v.meta && <span class="meta">{v.meta}</span>}
{v.note && <p class="note">{v.note}</p>}
</div>
<div class="side">
{v.chip && <Chip href={v.chip.href}>{v.chip.label}</Chip>}
{
post && postHref && (
<a href={postHref} class="gk-text post">
<Icon name="list-text" size={14} />
{post.title}
</a>
)
}
<span class="date">Updated <time datetime={v.date}>{formatDate(v.date)}</time></span>
</div>
</li>
<style>
.row {
display: flex;
gap: 16px;
align-items: center;
padding: 14px 0;
border-top: 1px solid var(--gk-lines);
scroll-margin-top: 24px;
}
.row:target {
background: linear-gradient(90deg, var(--gk-gold-tint), transparent 70%);
}
.thumb {
flex: 0 0 56px;
width: 56px;
height: 56px;
border-radius: 8px;
border: 1px solid var(--gk-lines);
outline: 1px solid rgba(201, 162, 74, 0.35);
outline-offset: 2px;
}
.photo {
object-fit: cover;
}
.tile {
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(ellipse at 30% 20%, rgba(106, 47, 224, 0.3), transparent 60%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 14px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 14px),
var(--gk-sky);
}
.tile img {
opacity: 0.5;
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.labels {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.kind,
.pinned {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
text-transform: uppercase;
}
.kind {
color: var(--gk-teal-bright);
}
.pinned {
gap: 4px;
color: var(--gk-gold-bright);
}
.title {
margin: 0;
font-size: 17px;
letter-spacing: 0.04em;
overflow-wrap: anywhere;
}
.title a {
color: var(--gk-text);
text-decoration: none;
}
.title a:hover {
color: var(--gk-teal-bright);
}
.meta,
.note {
margin: 0;
font-size: 13px;
color: var(--gk-muted);
}
.note {
color: var(--gk-soft);
}
.side {
flex: 0 1 260px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
text-align: right;
}
.post {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.date {
font-size: 12px;
color: var(--gk-muted);
}
@media (max-width: 640px) {
.row {
flex-wrap: wrap;
align-items: flex-start;
}
.main {
flex: 1 1 calc(100% - 72px);
}
.side {
flex: 1 1 100%;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 8px 14px;
padding-left: 72px;
text-align: left;
}
}
</style>
@@ -0,0 +1,145 @@
---
/**
* The favorites constellation: round covers strung on a dotted gold line,
* each linking to its row in the list.
*/
import Star from "@gk/ui/components/Star.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { mediaSrc } from "../../lib/site.ts";
import type { Item } from "../../lib/library.ts";
interface Props {
items: Item[];
}
const { items } = Astro.props;
const lift = [18, 0, 26, 0, 22, 2, 20];
const n = items.length;
const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lift[i % lift.length]! + 40}`).join(" ");
---
{
n > 0 && (
<section aria-labelledby="lib-fav" class="fav">
<div class="head">
<h2 id="lib-fav" class="kick">
<Star size={14} />
The favorites constellation
</h2>
<TickScale width={150} height={10} count={30} />
</div>
<div class="sky">
<svg aria-hidden="true" class="line" viewBox="0 0 1000 154" preserveAspectRatio="none">
<polyline points={points} fill="none" stroke="#C9A24A" stroke-width="1.4" stroke-dasharray="2 6" vector-effect="non-scaling-stroke" />
</svg>
<ul style={`--n:${n}`}>
{
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" />
<span>{item.title}</span>
</a>
</li>
))
}
</ul>
</div>
</section>
)
}
<style>
.fav {
padding: 0 0 8px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 18px;
}
.kick {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.sky {
position: relative;
}
ul {
position: relative;
display: grid;
grid-template-columns: repeat(var(--n), minmax(0, 1fr));
min-height: 154px;
margin: 0;
padding: 0;
list-style: none;
}
.line {
position: absolute;
inset: 0;
width: 100%;
height: 154px;
pointer-events: none;
}
li {
display: flex;
justify-content: center;
padding-top: var(--lift);
}
a {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 112px;
max-width: 100%;
color: var(--gk-text);
text-decoration: none;
}
img {
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 {
box-shadow:
0 0 0 2px var(--gk-gold-bright),
0 0 30px rgba(201, 162, 74, 0.7);
}
span {
font-size: 13px;
font-weight: 600;
text-align: center;
line-height: 1.3;
}
@media (max-width: 760px) {
ul {
grid-template-columns: repeat(4, minmax(0, 1fr));
row-gap: 20px;
}
.line {
display: none;
}
li {
padding-top: 0;
}
img {
width: 64px;
height: 64px;
}
}
</style>
@@ -0,0 +1,61 @@
---
/**
* The rail-and-list layout both tabs use. The rail is a GET form, so filters
* work without JS (an Apply button submits it); the page script filters live
* and hides the button.
*/
interface Props {
action: string;
/** For the page script to find the form. */
formId: string;
}
const { action, formId } = Astro.props;
---
<div class="layout">
<aside aria-label="Filters" class="rail">
<form id={formId} method="get" action={action} class="rail-form">
<input type="hidden" name="f" value="1" />
<slot name="rail" />
<button type="submit" class="gk-btn gk-sec gk-sm apply" data-nojs>Apply filters</button>
</form>
</aside>
<div class="main">
<slot />
</div>
</div>
<style>
.layout {
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
padding: 24px 0 72px;
}
.rail {
flex: 1 1 220px;
max-width: 260px;
min-width: 0;
}
.rail-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.apply {
align-self: flex-start;
}
.main {
flex: 3 1 600px;
min-width: 0;
}
@media (max-width: 760px) {
.layout {
gap: 28px;
}
.rail {
max-width: none;
flex-basis: 100%;
}
}
</style>
@@ -0,0 +1,130 @@
---
/**
* One board game on The Shelf: box art (or star-chart art), how it plays,
* title, blurb, its states and a link to BoardGameGeek. The card is the
* game's anchor (`#slug`); the data-* attributes feed the filter and sort
* script.
*/
import Card from "@gk/ui/components/Card.astro";
import Star from "@gk/ui/components/Star.astro";
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 type { Game } from "../../lib/library.ts";
interface Props {
game: Game;
site: SiteIndex;
hidden?: boolean;
}
const { game, site, hidden } = Astro.props;
const d = game.data;
const plays = d.howItPlays ?? [];
const status = site.statusFor(game);
---
<li
id={game.slug}
class="game"
data-p={plays.map((p) => p.toLowerCase()).join("|") || undefined}
data-o={d.owned ? "1" : undefined}
data-w={d.wantToPlay ? "1" : undefined}
data-f={d.favorite ? "1" : undefined}
data-lp={d.lastPlayed}
data-c={d.created}
hidden={hidden}
>
<Card as="div" class="game-card">
<StarChartArt class="box" ratio="1 / 1" seal="tria-prima" src={mediaSrc(d.cover)} alt="" />
<div class="body">
{plays.length > 0 && <span class="plays">{plays.join(" · ")}</span>}
<h3 class="t">
{game.title}
{d.favorite && <><Star size={12} /><span class="gk-visually-hidden">Favorite</span></>}
</h3>
<p class="blurb">{d.blurb}</p>
{
(status || d.wantToPlay || !d.owned || d.lastPlayed) && (
<div class="states">
{status && <StatusChip status={status} site={site} />}
{d.wantToPlay && <StatusPill kind="state">Want to play</StatusPill>}
{!d.owned && <StatusPill kind="neutral">Not owned</StatusPill>}
{d.lastPlayed && <span class="played">Last played {formatDate(d.lastPlayed)}</span>}
</div>
)
}
<a class="gk-text gk-quiet bgg" href={`https://boardgamegeek.com/boardgame/${d.bggId}`} rel="external noopener">
BoardGameGeek<span class="gk-visually-hidden">: {game.title}</span>
</a>
</div>
</Card>
</li>
<style>
.game {
display: flex;
scroll-margin-top: 24px;
}
.game[hidden] {
display: none;
}
.game :global(.game-card) {
flex: 1;
padding: 0;
gap: 0;
overflow: hidden;
}
.game:target :global(.game-card) {
border-color: var(--gk-gold);
}
.game :global(.box) {
border-radius: 0;
border-bottom: 1px solid var(--gk-lines);
}
.body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.plays {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-muted);
}
.t {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: 15px;
line-height: 1.3;
letter-spacing: 0.02em;
}
.blurb {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--gk-muted);
}
.states {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.played {
font-size: 12px;
color: var(--gk-muted);
}
.bgg {
align-self: flex-start;
margin-top: auto;
padding-top: 4px;
font-size: 12px;
}
</style>
@@ -0,0 +1,81 @@
---
/**
* The hero both Library tabs share: the title, the tab control linking The
* Library and The Shelf, and the page's lede.
*/
import PageHero from "../PageHero.astro";
interface Props {
current: "library" | "shelf";
lede?: string;
}
const { current, lede } = Astro.props;
const tabs = [
{ key: "library", href: "/library", name: "The Library", blurb: "Books, video games, anime, TV, movies" },
{ key: "shelf", href: "/library/shelf", name: "The Shelf", blurb: "Board games" },
];
---
<PageHero eyebrow="My world" title="Library" />
<nav aria-label="Library sections">
<ul class="tabs">
{
tabs.map((t) => (
<li>
<a href={t.href} class="tab" aria-current={t.key === current ? "page" : undefined}>
<span class="name">{t.name}</span>
<span class="blurb">{t.blurb}</span>
</a>
</li>
))
}
</ul>
</nav>
{lede && <p class="lede">{lede}</p>}
<style>
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px 36px;
margin: 8px 0 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.tab {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 4px 14px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--gk-muted);
text-decoration: none;
transition: color var(--gk-ease), border-color var(--gk-ease);
}
.tab:hover {
color: var(--gk-text);
border-bottom-color: var(--gk-gold-line);
}
.tab[aria-current] {
color: var(--gk-text);
border-bottom-color: var(--gk-gold);
}
.name {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.06em;
}
.blurb {
font-size: 13px;
font-weight: 500;
}
.lede {
margin: 6px 0 0;
font-size: 17px;
line-height: 1.6;
color: var(--gk-soft);
max-width: 64ch;
}
</style>
@@ -0,0 +1,162 @@
---
/**
* One Library item as a fixed-height row: cover (or star-chart art), title,
* verdict and note, details, the newest blog post about it, and on the right
* its Now chip and medium. The row is the item's anchor (`#slug`); the data-*
* attributes feed the filter script. Styles are global (lib-* classes) so
* hundreds of rows carry no scoping attributes.
*/
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusChip from "../StatusChip.astro";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
import { mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts";
interface Props {
item: Item;
site: SiteIndex;
hidden?: boolean;
}
const { item, site, hidden } = Astro.props;
const d = item.data;
const status = site.statusFor(item);
const post = site.backlinks(item, { type: "post" })[0];
const postHref = post && site.href(post);
const details = [d.creator, d.year, d.platform, d.progress, d.group].filter((x) => x !== undefined && x !== "");
---
<li
id={item.slug}
class="lib-row"
data-m={mediaGroupOf(item)}
data-s={sectionOf(item)}
data-f={d.favorite ? "1" : undefined}
data-b={post ? "1" : undefined}
hidden={hidden}
>
<StarChartArt class="lib-cover" ratio="2 / 3" seal="zodiac" src={mediaSrc(d.cover)} />
<div class="lib-body">
<h3 class="lib-t">
{item.title}
{d.favorite && <><Star size={14} /><span class="gk-visually-hidden">Favorite</span></>}
</h3>
{d.verdict && <p class="lib-v">{d.verdict}</p>}
{d.note && <p class="lib-v lib-n">{d.note}</p>}
{details.length > 0 && <p class="lib-d">{details.join(" · ")}</p>}
{
post && postHref && (
<a href={postHref} class="lib-blog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v14H4zM8 9h8M8 13h5" /></svg>
Blog: {post.title}
</a>
)
}
</div>
<div class="lib-side">
{status && <StatusChip status={status} site={site} />}
<span class="lib-med">{mediumLabel(d.medium)}</span>
</div>
</li>
<style is:global>
.lib-row {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 0;
border-top: 1px solid var(--gk-lines);
scroll-margin-top: 24px;
}
.lib-row:target {
background: linear-gradient(90deg, var(--gk-gold-tint), transparent 70%);
}
.lib-row[hidden] {
display: none !important;
}
.lib-row .lib-cover.gk-art {
flex: 0 0 48px;
width: 48px;
height: 72px;
border-radius: 6px;
border: 1px solid var(--gk-lines);
}
.lib-row .lib-cover .mat {
inset: 4px;
border-radius: 3px;
opacity: 0.6;
}
.lib-row .lib-cover .seal {
width: 30px;
height: 30px;
}
.lib-body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.lib-t {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 17px;
letter-spacing: 0.02em;
line-height: 1.3;
}
.lib-v,
.lib-d {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--gk-muted);
}
.lib-n {
font-style: italic;
}
.lib-d {
font-size: 12px;
color: var(--gk-placeholder);
}
.lib-blog {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.lib-side {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.lib-med {
font-size: 12px;
letter-spacing: 0.12em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-muted);
}
@media (max-width: 560px) {
.lib-row {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: start;
gap: 8px 14px;
}
.lib-row .lib-cover.gk-art {
grid-row: span 2;
}
.lib-side {
grid-column: 2;
flex-direction: row;
align-items: center;
}
}
</style>
@@ -0,0 +1,55 @@
---
/** A list group's heading: a state dot, the name in Cinzel, and the gold underline. */
interface Props {
id: string;
dot: "live" | "open" | "done";
}
const { id, dot } = Astro.props;
---
<div class="lh">
<h2 id={id}><span class:list={["dot", dot]} aria-hidden="true"></span><slot /></h2>
<span class="ul" aria-hidden="true"></span>
</div>
<style>
.lh {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
h2 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 22px;
letter-spacing: 0.08em;
}
.dot {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
}
.live {
background: var(--gk-teal-bright);
box-shadow: 0 0 10px var(--gk-teal-bright);
}
.open {
border: 1px solid var(--gk-text);
}
.done {
background: var(--gk-muted);
}
.ul {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
</style>
@@ -0,0 +1,64 @@
---
/**
* One group in a filter rail: a gold kicker legend over checkboxes, links or
* filter pills. Checkboxes go in as `<label class="check"><input …>Text</label>`.
*/
interface Props {
legend: string;
/** Lay the contents out as a row of pills. */
pills?: boolean;
}
const { legend, pills } = Astro.props;
---
<fieldset class:list={["rail-group", { pills }]}>
<legend>{legend}</legend>
<div class="items"><slot /></div>
</fieldset>
<style>
.rail-group {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
legend {
padding: 0;
margin-bottom: 10px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.items {
display: flex;
flex-direction: column;
gap: 10px;
}
.pills .items {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.items :global(.check) {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
font-size: 14px;
color: var(--gk-soft);
cursor: pointer;
}
.items :global(.check input) {
flex: none;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--gk-teal);
cursor: pointer;
}
.items :global(.check:hover) {
color: var(--gk-text);
}
</style>
@@ -0,0 +1,27 @@
---
/** "STORE / TABLETOP": the gold trail above a store page's title. */
interface Props {
here: string;
}
const { here } = Astro.props;
---
<nav aria-label="Breadcrumb" class="crumb">
<a href="/store">Store</a> <span aria-hidden="true">/</span> <span aria-current="page">{here}</span>
</nav>
<style>
.crumb {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.crumb a {
color: inherit;
text-decoration: none;
}
.crumb a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,231 @@
---
/**
* A product line on the Store: its lead image (star chart fallback), a ringed
* medallion with its icon, title, summary, three specs, its status and the
* price it starts from. Lines sold elsewhere link out to their shop.
*/
import Button from "@gk/ui/components/Button.astro";
import Icon from "@gk/ui/components/Icon.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import { mediaSrc } from "../../lib/site.ts";
import { channelOf, hasCapacity, lineIcon, priceSummary, shopLink, statusText, type Line } from "../../lib/store.ts";
interface Props {
line: Line;
/** The category's orders are open. */
open: boolean;
}
const { line, open } = Astro.props;
const d = line.data;
const img = d.images?.[0];
const price = priceSummary(line);
const shop = shopLink(line);
const ch = channelOf(line);
const soon = ch === "coming soon";
const status = statusText(line);
const live = ch === "here" && open && hasCapacity(line);
const headId = `line-${line.slug}`;
---
<article class:list={["line", { soon }]} aria-labelledby={headId}>
{
!soon && (
<StarChartArt seal="craft-dice" ratio="16 / 9" src={mediaSrc(img?.file)} alt={img?.alt ?? ""} class="art" />
)
}
<div class="medal-row" aria-hidden="true">
<TickRing size={soon ? 60 : 76} medallion class="medal">
<Icon name={lineIcon(line)} size={soon ? 26 : 34} />
</TickRing>
</div>
<div class="body">
<div class="top">
<StatusPill kind={live ? "live" : "state"}>{status}</StatusPill>
</div>
<div class="head">
<h3 id={headId}>{line.title}</h3>
<span class="gk-underline" aria-hidden="true"></span>
</div>
<p class="summary">{d.summary}</p>
{
!!d.specs?.length && (
<ul class="specs">
{d.specs.slice(0, 3).map((sp) => (
<li>
<Star size={10} />
<span>
<span class="spec-label">{sp.label}:</span> {sp.value}
</span>
</li>
))}
</ul>
)
}
{(price || shop || !soon) && (
<div class="foot">
{
price ? (
<span class="price">
<span class="from">From</span>
<span class="amount">{price.from}</span>
<span class="more">{[price.note, ...price.more].filter(Boolean).join(" · ")}</span>
</span>
) : (
<span />
)
}
{
shop ? (
<Button variant="secondary" href={shop.href} rel="noopener" arrow>
{shop.label}
</Button>
) : (
!soon && (
<Button variant="secondary" href={`/store/${line.slug}`} arrow aria-label={`Details and pricing: ${line.title}`}>
Details and pricing
</Button>
)
)
}
</div>
)}
</div>
</article>
<style>
.line {
flex: 1 1 460px;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: var(--gk-radius);
overflow: hidden;
}
.line.soon {
flex-basis: 300px;
}
.line :global(.art) {
border-radius: 0;
border-bottom: 1px solid var(--gk-lines);
max-height: 260px;
width: 100%;
}
.medal-row {
position: relative;
height: 0;
}
.medal-row :global(.medal) {
position: absolute;
right: 24px;
top: -38px;
}
.soon .medal-row :global(.medal) {
top: 22px;
}
.soon .head {
padding-right: 72px;
}
.body {
padding: 28px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.top {
display: flex;
padding-right: 84px;
}
.head {
display: flex;
flex-direction: column;
gap: 8px;
}
h3 {
margin: 0;
font-size: 28px;
letter-spacing: 0.05em;
line-height: 1.15;
}
.soon h3 {
font-size: 22px;
}
.gk-underline {
display: block;
width: 180px;
max-width: 60%;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.summary {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-soft);
}
.specs {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
line-height: 1.5;
color: var(--gk-soft);
}
.specs li {
display: flex;
gap: 8px;
}
.specs li :global(svg) {
flex: none;
margin-top: 5px;
}
.spec-label {
color: var(--gk-muted);
}
.foot {
margin-top: auto;
padding-top: 12px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--gk-lines);
}
.soon .foot {
border-top: 0;
padding-top: 0;
}
.price {
display: flex;
flex-direction: column;
}
.from,
.more {
font-size: 12px;
color: var(--gk-muted);
}
.amount {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 28px;
color: var(--gk-gold-bright);
line-height: 1.1;
}
@media (max-width: 640px) {
.body {
padding: 22px 18px;
}
}
</style>
@@ -0,0 +1,47 @@
---
/**
* A line's status panel: the SlotMeter for a made to order line sold here,
* its stock for an in stock line, or where it is sold.
*/
import SlotMeter from "@gk/ui/components/SlotMeter.astro";
import { channelOf, statusText, type Line } from "../../lib/store.ts";
interface Props {
line: Line;
label?: string;
class?: string;
}
const { line, label = line.title, class: className } = Astro.props;
const ch = channelOf(line);
const slots = ch === "here" && line.data.mode === "made to order" ? line.data.slots : undefined;
---
{
slots ? (
<SlotMeter label={label} open={slots.open} total={slots.total} class={className} />
) : (
<div class:list={["box", className]}>
<span class="label">{label}</span>
<span class="count">{statusText(line)}</span>
</div>
)
}
<style>
.box {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.label {
font-size: 14px;
font-weight: 600;
}
.count {
font-size: 13px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,288 @@
---
/**
* The order request form. Posts to /api/orders. Field names:
* name, email, line (a line slug), option.<line slug>.<group> (an option
* name), quantity, details, notes, terms ("accepted").
* Without JS every line's options show, labelled with their line; a small
* script shows only the chosen line's options and terms.
*/
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import Star from "@gk/ui/components/Star.astro";
import { optionGroups, type Line, type Term } from "../../lib/store.ts";
import Terms from "./Terms.astro";
interface Props {
lines: Line[];
selected?: string;
storeTerms: Term[];
}
const { lines, selected, storeTerms } = Astro.props;
const chosen = lines.find((l) => l.slug === selected)?.slug ?? (lines.length === 1 ? lines[0]!.slug : undefined);
const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
---
<form method="post" action="/api/orders" class="order" aria-labelledby="order-title" data-order-form>
<h2 id="order-title" class="gk-visually-hidden">Order request form</h2>
<div class="row">
<Field label="Name" name="name" autocomplete="name" required class="half" />
<Field label="Email" name="email" type="email" autocomplete="email" required class="half" />
</div>
<fieldset>
<legend>What would you like?</legend>
<div class="choices">
{
lines.map((l) => (
<label class:list={["gk-filter", "choice", { on: l.slug === chosen }]}>
<input type="radio" name="line" value={l.slug} checked={l.slug === chosen} required />
<span class="gk-fs" aria-hidden="true"><Star size={10} /></span>
{l.title}
</label>
))
}
</div>
</fieldset>
{
lines.flatMap((l) =>
optionGroups(l).map(([group, opts]) => (
<fieldset class="opts" data-line={l.slug} hidden={chosen ? l.slug !== chosen : undefined} disabled={chosen ? l.slug !== chosen : undefined}>
<legend>
{group} {lines.length > 1 && <span class="for">({l.title} only)</span>}
</legend>
<div class="choices">
{opts.map((o, i) => (
<label class:list={["gk-filter", "swatch", { out: !o.inStock }]}>
<input type="radio" name={`option.${l.slug}.${group}`} value={o.name} disabled={!o.inStock} id={`opt-${l.slug}-${slugPart(group)}-${i}`} />
{o.hex && <span class="dot" style={`background:${o.hex}`} aria-hidden="true" />}
{o.name}
{!o.inStock && <span class="stock">(out of stock)</span>}
</label>
))}
</div>
</fieldset>
)),
)
}
<Field label="Approximate quantity" name="quantity" placeholder="e.g. 75 cards, or 1 deck box" required />
<Field
label="Card list or deck box spec"
name="details"
as="textarea"
rows={6}
hint="For cards, paste your full card list, one per line."
class="big"
/>
<Field label="Notes (optional)" name="notes" as="textarea" rows={3} />
{
(lines.some((l) => l.data.terms?.length) || storeTerms.length > 0) && (
<details class="terms">
<summary>Order terms</summary>
{lines.map((l) =>
l.data.terms?.length ? (
<div class="line-terms" data-line={l.slug} hidden={chosen ? l.slug !== chosen : undefined}>
{lines.length > 1 && <h3>{l.title}</h3>}
<Terms terms={l.data.terms} />
</div>
) : null,
)}
{storeTerms.length > 0 && (
<div class="line-terms">
{lines.length > 1 && <h3>Every order</h3>}
<Terms terms={storeTerms} />
</div>
)}
</details>
)
}
<label class="ack">
<input type="checkbox" name="terms" value="accepted" required />
<span>I have read and accept the order terms for this request.</span>
</label>
<div class="submit">
<Button type="submit">Send order request</Button>
</div>
</form>
<script>
for (const form of document.querySelectorAll<HTMLFormElement>("[data-order-form]")) {
const sync = () => {
const line = form.querySelector<HTMLInputElement>('input[name="line"]:checked')?.value;
for (const el of form.querySelectorAll<HTMLElement>("[data-line]")) {
const show = !line || el.dataset.line === line;
el.hidden = !show;
if (el instanceof HTMLFieldSetElement) el.disabled = !show;
}
for (const lab of form.querySelectorAll("label.choice")) {
lab.classList.toggle("on", !!lab.querySelector<HTMLInputElement>("input:checked"));
}
};
form.addEventListener("change", sync);
sync();
}
</script>
<style>
.order {
position: relative;
display: flex;
flex-direction: column;
gap: 18px;
padding: 32px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.row :global(.half) {
flex: 1 1 240px;
}
.order :global(.gk-label-text) {
font-size: 14px;
color: var(--gk-text);
}
.order :global(input.gk-field) {
height: var(--gk-control);
}
.order :global(textarea.gk-field) {
padding: 10px 14px;
resize: vertical;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
fieldset[hidden] {
display: none;
}
legend {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
padding: 0;
}
.for {
font-weight: 400;
color: var(--gk-muted);
}
.choices {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.choice {
flex: 1 1 200px;
min-height: var(--gk-control);
}
.gk-filter input {
accent-color: var(--gk-teal);
margin: 0;
}
.choice input,
.swatch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.choice,
.swatch {
position: relative;
}
.choice:focus-within,
.swatch:focus-within {
outline: 2px solid var(--gk-gold-bright);
outline-offset: 3px;
}
.choice:has(input:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.choice:has(input:checked) .gk-fs {
display: inline-flex;
}
.swatch {
gap: 8px;
}
.swatch.out {
opacity: 0.5;
cursor: not-allowed;
}
.swatch:has(input:checked) {
border-color: var(--gk-gold);
background: var(--gk-gold-tint);
color: var(--gk-text);
}
.dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(236, 233, 226, 0.35);
flex: none;
}
.stock {
font-weight: 400;
color: var(--gk-muted);
}
.terms {
padding: 14px 16px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.terms summary {
font-weight: 700;
font-size: 14px;
cursor: pointer;
}
.line-terms {
margin-top: 12px;
}
.line-terms[hidden] {
display: none;
}
.line-terms h3 {
margin: 0 0 8px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.ack {
display: flex;
align-items: flex-start;
gap: 12px;
font-size: 13px;
line-height: 1.55;
color: var(--gk-soft);
}
.ack input {
margin-top: 3px;
width: 18px;
height: 18px;
flex: none;
accent-color: var(--gk-teal);
}
.submit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
@media (max-width: 640px) {
.order {
padding: 22px 16px;
}
}
</style>
@@ -0,0 +1,94 @@
---
/**
* Color options as round swatches. With `names`, each swatch is listed with
* its name and stock; without, only the swatches show (names on hover and for
* screen readers).
*/
import type { Line } from "../../lib/store.ts";
type Option = NonNullable<Line["data"]["options"]>[number];
interface Props {
options: Option[];
names?: boolean;
class?: string;
}
const { options, names, class: className } = Astro.props;
---
{
names ? (
<ul class:list={["named", className]}>
{options.map((o) => (
<li class:list={{ out: !o.inStock }}>
{o.hex ? <span class="dot" style={`background:${o.hex}`} aria-hidden="true" /> : <span class="dot none" aria-hidden="true" />}
<span class="name">{o.name}</span>
{!o.inStock && <span class="stock">out of stock</span>}
</li>
))}
</ul>
) : (
<ul class:list={["swatches", className]}>
{options.map((o) => (
<li title={o.name} class:list={{ out: !o.inStock }}>
<span class="sw" style={o.hex ? `background:${o.hex}` : undefined} aria-hidden="true" />
<span class="gk-visually-hidden">
{o.name}
{!o.inStock && ", out of stock"}
</span>
</li>
))}
</ul>
)
}
<style>
ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
}
.swatches {
gap: 8px;
}
.sw {
display: block;
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--gk-lines);
box-shadow: 0 0 0 1px rgba(236, 233, 226, 0.25);
}
.named {
gap: 8px;
}
.named li {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 14px 0 10px;
border: 1px solid var(--gk-lines);
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--gk-soft);
}
.dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(236, 233, 226, 0.35);
flex: none;
}
.dot.none {
background: var(--gk-lines);
}
.out {
opacity: 0.5;
}
.stock {
font-weight: 400;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,49 @@
---
/** Order terms as ledger notes, each with its kind. */
import Notice from "@gk/ui/components/Notice.astro";
import type { Term } from "../../lib/store.ts";
interface Props {
terms: Term[];
}
const { terms } = Astro.props;
const kindLabel = { info: "Good to know", warning: "Please note", rule: "Rule" } as const;
---
<ul class="terms">
{
terms.map((t) => (
<li>
<Notice title={t.title} role="note" class={`term ${t.kind}`}>
<span class="gk-visually-hidden">{kindLabel[t.kind]}: </span>
{t.body}
</Notice>
</li>
))
}
</ul>
<style>
.terms {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.terms :global(.gk-note) {
align-items: flex-start;
font-size: 14px;
line-height: 1.55;
}
.terms :global(.gk-note > svg) {
margin-top: 5px;
flex: none;
}
.terms :global(.term.warning) {
background: rgba(201, 162, 74, 0.12);
}
.terms :global(.term.rule) {
border-left-color: var(--gk-gold-bright);
}
</style>
@@ -0,0 +1,75 @@
---
/**
* A single-choice question drawn as filter pills: real radio buttons inside
* gk-filter labels, so it works without a script and posts with the form.
*/
import Star from "@gk/ui/components/Star.astro";
interface Props {
legend: string;
name: string;
options: string[];
/** The option checked at first. */
checked?: string;
}
const { legend, name, options, checked } = Astro.props;
---
<fieldset class="pills">
<legend>{legend}</legend>
<div class="row">
{
options.map((o) => (
<label class="gk-filter pill">
<input type="radio" name={name} value={o} checked={o === checked} class="sr" />
<span class="gk-fs star"><Star size={9} /></span>
{o}
</label>
))
}
</div>
</fieldset>
<style>
.pills {
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
legend {
padding: 0;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.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;
}
</style>
@@ -0,0 +1,209 @@
---
/**
* The one gilt panel on Work: the featured case study with its role, summary,
* tech, key facts and a link to the full study.
*/
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import type { Note } from "../../lib/content.ts";
import { workGroup, workKicker } from "../../lib/work.ts";
interface Props {
study: Note<"post">;
href: string | null;
core: Set<string>;
}
const { study, href, core } = Astro.props;
const d = study.data;
const facts = d.facts ?? [];
---
<section aria-labelledby="work-featured" class="featured-wrap" data-group={workGroup(study)}>
<article class="panel">
<GiltCorners inset={4} />
<div class="inner">
<Seal name="transmutation" size={340} class="ghost" />
<div class="main">
<span class="kicker"><Star size={14} />Featured · {workKicker(study)}</span>
<div class="heading">
<h2 id="work-featured">{d.title}</h2>
<span class="underline" aria-hidden="true"></span>
</div>
{d.role && <span class="role">{d.role}</span>}
{d.period && <span class="period">{d.period}</span>}
{d.summary && <p class="summary">{d.summary}</p>}
{
d.tech?.length ? (
<div class="chips">
{d.tech.map((t) => (
<Chip core={core.has(t.toLowerCase())}>{t}</Chip>
))}
</div>
) : null
}
{href && <Button href={href} arrow class="cta">Read the case study</Button>}
</div>
{
facts.length > 0 && (
<div class="side">
<dl class="facts">
{facts.map((f) => (
<div class="fact">
<dt>{f.label}</dt>
<dd>
<span class="value">{f.value}</span>
<TickScale width={110} height={10} count={22} class="ticks" />
</dd>
</div>
))}
</dl>
</div>
)
}
</div>
</article>
</section>
<style>
.featured-wrap {
padding: 24px 0 8px;
}
.panel {
position: relative;
overflow: hidden;
background: var(--gk-midnight);
border: 1px solid var(--gk-gold);
border-radius: var(--gk-radius);
padding: 8px;
}
.inner {
position: relative;
border: 1px solid rgba(138, 101, 22, 0.35);
border-radius: 6px;
padding: 36px;
display: flex;
flex-wrap: wrap;
gap: 36px;
}
.inner :global(.ghost) {
position: absolute;
right: -60px;
bottom: -80px;
opacity: 0.12;
pointer-events: none;
}
.main {
flex: 1 1 420px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
}
.kicker {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.heading {
display: flex;
flex-direction: column;
gap: 8px;
}
h2 {
margin: 0;
font-size: clamp(30px, 5vw, 40px);
letter-spacing: 0.06em;
line-height: 1.1;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent),
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.role {
font-size: 15px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.period {
margin-top: -8px;
font-size: 13px;
color: var(--gk-muted);
}
.summary {
margin: 0;
font-size: 16px;
line-height: 1.65;
color: var(--gk-muted);
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.main :global(.cta) {
align-self: flex-start;
margin-top: 6px;
}
.side {
flex: 1 1 360px;
min-width: 0;
position: relative;
}
.facts {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.fact {
flex: 1 1 140px;
display: flex;
flex-direction: column;
padding: 20px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
background: var(--gk-sky);
}
.fact dd {
margin: 0;
}
.value {
display: block;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 36px;
color: var(--gk-gold-bright);
line-height: 1;
}
.fact :global(.ticks) {
margin: 6px 0 2px;
}
.fact dt {
order: 1;
margin-top: 6px;
font-size: 14px;
color: var(--gk-muted);
}
@media (max-width: 640px) {
.inner {
padding: 24px 20px;
}
}
</style>
@@ -0,0 +1,93 @@
---
/** A case study in the Work grid: kicker, title, role, summary, tech and a link. */
import Chip from "@gk/ui/components/Chip.astro";
import type { Note } from "../../lib/content.ts";
import { workGroup, workKicker } from "../../lib/work.ts";
interface Props {
study: Note<"post">;
href: string | null;
core: Set<string>;
}
const { study, href, core } = Astro.props;
const d = study.data;
const Tag = href ? "a" : "article";
---
<li class="item" data-group={workGroup(study)}>
<Tag href={href ?? undefined} class:list={["gk-card", "study"]}>
<span class="kicker">{workKicker(study)}</span>
<h3 class="title">{d.title}</h3>
{d.role && <span class="role">{d.role}</span>}
{d.summary && <span class="summary">{d.summary}</span>}
{
d.tech?.length ? (
<span class="chips">
{d.tech.slice(0, 5).map((t) => (
<Chip core={core.has(t.toLowerCase())}>{t}</Chip>
))}
{d.tech.length > 5 && <Chip>+{d.tech.length - 5}</Chip>}
</span>
) : null
}
{
href && (
<span class="more">
Read the case study
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
)
}
</Tag>
</li>
<style>
.item {
display: flex;
min-width: 0;
}
.study {
flex: 1;
gap: 12px;
padding: 26px;
border-top: 3px solid var(--gk-gold);
}
.kicker {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.title {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.05em;
}
.role {
font-size: 14px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.summary {
font-size: 15px;
line-height: 1.6;
color: var(--gk-muted);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
padding-top: 6px;
}
.more {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--gk-teal-bright);
}
</style>
@@ -0,0 +1,57 @@
---
/** The closing band on a world page: a line, a note and one or two buttons. */
interface Props {
title: string;
note?: string;
}
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>
<div class="btns"><slot /></div>
</aside>
<style>
.cta {
margin-top: 48px;
padding: 32px 36px;
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;
flex-direction: column;
gap: 6px;
}
.t {
margin: 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 24px;
letter-spacing: 0.05em;
}
.n {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
.btns {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 640px) {
.cta {
padding: 24px 20px;
}
}
</style>
@@ -0,0 +1,129 @@
---
/**
* The "Right now" / "At the table now" strip: a gold kicker over a tick scale
* and a row of gold-framed cards, each with a thumbnail, a label, a name and
* a short note.
*/
import Kicker from "@gk/ui/components/Kicker.astro";
import Seal from "@gk/ui/components/Seal.astro";
import type { SealName } from "@gk/ui/components/seals.ts";
export interface NowItem {
id?: string;
label: string;
title: string;
note?: string;
href?: string | null;
img?: string;
}
interface Props {
id: string;
heading: string;
items: NowItem[];
seal?: SealName;
}
const { id, heading, items, seal = "mini-astrolabe" } = Astro.props;
---
<section class="now" aria-labelledby={id}>
<h2 id={id} class="kick"><Kicker star>{heading}</Kicker></h2>
<ul class="row">
{
items.map((it) => {
const Tag = it.href ? "a" : "div";
return (
<li id={it.id}>
<Tag href={it.href ?? undefined} class="item">
{it.img ? (
<img src={it.img} alt="" class="thumb" loading="lazy" />
) : (
<span class="thumb blank" aria-hidden="true"><Seal name={seal} size={38} /></span>
)}
<span class="txt">
<span class="label">{it.label}</span>
<span class="title">{it.title}</span>
{it.note && <span class="note">{it.note}</span>}
</span>
</Tag>
</li>
);
})
}
</ul>
</section>
<style>
.now {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 28px;
}
.kick {
margin: 0;
font: inherit;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.row > li {
flex: 1 1 300px;
min-width: 0;
}
.item {
height: 100%;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
background: var(--gk-midnight);
border: 1px solid var(--gk-gold);
border-radius: var(--gk-radius);
color: var(--gk-text);
text-decoration: none;
transition: box-shadow var(--gk-ease);
}
a.item:hover {
box-shadow: 0 0 0 3px var(--gk-gold-tint);
}
.thumb {
flex: 0 0 64px;
width: 64px;
height: 64px;
border-radius: 8px;
object-fit: cover;
}
.blank {
display: flex;
align-items: center;
justify-content: center;
background: var(--gk-sky);
opacity: 0.8;
}
.txt {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.label {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.title {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 16px;
}
.note {
font-size: 13px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,70 @@
---
/**
* A page section on the world pages: a gold star, the Cinzel heading with the
* filament underline, an optional intro line, and an action at the right.
*/
import Star from "@gk/ui/components/Star.astro";
interface Props {
id: string;
title: string;
intro?: string;
class?: string;
}
const { id, title, intro, class: className } = Astro.props;
---
<section class:list={["ws", className]} aria-labelledby={id}>
<div class="head">
<div class="titles">
<h2 id={id}><Star size={14} />{title}</h2>
<span class="underline" aria-hidden="true"></span>
{intro && <p class="intro">{intro}</p>}
</div>
{Astro.slots.has("action") && <div class="action"><slot name="action" /></div>}
</div>
<slot />
</section>
<style>
.ws {
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 32px;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.titles {
display: flex;
flex-direction: column;
gap: 8px;
}
h2 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
letter-spacing: 0.06em;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.intro {
margin: 0;
font-size: 15px;
color: var(--gk-muted);
}
</style>
+242
View File
@@ -0,0 +1,242 @@
/**
* What the Blog, topic, post and case study pages ask beyond the SiteIndex:
* topics, reading time, page-link chips, Now chips, related posts, the
* archive and the post's table of contents.
*/
import { slugify, topics } from "@gk/schema";
import type { Note } from "./content.ts";
import { formatDate, mediaSrc, type SiteIndex } from "./site.ts";
export type Post = Note<"post">;
export type Topic = (typeof topics)[number];
export { topics };
/** The address of a topic page. */
export const topicHref = (topic: string) => `/blog/topic/${slugify(topic)}`;
/** The topic a URL segment names, or undefined. */
export const topicFromSlug = (slug: string | undefined): Topic | undefined => topics.find((t) => slugify(t) === slug);
/** Where each topic lives on the site, for the topic page's sidebar. */
export const topicHome: Record<Topic, { label: string; href: string; blurb: string }> = {
Making: { label: "Workshop", href: "/workshop", blurb: "Crafts, tools and makes" },
Tabletop: { label: "Tabletop", href: "/tabletop", blurb: "Campaigns, systems, decks and card games" },
Library: { label: "Library", href: "/library", blurb: "Books, shows, anime and games" },
"Work and tech": { label: "Work", href: "/work", blurb: "Case studies and what I build" },
Life: { label: "Now", href: "/now", blurb: "Everything I am into right now" },
};
/** Minutes to read the body, at 220 words a minute. */
export function readMinutes(body: string | undefined): number {
const words = (body ?? "")
.replace(/<!--[\s\S]*?-->/g, " ")
.split(/\s+/)
.filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
}
/** "Oct 11, 2026 · 4 min read". */
export const dateLine = (p: Post) => `${formatDate(p.data.date)} · ${readMinutes(p.body)} min read`;
/** The top slot: the newest pinned post (Featured), else the newest post (Latest). */
export function leadPost(posts: Post[]): { post: Post; label: "Featured" | "Latest" } | undefined {
const pinned = posts.find((p) => p.data.pinned);
if (pinned) return { post: pinned, label: "Featured" };
return posts[0] ? { post: posts[0], label: "Latest" } : undefined;
}
/** The section a note lives in, for chip labels like "Workshop · Leather". */
const SECTION: Record<string, string> = {
post: "Blog",
"library-item": "Library",
"board-game": "Shelf",
craft: "Workshop",
make: "Workshop",
station: "Workshop",
tool: "Workshop",
"tabletop-system": "Tabletop",
campaign: "Tabletop",
"card-game": "Tabletop",
deck: "Tabletop",
experience: "Resume",
status: "Now",
"product-line": "Store",
};
export function sectionOf(n: Note): string | undefined {
if (n.type === "post") return (n as Post).data.postType === "case study" ? "Work" : "Blog";
return SECTION[n.type];
}
/** A smaller label under the section, for the Connected pages panel. */
const KIND: Record<string, string> = {
"library-item": "Entry",
"board-game": "Board game",
craft: "Craft",
make: "Make",
station: "Station",
tool: "Tool",
"tabletop-system": "System",
campaign: "Campaign",
"card-game": "Card game",
deck: "Deck",
experience: "Role",
status: "Status",
"product-line": "Product line",
};
export function kindOf(n: Note): string | undefined {
if (n.type === "post") return (n as Post).data.postType === "case study" ? "Case study" : "Post";
return KIND[n.type];
}
/** "Workshop · Leather", or just the title for a page or unknown type. */
export function chipLabel(n: Note): string {
const section = sectionOf(n);
return section && section !== n.title ? `${section} · ${n.title}` : n.title;
}
export interface PageLink {
note: Note;
href: string;
label: string;
}
/** The published pages a post links to, as chips. Statuses are left to the Now chips. */
export function pageLinks(s: SiteIndex, post: Post): PageLink[] {
return s
.linksFrom(post)
.filter((n) => n.type !== "status" && n.path !== post.path)
.map((n) => ({ note: n, href: s.href(n), label: chipLabel(n) }))
.filter((l): l is PageLink => !!l.href);
}
/** The current statuses of the notes a post links to, one per kind and subject. */
export function nowStatuses(s: SiteIndex, post: Post): Note<"status">[] {
const seen = new Set<string>();
const out: Note<"status">[] = [];
for (const n of s.linksFrom(post)) {
const st = s.statusFor(n);
if (st && !seen.has(st.path)) {
seen.add(st.path);
out.push(st);
}
}
return out;
}
/** A note's first image: its cover, else its first photo or product image. */
export function imageOf(n: Note): string | undefined {
const d = n.data as Record<string, unknown>;
const photos = d.photos as string[] | undefined;
const images = d.images as { file: string }[] | undefined;
return mediaSrc((d.cover as string) ?? photos?.[0] ?? images?.[0]?.file);
}
/** Case-insensitive match on title and summary. */
export function searchPosts(posts: Post[], q: string): Post[] {
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return posts;
return posts.filter((p) => {
const hay = `${p.title} ${p.data.summary ?? ""}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
/** Posts by month, newest first: [{ month: "2026-10", label: "October 2026", count }]. */
export function archive(posts: Post[]): { month: string; label: string; count: number }[] {
const map = new Map<string, number>();
for (const p of posts) {
const m = p.data.date.slice(0, 7);
map.set(m, (map.get(m) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, count]) => ({ month, label: formatDate(`${month}-01`, { month: "long", day: false }), count }));
}
/**
* Posts related to this one: posts that link to it or that it links to count
* most, then posts sharing linked pages, newest first among equals.
*/
export function relatedPosts(s: SiteIndex, post: Post, pool: Post[], limit = 3): Post[] {
const mine = new Set(s.linksFrom(post).map((n) => n.path));
const backlinks = new Set(s.backlinks(post, { type: "post" }).map((n) => n.path));
const scored = pool
.filter((p) => p.path !== post.path)
.map((p) => {
let score = backlinks.has(p.path) || mine.has(p.path) ? 10 : 0;
for (const n of s.linksFrom(p)) if (mine.has(n.path)) score += 1;
return { p, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || b.p.data.date.localeCompare(a.p.data.date));
return scored.slice(0, limit).map((x) => x.p);
}
/** The posts before and after this one in a newest-first list. */
export function neighbours(list: Post[], post: Post): { previous?: Post; next?: Post } {
const i = list.findIndex((p) => p.path === post.path);
if (i < 0) return {};
return { previous: list[i + 1], next: list[i - 1] };
}
/**
* Give each h2 in rendered HTML an id and return the list, for the
* "On this page" panel.
*/
export function withHeadings(html: string): { html: string; headings: { id: string; text: string }[] } {
const headings: { id: string; text: string }[] = [];
const used = new Set<string>();
const out = html.replace(/<h2>([\s\S]*?)<\/h2>/g, (_, inner: string) => {
const text = inner.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, "&").trim();
let id = slugify(text) || "section";
for (let n = 2; used.has(id); n++) id = `${slugify(text)}-${n}`;
used.add(id);
headings.push({ id, text });
return `<h2 id="${id}">${inner}</h2>`;
});
return { html: out, headings };
}
/** Escape text for XML. */
export const xml = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
export interface Connected {
href: string;
kicker: string;
title: string;
sub: string;
img?: string;
}
/**
* The pages a post links to and the pages that link back to it, each with
* its section, picture and a line on how it connects.
*/
export function connectedPages(s: SiteIndex, post: Post): Connected[] {
const seen = new Map<string, Note>();
for (const n of [...s.linksFrom(post), ...s.backlinks(post)]) {
if (n.path === post.path || n.type === "post" || n.type === "status" || !s.href(n)) continue;
seen.set(n.path, n);
}
return [...seen.values()].map((n) => {
const parts: string[] = [];
if (s.linksFrom(n).some((x) => x.path === post.path)) parts.push("Links back here");
if (n.type === "craft") {
const makes = s.all("make").filter((m) => s.byName(m.data.craft)?.path === n.path).length;
if (makes) parts.push(`${makes} ${makes === 1 ? "make" : "makes"}`);
}
const posts = s.backlinks(n, { type: "post" }).length;
if (posts > 1) parts.push(`${posts} posts`);
return {
href: s.href(n)!,
kicker: [sectionOf(n), kindOf(n)].filter(Boolean).join(" · "),
title: n.title,
sub: parts.join(" · "),
img: imageOf(n),
};
});
}
+4 -1
View File
@@ -30,6 +30,8 @@ export interface Redirect {
const env = (k: string, fallback?: string) => process.env[k] ?? import.meta.env[k] ?? fallback; const env = (k: string, fallback?: string) => process.env[k] ?? import.meta.env[k] ?? fallback;
const baseUrl = () => (env("CONTENT_URL", "http://localhost:4310") as string).replace(/\/$/, ""); const baseUrl = () => (env("CONTENT_URL", "http://localhost:4310") as string).replace(/\/$/, "");
const token = () => env("CONTENT_TOKEN", "") as string; const token = () => env("CONTENT_TOKEN", "") as string;
/** Preview only: include unpublished notes. Needs an editor or admin token; never set in production. */
const includeDrafts = () => env("CONTENT_INCLUDE_DRAFTS", "") === "true";
const refreshMs = () => Number(env("CONTENT_REFRESH_SECONDS", "30")) * 1000; const refreshMs = () => Number(env("CONTENT_REFRESH_SECONDS", "30")) * 1000;
async function api<T>(path: string): Promise<T> { async function api<T>(path: string): Promise<T> {
@@ -44,7 +46,8 @@ async function api<T>(path: string): Promise<T> {
async function loadAll(): Promise<SiteIndex> { async function loadAll(): Promise<SiteIndex> {
const notes: Note[] = []; const notes: Note[] = [];
for (let offset = 0; ; offset += 1000) { for (let offset = 0; ; offset += 1000) {
const page = await api<Note[]>(`/api/notes?full=true&published=true&limit=1000&offset=${offset}`); const published = includeDrafts() ? "" : "&published=true";
const page = await api<(Note & { publish?: boolean })[]>(`/api/notes?full=true${published}&limit=1000&offset=${offset}`);
notes.push(...page); notes.push(...page);
if (page.length < 1000) break; if (page.length < 1000) break;
} }
+216
View File
@@ -0,0 +1,216 @@
/**
* Helpers for the Landing, About and Now pages: how a status reads (its
* subject, a meta line, the page chip), how the Now page groups statuses, and
* small lookups for the landing doors.
*/
import { world } from "@gk/ui/components/nav.ts";
import type { Note } from "./content.ts";
import { brandIcon, mediaSrc, type SiteIndex } from "./site.ts";
const MEDIUM_LABEL: Record<string, string> = {
book: "Books",
"light novel": "Light novels",
webnovel: "Webnovels",
manga: "Manga",
manhwa: "Manhwa",
"video game": "Video games",
anime: "Anime",
TV: "TV",
movie: "Movies",
};
const SUB_LABEL: Partial<Record<string, string>> = {
"tabletop-system": "Systems",
campaign: "Campaigns",
"card-game": "Card games",
deck: "Decks",
"board-game": "The Shelf",
station: "Stations",
tool: "Tools",
};
const PAGE_LABEL: Record<string, string> = {
...Object.fromEntries(world.map((w) => [w.href, w.label])),
"/library/shelf": "The Shelf",
"/blog": "Blog",
"/store": "Store",
"/work": "Work",
"/resume": "Resume",
"/about": "About me",
};
/** "Library" for "/library", else the last path part with a capital. */
export function pageLabel(path: string): string {
const clean = path.split("#")[0]!.replace(/\/$/, "") || "/";
if (PAGE_LABEL[clean]) return PAGE_LABEL[clean]!;
const last = clean.split("/").filter(Boolean).pop() ?? "Home";
return last.charAt(0).toUpperCase() + last.slice(1).replace(/-/g, " ");
}
export interface StatusView {
status: Note<"status">;
slug: string;
kind: string;
icon: string;
pinned: boolean;
title: string;
href: string | null;
subject?: Note;
/** A short line about the subject: creator, medium or platform. */
meta?: string;
note?: string;
date: string;
cover?: string;
chip?: { label: string; href: string };
upNext: boolean;
}
/** Everything a page shows about one status. */
export function viewStatus(s: SiteIndex, status: Note<"status">): StatusView {
const d = status.data;
const link = d.subject ? s.link(d.subject) : null;
const subject = link?.note;
const kind = s.kind(d.kind);
const sd = (subject?.data ?? {}) as Record<string, unknown>;
const metaParts: string[] = [];
if (subject?.type === "library-item") {
if (sd.creator) metaParts.push(String(sd.creator));
else if (sd.medium) metaParts.push(capital(String(sd.medium)));
if (sd.platform) metaParts.push(String(sd.platform));
} else if (subject?.type === "deck" && sd.format) {
metaParts.push(String(sd.format));
} else if (subject?.type === "campaign" && sd.platform) {
metaParts.push(String(sd.platform));
}
let sub: string | undefined;
if (subject?.type === "library-item") sub = MEDIUM_LABEL[String(sd.medium)];
else if (subject?.type === "make" || subject?.type === "craft") {
const craft = subject.type === "craft" ? subject : s.byName(String(sd.craft ?? ""));
sub = craft?.title;
} else if (subject) sub = SUB_LABEL[subject.type];
const page = kind.page;
const chip = page ? { label: sub ? `${pageLabel(page)} · ${sub}` : pageLabel(page), href: page } : undefined;
return {
status,
slug: status.slug,
kind: d.kind,
icon: brandIcon(kind.icon),
pinned: !!d.pinned,
title: link?.title ?? status.title,
href: link?.href ?? null,
subject,
meta: metaParts.join(" · ") || undefined,
note: d.note,
date: d.date,
cover: mediaSrc((sd.cover as string | undefined) ?? (status.data as { cover?: string }).cover),
chip,
upNext: isUpNext(d.kind, subject),
};
}
function isUpNext(kind: string, subject?: Note): boolean {
if (/up next|want to|queued|next/i.test(kind)) return true;
const sd = (subject?.data ?? {}) as Record<string, unknown>;
return subject?.type === "library-item" && sd.state === "up next";
}
const capital = (x: string) => x.charAt(0).toUpperCase() + x.slice(1);
/** The Now page's groups, in page order. */
export const NOW_GROUPS = [
{ key: "making", title: "Making" },
{ key: "table", title: "At the table" },
{ key: "playing", title: "Playing" },
{ key: "reading", title: "Reading and listening" },
{ key: "watching", title: "Watching" },
{ key: "other", title: "Elsewhere" },
{ key: "next", title: "Up next" },
] as const;
export type NowGroupKey = (typeof NOW_GROUPS)[number]["key"];
const READ = new Set(["book", "light novel", "webnovel", "manga", "manhwa"]);
const WATCH = new Set(["anime", "TV", "movie"]);
const TABLE = new Set(["tabletop-system", "campaign", "card-game", "deck", "board-game"]);
const MAKE = new Set(["craft", "make", "station", "tool"]);
/** Which Now group a status belongs to: its subject decides, else its kind. */
export function nowGroup(v: StatusView, s: SiteIndex): NowGroupKey {
if (v.upNext) return "next";
const t = v.subject?.type;
if (t && MAKE.has(t)) return "making";
if (t && TABLE.has(t)) return "table";
if (t === "library-item") {
const medium = String((v.subject!.data as Record<string, unknown>).medium);
if (READ.has(medium)) return "reading";
if (WATCH.has(medium)) return "watching";
if (medium === "video game") return "playing";
}
const k = v.kind.toLowerCase();
if (/read|listen/.test(k)) return "reading";
if (/watch|caught up/.test(k)) return "watching";
if (/play/.test(k)) return "playing";
const page = s.kind(v.kind).page ?? "";
if (page.startsWith("/workshop")) return "making";
if (page.startsWith("/tabletop")) return "table";
return "other";
}
/** Every status grouped for the Now page, empty groups left out. */
export function groupStatuses(s: SiteIndex): { key: NowGroupKey; title: string; items: StatusView[] }[] {
const views = s.statuses().map((st) => viewStatus(s, st));
return NOW_GROUPS.map((g) => ({ ...g, items: views.filter((v) => nowGroup(v, s) === g.key) })).filter((g) => g.items.length);
}
/** The newest status date, for "Last updated". */
export function lastUpdated(s: SiteIndex): string | undefined {
return s
.statuses()
.map((x) => x.data.date)
.sort()
.pop();
}
/** The current role (an experience with no end), newest first. */
export function currentRole(s: SiteIndex): Note<"experience"> | undefined {
return [...s.all("experience")].filter((e) => !e.data.end).sort((a, b) => b.data.start.localeCompare(a.data.start))[0];
}
/** A make photo for the My world door: a favorite first, then Galaxy dice, then any. */
export function worldPhoto(s: SiteIndex): { src: string; alt: string } | undefined {
const makes = s.all("make").filter((m) => m.data.cover || m.data.photos?.length);
const pick =
makes.find((m) => m.data.favorite) ?? makes.find((m) => m.slug === "galaxy-dice") ?? makes[0];
if (!pick) return undefined;
const src = mediaSrc(pick.data.cover ?? pick.data.photos?.[0]);
return src ? { src, alt: pick.data.blurb ?? pick.title } : undefined;
}
/** Minutes to read a body at 220 words a minute, at least one. */
export function readMinutes(body: string): number {
const words = body.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 220));
}
/** The featured post (pinned, else newest) and the two most recent after it. */
export function blogStrip(s: SiteIndex): { featured?: Note<"post">; recent: Note<"post">[] } {
const posts = s.posts();
const featured = posts.find((p) => p.data.pinned) ?? posts[0];
return { featured, recent: posts.filter((p) => p !== featured).slice(0, 2) };
}
/** Split a Page text body into its intro (before the first heading) and the sections after. */
export function splitIntro(body: string): { intro: string; sections: { heading: string; body: string }[] } {
const parts = body.split(/^##\s+/m);
const intro = parts.shift()?.trim() ?? "";
return {
intro,
sections: parts.map((p) => {
const [heading, ...rest] = p.split("\n");
return { heading: heading!.trim(), body: rest.join("\n").trim() };
}),
};
}
+164
View File
@@ -0,0 +1,164 @@
/**
* The Library and The Shelf: how items group, filter and sort. The pages
* filter on the server from the query string (so they work without JS) and a
* small script repeats the same rules live in the browser.
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
export type Item = Note<"library-item">;
export type Game = Note<"board-game">;
// ---------------------------------------------------------------- Library
/** The list's three groups. Dropped items are left out. */
export const sections = [
{ key: "now", label: "Now", states: ["now"] },
{ key: "next", label: "Up next", states: ["up next"] },
{ key: "done", label: "Finished", states: ["finished", "caught up", "played"] },
] as const;
export type SectionKey = (typeof sections)[number]["key"];
export const sectionOf = (item: Item): SectionKey | undefined =>
sections.find((s) => (s.states as readonly string[]).includes(item.data.state))?.key;
/** The Type filter: media gathered the way the rail lists them. */
export const mediaGroups = [
{ key: "books", label: "Books", media: ["book", "light novel"] },
{ key: "serials", label: "Webnovels, manga and manhwa", media: ["webnovel", "manga", "manhwa"] },
{ key: "games", label: "Video games", media: ["video game"] },
{ key: "screen", label: "Anime and TV", media: ["anime", "TV"] },
{ key: "movies", label: "Movies", media: ["movie"] },
] as const;
export type MediaKey = (typeof mediaGroups)[number]["key"];
export const mediaGroupOf = (item: Item): MediaKey | undefined =>
mediaGroups.find((g) => (g.media as readonly string[]).includes(item.data.medium))?.key;
/** The short medium label on the right of a row. */
export const mediumLabel = (medium: string) => (medium === "video game" ? "Game" : medium === "TV" ? "TV" : medium);
/** How Finished is ordered: favorites, then items with a verdict, then A to Z. */
export function byStanding(a: Item, b: Item): number {
return (
Number(!!b.data.favorite) - Number(!!a.data.favorite) ||
Number(!!b.data.verdict) - Number(!!a.data.verdict) ||
a.title.localeCompare(b.title)
);
}
export interface LibraryFilter {
type?: MediaKey;
q: string;
show: Set<SectionKey>;
favorites: boolean;
blog: boolean;
}
export function libraryFilter(params: URLSearchParams): LibraryFilter {
const type = mediaGroups.find((g) => g.key === params.get("type"))?.key;
// A submitted form sends `f`; before that every group shows.
const submitted = params.has("f");
const show = new Set<SectionKey>(
submitted ? (params.getAll("show").filter((v) => sections.some((s) => s.key === v)) as SectionKey[]) : sections.map((s) => s.key),
);
return {
type,
q: (params.get("q") ?? "").trim().toLowerCase(),
show,
favorites: params.get("fav") === "1",
blog: params.get("blog") === "1",
};
}
/** Text the search matches against. Kept in step with the row's visible text. */
export const searchText = (parts: (string | number | undefined)[]) =>
parts
.filter((p) => p !== undefined && p !== "")
.join(" ")
.toLowerCase();
/**
* The favorites constellation: favorites with a cover, taken in turn from each
* medium so the strip is not all one kind.
*/
export function constellation(items: Item[], max = 7): Item[] {
const favs = items.filter((i) => i.data.favorite && i.data.cover).sort(byStanding);
const byMedium = new Map<string, Item[]>();
for (const i of favs) byMedium.set(i.data.medium, [...(byMedium.get(i.data.medium) ?? []), i]);
const order = mediaGroups.flatMap((g) => g.media as readonly string[]);
const queues = [...byMedium.entries()].sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([, q]) => q);
const out: Item[] = [];
while (out.length < max && queues.some((q) => q.length)) {
for (const q of queues) {
const next = q.shift();
if (next && out.length < max) out.push(next);
}
}
return out;
}
// ---------------------------------------------------------------- Shelf
export const sorts = [
{ key: "played", label: "Last played" },
{ key: "az", label: "A to Z" },
{ key: "added", label: "Recently added" },
] as const;
export type SortKey = (typeof sorts)[number]["key"];
export function sortGames(games: Game[], sort: SortKey): Game[] {
const az = (a: Game, b: Game) => a.title.localeCompare(b.title);
const desc = (x?: string, y?: string) => (y ?? "").localeCompare(x ?? "");
const cmp: Record<SortKey, (a: Game, b: Game) => number> = {
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),
};
return [...games].sort(cmp[sort]);
}
export interface ShelfFilter {
q: string;
plays: Set<string>;
owned: boolean;
want: boolean;
favorites: boolean;
sort: SortKey;
}
export function shelfFilter(params: URLSearchParams): ShelfFilter {
const submitted = params.has("f");
return {
q: (params.get("q") ?? "").trim().toLowerCase(),
plays: new Set(params.getAll("plays").map((p) => p.toLowerCase())),
owned: submitted ? params.get("owned") === "1" : true,
want: params.get("want") === "1",
favorites: params.get("fav") === "1",
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.
*/
export function gameShows(g: Game, f: ShelfFilter, text: string): 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.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
return !f.q || text.includes(f.q);
}
/** Every "how it plays" value on the shelf, A to Z. */
export const playStyles = (games: Game[]) =>
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));
/** Statuses about a board game: what is on the table now. */
export const onTheTable = (s: SiteIndex) =>
s
.statuses()
.map((status) => ({ status, game: s.byName(status.data.subject) }))
.filter((x): x is { status: Note<"status">; game: Game } => x.game?.type === "board-game");
+1
View File
@@ -32,6 +32,7 @@ export function renderMarkdown(
opts: { link: (name: string) => { title: string; href: string | null }; imageSrc: (file: string) => string | undefined }, opts: { link: (name: string) => { title: string; href: string | null }; imageSrc: (file: string) => string | undefined },
): string { ): string {
const withLinks = body const withLinks = body
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, f: string, alt?: string) => `![${alt ?? ""}](${opts.imageSrc(f.trim()) ?? ""})`) .replace(/!\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, f: string, alt?: string) => `![${alt ?? ""}](${opts.imageSrc(f.trim()) ?? ""})`)
.replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => { .replace(/\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g, (_, n: string, label?: string) => {
const l = opts.link(n.trim()); const l = opts.link(n.trim());
+215
View File
@@ -0,0 +1,215 @@
/**
* The printable resume. Built from the same ResumeData as the /resume page,
* laid out as one plain column so applicant tracking systems read it in order.
* Paper colors: ink on white, with the brand gold and teal darkened for print.
*/
import PDFDocument from "pdfkit";
import cinzelBold from "../assets/pdf-fonts/Cinzel-Bold.ttf?inline";
import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline";
import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline";
import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline";
import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline";
import { month, period, type ResumeData } from "./resume.ts";
const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64");
const FONTS = {
title: fromDataUrl(cinzelBold),
heading: fromDataUrl(cinzelSemi),
body: fromDataUrl(montReg),
medium: fromDataUrl(montMed),
strong: fromDataUrl(montSemi),
};
const INK = "#131c2b";
const MUTED = "#566173";
const GOLD = "#a8842f";
const TEAL = "#0f7f78";
const RULE = "#d9cfb8";
const PAGE = { size: "LETTER" as const, margin: 50 };
const SITE = "gregkilmer.com";
export function renderResumePdf(r: ResumeData): Promise<Buffer> {
const doc = new PDFDocument({
size: PAGE.size,
margins: { top: PAGE.margin, bottom: PAGE.margin + 14, left: PAGE.margin, right: PAGE.margin },
bufferPages: true,
info: { Title: `${r.name} – Resume`, Author: r.name, Subject: r.headline, Keywords: "resume" },
lang: "en-US",
displayTitle: true,
});
for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data);
const left = doc.page.margins.left;
const width = doc.page.width - left - doc.page.margins.right;
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
/** Starts a new page when fewer than `need` points remain. */
const room = (need: number) => {
if (doc.y + need > doc.page.height - doc.page.margins.bottom) doc.addPage();
};
const diamond = (x: number, y: number, s: number, color = GOLD) => {
doc.save().moveTo(x, y - s).lineTo(x + s, y).lineTo(x, y + s).lineTo(x - s, y).closePath().fill(color).restore();
};
/** A thin gold rule with a small star at its center. */
const starRule = (y: number) => {
const mid = left + width / 2;
doc.save().lineWidth(0.6).strokeColor(GOLD);
doc.moveTo(left, y).lineTo(mid - 10, y).stroke();
doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke();
doc.restore();
diamond(mid, y, 4);
};
const section = (title: string) => {
room(60);
doc.moveDown(0.75);
const y = doc.y;
doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 });
const ruleY = doc.y + 2;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore();
doc.y = ruleY + 8;
};
const bullet = (text: string, indent = 12) => {
doc.font("body").fontSize(9.5);
const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 });
room(h + 4);
const y = doc.y;
diamond(left + 3.5, y + 6, 2.2);
doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 });
doc.moveDown(0.15);
};
// Header
doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 });
if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width });
// Only the professional profiles belong on paper; the hobby links stay on the site.
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
const contact = [r.location, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · ");
doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width });
if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width });
doc.moveDown(0.7);
starRule(doc.y);
doc.moveDown(0.6);
if (r.summary) {
doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 });
}
if (r.stats.length) {
section("Highlights");
const cols = Math.min(r.stats.length, 4);
const colW = width / cols;
let top = doc.y;
r.stats.forEach((st, i) => {
if (i > 0 && i % cols === 0) top = doc.y + 6;
const x = left + (i % cols) * colW;
doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 });
doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 });
if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 });
});
doc.x = left;
}
if (r.roles.length) {
section("Experience");
r.roles.forEach((role, i) => {
const d = role.data;
if (i > 0) doc.moveDown(0.6);
room(64);
const y = doc.y;
const when = period(role);
doc.font("medium").fontSize(9).fillColor(GOLD);
const whenW = doc.widthOfString(when);
doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false });
doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 });
const at = [d.company, d.location].filter(Boolean).join(" · ");
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width });
if (d.previousRoles?.length) {
doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width });
}
if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 });
doc.moveDown(0.3);
for (const b of d.bullets ?? []) bullet(b);
if (d.tech?.length) {
doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 });
}
doc.x = left;
});
}
if (r.skills?.stack.length) {
section("Skills");
for (const g of r.skills.stack) {
const items = g.items.join(", ");
doc.font("body").fontSize(9.5);
room(doc.heightOfString(items, { width: width - 110 }) + 4);
const y = doc.y;
doc.font("strong").fontSize(9.5).fillColor(INK).text(g.label, left, y, { width: 100 });
const afterLabel = doc.y;
doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 });
doc.y = Math.max(doc.y, afterLabel) + 3;
}
if (r.skills.core?.length) {
room(30);
const y = doc.y;
doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 });
doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 });
}
doc.x = left;
}
if (r.education?.degrees.length) {
section("Education");
for (const dg of r.education.degrees) {
room(40);
const y = doc.y;
doc.font("medium").fontSize(9).fillColor(GOLD);
const w = doc.widthOfString(dg.period);
doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false });
doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 });
doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width });
if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width });
doc.moveDown(0.4);
}
}
if (r.education?.certifications?.length) {
section("Certifications");
for (const c of r.education.certifications) {
room(20);
const line = [c.issuer, c.year].filter(Boolean).join(" · ");
doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line });
if (line) doc.font("body").fillColor(MUTED).text(` ${line}`);
}
}
// Footer on every page.
const range = doc.bufferedPageRange();
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const y = doc.page.height - PAGE.margin + 4;
const bottom = doc.page.margins.bottom;
doc.page.margins.bottom = 0;
doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, y - 8).lineTo(left + width, y - 8).stroke().restore();
doc.font("body").fontSize(8).fillColor(MUTED);
doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), left, y, { lineBreak: false });
if (range.count > 1) {
const label = `${i - range.start + 1} of ${range.count}`;
doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false });
}
doc.page.margins.bottom = bottom;
}
doc.end();
return done;
}
+49
View File
@@ -0,0 +1,49 @@
/** The resume's data, shared by the page and the PDF so they never disagree. */
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
export interface ResumeData {
name: string;
headline: string;
location?: string;
summary?: string;
availability?: string;
stats: { value: string; label: string; detail?: string }[];
roles: Note<"experience">[];
skills?: Note<"skills">["data"];
education?: Note<"education">["data"];
socials: { label: string; handle: string; url: string }[];
/** The newest change to any note the resume is built from (YYYY-MM-DD). */
updated: string;
}
/** "Mar 2019", from "2019-03". */
export const month = (ym: string | undefined) => {
if (!ym) return "Present";
const [y, m] = ym.split("-").map(Number);
return new Date(Date.UTC(y!, (m ?? 1) - 1, 1)).toLocaleDateString("en-US", { timeZone: "UTC", month: "short", year: "numeric" });
};
export const period = (r: Note<"experience">) => `${month(r.data.start)} – ${month(r.data.end)}`;
export function resumeData(s: SiteIndex): ResumeData {
const settings = s.settings();
const page = s.pageText("/resume");
const roles = [...s.all("experience")].sort((a, b) => (b.data.end ?? "9999").localeCompare(a.data.end ?? "9999") || b.data.start.localeCompare(a.data.start));
const current = roles.find((r) => !r.data.end) ?? roles[0];
const sources: Note[] = [...roles, ...(page ? [page] : []), ...[s.one("skills"), s.one("education")].filter((n): n is Note<"skills"> | Note<"education"> => !!n)];
const updated = sources.map((n) => n.acceptedAt?.slice(0, 10) ?? "").sort().at(-1) ?? "";
return {
name: settings?.name ?? "Greg Kilmer",
headline: page?.data.lede ?? current?.data.role ?? "",
location: settings?.location,
summary: page?.body?.trim() || undefined,
availability: settings?.availability,
stats: page?.data.stats ?? [],
roles,
skills: s.one("skills")?.data,
education: s.one("education")?.data,
socials: settings?.socials ?? [],
updated,
};
}
+27 -4
View File
@@ -218,10 +218,11 @@ export function mediaSrc(value: string | undefined): string | undefined {
return `/media/${encodeURIComponent(file.trim())}`; return `/media/${encodeURIComponent(file.trim())}`;
} }
/** Status kinds name icons; older notes use Material names, mapped here to brand icons. */ /** Notes name icons; older notes use Material names, mapped here to brand icons. */
const MDI: Record<string, string> = { const MDI: Record<string, string> = {
"book-open-variant": "bookshelf", "book-open-variant": "bookshelf",
"book-open-page-variant": "bookshelf", "book-open-page-variant": "bookshelf",
notebook: "bookshelf",
headphones: "play", headphones: "play",
"television-classic": "tv", "television-classic": "tv",
television: "tv", television: "tv",
@@ -229,11 +230,33 @@ const MDI: Record<string, string> = {
"dice-multiple": "dice", "dice-multiple": "dice",
"hammer-wrench": "hammer", "hammer-wrench": "hammer",
school: "graduation", school: "graduation",
"map-marker": "location",
"map-marker-outline": "location",
"briefcase-outline": "briefcase",
paw: "dog",
"cup-outline": "tea",
coffee: "tea",
"home-outline": "home",
"heart-outline": "heart",
"code-tags": "web",
laptop: "monitor",
"cards-playing": "proxy-cards",
"cards-playing-outline": "proxy-cards",
cards: "proxy-cards",
"printer-3d-nozzle": "print",
"printer-3d": "print",
printer: "print",
}; };
export function brandIcon(name: string | undefined): string { /** The brand icons in @gk/ui. */
if (!name) return "favorite"; const ICONS = new Set(
return MDI[name] ?? name; Object.keys(import.meta.glob("../../../../packages/ui/src/assets/icons/*.svg")).map((p) => p.replace(/^.*\/(.+)\.svg$/, "$1")),
);
/** A brand icon for a note's icon name, or the fallback when none matches. */
export function brandIcon(name: string | undefined, fallback = "favorite"): string {
const mapped = name ? (MDI[name] ?? name) : fallback;
return ICONS.has(mapped) ? mapped : fallback;
} }
/** "Oct 11, 2026" from "2026-10-11". */ /** "Oct 11, 2026" from "2026-10-11". */
+133
View File
@@ -0,0 +1,133 @@
/**
* The Store's rules in one place: which lines show, in what order, whether a
* line can take an order, its status line and its price summary.
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
import { brandIcon, formatDate } from "./site.ts";
export type Line = Note<"product-line">;
export type Category = Line["data"]["category"];
export type Channel = Line["data"]["channel"];
type Settings = Note<"store-settings">["data"];
export type CategorySettings = Settings["categories"][number];
export type Term = NonNullable<Line["data"]["terms"]>[number];
export const CATEGORIES: Category[] = ["Tabletop", "Paper", "Leather"];
/** Proxy cards are sold locally only, never on Etsy. */
export function isProxy(line: Line): boolean {
return /prox/i.test(`${line.title} ${line.slug} ${line.data.summary}`);
}
/**
* Where a line is sold. A proxy line a note marks as Etsy is treated as sold
* here, since proxies are never sold on Etsy.
*/
export function channelOf(line: Line): Channel {
if (line.data.channel === "Etsy" && isProxy(line)) return "here";
return line.data.channel;
}
/** The shop link for a line sold elsewhere, never for a proxy line on Etsy. */
export function shopLink(line: Line): { href: string; label: string } | null {
const ch = channelOf(line);
if ((ch !== "Etsy" && ch !== "Patiun.com") || !line.data.channelUrl) return null;
return { href: line.data.channelUrl, label: `Shop on ${ch}` };
}
export interface StoreState {
lines: Line[];
groups: [Category, Line[]][];
settings: Settings | undefined;
category(name: Category): CategorySettings;
/** Categories that have a line sold here and are closed. */
closed: CategorySettings[];
/** True when some line sold here sits in an open category. */
anyOpen: boolean;
terms: Term[];
}
export function storeState(s: SiteIndex): StoreState {
const settings = s.one("store-settings")?.data;
const lines = [...s.all("product-line")].sort(
(a, b) =>
CATEGORIES.indexOf(a.data.category) - CATEGORIES.indexOf(b.data.category) ||
(a.data.order ?? 999) - (b.data.order ?? 999) ||
a.title.localeCompare(b.title),
);
const category = (name: Category): CategorySettings =>
settings?.categories.find((c) => c.name === name) ?? { name, ordersOpen: false };
const groups = CATEGORIES.map((c) => [c, lines.filter((l) => l.data.category === c)] as [Category, Line[]]).filter(([, ls]) => ls.length);
const sellingCats = CATEGORIES.filter((c) => lines.some((l) => l.data.category === c && channelOf(l) === "here"));
return {
lines,
groups,
settings,
category,
closed: sellingCats.map(category).filter((c) => !c.ordersOpen),
anyOpen: sellingCats.some((c) => category(c).ordersOpen),
terms: settings?.terms ?? [],
};
}
/** Whether a line has room: open slots when made to order, stock when in stock. */
export function hasCapacity(line: Line): boolean {
return line.data.mode === "made to order" ? (line.data.slots?.open ?? 0) > 0 : (line.data.quantity ?? 0) > 0;
}
/** Whether the order button shows for this line. */
export function canOrder(line: Line, st: StoreState): boolean {
return channelOf(line) === "here" && st.category(line.data.category).ordersOpen && hasCapacity(line);
}
/** A short status: slots, stock, or where it is sold. */
export function statusText(line: Line): string {
const ch = channelOf(line);
if (ch === "coming soon") return "Coming soon";
if (ch !== "here") return `Sold on ${ch}`;
if (line.data.mode === "made to order") {
const sl = line.data.slots;
return sl ? `${Math.min(sl.open, sl.total)} of ${sl.total} ${sl.total === 1 ? "slot" : "slots"} open` : "Made to order";
}
const q = line.data.quantity ?? 0;
return q > 0 ? `${q} in stock` : "Sold out";
}
/** "Orders closed. Reopens Oct 11, 2026." for a category. */
export function closedText(c: CategorySettings): string {
return c.reopens ? `Reopens ${formatDate(c.reopens)}.` : "";
}
const money = (p: string) => {
const m = /^\s*\$?\s*([\d,]+(?:\.\d+)?)\s*$/.exec(p);
return m ? Number(m[1]!.replace(/,/g, "")) : null;
};
/** The lowest price, its note, and up to two other priced tiers. */
export function priceSummary(line: Line): { from: string; note?: string; more: string[] } | null {
const priced = line.data.priceTiers.map((t, i) => ({ ...t, i, n: money(t.price) })).filter((t) => t.n !== null);
if (!priced.length) return null;
const low = priced.reduce((a, b) => (b.n! < a.n! ? b : a));
const more = priced
.filter((t) => t !== low)
.sort((a, b) => b.n! - a.n!)
.slice(0, 2)
.sort((a, b) => a.i - b.i)
.map((t) => `${t.band} ${t.price}`);
return { from: low.price, note: low.note, more };
}
/** Option groups in note order, with their options. */
export function optionGroups(line: Line): [string, NonNullable<Line["data"]["options"]>][] {
const map = new Map<string, NonNullable<Line["data"]["options"]>>();
for (const o of line.data.options ?? []) map.set(o.group, [...(map.get(o.group) ?? []), o]);
return [...map.entries()];
}
const CATEGORY_ICONS: Record<Category, string> = { Tabletop: "dice", Paper: "bookshelf", Leather: "leather" };
/** A brand icon for a line: its own when it maps to one, else its category's. */
export function lineIcon(line: Line): string {
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
}
+114
View File
@@ -0,0 +1,114 @@
/** Helpers for the Work index and the Inquiries pages. */
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
export type WorkGroup = "employer" | "personal" | "homelab";
export const groupLabels: Record<WorkGroup, string> = {
employer: "Employer",
personal: "Personal",
homelab: "Homelab",
};
/**
* Which group a case study belongs to: employer work has an employer; a
* project without one is homelab when it is tagged or described as such,
* otherwise personal.
*/
export function workGroup(p: Note<"post">): WorkGroup {
if (p.data.employer) return "employer";
const tags = (p.data.tags ?? []).map((t) => t.toLowerCase());
if (tags.includes("homelab") || /\bhome\s?lab\b/i.test(`${p.data.summary} ${p.body}`)) return "homelab";
return "personal";
}
/** "Implico Inc." as "Implico" for kickers. */
export function shortEmployer(name: string): string {
return name.replace(/,?\s+(Inc\.?|LLC|Ltd\.?|Corp\.?|GmbH)$/i, "").trim();
}
/** The gold kicker over a case study: "Employer · Implico", "Homelab". */
export function workKicker(p: Note<"post">): string {
const g = workGroup(p);
return g === "employer" && p.data.employer ? `${groupLabels[g]} · ${shortEmployer(p.data.employer)}` : groupLabels[g];
}
/**
* The featured study: the newest one marked favorite or pinned, else the
* newest. Same-day ties go to employer work, then to the one with more facts.
*/
export function featuredStudy(studies: Note<"post">[]): Note<"post"> | undefined {
const marked = studies.filter((p) => p.data.favorite || p.data.pinned);
const pool = marked.length ? marked : studies;
const newest = pool[0]?.data.date;
return pool
.filter((p) => p.data.date === newest)
.sort((a, b) => Number(!!b.data.employer) - Number(!!a.data.employer) || (b.data.facts?.length ?? 0) - (a.data.facts?.length ?? 0))[0];
}
const groupOrder: WorkGroup[] = ["employer", "homelab", "personal"];
/** The rest of the studies in group order (employer, homelab, personal), newest first within a group. */
export function otherStudies(studies: Note<"post">[], featured: Note<"post"> | undefined): Note<"post">[] {
return studies
.filter((p) => p !== featured)
.map((p, i) => ({ p, i }))
.sort((a, b) => groupOrder.indexOf(workGroup(a.p)) - groupOrder.indexOf(workGroup(b.p)) || a.i - b.i)
.map((x) => x.p);
}
/** Tech the resume outlines as the core stack, lower-cased, for the teal-edged chips. */
export function coreTech(s: SiteIndex): Set<string> {
const stack = s.one("skills")?.data.stack ?? [];
return new Set(stack.flatMap((g) => g.core ?? []).map((t) => t.toLowerCase()));
}
// ---------------------------------------------------------------- Inquiries
export type InquiryType = "job" | "freelance" | "patiun";
export const inquiryTypes: { value: InquiryType; label: string; blurb: string; summary: string }[] = [
{ value: "job", label: "A job or role", blurb: "Full-time roles and contracts", summary: "A job or role" },
{ value: "freelance", label: "Freelance web work", blurb: "Sites, apps and integrations", summary: "Freelance web work" },
{ value: "patiun", label: "Custom Patiun order", blurb: "Cards, deck boxes and more", summary: "Patiun custom order" },
];
/** `?type=` as an inquiry type; also accepts a few natural spellings. */
export function parseInquiryType(raw: string | null | undefined): InquiryType | undefined {
const v = (raw ?? "").trim().toLowerCase();
if (!v) return undefined;
if (["job", "role", "jobs", "hire", "hiring"].includes(v)) return "job";
if (["freelance", "web", "freelance-web"].includes(v)) return "freelance";
if (["patiun", "order", "custom", "custom-order", "store"].includes(v)) return "patiun";
return undefined;
}
/**
* What the sent page shows under "What you sent". The inquiries API (phase 6)
* sets this as the short-lived `gk-inquiry` cookie (URL-encoded JSON) before
* redirecting to /inquiries/sent.
*/
export interface SentSummary {
type?: InquiryType;
name?: string;
email?: string;
/** Extra rows in order, like { label: "How many", value: "60 cards" }. */
rows?: { label: string; value: string }[];
}
export function parseSentSummary(raw: string | undefined): SentSummary | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record<string, unknown>;
const str = (x: unknown) => (typeof x === "string" && x.trim() ? x.trim().slice(0, 300) : undefined);
const rows = Array.isArray(v.rows)
? v.rows
.map((r) => ({ label: str((r as Record<string, unknown>)?.label), value: str((r as Record<string, unknown>)?.value) }))
.filter((r): r is { label: string; value: string } => !!r.label && !!r.value)
.slice(0, 8)
: [];
return { type: parseInquiryType(str(v.type)), name: str(v.name), email: str(v.email), rows };
} catch {
return undefined;
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Helpers for the Workshop, craft and Tabletop pages: ordering, craft seals,
* make photos, the sword rack, and which pill a state wears.
*/
import type { SealName } from "@gk/ui/components/seals.ts";
import type { Note } from "./content.ts";
import { mediaSrc, type SiteIndex } from "./site.ts";
type Pill = "live" | "state" | "current" | "neutral";
/** By `order`, notes without one last, then by title. */
export function byOrder<T extends Note>(notes: T[]): T[] {
const o = (n: Note) => ((n.data as { order?: number }).order ?? Number.MAX_SAFE_INTEGER);
return [...notes].sort((a, b) => o(a) - o(b) || a.title.localeCompare(b.title));
}
/** Newest `date` first; undated notes keep their order after the dated ones. */
export function byDateDesc<T extends Note>(notes: T[]): T[] {
const d = (n: Note) => String((n.data as { date?: string }).date ?? "");
return [...notes].sort((a, b) => d(b).localeCompare(d(a)));
}
/** A craft's own seal: dice crafts, leather, print and paper. Undefined means the family seal. */
export function craftSeal(craft: Note<"craft">): SealName | undefined {
const text = `${craft.title} ${craft.data.blurb ?? ""}`.toLowerCase();
if (/leather/.test(text)) return "craft-leather";
if (/dice|resin/.test(text)) return "craft-dice";
if (/print|paper|bookbind|card|decal/.test(text)) return "craft-print";
return undefined;
}
/** A make's photos as image addresses (the `photos` list, else its `cover`). */
export function makePhotos(make: Note<"make">): string[] {
const d = make.data as { photos?: string[]; cover?: string };
const list = d.photos?.length ? d.photos : d.cover ? [d.cover] : [];
return list.map((p) => mediaSrc(p)).filter((x): x is string => !!x);
}
/** Makes tagged for the sword rack. */
export function isSword(make: Note<"make">): boolean {
const tags = make.data.tags ?? [];
return tags.some((t) => /sword/i.test(t));
}
/** The makes of a craft. */
export function makesOf(s: SiteIndex, craft: Note<"craft">): Note<"make">[] {
return s.all("make").filter((m) => s.byName(m.data.craft)?.path === craft.path);
}
/** Build logs for a craft: posts linking to it or its makes. */
export function buildLogs(s: SiteIndex, craft: Note<"craft">): Note<"post">[] {
return s.backlinks(craft, { type: "post" }) as Note<"post">[];
}
/** The tools of a craft, as notes where published, in the craft's order. */
export function toolsOf(s: SiteIndex, craft: Note<"craft">): { title: string; note?: Note<"tool"> }[] {
return (craft.data.tools ?? []).map((t) => {
const l = s.link(t);
return { title: l.title, note: l.note?.type === "tool" ? (l.note as Note<"tool">) : undefined };
});
}
/** The tools placed at a station. */
export function toolsAt(s: SiteIndex, station: Note<"station">): Note<"tool">[] {
return s.all("tool").filter((t) => s.byName(t.data.station)?.path === station.path);
}
export const craftPill = (status: string): Pill => (status === "Paused" ? "neutral" : "live");
export const systemPill = (state: string): Pill => (state === "running" ? "live" : "neutral");
/** Card game stances: the main event is gold, active ones live, the rest plain. */
export function stancePill(stance: string): Pill {
const s = stance.toLowerCase();
if (s === "main event") return "current";
if (/getting into|exploring|playing|learning/.test(s)) return "live";
return "neutral";
}
/** "want to run" -> "Want to run". */
export const sentence = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Order systems are grouped in. */
export const systemStates = ["running", "played", "want to run", "want to play", "want to try"] as const;
/** Statuses whose kind belongs to a page (Making and Learning for /workshop). */
export function statusesFor(s: SiteIndex, page: string) {
return s.statuses().filter((st) => s.kind(st.data.kind).page === page);
}
/** Archidekt deck address. */
export const archidektUrl = (id: number) => `https://archidekt.com/decks/${id}`;
/** Deck's list address: Archidekt when set, else its own list link. */
export function deckUrl(deck: Note<"deck">): string | undefined {
return deck.data.archidektId ? archidektUrl(deck.data.archidektId) : deck.data.listUrl;
}
+162
View File
@@ -0,0 +1,162 @@
---
/**
* Off the star chart. Renders even when the content service is down: the
* only data it reads (an optional Page text note for /404) is wrapped.
*/
import Button from "@gk/ui/components/Button.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import StarPath from "@gk/ui/components/StarPath.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { site } from "../lib/page.ts";
Astro.response.status = 404;
let lede = "This page isn't here. It may have moved when the site was rebuilt.";
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const s = await Promise.race([site(), new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error("timeout")), 2000)))]);
lede = s.pageText("/404")?.data.lede ?? lede;
} catch {
// The content service is unreachable; the page needs nothing from it.
} finally {
clearTimeout(timer);
}
const groups = [
{ id: "nf-work", label: "Work with me", links: [["Resume", "/resume"], ["Work", "/work"], ["Inquiries", "/inquiries"]] },
{ id: "nf-world", label: "My world", links: [["Library", "/library"], ["Workshop", "/workshop"], ["Tabletop", "/tabletop"], ["Now", "/now"]] },
{ id: "nf-also", label: "Also", links: [["Blog", "/blog"], ["Store", "/store"], ["About", "/about"]] },
];
---
<PageShell title="Off the star chart" family="notFound" description="This page isn't here.">
<meta slot="head" name="robots" content="noindex" />
<section class="lost" aria-labelledby="nf-title">
<Seal name="zodiac" size={180} class="seal" />
<span class="eyebrow">404 · Page not found</span>
<h1 id="nf-title">Off the star chart</h1>
<p class="lede">{lede}</p>
<div class="actions">
<Button href="/">Back home</Button>
<Button href="/blog" variant="secondary">Blog</Button>
</div>
</section>
<div class="path" aria-hidden="true"><StarPath seed="not-found" strength="calm" centerStar={false} /></div>
<nav class="groups" aria-label="Elsewhere on the site">
{
groups.map((g) => (
<section class="group" aria-labelledby={g.id}>
<h2 id={g.id} class="kicker">
<Star size={11} />
{g.label}
</h2>
<TickScale width={150} height={10} count={30} class="ticks" />
<ul>
{g.links.map(([label, href]) => (
<li>
<a href={href}>{label}</a>
</li>
))}
</ul>
</section>
))
}
</nav>
</PageShell>
<style>
.lost {
max-width: 680px;
margin: 0 auto;
padding: 40px 0 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.lost :global(.seal) {
opacity: 0.85;
filter: drop-shadow(0 0 22px rgba(43, 194, 183, 0.35));
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(32px, 6vw, 46px);
letter-spacing: 0.06em;
}
.lede {
margin: 0;
font-size: 17px;
line-height: 1.6;
color: var(--gk-soft);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 6px;
}
.path {
max-width: 680px;
margin: 0 auto;
padding: 16px 0;
}
.groups {
max-width: 680px;
margin: 0 auto;
padding: 8px 0 32px;
display: flex;
flex-wrap: wrap;
gap: 28px;
}
.group {
flex: 1 1 180px;
display: flex;
flex-direction: column;
gap: 8px;
}
.kicker {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.group :global(.ticks) {
margin: -4px 0 2px;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li a {
display: block;
padding: 6px 0;
border-top: 1px solid var(--gk-lines);
font-size: 15px;
font-weight: 600;
color: var(--gk-text);
text-decoration: none;
transition: color var(--gk-ease);
}
li a:hover {
color: var(--gk-teal-bright);
}
</style>
+435
View File
@@ -0,0 +1,435 @@
---
/**
* About me: the "I am Greg" intro beside the portrait, the longer story with
* quick facts and what is happening right now, the two wings of the site, and
* where else to find Greg. Text from the Page text note for /about.
*/
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 TickScale from "@gk/ui/components/TickScale.astro";
import PageHero from "../components/PageHero.astro";
import DoorLink from "../components/home/DoorLink.astro";
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";
const s = await site();
const text = s.pageText("/about");
const settings = s.settings();
const { intro, sections } = splitIntro(text?.body ?? "");
const introHtml = s.render(intro);
const facts = text?.data.quickFacts ?? [];
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 ?? [];
const SOCIAL_ICONS: Record<string, string> = { github: "web", linkedin: "briefcase", archidekt: "proxy-cards" };
---
<PageShell title="About me" family="about" current="about" description={text?.data.lede ?? "The person behind the projects."}>
<PageHero
slot="hero"
eyebrow="About"
title="The person behind the projects"
lede={text?.data.lede ?? "Two wings of the same house. This page is the bridge between them."}
/>
<section aria-label="Introduction" class="intro">
<div class="intro-text">
{introHtml ? <blockquote class="iam" set:html={introHtml} /> : <blockquote class="iam"><p>[PLACEHOLDER]</p></blockquote>}
</div>
<div class="intro-portrait">
<Portrait variant="panel" seal="astrolabe" />
</div>
</section>
<div class="story">
<div class="arc">
{
sections.map((sec, i) => (
<section aria-labelledby={`ab-${i}`} class="arc-section">
<SectionHeading id={`ab-${i}`}>
<Star size={14} />
{sec.heading}
</SectionHeading>
<div class="prose" set:html={s.render(sec.body)} />
</section>
))
}
</div>
<aside aria-label="Quick facts" class="facts">
<div class="panel">
<span class="panel-kicker"><Star size={11} /><span>Quick facts</span></span>
<TickScale width={200} height={10} count={30} class="panel-ticks" />
{
facts.length > 0 && (
<ul class="fact-list">
{facts.map((f) => (
<li>
<Icon name={brandIcon(f.icon)} size={18} class="fact-icon" />
<span>{f.text}</span>
</li>
))}
</ul>
)
}
{
now.length > 0 && (
<div class="right-now">
<span class="rn-label">Right now</span>
<ul class="rn-list">
{now.map((v) => (
<li>
<span class="rn-kind">{v.kind}</span>
<a class="rn-title" href={`/now#${v.slug}`}>{v.title}</a>
</li>
))}
</ul>
<p class="rn-note">
Same status notes as the <a href="/now">Now page</a>, so both change together.
{nowUpdated && <> Updated <time datetime={nowUpdated}>{formatDate(nowUpdated)}</time>.</>}
</p>
</div>
)
}
</div>
{
socials.length > 0 && (
<div class="panel">
<span class="panel-kicker"><Star size={11} /><span>Elsewhere</span></span>
<TickScale width={200} height={10} count={30} class="panel-ticks" />
<ul class="socials">
{socials.map((x) => (
<li>
<a href={x.url} rel="me noopener" class="social">
<Icon name={SOCIAL_ICONS[x.label.toLowerCase()] ?? "link"} size={18} />
<span class="s-label">{x.label}</span>
<span class="s-handle">{x.handle}</span>
<Icon name="external" size={14} class="s-ext" />
</a>
</li>
))}
</ul>
</div>
)
}
</aside>
</div>
<section aria-labelledby="ab-doors" class="wings">
<SectionHeading id="ab-doors">
<Star size={14} />
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-row">
<div class="wing featured">
<GiltCorners inset={6} size={30} />
<span class="wing-eyebrow">For employers and clients</span>
<h3 class="wing-title">Work with me</h3>
<ul class="wing-links">
<DoorLink size="small" href="/resume" title="Resume" />
<DoorLink size="small" href="/work" title="Work" />
<DoorLink size="small" href="/inquiries" title="Inquiries" />
</ul>
</div>
<div class="wing">
<span class="wing-eyebrow">For friends and fellow nerds</span>
<h3 class="wing-title">My world</h3>
<ul class="wing-links">
<DoorLink size="small" href="/library" title="Library" />
<DoorLink size="small" href="/workshop" title="Workshop" />
<DoorLink size="small" href="/tabletop" title="Tabletop" />
<DoorLink size="small" href="/blog" title="Blog" />
</ul>
</div>
</div>
</div>
</section>
</PageShell>
<style>
.intro {
padding-top: 40px;
padding-bottom: 8px;
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: center;
}
.intro-text {
flex: 3 1 520px;
min-width: 0;
}
.iam {
margin: 0;
padding: 4px 0 4px 22px;
border-left: 3px solid var(--gk-gold);
font-size: 22px;
line-height: 1.6;
max-width: 60ch;
}
.iam :global(p) {
margin: 0;
}
.iam :global(p + p) {
margin-top: 14px;
}
.intro-portrait {
flex: 1 1 260px;
max-width: 340px;
min-width: 0;
}
.story {
padding-top: 48px;
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
}
.arc {
flex: 3 1 560px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 32px;
}
.arc-section {
display: flex;
flex-direction: column;
gap: 18px;
}
.arc-section :global(h2),
.wings :global(h2) {
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
}
.prose {
font-size: 17px;
line-height: 1.75;
color: var(--gk-soft);
max-width: 68ch;
}
.prose :global(p) {
margin: 0 0 18px;
}
.prose :global(p:last-child) {
margin-bottom: 0;
}
.facts {
flex: 1 1 280px;
max-width: 340px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
}
.panel-kicker {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.panel :global(.panel-ticks) {
margin: -4px 0 2px;
}
.fact-list,
.rn-list,
.socials {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.fact-list li {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 14px;
line-height: 1.5;
}
.fact-list :global(.fact-icon) {
flex: none;
color: var(--gk-gold-bright);
margin-top: 1px;
}
.right-now {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--gk-lines);
}
.rn-label {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.rn-list {
gap: 8px;
}
.rn-list li {
display: flex;
flex-direction: column;
gap: 1px;
}
.rn-kind {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
color: var(--gk-teal-bright);
text-transform: uppercase;
}
.rn-title {
font-size: 14px;
font-weight: 600;
color: var(--gk-text);
text-decoration: none;
}
.rn-title:hover {
color: var(--gk-teal-bright);
}
.rn-note {
margin: 0;
font-size: 12px;
color: var(--gk-muted);
line-height: 1.5;
}
.social {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
color: var(--gk-text);
text-decoration: none;
font-size: 14px;
}
.social:hover {
color: var(--gk-teal-bright);
}
.s-label {
font-weight: 600;
}
.s-handle {
color: var(--gk-muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.social :global(.s-ext) {
margin-left: auto;
flex: none;
}
.wings {
padding-top: 56px;
padding-bottom: 8px;
display: flex;
flex-direction: column;
gap: 18px;
}
.wing-wrap {
position: relative;
isolation: isolate;
}
.wing-seal {
position: absolute;
inset: -70px 0;
z-index: -1;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
opacity: 0.12;
overflow: hidden;
}
.wing-row {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.wing {
flex: 1 1 380px;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 26px;
border-radius: var(--gk-radius);
background: rgba(19, 28, 43, 0.7);
border: 1px solid var(--gk-lines);
}
.wing.featured {
border-color: rgba(201, 162, 74, 0.55);
}
.wing-eyebrow {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.wing-title {
margin: 0;
font-size: 26px;
letter-spacing: 0.06em;
}
.wing-links {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.wing-links :global(.door-link) {
color: var(--gk-text);
}
.wing-links :global(.door-link:hover) {
color: var(--gk-gold-bright);
}
@media (max-width: 900px) {
.facts {
max-width: none;
}
}
@media (max-width: 640px) {
.iam {
font-size: 19px;
}
.wing {
padding: 22px 20px;
}
.intro-portrait {
max-width: none;
}
}
</style>
+29
View File
@@ -0,0 +1,29 @@
---
/**
* One blog post in the all-dark reading layout. A case study's slug answers
* with a 301 to /work/<slug>; an old slug follows its redirect.
*/
import PostLayout from "../../components/blog/PostLayout.astro";
import { topicHref } from "../../lib/blog.ts";
import { site } from "../../lib/page.ts";
const s = await site();
const slug = Astro.params.slug ?? "";
const post = s.bySlug("post", slug);
if (!post) {
const to = s.redirect("post", slug);
return to ? Astro.redirect(to, 301) : Astro.rewrite("/404");
}
if (post.data.postType === "case study") return Astro.redirect(`/work/${post.slug}`, 301);
const list = s.posts();
---
<PostLayout
post={post}
site={s}
family="post"
current="blog"
crumbs={[{ label: "Blog", href: "/blog" }, { label: post.data.topic, href: topicHref(post.data.topic) }]}
list={list}
pool={list}
/>
+169
View File
@@ -0,0 +1,169 @@
---
/**
* The Blog: the featured or latest post, topic pills, search, the posts as
* rows, and the sidebar (Right now, Topics, Archive, RSS). Case studies live
* on Work and are not listed here.
*/
import PageShell from "@gk/ui/components/PageShell.astro";
import Button from "@gk/ui/components/Button.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import Icon from "@gk/ui/components/Icon.astro";
import SearchField from "@gk/ui/components/SearchField.astro";
import PageHero from "../../components/PageHero.astro";
import BlogLayout from "../../components/blog/BlogLayout.astro";
import LeadPost from "../../components/blog/LeadPost.astro";
import PostList from "../../components/blog/PostList.astro";
import RightNowPanel from "../../components/blog/RightNowPanel.astro";
import RowList from "../../components/blog/RowList.astro";
import SidePanel from "../../components/blog/SidePanel.astro";
import StarHeading from "../../components/blog/StarHeading.astro";
import TopicsPanel from "../../components/blog/TopicsPanel.astro";
import { archive, leadPost, searchPosts, topicHref, topics } from "../../lib/blog.ts";
import { site } from "../../lib/page.ts";
import { formatDate } from "../../lib/site.ts";
const PER_PAGE = 10;
const s = await site();
const all = s.posts();
const params = Astro.url.searchParams;
const q = (params.get("q") ?? "").trim().slice(0, 100);
const month = /^\d{4}-\d{2}$/.test(params.get("month") ?? "") ? params.get("month")! : "";
const page = Math.max(1, Number.parseInt(params.get("page") ?? "1", 10) || 1);
const filtered = !!(q || month);
let list = searchPosts(all, q);
if (month) list = list.filter((p) => p.data.date.startsWith(month));
const lead = !filtered && page === 1 ? leadPost(all) : undefined;
if (lead) list = list.filter((p) => p.path !== lead.post.path);
const shown = list.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const more = list.length > page * PER_PAGE;
const keep = (extra: Record<string, string | number>) => {
const u = new URLSearchParams();
if (q) u.set("q", q);
if (month) u.set("month", month);
for (const [k, v] of Object.entries(extra)) u.set(k, String(v));
return `/blog?${u}`;
};
const text = s.pageText("/blog");
const lede = text?.data.lede ?? "What I am making, learning, playing and building, and how it went.";
const months = archive(all);
const heading = q ? `Posts matching “${q}”` : month ? `Posts from ${formatDate(`${month}-01`, { month: "long", day: false })}` : lead ? "More posts" : "Posts";
const empty = all.length === 0
? {
emptyTitle: "The first posts are on their way",
emptyText: "Nothing is published here yet. Until then, the Now page shows what I am reading, playing and making.",
emptyAction: { label: "See what I am into now", href: "/now" },
}
: lead && list.length === 0 && !filtered
? { emptyTitle: "That is the only post so far", emptyText: "More are on their way." }
: { emptyTitle: "No posts match", emptyText: "Try another word, or browse by topic.", emptyAction: { label: "Show every post", href: "/blog" } };
---
<PageShell title="Blog" family="blog" current="blog" description={lede}>
<Fragment slot="head">
<link rel="alternate" type="application/rss+xml" title="Greg Kilmer" href="/rss.xml" />
</Fragment>
<Fragment slot="hero">
<PageHero eyebrow="My world" title="Blog" lede={lede} />
</Fragment>
{
lead && (
<section class="top" aria-label={lead.label === "Featured" ? "Featured post" : "Latest post"}>
<LeadPost post={lead.post} label={lead.label} site={s} />
</section>
)
}
<BlogLayout aside="Browse the blog">
<section aria-labelledby="bl-posts" class="posts">
<div class="bar">
<StarHeading id="bl-posts">{heading}</StarHeading>
{list.length > 0 && <span class="sort">Sort: <b>Newest</b></span>}
</div>
<FilterGroup
label="Topics"
items={[{ label: "All", href: "/blog", active: !filtered }, ...topics.map((t) => ({ label: t, href: topicHref(t) }))]}
/>
<form method="get" action="/blog" role="search" class="search">
<SearchField label="Search posts" placeholder="Search posts" value={q} class="field" />
{month && <input type="hidden" name="month" value={month} />}
<Button type="submit" variant="secondary">Search</Button>
</form>
<PostList posts={shown} site={s} {...empty} />
{
(more || page > 1) && (
<nav class="pager" aria-label="Older and newer posts">
{page > 1 && <Button variant="text" href={keep({ page: page - 1 })}>Newer posts</Button>}
{more && <Button variant="secondary" href={keep({ page: page + 1 })}>Load older posts</Button>}
</nav>
)
}
</section>
<Fragment slot="aside">
<RightNowPanel site={s} />
<TopicsPanel site={s} />
{
months.length > 0 && (
<SidePanel title="Archive" seal="astrolabe">
<RowList items={months.map((m) => ({ label: m.label, href: `/blog?month=${m.month}`, count: m.count, current: m.month === month }))} />
</SidePanel>
)
}
<a href="/rss.xml" class="gk-text rss"><Icon name="web" size={18} />RSS feed</a>
</Fragment>
</BlogLayout>
</PageShell>
<style>
.top {
padding-top: 32px;
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.posts {
display: flex;
flex-direction: column;
gap: 16px;
}
.sort {
font-size: 13px;
color: var(--gk-muted);
}
.sort b {
color: var(--gk-text);
font-weight: 600;
}
.search {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.search :global(.field) {
flex: 1 1 260px;
}
.pager {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 20px;
padding-top: 8px;
}
.rss {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
font-size: 14px;
font-weight: 600;
}
</style>
@@ -0,0 +1,156 @@
---
/**
* A blog topic: its posts as rows, filterable by the page they link to, and
* a sidebar naming where the topic lives on the site and every topic.
*/
import PageShell from "@gk/ui/components/PageShell.astro";
import Button from "@gk/ui/components/Button.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import Seal from "@gk/ui/components/Seal.astro";
import BlogLayout from "../../../components/blog/BlogLayout.astro";
import CrumbHero from "../../../components/blog/CrumbHero.astro";
import PostList from "../../../components/blog/PostList.astro";
import TopicsPanel from "../../../components/blog/TopicsPanel.astro";
import { pageLinks, topicFromSlug, topicHome, topicHref } from "../../../lib/blog.ts";
import { site } from "../../../lib/page.ts";
const PER_PAGE = 10;
const topic = topicFromSlug(Astro.params.topic);
if (!topic) return Astro.rewrite("/404");
const s = await site();
const base = topicHref(topic);
const posts = s.posts({ topic });
// Filters: the pages this topic's posts link to, most posts first.
const byPage = new Map<string, { label: string; slug: string; count: number }>();
for (const p of posts) {
for (const l of pageLinks(s, p)) {
if (l.note.type === "post") continue;
const cur = byPage.get(l.note.path) ?? { label: l.note.title, slug: l.note.slug, count: 0 };
cur.count++;
byPage.set(l.note.path, cur);
}
}
const filters = [...byPage.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)).slice(0, 8);
const on = Astro.url.searchParams.get("on") ?? "";
const active = filters.find((f) => f.slug === on);
const page = Math.max(1, Number.parseInt(Astro.url.searchParams.get("page") ?? "1", 10) || 1);
const list = active ? posts.filter((p) => pageLinks(s, p).some((l) => l.note.slug === active.slug)) : posts;
const shown = list.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const more = list.length > page * PER_PAGE;
const pageHref = (n: number) => `${base}?${new URLSearchParams({ ...(active ? { on: active.slug } : {}), page: String(n) })}`;
const firstPage = (p: (typeof posts)[number]) => pageLinks(s, p).find((l) => l.note.type !== "post")?.note.title;
const kicker = (p: (typeof posts)[number]) => {
const sub = active?.label ?? firstPage(p);
return sub ? `${topic} · ${sub}` : topic;
};
const home = topicHome[topic];
const lede = s.pageText(base)?.data.lede;
---
<PageShell title={`${topic} · Blog`} family="blog" current="blog" description={lede ?? `Posts about ${topic.toLowerCase()}.`}>
<Fragment slot="hero">
<CrumbHero crumbs={[{ label: "Blog", href: "/blog" }, { label: "Topic" }]} title={topic} lede={lede} />
</Fragment>
<BlogLayout aside="More topics">
<section aria-label={`Posts about ${topic}`} class="posts">
{
filters.length > 1 && (
<FilterGroup
label={`Filter ${topic} posts`}
items={[
{ label: `All ${topic.toLowerCase()}`, href: base, active: !active },
...filters.map((f) => ({ label: f.label, href: `${base}?on=${f.slug}`, active: f === active })),
]}
/>
)
}
<PostList
posts={shown}
site={s}
kicker={kicker}
as="h2"
emptyTitle={posts.length ? "No posts here" : `Nothing on ${topic.toLowerCase()} yet`}
emptyText={posts.length ? "Try another filter." : `The first ${topic.toLowerCase()} post is on its way. Meanwhile, ${home.label} shows what is going on.`}
emptyAction={posts.length ? { label: `All ${topic.toLowerCase()} posts`, href: base } : { label: `Visit ${home.label}`, href: home.href }}
/>
{
(more || page > 1) && (
<nav class="pager" aria-label="Older and newer posts">
{page > 1 && <Button variant="text" href={pageHref(page - 1)}>Newer posts</Button>}
{more && <Button variant="secondary" href={pageHref(page + 1)}>Load older posts</Button>}
</nav>
)
}
</section>
<Fragment slot="aside">
<a href={home.href} class="home">
<Seal name="mini-transmutation" size={56} />
<span class="hw">
<span class="k">This topic lives in</span>
<span class="t">{home.label}</span>
<span class="b">{home.blurb}</span>
</span>
</a>
<TopicsPanel site={s} title="All topics" current={topic} />
</Fragment>
</BlogLayout>
</PageShell>
<style>
.posts {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 8px;
}
.pager {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 20px;
padding-top: 8px;
}
.home {
display: flex;
gap: 14px;
align-items: center;
padding: 18px 20px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
text-decoration: none;
color: var(--gk-text);
transition: border-color var(--gk-ease);
}
.home:hover {
border-color: var(--gk-gold);
}
.hw {
display: flex;
flex-direction: column;
gap: 3px;
}
.k {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.t {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 18px;
}
.b {
font-size: 12px;
color: var(--gk-muted);
}
</style>
+357 -2
View File
@@ -1,8 +1,363 @@
--- ---
/**
* The landing: Greg's name and one line, the two doors (work first), the Now
* strip from the status log and the newest posts.
*/
import Button from "@gk/ui/components/Button.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Kicker from "@gk/ui/components/Kicker.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 StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import BlogStrip from "../components/home/BlogStrip.astro";
import DoorLink from "../components/home/DoorLink.astro";
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 { site } from "../lib/page.ts";
const s = await site(); const s = await site();
const settings = s.settings();
const text = s.pageText("/");
const role = currentRole(s);
const skills = s.one("skills")?.data;
const name = settings?.name ?? "Greg Kilmer";
const roleTitle = role?.data.role.split(" / ").pop()?.trim();
const eyebrow = [roleTitle, settings?.location].filter(Boolean).join(" · ");
const intro = text?.data.lede ? `<p>${text.data.lede}</p>` : s.render(text?.body);
const studies = s.caseStudies().map((c) => c.title);
const coreStack = skills?.stack.flatMap((g) => g.core ?? []) ?? [];
const photo = worldPhoto(s);
const statuses = s.statuses().slice(0, 5).map((st) => viewStatus(s, st));
const listFormat = new Intl.ListFormat("en", { style: "long", type: "conjunction" });
--- ---
<PageShell title="Greg Kilmer" family="landing" current="home"> <PageShell title="Greg Kilmer" family="landing" current="home" description={settings?.tagline}>
<p>{s.notes.length} notes, {s.statuses().length} statuses.</p> <Fragment slot="hero">
<div class="intro">
<div class="who">
{
eyebrow && (
<Kicker class="eyebrow">
<span class="dot" aria-hidden="true" />
{eyebrow}
</Kicker>
)
}
<h1 class="name">{name}</h1>
{intro ? <div class="lede" set:html={intro} /> : <p class="lede">[PLACEHOLDER]</p>}
</div>
<div class="portrait-wrap">
<Portrait variant="hero" seal="transmutation-star-paths" />
</div>
</div>
<div class="doors">
<section aria-labelledby="door-work" class="door work">
<Seal name="transmutation" size={340} class="work-seal" />
<GiltCorners inset={10} />
<div class="door-head">
<div class="door-eyebrow">For employers and clients</div>
<div class="hd">
<h2 id="door-work">Work with me</h2>
<span class="underline" aria-hidden="true"></span>
</div>
{role?.data.focus && <p class="pitch">{role.data.focus}</p>}
</div>
<ul class="door-links">
<DoorLink href="/resume" title="Resume" line="One page to scan, plus a PDF" />
<DoorLink href="/work" title="Work" line={studies.length ? `Case studies: ${listFormat.format(studies)}` : "Case studies"} />
<DoorLink href="/inquiries" title="Inquiries" line="Jobs, freelance web work, custom orders" />
</ul>
<div class="door-foot">
<Button href="/inquiries">Start an inquiry</Button>
{coreStack.length > 0 && <span class="stack">{coreStack.join(" · ")}</span>}
{settings?.availability && <span class="stack">{settings.availability}</span>}
</div>
</section>
<section aria-labelledby="door-world" class="door world">
<StarChartArt seal="craft-dice" ratio="auto" src={photo?.src} alt={photo?.alt ?? ""} class="world-photo" />
<div class="world-body">
<span class="world-ticks" aria-hidden="true"><TickScale width={400} height={10} count={60} major={6} /></span>
<Seal name="mini-transmutation" size={90} class="world-seal" />
<Kicker>For friends and fellow nerds</Kicker>
<div class="hd">
<h2 id="door-world">My world</h2>
<span class="underline" aria-hidden="true"></span>
</div>
<div class="world-links">
<Button variant="secondary" href="/library">Library</Button>
<Button variant="secondary" href="/blog">Blog</Button>
<Button variant="secondary" href="/blog/topic/making">Making</Button>
<Button variant="secondary" href="/store">Store</Button>
</div>
</div>
</section>
</div>
</Fragment>
<section aria-labelledby="now-strip" class="now">
<div class="now-head">
<SectionHeading eyebrow="Status log" id="now-strip">Now</SectionHeading>
<a href="/now" class="gk-text all">Everything I am into</a>
</div>
{
statuses.length ? (
<ul class="now-grid">
{statuses.map((v) => (
<NowCard view={v} />
))}
</ul>
) : (
<p class="empty">Nothing on the status log yet.</p>
)
}
<BlogStrip site={s} />
</section>
</PageShell> </PageShell>
<style>
.intro {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 40px;
padding-top: 0;
margin-top: -8px;
}
.who {
flex: 999 1 520px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.who :global(.eyebrow) {
align-self: flex-start;
}
.who :global(.eyebrow .gk-kicker-text) {
gap: 10px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--gk-teal-bright);
flex: none;
}
.name {
margin: 0;
font-size: clamp(40px, 7vw, 56px);
line-height: 1.02;
letter-spacing: 0.1em;
}
.lede {
margin: 0;
padding-left: 18px;
border-left: 2px solid var(--gk-gold);
font-size: 18px;
line-height: 1.65;
color: var(--gk-soft);
max-width: 58ch;
}
.lede :global(p) {
margin: 0;
}
.lede :global(p + p) {
margin-top: 12px;
}
.portrait-wrap {
flex: 1 1 260px;
display: flex;
justify-content: center;
}
.doors {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding-top: 24px;
padding-bottom: 24px;
}
.door {
position: relative;
overflow: hidden;
min-width: 0;
border-radius: var(--gk-radius-lg);
display: flex;
flex-direction: column;
}
.hd {
display: flex;
flex-direction: column;
gap: 8px;
}
.hd h2 {
margin: 0;
letter-spacing: 0.08em;
}
.underline {
display: block;
width: 180px;
max-width: 60%;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.work {
flex: 3 1 520px;
padding: 34px;
gap: 20px;
color: var(--gk-starlight);
background: radial-gradient(ellipse at 0% 0%, rgba(20, 163, 154, 0.22), transparent 55%), var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.7);
box-shadow:
inset 0 0 0 6px var(--gk-midnight),
inset 0 0 0 7px rgba(201, 162, 74, 0.45);
}
.work :global(.work-seal) {
position: absolute;
right: -90px;
top: -90px;
opacity: 0.14;
pointer-events: none;
}
.work > :not(:global(.work-seal), :global(.gk-corner)) {
position: relative;
}
.door-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.door-eyebrow {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.work h2 {
font-size: clamp(28px, 4vw, 36px);
}
.pitch {
margin: 0;
font-size: 16px;
line-height: 1.6;
max-width: 52ch;
}
.door-links {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
border-top: 1px solid rgba(244, 242, 237, 0.35);
}
.door-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px 20px;
}
.stack {
font-size: 13px;
color: var(--gk-soft);
}
.world {
flex: 2 1 360px;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
}
.world :global(.world-photo) {
height: 190px;
border-radius: 0;
border-bottom: 2px solid var(--gk-gold);
}
.world-body {
position: relative;
isolation: isolate;
flex: 1;
padding: 26px;
display: flex;
flex-direction: column;
gap: 14px;
background:
radial-gradient(ellipse 60% 90% at 100% 100%, rgba(106, 47, 224, 0.18), transparent 70%),
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 32px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.05) 0 1px, transparent 1px 32px);
}
.world-ticks {
position: absolute;
left: 26px;
right: 26px;
top: 0;
overflow: hidden;
opacity: 0.6;
}
.world :global(.world-seal) {
position: absolute;
right: 14px;
bottom: 10px;
z-index: -1;
opacity: 0.3;
}
.world h2 {
font-size: 30px;
}
.world-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.now {
padding-top: 24px;
padding-bottom: 24px;
display: flex;
flex-direction: column;
gap: 22px;
}
.now-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.now-head :global(h2) {
font-size: 30px;
letter-spacing: 0.08em;
}
.all {
font-size: 14px;
}
.now-grid {
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 18px;
}
.empty {
margin: 0;
color: var(--gk-muted);
}
@media (max-width: 640px) {
.work {
padding: 24px 20px;
}
.world-body {
padding: 22px 20px;
}
}
</style>
@@ -0,0 +1,421 @@
---
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import HeroHorizon from "@gk/ui/components/HeroHorizon.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import ChoicePills from "../../components/work/ChoicePills.astro";
import { site } from "../../lib/page.ts";
import { inquiryTypes, parseInquiryType, type InquiryType } from "../../lib/work.ts";
const s = await site();
const settings = s.settings();
const text = s.pageText("/inquiries");
const selected = parseInquiryType(Astro.url.searchParams.get("type"));
const lede = text?.data.lede ?? "Roles, freelance web work or a custom Patiun order. Pick one and the form asks the right questions.";
const quickFacts = text?.data.quickFacts?.map((f) => f.text) ?? ["Replies within [PLACEHOLDER] days", "Read by me, not a bot"];
const socials = settings?.socials ?? [];
const placeholders: Record<InquiryType | "none", string> = {
none: "Tell me what you have in mind.",
job: "The role, the team, and anything I should know.",
freelance: "What you need built, who it is for, and any links.",
patiun: "Card list, deck colors, theme, or anything I should know.",
};
const timing = [
{ value: "no-rush", label: "No rush" },
{ value: "within-a-month", label: "Within a month" },
{ value: "date", label: "A specific date" },
];
const env = (k: string) => (process.env[k] ?? import.meta.env[k]) as string | undefined;
const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY");
---
<PageShell title="Inquiries" family="inquiries" current="inquiries" description="Ask about a role, freelance web work or a custom Patiun order.">
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
<div class="split">
<div class="intro">
<Seal name="transmutation" size={140} class="seal" />
<span class="eyebrow">Inquiries</span>
<h1>Let's make something</h1>
<span class="underline" aria-hidden="true"></span>
<p class="lede">{lede}</p>
{settings?.availability && <StatusPill class="avail">{settings.availability}</StatusPill>}
<HeroHorizon />
<ul class="facts">
{
quickFacts.map((f) => (
<li>
<Star size={12} />
{f}
</li>
))
}
{
socials.length > 0 && (
<li>
<Star size={12} />
<span>
Also on{" "}
{socials.map((x, i) => (
<>
<a href={x.url} rel="me noopener">{x.label}</a>
{i < socials.length - 2 ? ", " : i === socials.length - 2 ? " and " : ""}
</>
))}
</span>
</li>
)
}
</ul>
</div>
<form class="form" method="post" action="/api/inquiries" enctype="multipart/form-data" aria-label="Inquiry" data-inquiry>
<div class="frame">
<fieldset class="types">
<legend>What's this about?</legend>
<div class="tiles">
{
inquiryTypes.map((t) => (
<label class="tile">
<input type="radio" name="type" value={t.value} checked={t.value === selected} required class="sr" />
<span class="tick" aria-hidden="true"><Star size={12} /></span>
<span class="tile-name">{t.label}</span>
<span class="tile-blurb">{t.blurb}</span>
</label>
))
}
</div>
</fieldset>
<div class="pair">
<Field label="Your name" name="name" placeholder="Jane Doe" autocomplete="name" required maxlength={120} />
<Field label="Email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required maxlength={200} />
</div>
<div class="q q-job" data-q="job">
<div class="pair">
<Field label="Company" name="job_company" placeholder="Where is the role?" autocomplete="organization" maxlength={160} />
<Field label="Role" name="job_role" placeholder="e.g. Senior full-stack developer" maxlength={160} />
</div>
<ChoicePills legend="Kind of role" name="job_kind" options={["Full-time", "Contract", "Not sure yet"]} />
<Field label="Link to the posting" name="job_link" type="url" placeholder="https://" hint="Optional." maxlength={500} />
</div>
<div class="q q-freelance" data-q="freelance">
<ChoicePills legend="What do you need?" name="freelance_need" options={["A new site", "An app", "An integration", "Something else"]} />
<div class="pair">
<Field label="Your current site" name="freelance_site" type="url" placeholder="https://" hint="Optional." maxlength={500} />
<Field label="Timeline" name="freelance_timing" as="select" options={timing} />
</div>
<Field label="Date" name="freelance_date" type="date" class="when-date" />
</div>
<div class="q q-patiun" data-q="patiun">
<ChoicePills legend="What would you like made?" name="patiun_item" options={["Proxy cards", "Deck box", "Something else"]} />
<div class="pair">
<Field label="How many?" name="patiun_quantity" placeholder="e.g. 60 cards" maxlength={120} />
<Field label="Need it by" name="patiun_timing" as="select" options={timing} />
</div>
<Field label="Date" name="patiun_date" type="date" class="when-date" />
</div>
<Field label="Details" name="details" as="textarea" rows={5} required maxlength={5000} placeholder={placeholders[selected ?? "none"]} data-details />
<label class="upload">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4M7 9l5-5 5 5M5 20h14" /></svg>
<span>Add a reference image or decklist <span class="browse">Browse</span><span class="file-name" data-file-name></span></span>
<input type="file" name="attachment" accept="image/*,.pdf,.txt,.csv,.dek,.dec" class="sr" data-file />
</label>
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark"></div>}
<div class="submit">
<Button type="submit">Send inquiry</Button>
{turnstileKey && <span class="note">Protected by Cloudflare Turnstile.</span>}
</div>
</div>
</form>
</div>
</PageShell>
<script>
const form = document.querySelector<HTMLFormElement>("[data-inquiry]");
if (form) {
const details = form.querySelector<HTMLTextAreaElement>("[data-details]");
const hints: Record<string, string> = {
job: "The role, the team, and anything I should know.",
freelance: "What you need built, who it is for, and any links.",
patiun: "Card list, deck colors, theme, or anything I should know.",
};
// Only the chosen type's questions are sent.
const sync = () => {
const type = form.querySelector<HTMLInputElement>('input[name="type"]:checked')?.value;
form.querySelectorAll<HTMLElement>("[data-q]").forEach((q) => {
q.querySelectorAll<HTMLInputElement | HTMLSelectElement>("input, select").forEach((el) => (el.disabled = q.dataset.q !== type));
});
if (details && type && hints[type]) details.placeholder = hints[type];
};
form.querySelectorAll('input[name="type"]').forEach((r) => r.addEventListener("change", sync));
sync();
const file = form.querySelector<HTMLInputElement>("[data-file]");
const fileName = form.querySelector<HTMLElement>("[data-file-name]");
file?.addEventListener("change", () => {
if (fileName) fileName.textContent = file.files?.[0] ? ` · ${file.files[0].name}` : "";
});
}
</script>
<style>
.split {
padding: 24px 0 24px;
display: flex;
flex-wrap: wrap;
gap: 48px;
align-items: flex-start;
}
.intro {
flex: 1 1 340px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.intro :global(.seal) {
width: 140px;
height: 140px;
opacity: 0.9;
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.08em;
line-height: 1.05;
}
.underline {
display: block;
width: 180px;
height: 2px;
margin-top: -8px;
border-radius: 2px;
background:
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent),
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.lede {
margin: 0;
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
font-size: 17px;
line-height: 1.65;
color: var(--gk-soft);
max-width: 44ch;
}
.intro :global(.avail) {
align-self: flex-start;
}
.facts {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 14px;
color: var(--gk-muted);
}
.facts li {
display: flex;
align-items: center;
gap: 10px;
}
.form {
position: relative;
flex: 1 1 540px;
min-width: 0;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
border-radius: var(--gk-radius);
padding: 8px;
}
.frame {
border: 1px solid rgba(138, 101, 22, 0.35);
border-radius: 6px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.form :global(.gk-label-text) {
font-size: 14px;
color: var(--gk-text);
}
.form :global(.gk-field) {
font-size: 15px;
}
.types {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.types legend {
padding: 0;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
}
.tiles {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 72px;
padding: 14px 16px;
border-radius: var(--gk-radius);
border: 1px solid var(--gk-lines);
background: var(--gk-sky);
cursor: pointer;
transition: border-color var(--gk-ease), background var(--gk-ease);
}
.tile:hover {
border-color: var(--gk-gold-line);
}
.tile:has(:checked) {
border-color: var(--gk-gold);
background:
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.04) 0 1px, transparent 1px 14px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.04) 0 1px, transparent 1px 14px),
rgba(201, 162, 74, 0.1);
}
.tile:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
.tick {
position: absolute;
right: 10px;
top: 10px;
display: none;
}
.tile:has(:checked) .tick {
display: inline-flex;
}
.tile-name {
font-size: 14px;
font-weight: 700;
color: var(--gk-text);
padding-right: 18px;
}
.tile:has(:checked) .tile-name {
color: var(--gk-starlight);
}
.tile-blurb {
font-size: 12px;
color: var(--gk-muted);
}
.sr {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.pair {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.pair > :global(*) {
flex: 1 1 240px;
min-width: 0;
}
/* Each type's questions show once that type is picked; no script needed. */
.q {
display: none;
flex-direction: column;
gap: 20px;
}
.form:has(input[name="type"][value="job"]:checked) .q-job,
.form:has(input[name="type"][value="freelance"]:checked) .q-freelance,
.form:has(input[name="type"][value="patiun"]:checked) .q-patiun {
display: flex;
}
.q :global(.when-date) {
display: none;
}
.q:has(option[value="date"]:checked) :global(.when-date) {
display: flex;
}
.upload {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
border: 1px dashed var(--gk-lines);
border-radius: var(--gk-radius);
background: var(--gk-sky);
font-size: 14px;
color: var(--gk-muted);
cursor: pointer;
transition: border-color var(--gk-ease);
}
.upload svg {
flex: none;
color: var(--gk-teal-bright);
}
.upload:hover {
border-color: var(--gk-lines-strong);
}
.upload:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
.browse {
color: var(--gk-teal-bright);
font-weight: 600;
}
.file-name {
color: var(--gk-text);
}
.submit {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.note {
font-size: 13px;
color: var(--gk-muted);
}
@media (max-width: 640px) {
.tiles {
grid-template-columns: 1fr;
}
.frame {
padding: 20px 16px;
}
.intro :global(.seal) {
width: 96px;
height: 96px;
}
}
</style>
@@ -0,0 +1,229 @@
---
/**
* The confirmation after an inquiry. The inquiries API sets the short-lived
* `gk-inquiry` cookie (URL-encoded JSON: type, name, email, rows) before it
* redirects here; the page shows it once and clears it. Without the cookie
* the page still reads correctly, showing `?type=` when given.
*/
import Button from "@gk/ui/components/Button.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { inquiryTypes, parseInquiryType, parseSentSummary } from "../../lib/work.ts";
const sent = parseSentSummary(Astro.cookies.get("gk-inquiry")?.value);
if (sent) Astro.cookies.delete("gk-inquiry", { path: "/" });
Astro.response.headers.set("Cache-Control", "private, no-store");
const type = sent?.type ?? parseInquiryType(Astro.url.searchParams.get("type"));
const typeLabel = inquiryTypes.find((t) => t.value === type)?.summary;
const rows = [
...(typeLabel ? [{ label: "Type", value: typeLabel }] : []),
...(sent?.rows ?? []),
...(sent?.email ? [{ label: "Reply to", value: sent.email }] : []),
];
const firstName = sent?.name?.split(/\s+/)[0];
const steps = [
{ title: "I read it", text: "Usually within a day or two." },
{ title: "I reply", text: "With questions, a quote, or a time to talk." },
{ title: "We settle the details", text: "Nothing starts until you say yes." },
];
---
<PageShell title="Inquiry sent" family="inquiries" current="inquiries" description="Your inquiry is on its way.">
<meta slot="head" name="robots" content="noindex" />
<div class="split">
<div class="intro">
<Seal name="transmutation" size={132} class="seal" />
<span class="eyebrow">Inquiry sent</span>
<h1>Your note is on its way</h1>
<p class="lede">Thanks{firstName ? `, ${firstName}` : ""}. I read every inquiry myself and reply within [PLACEHOLDER] days.</p>
<ol class="steps" aria-label="What happens next">
{
steps.map((st, i) => (
<li>
<TickRing size={36}>{i + 1}</TickRing>
<span class="step-text">
<span class="step-title">{st.title}</span>
<span class="step-detail">{st.text}</span>
</span>
</li>
))
}
</ol>
</div>
<div class="side">
{
rows.length > 0 && (
<section class="sent" aria-labelledby="sent-heading">
<h2 id="sent-heading" class="kicker">
<Star size={11} />
What you sent
</h2>
<TickScale width={150} height={10} count={30} class="ticks" />
<dl>
{rows.map((r) => (
<div class="row">
<dt>{r.label}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
</section>
)
}
<nav class="wait" aria-labelledby="wait-heading">
<h2 id="wait-heading" class="wait-kicker">While you wait</h2>
<div class="wait-links">
<Button href="/blog" variant="secondary">Blog</Button>
<Button href="/workshop" variant="secondary">Workshop</Button>
<Button href="/" variant="text">Back home</Button>
</div>
</nav>
</div>
</div>
</PageShell>
<style>
.split {
padding: 32px 0 24px;
display: flex;
flex-wrap: wrap;
gap: 48px;
align-items: flex-start;
}
.intro {
flex: 1 1 420px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.intro :global(.seal) {
filter: drop-shadow(0 0 18px rgba(201, 162, 74, 0.35));
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(32px, 6vw, 46px);
letter-spacing: 0.06em;
line-height: 1.1;
}
.lede {
margin: 0;
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
font-size: 17px;
line-height: 1.6;
color: var(--gk-soft);
max-width: 46ch;
}
.steps {
margin: 8px 0 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.steps li {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 14px 0;
border-top: 1px solid var(--gk-lines);
font-size: 13px;
}
.step-text {
display: flex;
flex-direction: column;
gap: 3px;
}
.step-title {
font-weight: 700;
font-size: 15px;
}
.step-detail {
font-size: 14px;
color: var(--gk-muted);
line-height: 1.5;
}
.side {
flex: 1 1 440px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.sent {
position: relative;
padding: 28px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
}
.kicker {
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.sent :global(.ticks) {
margin: -4px 0 2px;
}
dl {
margin: 8px 0 0;
}
.row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-top: 1px solid var(--gk-lines);
font-size: 14px;
}
dt {
color: var(--gk-muted);
}
dd {
margin: 0;
font-weight: 600;
text-align: right;
overflow-wrap: anywhere;
}
.wait {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
border-radius: var(--gk-radius);
border: 1px solid var(--gk-lines);
}
.wait-kicker {
margin: 0;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.wait-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
---
/**
* The Library: every book, series, show, film and video game. The favorites
* 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";
import FilterLayout from "../../components/library/FilterLayout.astro";
import LibraryHero from "../../components/library/LibraryHero.astro";
import LibraryRow from "../../components/library/LibraryRow.astro";
import ListHeading from "../../components/library/ListHeading.astro";
import RailGroup from "../../components/library/RailGroup.astro";
import { byStanding, constellation, libraryFilter, mediaGroupOf, mediaGroups, sectionOf, sections, searchText } from "../../lib/library.ts";
import { site } from "../../lib/page.ts";
const s = await site();
const f = libraryFilter(Astro.url.searchParams);
const items = s.all("library-item").filter((i) => sectionOf(i));
const lede = s.pageText("/library")?.data.lede ?? "What I'm on now, what's next, and what stuck with me. Short verdicts, no scores.";
const shows = (i: (typeof items)[number]) =>
(!f.type || mediaGroupOf(i) === f.type) &&
f.show.has(sectionOf(i)!) &&
(!f.favorites || !!i.data.favorite) &&
(!f.blog || s.backlinks(i, { type: "post" }).length > 0) &&
(!f.q || searchText([i.title, i.data.creator, i.data.group, i.data.verdict, i.data.note, i.data.medium, i.data.platform, i.data.year, i.data.progress]).includes(f.q));
const groups = sections.map((sec) => {
const list = items.filter((i) => sectionOf(i) === sec.key).sort(byStanding);
return { ...sec, list, visible: list.filter(shows).length };
});
const anyVisible = groups.some((g) => g.visible > 0);
const dots = { now: "live", next: "open", done: "done" } as const;
const typeHref = (key?: string) => {
const p = new URLSearchParams(Astro.url.searchParams);
if (key) p.set("type", key);
else p.delete("type");
const q = p.toString();
return q ? `/library?${q}` : "/library";
};
/** Finished rows shown before "Show everything finished". */
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>
<FilterLayout action="/library" formId="lib-filters">
<Fragment slot="rail">
<SearchField label="Search the Library" placeholder="Search" value={f.q || undefined} />
{f.type && <input type="hidden" name="type" value={f.type} />}
<nav aria-label="Type" class="types">
<span class="types-label" aria-hidden="true">Type</span>
<a href={typeHref()} data-type="" aria-current={!f.type ? "true" : undefined}>Everything</a>
{mediaGroups.map((g) => <a href={typeHref(g.key)} data-type={g.key} aria-current={f.type === g.key ? "true" : undefined}>{g.label}</a>)}
</nav>
<RailGroup legend="Show">
{sections.map((sec) => <label class="check"><input type="checkbox" name="show" value={sec.key} checked={f.show.has(sec.key)} />{sec.label}</label>)}
<label class="check"><input type="checkbox" name="fav" value="1" checked={f.favorites} />Favorites only</label>
<label class="check"><input type="checkbox" name="blog" value="1" checked={f.blog} />Has a blog post</label>
</RailGroup>
</Fragment>
<div class="lists">
{
groups.map((g) => (
<section aria-labelledby={`lib-${g.key}`} class="group" data-group={g.key} hidden={g.visible === 0}>
<ListHeading id={`lib-${g.key}`} dot={dots[g.key]}>{g.label}</ListHeading>
<ul class="rows">
{g.list.map((item) => <LibraryRow item={item} site={s} hidden={!shows(item)} />)}
</ul>
{g.key === "done" && g.list.length > FIRST && (
<button type="button" class="gk-text more" data-more hidden>
Show everything finished ({g.list.length})
</button>
)}
</section>
))
}
<p class="empty" data-empty hidden={anyVisible}>Nothing in the Library matches these filters.</p>
</div>
</FilterLayout>
</PageShell>
<style>
.divide {
padding: 24px 0 4px;
}
.types {
display: flex;
flex-direction: column;
gap: 2px;
}
.types-label {
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.types a {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 12px;
border-left: 2px solid transparent;
color: var(--gk-soft);
font-size: 14px;
text-decoration: none;
transition: border-color var(--gk-ease), color var(--gk-ease), background var(--gk-ease);
}
.types a:hover {
color: var(--gk-text);
border-left-color: var(--gk-gold-line);
}
.types a[aria-current] {
border-left-color: var(--gk-gold);
background: rgba(201, 162, 74, 0.08);
color: var(--gk-text);
font-weight: 600;
}
.lists {
display: flex;
flex-direction: column;
gap: 36px;
}
.group[hidden] {
display: none;
}
.rows {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.more {
align-self: flex-start;
margin-top: 16px;
border: 0;
background-color: transparent;
font-family: var(--gk-font-body);
font-size: 15px;
cursor: pointer;
}
.group {
display: flex;
flex-direction: column;
}
.empty {
margin: 0;
color: var(--gk-muted);
}
@media (max-width: 760px) {
.types {
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.types-label {
flex-basis: 100%;
margin-bottom: 2px;
}
.types a {
min-height: 36px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius-pill);
padding: 0 14px;
font-size: 13px;
}
.types a:hover,
.types a[aria-current] {
border-color: var(--gk-gold);
}
}
</style>
<script>
// Live filtering: the same rules as the server, applied to the rendered rows.
const form = document.getElementById("lib-filters") as HTMLFormElement | null;
if (form) {
const FIRST = 12;
const rows = [...document.querySelectorAll<HTMLElement>(".rows > li[id]")];
const groups = [...document.querySelectorAll<HTMLElement>("[data-group]")];
const more = document.querySelector<HTMLButtonElement>("[data-more]");
const empty = document.querySelector<HTMLElement>("[data-empty]");
const typeLinks = [...form.querySelectorAll<HTMLAnchorElement>("[data-type]")];
const text = new Map(rows.map((r) => [r, (r.textContent ?? "").toLowerCase()]));
let type = new URLSearchParams(location.search).get("type") ?? "";
let expanded = false;
form.querySelector("[data-nojs]")?.remove();
form.querySelector('input[name="type"]')?.remove();
const apply = () => {
const data = new FormData(form);
const show = new Set(data.getAll("show"));
const fav = data.has("fav");
const blog = data.has("blog");
const q = String(data.get("q") ?? "").trim().toLowerCase();
const narrowed = !!(type || fav || blog || q);
let doneCount = 0;
for (const r of rows) {
const d = r.dataset;
let ok = (!type || d.m === type) && show.has(d.s!) && (!fav || !!d.f) && (!blog || !!d.b) && (!q || text.get(r)!.includes(q));
if (ok && d.s === "done" && !narrowed && !expanded && ++doneCount > FIRST) ok = false;
r.hidden = !ok;
}
let any = false;
for (const g of groups) {
const visible = !!g.querySelector(".rows > li:not([hidden])");
g.hidden = !visible;
any ||= visible;
}
if (more) more.hidden = expanded || narrowed || !show.has("done") || doneCount <= FIRST;
if (empty) empty.hidden = any;
};
// Opening an anchor to a folded-away row unfolds the list first.
const reveal = () => {
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (target && target.hidden && rows.includes(target)) {
expanded = true;
apply();
target.scrollIntoView();
}
};
form.addEventListener("input", apply);
form.addEventListener("submit", (e) => {
e.preventDefault();
apply();
});
for (const a of typeLinks) {
a.addEventListener("click", (e) => {
e.preventDefault();
type = a.dataset.type ?? "";
typeLinks.forEach((l) => (l === a ? l.setAttribute("aria-current", "true") : l.removeAttribute("aria-current")));
history.replaceState(null, "", a.href);
apply();
});
}
more?.addEventListener("click", () => {
expanded = true;
apply();
});
addEventListener("hashchange", reveal);
apply();
reveal();
}
</script>
+369
View File
@@ -0,0 +1,369 @@
---
/**
* The Shelf: every board game. What is on the table (from the status log),
* then the collection, filtered by how it plays, owned, want to play and
* favorites, and sorted by last played, A to Z or recently added. Player
* counts and play times come from BoardGameGeek later; 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";
import SearchField from "@gk/ui/components/SearchField.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 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 { site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.ts";
const s = await site();
const f = shelfFilter(Astro.url.searchParams);
const all = s.all("board-game");
const games = sortGames(all, f.sort);
const styles = playStyles(all);
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));
// 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 }) => ({
label: `${status.data.kind} now`,
game,
note: status.data.note,
live: true,
}));
const recent = sortGames(all, "played").find((g) => g.data.lastPlayed && !table.some((t) => t.game.path === g.path));
if (recent) table.push({ label: "Played recently", game: recent, note: `Last played ${formatDate(recent.data.lastPlayed)}`, live: false });
const sortHref = (key: string) => {
const p = new URLSearchParams(Astro.url.searchParams);
p.set("sort", key);
return `/library/shelf?${p}`;
};
---
<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} />
</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>
<FilterLayout action="/library/shelf" formId="shelf-filters">
<Fragment slot="rail">
<SearchField label="Search the Shelf" placeholder="Search games" value={f.q || undefined} />
<input type="hidden" name="sort" value={f.sort} />
{
styles.length > 0 && (
<RailGroup legend="How it plays">
{styles.map((p) => <label class="check"><input type="checkbox" name="plays" value={p.toLowerCase()} checked={f.plays.has(p.toLowerCase())} />{p}</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>
</RailGroup>
</Fragment>
<section aria-label="Games" class="games">
<div class="bar">
<p class="showing" aria-live="polite">Showing <strong data-count>{visible.size}</strong> <span data-noun>{visible.size === 1 ? "game" : "games"}</span></p>
<div class="sort">
<span class="sort-label" aria-hidden="true">Sort</span>
<FilterGroup label="Sort" items={sorts.map((o) => ({ label: o.label, href: sortHref(o.key), active: o.key === f.sort, value: o.key }))} />
</div>
</div>
<ul class="grid" data-grid>
{games.map((g) => <GameCard game={g} site={s} hidden={!visible.has(g.slug)} />)}
</ul>
<p class="quiet" data-empty hidden={visible.size > 0}>No games on the Shelf match these filters.</p>
</section>
</FilterLayout>
</PageShell>
<style>
.top {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: stretch;
padding: 0 0 8px;
}
.count {
position: relative;
flex: 1 1 380px;
min-width: 0;
min-height: 200px;
overflow: hidden;
}
.count :global(.count-art) {
position: absolute;
inset: 0;
border-radius: 0;
}
.count::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(14, 21, 34, 0.9), rgba(14, 21, 34, 0.2));
}
.count-text {
position: relative;
z-index: 1;
margin: 0;
padding: 28px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 15px;
font-weight: 600;
}
.num {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 56px;
line-height: 1;
color: var(--gk-gold-bright);
}
.count :global(.gk-corner) {
z-index: 1;
}
.table {
flex: 2 1 520px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.kick {
display: flex;
flex-direction: column;
gap: 6px;
}
.kick h2 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.table-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.table-list li {
flex: 1 1 280px;
min-width: 0;
display: flex;
}
.table-card {
flex: 1;
flex-direction: row;
align-items: center;
gap: 16px;
padding: 14px 16px;
}
.table-card.live {
border-color: var(--gk-gold);
}
.table-card :global(.table-art) {
flex: 0 0 56px;
width: 56px;
border-radius: 8px;
}
.table-card :global(.table-art .mat) {
display: none;
}
.table-card :global(.table-art .seal) {
width: 34px;
height: 34px;
}
.table-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.table-label {
font-size: 11px;
letter-spacing: 0.16em;
font-weight: 600;
text-transform: uppercase;
color: var(--gk-gold-bright);
}
.table-title {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 16px;
}
.table-note,
.quiet {
margin: 0;
font-size: 13px;
color: var(--gk-muted);
}
.divide {
padding: 24px 0 4px;
}
.games {
display: flex;
flex-direction: column;
gap: 18px;
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.showing {
margin: 0;
font-size: 14px;
color: var(--gk-muted);
}
.showing strong {
color: var(--gk-text);
}
.sort {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.sort-label {
font-size: 13px;
color: var(--gk-muted);
margin-right: 4px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
}
</style>
<script>
// Live filtering and sorting: the same rules as the server, on the rendered cards.
const form = document.getElementById("shelf-filters") as HTMLFormElement | null;
const grid = document.querySelector<HTMLElement>("[data-grid]");
if (form && grid) {
const cards = [...grid.children] as HTMLElement[];
const text = new Map(cards.map((c) => [c, (c.querySelector(".body")?.textContent ?? "").toLowerCase()]));
const title = (c: HTMLElement) => c.querySelector("h3")?.textContent?.trim() ?? "";
const sortInput = form.querySelector<HTMLInputElement>('input[name="sort"]')!;
const sortLinks = [...document.querySelectorAll<HTMLAnchorElement>(".sort .gk-filter")];
const count = document.querySelector("[data-count]");
const noun = document.querySelector("[data-noun]");
const empty = document.querySelector<HTMLElement>("[data-empty]");
form.querySelector("[data-nojs]")?.remove();
const desc = (k: "lp" | "c") => (a: HTMLElement, b: HTMLElement) => (b.dataset[k] ?? "").localeCompare(a.dataset[k] ?? "") || title(a).localeCompare(title(b));
const orders: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
played: desc("lp"),
az: (a, b) => title(a).localeCompare(title(b)),
added: desc("c"),
};
const apply = () => {
const data = new FormData(form);
const q = String(data.get("q") ?? "").trim().toLowerCase();
const plays = data.getAll("plays").map(String);
const owned = data.has("owned");
const want = data.has("want");
const fav = data.has("fav");
let n = 0;
for (const c of cards) {
const d = c.dataset;
const ok =
(!(owned || want) || (owned && !!d.o) || (want && !!d.w)) &&
(!fav || !!d.f) &&
(!plays.length || (d.p ?? "").split("|").some((p) => plays.includes(p))) &&
(!q || text.get(c)!.includes(q));
c.hidden = !ok;
if (ok) n++;
}
if (count) count.textContent = String(n);
if (noun) noun.textContent = n === 1 ? "game" : "games";
if (empty) empty.hidden = n > 0;
};
const sort = (key: string) => {
sortInput.value = key;
grid.append(...[...cards].sort(orders[key] ?? orders.played));
};
form.addEventListener("input", apply);
form.addEventListener("submit", (e) => {
e.preventDefault();
apply();
});
for (const a of sortLinks) {
a.addEventListener("click", (e) => {
e.preventDefault();
const key = new URL(a.href).searchParams.get("sort") ?? "played";
sortLinks.forEach((l) => (l === a ? l.setAttribute("aria-current", "page") : l.removeAttribute("aria-current")));
history.replaceState(null, "", a.href);
sort(key);
});
}
}
</script>
+262
View File
@@ -0,0 +1,262 @@
---
/**
* Now: every status in the log, grouped (making, at the table, playing,
* reading, watching, up next), each an anchor at its slug, with the
* Everything / Now / Up next filter.
*/
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import SectionHeading from "@gk/ui/components/SectionHeading.astro";
import Star from "@gk/ui/components/Star.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { seals } from "@gk/ui/components/seals.ts";
import PageHero from "../components/PageHero.astro";
import StatusRow from "../components/home/StatusRow.astro";
import { groupStatuses, lastUpdated, viewStatus } from "../lib/home.ts";
import { site } from "../lib/page.ts";
import { formatDate } from "../lib/site.ts";
const s = await site();
const text = s.pageText("/now");
const groups = groupStatuses(s);
const updated = lastUpdated(s);
const lede = [text?.data.lede ?? "What I am making, reading, playing and watching right now.", updated && `Last updated ${formatDate(updated)}.`]
.filter(Boolean)
.join(" ");
const intro = s.render(text?.body);
const hasNext = groups.some((g) => g.key === "next");
const wrapped = s
.all("status")
.filter((x) => /finish|wrapped|completed|done|caught up/i.test(x.data.kind))
.sort((a, b) => b.data.date.localeCompare(a.data.date))
.slice(0, 5)
.map((x) => viewStatus(s, x));
---
<PageShell title="Now" family="now" current="now" description="What I am making, reading, playing and watching right now.">
<PageHero slot="hero" eyebrow="My world" title="Now" lede={lede} />
<div class="filter-bar" data-now-filter hidden>
<FilterGroup
label="Show"
items={[
{ label: "Everything", value: "all", active: true },
{ label: "Now", value: "now" },
{ label: "Up next", value: "next" },
]}
/>
<span class="order">Newest first · pinned on top</span>
</div>
<div class="layout">
<div class="groups">
{intro && <div class="intro" set:html={intro} />}
{
groups.length ? (
groups.map((g) => (
<section aria-labelledby={`n-${g.key}`} class="group" data-next={g.key === "next" ? "true" : "false"}>
<SectionHeading id={`n-${g.key}`}>
<Star size={12} />
{g.title}
</SectionHeading>
<ul class="rows">
{g.items.map((v) => (
<StatusRow view={v} site={s} />
))}
</ul>
</section>
))
) : (
<p class="empty">Nothing on the status log yet.</p>
)
}
{!hasNext && <p class="empty" data-next-empty hidden>Nothing queued up next right now.</p>}
</div>
<aside aria-label="About this page" class="aside">
<div class="panel">
<img src={seals.zodiac} alt="" aria-hidden="true" class="panel-seal" />
<span class="panel-kicker"><Star size={11} /><span>How this works</span></span>
<TickScale width={200} height={10} count={30} class="panel-ticks" />
<p class="panel-text">
Each line is a status note in my vault. When I or one of my agents update it, this page, the landing page and the
matching page all change together.
</p>
</div>
{
wrapped.length > 0 && (
<div class="panel">
<img src={seals["seven-metals"]} alt="" aria-hidden="true" class="panel-seal" />
<span class="panel-kicker"><Star size={11} /><span>Recently wrapped up</span></span>
<TickScale width={200} height={10} count={30} class="panel-ticks" />
<ul class="wrapped">
{wrapped.map((v) => (
<li>
{v.href ? <a href={v.href}>{v.title}</a> : <span>{v.title}</span>}
<time datetime={v.date}>{formatDate(v.date)}</time>
</li>
))}
</ul>
</div>
)
}
</aside>
</div>
</PageShell>
<script>
const bar = document.querySelector<HTMLElement>("[data-now-filter]");
if (bar) {
bar.hidden = false;
const buttons = [...bar.querySelectorAll<HTMLButtonElement>("button[data-value]")];
const groups = [...document.querySelectorAll<HTMLElement>(".group[data-next]")];
const apply = (value: string) => {
for (const b of buttons) b.setAttribute("aria-pressed", String(b.dataset.value === value));
for (const g of groups) {
const next = g.dataset.next === "true";
g.hidden = value === "now" ? next : value === "next" ? !next : false;
}
const empty = document.querySelector<HTMLElement>("[data-next-empty]");
if (empty) empty.hidden = value !== "next";
};
for (const b of buttons) b.addEventListener("click", () => apply(b.dataset.value ?? "all"));
}
</script>
<style>
.filter-bar {
padding-top: 28px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filter-bar[hidden] {
display: none;
}
.order {
font-size: 13px;
color: var(--gk-muted);
}
.layout {
padding-top: 20px;
padding-bottom: 48px;
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
}
.groups {
flex: 3 1 640px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 32px;
}
.intro {
color: var(--gk-soft);
max-width: 68ch;
}
.group {
display: flex;
flex-direction: column;
gap: 10px;
}
.group[hidden] {
display: none;
}
.group :global(h2) {
display: flex;
align-items: center;
gap: 10px;
font-size: 22px;
}
.rows {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.empty[hidden] {
display: none;
}
.empty {
color: var(--gk-muted);
}
.aside {
flex: 1 1 260px;
max-width: 320px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.panel-seal {
position: absolute;
right: -26px;
top: -26px;
width: 104px;
height: 104px;
opacity: 0.14;
pointer-events: none;
}
.panel-kicker {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.panel :global(.panel-ticks) {
margin: -4px 0 2px;
}
.panel-text {
margin: 0;
font-size: 14px;
color: var(--gk-soft);
line-height: 1.6;
}
.wrapped {
margin: 0;
padding: 0;
list-style: none;
}
.wrapped li {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 0;
font-size: 14px;
border-top: 1px solid var(--gk-lines);
}
.wrapped a {
color: var(--gk-text);
text-decoration: none;
}
.wrapped a:hover {
color: var(--gk-teal-bright);
}
.wrapped time {
color: var(--gk-muted);
flex: none;
}
@media (max-width: 900px) {
.aside {
max-width: none;
}
}
</style>
+559
View File
@@ -0,0 +1,559 @@
---
/**
* The resume: at a glance with the PDF, highlights, skills and education in
* the sidebar, and the experience timeline. Every word comes from the Resume
* notes and the /resume Page text; the PDF is built from the same data.
*/
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
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 Star from "@gk/ui/components/Star.astro";
import StarPath from "@gk/ui/components/StarPath.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { site } from "../lib/page.ts";
import { period, resumeData } from "../lib/resume.ts";
import { formatDate } from "../lib/site.ts";
const s = await site();
const r = resumeData(s);
const caseStudies = s.caseStudies();
const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c) => c.toLowerCase() === item.toLowerCase()) ?? false;
---
<PageShell title="Resume" family="resume" current="resume" description={`${r.name}: ${r.headline}. Experience, skills and a PDF to take with you.`}>
<div slot="hero" class="hero">
<div class="intro">
<div class="plaque"><GiltCorners inset={4} size={30} />RESUME AT A GLANCE</div>
<h1>{r.name}</h1>
<p class="headline">{[r.headline, r.location].filter(Boolean).join(" · ")}</p>
{r.summary && <p class="summary">{r.summary}</p>}
</div>
<aside class="take" aria-labelledby="take-h">
<span class="take-star" aria-hidden="true"><TickRing size={36}><Star size={16} /></TickRing></span>
<span class="take-head">
<span id="take-h" class="take-label">TAKE IT WITH YOU</span>
<TickScale width={120} count={24} />
</span>
<Button href="/resume.pdf" download="Greg-Kilmer-Resume.pdf">
<Icon name="arrow-right" 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>
{r.availability && <span class="avail"><span class="dot" aria-hidden="true"></span>{r.availability}</span>}
</aside>
</div>
{
r.stats.length > 0 && (
<section aria-label="Highlights" class="stats">
{r.stats.map((st) => (
<div class="stat">
<span class="stat-value">{st.value}</span>
<TickScale width={120} count={24} />
<span class="stat-label">{st.label}</span>
{st.detail && <span class="stat-detail">{st.detail}</span>}
</div>
))}
</section>
)
}
{r.stats.length > 0 && <div class="rule"><StarPath seed="resume" strength="calm" centerStar={false} /></div>}
<div class="columns">
<aside class="side">
{
r.skills && (
<section aria-labelledby="skills-h" class="panel">
<Seal name="mini-astrolabe" size={116} class="panel-seal" />
<div class="panel-head">
<h2 id="skills-h">SKILLS</h2>
<TickScale width={140} count={28} />
</div>
{r.skills.stack.map((g) => (
<div class="group">
<span class="group-label">{g.label}</span>
<div class="chips">
{g.items.map((item) => (
<Chip core={isCore(g, item)}>{item}</Chip>
))}
</div>
</div>
))}
{r.skills.stack.some((g) => g.core?.length) && <span class="hint">Teal outline marks the core stack.</span>}
</section>
)
}
{
r.skills?.core?.length ? (
<section aria-labelledby="strengths-h" class="panel">
<div class="panel-head">
<h2 id="strengths-h">STRENGTHS</h2>
<TickScale width={140} count={28} />
</div>
<ul class="strengths">
{r.skills.core.map((c) => (
<li>
<Star size={10} />
{c}
</li>
))}
</ul>
</section>
) : null
}
{
r.education?.degrees.length ? (
<section aria-labelledby="edu-h" class="panel">
<Seal name="mini-tome" size={116} class="panel-seal" />
<div class="panel-head">
<h2 id="edu-h">EDUCATION</h2>
<TickScale width={140} count={28} />
</div>
{r.education.degrees.map((d) => (
<div class="degree">
<span class="degree-name">{d.credential}</span>
<span class="muted">
{d.institution} · {d.period}
</span>
{d.detail && <span class="muted small">{d.detail}</span>}
</div>
))}
</section>
) : null
}
{
r.education?.certifications?.length ? (
<section aria-labelledby="certs-h" class="panel">
<Seal name="mini-layered" size={116} class="panel-seal" />
<div class="panel-head">
<h2 id="certs-h">CERTIFICATIONS</h2>
<TickScale width={140} count={28} />
</div>
{r.education.certifications.map((c) => (
<div class="cert">
<span class="cert-name">{c.name}</span>
<span class="muted">
{c.issuer} · {c.year}
</span>
</div>
))}
</section>
) : null
}
</aside>
<section aria-labelledby="exp-h" class="experience">
<div class="exp-head">
<h2 id="exp-h">Experience</h2>
<span class="gk-underline" aria-hidden="true"></span>
</div>
<div class="timeline">
<div class="grid" aria-hidden="true"></div>
<div class="spine" aria-hidden="true"></div>
{
r.roles.map((role, i) => (
<article id={role.slug} class:list={["role", { current: i === 0 && !role.data.end }]}>
{i === 0 && !role.data.end && <GiltCorners inset={4} size={30} />}
<span class="marker" aria-hidden="true">
<Star size={i === 0 ? 14 : 11} tone={i === 0 ? "gold" : "outline"} />
</span>
<div class="role-top">
<h3>{role.data.role}</h3>
<span class="dates">{period(role)}</span>
</div>
<span class="company">{role.data.company}{role.data.location ? ` · ${role.data.location}` : ""}</span>
{role.data.previousRoles?.length ? (
<span class="previously">Previously {role.data.previousRoles.join(" and ")} at {role.data.company}</span>
) : null}
{role.data.focus && <span class="focus">{role.data.focus}</span>}
<ul>
{role.data.bullets.map((b) => (
<li>
<Star size={9} />
<span>{b}</span>
</li>
))}
</ul>
{role.data.tech?.length ? (
<div class="chips">
{role.data.tech.map((t) => (
<Chip>{t}</Chip>
))}
</div>
) : null}
</article>
))
}
</div>
{caseStudies.length > 0 && <Button variant="text" href="/work" arrow>See the case studies on the Work page</Button>}
</section>
</div>
</PageShell>
<style>
.hero {
display: flex;
flex-wrap: wrap;
gap: 32px;
align-items: center;
padding-top: 8px;
}
.intro {
flex: 1 1 520px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.plaque {
align-self: flex-start;
position: relative;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
border: 1px solid rgba(201, 162, 74, 0.55);
border-radius: 10px;
padding: 17px 60px;
line-height: 1.2;
}
h1 {
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.08em;
line-height: 1.05;
}
.headline {
margin: 0;
font-size: 18px;
color: var(--gk-soft);
}
.summary {
margin: 0;
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
font-size: 16px;
line-height: 1.65;
color: var(--gk-soft);
max-width: 62ch;
}
.take {
flex: 0 1 310px;
min-width: 0;
position: relative;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.6);
border-radius: 12px;
box-shadow:
inset 0 0 0 5px var(--gk-midnight),
inset 0 0 0 6px rgba(201, 162, 74, 0.3);
padding: 32px 26px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.take-star {
position: absolute;
left: 50%;
top: -18px;
transform: translateX(-50%);
border-radius: 999px;
background: var(--gk-midnight);
display: flex;
}
.take-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.take-label {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
}
.pdf-date {
margin-top: -4px;
font-size: 12px;
color: var(--gk-muted);
text-align: center;
}
.avail {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding-top: 12px;
border-top: 1px dashed rgba(244, 242, 237, 0.16);
font-size: 13px;
color: var(--gk-soft);
text-align: center;
}
.dot {
flex: 0 0 8px;
height: 8px;
border-radius: 999px;
background: var(--gk-teal-bright);
box-shadow: 0 0 8px rgba(43, 194, 183, 0.7);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
padding: 12px 0 8px;
}
.stat {
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: 10px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-value {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 44px;
color: var(--gk-gold-bright);
line-height: 1;
}
.stat-label {
font-size: 15px;
font-weight: 600;
}
.stat-detail {
font-size: 14px;
color: var(--gk-muted);
line-height: 1.5;
}
.rule {
padding: 28px 0 4px;
}
.columns {
display: flex;
flex-wrap: wrap;
gap: 48px;
align-items: flex-start;
padding: 32px 0 24px;
}
.side {
flex: 1 1 300px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
position: relative;
overflow: hidden;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: 10px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.panel :global(.panel-seal) {
position: absolute;
right: -30px;
top: -30px;
opacity: 0.12;
pointer-events: none;
}
.panel-head {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel h2 {
margin: 0;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
}
.group {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-label {
font-size: 13px;
font-weight: 600;
color: var(--gk-muted);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hint,
.muted {
font-size: 13px;
color: var(--gk-muted);
}
.small {
font-size: 13px;
line-height: 1.5;
}
.strengths {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
}
.strengths li {
display: flex;
align-items: center;
gap: 10px;
}
.degree {
display: flex;
flex-direction: column;
gap: 4px;
}
.degree-name {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 17px;
}
.cert {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
}
.cert-name {
font-weight: 600;
}
/* Once the columns stack, the experience leads and the sidebar follows. */
@media (max-width: 960px) {
.experience {
order: -1;
}
}
.experience {
flex: 2 1 560px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.exp-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.exp-head h2 {
margin: 0;
font-size: 28px;
letter-spacing: 0.08em;
}
.timeline {
position: relative;
display: flex;
flex-direction: column;
gap: 24px;
padding-left: 36px;
}
.grid {
position: absolute;
inset: -24px -24px -24px 0;
pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.045) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.045) 0 1px, transparent 1px 40px);
-webkit-mask-image: radial-gradient(ellipse at 40% 50%, #000 40%, transparent 80%);
mask-image: radial-gradient(ellipse at 40% 50%, #000 40%, transparent 80%);
}
.spine {
position: absolute;
left: 10px;
top: 10px;
bottom: 10px;
width: 1px;
background: repeating-linear-gradient(to bottom, var(--gk-gold) 0 1px, transparent 1px 4px);
}
.role {
position: relative;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: 10px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 10px;
scroll-margin-top: 24px;
}
.role.current {
border-color: rgba(201, 162, 74, 0.55);
}
.marker {
position: absolute;
left: -26.5px;
top: 28px;
transform: translateX(-50%);
display: flex;
background: var(--gk-sky);
padding: 3px;
}
.role-top {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
}
.role h3 {
margin: 0;
font-size: 18px;
letter-spacing: 0.04em;
}
.role.current h3 {
font-size: 20px;
}
.dates {
font-size: 13px;
font-weight: 600;
color: var(--gk-gold-bright);
}
.company {
font-size: 15px;
font-weight: 600;
color: var(--gk-teal-bright);
}
.previously,
.focus {
font-size: 13px;
color: var(--gk-muted);
}
.role ul {
margin: 4px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 15px;
line-height: 1.55;
color: var(--gk-soft);
}
.role li {
display: flex;
gap: 10px;
}
.role li :global(svg) {
margin-top: 7px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
/** The resume as a PDF, generated from the same notes as /resume and rebuilt only when they change. */
import type { APIRoute } from "astro";
import { site } from "../lib/page.ts";
import { resumeData } from "../lib/resume.ts";
import { renderResumePdf } from "../lib/resume-pdf.ts";
let cached: { key: string; pdf: Promise<Buffer> } | undefined;
export const GET: APIRoute = async () => {
const r = resumeData(await site());
const key = JSON.stringify(r);
if (cached?.key !== key) cached = { key, pdf: renderResumePdf(r) };
const pdf = await cached.pdf.catch((err) => {
cached = undefined;
throw err;
});
const file = `${r.name.replace(/[^A-Za-z0-9]+/g, "-")}-Resume.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
"content-type": "application/pdf",
"content-disposition": `inline; filename="${file}"`,
"cache-control": "public, max-age=300",
},
});
};
+53
View File
@@ -0,0 +1,53 @@
/**
* The RSS 2.0 feed: blog posts and case studies, newest first, with absolute
* links from the configured site address or the request's origin.
*/
import type { APIRoute } from "astro";
import { xml } from "../lib/blog.ts";
import { site } from "../lib/page.ts";
export const GET: APIRoute = async ({ site: configured, url }) => {
const s = await site();
const origin = (configured ?? new URL(url.origin)).href.replace(/\/$/, "");
const abs = (path: string) => new URL(path, `${origin}/`).href;
const settings = s.settings();
const name = settings?.name ?? "Greg Kilmer";
const posts = s.posts({ includeCaseStudies: true }).slice(0, 50);
const rfc822 = (iso: string) => new Date(`${iso}T12:00:00Z`).toUTCString();
const items = posts
.map((p) => {
const href = s.href(p);
if (!href) return "";
const link = abs(href);
const category = p.data.postType === "case study" ? "Case study" : p.data.topic;
return [
" <item>",
` <title>${xml(p.title)}</title>`,
` <link>${xml(link)}</link>`,
` <guid isPermaLink="true">${xml(link)}</guid>`,
` <pubDate>${rfc822(p.data.date)}</pubDate>`,
` <category>${xml(category)}</category>`,
p.data.summary ? ` <description>${xml(p.data.summary)}</description>` : "",
" </item>",
]
.filter(Boolean)
.join("\n");
})
.filter(Boolean)
.join("\n");
const body = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${xml(name)}</title>
<link>${xml(abs("/blog"))}</link>
<atom:link href="${xml(abs("/rss.xml"))}" rel="self" type="application/rss+xml" />
<description>${xml(settings?.tagline ?? "What I am making, learning, playing and building.")}</description>
<language>en-us</language>
${posts[0] ? ` <lastBuildDate>${rfc822(posts[0].data.date)}</lastBuildDate>\n` : ""}${items}
</channel>
</rss>
`;
return new Response(body, { headers: { "content-type": "application/rss+xml; charset=utf-8", "cache-control": "public, max-age=600" } });
};
+416
View File
@@ -0,0 +1,416 @@
---
/**
* One product line: gallery, summary and price, its slots or stock, the order
* button (or the closed or sold-out notice), specs, prices, options with
* swatches and stock, and its terms as ledger notes.
*/
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.astro";
import Notice from "@gk/ui/components/Notice.astro";
import PageShell from "@gk/ui/components/PageShell.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 Breadcrumb from "../../components/store/Breadcrumb.astro";
import LineStatus from "../../components/store/LineStatus.astro";
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";
const s = await site();
const slug = Astro.params.slug ?? "";
const line = s.bySlug("product-line", slug);
if (!line) {
const to = s.redirect("product-line", slug);
if (to) return Astro.redirect(to, 301);
return Astro.rewrite("/404");
}
const st = storeState(s);
const d = line.data;
const cat = st.category(d.category);
const ch = channelOf(line);
const orderable = canOrder(line, st);
const shop = shopLink(line);
const price = priceSummary(line);
const images = (d.images ?? []).map((im) => ({ ...im, src: mediaSrc(im.file) }));
const lead = images[0];
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}>
<div class="crumb-row">
<Breadcrumb here={d.category} />
</div>
<section class="top" aria-label={line.title}>
<div class="gallery">
<figure class="lead">
<StarChartArt seal="craft-dice" ratio="3 / 2" src={lead?.src} alt={lead?.alt ?? ""} class="lead-art" />
{lead?.label && <figcaption>{lead.label}</figcaption>}
</figure>
{
images.length > 1 && (
<ul class="thumbs" aria-label="More photos">
{images.map((im, i) => (
<li>
<a href={im.src} class:list={["thumb", { on: i === 0 }]} data-src={im.src} data-alt={im.alt} data-label={im.label} aria-current={i === 0 ? "true" : undefined}>
<img src={im.src} alt={im.alt} loading="lazy" decoding="async" />
</a>
</li>
))}
</ul>
)
}
</div>
<div class="info">
<h1>{line.title}</h1>
<p class="summary">{d.summary}</p>
{
price && (
<div class="price">
<span class="from">from</span>
<span class="amount">{price.from}</span>
<span class="more">{[price.note, ...price.more].filter(Boolean).join(" · ")}</span>
</div>
)
}
<LineStatus line={line} label={d.mode === "made to order" && ch === "here" ? "Order slots" : "Availability"} />
{
ch === "here" && !cat.ordersOpen && (
<Notice title={`${d.category} orders currently closed.`}>{cat.reopens ? `Reopens ${formatDate(cat.reopens)}.` : ""}</Notice>
)
}
{
ch === "here" && cat.ordersOpen && !hasCapacity(line) && (
<Notice title={d.mode === "made to order" ? "All order slots are taken." : "Sold out."}>
{d.mode === "made to order" ? "Slots reopen as current orders finish." : ""}
</Notice>
)
}
{ch === "coming soon" && <Chip>Coming soon</Chip>}
<div class="actions">
{orderable && <Button href={`/store/order?line=${encodeURIComponent(line.slug)}`}>Order request</Button>}
{shop && <Button href={shop.href} rel="noopener" arrow>{shop.label}</Button>}
<Button variant={orderable || shop ? "secondary" : "primary"} href="/inquiries">Something custom? Start an inquiry</Button>
</div>
{ch === "here" && cat.turnaround && <span class="turn">{cat.turnaround}</span>}
</div>
</section>
{body && <section class="gk-prose body" aria-label="About this line" set:html={body} />}
{
(!!d.specs?.length || d.priceTiers.length > 0) && (
<div class="tables">
{!!d.specs?.length && (
<section class="col" aria-labelledby="pl-specs">
<SectionHeading id="pl-specs">
<Star size={14} /> Specs
</SectionHeading>
<dl class="specs">
{d.specs.map((sp) => (
<div>
<dt>{sp.label}</dt>
<dd>{sp.value}</dd>
</div>
))}
</dl>
</section>
)}
{d.priceTiers.length > 0 && (
<section class="col" aria-labelledby="pl-price">
<SectionHeading id="pl-price">
<Star size={14} /> Pricing
</SectionHeading>
<table class="prices">
<caption class="gk-visually-hidden">Prices for {line.title}</caption>
<tbody>
{d.priceTiers.map((t) => (
<tr>
<th scope="row">{t.band}</th>
<td class="p">{t.price}</td>
<td class="n">{t.note}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</div>
)
}
{
groups.length > 0 && (
<section class="options" id="options" aria-labelledby="pl-options">
<SectionHeading id="pl-options">
<Star size={14} /> Options
</SectionHeading>
{groups.map(([group, opts]) => (
<div class="group">
<h3>{group}</h3>
<Swatches options={opts} names />
</div>
))}
</section>
)
}
{
terms.length > 0 && (
<section class="policies" aria-labelledby="pl-terms">
<details>
<summary id="pl-terms">Before you order: requirements and policies</summary>
<div class="pol-body">
<Terms terms={terms} />
</div>
</details>
</section>
)
}
</PageShell>
<script>
// Thumbnails swap the lead photo; without JS they open the full image.
for (const a of document.querySelectorAll<HTMLAnchorElement>(".thumb")) {
a.addEventListener("click", (e) => {
const fig = document.querySelector(".lead");
const img = fig?.querySelector<HTMLImageElement>("img.photo");
if (!fig || !img) return;
e.preventDefault();
img.src = a.dataset.src ?? img.src;
img.alt = a.dataset.alt ?? "";
const cap = fig.querySelector("figcaption");
if (cap) cap.textContent = a.dataset.label ?? "";
for (const t of document.querySelectorAll(".thumb")) {
t.classList.toggle("on", t === a);
if (t === a) t.setAttribute("aria-current", "true");
else t.removeAttribute("aria-current");
}
});
}
</script>
<style>
.crumb-row {
padding: 28px 0 8px;
}
.top {
padding: 8px 0;
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
}
.gallery {
flex: 3 1 560px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.lead {
margin: 0;
padding: 10px;
border-radius: 12px;
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
}
.lead :global(.lead-art) {
border-radius: 6px;
}
figcaption {
padding: 10px 6px 2px;
font-size: 13px;
color: var(--gk-muted);
}
.thumbs {
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 8px;
}
.thumb {
display: block;
border-radius: 6px;
border: 1px solid var(--gk-lines);
overflow: hidden;
}
.thumb.on,
.thumb:hover {
border-color: var(--gk-gold);
}
.thumb img {
display: block;
width: 100%;
height: 76px;
object-fit: cover;
}
.info {
flex: 2 1 380px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
h1 {
margin: 0;
font-size: clamp(32px, 5vw, 42px);
letter-spacing: 0.05em;
line-height: 1.1;
}
.summary {
margin: 0;
font-size: 16px;
line-height: 1.6;
color: var(--gk-soft);
}
.price {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.from,
.more {
font-size: 13px;
color: var(--gk-muted);
}
.amount {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 34px;
color: var(--gk-gold-bright);
}
.info :global(.gk-chip) {
align-self: flex-start;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.turn {
font-size: 13px;
color: var(--gk-muted);
}
.body {
padding: 28px 0 0;
max-width: 72ch;
}
.tables {
margin-top: 28px;
padding: 24px 0 8px;
border-top: 1px solid rgba(201, 162, 74, 0.35);
display: flex;
flex-wrap: wrap;
gap: 40px;
align-items: flex-start;
}
.col {
flex: 1 1 420px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.col :global(h2),
.options :global(h2) {
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
}
.specs {
margin: 0;
border-bottom: 1px solid var(--gk-lines);
}
.specs div {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 12px 0;
border-top: 1px solid var(--gk-lines);
}
dt {
flex: 0 0 160px;
font-size: 13px;
color: var(--gk-muted);
}
dd {
flex: 1 1 240px;
margin: 0;
font-size: 14px;
line-height: 1.5;
}
.prices {
width: 100%;
border-collapse: collapse;
border-bottom: 1px solid var(--gk-lines);
}
.prices tr {
border-top: 1px solid var(--gk-lines);
}
.prices th {
padding: 12px 0;
font-size: 14px;
font-weight: 400;
text-align: left;
}
.prices .p {
padding: 12px 0;
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 18px;
color: var(--gk-gold-bright);
text-align: right;
white-space: nowrap;
}
.prices .n {
padding: 12px 0 12px 14px;
font-size: 12px;
color: var(--gk-muted);
}
.options {
padding: 32px 0 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
.group {
display: flex;
flex-direction: column;
gap: 10px;
}
.group h3 {
margin: 0;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.policies {
padding: 28px 0 64px;
}
details {
padding: 18px 20px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
}
summary {
font-weight: 700;
cursor: pointer;
}
.pol-body {
margin-top: 12px;
}
</style>
+356
View File
@@ -0,0 +1,356 @@
---
/**
* The Store: product lines grouped by category, each with its status, the
* store notice from Store settings, the in-stock colors, how ordering works
* and the store-wide terms.
*/
import Button from "@gk/ui/components/Button.astro";
import Kicker from "@gk/ui/components/Kicker.astro";
import Notice from "@gk/ui/components/Notice.astro";
import SectionHeading from "@gk/ui/components/SectionHeading.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import LineCard from "../../components/store/LineCard.astro";
import LineStatus from "../../components/store/LineStatus.astro";
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";
const s = await site();
const st = storeState(s);
const text = s.pageText("/store");
const lede = text?.data.lede ?? st.settings?.capacityNote;
const selling = st.lines.filter((l) => channelOf(l) === "here");
const allClosed = st.closed.length > 0 && !st.anyOpen;
const reopenText = (c: { reopens?: string }) => (c.reopens ? `Reopens ${formatDate(c.reopens)}.` : "");
const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround).filter((t): t is string => !!t))];
/** In-stock colors of lines sold here, one strip per line and option group. */
const colorStrips = selling.flatMap((l) =>
optionGroups(l)
.map(([group, opts]) => ({ line: l, group, opts: opts.filter((o) => o.inStock && o.hex) }))
.filter((x) => x.opts.length),
);
---
<PageShell title="Store" family="store" current="store" description={st.settings?.capacityNote ?? "Things I make, in small batches."}>
<Fragment slot="hero">
<div class="hero">
<div class="hero-main">
{
allClosed ? (
<Notice title="Orders currently closed." class="hero-note">
{st.closed.map(reopenText).filter(Boolean).join(" ")}
</Notice>
) : (
st.closed.map((c) => (
<Notice title={`${c.name} orders currently closed.`} class="hero-note">
{reopenText(c)}
</Notice>
))
)
}
<h1>A small workshop, not a factory</h1>
{lede && <p class="lede">{lede}</p>}
<div class="actions">
{st.anyOpen && <Button href="/store/order">Start an order request</Button>}
<Button variant={st.anyOpen ? "secondary" : "primary"} href="/inquiries">Something custom? Start an inquiry</Button>
</div>
</div>
{
selling.length > 0 && (
<div class="hero-meters" aria-label="Order slots">
{selling.map((l) => <LineStatus line={l} class="meter" />)}
{turnarounds.map((t) => <span class="turn">{t}</span>)}
</div>
)
}
</div>
</Fragment>
{
st.groups.map(([cat, lines]) => {
const c = st.category(cat);
const id = `cat-${cat.toLowerCase()}`;
return (
<section class="cat" aria-labelledby={id}>
<div class="cat-head">
<Kicker as="div" star>
<span id={id}>{cat}</span>
</Kicker>
{lines.some((l) => channelOf(l) === "here") && (
<span class="cat-state">{c.ordersOpen ? "Orders open" : `Orders closed${c.reopens ? ` · reopens ${formatDate(c.reopens)}` : ""}`}</span>
)}
</div>
<div class="cards">
{lines.map((l) => (
<LineCard line={l} open={c.ordersOpen} />
))}
</div>
</section>
);
})
}
{st.lines.length === 0 && <p class="empty">[PLACEHOLDER]</p>}
{
colorStrips.map(({ line, group, opts }) => {
const id = `colors-${line.slug}-${group.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<section class="colors" aria-labelledby={id}>
<div class="colors-head">
<h2 id={id}>{group} in stock</h2>
<TickScale width={140} height={10} count={22} />
<span class="colors-for">{line.title}</span>
</div>
<Swatches options={opts} />
<a href={`/store/${line.slug}#options`} class="gk-text all">All colors</a>
</section>
);
})
}
<section class="how" aria-labelledby="st-how">
<SectionHeading id="st-how"><Star size={14} /> How ordering works</SectionHeading>
<ol class="steps">
<li>
<TickRing size={44}>1</TickRing>
<span class="step-title">Send a request</span>
<span class="step-text">Use the order form with your card list or deck box colors. No payment is taken through the site.</span>
</li>
<li>
<TickRing size={44}>2</TickRing>
<span class="step-title">Confirm the details</span>
<span class="step-text">I check the list and art, then confirm the total with you.</span>
</li>
<li>
<TickRing size={44}>3</TickRing>
<span class="step-title">I make it</span>
{turnarounds.map((t) => <span class="step-text">{t}</span>)}
</li>
</ol>
</section>
{
st.terms.length > 0 && (
<section class="policies" aria-labelledby="st-pol">
<details open>
<Seal name="craft-dice" size={220} class="pol-seal" />
<summary id="st-pol">Before you order: requirements and policies</summary>
<div class="pol-body">
<Terms terms={st.terms} />
<p class="pol-more">Each product line lists its own terms on its page.</p>
</div>
</details>
</section>
)
}
</PageShell>
<style>
.hero {
display: flex;
flex-wrap: wrap;
gap: 32px;
align-items: center;
}
.hero-main {
flex: 2 1 520px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.hero-main :global(.hero-note) {
align-self: flex-start;
}
h1 {
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.06em;
line-height: 1.08;
}
.lede {
margin: 0;
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
font-size: 17px;
line-height: 1.65;
color: var(--gk-soft);
max-width: 58ch;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 4px;
}
.hero-meters {
flex: 1 1 320px;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.hero-meters :global(.meter) {
flex: 1 1 220px;
}
.turn {
flex: 1 1 100%;
font-size: 13px;
color: var(--gk-muted);
}
.cat {
padding: 32px 0 16px;
display: flex;
flex-direction: column;
gap: 18px;
}
.cat-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 8px 24px;
}
.cat-head :global(.gk-kicker-text) {
font-size: 13px;
}
.cat-state {
font-size: 13px;
color: var(--gk-muted);
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.empty {
padding: 32px 0;
}
.colors {
margin: 8px 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px 28px;
padding: 20px 24px;
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
background:
repeating-linear-gradient(0deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 24px),
repeating-linear-gradient(90deg, rgba(244, 242, 237, 0.035) 0 1px, transparent 1px 24px),
var(--gk-sky);
}
.colors-head {
display: flex;
flex-direction: column;
gap: 6px;
}
.colors h2 {
margin: 0;
font-family: var(--gk-font-body);
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.colors-for {
font-size: 12px;
color: var(--gk-muted);
}
.colors .all {
margin-left: auto;
font-size: 14px;
font-weight: 600;
}
.how {
padding: 32px 0 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.how :global(h2) {
display: flex;
align-items: center;
gap: 10px;
font-size: 26px;
}
.steps {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.steps li {
flex: 1 1 220px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
}
.step-title {
font-weight: 700;
font-size: 16px;
}
.step-text {
font-size: 14px;
color: var(--gk-muted);
line-height: 1.55;
}
.policies {
padding: 16px 0 64px;
}
details {
position: relative;
overflow: hidden;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
padding: 22px 26px;
}
details :global(.pol-seal) {
position: absolute;
right: -40px;
bottom: -60px;
opacity: 0.08;
pointer-events: none;
}
summary {
cursor: pointer;
font-weight: 700;
font-size: 16px;
}
.pol-body {
margin-top: 14px;
position: relative;
}
.pol-more {
margin: 12px 0 0;
font-size: 13px;
color: var(--gk-muted);
}
@media (max-width: 640px) {
details {
padding: 18px 16px;
}
.colors {
padding: 18px 16px;
}
}
</style>
+176
View File
@@ -0,0 +1,176 @@
---
/**
* The order request form: lines that can take an order now, the chosen
* line's options, quantity, details, notes, contact and the terms. When no
* category takes orders, or every open line is full, a notice shows instead.
*/
import Button from "@gk/ui/components/Button.astro";
import Notice from "@gk/ui/components/Notice.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Star from "@gk/ui/components/Star.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import Breadcrumb from "../../components/store/Breadcrumb.astro";
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";
const s = await site();
const st = storeState(s);
const selected = Astro.url.searchParams.get("line") ?? undefined;
const orderable = st.lines.filter((l) => canOrder(l, st));
const selling = st.lines.filter((l) => channelOf(l) === "here");
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.">
<section class="intro" aria-labelledby="order-h1">
<Breadcrumb here="Order request" />
<h1 id="order-h1">Order request</h1>
<p class="lede">Send a request and I will confirm the details and the total with you. No payment is taken through the site.</p>
</section>
<div class="layout">
<div class="main">
{
orderable.length > 0 ? (
<OrderForm lines={orderable} selected={selected} storeTerms={st.terms} />
) : (
<div class="closed">
{st.anyOpen ? (
<Notice title="All order slots are taken right now.">Slots reopen as current orders finish.</Notice>
) : (
<Notice title="Orders currently closed.">{reopens.join(" ")}</Notice>
)}
<p>{st.settings?.capacityNote}</p>
<div class="actions">
<Button href="/store">Back to the Store</Button>
<Button variant="secondary" href="/inquiries">Something custom? Start an inquiry</Button>
</div>
</div>
)
}
</div>
<aside class="side" aria-label="Order status">
{shown.map((l) => <LineStatus line={l} />)}
{
(turnarounds.length > 0 || st.settings?.capacityNote) && (
<div class="know">
<span class="know-head">
<Star size={11} />
<span>Good to know</span>
</span>
<TickScale width={160} height={10} count={24} />
{turnarounds.map((t) => <span>{t}</span>)}
{orderable.length > 0 && st.settings?.capacityNote && <span>{st.settings.capacityNote}</span>}
</div>
)
}
{orderable.length > 0 && <a href="/inquiries" class="gk-text inq">Something custom? Start an inquiry</a>}
</aside>
</div>
</PageShell>
<style>
.intro {
padding: 36px 0 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
h1 {
margin: 0;
font-size: clamp(32px, 5vw, 44px);
letter-spacing: 0.05em;
}
.lede {
margin: 0;
font-size: 16px;
color: var(--gk-soft);
max-width: 64ch;
line-height: 1.6;
}
.layout {
padding: 16px 0 64px;
display: flex;
flex-wrap: wrap;
gap: 32px;
align-items: flex-start;
}
.main {
flex: 3 1 600px;
min-width: 0;
}
.closed {
display: flex;
flex-direction: column;
gap: 16px;
padding: 32px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid rgba(201, 162, 74, 0.55);
}
.closed :global(.gk-note) {
font-size: 15px;
}
.closed p {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--gk-soft);
}
.closed p:empty {
display: none;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.side {
flex: 1 1 280px;
max-width: 360px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.know {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px;
border-radius: var(--gk-radius);
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
font-size: 14px;
line-height: 1.6;
color: var(--gk-soft);
}
.know-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.inq {
font-size: 14px;
font-weight: 600;
align-self: flex-start;
}
@media (max-width: 700px) {
.side {
max-width: none;
}
.closed {
padding: 22px 16px;
}
}
</style>
+448
View File
@@ -0,0 +1,448 @@
---
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import Icon from "@gk/ui/components/Icon.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import PageHero from "../components/PageHero.astro";
import NowStrip, { type NowItem } from "../components/world/NowStrip.astro";
import WorldSection from "../components/world/WorldSection.astro";
import type { Note } from "../lib/content.ts";
import { plural, site } from "../lib/page.ts";
import { slugify } from "@gk/schema";
import { byOrder, deckUrl, makePhotos, sentence, stancePill, systemPill, systemStates } from "../lib/world.ts";
const s = await site();
const text = s.pageText("/tabletop");
const intro = s.render(text?.body);
const stateRank = (st: string) => systemStates.indexOf(st as (typeof systemStates)[number]);
const systems = byOrder(s.all("tabletop-system")).sort((a, b) => stateRank(a.data.state) - stateRank(b.data.state));
const states = systemStates.filter((st) => systems.some((x) => x.data.state === st));
const campaigns = [...s.all("campaign")].sort((a, b) => a.title.localeCompare(b.title));
const atTable = campaigns.filter((c) => c.data.status === "active" || c.data.status === "playtest");
const pastCampaigns = campaigns.filter((c) => !atTable.includes(c));
const games = byOrder(s.all("card-game"));
const allDecks = [...s.all("deck")].sort((a, b) => a.title.localeCompare(b.title));
const isFav = (d: Note<"deck">) => !!d.data.favorite;
const decks = allDecks.some(isFav) ? allDecks.filter(isFav) : allDecks;
const gameOf = (d: Note<"deck">) => s.link(d.data.game);
const deckGroup = (d: Note<"deck">) => [shortGame(gameOf(d).title), d.data.format].filter(Boolean).join(" ");
const deckGroups = [...new Set(decks.map(deckGroup))];
/** "Magic: The Gathering" reads as MTG in kickers, as the design does. */
function shortGame(title: string) {
return /^magic/i.test(title) ? "MTG" : title;
}
/** A status that points at a deck makes it the deck of the moment. */
const deckStatus = s.statuses().find((st) => s.byName(st.data.subject)?.type === "deck");
const deckOfMoment = deckStatus ? (s.byName(deckStatus.data.subject) as Note<"deck">) : undefined;
const nowItems: NowItem[] = atTable.map((c) => {
const system = c.data.system ? s.link(c.data.system) : null;
const label = c.data.status === "playtest" ? "Playtest" : [system?.title, c.data.platform && `on ${c.data.platform}`].filter(Boolean).join(" · ");
return {
id: c.slug,
label: label || "Campaign",
title: c.title,
note: [c.data.schedule, c.data.blurb].filter(Boolean).join(" · ") || undefined,
};
});
if (deckOfMoment) {
nowItems.push({
label: ["Deck of the moment", deckGroup(deckOfMoment)].filter(Boolean).join(" · "),
title: deckOfMoment.title,
note: deckStatus?.data.note ?? deckOfMoment.data.take,
href: `#${deckOfMoment.slug}`,
});
}
const archidekt = s.settings()?.socials.find((x) => /archidekt/i.test(x.label));
const shelfCount = s.all("board-game").length;
const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
---
<PageShell title="Tabletop" family="tabletop" current="tabletop" description={text?.data.lede ?? "Campaigns, systems, decks and card games."}>
<Fragment slot="hero">
<PageHero eyebrow="My world" title={text?.title ?? "Tabletop"} lede={text?.data.lede} />
</Fragment>
{nowItems.length > 0 && <NowStrip id="tt-now" heading="At the table now" items={nowItems} seal="astrolabe" />}
{intro && <div class="gk-prose intro" set:html={intro} />}
{
systems.length > 0 && (
<WorldSection id="tt-sys" title="Systems" intro="What I run, what I have played, and what I want to try.">
{states.length > 1 && (
<FilterGroup
label="Filter systems by state"
class="tt-filter"
items={[{ label: "All", value: "all", active: true }, ...states.map((st) => ({ label: sentence(st), value: st }))]}
/>
)}
<ul class="cards systems" data-filter-target>
{systems.map((x) => {
const runs = campaigns.filter((c) => s.byName(c.data.system)?.path === x.path);
return (
<li id={x.slug} class="entry" data-value={x.data.state}>
<div class="e-top">
<h3 class="e-name">{x.title}</h3>
<StatusPill kind={systemPill(x.data.state)}>{sentence(x.data.state)}</StatusPill>
</div>
<p class="e-blurb">{x.data.blurb}</p>
{runs.length > 0 && (
<p class="e-foot">
{runs.map((c, i) => (
<>
{i > 0 && " · "}
<a href={`#${c.slug}`}>{c.title}</a>
</>
))}
</p>
)}
</li>
);
})}
</ul>
{pastCampaigns.length > 0 && (
<div class="past">
<h3 class="past-h">Earlier campaigns</h3>
<ul class="past-list">
{pastCampaigns.map((c) => (
<li id={c.slug}>
<Star size={9} />
<span>{c.title}</span>
<StatusPill kind="neutral">{sentence(c.data.status)}</StatusPill>
{c.data.blurb && <span class="past-note">{c.data.blurb}</span>}
</li>
))}
</ul>
</div>
)}
</WorldSection>
)
}
{
decks.length > 0 && (
<WorldSection id="tt-decks" title={allDecks.some(isFav) ? "Favorite decks" : "Decks"} intro="Magic decks pull their details from Archidekt; other games link to wherever their lists live.">
{archidekt && (
<a slot="action" href={archidekt.url} class="gk-text all-decks" rel="me noopener">
All my Magic decks on Archidekt
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</a>
)}
{deckGroups.length > 1 && (
<FilterGroup
label="Filter decks by game"
class="tt-filter"
items={[{ label: "All games", value: "all", active: true }, ...deckGroups.map((g) => ({ label: g, value: g }))]}
/>
)}
<ul class="cards decks" data-filter-target>
{decks.map((d) => {
const url = deckUrl(d);
const game = gameOf(d);
return (
<li id={d.slug} class="deck" data-value={deckGroup(d)}>
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" />
<div class="d-txt">
<span class="d-kick">{[shortGame(game.title), d.data.format].filter(Boolean).join(" · ")}</span>
<div class="e-top">
<h3 class="e-name d-name">
{url ? (
<a href={url} rel="noopener">
{d.title}
</a>
) : (
d.title
)}
{isFav(d) && <Star size={12} />}
</h3>
{deckOfMoment?.path === d.path && <StatusPill>Playing now</StatusPill>}
</div>
{d.data.take && <p class="e-blurb">{d.data.take}</p>}
{url && (
<a href={url} class="gk-text d-link" rel="noopener" aria-label={`${d.title} deck list`}>
{d.data.archidektId ? "On Archidekt" : "Deck list"}
</a>
)}
</div>
</li>
);
})}
</ul>
</WorldSection>
)
}
{
games.length > 0 && (
<WorldSection id="tt-tcg" title="Card games" intro="Each game is one entry, so adding a new one is one note.">
<ul class="cards games">
{games.map((g) => {
const n = s.all("deck").filter((d) => s.byName(d.data.game)?.path === g.path).length;
return (
<li id={g.slug} class="entry">
<div class="e-top">
<h3 class="e-name">{g.title}</h3>
<StatusPill kind={stancePill(g.data.stance)}>{g.data.stance}</StatusPill>
</div>
{g.data.blurb && <p class="e-blurb">{g.data.blurb}</p>}
{n > 0 && <p class="e-foot">{plural(n, "deck")} here</p>}
</li>
);
})}
</ul>
</WorldSection>
)
}
<nav class="more" aria-label="More from the table">
<a href="/library/shelf" class="gk-card more-card">
<span class="m-icon" aria-hidden="true"><Icon name="chess" size={30} /></span>
<span class="m-txt">
<span class="m-name">The Shelf</span>
<span class="m-note">{shelfCount > 0 ? `${plural(shelfCount, "board game")}, filtered by how they play` : "Board games, filtered by how they play"}</span>
</span>
</a>
<a href="/workshop" class="gk-card more-card">
{workshopImg ? <img src={workshopImg} alt="" class="m-icon" loading="lazy" /> : <span class="m-icon" aria-hidden="true"><Icon name="hammer" size={30} /></span>}
<span class="m-txt">
<span class="m-name">Workshop</span>
<span class="m-note">The dice, proxies and terrain I make for the table</span>
</span>
</a>
<a href={`/blog/topic/${slugify("Tabletop")}`} class="gk-card more-card">
<span class="m-icon" aria-hidden="true"><Icon name="list-text" size={30} /></span>
<span class="m-txt">
<span class="m-name">Tabletop posts</span>
<span class="m-note">Session recaps, new games and deck techs</span>
</span>
</a>
</nav>
</PageShell>
<script>
// Filter pills: show the entries whose data-value matches the pressed pill.
document.querySelectorAll<HTMLElement>(".tt-filter").forEach((group) => {
const list = group.parentElement?.querySelector<HTMLElement>("[data-filter-target]");
if (!list) return;
group.classList.add("ready");
group.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("button[data-value]");
if (!btn) return;
const value = btn.dataset.value;
group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === btn)));
list.querySelectorAll<HTMLElement>(":scope > li").forEach((li) => {
li.hidden = value !== "all" && li.dataset.value !== value;
});
});
});
</script>
<style>
.intro {
max-width: 70ch;
padding-top: 28px;
color: var(--gk-soft);
font-size: 16px;
line-height: 1.7;
}
.intro :global(p) {
margin: 0 0 14px;
}
/* The filters only work with the script, which reveals them. */
:global(.tt-filter) {
display: none;
}
:global(.tt-filter.ready) {
display: flex;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.cards > li[hidden] {
display: none;
}
.entry {
min-width: 0;
padding: 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
display: flex;
flex-direction: column;
gap: 8px;
scroll-margin-top: 24px;
}
.games {
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
}
.entry:target,
.deck:target {
border-color: var(--gk-gold);
}
.e-top {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.e-name {
margin: 0;
font-size: 18px;
letter-spacing: 0.02em;
}
.e-blurb {
margin: 0;
font-size: 14px;
color: var(--gk-soft);
line-height: 1.55;
}
.e-foot {
margin: auto 0 0;
padding-top: 8px;
border-top: 1px solid var(--gk-lines);
font-size: 13px;
color: var(--gk-muted);
}
.e-foot a {
color: var(--gk-teal-bright);
}
.past {
display: flex;
flex-direction: column;
gap: 10px;
}
.past-h {
margin: 0;
font-size: 15px;
}
.past-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
}
.past-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.past-note {
color: var(--gk-muted);
font-size: 13px;
}
.all-decks {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.decks {
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
}
.deck {
min-width: 0;
display: flex;
flex-direction: column;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
overflow: hidden;
scroll-margin-top: 24px;
}
.deck :global(.d-art) {
height: 150px;
border-radius: 0;
}
.d-txt {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.d-kick {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.d-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
}
.d-name a {
color: var(--gk-text);
text-decoration: none;
}
.d-name a:hover {
color: var(--gk-gold-bright);
}
.d-link {
align-self: flex-start;
font-size: 13px;
}
.more {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding-top: 48px;
}
.more-card {
flex: 1 1 300px;
flex-direction: row;
align-items: center;
gap: 16px;
padding: 18px;
}
.m-icon {
flex: none;
width: 72px;
height: 72px;
border-radius: 8px;
object-fit: cover;
background: var(--gk-sky);
display: flex;
align-items: center;
justify-content: center;
color: var(--gk-teal-bright);
}
.m-txt {
display: flex;
flex-direction: column;
gap: 2px;
}
.m-name {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 17px;
}
.m-note {
font-size: 13px;
color: var(--gk-muted);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
---
/**
* One case study: the post reading layout plus a facts strip (employer,
* role, period, key numbers, tech). A blog post's slug answers with a 301 to
* /blog/<slug>; an old slug follows its redirect.
*/
import FactsStrip from "../../components/blog/FactsStrip.astro";
import PostLayout from "../../components/blog/PostLayout.astro";
import { site } from "../../lib/page.ts";
const s = await site();
const slug = Astro.params.slug ?? "";
const post = s.bySlug("post", slug);
if (!post) {
const to = s.redirect("post", slug);
return to ? Astro.redirect(to, 301) : Astro.rewrite("/404");
}
if (post.data.postType !== "case study") return Astro.redirect(`/blog/${post.slug}`, 301);
const list = s.caseStudies();
const pool = s.posts({ includeCaseStudies: true });
const d = post.data;
---
<PostLayout
post={post}
site={s}
family="work"
current="work"
crumbs={[{ label: "Work", href: "/work" }, { label: d.employer ?? "Case study" }]}
list={list}
pool={pool}
noun="case study"
>
<FactsStrip slot="facts" post={post} />
</PostLayout>
+174
View File
@@ -0,0 +1,174 @@
---
import Button from "@gk/ui/components/Button.astro";
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
import Notice from "@gk/ui/components/Notice.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import PageHero from "../../components/PageHero.astro";
import FeaturedStudy from "../../components/work/FeaturedStudy.astro";
import StudyCard from "../../components/work/StudyCard.astro";
import { site } from "../../lib/page.ts";
import { coreTech, featuredStudy, groupLabels, otherStudies, workGroup, type WorkGroup } from "../../lib/work.ts";
const s = await site();
const text = s.pageText("/work");
const studies = s.caseStudies();
const featured = featuredStudy(studies);
const rest = otherStudies(studies, featured);
const core = coreTech(s);
const groups = (["employer", "personal", "homelab"] as WorkGroup[]).filter((g) => studies.some((p) => workGroup(p) === g));
const lede = text?.data.lede ?? "Employer work told as written case studies, plus the personal and homelab projects I build for myself.";
---
<PageShell title="Work" family="work" current="work" description="Case studies from employer work, personal projects and the homelab.">
<Fragment slot="hero">
<PageHero eyebrow="Work" title="Case studies" lede={lede} />
</Fragment>
{
groups.length > 1 && (
<section aria-label="Filter case studies" class="filters" data-work-filters hidden>
<FilterGroup
label="Show"
items={[{ label: "All", value: "all", active: true }, ...groups.map((g) => ({ label: groupLabels[g], value: g }))]}
/>
</section>
)
}
{
studies.length === 0 && (
<div class="empty">
<Notice title="No case studies yet.">They are being written up now.</Notice>
</div>
)
}
{featured && <FeaturedStudy study={featured} href={s.href(featured)} core={core} />}
{
rest.length > 0 && (
<section aria-label="More case studies" class="more">
<ul class="grid">
{rest.map((p) => (
<StudyCard study={p} href={s.href(p)} core={core} />
))}
</ul>
<p class="none" data-work-none hidden>Nothing else in this group yet.</p>
</section>
)
}
<section aria-labelledby="work-cta" class="cta">
<div class="cta-inner">
<div class="cta-text">
<div class="heading">
<h2 id="work-cta">Have something like this in mind?</h2>
<span class="underline" aria-hidden="true"></span>
</div>
<span class="cta-sub">Jobs and freelance web work.</span>
</div>
<div class="cta-actions">
<Button href="/inquiries">Start an inquiry</Button>
<Button href="/resume" variant="secondary">See the resume</Button>
</div>
</div>
</section>
</PageShell>
<script>
const bar = document.querySelector<HTMLElement>("[data-work-filters]");
if (bar) {
bar.hidden = false;
const buttons = [...bar.querySelectorAll<HTMLButtonElement>("button[data-value]")];
const items = [...document.querySelectorAll<HTMLElement>("[data-group]")];
const none = document.querySelector<HTMLElement>("[data-work-none]");
const apply = (value: string) => {
buttons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.value === value)));
let shownInGrid = 0;
for (const el of items) {
const show = value === "all" || el.dataset.group === value;
el.hidden = !show;
if (show && el.tagName === "LI") shownInGrid++;
}
if (none) none.hidden = shownInGrid > 0;
};
buttons.forEach((b) => b.addEventListener("click", () => apply(b.dataset.value ?? "all")));
}
</script>
<style>
.filters {
padding-top: 28px;
}
.empty {
padding-top: 28px;
}
.more {
padding: 24px 0 48px;
}
.grid {
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: 20px;
}
.grid :global([hidden]) {
display: none;
}
.none {
margin: 0;
color: var(--gk-muted);
}
.cta {
margin-inline: calc(50% - 50vw);
margin-top: 24px;
background: var(--gk-midnight);
border-top: 1px solid var(--gk-lines);
border-bottom: 1px solid var(--gk-lines);
}
.cta-inner {
max-width: var(--gk-page-max);
margin: 0 auto;
padding: 40px var(--gk-gutter);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.cta-text {
display: flex;
flex-direction: column;
gap: 6px;
}
.heading {
display: flex;
flex-direction: column;
gap: 8px;
}
.heading h2 {
margin: 0;
font-size: 26px;
letter-spacing: 0.06em;
}
.underline {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent),
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.cta-sub {
font-size: 15px;
color: var(--gk-muted);
}
.cta-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
</style>
@@ -0,0 +1,419 @@
---
import Button from "@gk/ui/components/Button.astro";
import Chip from "@gk/ui/components/Chip.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";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import { pageSeals } from "@gk/ui/components/seals.ts";
import CtaBand from "../../components/world/CtaBand.astro";
import NowStrip, { type NowItem } from "../../components/world/NowStrip.astro";
import WorldSection from "../../components/world/WorldSection.astro";
import type { Note } from "../../lib/content.ts";
import { plural, site } from "../../lib/page.ts";
import { formatDate, mediaSrc } from "../../lib/site.ts";
import { buildLogs, byDateDesc, byOrder, craftPill, craftSeal, makePhotos, makesOf, sentence, toolsOf } from "../../lib/world.ts";
const s = await site();
const slug = Astro.params.slug ?? "";
const craft = s.bySlug("craft", slug);
if (!craft) {
const to = s.redirect("craft", slug);
if (to) return Astro.redirect(to, 301);
return Astro.rewrite("/404");
}
const seal = craftSeal(craft);
const artSeal = seal ?? pageSeals.workshop;
const station = craft.data.station ? s.link(craft.data.station) : null;
const tools = toolsOf(s, craft);
const makes = byDateDesc(makesOf(s, craft));
const logs = buildLogs(s, craft);
const body = s.render(craft.body);
const others = byOrder(s.all("craft")).filter((c) => c.path !== craft.path);
const related = new Set([craft.path, ...makes.map((m) => m.path)]);
const nowItems: NowItem[] = s
.statuses()
.filter((st) => {
const subject = s.byName(st.data.subject);
return subject && related.has(subject.path);
})
.map((st) => {
const subject = s.link(st.data.subject!);
const img = subject.note?.type === "make" ? makePhotos(subject.note as Note<"make">)[0] : undefined;
return {
id: st.slug,
label: st.data.kind === "Making" ? "On the bench" : st.data.kind,
title: subject.title,
note: st.data.note,
href: subject.note?.type === "make" ? `#${subject.note.slug}` : undefined,
img,
};
});
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);
---
<PageShell title={craft.title} family="workshop" seal={seal} current="workshop" description={craft.data.blurb ?? `${craft.title} in Greg Kilmer's Workshop.`}>
<Fragment slot="hero">
<div class="crumb"><a href="/workshop">Workshop</a> / Craft</div>
<h1 class="c-title"><Seal name={artSeal} size={64} />{craft.title}</h1>
{craft.data.blurb && <p class="c-lede">{craft.data.blurb}</p>}
<div class="c-meta">
<StatusPill kind={craftPill(craft.data.status)}>{craft.data.status}</StatusPill>
<span>
{meta}
{
station && (
<>
{" · "}
{station.href ? <a href={station.href}>{station.title}</a> : station.title}
{" station"}
</>
)
}
</span>
</div>
</Fragment>
{body && <div class="gk-prose body" set:html={body} />}
{nowItems.length > 0 && <NowStrip id="cr-now" heading="Right now" items={nowItems} seal={artSeal} />}
{
tools.length > 0 && (
<WorldSection id="cr-tools" title="Tools and materials">
<ul class="tools">
{tools.map(({ title, note }) => (
<li class="tool">
{note && <span class="t-kind">{note.data.kind === "other" ? "Tool" : sentence(note.data.kind)}</span>}
<h3 class="t-name">{note ? <a href={s.href(note) ?? undefined}>{title}</a> : title}</h3>
{note?.data.blurb && <span class="t-blurb">{note.data.blurb}</span>}
</li>
))}
</ul>
</WorldSection>
)
}
<WorldSection id="cr-makes" title="Makes" intro={makes.length > 1 ? "Newest first." : undefined}>
{
makes.length > 0 ? (
<ul class="makes">
{makes.map((m) => {
const photos = makePhotos(m);
const log = s.backlinks(m, { type: "post" })[0];
return (
<li id={m.slug} class="make">
<StarChartArt seal={artSeal} ratio="auto" src={photos[0]} alt={m.data.blurb ?? m.title} class="m-art" />
{photos.length > 1 && (
<span class="m-more">
{photos.slice(1, 5).map((p, i) => (
<img src={p} alt={`${m.title}, photo ${i + 2}`} loading="lazy" />
))}
</span>
)}
<span class="m-txt">
<h3 class="m-name">{m.title}</h3>
{m.data.blurb && <span class="m-blurb">{m.data.blurb}</span>}
{(m.data.date || log) && (
<span class="m-meta">
{m.data.date && <time datetime={m.data.date}>{formatDate(m.data.date)}</time>}
{log && <Chip href={s.href(log) ?? undefined}>Build log</Chip>}
</span>
)}
</span>
</li>
);
})}
</ul>
) : (
<p class="empty">Nothing finished in this craft yet.</p>
)
}
</WorldSection>
{
logs.length > 0 && (
<WorldSection id="cr-logs" title="Build logs" intro={`Blog posts about ${craft.title.toLowerCase()} and its makes.`}>
<ul class="logs">
{logs.map((p) => (
<li class="log">
<StarChartArt seal={artSeal} ratio="auto" src={postCover(p)} class="l-art" />
<div class="l-txt">
<span class="l-kick">
<span class="l-topic">{p.data.topic}</span>
<time datetime={p.data.date}>{formatDate(p.data.date)}</time>
</span>
<a href={s.href(p) ?? undefined} class="l-title">{p.title}</a>
{p.data.summary && <span class="l-sum">{p.data.summary}</span>}
</div>
</li>
))}
</ul>
</WorldSection>
)
}
{
others.length > 0 && (
<nav class="others" aria-label="Other crafts">
<span class="o-label">Other crafts</span>
{others.map((c) => <Chip href={s.href(c) ?? undefined}>{c.title}</Chip>)}
</nav>
)
}
<CtaBand title="Want one of your own?" note="Custom pieces start as an inquiry.">
<Button href="/inquiries">Start an inquiry</Button>
</CtaBand>
</PageShell>
<style>
.crumb {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.crumb a {
color: inherit;
text-decoration: none;
}
.crumb a:hover {
text-decoration: underline;
}
.c-title {
display: flex;
align-items: center;
gap: 18px;
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.08em;
line-height: 1.05;
}
.c-lede {
margin: 0;
font-size: 17px;
color: var(--gk-soft);
max-width: 62ch;
line-height: 1.6;
}
.c-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 6px;
font-size: 13px;
color: var(--gk-muted);
}
.c-meta a {
color: var(--gk-teal-bright);
}
.body {
max-width: 70ch;
padding-top: 28px;
color: var(--gk-soft);
font-size: 16px;
line-height: 1.7;
}
.tools {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.tool {
min-width: 0;
padding: 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: var(--gk-radius);
display: flex;
flex-direction: column;
gap: 6px;
}
.t-kind {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.t-name {
margin: 0;
font-size: 18px;
letter-spacing: 0.02em;
}
.t-name a {
color: var(--gk-text);
text-decoration: none;
}
.t-name a:hover {
color: var(--gk-gold-bright);
}
.t-blurb {
font-size: 14px;
color: var(--gk-soft);
line-height: 1.55;
}
.makes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.make {
display: flex;
flex-direction: column;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
overflow: hidden;
scroll-margin-top: 24px;
}
.make:target {
border-color: var(--gk-gold);
}
.make :global(.m-art) {
height: 200px;
border-radius: 0;
}
.m-more {
display: flex;
gap: 4px;
padding: 4px 4px 0;
}
.m-more img {
flex: 1;
min-width: 0;
height: 56px;
object-fit: cover;
border-radius: 4px;
}
.m-txt {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.m-name {
margin: 0;
font-size: 17px;
}
.m-blurb {
font-size: 13px;
color: var(--gk-soft);
line-height: 1.5;
}
.m-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
font-size: 12px;
color: var(--gk-muted);
}
.empty {
margin: 0;
color: var(--gk-muted);
}
.logs {
margin: 0;
padding: 0;
list-style: none;
border-bottom: 1px solid var(--gk-lines);
}
.log {
display: flex;
gap: 20px;
height: 164px;
padding: 22px 0 20px;
border-top: 1px dashed rgba(244, 242, 237, 0.16);
}
.log :global(.l-art) {
flex: 0 0 176px;
height: 120px;
border-radius: 8px;
}
.l-txt {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.l-kick {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--gk-muted);
}
.l-topic {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.l-title {
font-family: var(--gk-font-display);
font-weight: 700;
font-size: 20px;
line-height: 1.3;
color: var(--gk-text);
text-decoration: none;
}
.l-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.l-title:hover {
color: var(--gk-gold-bright);
}
.l-sum {
font-size: 14px;
color: var(--gk-soft);
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 640px) {
.log :global(.l-art) {
flex-basis: 96px;
height: 96px;
}
.log {
gap: 14px;
}
}
.others {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding-top: 32px;
}
.o-label {
font-size: 13px;
color: var(--gk-muted);
}
</style>
@@ -0,0 +1,481 @@
---
import Button from "@gk/ui/components/Button.astro";
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import TickRing from "@gk/ui/components/TickRing.astro";
import PageHero from "../../components/PageHero.astro";
import CtaBand from "../../components/world/CtaBand.astro";
import NowStrip, { type NowItem } from "../../components/world/NowStrip.astro";
import WorldSection from "../../components/world/WorldSection.astro";
import type { Note } from "../../lib/content.ts";
import { plural, site } from "../../lib/page.ts";
import { buildLogs, byDateDesc, byOrder, craftPill, craftSeal, isSword, makePhotos, makesOf, statusesFor, toolsAt, toolsOf } from "../../lib/world.ts";
const s = await site();
const text = s.pageText("/workshop");
const intro = s.render(text?.body);
const crafts = byOrder(s.all("craft")).map((craft) => {
const makes = byDateDesc(makesOf(s, craft));
const photos = makes.flatMap((m) => makePhotos(m).slice(0, 1)).slice(0, 3);
return { craft, makes, photos, logs: buildLogs(s, craft).length, tools: toolsOf(s, craft).map((t) => t.title) };
});
const stations = byOrder(s.all("station")).map((station) => ({ station, tools: toolsAt(s, station) }));
const loose = s.all("tool").filter((t) => !s.byName(t.data.station));
const allMakes = s.all("make");
const swords = allMakes.filter(isSword);
const recent = byDateDesc(allMakes.filter((m) => !isSword(m)))
.sort((a, b) => Number(!!b.data.date) - Number(!!a.data.date) || Number(makePhotos(b).length > 0) - Number(makePhotos(a).length > 0))
.slice(0, 4);
const nowItems: NowItem[] = statusesFor(s, "/workshop").map((st) => {
const subject = st.data.subject ? s.link(st.data.subject) : null;
const img = subject?.note?.type === "make" ? makePhotos(subject.note as Note<"make">)[0] : undefined;
return {
id: st.slug,
label: st.data.kind === "Making" ? "On the bench" : st.data.kind,
title: subject?.title ?? st.title,
note: st.data.note,
href: subject?.href,
img,
};
});
---
<PageShell title="Workshop" family="workshop" current="workshop" description={text?.data.lede ?? "What I make, what I make it with, and what I am learning next."}>
<Fragment slot="hero">
<PageHero eyebrow="My world" title={text?.title ?? "Workshop"} lede={text?.data.lede} />
</Fragment>
{intro && <div class="gk-prose intro" set:html={intro} />}
{nowItems.length > 0 && <NowStrip id="ws-now" heading="Right now" items={nowItems} seal="craft-print" />}
{
stations.length > 0 && (
<WorldSection id="ws-space" title="The workspace" intro={`${plural(stations.length, "station")} around the office.`}>
<ol class="stations">
{stations.map(({ station, tools }) => (
<li id={station.slug} class="station">
<span class="st-head">
<TickRing size={32}>{station.data.order}</TickRing>
<h3>{station.title}</h3>
</span>
<span class="where">{station.data.where}</span>
{tools.length > 0 && (
<ul class="tools">
{tools.map((t) => (
<li id={t.slug} title={t.data.blurb}>
<Star size={9} />
{t.title}
</li>
))}
</ul>
)}
{station.data.goodFor && <span class="good">{station.data.goodFor}</span>}
</li>
))}
{loose.length > 0 && (
<li class="station">
<span class="st-head">
<TickRing size={32}><Star size={10} /></TickRing>
<h3>Around the shop</h3>
</span>
<ul class="tools">
{loose.map((t) => (
<li id={t.slug}>
<Star size={9} />
{t.title}
</li>
))}
</ul>
</li>
)}
</ol>
</WorldSection>
)
}
<WorldSection id="ws-crafts" title="Crafts" intro="Each craft keeps its own makes, tools and build logs, so the page stays short as the collection grows.">
<ul class="crafts">
{
crafts.map(({ craft, makes, photos, logs, tools }) => (
<li>
<a href={s.href(craft) ?? undefined} class="gk-card craft">
<span class="c-top">
<h3 class="c-name">{craft.title}</h3>
<StatusPill kind={craftPill(craft.data.status)}>{craft.data.status}</StatusPill>
</span>
<span class="thumbs" aria-hidden="true">
{[0, 1, 2].map((i) =>
photos[i] ? (
<img src={photos[i]} alt="" loading="lazy" />
) : (
<span class="blank">
<Seal name={craftSeal(craft) ?? "craft-print"} size={30} />
</span>
),
)}
</span>
{craft.data.blurb && <span class="c-blurb">{craft.data.blurb}</span>}
{tools.length > 0 && <span class="c-tools">{tools.join(" · ")}</span>}
<span class="c-foot">
<span>
{plural(makes.length, "make")}
{logs > 0 && ` · ${plural(logs, "build log")}`}
</span>
<span class="open">
Open the craft
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</span>
</span>
</a>
</li>
))
}
</ul>
</WorldSection>
{
recent.length > 0 && (
<WorldSection id="ws-recent" title="Recently finished">
<ul class="recent">
{recent.map((m) => {
const found = s.byName(m.data.craft);
const craft = found?.type === "craft" ? (found as Note<"craft">) : undefined;
const log = s.backlinks(m, { type: "post" })[0];
return (
<li class="r-card">
<a href={s.href(m) ?? undefined} class="r-art">
<StarChartArt seal={(craft && craftSeal(craft)) ?? "craft-print"} ratio="auto" src={makePhotos(m)[0]} alt={m.data.blurb ?? m.title} class="r-img" />
<span class="gk-visually-hidden">{m.title}</span>
</a>
<div class="r-txt">
{craft && <span class="r-craft">{craft.title}</span>}
<h3 class="r-name">
<a href={s.href(m) ?? undefined}>{m.title}</a>
</h3>
{m.data.blurb && <span class="r-blurb">{m.data.blurb}</span>}
{log && (
<a href={s.href(log) ?? undefined} class="gk-text r-log">
Blog: {log.title}
</a>
)}
</div>
</li>
);
})}
</ul>
</WorldSection>
)
}
{
swords.length > 0 && (
<section class="rack gk-featured" aria-labelledby="ws-swords">
<GiltCorners />
<div class="rack-words">
<span class="rack-kick">The sword rack</span>
<h2 id="ws-swords">Blades from the stories I love</h2>
<span class="rack-line" aria-hidden="true" />
<span class="rack-note">I always pick the sword class, and the spellsword when I can.</span>
</div>
<ul class="blades">
{swords.map((m) => (
<li>
<Star size={10} />
<a href={s.href(m) ?? undefined} title={m.data.blurb}>
{m.title}
</a>
</li>
))}
</ul>
</section>
)
}
<CtaBand title="Want one of these?" note="Some of it is in the Store. Anything else can start as an inquiry.">
<Button href="/store">Visit the Store</Button>
<Button href="/inquiries" variant="secondary">Start an inquiry</Button>
</CtaBand>
</PageShell>
<style>
.intro {
max-width: 70ch;
padding-top: 28px;
color: var(--gk-soft);
font-size: 16px;
line-height: 1.7;
}
.intro :global(p) {
margin: 0 0 14px;
}
/* Stations */
.stations {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.station {
flex: 1 1 250px;
min-width: 0;
padding: 20px;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-top: 2px solid var(--gk-gold);
border-radius: var(--gk-radius);
display: flex;
flex-direction: column;
gap: 10px;
scroll-margin-top: 24px;
}
.st-head {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.st-head h3 {
margin: 0;
font-size: 18px;
letter-spacing: 0.02em;
}
.where {
font-size: 13px;
color: var(--gk-muted);
}
.tools {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.tools li {
display: flex;
align-items: center;
gap: 8px;
}
.good {
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--gk-lines);
font-size: 13px;
color: var(--gk-soft);
}
/* Crafts */
.crafts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.craft {
height: 100%;
gap: 12px;
padding: 20px;
}
.c-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.c-name {
margin: 0;
font-size: 20px;
letter-spacing: 0.04em;
}
.thumbs {
display: flex;
gap: 6px;
}
.thumbs > * {
flex: 1;
min-width: 0;
height: 72px;
border-radius: 6px;
object-fit: cover;
}
.thumbs .blank {
display: flex;
align-items: center;
justify-content: center;
background: var(--gk-sky);
opacity: 0.6;
}
.c-blurb {
font-size: 14px;
color: var(--gk-soft);
line-height: 1.55;
}
.c-tools {
font-size: 13px;
color: var(--gk-muted);
}
.c-foot {
margin-top: auto;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
font-size: 13px;
color: var(--gk-soft);
}
.open {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--gk-teal-bright);
font-weight: 600;
}
/* Recently finished */
.recent {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.r-card {
flex: 1 1 240px;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--gk-midnight);
border: 1px solid var(--gk-lines);
border-radius: var(--gk-radius);
overflow: hidden;
}
.r-art {
display: block;
}
.r-art :global(.r-img) {
height: 170px;
border-radius: 0;
}
.r-txt {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.r-craft {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-muted);
text-transform: uppercase;
}
.r-name {
margin: 0;
font-size: 16px;
}
.r-name a {
color: var(--gk-text);
text-decoration: none;
}
.r-name a:hover {
color: var(--gk-gold-bright);
}
.r-blurb {
font-size: 13px;
color: var(--gk-muted);
}
.r-log {
font-size: 13px;
align-self: flex-start;
}
/* Sword rack */
.rack {
margin-top: 40px;
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: center;
padding: 24px 28px;
}
.rack-words {
flex: 1 1 300px;
display: flex;
flex-direction: column;
gap: 8px;
}
.rack-kick {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
.rack h2 {
margin: 0;
font-size: 24px;
letter-spacing: 0.05em;
}
.rack-line {
display: block;
width: 180px;
height: 2px;
border-radius: 2px;
background:
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px),
linear-gradient(90deg, var(--gk-gold), rgba(201, 162, 74, 0.15) 70%, transparent);
box-shadow: 0 0 8px rgba(201, 162, 74, 0.35);
}
.rack-note {
font-size: 14px;
color: var(--gk-muted);
}
.blades {
flex: 2 1 480px;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 8px 16px;
font-size: 15px;
}
.blades li {
display: flex;
align-items: center;
gap: 8px;
}
.blades a {
color: var(--gk-text);
text-decoration: none;
}
.blades a:hover {
color: var(--gk-gold-bright);
}
@media (max-width: 640px) {
.rack {
padding: 24px 20px;
}
.blades {
flex-basis: 100%;
}
}
</style>
@@ -4,7 +4,8 @@ title: "{{title}}"
publish: false # Only notes set to true appear on the site. publish: false # Only notes set to true appear on the site.
created: "{{date:YYYY-MM-DD}}" # optional created: "{{date:YYYY-MM-DD}}" # optional
company: company:
role: role: # The current or last title at this company.
previousRoles: [] # optional. Earlier titles at the same company, newest first, shown as Previously.
start: start:
end: # optional. Leave out while it is your current role. end: # optional. Leave out while it is your current role.
location: # optional location: # optional