mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
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:
-399
@@ -1,399 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,345 +0,0 @@
|
||||
# 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
@@ -1,121 +0,0 @@
|
||||
# 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
@@ -1,151 +0,0 @@
|
||||
# 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
@@ -1,161 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Content model
|
||||
|
||||
Everything the site shows is a Markdown note with YAML frontmatter in the
|
||||
vault at `20-29_Makerspace/24_Homelab_Infrastructure/24.01_PersonalWebsite/Site/`.
|
||||
`24.02_PatiunWebsite` is reserved for the Patiun site later.
|
||||
|
||||
## Rules every note follows
|
||||
|
||||
- A note's `type` field says what it is. The types, their fields and their
|
||||
folders are defined once, in `packages/schema/src/types.ts`.
|
||||
- A note is live only when `publish: true`. Missing means false.
|
||||
- Links between notes are Obsidian wikilinks, `[[Note name]]`, in frontmatter
|
||||
or in the body. A link resolves by file name, so file names are unique across
|
||||
the whole Site folder.
|
||||
- Images live in `Site/_attachments/` and are linked the same way,
|
||||
`[[galaxy-dice.jpg]]`, or are full https URLs.
|
||||
- Folders starting with `_` hold templates and attachments, never notes.
|
||||
Files starting with `_` are single settings notes: `_site.md`,
|
||||
`Now/_kinds.md`, `Store/_settings.md`.
|
||||
- Slugs come from the title unless a note sets `slug`. They are unique within
|
||||
a type.
|
||||
|
||||
## Note types
|
||||
|
||||
| Type | Folder | Notes |
|
||||
| --- | --- | --- |
|
||||
| `post` | `Posts/` | Blog posts. `postType: case study` also appears on the Work page. |
|
||||
| `library-item` | `Library/<Medium>/` | Books, light novels, webnovels, manga, manhwa, video games, anime, TV, movies. |
|
||||
| `board-game` | `Shelf/` | The Shelf. Players and play time sync from BoardGameGeek by `bggId`. |
|
||||
| `craft`, `station`, `tool`, `make` | `Workshop/…` | The Workshop. |
|
||||
| `tabletop-system`, `campaign`, `card-game`, `deck` | `Tabletop/…` | Tabletop. Magic decks sync from Archidekt. |
|
||||
| `experience`, `skills`, `education` | `Resume/…` | The resume page and its PDF. |
|
||||
| `status`, `status-kinds` | `Now/` | The status log behind Now, the Landing strip and Now chips. |
|
||||
| `product-line`, `store-settings` | `Store/…` | The Store. |
|
||||
| `site-settings` | `_site.md` | Name, tagline, location and social links. |
|
||||
| `page` | `Pages/` | The written introduction for a page. |
|
||||
|
||||
Each field's title and description live in the schema too, so the
|
||||
JSON Schema export (`npm run schema:json`) can drive a form or explain a field
|
||||
to an agent.
|
||||
|
||||
## Designed for editing tools
|
||||
|
||||
The content service will be the only writer apart from Obsidian. It will
|
||||
expose one write API, built on the schema's `checkNote`, `writeNote` and
|
||||
`checkVault`. The MCP server for agents is one client of that API; the admin
|
||||
page is another; any editing tool built later is a third. None of them define
|
||||
fields or rules of their own.
|
||||
Reference in New Issue
Block a user