Start the rebuild: note schema, vault kit and data migration

Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

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 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
+399
View File
@@ -0,0 +1,399 @@
# Content Guide
How to add and edit content on the site. Four sections use **Content Collections**
(typed markdown/MDX/YAML); the other interest pages are hand-built `.astro` files.
---
## Projects (Professional page)
Projects render from `src/content/projects/*.md` (or `.mdx`). Each file is one
project card. The schema lives in `src/content.config.ts`.
### Add a project
Create `src/content/projects/my-project.md`:
```md
---
title: 'My Awesome Project'
role: 'Lead Developer' # optional, default: 'Developer'
tech: ['TypeScript', 'Node.js', 'SAP BTP'] # rendered as pills
summary: 'A 2-3 sentence description of what it does and your contribution.'
repo: 'https://github.com/you/repo' # optional
demo: 'https://demo.example.com' # optional
caseStudy: 'https://blog.example.com' # optional
cover: '/images/my-project.png' # optional, public path
order: 20 # lower = shown first, default 100
placeholder: false # true hides it from "real" styling
---
Optional longer writeup in markdown here. The card only shows `summary`, but you
can link to a case study for depth.
```
### Field reference
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | Project name |
| `role` | string | no | e.g. "Lead Developer" |
| `tech` | string[] | no | Tag pills |
| `summary` | string | yes | Short card description |
| `repo` / `demo` / `caseStudy` | URL | no | Outbound links |
| `cover` | string | no | Image path in `public/` |
| `order` | number | no | Sort weight |
| `placeholder` | boolean | no | Marks it as a TODO card |
To remove the seed placeholder, delete `placeholder-project.md`. Aim for 3-6 real
entries per the spec.
---
## Experience (Professional timeline)
Experience renders from `src/content/experience/*.md`. Each file is one role on
the vertical timeline.
### Add a role
Create `src/content/experience/another-role.md`:
```md
---
company: 'Previous Co.'
title: 'Junior Developer'
period: '2016 - 2020' # free-form date label
focus: 'What you primarily did.' # optional
icon: 'briefcase' # mdi icon name for the marker
order: 20 # lower = more recent / first
---
- Bullet point of an accomplishment.
- Another bullet, ideally with a metric.
- One more, showing impact.
```
The body renders inside an expandable "Show details" panel. Use markdown lists
for bullets. Replace the bracketed placeholders in the seed `implico.md` with
real outcomes, metrics, and dates.
### Icon names
The `icon` field takes an mdi name without the `mdi:` prefix. Browse names at
[pictogrammers.com](https://pictogrammers.com/library/mdi/) or search the
installed set:
```bash
node -e "const m=require('./node_modules/@iconify-json/mdi/icons.json'); console.log(Object.keys(m.icons).filter(n=>n.includes('cloud')).join('\n'))"
```
---
## Games (Gaming page)
The Gaming page (`src/pages/interests/gaming.astro`) has two content-driven
sections that read from collections: **Now playing** and the **Gaming Hall of
Fame**. The platforms grid and favorite-genre cards are still edited inline on
the page.
### Hall of Fame — one file per game
Each game is a markdown file in `src/content/games/hall-of-fame/`. Create a new
file (e.g. `hades.md`):
```md
---
title: 'Hades'
platform: 'Steam' # free-form; drives the icon + platform pill
year: 2020 # optional, shown as a small detail
art: '/images/games/hades.jpg' # optional, public path (see fetch tool below)
artAlt: 'Hades key art' # optional, defaults to the title
icon: 'sword-cross' # optional, overrides the platform-derived icon
blurb: 'A 2-3 sentence case for why it earned a Hall of Fame slot.'
order: 15 # lower = shown first, default 100
---
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | Game name |
| `platform` | string | yes | e.g. "PlayStation 5", "Steam", "Nintendo Switch" |
| `blurb` | string | yes | Why it's a favorite |
| `art` | string | no | `/images/games/<slug>.jpg` (public path) |
| `artAlt` | string | no | Alt text, defaults to title |
| `year` | number | no | Release year |
| `icon` | string | no | mdi icon override (else derived from platform) |
| `order` | number | no | Sort weight |
`platform` is matched case-insensitively to pick an icon: `playstation` →
`sony-playstation`, `switch` → `nintendo-switch`, `steam`/`pc` → `steam`,
`xbox` → `microsoft-xbox`, anything else → `gamepad-variant`. Override with
`icon` if you want something specific.
### Now playing — single list
Edit `src/content/games/now-playing.yaml`. This is the transient list of games
you're actively playing; each entry is a title + platform only:
```yaml
games:
- title: "Elden Ring"
platform: "PlayStation 5"
- title: "Balatro"
platform: "Steam"
```
If the list is empty, the page shows a TODO placeholder instead.
### Fetching box art (RAWG)
`scripts/fetch-box-art.mjs` downloads box/banner art from the
[RAWG API](https://rawg.io/apidocs) into `public/images/games/`, then prints the
public path to paste into a game file's `art:` field. RAWG covers Steam,
PlayStation, Xbox, and Nintendo in one place.
1. Get a free key at <https://rawg.io/login?forward=developer> and add it to
`.env` (copy from `.env.example`):
```
RAWG_API_KEY=your_key_here
```
2. Run (Node 20.6+ can auto-load `.env` via `--env-file`):
```bash
node --env-file=.env scripts/fetch-box-art.mjs "Elden Ring"
node --env-file=.env scripts/fetch-box-art.mjs "Hollow Knight" --platform pc
node --env-file=.env scripts/fetch-box-art.mjs --id 3328 # by RAWG id
node --env-file=.env scripts/fetch-box-art.mjs "Celeste" --out celeste.jpg
```
3. It prints something like:
```
✔ Saved: public/images/games/elden-ring.jpg
art: "/images/games/elden-ring.jpg"
```
Paste that `art:` value into the game file's frontmatter.
> RAWG serves **landscape** banner/screenshot art (not portrait Steam
> capsules), which matches the Hall of Fame cards (`aspect-[4/3]`).
---
## Board games (TTRPGs page)
The "Board games" section on the TTRPGs page reads from a JSON file and renders
an interactive shelf: a list of games on the left, and a tall image on the
right that cross-fades to a game's BGG cover when you hover its name. Clicking a
name opens the game on BoardGameGeek.
### The list — `src/data/boardgames.json`
A flat JSON array. Each entry needs a `name` and a `bggId`; `note` and `image`
are optional:
```json
[
{ "name": "Wingspan", "bggId": 266192, "note": "Engine builder" },
{ "name": "Brass: Birmingham", "bggId": 224517 }
]
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Display label + link text |
| `bggId` | number | yes | BoardGameGeek numeric id (from its URL) |
| `note` | string | no | One-line caption shown under the name |
| `image` | string | no | Cover path/URL; populated by the fetch tool |
Find a game's `bggId` in its BoardGameGeek URL:
`https://boardgamegeek.com/boardgame/<bggId>/<slug>`.
The shelf photo at rest is `/images/ttrpg-boardgame-shelf.jpg` — drop a real
photo of your shelf at that path. The image is a **progressive enhancement**:
without JS the list still works (links + shelf photo); games without an `image`
just keep the shelf photo on hover.
### Fetching covers (BoardGameGeek)
`scripts/fetch-bgg-covers.mjs` fills in the `image` field for any game missing
one, using BGG's public XML2 API (no key needed):
```bash
# Default: download covers to public/images/boardgames/<bggId>.jpg (recommended)
node scripts/fetch-bgg-covers.mjs
# Instead hotlink BGG's CDN URLs (no download, depends on their CDN)
node scripts/fetch-bgg-covers.mjs --remote
# Re-fetch even games that already have an image
node scripts/fetch-bgg-covers.mjs --force
# Slow down between BGG calls (they rate-limit; default 1200ms)
node scripts/fetch-bgg-covers.mjs --sleep 2000
```
It's idempotent — run it again any time you add games to the JSON.
---
## Media viewing history (Anime, TV, Movies, Reading pages)
Four interest pages — **Anime** (`/interests/anime`), **TV Shows**
(`/interests/tv`), **Movies** (`/interests/movies`), and **Reading**
(`/interests/reading`) — share one data pattern. Each reads a JSON file, shows
the **favorites** as poster/cover cards up top, then the **full library**
grouped by category as pills below.
The data files and their pages:
| Page | Data file |
|---|---|
| Anime | `src/data/media/anime.json` |
| TV Shows | `src/data/media/tv.json` |
| Movies | `src/data/media/movies.json` |
| Reading | `src/data/reading.json` |
### The shared item shape
A flat JSON array. Each entry has a `title` plus optional fields. The
`favorite` flag is the **single source of truth** for what appears in the top
grid — set it on the entry itself, no separate favorites list to keep in sync.
`category` drives the grouping of the full library below (items without a
category appear only in favorites, if favorited).
```json
[
{
"title": "Breaking Bad",
"favorite": true,
"blurb": "The character study of a decade."
},
{
"title": "Dark",
"category": "Streaming Originals",
"status": "Completed",
"note": "Optional short detail shown on hover."
}
]
```
| Field | Type | Notes |
|---|---|---|
| `title` | string | Display label (required) |
| `favorite` | boolean | True → rich card in the top "Favorites" grid |
| `category` | string | Grouping label for the full library, e.g. "Prestige Drama" |
| `status` | string | Pill text, e.g. "Completed", "Watching", "Caught up" |
| `year` | number | Release year; shown on movie/anime pills |
| `count` | string | Human span for a franchise, e.g. "All 4 films" |
| `blurb` | string | Long-form copy on favorite cards |
| `note` | string | Short detail on a full-list pill (hover/title attr) |
| `image` | string | Local art path; populated by the fetch tools below |
Adding an entry is just dropping another object in the right JSON file. The
page reads it via the helpers in `src/lib/media.ts` (`getFavorites`,
`getGrouped`). Categories appear in order of first occurrence, so order the
file the way you want the page grouped.
**Movies are film-level:** each entry is one film, with `category` naming the
franchise it belongs to (e.g. "The Matrix", "Marvel Cinematic Universe").
Single films with no sequel go under `category: "Standalone"`. A favorite film
carries `favorite: true` so it appears in the top grid **and** in its franchise
group.
### Fetching poster/cover art
Three idempotent scripts fill in the `image` field for **favorites only** (the
full library stays text pills), downloading to `public/images/<kind>/`:
```bash
# Anime — AniList GraphQL API (free, NO key required)
node scripts/fetch-anime-art.mjs
node scripts/fetch-anime-art.mjs --force # re-fetch existing
# Reading — Open Library Covers API (free, NO key required)
node scripts/fetch-book-covers.mjs
# Movies + TV — TMDB API (free key required: TMDB_API_KEY in .env)
node --env-file=.env scripts/fetch-tmdb-art.mjs
node --env-file=.env scripts/fetch-tmdb-art.mjs --kind movies
node --env-file=.env scripts/fetch-tmdb-art.mjs --kind tv
```
All three skip items that already have an `image` (use `--force` to re-fetch),
sleep briefly between requests, and write the local path back into the JSON.
Get a free TMDB key at <https://www.themoviedb.org/settings/api>.
Cards render the `image` when present and fall back to a kind-derived icon slab
otherwise, so partial coverage looks intentional.
---
## TCGs (Trading Card Games page)
The TCGs page (`src/pages/interests/tcgs.astro`) is hand-built, like the other
interest pages. Two parts are data-driven:
- **The games I play** and **Proxy making** are inline frontmatter arrays of
`{ icon, title, body }` — edit them directly on the page.
- **Magic: The Gathering decks** are pulled **live from Archidekt** on the
server (the site is SSR, so no CORS or API key is needed).
### Archidekt integration
`src/lib/archidekt.ts` fetches Greg's public decks from:
```
GET https://archidekt.com/api/decks/v3/?ownerUsername=<username>
```
- The username is the `ARCHIDEKT_USERNAME` export (currently `Patiun`). Change
it there to point at a different account.
- Results are cached to `node_modules/.cache/archidekt-<username>.json` for
`CACHE_TTL_MS` (default **1 hour**) so repeat visits are instant and we're
gentle on Archidekt. Tune the TTL via that export.
- Only public, non-unlisted decks are shown. If Archidekt is unreachable, the
section falls back to a "Manage on Archidekt" link — it never breaks the page.
Each deck renders as a `DeckCard` (featured card art, mana-color pips, card
count, format, last-updated) linking to its Archidekt page. To add or remove
decks, just change them on Archidekt — the page picks them up automatically
(within the cache window).
---
## Interest pages
Each interest is a hand-built page in `src/pages/interests/`. They follow the
spec's template: **Header** (title + tagline), **Overview** (narrative),
**Highlights** (card grid), **Currently** (active now), **Related** (cross-links).
To edit content, open the relevant `.astro` file and change the copy in the
frontmatter arrays or the section bodies. To add a new interest:
1. Create `src/pages/interests/my-interest.astro` (copy an existing page as a
template).
2. Use `<BaseLayout surface="interest">`, `<InterestHero>`, `<InterestCard>`, and
`<RelatedInterests>`.
3. Add a card for it in `src/pages/interests/index.astro` (the hub).
---
## Global content (nav, socials, tagline)
Edit `src/lib/site.ts`:
- `siteIdentity` — name, tagline, domain
- `navGroups` — the two-group top nav
- `socials` — footer + contact links (replace the `[Greg to fill]` handles)
---
## Finding placeholders
Every `[Greg to fill]` gap from the spec is rendered as a styled **Placeholder**
slot (dashed border + pencil badge + "TODO · label"). To find them all:
```bash
grep -rn "Placeholder label" src/
grep -rn "Greg to fill\|\[Greg" src/
```
Replace each with real content as you gather it.
+345
View File
@@ -0,0 +1,345 @@
# Deployment Guide
The site runs as a **Node server inside a container** on your homelab Cosmos
system, fronted by your reverse proxy (Traefik / Caddy / nginx) with TLS for
`gregkilmer.com`.
---
## 1. Configure environment
Copy the example env file and fill in what you need:
```bash
cp .env.example .env
```
At minimum, set `SITE_URL` (your canonical public URL). To enable contact-form
delivery, set **either** SMTP vars **or** `CONTACT_WEBHOOK_URL` (see
[Contact delivery](#contact-delivery) below). Everything else is optional.
| Variable | Purpose | Default |
| ----------------------------------------------------- | ----------------------------------------- | ------------------------ |
| `PORT` | Port the Node server binds to | `4321` |
| `HOST` | Bind address (`0.0.0.0` for container) | `0.0.0.0` |
| `SITE_URL` | Canonical URL for metadata | `https://gregkilmer.com` |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | SMTP relay | unset |
| `SMTP_FROM` | From address for outgoing mail | derived from `SITE_URL` |
| `SMTP_ALLOW_SELFSIGNED` | Accept self-signed cert (internal hop) | `false` |
| `CONTACT_TO` | Inbox that receives submissions | `contact@gregkilmer.com` |
| `CONTACT_WEBHOOK_URL` | JSON webhook fallback (Discord/Slack/n8n) | unset |
| `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` | Per-IP rate limit | `5` / `15` |
| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile bot protection | unset (skipped) |
---
## 2. Build & run with Docker Compose
```bash
docker compose up -d --build
```
This builds the multi-stage image (`Dockerfile`), starts the container, and maps
host port `${HOST_PORT:-4321}` to container `4321`. Logs:
```bash
docker compose logs -f greg-site
```
Healthcheck hits `/` every 30s. Verify locally:
```bash
curl http://localhost:4321/
```
---
## 3. Reverse proxy + TLS
Point your Cosmos reverse proxy at the container on port `4321`. The two common
shapes:
### Option A — Traefik (labels)
If your Cosmos uses Traefik, add labels to the service in `docker-compose.yml`:
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.greg-site.rule=Host(`gregkilmer.com`)"
- "traefik.http.routers.greg-site.entrypoints=websecure"
- "traefik.http.routers.greg-site.tls.certresolver=your-resolver"
- "traefik.http.services.greg-site.loadbalancer.server.port=4321"
```
Then drop the `ports:` mapping and join the Traefik network instead.
### Option B — Caddy / nginx
Forward `gregkilmer.com` (HTTPS) to `127.0.0.1:4321`. With Caddy, automatic TLS is a
one-liner:
```caddyfile
gregkilmer.com {
reverse_proxy 127.0.0.1:4321
}
```
Either way, ensure the proxy forwards `X-Forwarded-For` / `X-Real-IP` so the
contact API's rate limiter sees real client IPs (not the proxy's).
---
## 4. Contact delivery
The `/api/contact` endpoint delivers submissions through the first configured
path:
1. **SMTP** (if `SMTP_HOST` is set): sends a real email via nodemailer. Works
with any SMTP relay (Gmail app-password, Mailgun, SES, your homelab mail
server).
2. **Webhook** (if `CONTACT_WEBHOOK_URL` is set and SMTP is not): POSTs a JSON
payload `{ name, email, message, text }`. Great for a Discord/Slack/n8n
inbound.
3. **Neither configured**: returns `503` with a friendly "email me directly"
message so the form never silently swallows input.
### SMTP relay DNS requirements (homelab)
When pointing the form at a homelab SMTP relay (Postfix, Mailcow, etc.), the
relay itself must have proper DNS records or mail **will** land in recipients'
spam folders — this is the relay's responsibility, not the site's. Ensure the
sending domain (the host part of `SMTP_FROM`, or the relay's HELO name) has:
- **SPF** (`TXT` on the domain): lists the relay's IP as an authorized sender.
- **DKIM** (`TXT` on `selector._domainkey`): signs outgoing messages so the
relay's identity can be verified.
- **DMARC** (`TXT` on `_dmarc`): tells receivers how to handle SPF/DKIM
failures (start at `p=none` to monitor, tighten to `quarantine` later).
- **rDNS / PTR** on the relay's public IP: must match the relay's HELO name.
`SMTP_FROM` should use a domain you control (e.g. `no-reply@gregkilmer.com`),
not `gmail.com` — SPF on a domain you don't own will reject or sink the mail.
Test delivery + spam score with [mail-tester.com](https://www.mail-tester.com/)
(send a form submission to the unique address it gives you).
#### Self-signed certificates on an internal relay
If the SMTP relay (e.g. Stalwart) is on the same private Docker network as the
site container and presents a self-signed TLS certificate, nodemailer's default
certificate verification will reject the connection with
`Error: self-signed certificate` (`code: ESOCKET`). For a trusted internal hop
that never traverses an untrusted network, set:
```
SMTP_ALLOW_SELFSIGNED=true
```
This disables TLS certificate verification for the SMTP connection only. It is
`false` by default; only enable it when the relay is on a trusted private
network. For a relay reachable over the public internet, prefer installing a
real certificate (Let's Encrypt) on the relay instead.
### Cloudflare Turnstile (bot protection)
The form includes an invisible Cloudflare Turnstile challenge. To enable it:
1. Create a widget at
[Cloudflare Turnstile](https://dash.cloudflare.com/?to=/:account/turnstile).
Use the **Managed** challenge mode (invisible, no puzzle).
2. Put the **site key** in `TURNSTILE_SITE_KEY` and the **secret key** in
`TURNSTILE_SECRET_KEY` (both in `.env`).
3. The site key is public and ships to the browser; the secret stays
server-side. The API verifies each token against Cloudflare's `siteverify`
endpoint before sending mail.
If neither key is set, Turnstile is skipped entirely (useful for local dev —
the form works without a challenge). The honeypot + per-IP rate limit still
apply regardless.
Test it:
```bash
# With Turnstile configured, this is rejected (no/bad token) with 400:
curl -X POST http://localhost:4321/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com","message":"Hello there."}'
# Without Turnstile (keys unset), this succeeds and delivers mail:
curl -X POST http://localhost:4321/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com","message":"Hello there."}'
```
### Anti-scraping
The site applies a layered anti-scraping policy:
- **`public/robots.txt`** allows legitimate search engines (Google, Bing) but
blocks known AI training bots (GPTBot, ClaudeBot, CCBot, Bytespider, etc.)
and disallows `/api/` and `/contact` for everyone. Note: robots.txt is
voluntary; determined scrapers ignore it.
- **`noindex` on `/contact`**: the contact page emits
`<meta name="robots" content="noindex, nofollow">` so it won't be indexed
even if a bot reaches it.
- **Email obfuscation**: the Gmail address is never in the HTML source as
plaintext; it's base64-encoded and decoded to a `mailto:` client-side on
click. Dumb harvesters that grep for `@` or `mailto:` find nothing.
---
## 5. Security headers & hardening
Security is split between the app (version-controlled) and the reverse proxy
(owns the TLS edge). This division keeps each part where it's strongest.
### Set in the app — `src/middleware.ts`
Every response carries these headers, applied by Astro middleware:
| Header | Value | Purpose |
| --- | --- | --- |
| `Content-Security-Policy` | see file | Restricts script/style/font/img/connect/frame sources |
| `X-Content-Type-Options` | `nosniff` | Blocks MIME-sniffing |
| `X-Frame-Options` | `DENY` | Clickjacking (legacy browsers; CSP `frame-ancestors 'none'` covers modern ones) |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limits referrer leakage |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), interest-cohort=()` | Disables unused browser features |
The CSP allows `'self'`, `'unsafe-inline'` (temporary — see below), and
`https://challenges.cloudflare.com` (Cloudflare Turnstile, used only on
`/contact`). All fonts, images, and styles are self-hosted, so nothing else
needs an external origin.
### Set at the reverse proxy (Nginx Proxy Manager)
These belong at the proxy because they only make sense at the TLS edge, or
because only the proxy can overwrite client-supplied headers:
- **HTTP → HTTPS redirect** (Force SSL / `return 301 https://$host$request_uri`).
- **`Strict-Transport-Security`** (HSTS) — ignored over HTTP, so the proxy must
set it. Example: `max-age=31536000; includeSubDomains`. Leave `preload` off
until you've run the site a while and confirmed all subdomains serve HTTPS.
- **`X-Forwarded-For` overwrite** — **critical for the contact API's rate
limiter.** Add to the proxy host's Custom Nginx Configuration:
```nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
```
The `X-Forwarded-For $remote_addr` line **overwrites** (not appends) any
client-supplied value, closing a rate-limit bypass where an attacker spoofs
a fresh IP per request. (`/api/contact` reads the first XFF hop as the
client IP.)
### CSRF — not applicable, intentionally
The contact endpoint needs no CSRF token because none of the standard CSRF
preconditions hold:
- **No cookies or sessions** — there is no auth and no session state to forge
against.
- **JSON-only body** — the handler calls `request.json()`; a cross-origin form
POST (the classic CSRF vector) sends form-encoded data, which fails JSON
parsing and is rejected with 400 before any side effect.
- **No CORS** — the endpoint has no permissive `Access-Control-Allow-Origin`,
so a cross-origin `fetch` with JSON from a malicious site is blocked by the
browser's same-origin policy unless the request is "simple" (a JSON POST
with `Content-Type: application/json` is not simple and triggers a CORS
preflight that fails).
If any of these change (auth added, a form-encodable fallback, CORS opened),
revisit CSRF here.
### Rate-limiting caveats
- The limiter in `/api/contact` is **per-worker** (in-memory). For a single
container behind the proxy this is fine. If you ever scale to multiple
replicas, swap the in-memory `Map` for Redis/Upstash so the limit is global.
- The IP is taken from `X-Forwarded-For`, which is why the proxy XFF overwrite
above matters.
- The proxy can additionally rate-limit at the edge (`limit_req`) for defense
before traffic reaches Node. Optional — the app already limits.
### Dependency hygiene
There is no CI pipeline; deploys are manual. Before each deploy, run:
```bash
npm audit # report known vulns in the dependency tree
npm audit --omit=dev # production deps only
npm outdated # check for behind-version packages
```
Address any `high`/`critical` audit findings before building. `npm update`
within the caret ranges in `package.json` is safe between full upgrades.
### `/.well-known/security.txt`
A [RFC 9116](https://www.rfc-editor.org/rfc/rfc9116) security contact lives at
`public/.well-known/security.txt`, served at
`https://gregkilmer.com/.well-known/security.txt`. It points to
`security@gregkilmer.com` and expires ~1 year out — **renew the `Expires:`
date before it passes**, or vulnerability reporters' tools will flag it as
stale.
### Future: tighten CSP with nonces (deferred)
`script-src` currently includes `'unsafe-inline'` because two inline scripts
in `BaseLayout.astro` (the theme-init boot and the View Transitions
flash-killer) can't be nonce-tagged without restructuring how they're emitted.
The next step is to generate a per-request nonce in `src/middleware.ts`, inject
it onto those two scripts in `BaseLayout.astro`, and replace `'unsafe-inline'`
with `'nonce-<value>'` in `script-src`. Astro View Transitions can complicate
nonce propagation across the client-side swap, so this is its own task — the
file header in `src/middleware.ts` tracks it.
---
## 6. Updating the site
```bash
git pull
docker compose up -d --build
```
The build stage reinstalls deps and rebuilds `dist/`; the runtime stage swaps in
the new artifact with zero config.
---
## 7. Without Docker (bare Node)
If you prefer to run Node directly on the host:
```bash
npm ci
npm run build
HOST=0.0.0.0 PORT=4321 SITE_URL=https://gregkilmer.com node ./dist/server/entry.mjs
```
Run it under a process manager (systemd, pm2) so it restarts on crash/boot.
---
## 8. Verifying security headers after deploy
```bash
# App-set headers (from src/middleware.ts)
curl -sI https://gregkilmer.com | grep -iE 'content-security|x-frame|x-content|referrer|permissions'
# Proxy-set HSTS + redirect (Nginx Proxy Manager)
curl -sI https://gregkilmer.com | grep -i 'strict-transport'
curl -sI http://gregkilmer.com # expect 301 -> https://
# security.txt reachable
curl -s https://gregkilmer.com/.well-known/security.txt
```
All five app headers (`content-security-policy`, `x-frame-options`,
`x-content-type-options`, `referrer-policy`, `permissions-policy`) should be
present on every route, including `/api/contact`. The `strict-transport-
security` header should appear only on the HTTPS response, and the plain-HTTP
request should redirect.
+121
View File
@@ -0,0 +1,121 @@
# Photo Guide
Every image on the site right now is a **stock stand-in** (from Unsplash, free, no
attribution required) chosen to match the *vibe, pose, and style* of the real
photo that should eventually live there. This file is the shopping list for
**photos of yourself and your own stuff** — what to take or find, how to frame
each one, and which file to overwrite.
Replace a photo by **dropping a new file at the same path in `public/images/`**.
No code changes needed. Aim for roughly the recommended aspect ratio so the
crop stays clean.
> General mood across the whole site: **moody, warm-low-light, "candlelit study
> under a starfield."** Deep backgrounds, soft warm key light (window, lamp, or
> a single softbox off to one side), shallow depth of field. The frames, gold
> accents, and star background do the rest.
---
## 1. Portraits of you
These are the highest priority — they make the site feel like *yours*.
### `public/images/hero-portrait.jpg` — Home hero (used for the OG/social card too)
- **Subject:** You, at your desk or workspace.
- **Framing:** Portrait, roughly **4:5** (taller than wide). Waist-up or
three-quarter. Head near the top third.
- **Pose:** Natural, mid-thought or slight smile, looking just off-camera or at
the screen. One hand on keyboard or holding something (a die, a mug).
- **Light:** Warm side light from a window or lamp camera-left; let the
background fall into shadow. A little rim light from behind separates you
from the dark background.
- **Background:** Dark/blurred workspace — monitors, books, dice shelf out of
focus. Avoid pure white walls.
- **Wardrobe:** What you actually wear to work; dark or jewel tones read best
against the midnight palette.
- **Resolution:** ≥ 1200×1500 px.
### `public/images/about-portrait.jpg` — About page sidebar
- **Subject:** You, but warmer and more candid than the hero.
- **Framing:** Portrait **4:5**. Can be a different environment than the desk —
a reading chair, the making bench, the game table.
- **Pose:** Relaxed, looking at camera or at a hobby object. Smile is good here.
- **Light:** Soft, warm, even. Think "cozy evening."
- **Resolution:** ≥ 1200×1500 px.
### Optional extras that would level the site up
- **A tabletop "gamemaster" shot** — you behind the screen / Foundry VTT on a
monitor, dice in hand. Could replace `ttrpg-table.jpg`.
- **A making shot** — you casting resin or pulling a print, hands in frame.
Could replace `making-resin.jpg`.
---
## 2. Workspace & professional
### `public/images/pro-workspace.jpg` — Professional summary banner
- **Subject:** Your actual desk / dev setup. Monitors with code, keyboard,
a mug, low light.
- **Framing:** Wide **21:9** landscape. Slight angle, depth.
- **Mood:** "Late-night deploy." Dark room, screen glow as the key light,
warm accent lamp.
- **Resolution:** ≥ 1600×690 px.
### `public/images/pro-team.jpg` (currently unused — available)
- **Subject:** Whiteboard session, pair-programming, or team standup (with
consent). Optional; only add if you want a team-work shot.
---
## 3. Hobbies — your real collections
These benefit most from being *yours* — the stock versions are generic.
### Dice / tabletop
- `public/images/ttrpg-dice.jpg` — **your** polyhedral sets, fanned out or
stacked on a dark surface. Top-down or 45°, single warm light. ~**16:10**.
- `public/images/ttrpg-table.jpg` — your Foundry VTT screen, a battlemap, or
the physical game table mid-session. Wide **21:9**.
### Making
- `public/images/making-resin.jpg` — resin dice mid-cast, pressure pot, pigment
bottles. Macro / close-up, ~**4:3**.
- `public/images/making-print.jpg` — the Elegoo Mars 4 or Bambu P1S mid-print,
or a finished miniature in your hand. ~**4:3** or **16:10**.
- *(The Making gallery shows three images — the third reuses `ttrpg-dice.jpg`.
Add a third unique shot, e.g. painted minis, if you want.)*
### Reading
- `public/images/reading-nook.jpg` — your reading chair / nook. Wide **21:9**.
- `public/images/fantasy-books.jpg` — your Sanderson / Cosmere shelf, spines
out, warm light. ~**4:3** or **16:10**.
### Gaming
- `public/images/gaming-setup.jpg` — your PS5 / Switch / PC corner, controller
on the seat, screen glow. Wide **16:10** or **21:9**.
### Pokémon
- `public/images/pokemon-cards.jpg` — your card collection, a favorite binder
page, or a Metagross card front-and-center. ~**1:1** to **4:3**.
---
## How to swap an image
1. Put the new file at the matching path under `public/images/`.
2. Keep the **same filename** (and ideally the same aspect ratio).
3. Rebuild / redeploy. Done.
To change which image a specific spot uses, edit the `src="/images/..."` path in
the relevant `.astro` file (search the `src/pages/` and
`src/components/layout/InterestHero.astro`).
## Tech notes
- All photos render inside a `CelestialFrame` (gold hairline + corner star
bursts) or as a banner with a star accent, so they inherit the mystical
treatment automatically.
- `loading="lazy"` and `decoding="async"` are already set — no need to optimize
delivery by hand, but keep files under ~500 KB where you can.
- If you remove an image but leave the path, the `CelestialImage` component
falls back to a themed starfield placeholder so the layout never breaks.
+151
View File
@@ -0,0 +1,151 @@
# Store (`store.gregkilmer.com`)
A small storefront for Greg's **proxy MTG card** and **3D-printed deck box /
counter** services. It lives at `/store` inside the same app, reuses the main
site's aesthetic, and is reachable directly or via the `store.gregkilmer.com`
subdomain. It takes **no payment** — the order form only emails a request — and
the form is **hard-locked** until you choose to accept orders.
## Pages
| Route | What it is |
| ------------------ | ------------------------------------------------ |
| `/store` | Storefront: intro, products, live slots, policies |
| `/store/cards` | Proxy MTG cards specs + pricing |
| `/store/deck-boxes`| 3D-printed deck boxes & counters specs + pricing |
| `/store/order` | Order request form (gated) or "closed" notice |
The store uses its own nav (`StoreNav.astro`) and has **no links back to the
main site**. The two sites are intentionally not connected from the UI.
## Opening and closing orders
Everything customer-facing about availability is driven by **one file**:
```
src/data/store-availability.json
```
```json
{
"ordersOpen": false,
"lastUpdated": "2026-08-04",
"turnaround": "Estimated turnaround: ~2 weeks once a list is confirmed.",
"maxNote": "I take a limited number of orders at a time...",
"slots": {
"cards": { "open": 0, "total": 5 },
"deckBoxes": { "open": 0, "total": 4 }
}
}
```
- **`ordersOpen`** is the master switch.
- `false` → the order page shows a "closed" notice and renders **no form**.
- `true` → the order form appears.
- **`slots.cards` / `slots.deckBoxes`** drive the "X of Y open" meters on the
storefront and order page. Set `open` to 0 to show a category as Closed.
- **`turnaround`**, **`maxNote`**, and **`lastUpdated`** are shown verbatim.
Edit the file, commit, and redeploy. That's it.
### The lock is enforced in two places
1. **UI**: `/store/order` reads the file at render and hides the form when
`ordersOpen` is false.
2. **Server**: `/api/order` reads the file again and returns **403** if
`ordersOpen` is false — *before* doing any validation or delivery. So even a
direct POST can't bypass the lock.
## Editing products, specs, and pricing
Those live in version-controlled TypeScript (not the availability JSON):
```
src/data/store-products.ts
```
Each product has an `icon`, `title`, `summary`, example `image`, a `specs` list
(size, thickness, finishes, white back, etc.), and `pricing` tiers. Search for
`[Greg to fill]` to find the placeholder prices and concrete specs to replace.
### Available print colors (deck boxes)
The deck-box product has a `colors` array — this drives the **Available print
colors** swatch grid on `/store/deck-boxes` and the color dropdown in the order
form. Each entry is `{ name, hex, inStock }`:
```ts
colors: [
{ name: 'Midnight Black', hex: '#1b1730', inStock: true },
{ name: 'Nebula Teal', hex: '#4f8c84', inStock: false }, // shows "Out of stock"
],
```
- **Add a color** → append a new `{ name, hex, inStock }` entry.
- **Mark a color sold out** → set `inStock: false`. It shows an "Out of stock"
label on the grid and drops out of the order-form dropdown automatically.
- **Remove a color** → delete the entry.
Only in-stock colors are offered in the order form; the chosen color is included
in the order email. Colors are optional — products with no `colors` array (e.g.
proxy cards) simply don't show the grid or the dropdown.
The shared **requirements / policies** list is also in this file (`requirements`)
and is rendered on the storefront, the order page, and required as a checkbox on
the order form.
## Order delivery (email)
Order requests are emailed using the **same SMTP/webhook pipeline as the contact
form** (see `docs/DEPLOYMENT.md`). The only addition is the inbox:
```
ORDER_TO=orders@gregkilmer.com
```
If `ORDER_TO` is blank, it falls back to `CONTACT_TO`. Configure `SMTP_*` vars
as usual; if neither SMTP nor `CONTACT_WEBHOOK_URL` is set, `/api/order` returns
503 (delivery not configured) so it's visibly a config gap.
Rate limiting reuses `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` unless you
set `ORDER_RATE_LIMIT` / `ORDER_RATE_WINDOW_MIN` to give orders a separate budget.
Bot protection reuses the Cloudflare **Turnstile** keys (`TURNSTILE_SITE_KEY` /
`TURNSTILE_SECRET_KEY`). Leave both blank to skip Turnstile (e.g. local dev).
## Pointing `store.gregkilmer.com` at the store
The store is part of the same app — no separate server. The subdomain is purely
a reverse-proxy concern (Cosmos / Traefik / Nginx Proxy Manager):
1. Create a DNS record for `store.gregkilmer.com`.
2. In your reverse proxy, point `store.gregkilmer.com` at the same container on
port 4321, with TLS.
3. (Optional) Add a redirect or rewrite so `store.gregkilmer.com/` maps to
`/store`. The store pages are reachable at `/store*` on the main domain too,
so a redirect isn't strictly required — visitors who know the path can reach
it either way.
No code change is needed to enable the subdomain.
## Search visibility
Store pages are **`noindex` by default** (set in `StoreLayout.astro`). When
you're ready for the store to be indexed, change `noindex` to `false` there (or
override it per page via the `noindex` prop).
## Files added / changed
- `src/data/store-availability.json` — **the file you edit**
- `src/data/store-availability.ts` — typed reader (used by pages + API)
- `src/data/store-products.ts` — products, specs, pricing, requirements
- `src/components/store/` — `StoreNav`, `ProductCard`, `PricingTable`,
`SlotMeter`, `RequirementList`, `OrderForm.tsx`
- `src/layouts/StoreLayout.astro` — store wrapper (surface, store nav, noindex)
- `src/pages/store/` — `index`, `cards`, `deck-boxes`, `order`
- `src/pages/api/order.ts` — order endpoint with hard gate
- `src/layouts/BaseLayout.astro` — added optional `storeNav` prop (renders
`StoreNav` instead of the main `NavBar` on store pages)
- `src/components/nav/MobileMenu.tsx` — added store icon paths
- `src/lib/env.ts` — added `orderTo()` helper
- `.env.example` — added `ORDER_TO`
+161
View File
@@ -0,0 +1,161 @@
# Theming & Motion Guide
How color, light/dark mode, section tone, and animation work on this site.
---
## Palette
The site uses the Patiun LLC brand palette (shared by Patiun and Greg Kilmer),
exposed as CSS variables in `src/styles/global.css` under `@theme`:
| Token | Value | Meaning |
|---|---|---|
| `--color-teal` | `#14A39A` | Patiun Teal, primary: buttons, fills, the glyph |
| `--color-teal-bright` | `#2BC2B7` | Teal text and links on dark grounds |
| `--color-teal-deep` | `#0F8C84` | Teal marks on light grounds |
| `--color-slate-teal` | `#2D4A58` | Dark end of the Tide gradient |
| `--color-gold` | `#C9A24A` | Alchemist Gold: stars, rules, seal lines (never a large fill) |
| `--color-gold-bright` | `#D8B35A` | Gold text on dark grounds |
| `--color-gold-deep` | `#8A6516` | Gold text under 24px on Starlight |
| `--color-violet` / `--color-indigo` | `#6A2FE0` / `#3B1F8F` | Nebula light only, never text |
| `--color-midnight` | `#131C2B` | Panels, tiles, browser theme color |
| `--color-sky` | `#0E1522` | Night-sky ground |
| `--color-starlight` | `#F4F2ED` | Text on dark; paper ground |
The semantic Tailwind tokens (`--color-accent`, `--color-surface`, ...) resolve
at `:root`, so content sections render with the Starlight values in
`themes.css`. Night-sky bands (the nav and page heroes) carry the `.on-sky`
class, which re-points accent and gold to their on-dark values.
Brand colors are fixed. **Semantic** tokens (`--surface`, `--text`, `--accent`,
`--secondary`, `--border`, ...) resolve differently per theme + section, and are
what components actually use.
---
## Two theming axes
### Axis 1 — Global light/dark
Set on `<html data-theme="light|dark">`:
- A blocking inline script (`src/scripts/theme-init.ts`, inlined in `<head>`)
reads `localStorage['gk-theme']`, falling back to the OS
`prefers-color-scheme`. This runs **before first paint**, so there is no flash.
- The nav `ThemeToggle` island flips it and persists the choice.
- If the user has never chosen, the site follows OS changes live.
### Axis 2 — Section tone
Set on `<body data-surface="neutral|professional|interest">` via the `surface`
prop on `<BaseLayout>`:
| Surface | Used on | Tone |
|---|---|---|
| `neutral` | Home, About, Contact | Balanced — clean in light, calm in dark |
| `professional` | Professional page | Dark slate, polished, amber accents |
| `interest` | Interests pages | Warm cream/amber, inviting |
The Professional surface leans dark even in light mode (it is the "polished
wing"). The Interest surface is warm in both modes. This is what gives the two
wings their distinct feel while sharing the same typography and spacing.
Each `(data-theme, data-surface)` combination redefines the semantic tokens in
`themes.css`. Add or adjust a combination there to change any section's palette.
---
## Changing colors
1. **Tweak a brand color:** edit the fixed values in `global.css` under `@theme`
(e.g. `--color-amber`).
2. **Tweak a section's tone:** edit the relevant `[data-surface="..."]` block in
`themes.css`. Each block lists `--surface`, `--surface-raised`,
`--surface-sunken`, `--text`, `--text-muted`, `--text-subtle`, `--border`,
`--accent`, `--accent-contrast`, `--secondary`.
3. Components use utilities like `bg-surface`, `text-text`, `text-accent`
(mapped from these variables), so they update automatically.
### Contrast
Verify WCAG AA (4.5:1 for body text) whenever you change a token pair. The
current combinations were chosen for AA compliance. The `--accent` on the neutral
light surface uses a deeper amber (`#b45309`) specifically to meet AA on white.
---
## Motion
Motion is layered and **always** respects `prefers-reduced-motion`:
1. **Hero background** (`AnimatedBackground.tsx`):
- A CSS mesh-gradient layer (GPU-friendly, animated via the
`animate-gradient-shift` keyframes in `animations.css`).
- An optional `<canvas>` particle field (slow drifting dots) on the home hero
only. It renders at devicePixelRatio (capped at 2), pauses when off-screen,
and is **skipped entirely** under reduced motion.
2. **Scroll reveals** (`Reveal.astro` + the `.reveal` class):
- IntersectionObserver toggles `.is-visible`, triggering a CSS fade-up. Zero
framework cost. The reduced-motion media query in `global.css` forces these
to their final state instantly.
3. **Page transitions**: Astro `<ClientRouter />` (View Transitions API) morphs
between pages and persists the nav.
4. **Nav blur on scroll**: a tiny inline script adds `.scrolled` after a few px,
enabling backdrop-blur + a hairline border + shadow.
5. **Hover micro-interactions**: Tailwind transitions on cards/buttons (lift,
accent glow, arrow nudge).
### Disabling motion
Everything is gated by the global reduced-motion rule in `global.css`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
...
}
}
```
The canvas island additionally checks `matchMedia('(prefers-reduced-motion:
reduce)')` before drawing, so even JS-driven motion is skipped.
---
## Typography
Self-hosted variable fonts via Fontsource (woff2, no external requests):
- **Headings, names, card titles:** Cinzel Variable (`font-display`, used by
`h1`-`h4` automatically). Open tracking, never for paragraphs.
- **Body and UI:** Montserrat Variable (`font-sans`, `font-body`).
- **Eyebrows / kickers:** Montserrat uppercase with wide tracking (`font-eyebrow`).
- **Code-like blocks only:** JetBrains Mono Variable (`font-mono`).
Font sizes use `clamp()` for fluid scaling (see the `h1`-`h4` rules in
`global.css`).
---
## Icons and motifs
- **Icons:** `<Icon name="..." />` (Astro) and `<BrandIcon name="..." />` (React
islands) render the Patiun stroke icon set in `src/lib/brand-icons.ts`. Call
sites can use either the brand name (`dice`) or the mdi name (`dice-multiple`);
`brandIconAliases` maps one to the other. Only third-party logos (GitHub,
LinkedIn, Steam, ...) fall back to mdi. The gold star accent uses
`--icon-gold` (deep gold on Starlight, flat gold inside `.on-sky`). The file is
generated from the brand source (`newicons.py`); add icons there.
- **Motifs:** `<Motif name="..." />` shows a brand motif from `public/motifs/`,
copied from the brand files with ring text outlined to paths. `-light` files
are the Starlight recolor. One focal motif per surface, kept faint. Every
page carries at least one seal: night-sky heroes have the turning layered
seal, and pages without a hero use `<PageSeal name="..." />` behind the header.
- **Shape:** cards and panels have 10px corners (`--radius-xl`/`2xl`/`3xl` are
pinned to 10px); buttons and tags are pills. The Patiun seal is never shown
below 72px (nav and footer use 72px).
- **Gold accents:** image frames, quote bars, dividers and rules use
`--color-gold`; teal stays for links, buttons and icons.