Initial Commit

This commit is contained in:
Greg Kilmer
2026-07-04 07:42:24 -04:00
commit 71bffad507
77 changed files with 14437 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
---
/**
* 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';
// Spec "Quick Facts", emojis -> mdi icons.
const quickFacts = [
{ icon: 'map-marker', text: 'Philadelphia, PA' },
{ icon: 'briefcase', text: 'Software Developer 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 reading: Heir of Fire 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."
>
<!-- Header -->
<section class="container-prose flex flex-col gap-10 pb-10 pt-28 sm:pt-32">
<Reveal>
<SectionHeader
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>
</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-accent); color: var(--color-text);"
>
I am Greg &mdash; a software developer based in Philadelphia who spends
his free time running tabletop RPGs, making resin dice, 3D printing
miniatures and props, and reading his way through fantasy series at an
alarming rate. I live with my partner Krystina, our mini dachshund
Kashi, and more board games than any reasonable apartment should hold.
</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 surrounded by books and games, and not much has changed.
Technology pulled me into a career building cloud and mobile
applications, but I never stopped collecting dice, designing
campaigns, or losing weekends to Sanderson rereads. I 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>
</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-mono 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-accent);" />
</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>
+155
View File
@@ -0,0 +1,155 @@
/**
* /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. Deliver: SMTP via nodemailer if configured, else POST to a webhook if
* configured, else return 503 so delivery can be wired later.
* 5. 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';
// --- 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('')),
});
// --- 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.
const rateWindowMin = Number(process.env.CONTACT_RATE_WINDOW_MIN ?? 15);
const rateLimit = Number(process.env.CONTACT_RATE_LIMIT ?? 5);
const hits = new Map<string, { count: number; reset: number }>();
function rateLimited(ip: string): boolean {
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';
}
// --- 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 } = parsed.data;
// Rate limit.
const ip = clientIp(request);
if (rateLimited(ip)) {
return json({ error: 'Too many submissions. Please try again later.' }, 429);
}
// 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 smtpHost = process.env.SMTP_HOST;
const webhook = process.env.CONTACT_WEBHOOK_URL;
if (smtpHost) {
// Dynamic import keeps nodemailer out of cold-start when unused.
const nodemailer = (await import('nodemailer')).default;
const transport = nodemailer.createTransport({
host: smtpHost,
port: Number(process.env.SMTP_PORT ?? 587),
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
auth: process.env.SMTP_USER
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
const hostname = (() => {
try {
return new URL(process.env.SITE_URL ?? 'https://patiun.com').hostname;
} catch {
return 'patiun.com';
}
})();
await transport.sendMail({
from: process.env.SMTP_FROM ?? `Greg's Site <no-reply@${hostname}>`,
to: process.env.CONTACT_TO ?? 'greg@patiun.com',
replyTo: `${msg.name} <${msg.email}>`,
subject: `New message from ${msg.name} via patiun.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;
}
+141
View File
@@ -0,0 +1,141 @@
---
/**
* 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 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';
const professional = [
{ icon: 'email-outline', label: 'Email', hint: 'Greg professional email address.' },
{ icon: 'linkedin', label: 'LinkedIn', hint: 'LinkedIn profile URL.' },
{ icon: 'github', label: 'GitHub', hint: 'GitHub profile URL.' },
];
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.' },
];
---
<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."
>
<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-mono text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">
Professional
</h2>
<ul class="flex flex-col gap-3">
{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);">{item.hint}</span>
</span>
</li>
))}
</ul>
<Placeholder label="Professional links" hint="Replace the slots above with your real email / LinkedIn / GitHub once ready." />
</section>
</Reveal>
{/* Social / personal */}
<Reveal delay={80}>
<section class="flex flex-col gap-4">
<h2 class="font-mono 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-accent); 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 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-sans" 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 />
</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-accent); 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>
+202
View File
@@ -0,0 +1,202 @@
---
/**
* 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 SacredGeometry from '@components/mystic/SacredGeometry.astro';
// 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 apps 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 running two campaigns in Foundry VTT and playtesting MCDM's Crows. 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, and whatever else catches my eye. If it involves layers and precision, I am in.',
href: '/interests/making',
image: '/images/making-resin.jpg',
alt: 'Resin crafting in progress',
},
];
---
<BaseLayout>
<!-- ===================== HERO ===================== -->
<section class="relative flex min-h-[88vh] items-center overflow-hidden pt-16">
<AnimatedBackground client:idle />
<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-mono text-xs uppercase tracking-[0.18em]"
style="border-color: var(--color-border); color: var(--color-text-muted); background: color-mix(in srgb, var(--color-surface-raised) 60%, 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(--color-text);">
Greg Kilmer
</h1>
<p
class="mt-3 font-sans text-xl font-medium sm:text-2xl"
style="color: var(--color-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-accent); color: var(--color-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,
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. Swap for a real photo of Greg. */}
<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">
{/* Faint sacred-geometry ornament behind the section. */}
<SacredGeometry
figure="flower"
class="pointer-events-none absolute -right-40 top-10 h-[40rem] w-[40rem]"
style="color: var(--color-violet); opacity:.06;"
/>
<div class="container-prose relative 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-sans" 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-accent);" />
</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-mono 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>
+98
View File
@@ -0,0 +1,98 @@
---
/**
* 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 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 platforms = [
{ icon: 'sony-playstation', title: 'PlayStation 5', body: 'Main console for AAA titles and exclusives.' },
{ icon: 'nintendo-switch', title: 'Nintendo Switch / Switch 2', body: 'Handheld and docked. Nintendo first-party plus indie gems.' },
{ icon: 'steam', title: 'Steam', body: 'The primary PC library. Strategy, RPGs, and an ever-growing backlog.' },
];
const genres = [
'RPGs (both Western and JRPG)',
'Strategy and tactics',
'Open-world exploration',
'Indie darlings',
];
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="sony-playstation"
title="Gaming"
tagline="If it has a screen or a controller, I have probably played it."
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 is my other constant. I span console generations with a PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. I'm particularly drawn to RPGs, strategy games, and anything with a deeply layered world. If a game has lore compendiums, I'm reading them.</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}>
<ul class="flex flex-wrap gap-3">
{genres.map((g) => (
<li class="pill text-sm">
<Icon name="star-four-points" class="h-3.5 w-3.5" />
{g}
</li>
))}
</ul>
</Reveal>
</section>
{/* Currently */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="Currently" icon="calendar-clock" title="Now playing" /></Reveal>
<div class="grid gap-4 sm:grid-cols-[1fr_1fr]">
<figure
class="relative overflow-hidden rounded-xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
>
<img src="/images/gaming-setup.jpg" alt="Gaming setup" loading="lazy" decoding="async" class="aspect-[4/3] w-full object-cover" />
</figure>
<Placeholder label="Currently playing" hint="Add the games you are actively playing right now." />
</div>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+142
View File
@@ -0,0 +1,142 @@
---
/**
* 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 warm (cream/amber) 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 SacredGeometry from '@components/mystic/SacredGeometry.astro';
const interests = [
{
href: '/interests/ttrpgs',
icon: 'dice-multiple',
title: 'TTRPGs & Tabletop',
tagline: 'Three campaigns, two systems, one rolling table.',
blurb: 'Long-running D&D and Daggerheart campaigns on Foundry VTT, plus the Crows playtest. Worldbuilding, NPC relationships, and player-driven narrative.',
image: '/images/ttrpg-dice.jpg',
alt: 'Polyhedral dice on a dark surface',
},
{
href: '/interests/gaming',
icon: 'sony-playstation',
title: 'Gaming',
tagline: 'If it has a screen or a controller, I have probably played it.',
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 with controller',
},
{
href: '/interests/reading',
icon: 'book-open-page-variant',
title: 'Reading & Media',
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, anime, and film.',
image: '/images/fantasy-books.jpg',
alt: 'Fantasy bookshelf',
},
{
href: '/interests/making',
icon: 'palette',
title: 'Making & Creating',
tagline: 'If it involves layers and precision, I am in.',
blurb: 'Resin dice in a pressure pot, miniatures and terrain on the Elegoo Mars 4 and Bambu Labs P1S. Much of it ends up on the virtual table.',
image: '/images/making-resin.jpg',
alt: 'Resin crafting in progress',
},
{
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="relative overflow-hidden pt-28 sm:pt-32">
<AnimatedBackground client:idle particles={false} />
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-12">
<Reveal>
<SectionHeader
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">
<SacredGeometry
figure="metatron"
class="pointer-events-none absolute -left-48 bottom-0 h-[38rem] w-[38rem]"
style="color: var(--color-accent); opacity:.05;"
/>
<div class="container-prose relative">
<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-sans" 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>
+97
View File
@@ -0,0 +1,97 @@
---
/**
* 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: 'palette', 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: 'wrench', title: 'General Making', body: 'Always experimenting. 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.' },
];
---
<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="If it involves layers and precision, I am in."
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 how I decompress. I cast resin dice using pressure pots and custom molds, print miniatures and props on my Elegoo Mars 4 (resin) and Bambu Labs P1S (FDM), and generally fill my workspace with projects-in-progress. There's something deeply satisfying about taking raw material through a process and holding the finished piece, whether that's a perfect polyhedral set or a printed terrain piece for the next game session.</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" aspect="4/3" />
<CelestialImage src="/images/making-print.jpg" alt="3D printed miniature" aspect="4/3" />
<CelestialImage src="/images/ttrpg-dice.jpg" alt="Terrain piece and dice" aspect="4/3" />
</div>
</Reveal>
<Reveal delay={120}>
<Placeholder label="Your real photos" hint="Swap these stock stand-ins with photos of your own resin casts and prints." />
</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>
+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: 'sony-playstation', 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-mono 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>
+106
View File
@@ -0,0 +1,106 @@
---
/**
* reading.astro — Reading & Media
* ---------------------------------------------------------------------------
* Per-interest template with the spec's reading highlights table (status pills
* mapped from spec emoji statuses), anime favorites, and movies/TV note.
*/
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 Icon from '@components/Icon.astro';
// Spec reading highlights, with status mapped from the spec's emoji statuses.
const reading = [
{ icon: 'check-decagram', title: 'Brandon Sanderson — Cosmere', status: 'Complete', body: 'Complete. Every novel, every era. The gold standard for me.' },
{ icon: 'check-decagram', title: 'Frank Herbert — Dune', status: 'Complete', body: 'Books 1-3 complete.' },
{ icon: 'autorenew', title: 'Douglas Preston & Lincoln Child — Pendergast', status: 'Rereading', body: 'Currently rereading. Up to Reliquary.' },
{ icon: 'autorenew', title: 'Sarah J. Maas — Throne of Glass', status: 'Reading', body: 'Currently on Heir of Fire (Book 3).' },
{ icon: 'new-box', title: 'Wistoria: Wand and Sword', status: 'New', body: 'Recently added to the reading list.' },
];
const anime = [
'One Piece', 'Bleach', 'Gundam (all series)', 'Code Geass',
'Magi', 'That Time I Got Reincarnated as a Slime', 'Overlord',
];
const related = [
{ href: '/interests/ttrpgs', title: 'TTRPGs & Tabletop', icon: 'dice-multiple', blurb: 'Worldbuilding inspiration flows both ways.' },
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Props and displays for the stories I love.' },
];
---
<BaseLayout
surface="interest"
title="Reading & Media"
description="The Cosmere, Dune, the Pendergast saga, Throne of Glass. Plus light novels, manga, manhwa, anime, and film. If a story exists, I am probably consuming it."
>
<InterestHero
icon="book-open-page-variant"
title="Reading & Media"
tagline="Perpetually three books ahead of my to-read list."
image="/images/reading-nook.jpg"
imageAlt="A cozy reading nook with shelves of books"
/>
<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 read like it's a competitive sport. Fantasy is my native language, Brandon Sanderson's Cosmere is fully consumed and eagerly awaiting the next era. Frank Herbert's Dune trilogy shaped how I think about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga is my comfort reread. And I'm currently working through Sarah J. Maas's Throne of Glass series.</p></Reveal>
<Reveal delay={80}><p>Beyond novels, I read light novels, comic books, manhwa, and manga. I watch anime and film extensively. If a story exists in any format, I'm probably consuming it.</p></Reveal>
</section>
{/* Reading highlights */}
<section class="flex flex-col gap-6">
<Reveal><SectionHeader kicker="On the shelf" icon="bookshelf" title="Reading highlights" /></Reveal>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{reading.map((r, i) => (
<Reveal delay={i * 70}>
<InterestCard icon={r.icon} title={r.title} status={r.status}>{r.body}</InterestCard>
</Reveal>
))}
</div>
</section>
{/* Anime */}
<section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Animated" icon="television-classic" title="Anime favorites" /></Reveal>
<Reveal delay={80}>
<ul class="flex flex-wrap gap-3">
{anime.map((a) => (
<li class="pill text-sm">
<Icon name="play-circle" class="h-3.5 w-3.5" />
{a}
</li>
))}
</ul>
</Reveal>
</section>
{/* Movies & TV */}
<section class="flex flex-col gap-5">
<Reveal><SectionHeader kicker="Beyond the page" icon="movie-open" title="Movies & TV" /></Reveal>
<Reveal delay={80}>
<div class="grid gap-5 sm:grid-cols-[1fr_18rem]">
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-secondary); color: var(--color-text-muted);"
>
Watches extensively across genres. Particularly drawn to sci-fi, fantasy, and animated film.
</blockquote>
<figure
class="relative overflow-hidden rounded-xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
>
<img src="/images/fantasy-books.jpg" alt="Fantasy and media collection" loading="lazy" decoding="async" class="aspect-[4/3] w-full object-cover" />
</figure>
</div>
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+114
View File
@@ -0,0 +1,114 @@
---
/**
* 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';
const highlights = [
{ icon: 'castle', title: 'Dungeons & Dragons', body: 'Long-running campaign on 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: 'bird', title: 'Crows (Playtest)', body: "MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback." },
{ icon: 'monitor', title: 'Foundry VTT', body: 'Self-hosted on the home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns.' },
{ icon: 'target', title: 'Draw Steel', body: "MCDM's upcoming tactical combat game. Interested in running it when it releases." },
];
const related = [
{ href: '/interests/making', title: 'Making & Creating', icon: 'palette', blurb: 'Dice and terrain made for campaigns.' },
{ href: '/interests/gaming', title: 'Gaming', icon: 'sony-playstation', blurb: 'Digital RPGs as another expression of the same impulse.' },
];
---
<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="Three campaigns, two systems, one rolling table."
image="/images/ttrpg-table.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>Tabletop gaming isn't a hobby for me, it's a practice. I run Dungeons & Dragons and Daggerheart campaigns that span years of real-time play, maintaining worlds, NPCs, and narrative threads that my players remember years later. I host everything on Foundry VTT, which lets me build the kind of dynamic, visually rich experiences that keep players invested session after session.</p></Reveal>
<Reveal delay={80}><p>Right now, I'm also playtesting MCDM's <em>Crows</em>, Matt Colville's forthcoming RPG built around hex-crawling, faction play, and hard travel rules. The playtest has already influenced how I think about overland encounters in my own campaigns.</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-secondary); 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.
</blockquote>
</Reveal>
<Reveal delay={140}>
<div class="grid gap-4 sm:grid-cols-[2fr_1fr]">
<blockquote
class="border-l-2 pl-5 text-lg leading-relaxed"
style="border-color: var(--color-secondary); 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.
</blockquote>
<figure
class="relative overflow-hidden rounded-xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);"
>
<img src="/images/ttrpg-dice.jpg" alt="Dice and game pieces" loading="lazy" decoding="async" class="h-full w-full object-cover" />
</figure>
</div>
</Reveal>
<Reveal delay={200}>
<Placeholder
label="Board game shelf"
hint="Replace this with a real photo of your game shelf once you have one."
/>
</Reveal>
</section>
<RelatedInterests items={related} />
</div>
</BaseLayout>
+207
View File
@@ -0,0 +1,207 @@
---
/**
* 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 Placeholder from '@components/layout/Placeholder.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 AlchemicalDivider from '@components/mystic/AlchemicalDivider.astro';
import Astrolabe from '@components/mystic/Astrolabe.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 -->
<section class="relative overflow-hidden pt-28 sm:pt-32">
{/* Slowly rotating astrolabe ornament behind the header. */}
<Astrolabe
class="pointer-events-none absolute -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow"
style="color: var(--color-accent); opacity:.10;"
/>
<div class="container-prose relative flex flex-col gap-10">
<Reveal>
<SectionHeader
kicker="Professional"
icon="briefcase"
title="Resume &amp; portfolio"
subtitle="Clean, confident, skimmable. Browsable here, screenshot-able for a recruiter, printable if needed."
/>
</Reveal>
<Reveal delay={80}>
<SectionNav />
</Reveal>
</div>
</section>
<div class="container-prose flex flex-col gap-20 py-16 sm:gap-24 sm:py-20">
{/* ===== 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-accent); 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-accent) 35%, 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-mono text-[0.65rem] uppercase tracking-[0.18em] backdrop-blur-sm"
style="background: color-mix(in srgb, var(--color-ink) 60%, transparent); color: var(--color-cream);"
>
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-accent);" />
</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 &amp; 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. Replace the placeholder with 3-6 of your own."
/>
</Reveal>
<div class="grid gap-5 sm:grid-cols-2">
{projects.map((project, i) => (
<Reveal delay={i * 80}>
<ProjectCard project={project} />
</Reveal>
))}
</div>
<Placeholder
label="Add 3-6 projects"
hint="Drop a new markdown file in src/content/projects/ for each notable project. See docs/CONTENT.md."
/>
</section>
{/* ===== 5. Education ===== */}
<section id="education" class="scroll-mt-28 flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="05"
icon="school"
title="Education &amp; certifications"
subtitle="My foundation."
/>
</Reveal>
<div class="grid gap-4 sm:grid-cols-2">
<Placeholder label="Education history" hint="Add degrees, institutions, and dates." />
<Placeholder label="Certifications" hint="SAP, cloud, or other relevant certifications." />
</div>
</section>
{/* ===== 6. Contact ===== */}
<section id="contact" class="scroll-mt-28 flex flex-col gap-6">
<Reveal>
<SectionHeader
kicker="06"
icon="email"
title="Let's talk"
subtitle="Open to opportunities in cloud development, SAP BTP, 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);" />
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-accent);">Email: [Greg to fill]</span>
</li>
<li class="flex items-center gap-2">
<Icon name="linkedin" class="h-5 w-5" style="color: var(--color-accent);" />
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-accent);">LinkedIn: [Greg to fill]</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-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-accent);">GitHub: [Greg to fill]</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>