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
+177
View File
@@ -0,0 +1,177 @@
---
/**
* about.astro — About Me
* ---------------------------------------------------------------------------
* The human story bridging the professional and personal sides. Three sections
* from the spec, all copy verbatim: the short version, the longer arc, and the
* quick facts (spec emojis mapped to mdi icons). Ends with the SplitTransition
* fork into Professional / Interests.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import SplitTransition from "@components/layout/SplitTransition.astro";
import CelestialImage from "@components/mystic/CelestialImage.astro";
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
// Spec "Quick Facts", emojis -> mdi icons.
const quickFacts = [
{ icon: "map-marker", text: "Philadelphia, PA" },
{
icon: "briefcase",
text: "BTP, Cloud, and Mobile App Lead at Implico Inc.",
},
{ icon: "dog", text: "Dog dad to Kashi the mini dachshund" },
{ icon: "dice-multiple", text: "Running D&D & Daggerheart campaigns" },
{
icon: "book-open-variant",
text: "Currently tandem reading: Heir of Fire and Tower of Dawn by Sarah J. Maas",
},
{ icon: "tea", text: "Fueled by loose-leaf Chinese tea and Mountain Dew" },
];
---
<BaseLayout
title="About"
description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games."
>
<!-- Hero — full animated Milky Way field, matching the Interests hero. -->
<section class="on-sky relative overflow-hidden pb-8 pt-28 sm:pt-32">
<AnimatedBackground client:idle className="vt-sky" />
<div class="container-prose relative z-10 flex flex-col gap-8 py-8">
<Reveal>
<SectionHeader
tone="hero"
kicker="About"
icon="account-details"
title="The person behind the projects"
subtitle="Two wings of the same house. This page is the bridge between them."
/>
</Reveal>
</div>
</section>
<!-- Section 1: The Short Version -->
<section class="container-prose flex flex-col gap-8 py-10">
<Reveal>
<p
class="border-l-2 pl-5 font-sans text-xl leading-relaxed sm:text-2xl"
style="border-color: var(--color-gold); color: var(--color-text);"
>
I am Greg: a software developer and technical team lead based in
Philadelphia who spends his free time running tabletop RPGs, making
resin dice and custom cards, 3D printing anything I can, 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.
</p>
</Reveal>
</section>
<!-- Section 2: The Longer Arc -->
<section class="container-prose grid gap-10 py-10 lg:grid-cols-[1fr_18rem]">
<Reveal class="flex flex-col gap-5">
<h2>The longer arc</h2>
<div
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<p>
I grew up loving 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 reads. I am currently expanding into SAP's Business
Technology Platform, exploring AI and machine learning, and trying to
convince my group that yes, we
<em> can</em> fit one more board game into game night.
</p>
<p>
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.
</p>
</div>
<br />
<Reveal delay={80}>
<div class="grid gap-3 sm:grid-cols-2">
<CelestialImage
src="/images/kashi_at_work.jpg"
alt="Kashi, a mini dachshund, at work with his dog dad"
aspect="4/3"
/>
<CelestialImage
src="/images/tea-gaiwan.jpg"
alt="A tea gaiwan set on a mushroom towel"
aspect="4/3"
/>
</div></Reveal
>
</Reveal>
<!-- Portrait + quick facts sidebar -->
<div class="flex flex-col gap-6">
<Reveal delay={60}>
<CelestialImage
src="/images/about-portrait.jpg"
alt="Greg, a candid portrait"
aspect="4/5"
/>
</Reveal>
<Reveal delay={120}>
<aside class="card flex flex-col gap-4 lg:sticky lg:top-24">
<h3
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
style="color: var(--color-accent);"
>
Quick facts
</h3>
<ul class="flex flex-col gap-3">
{
quickFacts.map((f) => (
<li
class="flex items-start gap-3 text-sm"
style="color: var(--color-text);"
>
<Icon
name={f.icon}
class="mt-0.5 h-5 w-5 shrink-0"
style="color: var(--color-accent);"
/>
<span>{f.text}</span>
</li>
))
}
</ul>
</aside>
</Reveal>
</div>
</section>
<Reveal>
<div class="container-prose">
<AlchemicalDivider
class="mx-auto h-6 w-full max-w-sm"
style="color: var(--color-gold);"
/>
</div>
</Reveal>
<!-- Section 3: The fork -->
<section class="container-prose flex flex-col gap-6 py-16">
<Reveal>
<SectionHeader
kicker="Both doors open here"
icon="call-split"
title="Where curiosity goes next"
subtitle="The professional and personal sides are connected, not siloed. Pick a door."
/>
</Reveal>
<Reveal delay={100}>
<SplitTransition />
</Reveal>
</section>
</BaseLayout>
+218
View File
@@ -0,0 +1,218 @@
/**
* /api/contact
* ---------------------------------------------------------------------------
* POST endpoint that receives the contact form. Pipeline:
* 1. Reject non-POST.
* 2. Validate JSON body with Zod (name, email, message; honeypot must be empty).
* 3. Rate-limit per IP (in-memory windowed counter).
* 4. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
* 5. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
* configured, else return 503 so delivery can be wired later.
* 6. Always return JSON { ok } or { error }.
*
* Secrets come from env (see .env.example). Nothing secret ships to the client.
* This runs server-side only via the @astrojs/node adapter.
*/
import type { APIRoute } from 'astro';
import { z } from 'zod/v4';
import {
contactTo,
contactWebhookUrl,
env,
smtpFrom,
smtpHost,
smtpPass,
smtpPort,
smtpUser,
turnstileSecretKey,
} from '@lib/env';
// --- Validation schema ------------------------------------------------------
const schema = z.object({
name: z.string().min(1).max(120),
email: z.string().email().max(200),
message: z.string().min(10).max(5000),
// Honeypot: must be absent/empty. Bots tend to fill every field.
company: z.string().max(0).optional().or(z.literal('')),
// Turnstile token. Required when TURNSTILE_SECRET_KEY is set; ignored
// otherwise (dev). Validated against Cloudflare's siteverify endpoint below.
turnstileToken: z.string().optional(),
});
// --- In-memory rate limiter (per worker) ------------------------------------
// Good enough for a personal site behind a reverse proxy. For multi-instance,
// swap this for Redis/Upstash. Window = N submissions per IP per X minutes.
// Tunables are read per-request via the env helper so they resolve in both dev
// (import.meta.env) and Docker (process.env).
const hits = new Map<string, { count: number; reset: number }>();
function rateLimited(ip: string): boolean {
const rateWindowMin = Number(env('CONTACT_RATE_WINDOW_MIN') ?? 15);
const rateLimit = Number(env('CONTACT_RATE_LIMIT') ?? 5);
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now > entry.reset) {
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
return false;
}
entry.count += 1;
return entry.count > rateLimit;
}
// --- Helpers ----------------------------------------------------------------
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function clientIp(request: Request): string {
// Cosmos/Traefik typically forwards the real client IP here.
const fwd = request.headers.get('x-forwarded-for');
if (fwd) return fwd.split(',')[0]!.trim();
return request.headers.get('x-real-ip') ?? 'unknown';
}
// --- Turnstile verification -------------------------------------------------
// Skipped entirely when TURNSTILE_SECRET_KEY is unset (local dev). When set,
// the token from the client is POSTed to Cloudflare's siteverify endpoint
// along with the visitor's IP for fraud scoring. Only valid tokens proceed.
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
const secret = turnstileSecretKey();
if (!secret) return true; // Turnstile not configured -> skip.
if (!token) return false;
try {
const form = new URLSearchParams();
form.set('secret', secret);
form.set('response', token);
if (ip && ip !== 'unknown') form.set('remoteip', ip);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: form,
});
const data = (await res.json()) as { success: boolean };
return Boolean(data.success);
} catch (err) {
// Network error talking to Cloudflare. Fail closed: reject the submission
// rather than letting a bot through on a transient outage.
console.error('[contact] turnstile verification failed:', err);
return false;
}
}
// --- Route handler ----------------------------------------------------------
export const POST: APIRoute = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed.' }, 405);
}
// Parse + validate.
let payload: unknown;
try {
payload = await request.json();
} catch {
return json({ error: 'Invalid JSON body.' }, 400);
}
const parsed = schema.safeParse(payload);
if (!parsed.success) {
return json({ error: 'Please complete all fields correctly.', issues: parsed.error.issues }, 400);
}
const { name, email, message, turnstileToken } = parsed.data;
// Rate limit.
const ip = clientIp(request);
if (rateLimited(ip)) {
return json({ error: 'Too many submissions. Please try again later.' }, 429);
}
// Turnstile. Skipped when no secret is configured (dev). Fails closed on
// network errors so a Cloudflare outage can't open the door to bots.
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
if (!turnstileOk) {
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
}
// Deliver.
try {
const delivered = await deliver({ name, email, message });
if (!delivered) {
// No SMTP or webhook configured. Tell the client clearly without leaking
// server internals, but return 503 so it is visibly a config gap.
return json({ error: 'Contact delivery is not configured yet. Please email me directly.' }, 503);
}
return json({ ok: true }, 200);
} catch (err) {
console.error('[contact] delivery failed:', err);
return json({ error: 'Could not send your message right now. Please try again later.' }, 502);
}
};
// --- Delivery: SMTP first, webhook fallback ---------------------------------
interface Message {
name: string;
email: string;
message: string;
}
async function deliver(msg: Message): Promise<boolean> {
const host = smtpHost();
const webhook = contactWebhookUrl();
if (host) {
// Dynamic import keeps nodemailer out of cold-start when unused.
const nodemailer = (await import('nodemailer')).default;
const port = Number(smtpPort() ?? 587);
const user = smtpUser();
// Accept self-signed certificates when explicitly opted in. Intended for
// internal homelab hops where the SMTP relay (e.g. Stalwart on the same
// Docker network) presents a self-signed cert and the connection never
// traverses an untrusted network. Defaults to false so production-grade
// TLS verification is the default; the override is a deliberate choice.
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user ? { user, pass: smtpPass() } : undefined,
...(allowSelfSigned
? { tls: { rejectUnauthorized: false } }
: {}),
});
const hostname = (() => {
try {
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
} catch {
return 'gregkilmer.com';
}
})();
await transport.sendMail({
from: smtpFrom() ?? `Greg's Site <no-reply@${hostname}>`,
to: contactTo() ?? 'greg@gregkilmer.com',
replyTo: `${msg.name} <${msg.email}>`,
subject: `New message from ${msg.name} via gregkilmer.com`,
text: `Name: ${msg.name}\nEmail: ${msg.email}\n\n${msg.message}`,
});
return true;
}
if (webhook) {
const res = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New contact from ${msg.name} (${msg.email}):\n\n${msg.message}`,
// Structured fields for webhook consumers (Discord/Slack/n8n).
name: msg.name,
email: msg.email,
message: msg.message,
}),
});
return res.ok;
}
return false;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* /api/order
* ---------------------------------------------------------------------------
* POST endpoint for store order requests. Pipeline mirrors /api/contact:
* 1. Reject non-POST.
* 2. HARD GATE: read store-availability.json; if ordersOpen is false, return
* 403 immediately. This enforces the lock server-side so it can't be
* bypassed by POSTing directly while the client form is hidden.
* 3. Validate JSON body with Zod.
* 4. Rate-limit per IP (in-memory windowed counter).
* 5. Verify Cloudflare Turnstile token (if TURNSTILE_SECRET_KEY is set).
* 6. Deliver via SMTP (nodemailer) if configured, else webhook, else 503.
*
* This collects an ORDER REQUEST only — no payment is processed. The payload is
* emailed to the configured inbox (ORDER_TO, falling back to CONTACT_TO).
*/
import type { APIRoute } from 'astro';
import { z } from 'zod/v4';
import {
contactWebhookUrl,
env,
orderTo,
smtpFrom,
smtpHost,
smtpPass,
smtpPort,
smtpUser,
turnstileSecretKey,
} from '@lib/env';
import { storeAvailability } from '@data/store-availability';
// --- Validation schema ------------------------------------------------------
const orderTypeEnum = z.enum(['cards', 'deckBoxes', 'both']);
const schema = z.object({
name: z.string().min(1).max(120),
email: z.string().email().max(200),
orderType: orderTypeEnum.default('cards'),
// Filament color for printed items. Optional (only for deckBoxes/both).
color: z.string().max(120).optional().or(z.literal('')),
quantity: z.string().min(1).max(200),
details: z.string().min(10).max(20000),
notes: z.string().max(5000).optional().or(z.literal('')),
// Policy acknowledgment checkbox must be ticked.
agree: z.literal(true),
// Honeypot: must be empty.
company: z.string().max(0).optional().or(z.literal('')),
// Turnstile token (required when TURNSTILE_SECRET_KEY is set).
turnstileToken: z.string().optional(),
});
// Human-readable label for the order type, used in the email subject/body.
const orderTypeLabel: Record<string, string> = {
cards: 'Proxy MTG cards',
deckBoxes: '3D-printed deck box / counters',
both: 'Cards + 3D prints',
};
// --- In-memory rate limiter (per worker) ------------------------------------
// Separate counter namespace from /api/contact so order submits don't share
// the contact budget. Tunables read via env helper for dev + Docker parity.
const hits = new Map<string, { count: number; reset: number }>();
function rateLimited(ip: string): boolean {
const rateWindowMin = Number(env('ORDER_RATE_WINDOW_MIN') ?? env('CONTACT_RATE_WINDOW_MIN') ?? 15);
const rateLimit = Number(env('ORDER_RATE_LIMIT') ?? env('CONTACT_RATE_LIMIT') ?? 5);
const now = Date.now();
const entry = hits.get(ip);
if (!entry || now > entry.reset) {
hits.set(ip, { count: 1, reset: now + rateWindowMin * 60_000 });
return false;
}
entry.count += 1;
return entry.count > rateLimit;
}
// --- Helpers ----------------------------------------------------------------
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function clientIp(request: Request): string {
const fwd = request.headers.get('x-forwarded-for');
if (fwd) return fwd.split(',')[0]!.trim();
return request.headers.get('x-real-ip') ?? 'unknown';
}
// --- Turnstile verification (same logic as /api/contact) --------------------
async function verifyTurnstile(token: string | undefined, ip: string): Promise<boolean> {
const secret = turnstileSecretKey();
if (!secret) return true; // Turnstile not configured -> skip.
if (!token) return false;
try {
const form = new URLSearchParams();
form.set('secret', secret);
form.set('response', token);
if (ip && ip !== 'unknown') form.set('remoteip', ip);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: form,
});
const data = (await res.json()) as { success: boolean };
return Boolean(data.success);
} catch (err) {
console.error('[order] turnstile verification failed:', err);
return false;
}
}
// --- Route handler ----------------------------------------------------------
export const POST: APIRoute = async ({ request }) => {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed.' }, 405);
}
// HARD GATE: orders closed -> reject before any other work. Re-reading the
// JSON module here reflects whatever Greg committed to the file at deploy.
if (!storeAvailability.ordersOpen) {
return json({ error: 'Orders are currently closed. Please check back soon.' }, 403);
}
// Parse + validate.
let payload: unknown;
try {
payload = await request.json();
} catch {
return json({ error: 'Invalid JSON body.' }, 400);
}
const parsed = schema.safeParse(payload);
if (!parsed.success) {
return json({ error: 'Please complete all fields correctly, including the policy acknowledgment.', issues: parsed.error.issues }, 400);
}
const { name, email, orderType, color, quantity, details, notes, turnstileToken } = parsed.data;
// Rate limit.
const ip = clientIp(request);
if (rateLimited(ip)) {
return json({ error: 'Too many submissions. Please try again later.' }, 429);
}
// Turnstile.
const turnstileOk = await verifyTurnstile(turnstileToken, ip);
if (!turnstileOk) {
return json({ error: 'Bot check failed. Please refresh and try again.' }, 400);
}
// Deliver.
try {
const delivered = await deliver({ name, email, orderType, color: color ?? '', quantity, details, notes: notes ?? '' });
if (!delivered) {
return json({ error: 'Order delivery is not configured yet. Please email me directly.' }, 503);
}
return json({ ok: true }, 200);
} catch (err) {
console.error('[order] delivery failed:', err);
return json({ error: 'Could not send your order right now. Please try again later.' }, 502);
}
};
// --- Delivery: SMTP first, webhook fallback ---------------------------------
interface OrderMessage {
name: string;
email: string;
orderType: string;
color: string;
quantity: string;
details: string;
notes: string;
}
async function deliver(msg: OrderMessage): Promise<boolean> {
const host = smtpHost();
const webhook = contactWebhookUrl();
const typeLabel = orderTypeLabel[msg.orderType] ?? msg.orderType;
// Plain-text body for the email.
const text = [
`New store order request`,
``,
`Name: ${msg.name}`,
`Email: ${msg.email}`,
`Order type: ${typeLabel}`,
`Color: ${msg.color || '(not specified)'}`,
`Approx quantity: ${msg.quantity}`,
``,
`Details:`,
msg.details,
``,
msg.notes ? `Notes:\n${msg.notes}` : '',
].join('\n');
if (host) {
const nodemailer = (await import('nodemailer')).default;
const port = Number(smtpPort() ?? 587);
const user = smtpUser();
const allowSelfSigned = env('SMTP_ALLOW_SELFSIGNED') === 'true';
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user ? { user, pass: smtpPass() } : undefined,
...(allowSelfSigned ? { tls: { rejectUnauthorized: false } } : {}),
});
const hostname = (() => {
try {
return new URL(process.env.SITE_URL ?? 'https://gregkilmer.com').hostname;
} catch {
return 'gregkilmer.com';
}
})();
await transport.sendMail({
from: smtpFrom() ?? `Greg's Store <no-reply@${hostname}>`,
to: orderTo() ?? 'greg@gregkilmer.com',
replyTo: `${msg.name} <${msg.email}>`,
subject: `New store order — ${typeLabel} (${msg.name})`,
text,
});
return true;
}
if (webhook) {
const res = await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New store order from ${msg.name} (${msg.email}) — ${typeLabel}`,
name: msg.name,
email: msg.email,
orderType: typeLabel,
color: msg.color || undefined,
quantity: msg.quantity,
details: msg.details,
notes: msg.notes,
}),
});
return res.ok;
}
return false;
}
+251
View File
@@ -0,0 +1,251 @@
---
/**
* contact.astro — Contact / Connect
* ---------------------------------------------------------------------------
* A single place for all ways to reach Greg, professional and social, per the
* spec's two-column layout. Also hosts the contact form (a Node backend is
* available, so this is the natural place to use it). Closes with the spec's
* warm sign-off line.
*
* Spec items Greg still needs to fill (email, Discord, etc.) appear as
* Placeholder slots.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import PageSeal from "@components/brand/PageSeal.astro";
import Reveal from "@components/motion/Reveal.astro";
import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import ContactForm from "@components/professional/ContactForm.tsx";
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
import { turnstileSiteKey as getTurnstileSiteKey } from "@lib/env";
// Email is rendered separately via <ObfuscatedEmail> so the plaintext address
// never appears in the HTML source. The other entries are plain links.
const emailAddress = "contact@gregkilmer.com";
const professional = [
{
icon: "linkedin",
label: "LinkedIn",
href: "https://www.linkedin.com/in/gregory-kilmer-36646bb5/",
hint: "Gregory Kilmer",
},
{
icon: "github",
label: "GitHub",
href: "https://github.com/patiun",
hint: "patiun",
},
];
const social = [
{ icon: "discord", label: "Discord", hint: "Handle or invite link." },
{
icon: "twitter",
label: "Twitter / X",
hint: "Profile link, if applicable.",
},
{
icon: "paw",
label: "Pokémon",
hint: "Showdown or Trainer name, if shared.",
},
{
icon: "cube-outline",
label: "Foundry VTT",
hint: "Server link, if public.",
},
];
// Turnstile site key. Public per Cloudflare (safe to ship to the client).
// Read via the env helper so it resolves in BOTH `astro dev` (Vite loads `.env`
// into import.meta.env) AND the Docker runtime (env_file sets process.env).
// See src/lib/env.ts for why neither source alone covers both runtimes.
// Undefined when Turnstile is not configured, which makes ContactForm skip the
// widget entirely.
const turnstileSiteKey = getTurnstileSiteKey();
---
<BaseLayout
title="Contact"
description="Reach Greg Kilmer for cloud development, SAP BTP, technical leadership, or just to debate the best Gundam series or find a fourth for game night."
noindex={true}
>
<PageSeal name="zodiac-seal-light" />
<section class="container-prose flex flex-col gap-10 pt-28 sm:pt-32">
<Reveal>
<SectionHeader
kicker="Contact"
icon="email"
title="Let's connect"
subtitle="Whether you want to talk cloud architecture, debate the best Gundam series, or need a fourth for game night, reach out."
/>
</Reveal>
</section>
<div class="container-prose grid gap-8 py-12 lg:grid-cols-[1fr_1.1fr]">
{/* Left: contact methods */}
<div class="flex flex-col gap-10">
{/* Professional */}
<Reveal>
<section class="flex flex-col gap-4">
<h2
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
style="color: var(--color-accent);"
>
Professional
</h2>
<ul class="flex flex-col gap-3">
{
/* Email: rendered via ObfuscatedEmail so the address is not in
the HTML source. JS decodes it into a mailto: on click. */
}
<li class="card flex items-center gap-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name="email-outline" class="h-5 w-5" />
</span>
<span class="flex flex-col gap-0.5">
<span
class="font-sans text-sm font-semibold"
style="color: var(--color-text);"
>
Email
</span>
<span class="text-xs" style="color: var(--color-text-subtle);">
<ObfuscatedEmail
address={emailAddress}
class="font-eyebrow uppercase tracking-[0.1em]"
/>
</span>
</span>
</li>
{
professional.map((item) => (
<li class="card flex items-center gap-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={item.icon} class="h-5 w-5" />
</span>
<span class="flex flex-col gap-0.5">
<span
class="font-sans text-sm font-semibold"
style="color: var(--color-text);"
>
{item.label}
</span>
<span
class="text-xs"
style="color: var(--color-text-subtle);"
>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
>
{item.hint}
</a>
</span>
</span>
</li>
))
}
</ul>
</section>
</Reveal>
{
/* Social / personal
<Reveal delay={80}>
<section class="flex flex-col gap-4">
<h2
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
style="color: var(--color-secondary);"
>
Personal / Social
</h2>
<ul class="grid gap-3 sm:grid-cols-2">
{
social.map((item) => (
<li class="card flex items-center gap-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);"
aria-hidden="true"
>
<Icon name={item.icon} class="h-5 w-5" />
</span>
<span class="flex flex-col gap-0.5">
<span
class="font-sans text-sm font-semibold"
style="color: var(--color-text);"
>
{item.label}
</span>
<span
class="text-xs"
style="color: var(--color-text-subtle);"
>
{item.hint}
</span>
</span>
</li>
))
}
</ul>
</section>
</Reveal>
*/
}
{
/* Availability note
<Reveal delay={120}>
<p
class="rounded-xl border-l-2 p-4 text-sm"
style="border-color: var(--color-gold); background: color-mix(in srgb, var(--color-accent) 8%, transparent); color: var(--color-text-muted);"
>
<strong style="color: var(--color-text);">Availability:</strong> Open to
opportunities in AI/ML, app and cloud development, SAP BTP, and technical
leadership.
</p>
</Reveal>*/
}
</div>
{/* Right: contact form */}
<Reveal delay={100}>
<section class="card flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h2 class="font-display" style="color: var(--color-text);">
Send a message
</h2>
<p class="text-sm" style="color: var(--color-text-muted);">
Prefer a form? Drop a note and it will land in my inbox.
</p>
</div>
<ContactForm client:idle turnstileSiteKey={turnstileSiteKey} />
</section>
</Reveal>
</div>
{/* Closing note */}
<section class="container-prose py-12">
<Reveal>
<blockquote
class="mx-auto max-w-2xl border-l-2 pl-5 text-center font-sans text-lg italic"
style="border-color: var(--color-gold); color: var(--color-text-muted);"
>
Whether you want to talk cloud architecture, debate the best Gundam
series, or need a fourth for game night, reach out.
</blockquote>
</Reveal>
</section>
</BaseLayout>
+247
View File
@@ -0,0 +1,247 @@
---
/**
* index.astro — Home (Landing)
* ---------------------------------------------------------------------------
* First impression page. Full-width hero with the animated background, the spec
* headline/subheadline/intro paragraph, and the two CTA buttons. Below the fold
* is the 3-column snapshot grid (Professional, Tabletop, Making) plus the wider
* Reading & Media strip. All copy is verbatim from the spec.
*
* Emojis from the spec table are rendered as mdi icons (no emojis per spec).
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import CelestialImage from "@components/mystic/CelestialImage.astro";
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import Motif from "@components/brand/Motif.astro";
import ConstellationField from "@components/mystic/ConstellationField.tsx";
// Snapshot grid content from the spec, with spec emojis mapped to mdi icons.
// Each card now carries a vibe-matched stock image (swap for real photos later).
const cards = [
{
icon: "briefcase",
title: "Professional",
copy: "From SAP-adjacent cloud to full-stack mobile, a decade of building software that businesses actually use.",
href: "/professional",
image: "/images/pro-workspace.jpg",
alt: "Developer workspace with code on screen",
},
{
icon: "dice-multiple",
title: "Tabletop",
copy: "Currently obsessed with Spirit Island, Magic: The Gathering, and running campaigns in Foundry VTT. Always planning more; always rolling dice.",
href: "/interests/ttrpgs",
image: "/images/ttrpg-dice.jpg",
alt: "Polyhedral dice on a dark surface",
},
{
icon: "palette",
title: "Making",
copy: "Resin dice, 3D prints, TCG proxies, and whatever else catches my eye. If it involves really nice cardboard or mastering a new craft, I am in.",
href: "/interests/making",
image: "/images/making-resin.jpg",
alt: "Resin crafting in progress",
},
];
---
<BaseLayout>
<!-- ===================== HERO ===================== -->
<section
class="on-sky vt-hero-sky relative flex min-h-[88vh] items-center overflow-hidden pt-20"
>
<AnimatedBackground client:idle className="vt-sky" />
<div
class="container-prose relative z-10 flex flex-col items-start gap-8 py-20"
>
<div class="flex w-full flex-col gap-10 lg:flex-row lg:items-center">
<Reveal as="div" class="max-w-2xl grow">
<span
class="mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 font-eyebrow text-xs uppercase tracking-[0.18em]"
style="border-color: color-mix(in srgb, var(--color-gold) 40%, transparent); color: var(--color-gold-bright); background: color-mix(in srgb, var(--color-midnight) 75%, transparent);"
>
<span
class="h-2 w-2 rounded-full animate-pulse-glow"
style="background: var(--color-accent);"></span>
Developer. Maker. Storyteller.
</span>
<h1 class="text-balance" style="color: var(--text);">Greg Kilmer</h1>
<p
class="mt-3 font-sans text-xl font-medium sm:text-2xl"
style="color: var(--accent);"
>
Software Developer &middot; Maker &middot; Tabletop Gamemaster
</p>
<blockquote
class="mt-7 max-w-2xl border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-gold); color: var(--text-muted);"
>
I build things with code during the day and with resin, plastic, and
imagination at night. I lead cloud and mobile development at Implico
Inc., run D&D campaigns that span years, and fill my shelves with
Brandon Sanderson novels, custom dice, and board games. This is the
place where all of those worlds meet.
</blockquote>
<div class="mt-9 flex flex-wrap gap-4">
<a href="/professional" class="btn btn-primary">
See My Work
<Icon name="arrow-right" class="h-5 w-5" />
</a>
<a href="/interests" class="btn btn-secondary">
Explore My Interests
<Icon name="compass-outline" class="h-5 w-5" />
</a>
</div>
</Reveal>
{/* Hero portrait — framed in celestial ornament. */}
<Reveal as="div" delay={120} class="w-full max-w-sm shrink-0 lg:w-80">
<CelestialImage
src="/images/hero-portrait.jpg"
alt="Greg at his desk"
aspect="4/5"
/>
</Reveal>
</div>
</div>
<!-- Scroll cue -->
<div
class="pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2 animate-float"
style="color: var(--color-text-subtle);"
aria-hidden="true"
>
<Icon name="chevron-down" class="h-7 w-7" />
</div>
</section>
<!-- ===================== SNAPSHOT GRID ===================== -->
<section class="relative overflow-hidden py-20 sm:py-28">
{
/* Slow-rotating constellation field behind the section — a slice of sky. */
}
<ConstellationField client:idle className="opacity-60" />
{/* Faint transmutation seal behind the section (Starlight recolor). */}
<Motif
name="transmutation-seal-light"
class="absolute -right-40 top-10 z-0 h-[40rem] w-[40rem]"
style="opacity:.13;"
/>
<div class="container-prose relative z-10 flex flex-col gap-12">
<Reveal>
<SectionHeader
kicker="The two wings"
icon="home-heart"
title="A snapshot"
subtitle="One house, two wings. Polished and precise on the professional side; warm and inviting on the personal side."
/>
</Reveal>
<div class="grid gap-5 md:grid-cols-3">
{
cards.map((card, i) => (
<Reveal delay={i * 90}>
<a
href={card.href}
class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0"
>
<div class="aspect-[16/10] w-full overflow-hidden">
<img
src={card.image}
alt={card.alt}
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<div class="flex flex-col gap-4 px-5 pb-5">
<span
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={card.icon} class="h-6 w-6" />
</span>
<h3 class="font-display" style="color: var(--color-text);">
{card.title}
</h3>
<p
class="grow text-sm leading-relaxed"
style="color: var(--color-text-muted);"
>
{card.copy}
</p>
<span
class="mt-1 inline-flex items-center gap-1 font-sans text-sm font-semibold transition-transform group-hover:translate-x-1"
style="color: var(--color-accent);"
>
Learn more
<Icon name="arrow-right" class="h-4 w-4" />
</span>
</div>
</a>
</Reveal>
))
}
</div>
<Reveal>
<AlchemicalDivider
class="mx-auto h-6 w-full max-w-sm"
style="color: var(--color-gold);"
/>
</Reveal>
{/* Wider strip: Reading & Media */}
<Reveal delay={120}>
<a
href="/interests/reading"
class="card card-interactive group flex flex-col gap-4 overflow-hidden p-0 sm:flex-row sm:items-center sm:justify-between"
>
<span class="flex items-center gap-4">
<span
class="hidden aspect-square w-32 shrink-0 overflow-hidden sm:block"
>
<img
src="/images/fantasy-books.jpg"
alt="Fantasy bookshelf"
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
/>
</span>
<span class="flex flex-col gap-1 px-5 py-4 sm:px-0 sm:py-0">
<span
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-text-subtle);"
>
Reading &amp; Media
</span>
<span
class="font-sans text-lg font-semibold"
style="color: var(--color-text);"
>
Deep in the Cosmere, catching up on anime, and always three
books ahead on my to-read list.
</span>
</span>
</span>
<Icon
name="arrow-right"
class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1 sm:ml-4"
style="color: var(--color-accent);"
/>
</a>
</Reveal>
</div>
</section>
</BaseLayout>
+118
View File
@@ -0,0 +1,118 @@
---
/**
* anime.astro — Anime
* ---------------------------------------------------------------------------
* The primary pillar: 150+ completed series. Favorites render as poster cards
* up top (populated by scripts/fetch-anime-art.mjs); the full library is
* grouped by category below. Follows the canonical interest-page template.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import MediaCard from "@components/cards/MediaCard.astro";
import CategoryList from "@components/media/CategoryList.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import { getFavorites, getGrouped, mediaKinds, getCount } from "@lib/media";
const meta = mediaKinds.anime;
const favorites = getFavorites("anime");
const groups = getGrouped("anime");
const count = getCount("anime");
const related = [
{
href: "/interests/tv",
title: "TV Shows",
icon: "television-classic",
blurb: "When I step away from animation, the live-action backlog is waiting.",
},
{
href: "/interests/reading",
title: "Reading",
icon: "book-open-page-variant",
blurb: "Half of these have light novels and manga I'm reading too.",
},
];
---
<BaseLayout
surface="interest"
title={meta.title}
description={`${count}+ completed anime series. Shonen, isekai, dark psychological. The primary pillar of my watchlist, not a side shelf.`}
>
<InterestHero
icon={meta.icon}
title={meta.title}
tagline={meta.tagline}
image={meta.heroImage}
imageAlt={meta.heroImageAlt}
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal><p>{meta.overview}</p></Reveal>
</section>
{/* Favorites */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="The rewatch pile"
icon="star"
title="Favorites"
/></Reveal
>
{
favorites.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{favorites.map((item, i) => (
<Reveal delay={i * 70}>
<MediaCard
kind="anime"
title={item.title}
image={item.image}
status={item.status}
blurb={item.blurb}
/>
</Reveal>
))}
</div>
) : (
<Placeholder
label="Anime favorites"
hint='Mark items with "favorite": true in src/data/media/anime.json.'
/>
)
}
</section>
{/* Full library */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker={`${count}+ and counting`}
icon="format-list-bulleted"
title="The full watch history"
/></Reveal
>
<Reveal delay={80}>
{groups.length > 0 ? (
<CategoryList groups={groups} pillIcon="play-circle" />
) : (
<Placeholder
label="Anime library"
hint="Add categorized items to src/data/media/anime.json."
/>
)}
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+281
View File
@@ -0,0 +1,281 @@
---
/**
* gaming.astro — Gaming
* ---------------------------------------------------------------------------
* Per-interest template: header + tagline, verbatim overview, platforms card
* grid, favorite genres, currently playing placeholder, and related interests.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import InterestCard from "@components/cards/InterestCard.astro";
import GameCard from "@components/cards/GameCard.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import Icon from "@components/Icon.astro";
import { getCollection, getEntry } from "astro:content";
import { platformToIcon } from "@lib/platforms";
const platforms = [
{
icon: "sony-playstation",
title: "PlayStation 5",
body: "Mainly for single player and exclusives.",
},
{
icon: "nintendo-switch",
title: "Nintendo Switch / Switch 2",
body: "Handheld and docked. Pokemon, Mario, and many other Nintendo and indie gems.",
},
{
icon: "steam",
title: "Steam",
body: "The primary game library. Strategy, RPGs, Adventure, oh my! And an ever-growing backlog.",
},
];
/**
* Favorite genres. Each is a small card with an mdi icon and a one-line blurb
* on what Greg loves about that genre. Page-local; edit freely.
*/
const genres = [
{
icon: "sword-cross",
name: "RPGs (Western & JRPG)",
blurb:
"Character progression and stories I can live in for 100+ hours and then turn around and do it again.",
},
{
icon: "pokeball",
name: "Creature Collecting",
blurb: "If I can catch, train, or befriend a roster, I'm sold.",
},
{
icon: "chess-king",
name: "Strategy & Tactics",
blurb:
"Turn-based puzzles and tactical challengeswhere the right plan is oh so rewarding.",
},
{
icon: "hammer",
name: "Building & Crafting",
blurb:
"Give me a grid, resources, and a goal, and watch the evening vanish.",
},
{
icon: "factory",
name: "Automation",
blurb: "Optimizing a production line until its perfect is my zen.",
},
{
icon: "compass-outline",
name: "Open-world Exploration",
blurb:
"The map IS the point. I will try to climb every mountain and I will check every waterfall for treasure.",
},
{
icon: "star-four-points",
name: "Indie Darlings",
blurb:
"The small, weird, passionate projects that punch far above their size.",
},
];
// Gaming Hall of Fame — one file per game in src/content/games/hall-of-fame/.
const hallOfFame = (await getCollection("games")).sort(
(a, b) => a.data.order - b.data.order,
);
// Currently playing — single list in src/content/games/now-playing.yaml.
const nowPlayingEntry = await getEntry("nowPlaying", "now-playing");
const nowPlaying = nowPlayingEntry?.data.games ?? [];
const related = [
/*{
href: "/interests/pokemon",
title: "Pokémon",
icon: "pokeball",
blurb: "Nintendo console overlap and a lifelong obsession.",
},*/
{
href: "/interests/ttrpgs",
title: "TTRPGs & Tabletop",
icon: "dice-multiple",
blurb: "Digital RPGs as another expression of the same impulse.",
},
];
---
<BaseLayout
surface="interest"
title="Gaming"
description="PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. RPGs, strategy, and deeply layered worlds."
>
<InterestHero
icon="gamepad-variant"
title="Gaming"
tagline="Walking in another's shoes, escaping reality, or obsessively completing collections, one game at a time."
image="/images/gaming-setup.jpg"
imageAlt="A gaming setup with controller and console"
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
Gaming has always been a passion in my life. I span console
generations from the Sega Genesis to the modern era. I play mainly
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 creature to tame or a deeply layered world
to explore. But a good game loop will get me sucked in harder than the
sunk cost fallacy of a League of Legends streak. I love to play with
friends, but have found myself leaning more towards single-player
experiences. Someone should have told younger me that scheduling would
be the final boss!
</p></Reveal
>
</section>
{/* Platforms */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Platforms"
icon="gamepad-variant"
title="Where I play"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-3">
{
platforms.map((p, i) => (
<Reveal delay={i * 80}>
<InterestCard icon={p.icon} title={p.title}>
{p.body}
</InterestCard>
</Reveal>
))
}
</div>
</section>
{/* Genres */}
<section class="flex flex-col gap-5">
<Reveal
><SectionHeader
kicker="Tastes"
icon="shape"
title="Favorite genres"
/></Reveal
>
<Reveal delay={80}>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{
genres.map((g, i) => (
<Reveal delay={i * 60}>
<article class="card card-interactive flex h-full flex-col gap-2 p-4">
<div class="flex items-center gap-2.5">
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={g.icon} class="h-5 w-5" />
</span>
<h3
class="font-display text-base"
style="color: var(--color-text);"
>
{g.name}
</h3>
</div>
<p
class="text-sm leading-relaxed"
style="color: var(--color-text-muted);"
>
{g.blurb}
</p>
</article>
</Reveal>
))
}
</div>
</Reveal>
</section>
{/* Currently */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Currently"
icon="calendar-clock"
title="Now playing"
/></Reveal
>
{
nowPlaying.length > 0 ? (
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{nowPlaying.map((g, i) => (
<Reveal delay={i * 60}>
<InterestCard
icon={platformToIcon(g.platform)}
title={g.title}
currently
>
{g.platform}
</InterestCard>
</Reveal>
))}
</div>
) : (
<Placeholder
label="Currently playing"
hint="Add games to src/content/games/now-playing.yaml."
/>
)
}
</section>
{/* Gaming Hall of Fame */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Hall of Fame"
icon="trophy"
title="All-time favorites"
/></Reveal
>
{
hallOfFame.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{hallOfFame.map((g, i) => (
<Reveal delay={i * 80}>
<GameCard
title={g.data.title}
platform={g.data.platform}
blurb={g.data.blurb}
art={g.data.art}
artAlt={g.data.artAlt}
year={g.data.year}
icon={g.data.icon}
/>
</Reveal>
))}
</div>
) : (
<Placeholder
label="Gaming Hall of Fame"
hint="Add games to src/content/games/hall-of-fame/."
/>
)
}
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+227
View File
@@ -0,0 +1,227 @@
---
/**
* interests/index.astro — Interests Hub
* ---------------------------------------------------------------------------
* The "fun wing" landing page. A warm grid of cards linking into each interest
* sub-page, using the spec's taglines. Sets surface="interest" so the whole
* page reads as the craft-side night sky regardless of the global light/dark toggle.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
import Motif from "@components/brand/Motif.astro";
import ConstellationField from "@components/mystic/ConstellationField.tsx";
const interests = [
{
href: "/interests/ttrpgs",
icon: "dice-multiple",
title: "TTRPGs & Tabletop",
tagline: "Have dice, will roll.",
blurb:
"Card Games, Board Games, and Long-running D&D and Daggerheart campaigns on Foundry VTT. Worldbuilding, NPC relationships, and player-driven narrative.",
image: "/images/ttrpg-dice.jpg",
alt: "Boardgame Shelf",
},
{
href: "/interests/gaming",
icon: "gamepad-variant",
title: "Gaming",
tagline: "We do not talk about my backlog.",
blurb:
"PlayStation 5, Nintendo Switch 2, and a politely out-of-hand Steam library. RPGs, strategy, and anything with a deeply layered world.",
image: "/images/gaming-setup.jpg",
alt: "Gaming setup desk",
},
{
href: "/interests/reading",
icon: "book-open-page-variant",
title: "Reading",
tagline: "Perpetually three books ahead of my to-read list.",
blurb:
"The Cosmere, Dune, the Pendergast saga, and Throne of Glass. Plus light novels, manga, manhwa, and webtoons.",
image: "/images/fantasy-books.jpg",
alt: "Fantasy bookshelf",
},
{
href: "/interests/anime",
icon: "animation-play",
title: "Anime",
tagline: "Subbed, dubbed, and somehow still behind.",
blurb:
"150+ completed series. The shonen classics, the modern hits, the isekai rabbit hole, and Frieren living rent-free in my head.",
image: "/images/anime-hero.jpg",
alt: "A cosplayer in a colorful anime-inspired costume at a convention",
},
{
href: "/interests/tv",
icon: "television-classic",
title: "TV Shows",
tagline: "My rewatch rotation has a rewatch rotation.",
blurb:
"200+ series across every prestige drama, NBC comedy, streaming original, and the full Marvel and Star Wars rosters.",
image: "/images/tv-hero.jpg",
alt: "A cozy living room with a couch facing a TV",
},
{
href: "/interests/movies",
icon: "movie-open",
title: "Movies",
tagline: "I stayed for the post-credits scene.",
blurb:
"160+ films across 17+ franchises. When I love a series I commit all the way, and The Princess Bride is always within reach.",
image: "/images/movies-hero.jpg",
alt: "Empty red seats in a vintage movie theater",
},
{
href: "/interests/making",
icon: "palette",
title: "Making & Creating",
tagline: "A magnet for super glue and nice cardboard.",
blurb:
"Resin dice in a pressure pot, fun and functional prints on the P1s, custom TCG proxies and decks. Passion and utility.",
image: "/images/making-resin.jpg",
alt: "Polyhedral dice on a dark bag",
},
{
href: "/interests/tcgs",
icon: "cards",
title: "Trading Card Games",
tagline:
"Cardboard, deckbuilding, and the eternal question: one more pack?",
blurb:
"Magic: The Gathering Commander decks, Pokémon collecting, Riftbound, and the Gundam card game. Plus the proxies and props I make to play them.",
image: "/images/cards.jpg",
alt: "Trading cards and dice on a playmat",
} /*
{
href: '/interests/pokemon',
icon: 'pokeball',
title: 'Pokémon',
tagline: 'Metagross supremacy. This is not up for debate.',
blurb: 'A lifelong obsession from Red and Blue to modern competitive. Steel/Psychic, beautiful design, absolutely devastating in battle.',
image: '/images/pokemon-cards.jpg',
alt: 'Trading card collection',
},*/,
];
---
<BaseLayout
surface="interest"
title="Interests"
description="The fun wing: tabletop RPGs, gaming, reading, resin dice and 3D printing, and Pokémon. Where Greg's passions live as stories and collections."
>
<!-- Hero -->
<section class="on-sky relative overflow-hidden pb-8 pt-28 sm:pt-32">
{
/* Full animated Milky Way field + stars, matching the Home hero treatment. */
}
<AnimatedBackground client:idle className="vt-sky" />
<div
class="container-prose relative z-10 flex flex-col items-start gap-6 py-8"
>
<Reveal>
<SectionHeader
tone="hero"
kicker="The personal wing"
icon="heart"
title="Interests"
subtitle="Warmer, more visual, personality-driven. Not a list of hobbies but a collection of stories."
/>
</Reveal>
</div>
</section>
<!-- Interest grid -->
<section class="relative overflow-hidden py-12 sm:py-16">
<ConstellationField client:idle className="opacity-45" tint="teal" />
<Motif
name="tria-prima-array-seal-light"
class="absolute -left-48 bottom-0 z-0 h-[38rem] w-[38rem]"
style="opacity:.12;"
/>
<div class="container-prose relative z-10">
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{
interests.map((item, i) => (
<Reveal delay={i * 80}>
<a
href={item.href}
class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0"
>
<div class="aspect-[16/10] w-full overflow-hidden">
<img
src={item.image}
alt={item.alt}
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<div class="flex flex-col gap-3 px-5 pb-5">
<div class="flex items-center justify-between">
<span
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={item.icon} class="h-6 w-6" />
</span>
<Icon
name="arrow-right"
class="h-5 w-5 transition-transform group-hover:translate-x-1"
style="color: var(--color-accent);"
/>
</div>
<div class="flex flex-col gap-2">
<h3 class="font-display" style="color: var(--color-text);">
{item.title}
</h3>
<p
class="font-sans text-sm italic"
style="color: var(--color-secondary);"
>
{item.tagline}
</p>
</div>
<p
class="grow text-sm leading-relaxed"
style="color: var(--color-text-muted);"
>
{item.blurb}
</p>
</div>
</a>
</Reveal>
))
}
{/* Cross-link back to professional wing */}
<Reveal delay={interests.length * 80}>
<a
href="/professional"
class="card card-interactive group flex h-full flex-col justify-center gap-2 border-dashed p-5 text-center"
style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);"
>
<Icon
name="briefcase"
class="mx-auto h-7 w-7"
style="color: var(--color-accent);"
/>
<p
class="font-sans font-semibold"
style="color: var(--color-text);"
>
The other wing
</p>
<p class="text-sm" style="color: var(--color-text-muted);">
See where curiosity meets the day job.
</p>
</a>
</Reveal>
</div>
</div>
</section>
</BaseLayout>
+240
View File
@@ -0,0 +1,240 @@
---
/**
* making.astro — Making & Creating
* ---------------------------------------------------------------------------
* Per-interest template with equipment card grid, a gallery placeholder, and the
* spec's connection-to-TTRPGs callout. Related links back to TTRPGs and Gaming.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import InterestCard from "@components/cards/InterestCard.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import Icon from "@components/Icon.astro";
import CelestialImage from "@components/mystic/CelestialImage.astro";
const equipment = [
{
icon: "dice-multiple",
title: "Resin Dice Making",
body: "Custom molds, pressure pot curing, pigment experimentation. Producing dice sets for personal use and gifts.",
},
{
icon: "printer-3d",
title: "Elegoo Mars 4",
body: "Resin 3D printer. Miniatures, terrain, and high-detail props.",
},
{
icon: "printer-3d-nozzle",
title: "Bambu Labs P1S",
body: "FDM 3D printer. Functional prints, terrain pieces, and larger prototypes.",
},
{
icon: "scissors-cutting",
title: "Silhouette Cameo 5a",
body: "Multi material cutting machine. Silly stickers, custom vinyl decals, and custom/proxy TCG cards.",
},
{
icon: "wrench",
title: "General Making",
body: "Always experimenting and hoarding nice cardboard. If there is a new technique or material, I want to try it.",
},
];
const related = [
{
href: "/interests/ttrpgs",
title: "TTRPGs & Tabletop",
icon: "dice-multiple",
blurb: "Much of what I make ends up on the virtual table.",
},
{
href: "/interests/reading",
title: "Reading & Media",
icon: "book-open-page-variant",
blurb: "Displays and props for the stories I love.",
},
{
href: "/interests/tcgs",
title: "Trading Card Games",
icon: "cards",
blurb: "Proxies, tokens, and props are the TCG side of the workshop.",
},
];
---
<BaseLayout
surface="interest"
title="Making & Creating"
description="Resin dice in a pressure pot, miniatures and terrain on the Elegoo Mars 4 and Bambu Labs P1S. If it involves layers and precision, I am in."
>
<InterestHero
icon="palette"
title="Making & Creating"
tagline="A magnet for super glue and nice cardboard."
image="/images/making-print.jpg"
imageAlt="A 3D printer producing a detailed model"
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
Making is part of me. Ever since I can remember, I had been dreaming
of making inventions and things from cardboard and clay. I took things
apart to see how they worked, and rarely got them back together. But
now I have the tools and the skills to take ideas from my imagination
and physically produce them. 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. I make beatiful cards as gifts and even make
proxy mockups of trading cards to enjoy games casually.
</p></Reveal
>
<Reveal>
<p>
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 hook that fits just right in that weird spot in
the bathroom. The ability to blend my technial skills and my creative
impulses into tangible objects is a fantasy come true my child self
only dreamed of.
</p></Reveal
>
</section>
{/* Equipment */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Equipment"
icon="toolbox"
title="What is on the bench"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2">
{
equipment.map((e, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={e.icon} title={e.title}>
{e.body}
</InterestCard>
</Reveal>
))
}
</div>
</section>
{/* Gallery */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Gallery"
icon="image-multiple"
title="Finished pieces"
/></Reveal
>
<Reveal delay={80}>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<CelestialImage
src="/images/making-resin.jpg"
alt="Resin dice set teal with gold leaf inclusions"
aspect="4/3"
/>
<CelestialImage
src="/images/dice_golden_moss.jpg"
alt="Resin dice set moss like pattern with gold lettering"
aspect="4/3"
/>
<CelestialImage
src="/images/dice_scarlet.jpg"
alt="Resin dice set scarlet with gold lettering"
aspect="4/3"
/>
<CelestialImage
src="/images/dice_galaxy.jpg"
alt="Resin dice set dark galaxy pattern with gold powder swirls"
aspect="4/3"
/>
<CelestialImage
src="/images/minis.jpg"
alt="3D printed miniature"
aspect="4/3"
/>
<CelestialImage
src="/images/deckboxes.jpg"
alt="Deckboxes (Design by
@Iceenchanted on MakerWorld)"
aspect="4/3"
/>
<CelestialImage
src="/images/proxies.jpg"
alt="Proxy cards for Kynaios and Tiro deck"
aspect="4/3"
/>
<CelestialImage
src="/images/proxies2.jpg"
alt="Successful Foil Proxies MTG"
aspect="4/3"
/>
<CelestialImage
src="/images/xmasgifts.jpg"
alt="CS:GO Cases, Nightblood, Runescape Helmet, ACOTAR drawers"
aspect="4/3"
/>
<CelestialImage
src="/images/resin_rings.jpg"
alt="Resin and painted rings"
aspect="4/3"
/>
</div>
</Reveal>
</section>
{/* Connection to TTRPGs */}
<section class="flex flex-col gap-5">
<Reveal
><SectionHeader
kicker="Cross-pollination"
icon="link-variant"
title="Where it all connects"
/></Reveal
>
<Reveal delay={80}>
<a
href="/interests/ttrpgs"
class="card card-interactive group flex items-center gap-4"
>
<Icon
name="dice-multiple"
class="h-8 w-8 shrink-0"
style="color: var(--color-accent);"
/>
<span class="flex flex-col gap-1">
<span
class="font-sans font-semibold"
style="color: var(--color-text);"
>Much of what I make ends up on the virtual table.</span
>
<span class="text-sm" style="color: var(--color-text-muted);"
>Custom tokens, terrain, and props for Foundry VTT campaigns.</span
>
</span>
<Icon
name="arrow-right"
class="ml-auto h-5 w-5 shrink-0 transition-transform group-hover:translate-x-1"
style="color: var(--color-accent);"
/>
</a>
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+119
View File
@@ -0,0 +1,119 @@
---
/**
* movies.astro — Movies
* ---------------------------------------------------------------------------
* 160+ films across 17+ franchises. Favorite films render as poster cards;
* the full watch history below lists every film grouped by franchise (plus a
* Standalone group for single films). Posters populated by
* scripts/fetch-tmdb-art.mjs.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import MediaCard from "@components/cards/MediaCard.astro";
import CategoryList from "@components/media/CategoryList.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import { getFavorites, getGrouped, mediaKinds, getCount } from "@lib/media";
const meta = mediaKinds.movies;
const favorites = getFavorites("movies");
const groups = getGrouped("movies");
const count = getCount("movies");
const related = [
{
href: "/interests/tv",
title: "TV Shows",
icon: "television-classic",
blurb: "The MCU and Star Wars sagas continue here.",
},
{
href: "/interests/anime",
title: "Anime",
icon: "animation-play",
blurb: "The animated half of my watchlist.",
},
];
---
<BaseLayout
surface="interest"
title={meta.title}
description={`${count}+ films across 17+ franchises. When I love a series I commit all the way, and The Princess Bride is always within reach.`}
>
<InterestHero
icon={meta.icon}
title={meta.title}
tagline={meta.tagline}
image={meta.heroImage}
imageAlt={meta.heroImageAlt}
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal><p>{meta.overview}</p></Reveal>
</section>
{/* Favorites */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Stop on these any time"
icon="star"
title="Favorites"
/></Reveal
>
{
favorites.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{favorites.map((item, i) => (
<Reveal delay={i * 70}>
<MediaCard
kind="movies"
title={item.title}
image={item.image}
year={item.year}
blurb={item.blurb}
/>
</Reveal>
))}
</div>
) : (
<Placeholder
label="Movie favorites"
hint='Mark items with "favorite": true in src/data/media/movies.json.'
/>
)
}
</section>
{/* Full franchise watch history */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker={`${count}+ films across ${groups.length} franchises`}
icon="filmstrip"
title="By franchise"
/></Reveal
>
<Reveal delay={80}>
{groups.length > 0 ? (
<CategoryList groups={groups} pillIcon="movie-open" />
) : (
<Placeholder
label="Movie library"
hint="Add categorized films to src/data/media/movies.json."
/>
)}
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+95
View File
@@ -0,0 +1,95 @@
---
/**
* pokemon.astro — Pokémon
* ---------------------------------------------------------------------------
* Per-interest template: favorite callout (Metagross), games overview, a
* Showdown-format roster placeholder (spec calls for standard Showdown text),
* and related links to Gaming.
*/
import BaseLayout from '@layouts/BaseLayout.astro';
import Reveal from '@components/motion/Reveal.astro';
import InterestHero from '@components/layout/InterestHero.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import RelatedInterests from '@components/layout/RelatedInterests.astro';
import Placeholder from '@components/layout/Placeholder.astro';
import Icon from '@components/Icon.astro';
const related = [
{ href: '/interests/gaming', title: 'Gaming', icon: 'gamepad-variant', blurb: 'Nintendo console overlap, from Game Boy to Switch 2.' },
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Displays and props for collections.' },
];
---
<BaseLayout
surface="interest"
title="Pokémon"
description="A lifelong obsession from Red and Blue to modern competitive. Metagross supremacy: Steel/Psychic, beautiful design, absolutely devastating in battle."
>
<InterestHero
icon="pokeball"
title="Pokémon"
tagline="Metagross supremacy. This is not up for debate."
image="/images/pokemon-cards.jpg"
imageAlt="A collection of trading cards"
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section class="flex flex-col gap-5 text-lg leading-relaxed" style="color: var(--color-text-muted);">
<Reveal><p>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.</p></Reveal>
</section>
{/* Favorite callout */}
<Reveal>
<div
class="card relative flex flex-col items-center gap-4 overflow-hidden text-center sm:flex-row sm:items-center sm:gap-6 sm:text-left"
style="background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised));"
>
<figure
class="relative aspect-square w-full shrink-0 overflow-hidden sm:w-28"
>
<img src="/images/pokemon-cards.jpg" alt="Trading card collection" loading="lazy" decoding="async" class="h-full w-full object-cover" />
</figure>
<span
class="inline-flex h-20 w-20 items-center justify-center rounded-2xl"
style="background: color-mix(in srgb, var(--color-accent) 22%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name="robot" class="h-10 w-10" />
</span>
<div class="flex flex-col gap-1">
<span class="font-eyebrow text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">Favorite Pokémon</span>
<h2 style="color: var(--color-text);">Metagross</h2>
<p class="text-sm" style="color: var(--color-text-muted);">Steel / Psychic. The iron leg Pokémon.</p>
</div>
</div>
</Reveal>
{/* Roster */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Battle team" icon="sword-cross" title="Roster" /></Reveal>
<Reveal delay={80}>
<p class="text-sm" style="color: var(--color-text-subtle);">
Rosters use standard Pokémon Showdown text format.
</p>
<Placeholder label="Pokémon Showdown roster" hint="Paste your preferred roster in Showdown text format.">
<pre
class="mt-3 overflow-x-auto rounded-lg p-4 font-mono text-xs"
style="background: var(--color-surface-sunken); color: var(--color-text-muted);"
>## Pokémon
[Greg's preferred roster in Showdown format]</pre>
</Placeholder>
</Reveal>
</section>
{/* Games */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Across the generations" icon="gamepad-variant" title="Games" /></Reveal>
<Reveal delay={80}>
<Placeholder label="Mainline & spinoff titles" hint="List the mainline and spinoff titles played, if desired." />
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+119
View File
@@ -0,0 +1,119 @@
---
/**
* reading.astro — Reading
* ---------------------------------------------------------------------------
* Books, comics, light novels, webtoons, and manga/manhwa. Favorites render as
* cover cards up top (populated by scripts/fetch-book-covers.mjs); the full
* library is grouped by category below. Follows the canonical interest-page
* template. The anime and movies/TV content that used to live here now has its
* own pages under /interests/anime, /tv, and /movies.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import MediaCard from "@components/cards/MediaCard.astro";
import CategoryList from "@components/media/CategoryList.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import { getFavorites, getGrouped, mediaKinds } from "@lib/media";
const meta = mediaKinds.reading;
const favorites = getFavorites("reading");
const groups = getGrouped("reading");
const related = [
{
href: "/interests/anime",
title: "Anime",
icon: "animation-play",
blurb: "A lot of these light novels and manga have adaptations I've watched.",
},
{
href: "/interests/making",
title: "Making & Creating",
icon: "palette",
blurb: "Props and displays for the stories I love.",
},
];
---
<BaseLayout
surface="interest"
title={meta.title}
description="The Cosmere, Dune, the Pendergast saga, Throne of Glass. Plus light novels, manga, manhwa, and webtoons. If a story exists, I am probably consuming it."
>
<InterestHero
icon={meta.icon}
title={meta.title}
tagline={meta.tagline}
image={meta.heroImage}
imageAlt={meta.heroImageAlt}
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal><p>{meta.overview}</p></Reveal>
</section>
{/* Favorites */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="On the shelf"
icon="bookshelf"
title="Favorites"
/></Reveal
>
{
favorites.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{favorites.map((item, i) => (
<Reveal delay={i * 70}>
<MediaCard
kind="reading"
title={item.title}
image={item.image}
status={item.status}
blurb={item.blurb}
/>
</Reveal>
))}
</div>
) : (
<Placeholder
label="Reading favorites"
hint='Mark items with "favorite": true in src/data/reading.json.'
/>
)
}
</section>
{/* Full library */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="The rest of the shelf"
icon="format-list-bulleted"
title="What I'm reading"
/></Reveal
>
<Reveal delay={80}>
{groups.length > 0 ? (
<CategoryList groups={groups} pillIcon="book-open-page-variant" />
) : (
<Placeholder
label="Reading library"
hint="Add categorized items to src/data/reading.json."
/>
)}
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+231
View File
@@ -0,0 +1,231 @@
---
/**
* tcgs.astro — Trading Card Games
* ---------------------------------------------------------------------------
* Per-interest template. Sections: overview narrative, the games I play
* (MTG, Pokémon, Riftbound, Gundam), Magic: The Gathering decks pulled live
* from Archidekt, and proxy making. Related links to Tabletop and Making.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import InterestCard from "@components/cards/InterestCard.astro";
import DeckCard from "@components/cards/DeckCard.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import Icon from "@components/Icon.astro";
import {
getDecks,
archidektSearchUrl,
ARCHIDEKT_USERNAME,
} from "@lib/archidekt";
const games = [
{
icon: "cards-playing",
title: "Magic: The Gathering",
body: "The main event. Mostly Commander (EDH), with a deckbuilding obsession that Archidekt enables far too well.",
},
{
icon: "pokeball",
title: "Pokémon TCG",
body: "Collector energy and casual play. Beautiful art pulls, deck drafts, and a soft spot for Steel/Psychic. Mostly on Pocket TCG.",
},
{
icon: "sword-cross",
title: "Riftbound",
body: "Riot's League of Legends TCG. A new game with a lot of potential. Interested in exploring deckbuilding and the evolving meta.",
},
{
icon: "robot",
title: "Gundam Card Game",
body: "Bandai's Gundam TCG. A new game with a lot of potential. Interested in how some of the figures will come into play with the other table top pieces for this entry.",
},
];
const proxy = [
{
icon: "content-cut",
title: "Proxy TCG cards",
body: "Designing and cutting proxy cards on the Silhouette Cameo 5a for casual play",
},
{
icon: "scissors-cutting",
title: "Custom stickers & decals",
body: "Vinyl cut for sleeves, playmats, and storage, and precision for life counters, clan badges, and the occasional silly sticker.",
},
{
icon: "diamond-stone",
title: "Tokens & counters",
body: "Resin-cast tokens, +1/+1 counters, and commander damage trackers cast in the pressure pot.",
},
{
icon: "printer-3d-nozzle",
title: "3D-printed props",
body: "Life counters, dice towers, deck boxes, and display stands off.",
},
];
const related = [
{
href: "/interests/ttrpgs",
title: "TTRPGs & Tabletop",
icon: "dice-multiple",
blurb: "Cards and dice share the same shelf and the same table.",
},
{
href: "/interests/making",
title: "Making & Creating",
icon: "palette",
blurb: "Proxies, tokens, and props are the TCG side of the workshop.",
},
];
// Live Archidekt decks (server-side, cached ~1h, never throws).
const decks = await getDecks();
const decksHref = archidektSearchUrl();
---
<BaseLayout
surface="interest"
title="Trading Card Games"
description="Magic: The Gathering Commander decks, Pokémon collecting, Riftbound, and the Gundam card game. Plus the proxies, tokens, and props I make to play them."
>
<InterestHero
icon="cards"
title="Trading Card Games"
tagline="Cardboard, deckbuilding, and the eternal question: one more pack?"
image="/images/cards.jpg"
imageAlt="Trading cards and dice on a playmat"
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
Trading card games are where my collecting, deckbuilding, and making
instincts all collide. I primarily play Magic: The Gathering, mostly
Commander (Elder Dragon Highlander), where a hundred cards becomes a
personal expression and the brewing never really ends. But I keep a
hand in the Pokémon TCG, and I'm watching Riftbound and the new Gundam
card game closely. If it has nice cardboard, pretty art, and a
decision tree, I'm interested.
</p></Reveal
>
<Reveal delay={80}
><p>
The hobby overlaps the rest of the fun wing: the same workshop that
produces resin dice and 3D prints also turns out proxies, tokens, and
deckboxes and more. And the deckbuilding itch is a close relation of
the RPG character-builder in me. Optimizing a list tells a story about
how I want to play and the theme I build the deck around.
</p></Reveal
>
</section>
{/* The games I play */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Collection"
icon="cards-outline"
title="The games I play"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2">
{
games.map((g, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={g.icon} title={g.title}>
{g.body}
</InterestCard>
</Reveal>
))
}
</div>
</section>
{/* Magic: The Gathering decks (live from Archidekt) */}
<section class="flex flex-col gap-6">
<Reveal>
<div class="flex flex-wrap items-end justify-between gap-4">
<SectionHeader kicker="Magic" icon="magic-staff" title="MTG decks" />
<a
href={decksHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm transition-colors hover:underline"
style="color: var(--color-accent);"
>
Manage on Archidekt
<Icon name="open-in-new" class="h-4 w-4" />
</a>
</div>
</Reveal>
<Reveal delay={40}>
<p class="text-sm" style="color: var(--color-text-muted);">
Pulled live from my Archidekt account ({ARCHIDEKT_USERNAME}). Newest
decks first; click a card to open it.
</p>
</Reveal>
{
decks.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{decks
.slice()
.sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt))
.map((d, i) => (
<Reveal delay={Math.min(i * 50, 400)}>
<DeckCard
name={d.name}
href={d.href}
featured={d.featured}
colors={d.colors}
size={d.size}
formatLabel={d.formatLabel}
updatedAt={d.updatedAt}
/>
</Reveal>
))}
</div>
) : (
<Reveal delay={80}>
<Placeholder
label="Archidekt decks"
hint={`Couldn't reach Archidekt right now. View the full list at ${decksHref}.`}
/>
</Reveal>
)
}
</section>
{/* Proxy making */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Workshop"
icon="auto-fix"
title="Proxy making & printed props"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-2">
{
proxy.map((p, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={p.icon} title={p.title}>
{p.body}
</InterestCard>
</Reveal>
))
}
</div>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+204
View File
@@ -0,0 +1,204 @@
---
/**
* ttrpgs.astro — TTRPGs & Tabletop
* ---------------------------------------------------------------------------
* The spec's per-interest template: Header (title + tagline), Overview
* (narrative, verbatim), Highlights (card grid, spec emojis -> mdi), Currently,
* then the Board Games sub-section, then RelatedInterests cross-links.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import InterestCard from "@components/cards/InterestCard.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import BoardGameShelf from "@components/ttrpgs/BoardGameShelf.tsx";
import boardgames from "@/data/boardgames.json";
const highlights = [
{
icon: "castle",
title: "Dungeons & Dragons",
body: "Lately 5th Edition. Long-running campaigns on and off Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative.",
},
{
icon: "sword-cross",
title: "Daggerheart",
body: "Darrington Press's dice-building RPG. Currently running a campaign and exploring how its mechanics create different story rhythms than D&D.",
},
{
icon: "target",
title: "Draw Steel",
body: "MCDM's fantasy tactical cinematic combat game. Interested in running it when I get a chance.",
},
{
icon: "shield-moon-outline",
title: "Cypher System",
body: "Monte Cook's narrative-focused system. Played a few one-shots and short form campaigns.",
},
{
icon: "knife-military",
title: "Blades in the Dark",
body: "A gritty, cinematic system for heists and criminal enterprises. Interested in playing short campaigns in it one day.",
},
{
icon: "weather-windy",
title: "Avatar the Last Airbender RPG",
body: "A narrative-focused system set in the world of Avatar. Interested in exploring bending mechanics and character-driven stories.",
},
{
icon: "monitor",
title: "Foundry VTT",
body: "Self-hosted on the home server. Building custom modules, maps, and automation for campaigns.",
},
];
const related = [
{
href: "/interests/making",
title: "Making & Creating",
icon: "palette",
blurb: "Dice and terrain made for campaigns.",
},
{
href: "/interests/gaming",
title: "Gaming",
icon: "gamepad-variant",
blurb: "Digital RPGs as another expression of the same impulse.",
},
{
href: "/interests/tcgs",
title: "Trading Card Games",
icon: "cards",
blurb: "Cards and dice share the same shelf — and the same table.",
},
];
---
<BaseLayout
surface="interest"
title="TTRPGs & Tabletop"
description="Long-running D&D and Daggerheart campaigns on Foundry VTT, the MCDM Crows playtest, and a growing board game collection."
>
<InterestHero
icon="dice-multiple"
title="TTRPGs & Tabletop"
tagline="Minis and dice sets all sat round the table, card games commander all gathered when able. Games about dragons and spanning their wings, These are a few of my favorite things."
image="/images/ttrpg-mini.jpg"
imageAlt="A tabletop RPG spread with miniatures and dice"
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal
><p>
I love playing tabletop games from trading card games to role-playing
games. 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 and lets
me flex my technical skills.
</p></Reveal
>
<Reveal delay={60}
><p>
I also love board games, from strategy to social deduction. My
collection is growing, and I enjoy hosting game nights with friends. I
have a particular fondness for games that challenge players to think
critically and creatively, and I appreciate the social aspect of
gaming as much as the gameplay itself. Although, I do love the ability
to play some of the engine building and deck-building games solo.
Spirit Island and Wyrmspan have been my favorites lately, and I am
always on the lookout for new games to add to my collection.
</p></Reveal
>
<Reveal delay={100}
><p>
I have played a few trading card games and collected many cards. I
have been a lifelong Pokémon and Magic: The Gathering fan, and I enjoy
the strategy and collection aspects of both games in different ways.
While I mostly enjoy looking at Pokemon cards, I spend most of my time
with Magic deck building and playing Elder Dragon Highlander (or
Commander). I recently picked up Riftbound and have had my eye on the
Gundam TCG game as well. The mechanical differences of these games are
fascinating, and I enjoy exploring the different strategies and
deck-building techniques that each game offers.
</p></Reveal
>
</section>
{/* Highlights */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Highlights"
icon="view-grid"
title="Systems & tools"
/></Reveal
>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{
highlights.map((h, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={h.icon} title={h.title}>
{h.body}
</InterestCard>
</Reveal>
))
}
</div>
</section>
{
/* Currently
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="On the schedule" /></Reveal>
<div class="grid gap-4 sm:grid-cols-3">
<Placeholder label="Campaign 1 name" hint="D&D, [day of week]s." />
<Placeholder label="Campaign 2 name" hint="Daggerheart, [day of week]s." />
<Placeholder label="Crows playtest" hint="Ongoing sessions." />
</div>
</section>
*/
}
{/* Board games */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Beyond RPGs"
icon="cards-playing"
title="Board games"
/></Reveal
>
<Reveal delay={80}>
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-gold); color: var(--color-text-muted);"
>
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.
Hover a title to preview its cover; click to open it on BoardGameGeek.
</blockquote>
</Reveal>
<Reveal delay={140}>
<BoardGameShelf
client:idle
games={boardgames}
shelfImage="/images/ttrpg-boardgame-shelf.jpg"
shelfAlt="Greg's board game shelf"
shelfCaption="The shelf"
/>
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+118
View File
@@ -0,0 +1,118 @@
---
/**
* tv.astro — TV Shows
* ---------------------------------------------------------------------------
* 200+ completed series across prestige drama, streaming originals, workplace
* comedy, procedural, Marvel/Star Wars universes, animation, and reality.
* Favorites as poster cards; full library grouped by category below.
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import InterestHero from "@components/layout/InterestHero.astro";
import MediaCard from "@components/cards/MediaCard.astro";
import CategoryList from "@components/media/CategoryList.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import RelatedInterests from "@components/layout/RelatedInterests.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import { getFavorites, getGrouped, mediaKinds, getCount } from "@lib/media";
const meta = mediaKinds.tv;
const favorites = getFavorites("tv");
const groups = getGrouped("tv");
const count = getCount("tv");
const related = [
{
href: "/interests/anime",
title: "Anime",
icon: "animation-play",
blurb: "The other half of my watchlist, honestly.",
},
{
href: "/interests/movies",
title: "Movies",
icon: "movie-open",
blurb: "The MCU, the Matrix, and everything between.",
},
];
---
<BaseLayout
surface="interest"
title={meta.title}
description={`${count}+ completed series across prestige drama, workplace comedy, streaming originals, and the full Marvel and Star Wars rosters.`}
>
<InterestHero
icon={meta.icon}
title={meta.title}
tagline={meta.tagline}
image={meta.heroImage}
imageAlt={meta.heroImageAlt}
/>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* Overview */}
<section
class="flex flex-col gap-5 text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
<Reveal><p>{meta.overview}</p></Reveal>
</section>
{/* Favorites */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="On heavy rotation"
icon="star"
title="Favorites"
/></Reveal
>
{
favorites.length > 0 ? (
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{favorites.map((item, i) => (
<Reveal delay={i * 70}>
<MediaCard
kind="tv"
title={item.title}
image={item.image}
status={item.status}
blurb={item.blurb}
/>
</Reveal>
))}
</div>
) : (
<Placeholder
label="TV favorites"
hint='Mark items with "favorite": true in src/data/media/tv.json.'
/>
)
}
</section>
{/* Full library */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker={`${count}+ series`}
icon="format-list-bulleted"
title="Everything I've finished"
/></Reveal
>
<Reveal delay={80}>
{groups.length > 0 ? (
<CategoryList groups={groups} pillIcon="television-classic" />
) : (
<Placeholder
label="TV library"
hint="Add categorized items to src/data/media/tv.json."
/>
)}
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+331
View File
@@ -0,0 +1,331 @@
---
/**
* professional/index.astro — Professional (Resume / Portfolio)
* ---------------------------------------------------------------------------
* The "polished wing". A single long-scroll page whose sections read as a
* narrative (Summary -> Experience -> Skills -> Projects -> Education ->
* Contact), per the spec. Uses surface="professional" for the dark slate tone.
*
* Experience comes from the `experience` collection; projects from the
* `projects` collection. Both are sorted by `order`. Education is a placeholder
* block (spec leaves it to Greg).
*/
import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import SectionNav from "@components/professional/SectionNav.astro";
import Timeline from "@components/professional/Timeline.astro";
import SkillGrid from "@components/professional/SkillGrid.astro";
import ProjectCard from "@components/professional/ProjectCard.astro";
import EducationHistory from "@components/professional/EducationHistory.astro";
import Certifications from "@components/professional/Certifications.astro";
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import Motif from "@components/brand/Motif.astro";
import CircuitField from "@components/mystic/CircuitField.tsx";
import CelestialFrame from "@components/mystic/CelestialFrame.astro";
import ObfuscatedEmail from "@components/ObfuscatedEmail.astro";
import { getCollection } from "astro:content";
const experience = (await getCollection("experience")).sort(
(a, b) => a.data.order - b.data.order,
);
const projects = (await getCollection("projects")).sort(
(a, b) => a.data.order - b.data.order,
);
---
<BaseLayout
surface="professional"
title="Professional"
description="Software developer and team lead: cloud and mobile development, SAP Business Technology Platform, AI/ML, full-stack work. Resume and portfolio for Greg Kilmer."
>
{
/* Page header + overlapping headshot.
The headshot is anchored to the HERO SECTION (not container-prose) so its
straddle point is the true hero/next-section boundary. It's positioned on
the right, aligned with the content column's right edge via the same
container-prose centering math (a centered max-width column). translate-y-1/2
puts its center on the boundary: half in the hero, half over the next
section, in front of both (z-50). */
}
<section class="on-sky relative overflow-visible pt-28 pb-28 sm:pt-32 sm:pb-36">
{
/* Background layer — clipped to the hero so the circuit canvas/astrolabe
don't bleed into the content below. */
}
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<CircuitField client:idle className="vt-sky opacity-100" />
<Motif
name="astrolabe"
class="absolute z-0 -right-40 -top-24 h-[38rem] w-[34rem] object-contain"
style="opacity:.2;"
/>
</div>
{
/* Headshot — anchored to the hero section's bottom edge (the true boundary
with the next section), aligned right with the content column. Wrapped in
a centered max-width box that mirrors container-prose so the headshot's
right edge lines up with the content's right edge.
z-40: above the hero background (z-0) and text content (z-10), but BELOW
the fixed NavBar header (z-50) so the nav stays on top when they overlap. */
}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-40 mx-auto max-w-[72rem] px-5 lg:ml-56 lg:max-w-[calc(72rem+14rem)] sm:px-8"
>
<div
class="pointer-events-auto ml-auto w-44 translate-y-1/2 sm:w-52 md:w-60"
>
<CelestialFrame rounded="2xl" emphasis="strong">
<img
src="/images/about-portrait.jpg"
alt="Greg Kilmer headshot"
class="h-full w-full object-cover"
style="aspect-ratio: 4/5;"
/>
</CelestialFrame>
</div>
</div>
<div
class="container-prose relative z-10 lg:ml-56 lg:max-w-[calc(72rem+14rem)]"
>
<Reveal class="max-w-2xl">
<SectionHeader
tone="hero"
kicker="Professional"
icon="briefcase"
title="Resume & Portfolio"
subtitle="Clean, confident, skimmable."
/>
</Reveal>
</div>
</section>
{
/* Fixed left section rail. It is position:fixed so it renders once here
and stays pinned to the viewport's left edge while the page scrolls.
The main content column below is given lg:pl-* to clear the 14rem rail. */
}
<SectionNav />
{
/* Content column. On lg+ the fixed left rail (w-56 = 14rem) takes the left
edge, so this column is offset by that width via margin (NOT padding on a
centered max-width box — that caused asymmetric overflow / broken grids).
It keeps container-prose's max-width but lets it sit beside the rail. */
}
<div
class="container-prose flex flex-col gap-20 pt-28 pb-16 sm:gap-24 sm:pt-40 sm:pb-20 lg:ml-56 lg:max-w-[calc(72rem+14rem)]"
>
{/* ===== 1. Summary ===== */}
<section id="summary" class="scroll-mt-28 flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="01"
icon="format-list-text"
title="Professional summary"
/>
</Reveal>
<Reveal delay={80}>
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-gold); color: var(--color-text-muted);"
>
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.
</blockquote>
</Reveal>
<Reveal delay={120}>
<figure
class="relative overflow-hidden rounded-2xl border"
style="border-color: color-mix(in srgb, var(--color-gold) 70%, transparent);"
>
<img
src="/images/pro-workspace.jpg"
alt="Cloud development workspace"
loading="lazy"
decoding="async"
class="aspect-[21/9] w-full object-cover"
/>
<figcaption
class="absolute bottom-0 left-0 m-3 rounded-md px-3 py-1 font-eyebrow text-[0.65rem] uppercase tracking-[0.18em] backdrop-blur-sm"
style="background: color-mix(in srgb, var(--color-midnight) 60%, transparent); color: var(--color-starlight);"
>
Where the day-job work happens
</figcaption>
</figure>
</Reveal>
</section>
<Reveal>
<AlchemicalDivider
class="mx-auto h-6 w-full max-w-sm"
style="color: var(--color-gold);"
/>
</Reveal>
{/* ===== 2. Experience ===== */}
<section id="experience" class="scroll-mt-28 flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="02"
icon="timeline-text"
title="Experience"
subtitle="Where I've proven it. Expand any role for the detail."
/>
</Reveal>
<Timeline entries={experience} />
</section>
{/* ===== 3. Skills ===== */}
<section id="skills" class="scroll-mt-28 flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="03"
icon="chart-bar"
title="Skills & technologies"
subtitle="What I bring to the table, organized into three tiers."
/>
</Reveal>
<SkillGrid />
</section>
{/* ===== 4. Projects ===== */}
<section id="projects" class="scroll-mt-28 flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="04"
icon="view-grid"
title="Projects"
subtitle="What I've built."
/>
</Reveal>
<div class="grid
gap-5
sm:grid-cols-2">
{
projects.map((project, i) => (
<Reveal delay={i * 80}>
<ProjectCard project={project} />
</Reveal>
))
}
</div>
</section>
{/* ===== 5. Education ===== */}
<section id="education" class="scroll-mt-28 flex flex-col gap-8">
<Reveal>
<SectionHeader
kicker="05"
icon="school"
title="Education & certifications"
subtitle="My foundation."
/>
</Reveal>
<Reveal delay={60}>
<div class="flex flex-col gap-3">
<h3
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
style="color: var(--color-accent);"
>
Education history
</h3>
<EducationHistory />
</div>
</Reveal>
<Reveal delay={120}>
<div class="flex flex-col gap-3">
<h3
class="font-eyebrow text-xs uppercase tracking-[0.22em]"
style="color: var(--color-accent);"
>
Certifications
</h3>
<Certifications />
</div>
</Reveal>
</section>
{/* ===== 6. Contact ===== */}
<section id="contact" class="relative scroll-mt-28 flex flex-col gap-6">
{/* A faint seal beside the closing call to action (the astrolabe is the
hero's motif, so the seal sits down here rather than competing). */}
<Motif
name="kepler-nest-seal-light"
class="absolute -top-20 right-0 -z-10 h-64 w-64 sm:-top-28 sm:h-96 sm:w-96"
style="opacity:.16;"
/>
<Reveal>
<SectionHeader
kicker="06"
icon="email"
title="Let's talk"
subtitle="Open to opportunities in AI/ML, mobile app, and cloud development, VR and Game Development, and technical leadership."
/>
</Reveal>
<Reveal delay={80}>
<div class="card flex flex-col gap-6">
<ul class="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:gap-8">
<li class="flex items-center gap-2">
<Icon
name="email-outline"
class="h-5 w-5"
style="color: var(--color-accent);"
/>
<ObfuscatedEmail
address="contact@gregkilmer.com"
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
/>
</li>
<li class="flex items-center gap-2">
<Icon
name="linkedin"
class="h-5 w-5"
style="color: var(--color-accent);"
/>
<span
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
><a
href="https://www.linkedin.com/in/gregory-kilmer-36646bb5/"
target="_blank"
rel="noopener noreferrer">LinkedIn</a
></span
>
</li>
<li class="flex items-center gap-2">
<Icon
name="github"
class="h-5 w-5"
style="color: var(--color-accent);"
/>
<span
class="font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
><a
href="https://github.com/patiun"
target="_blank"
rel="noopener noreferrer">GitHub</a
></span
>
</li>
</ul>
<a href="/contact" class="btn btn-primary self-start">
Full contact page
<Icon name="arrow-right" class="h-5 w-5" />
</a>
</div>
</Reveal>
</section>
</div>
</BaseLayout>
+128
View File
@@ -0,0 +1,128 @@
---
/**
* /store/cards — Proxy MTG cards product line
* ---------------------------------------------------------------------------
* Order: back-link + title → live slot tracker → image carousel → specs →
* pricing → double-sided callout → key requirements → CTA.
*
* The carousel images + labels are managed server-side in store-products.ts
* (cardProduct.images). The specs block is image-free now; examples live in
* the carousel only.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import PageSeal from "@components/brand/PageSeal.astro";
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import Icon from '@components/Icon.astro';
import Carousel from '@components/store/Carousel.tsx';
import ProductCard from '@components/store/ProductCard.astro';
import PricingTable from '@components/store/PricingTable.astro';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { cardProduct, requirements } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
const av = storeAvailability;
const slot = av.slots[cardProduct.slotKey];
// The double-sided rule is important enough to surface as its own callout.
const doubleSided = requirements.find((r) => r.title.toLowerCase().includes('double-sided'))!;
const keyReqs = requirements.filter((r) =>
r.title.toLowerCase().includes('card list')
|| r.title.toLowerCase().includes('double-sided')
|| r.title.toLowerCase().includes('proxies')
|| r.title.toLowerCase().includes('custom'),
);
---
<StoreLayout
title="Proxy MTG Cards"
description="Proxy MTG cards printed to standard size with a solid white back — clearly marked proxies for casual play. Specs and pricing."
>
<PageSeal name="squared-circle-seal-light" />
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
{/* Header */}
<section class="flex flex-col gap-5">
<Reveal>
<a
href="/store"
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="arrow-left" class="h-4 w-4" />
Back to store
</a>
</Reveal>
<Reveal delay={60}>
<SectionHeader kicker="Product" icon={cardProduct.icon} title={cardProduct.title} subtitle={cardProduct.summary} />
</Reveal>
</section>
{/* Live slot tracker for this product line (top of content). */}
<section class="flex flex-col gap-3">
<Reveal>
<SlotMeter label="Card order slots" icon="cards-playing" open={slot.open} total={slot.total} />
</Reveal>
</section>
{/* Image carousel (server-driven, swipeable). */}
<section class="flex flex-col gap-4">
<Reveal><SectionHeader kicker="Examples" icon="image-multiple" title={`See the ${cardProduct.title.toLowerCase()}`} /></Reveal>
<Reveal delay={60}>
<Carousel client:visible slides={cardProduct.images} label={`${cardProduct.title} examples`} />
</Reveal>
</section>
{/* Specifications (no image — examples live in the carousel). */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Details" icon="ruler" title="Specifications" /></Reveal>
<Reveal delay={60}>
<ProductCard product={cardProduct} detailed />
</Reveal>
</section>
{/* Pricing */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Pricing" icon="currency-usd" title="Card sets" /></Reveal>
<Reveal delay={60}>
<PricingTable
icon="cards-playing"
caption="Per-card pricing by quantity"
subtitle="Final pricing confirmed when your list is received. Prices are placeholders pending update."
tiers={cardProduct.pricing}
/>
</Reveal>
</section>
{/* Double-sided callout */}
<Reveal>
<div
class="flex items-start gap-4 rounded-xl border p-5"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
>
<Icon name="alert" class="mt-0.5 h-6 w-6 shrink-0" style="color: var(--color-accent);" />
<div class="flex flex-col gap-1">
<span class="font-sans text-base font-bold" style="color: var(--color-text);">{doubleSided.title}</span>
<span class="text-sm leading-relaxed" style="color: var(--color-text-muted);">{doubleSided.body}</span>
</div>
</div>
</Reveal>
{/* Key requirements */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Before you order" icon="clipboard-check" title="Key requirements" /></Reveal>
<Reveal delay={60}>
<RequirementList items={keyReqs} />
</Reveal>
</section>
{/* CTA */}
<Reveal>
<div class="flex flex-col items-center gap-4 text-center">
<a href="/store/order" class="btn btn-primary">
<Icon name="clipboard-list" class="h-5 w-5" />
{av.ordersOpen ? 'Start an order request' : 'View order page'}
</a>
</div>
</Reveal>
</div>
</StoreLayout>
+165
View File
@@ -0,0 +1,165 @@
---
/**
* /store/deck-boxes — 3D-printed deck boxes & counters product line
* ---------------------------------------------------------------------------
* Order: back-link + title → live slot tracker → image carousel → specs →
* pricing → key requirements → CTA.
*
* The carousel images + labels are managed server-side in store-products.ts
* (deckBoxProduct.images). The specs block is image-free now; examples live in
* the carousel only.
*/
import PageSeal from "@components/brand/PageSeal.astro";
import StoreLayout from "@layouts/StoreLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import Icon from "@components/Icon.astro";
import Carousel from "@components/store/Carousel.tsx";
import ProductCard from "@components/store/ProductCard.astro";
import PricingTable from "@components/store/PricingTable.astro";
import SlotMeter from "@components/store/SlotMeter.astro";
import ColorGrid from "@components/store/ColorGrid.astro";
import RequirementList from "@components/store/RequirementList.astro";
import { deckBoxProduct, requirements } from "@data/store-products";
import { storeAvailability } from "@data/store-availability";
const av = storeAvailability;
const slot = av.slots[deckBoxProduct.slotKey];
const keyReqs = requirements.filter(
(r) =>
r.title.toLowerCase().includes("final") ||
r.title.toLowerCase().includes("capacity") ||
r.title.toLowerCase().includes("affiliation"),
);
---
<StoreLayout
title="3D-Printed Deck Boxes & Counters"
description="3D-printed deck boxes, life counters, and tokens. Specs and pricing for printed storage and trackers."
>
<PageSeal name="craft-seal-print-light" />
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
{/* Header */}
<section class="flex flex-col gap-5">
<Reveal>
<a
href="/store"
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="arrow-left" class="h-4 w-4" />
Back to store
</a>
</Reveal>
<Reveal delay={60}>
<SectionHeader
kicker="Product"
icon={deckBoxProduct.icon}
title={deckBoxProduct.title}
subtitle={deckBoxProduct.summary}
/>
</Reveal>
</section>
{/* Live slot tracker for this product line (top of content). */}
<section class="flex flex-col gap-3">
<Reveal>
<SlotMeter
label="3D-print slots"
icon="printer-3d-nozzle"
open={slot.open}
total={slot.total}
/>
</Reveal>
</section>
{/* Image carousel (server-driven, swipeable). */}
<section class="flex flex-col gap-4">
<Reveal
><SectionHeader
kicker="Examples"
icon="image-multiple"
title={`See the ${deckBoxProduct.title.toLowerCase()}`}
/></Reveal
>
<Reveal delay={60}>
<Carousel
client:visible
slides={deckBoxProduct.images}
label={`${deckBoxProduct.title} examples`}
/>
</Reveal>
</section>
{/* Specifications (no image — examples live in the carousel). */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Details"
icon="ruler"
title="Specifications"
/></Reveal
>
<Reveal delay={60}>
<ProductCard product={deckBoxProduct} detailed />
</Reveal>
</section>
{/* Pricing */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Pricing"
icon="currency-usd"
title="Prints"
/></Reveal
>
<Reveal delay={60}>
<PricingTable
icon="printer-3d-nozzle"
caption="Per-print pricing"
subtitle="Final pricing confirmed when your request is received. Prices are placeholders pending update."
tiers={deckBoxProduct.pricing}
/>
</Reveal>
</section>
{/* Available print colors (filament). Managed in store-products.ts. */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Colors"
icon="palette"
title="Available print colors"
/></Reveal
>
<Reveal delay={60}>
<ColorGrid colors={deckBoxProduct.colors} />
</Reveal>
</section>
{/* Key requirements */}
<section class="flex flex-col gap-6">
<Reveal
><SectionHeader
kicker="Before you order"
icon="clipboard-check"
title="Key requirements"
/></Reveal
>
<Reveal delay={60}>
<RequirementList items={keyReqs} />
</Reveal>
</section>
{/* CTA */}
<Reveal>
<div class="flex flex-col items-center gap-4 text-center">
<a href="/store/order" class="btn btn-primary">
<Icon name="clipboard-list" class="h-5 w-5" />
{av.ordersOpen ? "Start an order request" : "View order page"}
</a>
</div>
</Reveal>
</div>
</StoreLayout>
+255
View File
@@ -0,0 +1,255 @@
---
/**
* /store — Storefront
* ---------------------------------------------------------------------------
* The store home: hero on the animated field, intro, the two product lines,
* live availability (slots) from store-availability.json, the disclaimer /
* requirements block, and a CTA to the order page.
*
* Reuses the main site's aesthetic (InterestHero-style hero, Reveal, cards)
* but is rendered through StoreLayout so it gets the store-only nav and
* noindex by default. No links back to the main site.
*/
import StoreLayout from "@layouts/StoreLayout.astro";
import Reveal from "@components/motion/Reveal.astro";
import SectionHeader from "@components/layout/SectionHeader.astro";
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
import StarBurst from "@components/mystic/StarBurst.astro";
import Icon from "@components/Icon.astro";
import ProductTile from "@components/store/ProductTile.astro";
import SlotMeter from "@components/store/SlotMeter.astro";
import RequirementList from "@components/store/RequirementList.astro";
import {
cardProduct,
deckBoxProduct,
requirements,
} from "@data/store-products";
import { storeAvailability } from "@data/store-availability";
const av = storeAvailability;
const orderHref = "/store/order";
---
<StoreLayout title="Store — Proxy MTG Cards & 3D-Printed Deck Boxes">
{/* --- Hero --- */}
{/* pt-36/sm:pt-40 gives the bright hero content clear breathing room below
the solid fixed StoreNav (64px tall), so the gold label and nav never
visually crowd each other. */}
<section class="on-sky relative overflow-hidden pb-8 pt-36 sm:pt-40">
<AnimatedBackground client:idle className="vt-sky" />
<div
class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]"
>
<div class="flex flex-col items-start gap-4">
<Reveal>
<span
class="inline-flex items-center gap-1.5 font-eyebrow text-sm tracking-[0.06em]"
style="color: var(--color-accent);"
>
<Icon name="store" class="h-4 w-4" />
Store.GregKilmer.com
</span>
</Reveal>
<Reveal delay={60}>
<span
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name="cards" class="h-6 w-6" />
</span>
</Reveal>
<Reveal delay={100}>
<h2
class="font-display font-bold"
style="color: var(--color-starlight); font-size: clamp(1.75rem, 2vw + 1rem, 2.75rem); line-height: 1.15;"
>
Proxy MTG cards &amp; 3D-printed deck boxes
</h2>
</Reveal>
<Reveal delay={140}>
<p
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
style="border-color: var(--color-gold); color: var(--color-starlight); opacity: 0.92;"
>
Clearly-marked proxies for casual play, and matching 3D-printed
storage &amp; counters. Hand-made in small batches.
</p>
</Reveal>
<Reveal delay={180}>
<a href={orderHref} class="btn btn-primary mt-2">
<Icon name="clipboard-list" class="h-5 w-5" />
Start an order request
</a>
</Reveal>
</div>
<Reveal delay={80} class="w-full">
<figure
class="relative w-full overflow-hidden rounded-2xl border"
style="border-color: color-mix(in srgb, var(--color-gold) 70%, transparent);"
>
<img
src="/images/proxies.jpg"
alt="Example printed proxy MTG cards"
loading="lazy"
decoding="async"
class="aspect-[4/3] w-full object-cover"
/>
<StarBurst
class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-gold)_60%,transparent)]"
style="color: var(--color-gold);"
/>
</figure>
</Reveal>
</div>
</section>
<div class="container-prose flex flex-col gap-20 py-12 sm:py-16">
{/* --- Intro + limited capacity banner --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="Welcome"
icon="store"
title="A small workshop, not a factory"
/>
</Reveal>
<Reveal delay={60}>
<p
class="max-w-2xl text-lg leading-relaxed"
style="color: var(--color-text-muted);"
>
{av.maxNote}
</p>
</Reveal>
<Reveal delay={100}>
<div
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);"
>
<div class="flex items-center gap-3">
<Icon
name="clock-outline"
class="h-6 w-6 shrink-0"
style="color: var(--color-accent);"
/>
<span class="text-sm sm:text-base" style="color: var(--color-text);"
>{av.turnaround}</span
>
</div>
<span
class="pill"
style={av.ordersOpen
? undefined
: "background: color-mix(in srgb, #e55 15%, transparent); border-color: color-mix(in srgb, #e55 40%, transparent);"}
>
{av.ordersOpen ? "Orders open" : "Orders currently closed"}
</span>
</div>
</Reveal>
</section>
{/* --- Product lines --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="Products"
icon="cards-outline"
title="What I make"
/>
</Reveal>
<div class="grid gap-5 sm:grid-cols-2">
<Reveal delay={60}>
<ProductTile product={cardProduct} href="/store/cards" />
</Reveal>
<Reveal delay={120}>
<ProductTile product={deckBoxProduct} href="/store/deck-boxes" />
</Reveal>
</div>
</section>
{/* --- Live availability --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="Availability"
icon="gauge"
title="Current order slots"
/>
</Reveal>
<Reveal delay={60}>
<p class="max-w-2xl text-base" style="color: var(--color-text-muted);">
I can only take so many requests at a time. These update as I open and
finish orders. Last updated {av.lastUpdated}.
</p>
</Reveal>
<Reveal delay={100}>
<div class="grid gap-4 sm:grid-cols-2">
<SlotMeter
label="Card orders"
icon="cards-playing"
open={av.slots.cards.open}
total={av.slots.cards.total}
/>
<SlotMeter
label="3D-print slots"
icon="printer-3d-nozzle"
open={av.slots.deckBoxes.open}
total={av.slots.deckBoxes.total}
/>
</div>
</Reveal>
</section>
{/* --- Requirements / disclaimer --- */}
<section class="flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="Before you order"
icon="clipboard-check"
title="Requirements & policies"
/>
</Reveal>
<Reveal delay={60}>
<RequirementList items={requirements} />
</Reveal>
</section>
{/* --- CTA --- */}
<section
class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center"
style="border-color: var(--color-border); background: var(--color-surface-raised);"
>
<Reveal>
<h3
class="font-display text-2xl font-bold"
style="color: var(--color-text);"
>
Ready to put in a request?
</h3>
</Reveal>
<Reveal delay={60}>
<p class="max-w-lg text-sm" style="color: var(--color-text-muted);">
The order form collects your request and emails it to me. No payment
is taken through the site. {
av.ordersOpen
? ""
: "Orders are currently closed — check back soon."
}
</p>
</Reveal>
<Reveal delay={100}>
<a href={orderHref} class="btn btn-primary">
<Icon name="clipboard-list" class="h-5 w-5" />
Go to the order page
</a>
</Reveal>
</section>
</div>
</StoreLayout>
+142
View File
@@ -0,0 +1,142 @@
---
/**
* /store/order — Order request page
* ---------------------------------------------------------------------------
* Hard-gated by store-availability.json:
* - ordersOpen === false => renders a "closed" notice + email link, NO form.
* - ordersOpen === true => renders the OrderForm island + slot meters +
* the requirements list.
*
* The server endpoint /api/order re-checks the same file and returns 403 when
* closed, so the gate can't be bypassed by POSTing directly.
*/
import StoreLayout from '@layouts/StoreLayout.astro';
import PageSeal from "@components/brand/PageSeal.astro";
import Reveal from '@components/motion/Reveal.astro';
import SectionHeader from '@components/layout/SectionHeader.astro';
import Icon from '@components/Icon.astro';
import ObfuscatedEmail from '@components/ObfuscatedEmail.astro';
import OrderForm from '@components/store/OrderForm.tsx';
import SlotMeter from '@components/store/SlotMeter.astro';
import RequirementList from '@components/store/RequirementList.astro';
import { requirements, deckBoxProduct } from '@data/store-products';
import { storeAvailability } from '@data/store-availability';
import { turnstileSiteKey } from '@lib/env';
const av = storeAvailability;
const open = av.ordersOpen;
const siteKey = turnstileSiteKey();
---
<StoreLayout
title="Order Request"
description="Submit a proxy-card or 3D-print order request. No payment is taken through the site."
>
<PageSeal name="transmutation-seal-light" />
<div class="container-prose flex flex-col gap-14 py-28 sm:py-32">
<section class="flex flex-col gap-5">
<Reveal>
<a
href="/store"
class="inline-flex items-center gap-1.5 font-eyebrow text-xs uppercase tracking-[0.18em]"
style="color: var(--color-accent);"
>
<Icon name="arrow-left" class="h-4 w-4" />
Back to store
</a>
</Reveal>
<Reveal delay={60}>
<SectionHeader kicker="Order" icon="clipboard-list" title="Order request" />
</Reveal>
<Reveal delay={100}>
<p class="max-w-2xl text-lg leading-relaxed" style="color: var(--color-text-muted);">
This form sends me an order request by email. No payment is taken through the site.
{av.turnaround}
</p>
</Reveal>
</section>
{/* Availability meters — shown first so visitors immediately see whether
there's capacity before reading the rest of the page. */}
<section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Availability" icon="gauge" title="Current order slots" /></Reveal>
<Reveal delay={60}>
<div class="grid gap-4 sm:grid-cols-2">
<SlotMeter label="Card orders" icon="cards-playing" open={av.slots.cards.open} total={av.slots.cards.total} />
<SlotMeter label="3D-print slots" icon="printer-3d-nozzle" open={av.slots.deckBoxes.open} total={av.slots.deckBoxes.total} />
</div>
</Reveal>
{/* Open/closed status banner (under the meters). */}
<Reveal delay={100}>
<div
class="flex flex-col gap-3 rounded-xl border p-5 sm:flex-row sm:items-center sm:justify-between"
style={open
? 'border-color: color-mix(in srgb, var(--color-accent) 35%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);'
: 'border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 10%, transparent);'}
>
<div class="flex items-center gap-3">
<Icon name={open ? 'check-circle' : 'close-circle'} class="h-6 w-6 shrink-0" style={open ? 'color: var(--color-accent);' : 'color: #e55;'} />
<span class="text-sm sm:text-base" style="color: var(--color-text);">
{open ? 'Orders are open. Slots are limited.' : 'Orders are currently closed.'}
</span>
</div>
<span class="font-eyebrow text-xs" style="color: var(--color-text-subtle);">
Updated {av.lastUpdated}
</span>
</div>
</Reveal>
</section>
{/* Either the form (open) or the closed notice (no form). */}
{open ? (
<section class="flex flex-col gap-8 lg:grid lg:grid-cols-[1.6fr_1fr] lg:items-start">
<Reveal>
<div class="card flex flex-col gap-2">
<h3 class="font-display text-lg font-bold" style="color: var(--color-text);">Your request</h3>
<p class="text-sm" style="color: var(--color-text-muted);">
Fill in the details below. Remember: the card list is final once received, and
double-sided cards count as 2 slots.
</p>
<div class="mt-3">
<OrderForm client:idle turnstileSiteKey={siteKey} colors={deckBoxProduct.colors} />
</div>
</div>
</Reveal>
<Reveal delay={80}>
<div class="flex flex-col gap-4 lg:sticky lg:top-24">
<RequirementList items={requirements} heading="Policies &amp; requirements" />
</div>
</Reveal>
</section>
) : (
<section class="flex flex-col gap-6">
<Reveal>
<div
class="flex flex-col items-center gap-4 rounded-2xl border p-8 text-center"
style="border-color: color-mix(in srgb, #e55 40%, transparent); background: color-mix(in srgb, #e55 8%, transparent);"
>
<span class="inline-flex h-12 w-12 items-center justify-center rounded-full" style="background: color-mix(in srgb, #e55 20%, transparent); color: #e55;" aria-hidden="true">
<Icon name="lock" class="h-6 w-6" />
</span>
<h3 class="font-display text-xl font-bold" style="color: var(--color-text);">Orders are not being taken right now</h3>
<p class="max-w-md text-sm" style="color: var(--color-text-muted);">
I open a limited number of slots at a time. Check back here &mdash; the meters above
show live availability, and the order form appears as soon as orders reopen. You can
also email me directly with questions.
</p>
<div class="mt-1 flex flex-wrap items-center justify-center gap-2">
<Icon name="email" class="h-5 w-5" style="color: var(--color-accent);" />
<ObfuscatedEmail address="orders@gregkilmer.com" class="font-medium" style="color: var(--color-accent);" />
</div>
</div>
</Reveal>
<Reveal delay={60}>
<RequirementList items={requirements} heading="Policies &amp; requirements (for when orders open)" />
</Reveal>
</section>
)}
</div>
</StoreLayout>