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
+17
View File
@@ -0,0 +1,17 @@
# Keep the Docker build context lean.
node_modules
dist
.astro
.git
.gitignore
.env
.env.*
!.env.example
*.log
npm-debug.log*
.DS_Store
.vscode
.idea
docs
*.md
!README.md
+69
View File
@@ -0,0 +1,69 @@
# =============================================================================
# Greg Kilmer Profile Site - Environment Variables
# =============================================================================
# Copy this file to `.env` (local dev) or pass via Cosmos container environment.
# Never commit your real `.env`. All values are optional unless noted.
# =============================================================================
# --- Server -----------------------------------------------------------------
# Port and host the Node server binds to. Cosmos reverse proxy should forward here.
PORT=4321
HOST=0.0.0.0
# Canonical site URL (used for absolute metadata URLs and API responses).
SITE_URL=https://gregkilmer.com
# --- Contact form -> /api/contact -------------------------------------------
# Two delivery modes. If SMTP is configured, email is sent. Otherwise, if a webhook
# URL is set, the payload is POSTed there (e.g. a Discord/Slack/n8n webhook). If
# neither is set, the API returns 503 so you can wire delivery later.
# SMTP (nodemailer). CONTACT_TO is the inbox that receives submissions.
# Point this at your homelab SMTP relay (Postfix/Mailcow/etc.). The relay must
# have SPF/DKIM/DMARC configured or mail will land in spam. Leave SMTP_HOST blank
# to disable delivery (the API returns 503 until this is set).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=contact@gregkilmer.com
SMTP_FROM="Greg's Site <no-reply@gregkilmer.com>"
# Set to "true" to accept a self-signed TLS certificate on the SMTP connection.
# Only enable when the relay is on a trusted private network (e.g. the homelab
# Docker network) and presents a self-signed cert. Defaults to false (verify).
SMTP_ALLOW_SELFSIGNED=
# Optional webhook fallback (JSON POST). Only used if SMTP_HOST is unset.
# CONTACT_WEBHOOK_URL=
# --- Store order form -> /api/order -----------------------------------------
# Reuses the SMTP/relay settings above. ORDER_TO is the inbox that receives
# order requests; if blank it falls back to CONTACT_TO. No payment is taken;
# the form only collects an order request that is emailed to you. The order
# form is hard-gated by src/data/store-availability.json (ordersOpen).
ORDER_TO=orders@gregkilmer.com
# Honeypot / basic abuse control. Maximum submissions per IP per window.
CONTACT_RATE_LIMIT=5
CONTACT_RATE_WINDOW_MIN=15
# --- Cloudflare Turnstile (bot protection) ----------------------------------
# Get free keys at: https://dash.cloudflare.com/?to=/:account/turnstile
# SITE_KEY is public (ships to the client); SECRET_KEY is server-only.
# Leave both blank to skip Turnstile entirely (e.g. local dev).
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# --- Gaming box-art tool ----------------------------------------------------
# Used by scripts/fetch-box-art.mjs to download box/banner art for Gaming Hall
# of Fame entries via the RAWG API. Get a free key at:
# https://rawg.io/login?forward=developer
RAWG_API_KEY=
# --- Media poster tool ------------------------------------------------------
# Used by scripts/fetch-tmdb-art.mjs to download posters for Movies and TV
# favorites via the TMDB API. Get a free key at:
# https://www.themoviedb.org/settings/api
# (Anime and Reading cover fetchers use keyless APIs — no env needed.)
TMDB_API_KEY=
+30
View File
@@ -0,0 +1,30 @@
# build output
dist/
.output/
# dependencies
node_modules/
# generated types
.astro/
# environment
.env
.env.*
!.env.example
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# macOS / editor
.DS_Store
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
# misc
*.local
@@ -0,0 +1,92 @@
# Store (store.gregkilmer.com) — MTG Proxy Cards & 3D-Printed Deck Boxes / Counters
## Summary
Add a self-contained **store** section to this repo that renders a commerce-style storefront for your proxy-card printing and 3D-printing services, with a gated email-submission order form. It lives at `/store` (and is reachable via `store.gregkilmer.com`), reuses the existing mystic night-sky aesthetic, and shares **no nav links** with the main site. A single JSON file you edit controls order-open/closed, per-category slot counts, and turnaround messaging.
**Key constraint honored:** no payment is taken; the order form is **hard-locked** until you flip a flag in the JSON.
---
## Architecture decision (defaulting, since the question went unanswered)
- **Same app, `/store` route set, with a store-only nav and zero cross-links from the main site.** Store pages are reachable only by direct URL or the `store.gregkilmer.com` subdomain (the subdomain routing is a reverse-proxy concern, not code). This reuses BaseLayout, the theme system, Icon, Reveal, SectionHeader, the contact/email pipeline, and Turnstile — no new dependencies.
- **Order lock = hard gate.** When `ordersOpen` is false the order page shows a clear "orders currently closed" notice, links to your email, and **renders no form at all**. When true, the full order form appears.
- **Capacity JSON drives:** overall store open/closed flag, open-slot counts for cards and for 3D-print jobs (shown as "X of Y open"), an estimated turnaround string, and a last-updated timestamp.
---
## Files to create
### 1. Store data + availability (the "simple JSON file you edit")
- **`src/data/store-availability.json`** — the single file you edit to control the storefront. Schema:
```json
{
"ordersOpen": false, // master gate — false = form hidden
"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 so each gets full attention.",
"slots": {
"cards": { "open": 0, "total": 5 },
"deckBoxes": { "open": 0, "total": 4 }
}
}
```
- **`src/data/store-products.ts`** — typed product/pricing data (cards, deck boxes, finishes, sizing), authored in TS for type-safety and kept here (not JSON) so it's version-controlled with the components that render it. Pricing tables and product specs read from here.
### 2. Store-only layout + nav
- **`src/layouts/StoreLayout.astro`** — wraps `BaseLayout` with `surface="interest"` (warm candlelit tone, clearly distinct from the dark professional wing but still on-brand). Passes a `noindex` option (store can be noindexed until you're ready to promote). Renders a **store-specific top nav** (`/store`, `/store/cards`, `/store/deck-boxes`, `/store/order`) with a store wordmark and a small "store.gregkilmer.com" label, and a store footer variant that points to your email — **no links back to the main site**, per your request.
- **`src/components/store/StoreNav.astro`** — the store nav bar (mirrors `NavBar.astro`'s scroll-blur treatment and StarBurst mark, but store items only).
### 3. Reusable store UI components
- **`src/components/store/ProductCard.astro`** — image + spec list (size, thickness, finish, white back, etc.) for a product.
- **`src/components/store/PricingTable.astro`** — generic pricing-by-quantity table (takes rows of `{ quantity, price, note? }`).
- **`src/components/store/SlotMeter.astro`** — visual "X of Y slots open" meter with open/closed pill.
- **`src/components/store/RequirementList.astro`** — the rules/requirements list with mdi check/alert icons.
### 4. Pages
- **`src/pages/store/index.astro`** — Storefront home: hero (uses existing `/images/proxies.jpg` / `/images/deckboxes.jpg`), intro, **clear disclaimer block** (not affiliated with WotC/any game company; these are clearly-marked proxies, not counterfeits; will not print offensive content or custom art), capacity/availability summary from the JSON, links to the two product lines + order page.
- **`src/pages/store/cards.astro`** — Proxy MTG cards product line: example images, spec details (size = standard MTG 63×88mm, cardstock thickness/finish options, white back, etc.), pricing table for sets (e.g. 1–9 / 10–39 / 40–99 / 100+ tiers), double-sided-counts-as-two rule callout.
- **`src/pages/store/deck-boxes.astro`** — 3D-printed deck box & counter product line: example images (`/images/deckboxes.jpg`), sizing/finish/material details, pricing table for prints.
- **`src/pages/store/order.astro`** — Order page. Reads `store-availability.json`; when `ordersOpen === false`, renders the **closed notice + email link, no form**. When open, renders the order form island and shows the slot meters + requirements inline.
### 5. Order form + API
- **`src/components/store/OrderForm.tsx`** — React island modeled on the existing `ContactForm.tsx` (same idle/submitting/success/error states, honeypot, optional Turnstile, aria-live, same input styling). Fields: name, email, order type (cards / deck-box print / both), card list / deck-box spec (textarea), quantity/finish selectors, notes, honeypot, Turnstile token. **Posts to `/api/order`.**
- **`src/pages/api/order.ts`** — new POST endpoint cloned from `/api/contact.ts`'s proven pipeline: Zod validation, per-IP rate limit, Turnstile verification (reusing `@lib/env`), SMTP-or-webhook delivery to your inbox with a subject like `New store order — ${type}`. **Server-side hard gate:** even if a client POSTs while `ordersOpen` is false, the endpoint reads the JSON and returns 403 "Orders are currently closed." This makes the lock enforced both in UI and on the server.
### 6. Env additions
- Append to **`.env.example`** (and document in the new docs file): an optional `ORDER_TO` inbox (defaults to `CONTACT_TO`), and reuse existing `TURNSTILE_*`, `SMTP_*`, and rate-limit vars — **no new dependencies**. Order emails reuse the same `deliver()` pattern.
### 7. Docs
- **`docs/STORE.md`** — how to edit `store-availability.json` to open/close orders and update slots/turnaround, how the hard gate works (UI + API), how to point `store.gregkilmer.com` at the same server via your reverse proxy, and the "no cross-links" rule.
---
## Files modified
- **`src/lib/env.ts`** — add `orderTo()` helper (falls back to `contactTo()`).
- **`.env.example`** — add `ORDER_TO=` with a comment.
- **`src/middleware.ts`** — no change needed; CSP already allows `'self'` form-action, Turnstile origins, and same-origin connect. (I'll add a comment noting `/api/order` reuses the existing policy.)
- No change to the main site nav (`site.ts`), Footer, or any main-site page — **zero cross-links**, as requested.
---
## How each of your requirements is met
| Requirement | Where |
|---|---|
| Order form submits an email | `OrderForm.tsx` → `/api/order` → SMTP/webhook (cloned from working `/api/contact`) |
| Example images + product details | `cards.astro` / `deck_boxes.astro` + `ProductCard.astro` (size, thickness, finishes, white back, etc.) |
| Pricing tables for card sets & deck-box prints | `PricingTable.astro` fed by `store-products.ts` |
| Hosted at store.gregkilmer.com, no clear link between sites | `/store` route set, store-only nav, no main-site links; subdomain is a reverse-proxy mapping to the same server |
| No payment taken | Form collects a *request* only; no payment fields, no checkout |
| Order form locked until you choose to accept | Hard gate: `ordersOpen` in JSON hides the form (UI) **and** 403s the API (server) |
| Number of card + 3D-print slots, updatable from a simple JSON | `slots.cards` / `slots.deckBoxes` in `store-availability.json`, rendered by `SlotMeter.astro` |
| Clear "I can only take so many at a time" | `maxNote` + slot meters prominently on storefront and order page |
| Stated requirements (card list final, double-sided = 2 slots, payment is final, not affiliated with WotC, no counterfeits — clearly proxies, no offensive/custom art, etc.) | `RequirementList.astro` on storefront + repeated on the order page; order form requires an explicit acknowledgment checkbox |
---
## Defaults I'll use (flag any you want changed)
- **Disclaimer copy** drafted to match your wording (not affiliated with WotC / any game company; clearly-marked proxies, never counterfeits; no offensive or unauthorized custom art; card list is final once received; double-sided = two cards/two slots; all sales final). You can tweak exact phrasing after.
- **Pricing & specs** are placeholder structure with clearly-marked `[Greg to fill]` values (you'll swap in your real tier prices, cardstock GSM, finish names, deck-box dimensions). I won't invent real prices.
- **Slot counts** default to `{ cards: 0/5, deckBoxes: 0/4 }` and `ordersOpen: false`, so the site launches visibly "closed."
- **`noindex`** on by default for store pages until you say otherwise.
I will not commit or push anything. Ready to implement on approval.
+53
View File
@@ -0,0 +1,53 @@
# =============================================================================
# Dockerfile — Greg Kilmer Profile Site
# =============================================================================
# Multi-stage build producing a minimal Node server image for the Cosmos homelab.
#
# Stage 1 (builder): install all deps, run astro build -> dist/server/entry.mjs
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server
#
# The @astrojs/node standalone adapter emits a self-contained server, so the
# runtime image needs only node_modules + dist. Build once, run anywhere.
# =============================================================================
# ---------- Stage 1: build ----------
FROM node:22.12.0-alpine AS builder
WORKDIR /app
# Install deps. Copy manifests first to leverage Docker layer caching.
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund --legacy-peer-deps
# Copy source and build. SITE_URL can be overridden at build time.
COPY . .
ARG SITE_URL=https://gregkilmer.com
ENV SITE_URL=$SITE_URL
RUN npm run build
# Prune to production dependencies for the runtime stage.
RUN npm prune --omit=dev
# ---------- Stage 2: runtime ----------
FROM node:20-alpine AS runtime
WORKDIR /app
# Run as a non-root user for defense in depth.
RUN addgroup -S app && adduser -S app -G app
# Copy production node_modules and the built server + client assets.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Astro Node standalone server reads HOST/PORT from env at runtime.
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
USER app
# Healthcheck: the server responds 200 on /.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
CMD ["node", "./dist/server/entry.mjs"]
+7
View File
@@ -0,0 +1,7 @@
# Legacy site
The GregKilmer.com site as it was before the 2026 rebuild, kept for reference.
It still runs the live site until cutover, built from this folder.
Do not add features here. Its data was migrated into vault notes by
`tools/migrate`; new content goes into the vault.
+518
View File
@@ -0,0 +1,518 @@
# Greg Kilmer — Personal Profile Website Specification
> **Document Purpose:** This spec defines all pages, sections, copy, content flow, and structural relationships for a personal profile website serving as both a hobby/interest hub and a professional resume-portfolio. It is intended as a build-ready reference for web development.
> **Design Philosophy:** The site should feel like two wings of the same house — the professional side is polished and precise; the personal side is warm and inviting. They share a consistent design language (typography, color palette, spacing) but differ in tone. Navigation makes it easy to cross between them without confusion.
> **Human Voice:** Do not use emojis, use mdi icons when appropriate. Do not use em-dashes.
---
## 1. Global Structure & Navigation
### Domain
- `gregkilmer.com`
### Site Map
```
Home (Landing)
├── About Me
├── Professional (Resume/Portfolio)
│ ├── Experience
│ ├── Skills & Technologies
│ ├── Projects
│ └── Contact
├── Interests
│ ├── TTRPGs & Tabletop
│ ├── Gaming
│ ├── Reading & Media
│ ├── Making & Creating
│ └── Pokémon
└── Contact / Connect
```
### Navigation Design
- **Primary nav:** Horizontal top bar with two visual groupings separated by a subtle divider:
- **Left group:** Home, About
- **Right group:** Professional, Interests, Contact
- The grouping signals "who I am" vs. "what I do" without labeling them that way explicitly.
- **Persistent:** Nav remains fixed on scroll with a slight background blur on scroll.
- **Mobile:** Hamburger menu with the same two-group structure.
- A subtle tagline appears under the nav or in the logo area: _"Developer. Maker. Storyteller."_
### Color Palette Suggestion
- **Primary:** Deep slate or charcoal (#1E293B) — professional confidence
- **Accent:** Warm amber/gold (#F59E0B) — ties to the making/creative side
- **Secondary:** Soft Teal (#699a94) — favorite color group and works with gold and charcoal
- **Text:** Near-black (#0F172A) on light, cream-white (#FFFBEB) on dark sections
- The palette works equally for a dark-mode resume page and a warm-toned interests page.
### Typography
- Headings: A geometric sans-serif (e.g., Inter, Plus Jakarta Sans)
- Body: Same family or a readable serif for longer-form reading content
- Accent/quotes: A monospace or display font sparingly for pull quotes or section numbers
---
## 2. Home — Landing Page
**Purpose:** First impression. A brief, compelling snapshot that makes visitors want to explore both the professional and personal sides.
### Hero Section
- Full-width hero with a subtle animated or gradient background (dark charcoal to warm amber at edges).
- **Headline:** `Greg Kilmer`
- **Subheadline:** `Software Developer • Maker • Tabletop Gamemaster`
- **One-paragraph intro:**
> I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico, run D&D campaigns that span years, and fill my shelves withBrandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.
- **Two CTA buttons, side by side:**
- **Primary (amber):** "See My Work" → links to `/professional`
- **Secondary (outlined):** "Explore My Interests" → links to `/interests`
### Below the Fold — Snapshot Grid
A 3-column grid of cards giving a teaser for each major section. Each card has an icon, a short description, and a "Learn more →" link.
| Card | Icon | Teaser Copy |
| ---------------- | ---- | --------------------------------------------------------------------------------------------------------------- |
| **Professional** | 💼 | From SAP-adjacent cloud apps to full-stack mobile — a decade of building software that businesses actually use. |
| **Tabletop** | 🎲 | Currently running two campaigns in Foundry VTT and playtesting MCDM's Crows. Always rolling dice. |
| **Making** | 🎨 | Resin dice, 3D prints, and whatever else catches my eye. If it involves layers and precision, I'm in. |
A fourth card spans the bottom or sits as a wider strip:
| Card | Teaser Copy |
| ------------------- | ------------------------------------------------------------------------------------------- |
| **Reading & Media** | Deep in the Cosmere, catching up on anime, and always three books ahead on my to-read list. |
### Footer
- Minimal footer with social links (GitHub, LinkedIn, Discord) and copyright.
- Subtle: _"Built with intention. Powered by caffeine."_
---
## 3. About Me Page
**Purpose:** The human story. Who Greg is beyond the resume and the hobby list. This bridges the professional and personal sides.
### Section 1: The Short Version
> I'm Greg — a software developer based in Philadelphia who spends his free time running tabletop RPGs, making resin dice, 3D printing miniatures and props, and reading his way through fantasy series at an alarming rate. I live with my partner Krystina, our mini dachshund Kashi, and more board games than any reasonable apartment should hold.
### Section 2: The Longer Arc
> I grew up surrounded by books and games, and not much has changed. Technology pulled me into a career building cloud and mobile applications, but I never stopped collecting dice, designing campaigns, or losing weekends to Sanderson rereads. I'm currently expanding into SAP's Business Technology Platform, exploring AI and machine learning, and trying to convince my group that yes, we _can_ fit one more board game into game night.
>
> I believe the best work happens when curiosity isn't compartmentalized. The same system-thinking that makes a good developer makes a good gamemaster. The patience that produces a clean resin cast is the same patience that debugs a stubborn deployment. This site is where I don't have to pretend those things are separate.
### Section 3: Quick Facts (Sidebar or Inline Grid)
- 📍 Philadelphia, PA
- 💼 Software Developer at Implico Inc.
- 🐕 Dog dad to Kashi the mini dachshund
- 🎲 Running D&D & Daggerheart campaigns
- 📚 Currently reading: _Heir of Fire_ by Sarah J. Maas
- 🍵 Fueled by loose-leaf Chinese tea and Mountain Dew
### Flow: About → Professional & Interests
The About page ends with a soft transition: a split section with two directional paths:
- Left: _"See where I apply that curiosity professionally →"_ (links to Professional)
- Right: _"See where it takes me outside of work →"_ (links to Interests)
This reinforces that the two sides are connected, not siloed.
---
## 4. Professional — Resume / Portfolio
**Purpose:** Hire me. This section is clean, confident, and skimmable. It should work as both a browsable website and something a recruiter can screenshot or print.
### Section 1: Professional Summary
> Software developer and team lead with deep experience in cloud and mobile application development, currently focused on SAP-adjacent solutions at Implico Inc. Expanding into SAP Business Technology Platform (BTP) while maintaining a broad technical foundation across AI/ML, full-stack development, and automation. Known for bridging complex technical requirements with practical, maintainable solutions. Strong communicator who thrives at the intersection of engineering and business needs.
### Section 2: Experience
Presented as a vertical timeline with expandable detail.
**Implico Inc. — Software Developer / Lead Developer / Team Lead**
- _Focus:_ Cloud and mobile application development, SAP-adjacent solutions
- _Current expansion:_ SAP Business Technology Platform (BTP)
- _Key bullets (template — fill in specifics):_
- Lead development on [cloud/mobile project], delivering [specific outcome]
- Architect and maintain [system/platform], supporting [X users/clients]
- Collaborate with cross-functional teams to translate business requirements into technical solutions
- Mentor junior developers and establish coding standards and review practices
- Drive adoption of new technologies and frameworks within the team
_(Note: Greg should fill in specific projects, metrics, and dates here. The structure is ready.)_
### Section 3: Skills & Technologies
Organized in visual skill bars or a categorized grid. Three tiers:
**Core Competencies**
- Cloud Architecture & Development
- Mobile Application Development
- SAP Business Technology Platform (BTP)
- Team Leadership & Technical Mentorship
- Cross-functional Communication
**Technical Stack**
- _Languages:_ [Fill in — likely JavaScript/TypeScript, Python, Java, ABAP given SAP focus]
- _Frameworks:_ [Fill in]
- _Cloud Platforms:_ SAP BCP, [others]
- _Tools:_ Git, CI/CD pipelines, [fill in]
- _AI/ML:_ Hands-on experience with machine learning frameworks and LLM integration
**Emerging & Exploring**
- SAP BTP deep-dive (active expansion)
- AI-powered development tooling
- Advanced automation and workflow engineering
### Section 4: Projects
A card grid of notable projects. Each card:
**Project Card Template:**
- **Title:** [Project Name]
- **Role:** [Role — e.g., "Lead Developer", "Solo Developer"]
- **Tech:** [Tag pills — e.g., React, Node.js, SAP BTP]
- **Summary:** 2-3 sentence description of what the project does and Greg's contribution.
- **Links:** GitHub repo, live demo (if public), or case study.
_(Greg should populate with 3-6 projects. Placeholder cards can be used initially.)_
### Section 5: Education & Certifications
_(To be filled by Greg)_
- Education history
- Relevant certifications (SAP, cloud, etc.)
### Section 6: Contact — Professional
- **Email:** [Greg's professional email]
- **LinkedIn:** [Link]
- **GitHub:** [Link]
- **Availability note:** "Open to opportunities in cloud development, SAP BTP, and technical leadership."
### Flow Within Professional Page
The sections read as a narrative:
1. **Summary** → "Here's who I am professionally"
2. **Experience** → "Here's where I've proven it"
3. **Skills** → "Here's what I bring to the table"
4. **Projects** → "Here's what I've built"
5. **Education** → "Here's my foundation"
6. **Contact** → "Let's talk"
Each section subtly references the last. For example, the Skills section can highlight technologies mentioned in Experience, and Projects should showcase the skills in action.
---
## 5. Interests — The Personal Side
**Purpose:** This is the fun wing. It should feel warmer, more visual, and personality-driven than the professional side. It's where Greg's passions live — not as a list, but as stories and collections.
### Overall Tone for Interest Pages
- More relaxed formatting — shorter paragraphs, more images, visual hierarchy through cards and grids
- Each interest page follows a consistent template but adapts to the content type:
- **Header:** Title + tagline
- **Overview:** 2-3 paragraphs of narrative
- **Highlights:** Visual cards or grids of specifics
- **Currently:** What's active right now (keeps it feeling alive)
---
### 5a. TTRPGs & Tabletop
**Header Tagline:** _"Three campaigns, two systems, one rolling table."_
**Overview:**
> Tabletop gaming isn't a hobby for me — it's a practice. I run Dungeons & Dragons and Daggerheart campaigns that span years of real-time play, maintaining worlds, NPCs, and narrative threads that my players remember years later. I host everything on Foundry VTT, which lets me build the kind of dynamic, visually rich experiences that keep players invested session after session.
>
> Right now, I'm also playtesting MCDM's _Crows_ — Matt Colville's forthcoming RPG built around hex-crawling, faction play, and hard travel rules. The playtest has already influenced how I think about overland encounters in my own campaigns.
**Highlights (Card Grid):**
| Card | Content |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 🐉 **Dungeons & Dragons** | Long-running campaign on Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative. |
| 🗡️ **Daggerheart** | Darrington Press's dice-building RPG. Currently running a campaign — exploring how its mechanics create different story rhythms than D&D. |
| 🐦 **Crows (Playtest)** | MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback. |
| 🖥️ **Foundry VTT** | Self-hosted on my home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns. |
| 🎯 **Draw Steel** | MCDM's upcoming tactical combat game. Interested in running it when it releases. |
**Currently Playing/Running:**
- [Campaign 1 name] — D&D, [day of week]s
- [Campaign 2 name] — Daggerheart, [day of week]s
- Crows playtest sessions — ongoing
**Board Games Section (within TTRPGs page or as a sub-section):**
> Beyond RPGs, I maintain a growing board and card game collection. Favorites span strategy, deck-building, and social deduction. Game night is a regular event — and yes, there's always room for one more.
_(Consider a visual "shelf" or gallery showing box art for favorite games if images are available.)_
---
### 5b. Gaming
**Header Tagline:** _"If it has a screen or a controller, I've probably played it."_
**Overview:**
> Gaming is my other constant. I span console generations with a PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. I'm particularly drawn to RPGs, strategy games, and anything with a deeply layered world. If a game has lore compendiums, I'm reading them.
**Platforms:**
| Card | Content |
| --------------------------------- | -------------------------------------------------------------------- |
| 🎮 **PlayStation 5** | Main console for AAA titles and exclusives. |
| 🔴 **Nintendo Switch / Switch 2** | Handheld and docked. Nintendo first-party plus indie gems. |
| 💻 **Steam** | The primary PC library. Strategy, RPGs, and an ever-growing backlog. |
**Favorite Genres:**
- RPGs (both Western and JRPG)
- Strategy and tactics
- Open-world exploration
- Indie darlings
**Currently Playing:** _(Greg to fill in)_
---
### 5c. Reading & Media
**Header Tagline:** _"Perpetually three books ahead of my to-read list."_
**Overview:**
> I read like it's a competitive sport. Fantasy is my native language — Brandon Sanderson's Cosmere is fully consumed and eagerly awaiting the next era. Frank Herbert's Dune trilogy shaped how I think about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga is my comfort reread. And I'm currently working through Sarah J. Maas's Throne of Glass series.
>
> Beyond novels, I read light novels, comic books, manhwa, and manga. I watch anime and film extensively. If a story exists in any format, I'm probably consuming it.
**Reading Highlights (Card Grid):**
| Card | Status |
| --------------------------------------------------- | ----------------------------------------------------------- |
| ✅ **Brandon Sanderson — Cosmere** | Complete. Every novel, every era. The gold standard for me. |
| ✅ **Frank Herbert — Dune** | Books 1-3 complete. |
| 🔄 **Douglas Preston & Lincoln Child — Pendergast** | Currently rereading. Up to _Reliquary_. |
| 🔄 **Sarah J. Maas — Throne of Glass** | Currently on _Heir of Fire_ (Book 3). |
| 🆕 **Wistoria: Wand and Sword** | Recently added to the reading list. |
**Anime Favorites (visual list with small banners or icons):**
- One Piece
- Bleach
- Gundam (all series)
- Code Geass
- Magi
- That Time I Got Reincarnated as a Slime
- Overlord
**Movies & TV:**
> Watches extensively across genres. Particularly drawn to sci-fi, fantasy, and animated film.
---
### 5d. Making & Creating
**Header Tagline:** _"If it involves layers and precision, I'm in."_
**Overview:**
> Making is how I decompress. I cast resin dice using pressure pots and custom molds, print miniatures and props on my Elegoo Mars 4 (resin) and Bambu Labs P1S (FDM), and generally fill my workspace with projects-in-progress. There's something deeply satisfying about taking raw material through a process and holding the finished piece — whether that's a perfect polyhedral set or a printed terrain piece for the next game session.
**Equipment:**
| Card | Details |
| ------------------------ | ----------------------------------------------------------------------------------------------------------- |
| 🎨 **Resin Dice Making** | Custom molds, pressure pot curing, pigment experimentation. Producing dice sets for personal use and gifts. |
| 🖨️ **Elegoo Mars 4** | Resin 3D printer. Miniatures, terrain, and high-detail props. |
| 🖨️ **Bambu Labs P1S** | FDM 3D printer. Functional prints, terrain pieces, and larger prototypes. |
| 🛠️ **General Making** | Always experimenting. If there's a new technique or material, I want to try it. |
**Gallery Section:**
> _(Placeholder for photo gallery of completed projects — dice sets, prints, terrain. Highly recommended for visual impact.)_
**Connection to TTRPGs:**
Include a subtle callout: _"Much of what I make ends up on the virtual table — custom tokens, terrain, and props for Foundry VTT campaigns."_ This creates a natural link back to the TTRPGs page.
---
### 5e. Pokémon
**Header Tagline:** _"Metagross supremacy. This is not up for debate."_
**Overview:**
> Pokémon has been a lifelong obsession. From Red and Blue on the original Game Boy to modern competitive formats, I've followed the franchise through every generation. My favorite? Metagross — the iron leg Pokémon. Steel/Psychic, beautiful design, absolutely devastating in battle. No further questions.
**Favorites & Team:**
- **Favorite Pokémon:** Metagross
- **Roster:** _(Greg to fill — note: rosters should use standard Pokémon Showdown text format)_
```
## Pokémon
[Greg's preferred roster in Showdown format]
```
**Games:**
> _(List of mainline and spinoff titles played, if desired)_
---
### Interests Page Flow
The interests pages should interlink naturally:
- **TTRPGs** ↔ **Making** (dice and terrain made for campaigns)
- **TTRPGs** ↔ **Gaming** (digital RPGs as another expression of the same impulse)
- **Reading** ↔ **TTRPGs** (worldbuilding inspiration flows both ways)
- **Gaming** ↔ **Pokémon** (Nintendo console overlap)
- **Making** can link to **all of the above** (props for games, displays for collections)
Each interest page should end with a "You might also like..." section suggesting 2 related interest pages.
---
## 6. Contact / Connect Page
**Purpose:** A single place for all ways to reach Greg — professional and social.
### Layout
Two columns:
**Professional:**
- Email: [Professional email]
- LinkedIn: [Link]
- GitHub: [Link]
- Availability note (if open to opportunities)
**Personal / Social:**
- Discord: [Handle or link]
- Twitter/X: [Link, if applicable]
- Pokémon: [Showdown or Trainer name, if shared]
- Foundry VTT: [Server link, if public]
**Closing note:**
> Whether you want to talk cloud architecture, debate the best Gundam series, or need a fourth for game night — reach out.
---
## 7. Cross-Page Relationships & User Journey
### For Recruiters / Hiring Managers
1. **Home** → sees the split between professional and personal → clicks "See My Work"
2. **Professional → Experience** → understands track record
3. **Professional → Skills** → evaluates technical fit
4. **Professional → Projects** → sees proof of ability
5. **Professional → Contact** → reaches out
6. _Optional detour:_ **About Me** → gets a sense of personality and culture fit
### For Fellow Gamers / Friends
1. **Home** → clicks "Explore My Interests"
2. **Interests → TTRPGs** → reads about campaigns and playtest involvement
3. **TTRPGs → Making** → sees terrain and dice for campaigns
4. **TTRPGs → Gaming** → discovers shared gaming interests
5. **Contact** → connects on Discord
### For Fellow Makers
1. **Home** → clicks **Making** from snapshot grid
2. **Making** → sees equipment, process, gallery
3. **Making → TTRPGs** → sees how making connects to campaigns
4. **Contact** → reaches out to collaborate or discuss techniques
---
## 8. Technical Recommendations
### Framework
- Static site generator (Astro, Next.js, or 11ty) for performance and easy hosting
- MDX or markdown content for blog-like sections (reading reviews, project writeups)
- Responsive design — mobile-first
### Hosting
- Vercel, Netlify, or Cloudflare Pages (free tier sufficient for a personal site)
- Custom domain via the registrar of choice
### Content Management
- Markdown files for easy updates
- Image optimization (WebP, lazy loading)
- Consider a headless CMS (like Sanity or Contentful) only if Greg wants to update frequently without touching code
### Performance Targets
- Lighthouse score 90+ across all categories
- Sub-2s initial load
- Accessible (WCAG 2.1 AA)
### Future Expansions (Nice-to-Have)
- **Blog section:** For longer-form thoughts on tech, game design, or reading reviews
- **Project showcase:** Deeper case studies with screenshots and writeups
- **Dice shop page:** If Greg ever wants to sell resin dice
- **Campaign log:** A public-facing Foundry VTT campaign journal or session notes
- **Reading tracker:** Integration with a reading tracker API or manual log
---
## 9. Content Greg Needs to Provide
To populate this site fully, Greg should prepare:
- [ ] Specific work experience details (dates, projects, metrics, technologies)
- [ ] Technical stack specifics (languages, frameworks, tools)
- [ ] 3-6 portfolio projects with descriptions and links
- [ ] Education history and certifications
- [ ] Professional email, LinkedIn, GitHub URLs
- [ ] Campaign names and schedules (for TTRPGs page)
- [ ] Pokémon Showdown roster
- [ ] Photos: headshot, making/3D printing process shots, dice sets, game night, reading nook
- [ ] Currently playing (games, books, shows)
- [ ] Board game favorites list
- [ ] Social handles (Discord, Twitter/X, etc.)
- [ ] Domain preference
---
_Document prepared by Aurelius, the Curator 📜_
_Created: July 3, 2026_
_Status: Ready for review and population by Antares_
+156
View File
@@ -0,0 +1,156 @@
# Greg Kilmer — Profile & Portfolio Site
A responsive, content-driven personal site for Greg Kilmer, built as **two wings
of the same house**: a polished professional resume/portfolio on one side, and a
warm, personality-driven interests hub on the other.
Built with **Astro 7 + React Islands + TypeScript**, served by a **Node server**
with API routes, ready to deploy as a container on a homelab Cosmos system.
---
## Highlights
- **Astro 7** static-first core with React Islands for interactivity (mobile
menu, theme toggle, animated hero background, contact form).
- **Node backend** (`@astrojs/node`, standalone) with a typed `/api/contact`
endpoint (SMTP via nodemailer + webhook fallback, Zod validation, rate limit).
- **Section-aware theming**: the Professional wing reads dark/polished, the
Interests wing reads warm/inviting, plus a global light/dark toggle.
- **Rich motion**: animated mesh-gradient hero with an optional canvas particle
field, IntersectionObserver scroll reveals, View Transitions page swaps,
scroll-triggered nav blur. All `prefers-reduced-motion` aware.
- **Content Collections** (typed): add projects and experience by dropping
markdown/MDX files.
- **mdi icons everywhere** (no emojis, per the spec), inlined at build time from
`@iconify-json/mdi` for zero runtime cost.
- **Self-hosted variable fonts** (Inter, Plus Jakarta Sans, JetBrains Mono).
- **Accessible**: semantic landmarks, skip link, keyboard-operable menu,
focus-visible rings, ARIA on interactive bits, WCAG-AA contrast targets.
- **Deploy artifacts**: multi-stage `Dockerfile` + `docker-compose.yml`.
---
## Quickstart
```bash
# 1. Install dependencies (Node 20+)
npm install
# 2. Start the dev server with hot reload
npm run dev
# -> http://localhost:4321
# 3. Production build (runs astro check + build)
npm run build
# 4. Run the built Node server
npm run serve
# -> http://localhost:4321
```
Environment is optional for local dev. To enable contact-form delivery, copy
`.env.example` to `.env` and fill in SMTP or webhook vars (see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
---
## Project structure
```
src/
├── components/
│ ├── hero/ AnimatedBackground.tsx (gradient + canvas particles)
│ ├── motion/ Reveal.astro (IntersectionObserver scroll reveals)
│ ├── nav/ NavBar.astro, MobileMenu.tsx, ThemeToggle.tsx
│ ├── cards/ InterestCard.astro
│ ├── professional/ Timeline, SkillGrid, ProjectCard, SectionNav, ContactForm
│ ├── layout/ Footer, SectionHeader, SplitTransition, InterestHero,
│ │ RelatedInterests, Placeholder
│ └── Icon.astro Inline mdi icon renderer (build-time)
├── content/
│ ├── projects/ Project collection entries (markdown/MDX)
│ └── experience/ Experience collection entries
├── layouts/
│ └── BaseLayout.astro <html> shell, head, fonts, theme init, nav, footer
├── pages/
│ ├── index.astro Home (hero + snapshot grid)
│ ├── about.astro About + split transition fork
│ ├── professional/index.astro Single long page: Summary, Experience,
│ │ Skills, Projects, Education, Contact
│ ├── interests/ Hub + 5 sub-pages
│ │ ├── index.astro
│ │ ├── ttrpgs / gaming / reading / making / pokemon .astro
│ ├── contact.astro Two-column connect page + contact form
│ └── api/contact.ts POST endpoint -> SMTP / webhook
├── content.config.ts Collection schemas (projects, experience)
├── lib/ site.ts (nav/socials), motion.ts, content helpers
├── styles/ global.css, themes.css, animations.css
└── scripts/theme-init.ts No-FOUC theme boot (inlined in <head>)
```
---
## The two wings (theming model)
Theming has two independent axes (see [docs/THEMING.md](docs/THEMING.md)):
1. **Global light/dark** on `<html data-theme>`, set before paint by an inline
script, persisted to `localStorage`, toggleable via the nav button.
2. **Section tone** via `data-surface` on `<body>`:
- `neutral` (Home/About/Contact) — balanced middle ground
- `professional` — dark slate, polished
- `interest` — warm cream/amber, inviting
Components only reference semantic CSS variables (`--surface`, `--text`,
`--accent`...), so changing either axis restyles everything automatically.
---
## Adding content
- **Projects / Experience:** drop a markdown or MDX file in
`src/content/projects/` or `src/content/experience/`. Schemas and field
reference are in [docs/CONTENT.md](docs/CONTENT.md).
- **Interest pages:** hand-built `.astro` files under `src/pages/interests/`,
each following the spec's template (Header, Overview, Highlights, Currently,
Related). Edit copy in place.
- **Placeholders:** every `[Greg to fill]` gap from the spec is a styled
`Placeholder` slot with a TODO badge so you can find and replace them.
---
## Deployment (Cosmos homelab)
The site runs as a Node server inside a container. Full walkthrough in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
```bash
docker compose up -d --build
# Then point your reverse proxy at port 4321 with TLS for gregkilmer.com.
```
---
## Scripts
| Script | Description |
| ----------------- | ----------------------------------------------------- |
| `npm run dev` | Dev server with hot reload |
| `npm run build` | Type-check (`astro check`) then build the Node server |
| `npm run serve` | Run the built server (`dist/server/entry.mjs`) |
| `npm run preview` | Preview the build locally |
| `npm run check` | Type-check only |
---
## Tech stack
Astro 7, React 19, TypeScript 6, Tailwind CSS v4, Framer Motion, nodemailer,
Zod, `@iconify-json/mdi`, Fontsource variable fonts, `@astrojs/node` standalone.
---
## License
Personal site for Greg Kilmer. All rights reserved.
+100
View File
@@ -0,0 +1,100 @@
// @ts-check
/**
* Astro configuration for Greg Kilmer's profile site.
*
* Stack:
* - @astrojs/node (standalone) -> Node server with API routes for the Cosmos homelab.
* - @astrojs/react -> React 19 islands for interactive components (mobile menu,
* theme toggle, animated hero background, contact form).
* - @astrojs/mdx -> MDX content collections (projects, experience).
* - @tailwindcss/vite -> Tailwind v4 via the Vite plugin (CSS-first config in styles).
* - astro-icon + mdi set -> render Material Design Icons (no emojis, per spec).
*
* The `standalone` output produces `dist/server/entry.mjs`, launched by `npm run serve`
* or by the Docker runtime. API routes under src/pages/api/* are served by the same server.
*/
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import mdx from '@astrojs/mdx';
import tailwindcss from '@tailwindcss/vite';
// `SITE_URL` lets us produce absolute URLs in metadata / API responses. Defaults to the
// production domain from the spec; override locally or in CI as needed.
const SITE_URL = process.env.SITE_URL ?? 'https://gregkilmer.com';
// Astro exposes the configured server host/port to the Node adapter via env at runtime.
// We read them here only for documentation; the adapter picks them up automatically.
const PORT = Number(process.env.PORT ?? 4321);
const HOST = process.env.HOST ?? '0.0.0.0';
/**
* React-refresh preamble, injected into the layout <head> in dev (see
* BaseLayout.astro). Returned as a string so the layout can emit it as an
* inline module script that runs BEFORE any island module evaluates.
*
* Why this is needed: Vite 8 (Rolldown) + @vitejs/plugin-react 5.x does not
* inject this preamble through Astro's SSR dev HTML pipeline, so the compiled
* islands call $RefreshSig$() at module-eval time, throw, and never hydrate —
* surfacing as "_jsxDEV is not a function" and leaving every React island
* (carousel, mobile menu, contact/order forms, animated background) dead.
*
* Production builds are unaffected: the client bundle uses jsx/jsxs directly
* and never touches $RefreshSig$. Remove this once @astrojs/react ships
* plugin-react 6, which handles Rolldown natively.
*/
export const REACT_REFRESH_PREAMBLE =
"import { injectIntoGlobalHook } from '/@react-refresh';\n" +
'injectIntoGlobalHook(window);\n' +
'window.$RefreshReg$ = () => {};\n' +
'window.$RefreshSig$ = () => (type) => type;';
export default defineConfig({
// Used for canonical URLs, sitemaps, and absolute metadata. Keep this in sync with the
// deployed domain on your Cosmos reverse proxy.
site: SITE_URL,
// Server output mode. `standalone` emits a self-contained Node server we can run in a
// container. This is what enables /api/* routes to execute server-side.
output: 'server',
adapter: node({
mode: 'standalone',
}),
// Page-to-page transitions using the native View Transitions API. Persists the nav and
// animates content swaps. Honored automatically by Astro's <ClientRouter />.
// (Icons are rendered inline at build time via src/components/Icon.astro, reading
// directly from @iconify-json/mdi; no astro-icon integration needed.)
integrations: [
react(),
mdx(),
],
vite: {
plugins: [tailwindcss()],
build: {
// Never inline assets as data URIs. The default (4096 bytes) causes Vite
// to inline the smallest font subsets (e.g. cyrillic-ext woff2) as
// data:font/woff2 base64, which (a) requires data: in font-src and (b)
// triggers Chromium "OTS parsing error: Size of decompressed WOFF 2.0 is
// less than compressed size" warnings on those inlined subsets. Forcing
// every asset to a real file keeps font-src tight at 'self' and avoids
// the OTS parse errors.
assetsInlineLimit: 0,
},
},
server: {
host: HOST,
port: PORT,
},
// Markdown rendering options shared by content collections and MDX.
markdown: {
shikiConfig: {
theme: 'github-dark-dimmed',
wrap: true,
},
},
});
+48
View File
@@ -0,0 +1,48 @@
# =============================================================================
# docker-compose.yml — Greg Kilmer Profile Site (Cosmos homelab)
# =============================================================================
# Builds and runs the site as a container behind your Cosmos reverse proxy.
#
# Usage:
# 1. Copy .env.example -> .env and fill in SMTP/contact vars.
# 2. `docker compose up -d --build`
# 3. Point your Cosmos reverse proxy (Traefik/Caddy/nginx) at this service on
# port 4321, with TLS for gregkilmer.com.
#
# See docs/DEPLOYMENT.md for the full walkthrough.
# =============================================================================
services:
greg-site:
container_name: greg-profile-site
build:
context: .
dockerfile: Dockerfile
args:
# Canonical URL baked in at build time for metadata.
SITE_URL: ${SITE_URL:-https://gregkilmer.com}
image: greg-profile-site:latest
restart: unless-stopped
ports:
# Map container 4321 -> host. If Cosmos reverse-proxies, you can instead
# use `expose:` and join a shared network. Adjust to your setup.
- "${HOST_PORT:-4321}:4321"
env_file:
- .env
environment:
- HOST=0.0.0.0
- PORT=4321
- SITE_URL=${SITE_URL:-https://gregkilmer.com}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4321/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- homelab_network
networks:
homelab_network:
name: homelab_network
external: true
+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.
+8470
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "greg-kilmer-profile",
"version": "1.0.0",
"type": "module",
"private": true,
"description": "Personal profile / portfolio website for Greg Kilmer. Astro + React Islands + Node server.",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"serve": "node ./dist/server/entry.mjs",
"astro": "astro",
"check": "astro check",
"typecheck": "astro check && tsc --noEmit"
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.2",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.2",
"@fontsource-variable/cinzel": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/montserrat": "^5.3.0",
"@iconify-json/mdi": "^1.2.3",
"@tailwindcss/vite": "^4.3.2",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^7.2.0",
"framer-motion": "^12.42.2",
"nodemailer": "^9.0.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
# Security contact for gregkilmer.com.
# Spec: https://www.rfc-editor.org/rfc/rfc9116
# Reachable at https://gregkilmer.com/.well-known/security.txt
# Renew Expires before the date below (set ~1 year out).
Contact: mailto:security@gregkilmer.com
Expires: 2027-07-11T00:00:00.000Z
Preferred-Languages: en
Canonical: https://gregkilmer.com/.well-known/security.txt
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+76
View File
@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Patiun LLC seal" viewBox="6 6 180 181"><g transform="translate(102,96.5) scale(0.82) translate(-96,-96.5)"><g fill="none" stroke="#14A39A" stroke-width="7.2" stroke-linejoin="round">
<path d="M31,46.75 A54.3,54.3 0 1 1 31,146.25 A53.4,53.4 0 0 0 31,46.75 Z"/><circle cx="134" cy="96.5" r="26.9"/></g><circle cx="134" cy="96.5" r="10.3" fill="#14A39A"/></g>
<circle cx="96" cy="96.5" r="84" fill="none" stroke="#C9A24A" stroke-width="1.8"/>
<circle cx="96" cy="96.5" r="76" fill="none" stroke="#C9A24A" stroke-width="1"/>
<line x1="171.71" y1="103.12" x2="174.90" y2="103.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="170.85" y1="109.70" x2="174.00" y2="110.25" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="169.41" y1="116.17" x2="172.50" y2="117.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="167.42" y1="122.49" x2="170.42" y2="123.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="164.88" y1="128.62" x2="167.78" y2="129.97" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="161.82" y1="134.50" x2="168.75" y2="138.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="158.26" y1="140.09" x2="160.88" y2="141.93" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="154.22" y1="145.35" x2="156.67" y2="147.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="149.74" y1="150.24" x2="152.00" y2="152.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="144.85" y1="154.72" x2="146.91" y2="157.17" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="139.59" y1="158.76" x2="141.43" y2="161.38" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="134.00" y1="162.32" x2="138.00" y2="169.25" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="128.12" y1="165.38" x2="129.47" y2="168.28" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="121.99" y1="167.92" x2="123.09" y2="170.92" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="115.67" y1="169.91" x2="116.50" y2="173.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="109.20" y1="171.35" x2="109.75" y2="174.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="102.62" y1="172.21" x2="102.90" y2="175.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="89.38" y1="172.21" x2="89.10" y2="175.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="82.80" y1="171.35" x2="82.25" y2="174.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="76.33" y1="169.91" x2="75.50" y2="173.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="70.01" y1="167.92" x2="68.91" y2="170.92" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="63.88" y1="165.38" x2="62.53" y2="168.28" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="58.00" y1="162.32" x2="54.00" y2="169.25" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="52.41" y1="158.76" x2="50.57" y2="161.38" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="47.15" y1="154.72" x2="45.09" y2="157.17" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="42.26" y1="150.24" x2="40.00" y2="152.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="37.78" y1="145.35" x2="35.33" y2="147.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="33.74" y1="140.09" x2="31.12" y2="141.93" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="30.18" y1="134.50" x2="23.25" y2="138.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="27.12" y1="128.62" x2="24.22" y2="129.97" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="24.58" y1="122.49" x2="21.58" y2="123.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="22.59" y1="116.17" x2="19.50" y2="117.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="21.15" y1="109.70" x2="18.00" y2="110.25" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="20.29" y1="103.12" x2="17.10" y2="103.40" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="20.29" y1="89.88" x2="17.10" y2="89.60" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="21.15" y1="83.30" x2="18.00" y2="82.75" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="22.59" y1="76.83" x2="19.50" y2="76.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="24.58" y1="70.51" x2="21.58" y2="69.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="27.12" y1="64.38" x2="24.22" y2="63.03" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="30.18" y1="58.50" x2="23.25" y2="54.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="33.74" y1="52.91" x2="31.12" y2="51.07" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="37.78" y1="47.65" x2="35.33" y2="45.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="42.26" y1="42.76" x2="40.00" y2="40.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="47.15" y1="38.28" x2="45.09" y2="35.83" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="52.41" y1="34.24" x2="50.57" y2="31.62" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="58.00" y1="30.68" x2="54.00" y2="23.75" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="63.88" y1="27.62" x2="62.53" y2="24.72" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="70.01" y1="25.08" x2="68.91" y2="22.08" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="76.33" y1="23.09" x2="75.50" y2="20.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="82.80" y1="21.65" x2="82.25" y2="18.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="89.38" y1="20.79" x2="89.10" y2="17.60" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="102.62" y1="20.79" x2="102.90" y2="17.60" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="109.20" y1="21.65" x2="109.75" y2="18.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="115.67" y1="23.09" x2="116.50" y2="20.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="121.99" y1="25.08" x2="123.09" y2="22.08" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="128.12" y1="27.62" x2="129.47" y2="24.72" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="134.00" y1="30.68" x2="138.00" y2="23.75" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="139.59" y1="34.24" x2="141.43" y2="31.62" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="144.85" y1="38.28" x2="146.91" y2="35.83" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="149.74" y1="42.76" x2="152.00" y2="40.50" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="154.22" y1="47.65" x2="156.67" y2="45.59" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="158.26" y1="52.91" x2="160.88" y2="51.07" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="161.82" y1="58.50" x2="168.75" y2="54.50" stroke="#C9A24A" stroke-width="1.3"/>
<line x1="164.88" y1="64.38" x2="167.78" y2="63.03" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="167.42" y1="70.51" x2="170.42" y2="69.41" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="169.41" y1="76.83" x2="172.50" y2="76.00" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="170.85" y1="83.30" x2="174.00" y2="82.75" stroke="#C9A24A" stroke-width="0.8"/>
<line x1="171.71" y1="89.88" x2="174.90" y2="89.60" stroke="#C9A24A" stroke-width="0.8"/>
<polygon points="96.00,7.00 97.88,14.62 105.50,16.50 97.88,18.38 96.00,26.00 94.12,18.38 86.50,16.50 94.12,14.62" fill="#C9A24A"/>
<polygon points="176.00,87.00 177.88,94.62 185.50,96.50 177.88,98.38 176.00,106.00 174.12,98.38 166.50,96.50 174.12,94.62" fill="#C9A24A"/>
<polygon points="96.00,167.00 97.88,174.62 105.50,176.50 97.88,178.38 96.00,186.00 94.12,178.38 86.50,176.50 94.12,174.62" fill="#C9A24A"/>
<polygon points="16.00,87.00 17.88,94.62 25.50,96.50 17.88,98.38 16.00,106.00 14.12,98.38 6.50,96.50 14.12,94.62" fill="#C9A24A"/></svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="fgk" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#131C2B"/><stop offset="1" stop-color="#1F3B49"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#fgk)"/><circle cx="32" cy="32" r="26" fill="none" stroke="#14A39A" stroke-width="2.4" /><g fill="#F4F2ED" aria-label="GK"><path d="M754.5337524414062 280V115Q730.9266357421875 85 683.427001953125 55.0Q635.9273681640625 25 569.7135009765625 5.5Q503.4996337890625 -14 422.6427001953125 -14Q309.3572998046875 -13.60711669921875 224.41104125976562 30.67864990234375Q139.46478271484375 74.96441650390625 92.1434326171875 156.76797485351562Q44.82208251953125 238.571533203125 44.82208251953125 351.2142333984375Q44.82208251953125 462.6427001953125 91.53631591796875 544.0355834960938Q138.25054931640625 625.428466796875 224.714599609375 669.7142333984375Q311.17864990234375 714 428.7138671875 714Q483.7138671875 714 535.9099426269531 706.5177917480469Q588.1060180664062 699.0355834960938 632.1056518554688 686.8747253417969Q676.1052856445312 674.7138671875 705.1052856445312 659.3565673828125L706.8917846679688 488.000732421875H697.8917846679688Q679.2490844726562 556.3217163085938 643.2312927246094 596.4288330078125Q607.2135009765625 636.5359497070312 559.7668762207031 654.1968078613281Q512.3202514648438 671.857666015625 459.0697021484375 671.857666015625Q377.4981689453125 671.857666015625 321.39068603515625 632.2861328125Q265.283203125 592.714599609375 236.568603515625 519.8395080566406Q207.85400390625 446.96441650390625 207.85400390625 345.14306640625Q207.85400390625 248.7857666015625 234.44369506835938 177.428466796875Q261.03338623046875 106.0711669921875 311.9803771972656 67.30319213867188Q362.9273681640625 28.53521728515625 435.999267578125 28.142333984375Q473.32098388671875 28.142333984375 507.4462585449219 39.553009033203125Q541.571533203125 50.96368408203125 567.5541076660156 73.19570922851562Q593.5366821289062 95.427734375 604.5374145507812 127.28466796875L605.5374145507812 280Q605.5374145507812 325 554.5374145507812 325H538.5374145507812V335H819.5337524414062V325H804.5337524414062Q752.5337524414062 325 754.5337524414062 280Z" transform="translate(12.09,41.50) scale(0.02500,-0.02500)"/><path d="M412.851806640625 430.53375244140625 674.99560546875 72.24908447265625Q697.8169555664062 41.9281005859375 722.0489807128906 26.46405029296875Q746.281005859375 11 777.281005859375 10V0H694.8184204101562Q684.2113037109375 0 658.7294616699219 1.196441650390625Q633.2476196289062 2.39288330078125 601.1230773925781 9.410675048828125Q568.99853515625 16.428466796875 536.5704345703125 33.696075439453125Q504.142333984375 50.96368408203125 479.85693359375 83.10601806640625Q475 89.7843017578125 460.2320251464844 109.87362670898438Q445.46405029296875 129.96295166015625 425.24945068359375 157.80245971679688Q405.03485107421875 185.6419677734375 383.3380432128906 215.55300903320312Q361.6412353515625 245.46405029296875 343.04561360677087 271.22924296061194Q324.4499918619792 296.9944356282552 312.61641438802087 313.3904673258463Q300.7828369140625 329.7864990234375 300.7828369140625 329.7864990234375ZM663.6034545898438 693 362.7821044921875 335.857666015625 300.7828369140625 329.7864990234375 575.7828369140625 693ZM733.8176879882812 700V690.8438842773437H729.8176879882812Q696.8176879882812 690.8438842773437 668.3176879882812 677.6183837890625Q639.8176879882812 664.3928833007812 619.8176879882812 642L538.6041870117188 644Q550.9970703125 661 543.1041870117188 676.0Q535.2113037109375 691 514.8065592447916 691H504.60418701171875V700H729.8176879882812ZM268.7828369140625 700V0H118.7864990234375V700ZM121.7864990234375 73V0H41.7864990234375V10Q43.000732421875 10 48.590057373046875 10.0Q54.17938232421875 10 54.7864990234375 10Q80.7864990234375 10 99.2864990234375 28.5Q117.7864990234375 47 118.7864990234375 73ZM121.7864990234375 627H118.7864990234375Q117.7864990234375 653 99.2864990234375 671.5Q80.7864990234375 690 54.7864990234375 690Q54.17938232421875 690 48.590057373046875 690.0Q43.000732421875 690 41.7864990234375 690V700H121.7864990234375ZM265.7828369140625 73H268.7828369140625Q269.7828369140625 47 288.2828369140625 28.5Q306.7828369140625 10 332.7828369140625 10Q333.9970703125 10 339.43665489783655 10.0Q344.8762394831731 10 345.7828369140625 10V0H265.7828369140625ZM265.7828369140625 627V700H345.7828369140625V690Q344.7828369140625 690 339.38995361328125 690.0Q333.9970703125 690 332.7828369140625 690Q306.7828369140625 690 288.2828369140625 671.5Q269.7828369140625 653 268.7828369140625 627Z" transform="translate(32.99,41.50) scale(0.02500,-0.02500)"/></g><circle cx="32" cy="6" r="4" fill="#131C2B"/><polygon points="32.00,0.80 33.10,4.90 37.20,6.00 33.10,7.10 32.00,11.20 30.90,7.10 26.80,6.00 30.90,4.90" fill="#C9A24A"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: deck box open showing card capacity">
<defs>
<linearGradient id="bg3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow3" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#e8b54a" stop-opacity="0.16"/>
<stop offset="1" stop-color="#e8b54a" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg3)"/>
<rect width="640" height="480" fill="url(#glow3)"/>
<!-- open deck box, lid back, cards inside -->
<g transform="translate(150 150)">
<!-- base -->
<rect x="0" y="80" width="340" height="180" rx="14" fill="#221630" stroke="#3e2f4a" stroke-width="2"/>
<!-- lid (hinged back) -->
<rect x="0" y="0" width="340" height="90" rx="14" fill="#2c2050" stroke="#3e2f4a" stroke-width="2"/>
<!-- cards stack -->
<rect x="30" y="40" width="200" height="130" rx="8" fill="#f4f1e8" opacity="0.9"/>
<rect x="40" y="30" width="200" height="130" rx="8" fill="#e8b54a" opacity="0.35"/>
<rect x="50" y="20" width="200" height="130" rx="8" fill="#8b7bd8" opacity="0.35"/>
<text x="270" y="180" font-family="monospace" font-size="14" fill="#c4ab8e">100+</text>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: counter close-up with life totals">
<defs>
<linearGradient id="bg4" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow4" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#6fb6ad" stop-opacity="0.16"/>
<stop offset="1" stop-color="#6fb6ad" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg4)"/>
<rect width="640" height="480" fill="url(#glow4)"/>
<!-- spinner-style counter -->
<g transform="translate(220 110)">
<circle cx="100" cy="130" r="110" fill="#221630" stroke="#3e2f4a" stroke-width="2"/>
<circle cx="100" cy="130" r="86" fill="none" stroke="#e8b54a" stroke-width="3" opacity="0.5"/>
<text x="100" y="120" text-anchor="middle" font-family="monospace" font-size="13" fill="#c4ab8e" letter-spacing="2">LIFE</text>
<text x="100" y="165" text-anchor="middle" font-family="sans-serif" font-size="46" font-weight="700" fill="#f4f1e8">40</text>
<!-- dial nubs -->
<circle cx="100" cy="24" r="6" fill="#8b7bd8"/>
<circle cx="100" cy="236" r="6" fill="#8b7bd8"/>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: close-up of card finish and cut">
<defs>
<linearGradient id="bg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow1" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#e8b54a" stop-opacity="0.18"/>
<stop offset="1" stop-color="#e8b54a" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg1)"/>
<rect width="640" height="480" fill="url(#glow1)"/>
<!-- stylized card -->
<g transform="translate(170 70) rotate(-6)">
<rect width="300" height="420" rx="18" fill="#0b1026" stroke="#3e2f4a" stroke-width="2"/>
<rect x="22" y="22" width="256" height="100" rx="8" fill="#221630"/>
<rect x="22" y="140" width="256" height="180" rx="8" fill="#2c2050" opacity="0.5"/>
<rect x="22" y="336" width="180" height="14" rx="4" fill="#8b7bd8" opacity="0.5"/>
<rect x="22" y="360" width="120" height="14" rx="4" fill="#8b7bd8" opacity="0.3"/>
<!-- sparkle -->
<path d="M150 60 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4 z" fill="#e8b54a" opacity="0.8"/>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-label="Placeholder image: white card back showing the proxy marking">
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#170f24"/>
<stop offset="1" stop-color="#221630"/>
</linearGradient>
<radialGradient id="glow2" cx="0.5" cy="0.4" r="0.6">
<stop offset="0" stop-color="#8b7bd8" stop-opacity="0.16"/>
<stop offset="1" stop-color="#8b7bd8" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="640" height="480" fill="url(#bg2)"/>
<rect width="640" height="480" fill="url(#glow2)"/>
<!-- white-backed card -->
<g transform="translate(170 70) rotate(5)">
<rect width="300" height="420" rx="18" fill="#f4f1e8" stroke="#3e2f4a" stroke-width="2"/>
<rect x="30" y="30" width="240" height="360" rx="10" fill="none" stroke="#d2c6a2" stroke-width="2" stroke-dasharray="6 6"/>
<text x="150" y="210" text-anchor="middle" font-family="sans-serif" font-size="34" font-weight="700" fill="#1b1730">PROXY</text>
<text x="150" y="248" text-anchor="middle" font-family="monospace" font-size="13" fill="#4a445e" letter-spacing="2">NOT TOURNAMENT LEGAL</text>
</g>
<text x="320" y="450" text-anchor="middle" font-family="monospace" font-size="16" fill="#c4ab8e" letter-spacing="2">PLACEHOLDER &#183; SWAP IN /images/store-products.ts</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Some files were not shown because too many files have changed in this diff Show More