Initial Commit
@@ -0,0 +1,17 @@
|
||||
# Keep the Docker build context lean.
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
@@ -0,0 +1,34 @@
|
||||
# =============================================================================
|
||||
# Greg Kilmer Profile Site - Environment Variables
|
||||
# =============================================================================
|
||||
# Copy this file to `.env` (local dev) or pass via Cosmos container environment.
|
||||
# Never commit your real `.env`. All values are optional unless noted.
|
||||
# =============================================================================
|
||||
|
||||
# --- Server -----------------------------------------------------------------
|
||||
# Port and host the Node server binds to. Cosmos reverse proxy should forward here.
|
||||
PORT=4321
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Canonical site URL (used for absolute metadata URLs and API responses).
|
||||
SITE_URL=https://patiun.com
|
||||
|
||||
# --- Contact form -> /api/contact -------------------------------------------
|
||||
# Two delivery modes. If SMTP is configured, email is sent. Otherwise, if a webhook
|
||||
# URL is set, the payload is POSTed there (e.g. a Discord/Slack/n8n webhook). If
|
||||
# neither is set, the API returns 503 so you can wire delivery later.
|
||||
|
||||
# SMTP (nodemailer). CONTACT_TO is the inbox that receives submissions.
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=
|
||||
# SMTP_PASS=
|
||||
# CONTACT_TO=greg@patiun.com
|
||||
# SMTP_FROM="Greg's Site <no-reply@patiun.com>"
|
||||
|
||||
# Optional webhook fallback (JSON POST).
|
||||
# CONTACT_WEBHOOK_URL=
|
||||
|
||||
# Honeypot / basic abuse control. Maximum submissions per IP per window.
|
||||
CONTACT_RATE_LIMIT=5
|
||||
CONTACT_RATE_WINDOW_MIN=15
|
||||
@@ -0,0 +1,30 @@
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# macOS / editor
|
||||
.DS_Store
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.idea/
|
||||
|
||||
# misc
|
||||
*.local
|
||||
@@ -0,0 +1,53 @@
|
||||
# =============================================================================
|
||||
# Dockerfile — Greg Kilmer Profile Site
|
||||
# =============================================================================
|
||||
# Multi-stage build producing a minimal Node server image for the Cosmos homelab.
|
||||
#
|
||||
# Stage 1 (builder): install all deps, run astro build -> dist/server/entry.mjs
|
||||
# Stage 2 (runtime): copy only dist + prod node_modules, run the Node server
|
||||
#
|
||||
# The @astrojs/node standalone adapter emits a self-contained server, so the
|
||||
# runtime image needs only node_modules + dist. Build once, run anywhere.
|
||||
# =============================================================================
|
||||
|
||||
# ---------- Stage 1: build ----------
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps. Copy manifests first to leverage Docker layer caching.
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund --legacy-peer-deps
|
||||
|
||||
# Copy source and build. SITE_URL can be overridden at build time.
|
||||
COPY . .
|
||||
ARG SITE_URL=https://patiun.com
|
||||
ENV SITE_URL=$SITE_URL
|
||||
RUN npm run build
|
||||
|
||||
# Prune to production dependencies for the runtime stage.
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# ---------- Stage 2: runtime ----------
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Run as a non-root user for defense in depth.
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
|
||||
# Copy production node_modules and the built server + client assets.
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
# Astro Node standalone server reads HOST/PORT from env at runtime.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
USER app
|
||||
|
||||
# Healthcheck: the server responds 200 on /.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -0,0 +1,468 @@
|
||||
# Greg Kilmer — Personal Profile Website Specification
|
||||
|
||||
> **Document Purpose:** This spec defines all pages, sections, copy, content flow, and structural relationships for a personal profile website serving as both a hobby/interest hub and a professional resume-portfolio. It is intended as a build-ready reference for web development.
|
||||
|
||||
> **Design Philosophy:** The site should feel like two wings of the same house — the professional side is polished and precise; the personal side is warm and inviting. They share a consistent design language (typography, color palette, spacing) but differ in tone. Navigation makes it easy to cross between them without confusion.
|
||||
|
||||
> **Human Voice:** Do not use emojis, use mdi icons when appropriate. Do not use em-dashes.
|
||||
---
|
||||
|
||||
## 1. Global Structure & Navigation
|
||||
|
||||
### Domain
|
||||
- `patiun.com`
|
||||
|
||||
### Site Map
|
||||
```
|
||||
Home (Landing)
|
||||
├── About Me
|
||||
├── Professional (Resume/Portfolio)
|
||||
│ ├── Experience
|
||||
│ ├── Skills & Technologies
|
||||
│ ├── Projects
|
||||
│ └── Contact
|
||||
├── Interests
|
||||
│ ├── TTRPGs & Tabletop
|
||||
│ ├── Gaming
|
||||
│ ├── Reading & Media
|
||||
│ ├── Making & Creating
|
||||
│ └── Pokémon
|
||||
└── Contact / Connect
|
||||
```
|
||||
|
||||
### Navigation Design
|
||||
- **Primary nav:** Horizontal top bar with two visual groupings separated by a subtle divider:
|
||||
- **Left group:** Home, About
|
||||
- **Right group:** Professional, Interests, Contact
|
||||
- The grouping signals "who I am" vs. "what I do" without labeling them that way explicitly.
|
||||
- **Persistent:** Nav remains fixed on scroll with a slight background blur on scroll.
|
||||
- **Mobile:** Hamburger menu with the same two-group structure.
|
||||
- A subtle tagline appears under the nav or in the logo area: *"Developer. Maker. Storyteller."*
|
||||
|
||||
### Color Palette Suggestion
|
||||
- **Primary:** Deep slate or charcoal (#1E293B) — professional confidence
|
||||
- **Accent:** Warm amber/gold (#F59E0B) — ties to the making/creative side
|
||||
- **Secondary:** Soft Teal (#699a94) — favorite color group and works with gold and charcoal
|
||||
- **Text:** Near-black (#0F172A) on light, cream-white (#FFFBEB) on dark sections
|
||||
- The palette works equally for a dark-mode resume page and a warm-toned interests page.
|
||||
|
||||
### Typography
|
||||
- Headings: A geometric sans-serif (e.g., Inter, Plus Jakarta Sans)
|
||||
- Body: Same family or a readable serif for longer-form reading content
|
||||
- Accent/quotes: A monospace or display font sparingly for pull quotes or section numbers
|
||||
|
||||
---
|
||||
|
||||
## 2. Home — Landing Page
|
||||
|
||||
**Purpose:** First impression. A brief, compelling snapshot that makes visitors want to explore both the professional and personal sides.
|
||||
|
||||
### Hero Section
|
||||
- Full-width hero with a subtle animated or gradient background (dark charcoal to warm amber at edges).
|
||||
- **Headline:** `Greg Kilmer`
|
||||
- **Subheadline:** `Software Developer • Maker • Tabletop Gamemaster`
|
||||
- **One-paragraph intro:**
|
||||
|
||||
> I build things with code during the day and with resin, plastic, and imagination at night. I lead cloud and mobile development at Implico, run D&D campaigns that span years, and fill my shelves withBrandon Sanderson novels, custom dice, and board games. This is the place where all of those worlds meet.
|
||||
|
||||
- **Two CTA buttons, side by side:**
|
||||
- **Primary (amber):** "See My Work" → links to `/professional`
|
||||
- **Secondary (outlined):** "Explore My Interests" → links to `/interests`
|
||||
|
||||
### Below the Fold — Snapshot Grid
|
||||
A 3-column grid of cards giving a teaser for each major section. Each card has an icon, a short description, and a "Learn more →" link.
|
||||
|
||||
| Card | Icon | Teaser Copy |
|
||||
|------|------|-------------|
|
||||
| **Professional** | 💼 | From SAP-adjacent cloud apps to full-stack mobile — a decade of building software that businesses actually use. |
|
||||
| **Tabletop** | 🎲 | Currently running two campaigns in Foundry VTT and playtesting MCDM's Crows. Always rolling dice. |
|
||||
| **Making** | 🎨 | Resin dice, 3D prints, and whatever else catches my eye. If it involves layers and precision, I'm in. |
|
||||
|
||||
A fourth card spans the bottom or sits as a wider strip:
|
||||
|
||||
| Card | Teaser Copy |
|
||||
|------|-------------|
|
||||
| **Reading & Media** | Deep in the Cosmere, catching up on anime, and always three books ahead on my to-read list. |
|
||||
|
||||
### Footer
|
||||
- Minimal footer with social links (GitHub, LinkedIn, Discord) and copyright.
|
||||
- Subtle: *"Built with intention. Powered by caffeine."*
|
||||
|
||||
---
|
||||
|
||||
## 3. About Me Page
|
||||
|
||||
**Purpose:** The human story. Who Greg is beyond the resume and the hobby list. This bridges the professional and personal sides.
|
||||
|
||||
### Section 1: The Short Version
|
||||
> I'm Greg — a software developer based in Philadelphia who spends his free time running tabletop RPGs, making resin dice, 3D printing miniatures and props, and reading his way through fantasy series at an alarming rate. I live with my partner Krystina, our mini dachshund Kashi, and more board games than any reasonable apartment should hold.
|
||||
|
||||
### Section 2: The Longer Arc
|
||||
> I grew up surrounded by books and games, and not much has changed. Technology pulled me into a career building cloud and mobile applications, but I never stopped collecting dice, designing campaigns, or losing weekends to Sanderson rereads. I'm currently expanding into SAP's Business Technology Platform, exploring AI and machine learning, and trying to convince my group that yes, we *can* fit one more board game into game night.
|
||||
>
|
||||
> I believe the best work happens when curiosity isn't compartmentalized. The same system-thinking that makes a good developer makes a good gamemaster. The patience that produces a clean resin cast is the same patience that debugs a stubborn deployment. This site is where I don't have to pretend those things are separate.
|
||||
|
||||
### Section 3: Quick Facts (Sidebar or Inline Grid)
|
||||
- 📍 Philadelphia, PA
|
||||
- 💼 Software Developer at Implico Inc.
|
||||
- 🐕 Dog dad to Kashi the mini dachshund
|
||||
- 🎲 Running D&D & Daggerheart campaigns
|
||||
- 📚 Currently reading: *Heir of Fire* by Sarah J. Maas
|
||||
- 🍵 Fueled by loose-leaf Chinese tea and Mountain Dew
|
||||
|
||||
### Flow: About → Professional & Interests
|
||||
The About page ends with a soft transition: a split section with two directional paths:
|
||||
- Left: *"See where I apply that curiosity professionally →"* (links to Professional)
|
||||
- Right: *"See where it takes me outside of work →"* (links to Interests)
|
||||
|
||||
This reinforces that the two sides are connected, not siloed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Professional — Resume / Portfolio
|
||||
|
||||
**Purpose:** Hire me. This section is clean, confident, and skimmable. It should work as both a browsable website and something a recruiter can screenshot or print.
|
||||
|
||||
### Section 1: Professional Summary
|
||||
|
||||
> Software developer and team lead with deep experience in cloud and mobile application development, currently focused on SAP-adjacent solutions at Implico Inc. Expanding into SAP Business Technology Platform (BTP) while maintaining a broad technical foundation across AI/ML, full-stack development, and automation. Known for bridging complex technical requirements with practical, maintainable solutions. Strong communicator who thrives at the intersection of engineering and business needs.
|
||||
|
||||
### Section 2: Experience
|
||||
|
||||
Presented as a vertical timeline with expandable detail.
|
||||
|
||||
**Implico Inc. — Software Developer / Lead Developer / Team Lead**
|
||||
- *Focus:* Cloud and mobile application development, SAP-adjacent solutions
|
||||
- *Current expansion:* SAP Business Technology Platform (BTP)
|
||||
- *Key bullets (template — fill in specifics):*
|
||||
- Lead development on [cloud/mobile project], delivering [specific outcome]
|
||||
- Architect and maintain [system/platform], supporting [X users/clients]
|
||||
- Collaborate with cross-functional teams to translate business requirements into technical solutions
|
||||
- Mentor junior developers and establish coding standards and review practices
|
||||
- Drive adoption of new technologies and frameworks within the team
|
||||
|
||||
*(Note: Greg should fill in specific projects, metrics, and dates here. The structure is ready.)*
|
||||
|
||||
### Section 3: Skills & Technologies
|
||||
|
||||
Organized in visual skill bars or a categorized grid. Three tiers:
|
||||
|
||||
**Core Competencies**
|
||||
- Cloud Architecture & Development
|
||||
- Mobile Application Development
|
||||
- SAP Business Technology Platform (BTP)
|
||||
- Team Leadership & Technical Mentorship
|
||||
- Cross-functional Communication
|
||||
|
||||
**Technical Stack**
|
||||
- *Languages:* [Fill in — likely JavaScript/TypeScript, Python, Java, ABAP given SAP focus]
|
||||
- *Frameworks:* [Fill in]
|
||||
- *Cloud Platforms:* SAP BCP, [others]
|
||||
- *Tools:* Git, CI/CD pipelines, [fill in]
|
||||
- *AI/ML:* Hands-on experience with machine learning frameworks and LLM integration
|
||||
|
||||
**Emerging & Exploring**
|
||||
- SAP BTP deep-dive (active expansion)
|
||||
- AI-powered development tooling
|
||||
- Advanced automation and workflow engineering
|
||||
|
||||
### Section 4: Projects
|
||||
|
||||
A card grid of notable projects. Each card:
|
||||
|
||||
**Project Card Template:**
|
||||
- **Title:** [Project Name]
|
||||
- **Role:** [Role — e.g., "Lead Developer", "Solo Developer"]
|
||||
- **Tech:** [Tag pills — e.g., React, Node.js, SAP BTP]
|
||||
- **Summary:** 2-3 sentence description of what the project does and Greg's contribution.
|
||||
- **Links:** GitHub repo, live demo (if public), or case study.
|
||||
|
||||
*(Greg should populate with 3-6 projects. Placeholder cards can be used initially.)*
|
||||
|
||||
### Section 5: Education & Certifications
|
||||
|
||||
*(To be filled by Greg)*
|
||||
- Education history
|
||||
- Relevant certifications (SAP, cloud, etc.)
|
||||
|
||||
### Section 6: Contact — Professional
|
||||
|
||||
- **Email:** [Greg's professional email]
|
||||
- **LinkedIn:** [Link]
|
||||
- **GitHub:** [Link]
|
||||
- **Availability note:** "Open to opportunities in cloud development, SAP BTP, and technical leadership."
|
||||
|
||||
### Flow Within Professional Page
|
||||
The sections read as a narrative:
|
||||
1. **Summary** → "Here's who I am professionally"
|
||||
2. **Experience** → "Here's where I've proven it"
|
||||
3. **Skills** → "Here's what I bring to the table"
|
||||
4. **Projects** → "Here's what I've built"
|
||||
5. **Education** → "Here's my foundation"
|
||||
6. **Contact** → "Let's talk"
|
||||
|
||||
Each section subtly references the last. For example, the Skills section can highlight technologies mentioned in Experience, and Projects should showcase the skills in action.
|
||||
|
||||
---
|
||||
|
||||
## 5. Interests — The Personal Side
|
||||
|
||||
**Purpose:** This is the fun wing. It should feel warmer, more visual, and personality-driven than the professional side. It's where Greg's passions live — not as a list, but as stories and collections.
|
||||
|
||||
### Overall Tone for Interest Pages
|
||||
- More relaxed formatting — shorter paragraphs, more images, visual hierarchy through cards and grids
|
||||
- Each interest page follows a consistent template but adapts to the content type:
|
||||
- **Header:** Title + tagline
|
||||
- **Overview:** 2-3 paragraphs of narrative
|
||||
- **Highlights:** Visual cards or grids of specifics
|
||||
- **Currently:** What's active right now (keeps it feeling alive)
|
||||
|
||||
---
|
||||
|
||||
### 5a. TTRPGs & Tabletop
|
||||
|
||||
**Header Tagline:** *"Three campaigns, two systems, one rolling table."*
|
||||
|
||||
**Overview:**
|
||||
> Tabletop gaming isn't a hobby for me — it's a practice. I run Dungeons & Dragons and Daggerheart campaigns that span years of real-time play, maintaining worlds, NPCs, and narrative threads that my players remember years later. I host everything on Foundry VTT, which lets me build the kind of dynamic, visually rich experiences that keep players invested session after session.
|
||||
>
|
||||
> Right now, I'm also playtesting MCDM's *Crows* — Matt Colville's forthcoming RPG built around hex-crawling, faction play, and hard travel rules. The playtest has already influenced how I think about overland encounters in my own campaigns.
|
||||
|
||||
**Highlights (Card Grid):**
|
||||
|
||||
| Card | Content |
|
||||
|------|---------|
|
||||
| 🐉 **Dungeons & Dragons** | Long-running campaign on Foundry VTT. Focus on rich worldbuilding, meaningful NPC relationships, and player-driven narrative. |
|
||||
| 🗡️ **Daggerheart** | Darrington Press's dice-building RPG. Currently running a campaign — exploring how its mechanics create different story rhythms than D&D. |
|
||||
| 🐦 **Crows (Playtest)** | MCDM's tactical RPG in active playtest. Hex-based travel, revised travel roles, Miasma mechanics. Contributing playtest feedback. |
|
||||
| 🖥️ **Foundry VTT** | Self-hosted on my home server (Cosmos Lenovo Tiny). Building custom modules, maps, and automation for campaigns. |
|
||||
| 🎯 **Draw Steel** | MCDM's upcoming tactical combat game. Interested in running it when it releases. |
|
||||
|
||||
**Currently Playing/Running:**
|
||||
- [Campaign 1 name] — D&D, [day of week]s
|
||||
- [Campaign 2 name] — Daggerheart, [day of week]s
|
||||
- Crows playtest sessions — ongoing
|
||||
|
||||
**Board Games Section (within TTRPGs page or as a sub-section):**
|
||||
> Beyond RPGs, I maintain a growing board and card game collection. Favorites span strategy, deck-building, and social deduction. Game night is a regular event — and yes, there's always room for one more.
|
||||
|
||||
*(Consider a visual "shelf" or gallery showing box art for favorite games if images are available.)*
|
||||
|
||||
---
|
||||
|
||||
### 5b. Gaming
|
||||
|
||||
**Header Tagline:** *"If it has a screen or a controller, I've probably played it."*
|
||||
|
||||
**Overview:**
|
||||
> Gaming is my other constant. I span console generations with a PlayStation 5, Nintendo Switch 2, and a Steam library that has gotten politely out of hand. I'm particularly drawn to RPGs, strategy games, and anything with a deeply layered world. If a game has lore compendiums, I'm reading them.
|
||||
|
||||
**Platforms:**
|
||||
|
||||
| Card | Content |
|
||||
|------|---------|
|
||||
| 🎮 **PlayStation 5** | Main console for AAA titles and exclusives. |
|
||||
| 🔴 **Nintendo Switch / Switch 2** | Handheld and docked. Nintendo first-party plus indie gems. |
|
||||
| 💻 **Steam** | The primary PC library. Strategy, RPGs, and an ever-growing backlog. |
|
||||
|
||||
**Favorite Genres:**
|
||||
- RPGs (both Western and JRPG)
|
||||
- Strategy and tactics
|
||||
- Open-world exploration
|
||||
- Indie darlings
|
||||
|
||||
**Currently Playing:** *(Greg to fill in)*
|
||||
|
||||
---
|
||||
|
||||
### 5c. Reading & Media
|
||||
|
||||
**Header Tagline:** *"Perpetually three books ahead of my to-read list."*
|
||||
|
||||
**Overview:**
|
||||
> I read like it's a competitive sport. Fantasy is my native language — Brandon Sanderson's Cosmere is fully consumed and eagerly awaiting the next era. Frank Herbert's Dune trilogy shaped how I think about worldbuilding. Douglas Preston & Lincoln Child's Pendergast saga is my comfort reread. And I'm currently working through Sarah J. Maas's Throne of Glass series.
|
||||
>
|
||||
> Beyond novels, I read light novels, comic books, manhwa, and manga. I watch anime and film extensively. If a story exists in any format, I'm probably consuming it.
|
||||
|
||||
**Reading Highlights (Card Grid):**
|
||||
|
||||
| Card | Status |
|
||||
|------|--------|
|
||||
| ✅ **Brandon Sanderson — Cosmere** | Complete. Every novel, every era. The gold standard for me. |
|
||||
| ✅ **Frank Herbert — Dune** | Books 1-3 complete. |
|
||||
| 🔄 **Douglas Preston & Lincoln Child — Pendergast** | Currently rereading. Up to *Reliquary*. |
|
||||
| 🔄 **Sarah J. Maas — Throne of Glass** | Currently on *Heir of Fire* (Book 3). |
|
||||
| 🆕 **Wistoria: Wand and Sword** | Recently added to the reading list. |
|
||||
|
||||
**Anime Favorites (visual list with small banners or icons):**
|
||||
- One Piece
|
||||
- Bleach
|
||||
- Gundam (all series)
|
||||
- Code Geass
|
||||
- Magi
|
||||
- That Time I Got Reincarnated as a Slime
|
||||
- Overlord
|
||||
|
||||
**Movies & TV:**
|
||||
> Watches extensively across genres. Particularly drawn to sci-fi, fantasy, and animated film.
|
||||
|
||||
---
|
||||
|
||||
### 5d. Making & Creating
|
||||
|
||||
**Header Tagline:** *"If it involves layers and precision, I'm in."*
|
||||
|
||||
**Overview:**
|
||||
> Making is how I decompress. I cast resin dice using pressure pots and custom molds, print miniatures and props on my Elegoo Mars 4 (resin) and Bambu Labs P1S (FDM), and generally fill my workspace with projects-in-progress. There's something deeply satisfying about taking raw material through a process and holding the finished piece — whether that's a perfect polyhedral set or a printed terrain piece for the next game session.
|
||||
|
||||
**Equipment:**
|
||||
|
||||
| Card | Details |
|
||||
|------|---------|
|
||||
| 🎨 **Resin Dice Making** | Custom molds, pressure pot curing, pigment experimentation. Producing dice sets for personal use and gifts. |
|
||||
| 🖨️ **Elegoo Mars 4** | Resin 3D printer. Miniatures, terrain, and high-detail props. |
|
||||
| 🖨️ **Bambu Labs P1S** | FDM 3D printer. Functional prints, terrain pieces, and larger prototypes. |
|
||||
| 🛠️ **General Making** | Always experimenting. If there's a new technique or material, I want to try it. |
|
||||
|
||||
**Gallery Section:**
|
||||
> *(Placeholder for photo gallery of completed projects — dice sets, prints, terrain. Highly recommended for visual impact.)*
|
||||
|
||||
**Connection to TTRPGs:**
|
||||
Include a subtle callout: *"Much of what I make ends up on the virtual table — custom tokens, terrain, and props for Foundry VTT campaigns."* This creates a natural link back to the TTRPGs page.
|
||||
|
||||
---
|
||||
|
||||
### 5e. Pokémon
|
||||
|
||||
**Header Tagline:** *"Metagross supremacy. This is not up for debate."*
|
||||
|
||||
**Overview:**
|
||||
> Pokémon has been a lifelong obsession. From Red and Blue on the original Game Boy to modern competitive formats, I've followed the franchise through every generation. My favorite? Metagross — the iron leg Pokémon. Steel/Psychic, beautiful design, absolutely devastating in battle. No further questions.
|
||||
|
||||
**Favorites & Team:**
|
||||
- **Favorite Pokémon:** Metagross
|
||||
- **Roster:** *(Greg to fill — note: rosters should use standard Pokémon Showdown text format)*
|
||||
|
||||
```
|
||||
## Pokémon
|
||||
[Greg's preferred roster in Showdown format]
|
||||
```
|
||||
|
||||
**Games:**
|
||||
> *(List of mainline and spinoff titles played, if desired)*
|
||||
|
||||
---
|
||||
|
||||
### Interests Page Flow
|
||||
The interests pages should interlink naturally:
|
||||
- **TTRPGs** ↔ **Making** (dice and terrain made for campaigns)
|
||||
- **TTRPGs** ↔ **Gaming** (digital RPGs as another expression of the same impulse)
|
||||
- **Reading** ↔ **TTRPGs** (worldbuilding inspiration flows both ways)
|
||||
- **Gaming** ↔ **Pokémon** (Nintendo console overlap)
|
||||
- **Making** can link to **all of the above** (props for games, displays for collections)
|
||||
|
||||
Each interest page should end with a "You might also like..." section suggesting 2 related interest pages.
|
||||
|
||||
---
|
||||
|
||||
## 6. Contact / Connect Page
|
||||
|
||||
**Purpose:** A single place for all ways to reach Greg — professional and social.
|
||||
|
||||
### Layout
|
||||
Two columns:
|
||||
|
||||
**Professional:**
|
||||
- Email: [Professional email]
|
||||
- LinkedIn: [Link]
|
||||
- GitHub: [Link]
|
||||
- Availability note (if open to opportunities)
|
||||
|
||||
**Personal / Social:**
|
||||
- Discord: [Handle or link]
|
||||
- Twitter/X: [Link, if applicable]
|
||||
- Pokémon: [Showdown or Trainer name, if shared]
|
||||
- Foundry VTT: [Server link, if public]
|
||||
|
||||
**Closing note:**
|
||||
> Whether you want to talk cloud architecture, debate the best Gundam series, or need a fourth for game night — reach out.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-Page Relationships & User Journey
|
||||
|
||||
### For Recruiters / Hiring Managers
|
||||
1. **Home** → sees the split between professional and personal → clicks "See My Work"
|
||||
2. **Professional → Experience** → understands track record
|
||||
3. **Professional → Skills** → evaluates technical fit
|
||||
4. **Professional → Projects** → sees proof of ability
|
||||
5. **Professional → Contact** → reaches out
|
||||
6. *Optional detour:* **About Me** → gets a sense of personality and culture fit
|
||||
|
||||
### For Fellow Gamers / Friends
|
||||
1. **Home** → clicks "Explore My Interests"
|
||||
2. **Interests → TTRPGs** → reads about campaigns and playtest involvement
|
||||
3. **TTRPGs → Making** → sees terrain and dice for campaigns
|
||||
4. **TTRPGs → Gaming** → discovers shared gaming interests
|
||||
5. **Contact** → connects on Discord
|
||||
|
||||
### For Fellow Makers
|
||||
1. **Home** → clicks **Making** from snapshot grid
|
||||
2. **Making** → sees equipment, process, gallery
|
||||
3. **Making → TTRPGs** → sees how making connects to campaigns
|
||||
4. **Contact** → reaches out to collaborate or discuss techniques
|
||||
|
||||
---
|
||||
|
||||
## 8. Technical Recommendations
|
||||
|
||||
### Framework
|
||||
- Static site generator (Astro, Next.js, or 11ty) for performance and easy hosting
|
||||
- MDX or markdown content for blog-like sections (reading reviews, project writeups)
|
||||
- Responsive design — mobile-first
|
||||
|
||||
### Hosting
|
||||
- Vercel, Netlify, or Cloudflare Pages (free tier sufficient for a personal site)
|
||||
- Custom domain via the registrar of choice
|
||||
|
||||
### Content Management
|
||||
- Markdown files for easy updates
|
||||
- Image optimization (WebP, lazy loading)
|
||||
- Consider a headless CMS (like Sanity or Contentful) only if Greg wants to update frequently without touching code
|
||||
|
||||
### Performance Targets
|
||||
- Lighthouse score 90+ across all categories
|
||||
- Sub-2s initial load
|
||||
- Accessible (WCAG 2.1 AA)
|
||||
|
||||
### Future Expansions (Nice-to-Have)
|
||||
- **Blog section:** For longer-form thoughts on tech, game design, or reading reviews
|
||||
- **Project showcase:** Deeper case studies with screenshots and writeups
|
||||
- **Dice shop page:** If Greg ever wants to sell resin dice
|
||||
- **Campaign log:** A public-facing Foundry VTT campaign journal or session notes
|
||||
- **Reading tracker:** Integration with a reading tracker API or manual log
|
||||
|
||||
---
|
||||
|
||||
## 9. Content Greg Needs to Provide
|
||||
|
||||
To populate this site fully, Greg should prepare:
|
||||
|
||||
- [ ] Specific work experience details (dates, projects, metrics, technologies)
|
||||
- [ ] Technical stack specifics (languages, frameworks, tools)
|
||||
- [ ] 3-6 portfolio projects with descriptions and links
|
||||
- [ ] Education history and certifications
|
||||
- [ ] Professional email, LinkedIn, GitHub URLs
|
||||
- [ ] Campaign names and schedules (for TTRPGs page)
|
||||
- [ ] Pokémon Showdown roster
|
||||
- [ ] Photos: headshot, making/3D printing process shots, dice sets, game night, reading nook
|
||||
- [ ] Currently playing (games, books, shows)
|
||||
- [ ] Board game favorites list
|
||||
- [ ] Social handles (Discord, Twitter/X, etc.)
|
||||
- [ ] Domain preference
|
||||
|
||||
---
|
||||
|
||||
*Document prepared by Aurelius, the Curator 📜*
|
||||
*Created: July 3, 2026*
|
||||
*Status: Ready for review and population by Antares*
|
||||
@@ -0,0 +1,156 @@
|
||||
# Greg Kilmer — Profile & Portfolio Site
|
||||
|
||||
A responsive, content-driven personal site for Greg Kilmer, built as **two wings
|
||||
of the same house**: a polished professional resume/portfolio on one side, and a
|
||||
warm, personality-driven interests hub on the other.
|
||||
|
||||
Built with **Astro 7 + React Islands + TypeScript**, served by a **Node server**
|
||||
with API routes, ready to deploy as a container on a homelab Cosmos system.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Astro 7** static-first core with React Islands for interactivity (mobile
|
||||
menu, theme toggle, animated hero background, contact form).
|
||||
- **Node backend** (`@astrojs/node`, standalone) with a typed `/api/contact`
|
||||
endpoint (SMTP via nodemailer + webhook fallback, Zod validation, rate limit).
|
||||
- **Section-aware theming**: the Professional wing reads dark/polished, the
|
||||
Interests wing reads warm/inviting, plus a global light/dark toggle.
|
||||
- **Rich motion**: animated mesh-gradient hero with an optional canvas particle
|
||||
field, IntersectionObserver scroll reveals, View Transitions page swaps,
|
||||
scroll-triggered nav blur. All `prefers-reduced-motion` aware.
|
||||
- **Content Collections** (typed): add projects and experience by dropping
|
||||
markdown/MDX files.
|
||||
- **mdi icons everywhere** (no emojis, per the spec), inlined at build time from
|
||||
`@iconify-json/mdi` for zero runtime cost.
|
||||
- **Self-hosted variable fonts** (Inter, Plus Jakarta Sans, JetBrains Mono).
|
||||
- **Accessible**: semantic landmarks, skip link, keyboard-operable menu,
|
||||
focus-visible rings, ARIA on interactive bits, WCAG-AA contrast targets.
|
||||
- **Deploy artifacts**: multi-stage `Dockerfile` + `docker-compose.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies (Node 20+)
|
||||
npm install
|
||||
|
||||
# 2. Start the dev server with hot reload
|
||||
npm run dev
|
||||
# -> http://localhost:4321
|
||||
|
||||
# 3. Production build (runs astro check + build)
|
||||
npm run build
|
||||
|
||||
# 4. Run the built Node server
|
||||
npm run serve
|
||||
# -> http://localhost:4321
|
||||
```
|
||||
|
||||
Environment is optional for local dev. To enable contact-form delivery, copy
|
||||
`.env.example` to `.env` and fill in SMTP or webhook vars (see
|
||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── hero/ AnimatedBackground.tsx (gradient + canvas particles)
|
||||
│ ├── motion/ Reveal.astro (IntersectionObserver scroll reveals)
|
||||
│ ├── nav/ NavBar.astro, MobileMenu.tsx, ThemeToggle.tsx
|
||||
│ ├── cards/ InterestCard.astro
|
||||
│ ├── professional/ Timeline, SkillGrid, ProjectCard, SectionNav, ContactForm
|
||||
│ ├── layout/ Footer, SectionHeader, SplitTransition, InterestHero,
|
||||
│ │ RelatedInterests, Placeholder
|
||||
│ └── Icon.astro Inline mdi icon renderer (build-time)
|
||||
├── content/
|
||||
│ ├── projects/ Project collection entries (markdown/MDX)
|
||||
│ └── experience/ Experience collection entries
|
||||
├── layouts/
|
||||
│ └── BaseLayout.astro <html> shell, head, fonts, theme init, nav, footer
|
||||
├── pages/
|
||||
│ ├── index.astro Home (hero + snapshot grid)
|
||||
│ ├── about.astro About + split transition fork
|
||||
│ ├── professional/index.astro Single long page: Summary, Experience,
|
||||
│ │ Skills, Projects, Education, Contact
|
||||
│ ├── interests/ Hub + 5 sub-pages
|
||||
│ │ ├── index.astro
|
||||
│ │ ├── ttrpgs / gaming / reading / making / pokemon .astro
|
||||
│ ├── contact.astro Two-column connect page + contact form
|
||||
│ └── api/contact.ts POST endpoint -> SMTP / webhook
|
||||
├── content.config.ts Collection schemas (projects, experience)
|
||||
├── lib/ site.ts (nav/socials), motion.ts, content helpers
|
||||
├── styles/ global.css, themes.css, animations.css
|
||||
└── scripts/theme-init.ts No-FOUC theme boot (inlined in <head>)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The two wings (theming model)
|
||||
|
||||
Theming has two independent axes (see [docs/THEMING.md](docs/THEMING.md)):
|
||||
|
||||
1. **Global light/dark** on `<html data-theme>`, set before paint by an inline
|
||||
script, persisted to `localStorage`, toggleable via the nav button.
|
||||
2. **Section tone** via `data-surface` on `<body>`:
|
||||
- `neutral` (Home/About/Contact) — balanced middle ground
|
||||
- `professional` — dark slate, polished
|
||||
- `interest` — warm cream/amber, inviting
|
||||
|
||||
Components only reference semantic CSS variables (`--surface`, `--text`,
|
||||
`--accent`...), so changing either axis restyles everything automatically.
|
||||
|
||||
---
|
||||
|
||||
## Adding content
|
||||
|
||||
- **Projects / Experience:** drop a markdown or MDX file in
|
||||
`src/content/projects/` or `src/content/experience/`. Schemas and field
|
||||
reference are in [docs/CONTENT.md](docs/CONTENT.md).
|
||||
- **Interest pages:** hand-built `.astro` files under `src/pages/interests/`,
|
||||
each following the spec's template (Header, Overview, Highlights, Currently,
|
||||
Related). Edit copy in place.
|
||||
- **Placeholders:** every `[Greg to fill]` gap from the spec is a styled
|
||||
`Placeholder` slot with a TODO badge so you can find and replace them.
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Cosmos homelab)
|
||||
|
||||
The site runs as a Node server inside a container. Full walkthrough in
|
||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
# Then point your reverse proxy at port 4321 with TLS for patiun.com.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|---|---|
|
||||
| `npm run dev` | Dev server with hot reload |
|
||||
| `npm run build` | Type-check (`astro check`) then build the Node server |
|
||||
| `npm run serve` | Run the built server (`dist/server/entry.mjs`) |
|
||||
| `npm run preview` | Preview the build locally |
|
||||
| `npm run check` | Type-check only |
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
Astro 7, React 19, TypeScript 6, Tailwind CSS v4, Framer Motion, nodemailer,
|
||||
Zod, `@iconify-json/mdi`, Fontsource variable fonts, `@astrojs/node` standalone.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Personal site for Greg Kilmer. All rights reserved.
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Astro configuration for Greg Kilmer's profile site.
|
||||
*
|
||||
* Stack:
|
||||
* - @astrojs/node (standalone) -> Node server with API routes for the Cosmos homelab.
|
||||
* - @astrojs/react -> React 19 islands for interactive components (mobile menu,
|
||||
* theme toggle, animated hero background, contact form).
|
||||
* - @astrojs/mdx -> MDX content collections (projects, experience).
|
||||
* - @tailwindcss/vite -> Tailwind v4 via the Vite plugin (CSS-first config in styles).
|
||||
* - astro-icon + mdi set -> render Material Design Icons (no emojis, per spec).
|
||||
*
|
||||
* The `standalone` output produces `dist/server/entry.mjs`, launched by `npm run serve`
|
||||
* or by the Docker runtime. API routes under src/pages/api/* are served by the same server.
|
||||
*/
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import react from '@astrojs/react';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// `SITE_URL` lets us produce absolute URLs in metadata / API responses. Defaults to the
|
||||
// production domain from the spec; override locally or in CI as needed.
|
||||
const SITE_URL = process.env.SITE_URL ?? 'https://patiun.com';
|
||||
|
||||
// Astro exposes the configured server host/port to the Node adapter via env at runtime.
|
||||
// We read them here only for documentation; the adapter picks them up automatically.
|
||||
const PORT = Number(process.env.PORT ?? 4321);
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
export default defineConfig({
|
||||
// Used for canonical URLs, sitemaps, and absolute metadata. Keep this in sync with the
|
||||
// deployed domain on your Cosmos reverse proxy.
|
||||
site: SITE_URL,
|
||||
|
||||
// Server output mode. `standalone` emits a self-contained Node server we can run in a
|
||||
// container. This is what enables /api/* routes to execute server-side.
|
||||
output: 'server',
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
|
||||
// Page-to-page transitions using the native View Transitions API. Persists the nav and
|
||||
// animates content swaps. Honored automatically by Astro's <ClientRouter />.
|
||||
// (Icons are rendered inline at build time via src/components/Icon.astro, reading
|
||||
// directly from @iconify-json/mdi; no astro-icon integration needed.)
|
||||
integrations: [
|
||||
react(),
|
||||
mdx(),
|
||||
],
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
server: {
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
},
|
||||
|
||||
// Markdown rendering options shared by content collections and MDX.
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
theme: 'github-dark-dimmed',
|
||||
wrap: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
# =============================================================================
|
||||
# docker-compose.yml — Greg Kilmer Profile Site (Cosmos homelab)
|
||||
# =============================================================================
|
||||
# Builds and runs the site as a container behind your Cosmos reverse proxy.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy .env.example -> .env and fill in SMTP/contact vars.
|
||||
# 2. `docker compose up -d --build`
|
||||
# 3. Point your Cosmos reverse proxy (Traefik/Caddy/nginx) at this service on
|
||||
# port 4321, with TLS for patiun.com.
|
||||
#
|
||||
# See docs/DEPLOYMENT.md for the full walkthrough.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
greg-site:
|
||||
container_name: greg-profile-site
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Canonical URL baked in at build time for metadata.
|
||||
SITE_URL: ${SITE_URL:-https://patiun.com}
|
||||
image: greg-profile-site:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Map container 4321 -> host. If Cosmos reverse-proxies, you can instead
|
||||
# use `expose:` and join a shared network. Adjust to your setup.
|
||||
- "${HOST_PORT:-4321}:4321"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- SITE_URL=${SITE_URL:-https://patiun.com}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4321/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
@@ -0,0 +1,130 @@
|
||||
# Content Guide
|
||||
|
||||
How to add and edit content on the site. Two sections use **Content Collections**
|
||||
(typed markdown/MDX); the interest pages are hand-built `.astro` files.
|
||||
|
||||
---
|
||||
|
||||
## Projects (Professional page)
|
||||
|
||||
Projects render from `src/content/projects/*.md` (or `.mdx`). Each file is one
|
||||
project card. The schema lives in `src/content.config.ts`.
|
||||
|
||||
### Add a project
|
||||
|
||||
Create `src/content/projects/my-project.md`:
|
||||
|
||||
```md
|
||||
---
|
||||
title: 'My Awesome Project'
|
||||
role: 'Lead Developer' # optional, default: 'Developer'
|
||||
tech: ['TypeScript', 'Node.js', 'SAP BTP'] # rendered as pills
|
||||
summary: 'A 2-3 sentence description of what it does and your contribution.'
|
||||
repo: 'https://github.com/you/repo' # optional
|
||||
demo: 'https://demo.example.com' # optional
|
||||
caseStudy: 'https://blog.example.com' # optional
|
||||
cover: '/images/my-project.png' # optional, public path
|
||||
order: 20 # lower = shown first, default 100
|
||||
placeholder: false # true hides it from "real" styling
|
||||
---
|
||||
|
||||
Optional longer writeup in markdown here. The card only shows `summary`, but you
|
||||
can link to a case study for depth.
|
||||
```
|
||||
|
||||
### Field reference
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `title` | string | yes | Project name |
|
||||
| `role` | string | no | e.g. "Lead Developer" |
|
||||
| `tech` | string[] | no | Tag pills |
|
||||
| `summary` | string | yes | Short card description |
|
||||
| `repo` / `demo` / `caseStudy` | URL | no | Outbound links |
|
||||
| `cover` | string | no | Image path in `public/` |
|
||||
| `order` | number | no | Sort weight |
|
||||
| `placeholder` | boolean | no | Marks it as a TODO card |
|
||||
|
||||
To remove the seed placeholder, delete `placeholder-project.md`. Aim for 3-6 real
|
||||
entries per the spec.
|
||||
|
||||
---
|
||||
|
||||
## Experience (Professional timeline)
|
||||
|
||||
Experience renders from `src/content/experience/*.md`. Each file is one role on
|
||||
the vertical timeline.
|
||||
|
||||
### Add a role
|
||||
|
||||
Create `src/content/experience/another-role.md`:
|
||||
|
||||
```md
|
||||
---
|
||||
company: 'Previous Co.'
|
||||
title: 'Junior Developer'
|
||||
period: '2016 - 2020' # free-form date label
|
||||
focus: 'What you primarily did.' # optional
|
||||
icon: 'briefcase' # mdi icon name for the marker
|
||||
order: 20 # lower = more recent / first
|
||||
---
|
||||
|
||||
- Bullet point of an accomplishment.
|
||||
- Another bullet, ideally with a metric.
|
||||
- One more, showing impact.
|
||||
```
|
||||
|
||||
The body renders inside an expandable "Show details" panel. Use markdown lists
|
||||
for bullets. Replace the bracketed placeholders in the seed `implico.md` with
|
||||
real outcomes, metrics, and dates.
|
||||
|
||||
### Icon names
|
||||
|
||||
The `icon` field takes an mdi name without the `mdi:` prefix. Browse names at
|
||||
[pictogrammers.com](https://pictogrammers.com/library/mdi/) or search the
|
||||
installed set:
|
||||
|
||||
```bash
|
||||
node -e "const m=require('./node_modules/@iconify-json/mdi/icons.json'); console.log(Object.keys(m.icons).filter(n=>n.includes('cloud')).join('\n'))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interest pages
|
||||
|
||||
Each interest is a hand-built page in `src/pages/interests/`. They follow the
|
||||
spec's template: **Header** (title + tagline), **Overview** (narrative),
|
||||
**Highlights** (card grid), **Currently** (active now), **Related** (cross-links).
|
||||
|
||||
To edit content, open the relevant `.astro` file and change the copy in the
|
||||
frontmatter arrays or the section bodies. To add a new interest:
|
||||
|
||||
1. Create `src/pages/interests/my-interest.astro` (copy an existing page as a
|
||||
template).
|
||||
2. Use `<BaseLayout surface="interest">`, `<InterestHero>`, `<InterestCard>`, and
|
||||
`<RelatedInterests>`.
|
||||
3. Add a card for it in `src/pages/interests/index.astro` (the hub).
|
||||
|
||||
---
|
||||
|
||||
## Global content (nav, socials, tagline)
|
||||
|
||||
Edit `src/lib/site.ts`:
|
||||
|
||||
- `siteIdentity` — name, tagline, domain
|
||||
- `navGroups` — the two-group top nav
|
||||
- `socials` — footer + contact links (replace the `[Greg to fill]` handles)
|
||||
|
||||
---
|
||||
|
||||
## Finding placeholders
|
||||
|
||||
Every `[Greg to fill]` gap from the spec is rendered as a styled **Placeholder**
|
||||
slot (dashed border + pencil badge + "TODO · label"). To find them all:
|
||||
|
||||
```bash
|
||||
grep -rn "Placeholder label" src/
|
||||
grep -rn "Greg to fill\|\[Greg" src/
|
||||
```
|
||||
|
||||
Replace each with real content as you gather it.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Deployment Guide
|
||||
|
||||
The site runs as a **Node server inside a container** on your homelab Cosmos
|
||||
system, fronted by your reverse proxy (Traefik / Caddy / nginx) with TLS for
|
||||
`patiun.com`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Configure environment
|
||||
|
||||
Copy the example env file and fill in what you need:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
At minimum, set `SITE_URL` (your canonical public URL). To enable contact-form
|
||||
delivery, set **either** SMTP vars **or** `CONTACT_WEBHOOK_URL` (see
|
||||
[Contact delivery](#contact-delivery) below). Everything else is optional.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PORT` | Port the Node server binds to | `4321` |
|
||||
| `HOST` | Bind address (`0.0.0.0` for container) | `0.0.0.0` |
|
||||
| `SITE_URL` | Canonical URL for metadata | `https://patiun.com` |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | SMTP relay | unset |
|
||||
| `SMTP_FROM` | From address for outgoing mail | derived from `SITE_URL` |
|
||||
| `CONTACT_TO` | Inbox that receives submissions | `greg@patiun.com` |
|
||||
| `CONTACT_WEBHOOK_URL` | JSON webhook fallback (Discord/Slack/n8n) | unset |
|
||||
| `CONTACT_RATE_LIMIT` / `CONTACT_RATE_WINDOW_MIN` | Per-IP rate limit | `5` / `15` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Build & run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
This builds the multi-stage image (`Dockerfile`), starts the container, and maps
|
||||
host port `${HOST_PORT:-4321}` to container `4321`. Logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f greg-site
|
||||
```
|
||||
|
||||
Healthcheck hits `/` every 30s. Verify locally:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4321/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Reverse proxy + TLS
|
||||
|
||||
Point your Cosmos reverse proxy at the container on port `4321`. The two common
|
||||
shapes:
|
||||
|
||||
### Option A — Traefik (labels)
|
||||
|
||||
If your Cosmos uses Traefik, add labels to the service in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.greg-site.rule=Host(`patiun.com`)"
|
||||
- "traefik.http.routers.greg-site.entrypoints=websecure"
|
||||
- "traefik.http.routers.greg-site.tls.certresolver=your-resolver"
|
||||
- "traefik.http.services.greg-site.loadbalancer.server.port=4321"
|
||||
```
|
||||
|
||||
Then drop the `ports:` mapping and join the Traefik network instead.
|
||||
|
||||
### Option B — Caddy / nginx
|
||||
|
||||
Forward `patiun.com` (HTTPS) to `127.0.0.1:4321`. With Caddy, automatic TLS is a
|
||||
one-liner:
|
||||
|
||||
```caddyfile
|
||||
patiun.com {
|
||||
reverse_proxy 127.0.0.1:4321
|
||||
}
|
||||
```
|
||||
|
||||
Either way, ensure the proxy forwards `X-Forwarded-For` / `X-Real-IP` so the
|
||||
contact API's rate limiter sees real client IPs (not the proxy's).
|
||||
|
||||
---
|
||||
|
||||
## 4. Contact delivery
|
||||
|
||||
The `/api/contact` endpoint delivers submissions through the first configured
|
||||
path:
|
||||
|
||||
1. **SMTP** (if `SMTP_HOST` is set): sends a real email via nodemailer. Works
|
||||
with any SMTP relay (Gmail app-password, Mailgun, SES, your homelab mail
|
||||
server).
|
||||
2. **Webhook** (if `CONTACT_WEBHOOK_URL` is set and SMTP is not): POSTs a JSON
|
||||
payload `{ name, email, message, text }`. Great for a Discord/Slack/n8n
|
||||
inbound.
|
||||
3. **Neither configured**: returns `503` with a friendly "email me directly"
|
||||
message so the form never silently swallows input.
|
||||
|
||||
Test it:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4321/api/contact \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Test","email":"test@example.com","message":"Hello there."}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Updating the site
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The build stage reinstalls deps and rebuilds `dist/`; the runtime stage swaps in
|
||||
the new artifact with zero config.
|
||||
|
||||
---
|
||||
|
||||
## 6. Without Docker (bare Node)
|
||||
|
||||
If you prefer to run Node directly on the host:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
HOST=0.0.0.0 PORT=4321 SITE_URL=https://patiun.com node ./dist/server/entry.mjs
|
||||
```
|
||||
|
||||
Run it under a process manager (systemd, pm2) so it restarts on crash/boot.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Photo Guide
|
||||
|
||||
Every image on the site right now is a **stock stand-in** (from Unsplash, free, no
|
||||
attribution required) chosen to match the *vibe, pose, and style* of the real
|
||||
photo that should eventually live there. This file is the shopping list for
|
||||
**photos of yourself and your own stuff** — what to take or find, how to frame
|
||||
each one, and which file to overwrite.
|
||||
|
||||
Replace a photo by **dropping a new file at the same path in `public/images/`**.
|
||||
No code changes needed. Aim for roughly the recommended aspect ratio so the
|
||||
crop stays clean.
|
||||
|
||||
> General mood across the whole site: **moody, warm-low-light, "candlelit study
|
||||
> under a starfield."** Deep backgrounds, soft warm key light (window, lamp, or
|
||||
> a single softbox off to one side), shallow depth of field. The frames, gold
|
||||
> accents, and star background do the rest.
|
||||
|
||||
---
|
||||
|
||||
## 1. Portraits of you
|
||||
|
||||
These are the highest priority — they make the site feel like *yours*.
|
||||
|
||||
### `public/images/hero-portrait.jpg` — Home hero (used for the OG/social card too)
|
||||
- **Subject:** You, at your desk or workspace.
|
||||
- **Framing:** Portrait, roughly **4:5** (taller than wide). Waist-up or
|
||||
three-quarter. Head near the top third.
|
||||
- **Pose:** Natural, mid-thought or slight smile, looking just off-camera or at
|
||||
the screen. One hand on keyboard or holding something (a die, a mug).
|
||||
- **Light:** Warm side light from a window or lamp camera-left; let the
|
||||
background fall into shadow. A little rim light from behind separates you
|
||||
from the dark background.
|
||||
- **Background:** Dark/blurred workspace — monitors, books, dice shelf out of
|
||||
focus. Avoid pure white walls.
|
||||
- **Wardrobe:** What you actually wear to work; dark or jewel tones read best
|
||||
against the midnight palette.
|
||||
- **Resolution:** ≥ 1200×1500 px.
|
||||
|
||||
### `public/images/about-portrait.jpg` — About page sidebar
|
||||
- **Subject:** You, but warmer and more candid than the hero.
|
||||
- **Framing:** Portrait **4:5**. Can be a different environment than the desk —
|
||||
a reading chair, the making bench, the game table.
|
||||
- **Pose:** Relaxed, looking at camera or at a hobby object. Smile is good here.
|
||||
- **Light:** Soft, warm, even. Think "cozy evening."
|
||||
- **Resolution:** ≥ 1200×1500 px.
|
||||
|
||||
### Optional extras that would level the site up
|
||||
- **A tabletop "gamemaster" shot** — you behind the screen / Foundry VTT on a
|
||||
monitor, dice in hand. Could replace `ttrpg-table.jpg`.
|
||||
- **A making shot** — you casting resin or pulling a print, hands in frame.
|
||||
Could replace `making-resin.jpg`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Workspace & professional
|
||||
|
||||
### `public/images/pro-workspace.jpg` — Professional summary banner
|
||||
- **Subject:** Your actual desk / dev setup. Monitors with code, keyboard,
|
||||
a mug, low light.
|
||||
- **Framing:** Wide **21:9** landscape. Slight angle, depth.
|
||||
- **Mood:** "Late-night deploy." Dark room, screen glow as the key light,
|
||||
warm accent lamp.
|
||||
- **Resolution:** ≥ 1600×690 px.
|
||||
|
||||
### `public/images/pro-team.jpg` (currently unused — available)
|
||||
- **Subject:** Whiteboard session, pair-programming, or team standup (with
|
||||
consent). Optional; only add if you want a team-work shot.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hobbies — your real collections
|
||||
|
||||
These benefit most from being *yours* — the stock versions are generic.
|
||||
|
||||
### Dice / tabletop
|
||||
- `public/images/ttrpg-dice.jpg` — **your** polyhedral sets, fanned out or
|
||||
stacked on a dark surface. Top-down or 45°, single warm light. ~**16:10**.
|
||||
- `public/images/ttrpg-table.jpg` — your Foundry VTT screen, a battlemap, or
|
||||
the physical game table mid-session. Wide **21:9**.
|
||||
|
||||
### Making
|
||||
- `public/images/making-resin.jpg` — resin dice mid-cast, pressure pot, pigment
|
||||
bottles. Macro / close-up, ~**4:3**.
|
||||
- `public/images/making-print.jpg` — the Elegoo Mars 4 or Bambu P1S mid-print,
|
||||
or a finished miniature in your hand. ~**4:3** or **16:10**.
|
||||
- *(The Making gallery shows three images — the third reuses `ttrpg-dice.jpg`.
|
||||
Add a third unique shot, e.g. painted minis, if you want.)*
|
||||
|
||||
### Reading
|
||||
- `public/images/reading-nook.jpg` — your reading chair / nook. Wide **21:9**.
|
||||
- `public/images/fantasy-books.jpg` — your Sanderson / Cosmere shelf, spines
|
||||
out, warm light. ~**4:3** or **16:10**.
|
||||
|
||||
### Gaming
|
||||
- `public/images/gaming-setup.jpg` — your PS5 / Switch / PC corner, controller
|
||||
on the seat, screen glow. Wide **16:10** or **21:9**.
|
||||
|
||||
### Pokémon
|
||||
- `public/images/pokemon-cards.jpg` — your card collection, a favorite binder
|
||||
page, or a Metagross card front-and-center. ~**1:1** to **4:3**.
|
||||
|
||||
---
|
||||
|
||||
## How to swap an image
|
||||
|
||||
1. Put the new file at the matching path under `public/images/`.
|
||||
2. Keep the **same filename** (and ideally the same aspect ratio).
|
||||
3. Rebuild / redeploy. Done.
|
||||
|
||||
To change which image a specific spot uses, edit the `src="/images/..."` path in
|
||||
the relevant `.astro` file (search the `src/pages/` and
|
||||
`src/components/layout/InterestHero.astro`).
|
||||
|
||||
## Tech notes
|
||||
- All photos render inside a `CelestialFrame` (gold hairline + corner star
|
||||
bursts) or as a banner with a star accent, so they inherit the mystical
|
||||
treatment automatically.
|
||||
- `loading="lazy"` and `decoding="async"` are already set — no need to optimize
|
||||
delivery by hand, but keep files under ~500 KB where you can.
|
||||
- If you remove an image but leave the path, the `CelestialImage` component
|
||||
falls back to a themed starfield placeholder so the layout never breaks.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Theming & Motion Guide
|
||||
|
||||
How color, light/dark mode, section tone, and animation work on this site.
|
||||
|
||||
---
|
||||
|
||||
## Palette
|
||||
|
||||
From the spec, exposed as CSS variables in `src/styles/themes.css` and mapped to
|
||||
Tailwind utilities in `src/styles/global.css`:
|
||||
|
||||
| Token | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `--color-slate` | `#1E293B` | Professional confidence (primary) |
|
||||
| `--color-amber` | `#F59E0B` | Warm accent (the making/creative side) |
|
||||
| `--color-teal` | `#699a94` | Soft teal (secondary, favorite) |
|
||||
| `--color-ink` | `#0F172A` | Near-black text on light |
|
||||
| `--color-cream` | `#FFFBEB` | Cream-white text on dark |
|
||||
|
||||
Brand colors are fixed. **Semantic** tokens (`--surface`, `--text`, `--accent`,
|
||||
`--secondary`, `--border`, ...) resolve differently per theme + section, and are
|
||||
what components actually use.
|
||||
|
||||
---
|
||||
|
||||
## Two theming axes
|
||||
|
||||
### Axis 1 — Global light/dark
|
||||
|
||||
Set on `<html data-theme="light|dark">`:
|
||||
|
||||
- A blocking inline script (`src/scripts/theme-init.ts`, inlined in `<head>`)
|
||||
reads `localStorage['gk-theme']`, falling back to the OS
|
||||
`prefers-color-scheme`. This runs **before first paint**, so there is no flash.
|
||||
- The nav `ThemeToggle` island flips it and persists the choice.
|
||||
- If the user has never chosen, the site follows OS changes live.
|
||||
|
||||
### Axis 2 — Section tone
|
||||
|
||||
Set on `<body data-surface="neutral|professional|interest">` via the `surface`
|
||||
prop on `<BaseLayout>`:
|
||||
|
||||
| Surface | Used on | Tone |
|
||||
|---|---|---|
|
||||
| `neutral` | Home, About, Contact | Balanced — clean in light, calm in dark |
|
||||
| `professional` | Professional page | Dark slate, polished, amber accents |
|
||||
| `interest` | Interests pages | Warm cream/amber, inviting |
|
||||
|
||||
The Professional surface leans dark even in light mode (it is the "polished
|
||||
wing"). The Interest surface is warm in both modes. This is what gives the two
|
||||
wings their distinct feel while sharing the same typography and spacing.
|
||||
|
||||
Each `(data-theme, data-surface)` combination redefines the semantic tokens in
|
||||
`themes.css`. Add or adjust a combination there to change any section's palette.
|
||||
|
||||
---
|
||||
|
||||
## Changing colors
|
||||
|
||||
1. **Tweak a brand color:** edit the fixed values in `global.css` under `@theme`
|
||||
(e.g. `--color-amber`).
|
||||
2. **Tweak a section's tone:** edit the relevant `[data-surface="..."]` block in
|
||||
`themes.css`. Each block lists `--surface`, `--surface-raised`,
|
||||
`--surface-sunken`, `--text`, `--text-muted`, `--text-subtle`, `--border`,
|
||||
`--accent`, `--accent-contrast`, `--secondary`.
|
||||
3. Components use utilities like `bg-surface`, `text-text`, `text-accent`
|
||||
(mapped from these variables), so they update automatically.
|
||||
|
||||
### Contrast
|
||||
|
||||
Verify WCAG AA (4.5:1 for body text) whenever you change a token pair. The
|
||||
current combinations were chosen for AA compliance. The `--accent` on the neutral
|
||||
light surface uses a deeper amber (`#b45309`) specifically to meet AA on white.
|
||||
|
||||
---
|
||||
|
||||
## Motion
|
||||
|
||||
Motion is layered and **always** respects `prefers-reduced-motion`:
|
||||
|
||||
1. **Hero background** (`AnimatedBackground.tsx`):
|
||||
- A CSS mesh-gradient layer (GPU-friendly, animated via the
|
||||
`animate-gradient-shift` keyframes in `animations.css`).
|
||||
- An optional `<canvas>` particle field (slow drifting dots) on the home hero
|
||||
only. It renders at devicePixelRatio (capped at 2), pauses when off-screen,
|
||||
and is **skipped entirely** under reduced motion.
|
||||
2. **Scroll reveals** (`Reveal.astro` + the `.reveal` class):
|
||||
- IntersectionObserver toggles `.is-visible`, triggering a CSS fade-up. Zero
|
||||
framework cost. The reduced-motion media query in `global.css` forces these
|
||||
to their final state instantly.
|
||||
3. **Page transitions**: Astro `<ClientRouter />` (View Transitions API) morphs
|
||||
between pages and persists the nav.
|
||||
4. **Nav blur on scroll**: a tiny inline script adds `.scrolled` after a few px,
|
||||
enabling backdrop-blur + a hairline border + shadow.
|
||||
5. **Hover micro-interactions**: Tailwind transitions on cards/buttons (lift,
|
||||
accent glow, arrow nudge).
|
||||
|
||||
### Disabling motion
|
||||
|
||||
Everything is gated by the global reduced-motion rule in `global.css`:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The canvas island additionally checks `matchMedia('(prefers-reduced-motion:
|
||||
reduce)')` before drawing, so even JS-driven motion is skipped.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
Self-hosted variable fonts via Fontsource (woff2, no external requests):
|
||||
|
||||
- **Headings / UI:** Plus Jakarta Sans Variable (geometric sans)
|
||||
- **Body:** Inter Variable
|
||||
- **Mono / kickers / quotes:** JetBrains Mono Variable
|
||||
|
||||
Font sizes use `clamp()` for fluid scaling (see the `h1`-`h4` rules in
|
||||
`global.css`).
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "greg-kilmer-profile",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Personal profile / portfolio website for Greg Kilmer. Astro + React Islands + Node server.",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"serve": "node ./dist/server/entry.mjs",
|
||||
"astro": "astro",
|
||||
"check": "astro check",
|
||||
"typecheck": "astro check && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/mdx": "^7.0.2",
|
||||
"@astrojs/node": "^11.0.2",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.2.8",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^7.0.6",
|
||||
"framer-motion": "^12.42.2",
|
||||
"nodemailer": "^9.0.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- Greg Kilmer mark: a stylized "G" over a warm amber field with slate. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1e293b"/>
|
||||
<stop offset="1" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#bg)"/>
|
||||
<!-- Amber accent arc -->
|
||||
<circle cx="32" cy="32" r="20" fill="none" stroke="#f59e0b" stroke-width="4" stroke-dasharray="94 200" stroke-linecap="round" transform="rotate(-30 32 32)" opacity="0.85"/>
|
||||
<!-- G glyph -->
|
||||
<path d="M40 26a12 12 0 1 0 0 12" fill="none" stroke="#fffbeb" stroke-width="5" stroke-linecap="round"/>
|
||||
<line x1="32" y1="32" x2="42" y2="32" stroke="#fffbeb" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
|
After Width: | Height: | Size: 874 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,47 @@
|
||||
---
|
||||
/**
|
||||
* Icon.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Inline mdi (Material Design Icons) renderer. Pulls icon path data at build
|
||||
* time from `@iconify-json/mdi/icons.json` and emits a single inline <svg>, so
|
||||
* there is no runtime icon library and no extra requests. The svg inherits
|
||||
* `currentColor`, so color is controlled via text-* utilities or inline style.
|
||||
*
|
||||
* The spec calls for mdi icons everywhere instead of emojis; all spec emoji
|
||||
* references are mapped to mdi names at the call sites.
|
||||
*
|
||||
* Usage: <Icon name="briefcase" class="h-6 w-6" />
|
||||
*/
|
||||
import mdi from '@iconify-json/mdi/icons.json';
|
||||
|
||||
interface Props {
|
||||
/** mdi icon name WITHOUT prefix, e.g. "briefcase". */
|
||||
name: string;
|
||||
/** Accessible label. Set when the icon conveys meaning alone (not decorative). */
|
||||
title?: string;
|
||||
class?: string;
|
||||
/** Override default 1em sizing (rarely needed; prefer class utilities). */
|
||||
size?: number | string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { name, title, class: className, size, style } = Astro.props;
|
||||
|
||||
const icon = (mdi as { icons: Record<string, { body: string }> }).icons[name];
|
||||
if (!icon) {
|
||||
// Surface missing-icon names during dev so typos are caught immediately.
|
||||
console.warn(`[Icon] unknown mdi icon: "${name}"`);
|
||||
}
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size ?? '1em'}
|
||||
height={size ?? '1em'}
|
||||
viewBox="0 0 24 24"
|
||||
class={className}
|
||||
style={style}
|
||||
aria-hidden={title ? undefined : 'true'}
|
||||
role={title ? 'img' : undefined}
|
||||
set:html={(title ? `<title>${title}</title>` : '') + (icon?.body ?? '')}
|
||||
/>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* InterestCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Generic highlight card used across interest pages (the spec's "Highlights
|
||||
* (Card Grid)" sections). Each card: an mdi icon, a bold title, and a body.
|
||||
* Spec emojis in the highlight tables are mapped to mdi icon names at the call
|
||||
* site.
|
||||
*
|
||||
* Optional `currently` prop restyles the card as a "what's active now" item
|
||||
* with a pulse dot, matching the spec's "Currently" sections.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Optional small status pill text, e.g. "Complete", "Reading". */
|
||||
status?: string;
|
||||
currently?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { icon, title, status, currently = false, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
'card card-interactive flex h-full flex-col gap-3',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
|
||||
style={currently
|
||||
? 'background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent);'
|
||||
: 'background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={icon} class="h-6 w-6" />
|
||||
</span>
|
||||
{currently && (
|
||||
<span class="inline-flex items-center gap-1.5 font-mono text-[0.65rem] uppercase tracking-[0.18em]" style="color: var(--color-accent);">
|
||||
<span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{status && !currently && (
|
||||
<span class="pill">{status}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="font-sans text-lg" style="color: var(--color-text);">{title}</h3>
|
||||
<div class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* AnimatedBackground.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* The hero background island. Three layers:
|
||||
*
|
||||
* 1. A CSS-driven mesh-gradient layer (always on, GPU-friendly, animated via
|
||||
* the `animate-gradient-shift` keyframes in global.css). Tuned to the
|
||||
* night-sky palette: midnight indigo with violet + gold nebular edges.
|
||||
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) baked
|
||||
* in as absolutely-positioned decorations for esoteric texture.
|
||||
* 3. An optional lightweight <canvas> star field: dozens of twinkling stars
|
||||
* with thin constellation lines drawn between near neighbors, plus the
|
||||
* occasional slow shooting-star streak. We render at devicePixelRatio and
|
||||
* cap the count for perf. It pauses when off-screen and is fully skipped
|
||||
* when prefers-reduced-motion is set.
|
||||
*
|
||||
* The component is presentational only; it reads CSS variables for colors so it
|
||||
* adapts to the section surface tone automatically.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Props {
|
||||
/** Show the canvas star field. Off on interest pages for a calmer feel. */
|
||||
particles?: boolean;
|
||||
/** Tailwind/className passthrough for the root. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AnimatedBackground({ particles = true, className = '' }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduce || !particles) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number; // base radius
|
||||
vx: number; // slow drift
|
||||
vy: number;
|
||||
tw: number; // twinkle phase
|
||||
tws: number; // twinkle speed
|
||||
baseA: number; // base alpha
|
||||
anchor: boolean; // brighter "anchor" star used for constellations
|
||||
}
|
||||
|
||||
interface Shooter {
|
||||
x: number;
|
||||
y: number;
|
||||
len: number;
|
||||
speed: number;
|
||||
angle: number; // direction of travel (radians)
|
||||
life: number; // 0..1 remaining
|
||||
maxLife: number;
|
||||
}
|
||||
|
||||
let stars: Star[] = [];
|
||||
let shooters: Shooter[] = [];
|
||||
let nextShooterAt = 0;
|
||||
let t0 = performance.now();
|
||||
|
||||
// Resolve star colors from CSS variables so the field matches the theme.
|
||||
const gold =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-amber').trim() ||
|
||||
'#e8b54a';
|
||||
const violet =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-violet').trim() ||
|
||||
'#8b7bd8';
|
||||
const teal =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-teal').trim() ||
|
||||
'#6fb6ad';
|
||||
const white =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
|
||||
'#f4f1e8';
|
||||
|
||||
// Max distance for two stars to be connected by a constellation line.
|
||||
const LINK_DIST = 132;
|
||||
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
|
||||
|
||||
function resize() {
|
||||
if (!canvas || !ctx) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Cap density: one star per ~9k px^2, max 140. Anchor stars are rarer.
|
||||
const count = Math.min(140, Math.floor((width * height) / 9000));
|
||||
stars = Array.from({ length: count }, (_, i) => {
|
||||
const anchor = i % 11 === 0; // ~9% anchors -> the brighter backbone stars
|
||||
const r = anchor ? 1.4 + Math.random() * 1.3 : 0.4 + Math.random() * 1.1;
|
||||
return {
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
r,
|
||||
vx: (Math.random() - 0.5) * 0.06,
|
||||
vy: (Math.random() - 0.5) * 0.06,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
tws: 0.4 + Math.random() * 1.1, // twinkle cycles/sec-ish
|
||||
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.18 + Math.random() * 0.4,
|
||||
anchor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function spawnShooter(now: number) {
|
||||
// Diagonal streak starting somewhere in the top half.
|
||||
const startX = Math.random() * width * 0.8;
|
||||
const startY = Math.random() * height * 0.5;
|
||||
const angle = Math.PI * (0.18 + Math.random() * 0.12); // ~32-54deg down-right
|
||||
const speed = 5.5 + Math.random() * 3.5;
|
||||
shooters.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
len: 90 + Math.random() * 120,
|
||||
speed,
|
||||
angle,
|
||||
life: 1,
|
||||
maxLife: 1,
|
||||
});
|
||||
// Next shooter in 5-13s.
|
||||
nextShooterAt = now + 5000 + Math.random() * 8000;
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!ctx) return;
|
||||
const dt = Math.min(0.05, (now - t0) / 1000); // seconds, clamped
|
||||
t0 = now;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 1) Constellation lines (drawn first, behind stars). Connect near anchors.
|
||||
ctx.lineWidth = 0.6;
|
||||
for (let i = 0; i < stars.length; i++) {
|
||||
const a = stars[i];
|
||||
if (!a.anchor) continue; // only anchors backbone the constellations
|
||||
for (let j = i + 1; j < stars.length; j++) {
|
||||
const b = stars[j];
|
||||
if (!b.anchor) continue;
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 > LINK_DIST_SQ) continue;
|
||||
// Alpha falls off with distance; gentle pulse tied to time.
|
||||
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
|
||||
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
|
||||
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`; // gold, rgb fixed for perf
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Stars (twinkle + drift).
|
||||
for (const s of stars) {
|
||||
s.x += s.vx;
|
||||
s.y += s.vy;
|
||||
s.tw += s.tws * dt;
|
||||
// Wrap softly at edges.
|
||||
if (s.x < -4) s.x = width + 4;
|
||||
else if (s.x > width + 4) s.x = -4;
|
||||
if (s.y < -4) s.y = height + 4;
|
||||
else if (s.y > height + 4) s.y = -4;
|
||||
|
||||
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
|
||||
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
|
||||
const color = s.anchor ? gold : s.r > 1 ? white : s.r > 0.7 ? violet : teal;
|
||||
|
||||
if (s.anchor) {
|
||||
// Glow halo for anchors.
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha * 0.5;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(s.x, s.y, s.r * 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.shadowBlur = s.anchor ? 6 : 3;
|
||||
ctx.shadowColor = color;
|
||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 3) Shooting stars.
|
||||
if (now >= nextShooterAt) spawnShooter(now);
|
||||
for (let i = shooters.length - 1; i >= 0; i--) {
|
||||
const sh = shooters[i];
|
||||
sh.x += Math.cos(sh.angle) * sh.speed;
|
||||
sh.y += Math.sin(sh.angle) * sh.speed;
|
||||
sh.life -= dt * 0.5; // fade over ~2s
|
||||
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
|
||||
shooters.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const tailX = sh.x - Math.cos(sh.angle) * sh.len;
|
||||
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
|
||||
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
|
||||
const headA = Math.max(0, Math.min(1, sh.life));
|
||||
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`); // star-white head
|
||||
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`); // gold mid
|
||||
grad.addColorStop(1, 'rgba(232, 181, 74, 0)');
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 1.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sh.x, sh.y);
|
||||
ctx.lineTo(tailX, tailY);
|
||||
ctx.stroke();
|
||||
|
||||
// Bright head dot.
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.shadowColor = gold;
|
||||
ctx.arc(sh.x, sh.y, 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
t0 = performance.now();
|
||||
nextShooterAt = t0 + 3000 + Math.random() * 4000;
|
||||
raf = requestAnimationFrame(frame);
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Pause when off-screen to save cycles.
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
if (!raf) {
|
||||
t0 = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
} else {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
io.disconnect();
|
||||
};
|
||||
}, [particles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Layer 1: animated mesh gradient (midnight indigo -> violet + gold edges). */}
|
||||
<div
|
||||
className="animate-gradient-shift absolute inset-0 opacity-95"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-sunken)',
|
||||
backgroundImage: `
|
||||
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--color-amber) 18%, transparent), transparent 60%),
|
||||
radial-gradient(55% 70% at 85% 0%, color-mix(in srgb, var(--color-violet, var(--color-secondary)) 30%, transparent), transparent 60%),
|
||||
radial-gradient(70% 90% at 50% 110%, color-mix(in srgb, var(--color-amber) 14%, transparent), transparent 60%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{/* Faint astrolabe + sacred-geometry ornaments for esoteric texture. */}
|
||||
<svg
|
||||
className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.08]"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="50" cy="50" r="49" strokeWidth="0.8" />
|
||||
<circle cx="50" cy="50" r="42" />
|
||||
<circle cx="50" cy="50" r="30" />
|
||||
<circle cx="50" cy="50" r="18" />
|
||||
{Array.from({ length: 36 }).map((_, i) => {
|
||||
const rad = (i * 10 * Math.PI) / 180;
|
||||
const major = i % 3 === 0;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={50 + 49 * Math.cos(rad)}
|
||||
y1={50 + 49 * Math.sin(rad)}
|
||||
x2={50 + (major ? 43 : 45.5) * Math.cos(rad)}
|
||||
y2={50 + (major ? 43 : 45.5) * Math.sin(rad)}
|
||||
strokeWidth={major ? 0.8 : 0.4}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.08]"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Compact flower-of-life cluster. */}
|
||||
<circle cx="50" cy="50" r="10" />
|
||||
{Array.from({ length: 6 }).map((_, i) => {
|
||||
const rad = (Math.PI / 3) * i;
|
||||
return <circle key={i} cx={50 + 10 * Math.cos(rad)} cy={50 + 10 * Math.sin(rad)} r="10" />;
|
||||
})}
|
||||
{Array.from({ length: 6 }).map((_, i) => {
|
||||
const rad = (Math.PI / 3) * i + Math.PI / 6;
|
||||
return (
|
||||
<circle key={`o${i}`} cx={50 + 10 * Math.sqrt(3) * Math.cos(rad)} cy={50 + 10 * Math.sqrt(3) * Math.sin(rad)} r="10" />
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Subtle vignette for text legibility. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(120% 80% at 50% 30%, transparent 40%, color-mix(in srgb, var(--color-surface-sunken) 70%, transparent) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Layer 3: canvas star field (skipped under reduced motion). */}
|
||||
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* Footer.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Minimal footer: social links (mdi icons), copyright, and the spec microcopy
|
||||
* "Built with intention. Powered by caffeine." No emojis per spec.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
|
||||
const year = new Date().getFullYear();
|
||||
const socialList = Object.values(socials);
|
||||
---
|
||||
|
||||
<footer
|
||||
class="border-t"
|
||||
style="border-color: var(--color-border); background: var(--color-surface-sunken);"
|
||||
>
|
||||
<div class="container-prose flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-sans text-sm font-semibold" style="color: var(--color-text);">
|
||||
{siteIdentity.name}
|
||||
</p>
|
||||
<p class="font-mono text-xs italic" style="color: var(--color-text-subtle);">
|
||||
{footerMicrocopy}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
{socialList.map((s) => (
|
||||
<li>
|
||||
<a
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={s.label}
|
||||
title={s.label}
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-lg border transition-colors hover:bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)]"
|
||||
style="color: var(--color-text-muted); border-color: var(--color-border);"
|
||||
>
|
||||
<Icon name={s.icon} class="h-5 w-5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="container-prose pb-8 text-center text-xs" style="color: var(--color-text-subtle);">
|
||||
© {year} {siteIdentity.name}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
/**
|
||||
* InterestHero.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Shared header for every interest sub-page, implementing the spec's
|
||||
* per-interest template (Header: title + tagline; Overview: narrative). Uses the
|
||||
* warm "interest" surface tone via InterestLayout.
|
||||
*
|
||||
* The decorative icon and the tagline (in mono, as a pull quote) give each page
|
||||
* a consistent rhythm while the page supplies its own title + overview copy.
|
||||
*
|
||||
* An optional `image` prop renders a wide celestial-framed banner behind / above
|
||||
* the heading so each interest page leads with a vibe-matched photo. A faint
|
||||
* starfield + alchemical accent reinforces the esoteric mood.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
|
||||
import StarBurst from '@components/mystic/StarBurst.astro';
|
||||
|
||||
interface Props {
|
||||
/** mdi icon used as the decorative mark. */
|
||||
icon: string;
|
||||
title: string;
|
||||
/** The italicized pull-quote tagline from the spec. */
|
||||
tagline: string;
|
||||
/** Back-to-hub breadcrumb label. */
|
||||
eyebrow?: string;
|
||||
/** Optional hero banner image (vibe-matched stock; swap for real photos). */
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
tagline,
|
||||
eyebrow = 'Interests',
|
||||
image,
|
||||
imageAlt = '',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden pt-28 sm:pt-32">
|
||||
<!-- Calmer background: gradient only, no particles on interest pages. -->
|
||||
<AnimatedBackground client:idle particles={false} />
|
||||
|
||||
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-12">
|
||||
<Reveal>
|
||||
<a
|
||||
href="/interests"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em] transition-colors"
|
||||
style="color: var(--color-text-subtle);"
|
||||
>
|
||||
<Icon name="arrow-left" class="h-4 w-4" />
|
||||
{eyebrow}
|
||||
</a>
|
||||
</Reveal>
|
||||
|
||||
{image && (
|
||||
<Reveal delay={40} class="w-full">
|
||||
<figure
|
||||
class="relative w-full overflow-hidden rounded-2xl border"
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);"
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={imageAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-[21/9] 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-accent)_60%,transparent)]"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
</figure>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal delay={60}>
|
||||
<span
|
||||
class="inline-flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name={icon} class="h-8 w-8" />
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={120}>
|
||||
<h1 class="text-balance">{title}</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={160}>
|
||||
<p
|
||||
class="max-w-2xl border-l-2 pl-5 font-sans text-lg italic sm:text-xl"
|
||||
style="border-color: var(--color-accent); color: var(--color-text);"
|
||||
>
|
||||
{tagline}
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* Placeholder.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Visual marker for content Greg still needs to provide (the spec's many
|
||||
* "[Greg to fill]" items). Renders a dashed slot with a small mdi pencil badge
|
||||
* and a "TODO" label so the structure is visible and demoable, but it's obvious
|
||||
* what needs swapping. The chosen content approach is "verbatim spec copy, leave
|
||||
* gaps", so these mark every gap rather than inventing fake content.
|
||||
*
|
||||
* Usage:
|
||||
* <Placeholder label="Project name" hint="Add 3-6 portfolio entries" />
|
||||
* <Placeholder label="Email"><p>Inline hint...</p></Placeholder>
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Short name of what's missing, e.g. "Email". */
|
||||
label: string;
|
||||
/** Optional longer hint shown as muted text. */
|
||||
hint?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { label, hint, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'flex items-start gap-3 rounded-xl border border-dashed p-4',
|
||||
className,
|
||||
]}
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); background: color-mix(in srgb, var(--color-accent) 6%, transparent);"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
style="background: var(--color-accent); color: var(--color-accent-contrast);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="pencil" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-mono text-[0.65rem] font-bold uppercase tracking-[0.18em]" style="color: var(--color-accent);">
|
||||
TODO · {label}
|
||||
</span>
|
||||
{hint && <p class="text-sm" style="color: var(--color-text-muted);">{hint}</p>}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
/**
|
||||
* RelatedInterests.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "You might also like..." cross-link block that ends each interest page,
|
||||
* per the spec's Interests Page Flow. Pass an array of related slugs/titles and
|
||||
* this renders attractive teaser cards.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
export interface RelatedLink {
|
||||
href: string;
|
||||
title: string;
|
||||
blurb: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: RelatedLink[];
|
||||
}
|
||||
|
||||
const { items } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="mt-16">
|
||||
<h3 class="mb-5 font-mono text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">
|
||||
You might also like
|
||||
</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<a href={item.href} class="card card-interactive group flex items-start gap-4">
|
||||
<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-1">
|
||||
<span class="flex items-center gap-1 font-sans font-semibold" style="color: var(--color-text);">
|
||||
{item.title}
|
||||
<Icon name="arrow-right" class="h-4 w-4 transition-transform group-hover:translate-x-1" style="color: var(--color-accent);" />
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--color-text-muted);">{item.blurb}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
/**
|
||||
* SectionHeader.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Reusable section title block: a mono "kicker" (e.g. "02"), an h2 heading, and
|
||||
* an optional supporting line. Keeps section openers visually consistent.
|
||||
*
|
||||
* The kicker is rendered as a monospace number/label per the spec's "Accent/
|
||||
* quotes: a monospace or display font sparingly for section numbers" guidance.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Monospace kicker, e.g. "02" or "Experience". */
|
||||
kicker?: string;
|
||||
title: string;
|
||||
/** Optional subtitle shown under the title. */
|
||||
subtitle?: string;
|
||||
/** Optional mdi icon shown beside the kicker. */
|
||||
icon?: string;
|
||||
class?: string;
|
||||
align?: 'left' | 'center';
|
||||
}
|
||||
|
||||
const { kicker, title, subtitle, icon, class: className = '', align = 'left' } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'flex flex-col gap-3',
|
||||
align === 'center' && 'items-center text-center',
|
||||
className,
|
||||
]}
|
||||
>
|
||||
{kicker && (
|
||||
<span
|
||||
class="inline-flex items-center gap-2 font-mono text-xs uppercase tracking-[0.22em]"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
{icon && <Icon name={icon} class="h-4 w-4" />}
|
||||
{kicker}
|
||||
</span>
|
||||
)}
|
||||
<h2>{title}</h2>
|
||||
{subtitle && (
|
||||
<p class="max-w-2xl text-base sm:text-lg" style="color: var(--color-text-muted);">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
/**
|
||||
* SplitTransition.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The "fork" used at the end of the About page (and reusable elsewhere) that
|
||||
* reinforces the spec's "two wings of the same house" idea: two side-by-side
|
||||
* paths inviting the visitor into Professional or Interests.
|
||||
*
|
||||
* Left -> Professional (polished tone)
|
||||
* Right -> Interests (warm tone)
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
/** Optional override copy for each side. Defaults to spec phrasing. */
|
||||
leftHref?: string;
|
||||
rightHref?: string;
|
||||
leftText?: string;
|
||||
rightText?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
leftHref = '/professional',
|
||||
rightHref = '/interests',
|
||||
leftText = 'See where I apply that curiosity professionally',
|
||||
rightText = 'See where it takes me outside of work',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href={leftHref}
|
||||
class="card card-interactive group flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
The professional wing
|
||||
</span>
|
||||
<span class="font-sans text-lg font-semibold" style="color: var(--color-text);">
|
||||
{leftText}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-right" class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1" style="color: var(--color-accent);" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={rightHref}
|
||||
class="card card-interactive group flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="flex flex-col gap-1">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
The personal wing
|
||||
</span>
|
||||
<span class="font-sans text-lg font-semibold" style="color: var(--color-text);">
|
||||
{rightText}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="arrow-right" class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1" style="color: var(--color-accent);" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* Reveal.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Zero-dependency scroll-reveal wrapper. Adds the `reveal` class (defined in
|
||||
* global.css, which handles the transition + the reduced-motion override) and
|
||||
* toggles `is-visible` when the element enters the viewport.
|
||||
*
|
||||
* Why not Framer Motion here? Scroll reveals happen on potentially dozens of
|
||||
* elements per page; using IntersectionObserver + a CSS class keeps this free
|
||||
* of client JS. Framer Motion is reserved for genuinely interactive islands.
|
||||
*
|
||||
* This component renders a <div> wrapper. When you need a different semantic
|
||||
* element (e.g. an <li> or <section>), apply the `reveal` class and the
|
||||
* `data-reveal` attribute to that element directly instead of using Reveal.
|
||||
*
|
||||
* Usage:
|
||||
* <Reveal delay={100}><h2>...</h2></Reveal>
|
||||
* <li class="reveal" data-reveal style="transition-delay:80ms">...</li>
|
||||
*/
|
||||
interface Props {
|
||||
delay?: number;
|
||||
class?: string;
|
||||
style?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const { delay = 0, class: className = '', style = '', ...rest } = Astro.props;
|
||||
const delayStyle = delay ? `transition-delay:${delay}ms;` : '';
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['reveal', className]}
|
||||
style={`${delayStyle}${style}`}
|
||||
data-reveal
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
/**
|
||||
* AlchemicalDivider.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A horizontal section divider built from a thin rule with alchemical /
|
||||
* celestial symbols along it: a central sun/star flanked by a crescent moon
|
||||
* and small sparkles, with hairlines tapering to the edges. Replaces plain
|
||||
* vertical gaps between sections on Home / About / Interests.
|
||||
*
|
||||
* Decorative: aria-hidden, inherits currentColor (set to --color-accent or
|
||||
* --color-violet at the call site).
|
||||
*
|
||||
* Usage:
|
||||
* <AlchemicalDivider class="my-4 h-8 w-full max-w-md mx-auto" style="color: var(--color-amber)" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { class: className = '', style = '' } = Astro.props;
|
||||
---
|
||||
<div class={`flex items-center justify-center ${className}`} style={style} aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 200 24"
|
||||
class="h-full w-full"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
{/* Tapered hairlines either side of the central cluster. */}
|
||||
<line x1="2" y1="12" x2="74" y2="12" stroke-width="0.8" opacity="0.7" />
|
||||
<line x1="126" y1="12" x2="198" y2="12" stroke-width="0.8" opacity="0.7" />
|
||||
<circle cx="8" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="192" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
|
||||
{/* Crescent moon (left). */}
|
||||
<path d="M86 12a6 6 0 1 0 5-5.9 4.7 4.7 0 0 1 0 11.8 6 6 0 0 0-5-5.9z" fill="currentColor" stroke="none" opacity="0.85" />
|
||||
|
||||
{/* Central radiant sun/star (alchemical sun: circle + rays). */}
|
||||
<circle cx="100" cy="12" r="3.2" stroke-width="1" />
|
||||
<g stroke-width="1">
|
||||
<line x1="100" y1="2.5" x2="100" y2="5" />
|
||||
<line x1="100" y1="19" x2="100" y2="21.5" />
|
||||
<line x1="90.5" y1="12" x2="93" y2="12" />
|
||||
<line x1="107" y1="12" x2="109.5" y2="12" />
|
||||
<line x1="93.3" y1="5.3" x2="95.1" y2="7.1" />
|
||||
<line x1="104.9" y1="16.9" x2="106.7" y2="18.7" />
|
||||
<line x1="106.7" y1="5.3" x2="104.9" y2="7.1" />
|
||||
<line x1="95.1" y1="16.9" x2="93.3" y2="18.7" />
|
||||
</g>
|
||||
|
||||
{/* Small sparkles. */}
|
||||
<path d="M114 12c.25 1.6.9 2.8 2.4 3.1-1.5.25-2.15 1.5-2.4 3.1-.25-1.6-.9-2.85-2.4-3.1 1.5-.3 2.15-1.5 2.4-3.1z" fill="currentColor" stroke="none" />
|
||||
<path d="M76 12c.2 1.3.7 2.3 2 2.5-1.3.2-1.8 1.2-2 2.5-.2-1.3-.7-2.3-2-2.5 1.3-.2 1.8-1.2 2-2.5z" fill="currentColor" stroke="none" opacity="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
/**
|
||||
* Astrolabe.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Inline-SVG astrolabe / celestial-chart line art: concentric rings, tick
|
||||
* marks like a degree scale, zodiac-like division lines, and a star at the
|
||||
* pivot. Reads as a "mystic instrument" silhouette. Intended as a hero-corner
|
||||
* ornament where it can slowly rotate (animate-spin-slow).
|
||||
*
|
||||
* Decorative: aria-hidden, pointer-events none, inherits currentColor. Apply
|
||||
* opacity + color at the call site so it reads as a faint engraving.
|
||||
*
|
||||
* Usage:
|
||||
* <Astrolabe class="pointer-events-none absolute -left-32 -top-32 h-[28rem] w-[28rem] animate-spin-slow" style="color: var(--color-amber); opacity:.12" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { class: className = '', style = '' } = Astro.props;
|
||||
|
||||
// Degree ticks every 10deg around the outer ring (r=48, on a 100x100 canvas).
|
||||
const ticks: { x1: number; y1: number; x2: number; y2: number; major: boolean }[] = [];
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
for (let deg = 0; deg < 360; deg += 10) {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const major = deg % 30 === 0;
|
||||
const outer = 48;
|
||||
const inner = major ? 43 : 45.5;
|
||||
ticks.push({
|
||||
x1: cx + outer * Math.cos(rad),
|
||||
y1: cy + outer * Math.sin(rad),
|
||||
x2: cx + inner * Math.cos(rad),
|
||||
y2: cy + inner * Math.sin(rad),
|
||||
major,
|
||||
});
|
||||
}
|
||||
// Zodiac-like spokes (12 divisions).
|
||||
const spokes: [number, number][] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const rad = (i * 30 * Math.PI) / 180;
|
||||
spokes.push([cx + 40 * Math.cos(rad), cy + 40 * Math.sin(rad)]);
|
||||
}
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
class={`pointer-events-none ${className}`}
|
||||
style={style}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Outer + inner rings. */}
|
||||
<circle cx={cx} cy={cy} r="49" stroke-width="0.8" />
|
||||
<circle cx={cx} cy={cy} r="42" />
|
||||
<circle cx={cx} cy={cy} r="30" />
|
||||
<circle cx={cx} cy={cy} r="18" />
|
||||
{/* Degree ticks. */}
|
||||
{ticks.map((t) => (
|
||||
<line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} stroke-width={t.major ? 0.8 : 0.4} />
|
||||
))}
|
||||
{/* 12 spokes. */}
|
||||
{spokes.map(([x, y]) => (
|
||||
<line x1={cx} y1={cy} x2={x} y2={y} stroke-width="0.3" opacity="0.7" />
|
||||
))}
|
||||
{/* Central pivot star. */}
|
||||
<path
|
||||
d="M50 44c.4 3 1.4 5.3 4 5.7-2.6.4-3.6 2.7-4 5.7-.4-3-1.4-5.3-4-5.7 2.6-.4 3.6-2.7 4-5.7z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
/**
|
||||
* CelestialFrame.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* An ornamental frame for imagery: rounded image container with thin gold
|
||||
* hairline + small star-burst flourishes in the corners and a faint sacred-
|
||||
* geometry tint behind. Wraps an <img> (or any media) passed via the default
|
||||
* slot.
|
||||
*
|
||||
* The frame is structural (not just decorative), so it owns the rounded
|
||||
* corners + border + overflow. The corner sparkles are absolutely positioned
|
||||
* SVGs that inherit the accent color.
|
||||
*
|
||||
* Usage:
|
||||
* <CelestialFrame>
|
||||
* <img src="/images/hero.jpg" alt="..." class="h-full w-full object-cover" />
|
||||
* </CelestialFrame>
|
||||
* <CelestialFrame rounded="none"> ... </CelestialFrame>
|
||||
*/
|
||||
import StarBurst from './StarBurst.astro';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Corner rounding. */
|
||||
rounded?: 'lg' | 'xl' | '2xl' | 'none';
|
||||
/** Stroke emphasis on the frame border. */
|
||||
emphasis?: 'subtle' | 'strong';
|
||||
}
|
||||
const {
|
||||
class: className = '',
|
||||
rounded = '2xl',
|
||||
emphasis = 'subtle',
|
||||
} = Astro.props;
|
||||
|
||||
const radius = {
|
||||
none: 'rounded-none',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl': 'rounded-2xl',
|
||||
}[rounded];
|
||||
|
||||
const borderStyle =
|
||||
emphasis === 'strong'
|
||||
? 'border-2'
|
||||
: 'border';
|
||||
---
|
||||
<figure
|
||||
class:list={['relative', radius, borderStyle, className]}
|
||||
style="border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);"
|
||||
>
|
||||
{/* Faint sacred-geometry tint behind the image. */}
|
||||
<div
|
||||
class:list={['absolute inset-0', radius]}
|
||||
style="background: color-mix(in srgb, var(--color-violet, var(--color-accent)) 14%, var(--color-surface-sunken));"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
{/* The media itself, clipped to the frame. */}
|
||||
<div class:list={['relative overflow-hidden', radius]}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{/* Corner star-burst flourishes (sit on the border corners). */}
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute -left-2 -top-2 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-accent)_60%,transparent)]"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<StarBurst
|
||||
class="pointer-events-none absolute -bottom-2 -right-2 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-accent)_60%,transparent)]"
|
||||
style="color: var(--color-accent);"
|
||||
/>
|
||||
<StarBurst
|
||||
variant="outline"
|
||||
class="pointer-events-none absolute -right-1 -top-1 h-3 w-3"
|
||||
style="color: var(--color-accent); opacity:.7;"
|
||||
/>
|
||||
<StarBurst
|
||||
variant="outline"
|
||||
class="pointer-events-none absolute -left-1 -bottom-1 h-3 w-3"
|
||||
style="color: var(--color-accent); opacity:.7;"
|
||||
/>
|
||||
</figure>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
/**
|
||||
* CelestialImage.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The standard way to put an image on the site: an <img> with sensible defaults
|
||||
* (lazy, async decode, object-cover, explicit aspect via prop) wrapped in a
|
||||
* CelestialFrame so every photo reads as part of the mystical system.
|
||||
*
|
||||
* Falls back to a themed SVG placeholder (starfield on the surface tone) when
|
||||
* `src` is empty or the path is known to be missing — so the layout never
|
||||
* shows a broken-image icon while stock photos are being swapped for real ones.
|
||||
*
|
||||
* Usage:
|
||||
* <CelestialImage src="/images/hero.jpg" alt="Greg at his desk" aspect="4/3" />
|
||||
* <CelestialImage src="/images/about.jpg" alt="Greg" aspect="3/4" rounded="xl" />
|
||||
*/
|
||||
import CelestialFrame from './CelestialFrame.astro';
|
||||
import StarField from './StarField.astro';
|
||||
|
||||
interface Props {
|
||||
src?: string;
|
||||
alt: string;
|
||||
/** Aspect ratio, e.g. "4/3", "3/4", "16/9". */
|
||||
aspect?: string;
|
||||
/** Image fit. object-cover by default. */
|
||||
fit?: 'cover' | 'contain';
|
||||
rounded?: 'lg' | 'xl' | '2xl' | 'none';
|
||||
class?: string;
|
||||
/** When true, render only the frame + placeholder (no <img>). */
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
alt,
|
||||
aspect = '4/3',
|
||||
fit = 'cover',
|
||||
rounded = '2xl',
|
||||
class: className = '',
|
||||
placeholder = false,
|
||||
} = Astro.props;
|
||||
|
||||
const showPlaceholder = placeholder || !src;
|
||||
const objectFit = fit === 'contain' ? 'object-contain' : 'object-cover';
|
||||
---
|
||||
<CelestialFrame rounded={rounded} class:list={['w-full', className]}>
|
||||
<div class="w-full" style={`aspect-ratio: ${aspect};`}>
|
||||
{showPlaceholder ? (
|
||||
// Themed SVG placeholder: starfield + moon glyph over the surface tone.
|
||||
<div
|
||||
class="relative h-full w-full"
|
||||
style="background: radial-gradient(120% 90% at 30% 20%, color-mix(in srgb, var(--color-violet, var(--color-accent)) 22%, transparent), transparent 60%), var(--color-surface-sunken);"
|
||||
>
|
||||
<StarField
|
||||
class="absolute inset-0 h-full w-full"
|
||||
style="color: var(--color-accent); opacity:.5;"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-10 w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
style="color: var(--color-accent); opacity:.55;"
|
||||
>
|
||||
{/* Crescent moon + small star as the placeholder glyph. */}
|
||||
<path d="M16 12a7 7 0 1 1-6.4-7 5.5 5.5 0 0 0 0 14A7 7 0 0 0 16 12z" />
|
||||
<path d="M18.5 5.5c.3 1.8 1 3.1 2.5 3.5-1.5.3-2.2 1.7-2.5 3.5-.3-1.8-1-3.2-2.5-3.5 1.5-.4 2.2-1.7 2.5-3.5z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 font-mono text-[0.6rem] uppercase tracking-[0.2em]"
|
||||
style="color: var(--color-text-subtle); opacity:.8;"
|
||||
>
|
||||
{alt}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class:list={['h-full w-full', objectFit]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CelestialFrame>
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
/**
|
||||
* SacredGeometry.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Inline-SVG sacred-geometry line art: a flower-of-life / metatron's-cube
|
||||
* inspired figure rendered as thin gold strokes. Used as an extremely faint
|
||||
* (low-opacity) decorative background behind hero blocks and section openers —
|
||||
* it should read as texture, never compete with content.
|
||||
*
|
||||
* Purely decorative: aria-hidden, pointer-events none, inherits currentColor.
|
||||
* Apply opacity + sizing + color at the call site.
|
||||
*
|
||||
* Usage:
|
||||
* <SacredGeometry class="pointer-events-none absolute -right-24 -top-24 h-96 w-96" style="color: var(--color-amber); opacity:.08" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
/** Which figure to draw. */
|
||||
figure?: 'flower' | 'metatron';
|
||||
}
|
||||
const { class: className = '', style = '', figure = 'flower' } = Astro.props;
|
||||
|
||||
// Flower of life: a central circle surrounded by 6, then 12, then edge circles
|
||||
// of the same radius, all r=10 on a 100x100 canvas centered at 50,50.
|
||||
const r = 10;
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const rings: [number, number][] = [];
|
||||
// Ring 1: 6 circles around center.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i;
|
||||
rings.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
|
||||
}
|
||||
// Ring 2: 6 more, one step further on alternating angles (the classic pattern).
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i + Math.PI / 6;
|
||||
rings.push([cx + r * Math.sqrt(3) * Math.cos(a), cy + r * Math.sqrt(3) * Math.sin(a)]);
|
||||
}
|
||||
// Ring 3 (outer partial): completes the visible flower.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i;
|
||||
rings.push([cx + 2 * r * Math.cos(a), cy + 2 * r * Math.sin(a)]);
|
||||
}
|
||||
const flowerCircles: [number, number][] = [[cx, cy], ...rings];
|
||||
|
||||
// Metatron's cube: 13 circles on a triangular grid + all internal connecting lines.
|
||||
const metatronCenters: [number, number][] = [[cx, cy]];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i;
|
||||
metatronCenters.push([cx + 2 * r * Math.cos(a), cy + 2 * r * Math.sin(a)]);
|
||||
}
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (Math.PI / 3) * i + Math.PI / 6;
|
||||
metatronCenters.push([cx + 2 * r * Math.sqrt(3) * Math.cos(a), cy + 2 * r * Math.sqrt(3) * Math.sin(a)]);
|
||||
}
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
class={`pointer-events-none ${className}`}
|
||||
style={style}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{figure === 'flower' ? (
|
||||
flowerCircles.map(([x, y]) => (
|
||||
<circle cx={x} cy={y} r={r} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{metatronCenters.map(([x, y]) => (
|
||||
<circle cx={x} cy={y} r={r * 0.75} />
|
||||
))}
|
||||
{/* Connect every pair of the 13 nodes with thin lines. */}
|
||||
{metatronCenters.map((a, i) =>
|
||||
metatronCenters.slice(i + 1).map((b) => (
|
||||
<line x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} />
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* StarBurst.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A small decorative four-point "sparkle" star (alchemical/star-chart style),
|
||||
* drawn as inline SVG so it inherits `currentColor`. Used as an accent mark
|
||||
* next to headings, in dividers, and as corner flourishes on frames.
|
||||
*
|
||||
* Variants:
|
||||
* - `solid` : filled sparkle (default)
|
||||
* - `outline`: stroke-only, lighter
|
||||
*
|
||||
* Usage:
|
||||
* <StarBurst class="h-4 w-4" style="color: var(--color-amber)" />
|
||||
* <StarBurst variant="outline" class="h-6 w-6" />
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'solid' | 'outline';
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { variant = 'solid', class: className = '', style = '' } = Astro.props;
|
||||
const fill = variant === 'outline' ? 'none' : 'currentColor';
|
||||
const sw = variant === 'outline' ? 1.4 : 0;
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class={className}
|
||||
style={style}
|
||||
fill={fill}
|
||||
stroke="currentColor"
|
||||
stroke-width={sw}
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Four-point sparkle: two perpendicular sharp diamonds + small inner core. */}
|
||||
<path d="M12 1c.6 4.7 2.3 8.4 7 9-4.7.6-8.4 2.3-9 7-.6-4.7-2.3-8.4-7-9 4.7-.6 8.4-2.3 9-7z" />
|
||||
</svg>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
/**
|
||||
* StarField.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* A static field of tiny scattered stars + a few brighter sparkles, as inline
|
||||
* SVG. Use as an ambient, non-animated decorative layer behind hero blocks or
|
||||
* inside frames — the *animated* star field lives in AnimatedBackground.tsx.
|
||||
*
|
||||
* Stars are deterministic (hard-coded positions) so the layout is stable and
|
||||
* cheap. Pointer-events disabled; inherits `currentColor`.
|
||||
*
|
||||
* Usage:
|
||||
* <StarField class="absolute inset-0 h-full w-full" style="color: var(--color-amber); opacity:.5" />
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
/** Seed for how many sparkles; keeps the markup compact. */
|
||||
density?: 'sparse' | 'default' | 'dense';
|
||||
}
|
||||
const { class: className = '', style = '', density = 'default' } = Astro.props;
|
||||
|
||||
// Pre-baked star positions (cx, cy, r) on a 100x100 viewBox; scaled by density.
|
||||
const baseDots: [number, number, number][] = [
|
||||
[8, 12, 0.5], [22, 6, 0.7], [34, 18, 0.4], [47, 9, 0.6], [61, 14, 0.5],
|
||||
[73, 5, 0.8], [86, 16, 0.4], [94, 8, 0.5], [14, 28, 0.4], [29, 34, 0.6],
|
||||
[43, 27, 0.5], [57, 36, 0.4], [68, 29, 0.7], [81, 38, 0.5], [92, 31, 0.4],
|
||||
[6, 46, 0.6], [19, 52, 0.4], [31, 47, 0.5], [44, 55, 0.4], [58, 49, 0.7],
|
||||
[71, 54, 0.5], [84, 47, 0.4], [96, 55, 0.5], [12, 66, 0.4], [26, 72, 0.6],
|
||||
[39, 64, 0.5], [52, 73, 0.4], [65, 67, 0.5], [78, 74, 0.7], [90, 68, 0.4],
|
||||
[9, 84, 0.5], [23, 90, 0.4], [36, 86, 0.6], [49, 92, 0.4], [62, 85, 0.5],
|
||||
[75, 91, 0.7], [88, 88, 0.4], [16, 40, 0.4], [50, 22, 0.4], [80, 60, 0.4],
|
||||
];
|
||||
// Brighter 4-point sparkles (cx, cy, size).
|
||||
const sparkles: [number, number, number][] = [
|
||||
[20, 20, 2.2], [70, 30, 1.8], [40, 60, 2.4], [85, 75, 1.6], [12, 70, 1.4],
|
||||
];
|
||||
|
||||
const step = density === 'sparse' ? 2 : density === 'dense' ? 1 : 1;
|
||||
const dots = baseDots.filter((_, i) => i % step === 0);
|
||||
---
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class={className}
|
||||
style={style}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{dots.map(([x, y, r]) => (
|
||||
<circle cx={x} cy={y} r={r} />
|
||||
))}
|
||||
{sparkles.map(([x, y, s]) => (
|
||||
<path
|
||||
d={`M${x} ${y - s}c${s * 0.18} ${s * 0.6} ${s * 0.4} ${s * 0.82} ${s} ${s}c${-s * 0.6}${s * 0.18} ${-s * 0.82}${s * 0.4} ${-s}${s}c${-s * 0.18}${-s * 0.6} ${-s * 0.4}${-s * 0.82} ${-s}${-s}c${s * 0.6}${-s * 0.18} ${s * 0.82}${-s * 0.4} ${s}${-s}z`}
|
||||
opacity="0.9"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* MobileMenu.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* React island that powers the hamburger menu on small screens. Renders the
|
||||
* same two-group nav structure as the desktop bar (imported via a data prop so
|
||||
* the source of truth stays in site.ts).
|
||||
*
|
||||
* Accessibility: the button is aria-expanded/aria-controls wired to the panel,
|
||||
* Escape closes, clicking a link closes, and focus is returned to the toggle.
|
||||
* Animations respect prefers-reduced-motion (Framer Motion reads the media query
|
||||
* automatically when we pass the transition).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
export interface MobileNavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
export interface MobileNavGroup {
|
||||
items: MobileNavItem[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
groups: MobileNavGroup[];
|
||||
/** Current path for active link highlighting. */
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
// Small inline mdi-like icons keyed by name (keeps the menu self-contained).
|
||||
const ICON_PATHS: Record<string, string> = {
|
||||
home: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
|
||||
account: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 10c-4.4 0-8 2.2-8 5v1h16v-1c0-2.8-3.6-5-8-5z',
|
||||
briefcase:
|
||||
'M10 16h4a1 1 0 0 0 1-1v-1H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-1v1a3 3 0 0 1-3 3h-4a3 3 0 0 1-3-3v-1z',
|
||||
'dice-multiple':
|
||||
'M6 3h5v5H6V3zm7 0h5v5h-5V3zM3 11h5v5H3v-5zm7 0h5v5h-5v-5zm7 0h-5v5h5v-5z',
|
||||
email:
|
||||
'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zm8 7L4 6v1l8 5 8-5V6l-8 5z',
|
||||
};
|
||||
|
||||
function Icon({ name, className }: { name: string; className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={ICON_PATHS[name] ?? ''} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function isActive(href: string, path: string): boolean {
|
||||
if (href === '/') return path === '/';
|
||||
return path === href || path.startsWith(href + '/');
|
||||
}
|
||||
|
||||
export default function MobileMenu({ groups, currentPath }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Escape closes the menu; lock body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={open ? 'Close menu' : 'Open menu'}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border"
|
||||
style={{ color: 'var(--color-text)', borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
{open ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm"
|
||||
/>
|
||||
{/* Panel */}
|
||||
<motion.nav
|
||||
id="mobile-menu"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'tween', duration: 0.28 }}
|
||||
className="fixed right-0 top-0 z-50 flex h-[100dvh] w-[min(82vw,22rem)] flex-col gap-6 overflow-y-auto p-6 pt-20 shadow-2xl"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
{groups.map((group, gi) => (
|
||||
<ul key={gi} className="flex flex-col gap-1">
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(item.href, currentPath);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-3 text-lg font-medium transition-colors"
|
||||
style={{
|
||||
color: active ? 'var(--color-accent)' : 'var(--color-text)',
|
||||
backgroundColor: active
|
||||
? 'color-mix(in srgb, var(--color-accent) 14%, transparent)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</motion.nav>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
/**
|
||||
* NavBar.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Fixed top navigation. Two visual groups separated by a subtle divider, per
|
||||
* the spec: left = "who I am" (Home, About), right = "what I do" (Professional,
|
||||
* Interests, Contact). The logo block shows the name + tagline.
|
||||
*
|
||||
* On scroll a tiny inline script adds a `scrolled` class -> backdrop blur + a
|
||||
* hairline border + subtle shadow, so content stays readable under the bar.
|
||||
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import ThemeToggle from './ThemeToggle.tsx';
|
||||
import MobileMenu from './MobileMenu.tsx';
|
||||
import { navGroups, siteIdentity } from '@lib/site';
|
||||
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href: string) =>
|
||||
href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
|
||||
---
|
||||
|
||||
<header
|
||||
id="site-nav"
|
||||
data-nav
|
||||
class="fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300"
|
||||
>
|
||||
<div class="border-b border-transparent transition-colors duration-300" data-nav-inner>
|
||||
<nav
|
||||
class="container-prose flex h-16 items-center justify-between gap-4"
|
||||
aria-label="Primary"
|
||||
>
|
||||
<!-- Logo / identity -->
|
||||
<a href="/" class="group flex flex-col leading-tight" aria-label={`${siteIdentity.name} home`}>
|
||||
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);">
|
||||
{siteIdentity.name}
|
||||
</span>
|
||||
<span class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block" style="color: var(--color-text-subtle);">
|
||||
{siteIdentity.tagline}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav: two groups with a divider -->
|
||||
<div class="hidden items-center gap-5 md:flex">
|
||||
{navGroups.map((group, gi) => (
|
||||
<div class:list={['flex items-center gap-1', { 'border-l pl-5': gi > 0 }]} style={gi > 0 ? 'border-color: var(--color-border);' : ''}>
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class:list={[
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
]}
|
||||
style={active ? 'color: var(--color-accent);' : 'color: var(--color-text-muted);'}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon name={item.icon} class="h-4 w-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div class="ml-1">
|
||||
<ThemeToggle client:idle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile controls -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle client:idle />
|
||||
<MobileMenu client:idle groups={navGroups} currentPath={path} />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* Scrolled state: blur + hairline border + shadow. Toggled by the script below. */
|
||||
[data-nav].scrolled [data-nav-inner] {
|
||||
background-color: color-mix(in srgb, var(--color-surface) 78%, transparent);
|
||||
backdrop-filter: saturate(150%) blur(10px);
|
||||
-webkit-backdrop-filter: saturate(150%) blur(10px);
|
||||
border-bottom-color: var(--color-border);
|
||||
box-shadow: 0 8px 30px -20px rgba(11, 16, 38, 0.6);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Add the `scrolled` class once the page has scrolled a few px. Re-bind on
|
||||
* View Transitions navigations so it keeps working across page changes.
|
||||
*/
|
||||
function setupNavScroll() {
|
||||
const nav = document.getElementById('site-nav');
|
||||
if (!nav) return;
|
||||
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
setupNavScroll();
|
||||
document.addEventListener('astro:page-load', setupNavScroll);
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* ThemeToggle.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* React island that toggles `data-theme` on <html> between 'light' and 'dark'
|
||||
* and persists the choice to localStorage (key matches theme-init.ts).
|
||||
*
|
||||
* Renders mdi sun/moon SVGs inline (no extra requests). The initial state is
|
||||
* read from the DOM at mount so it always matches what theme-init set, even
|
||||
* across View Transitions navigations.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'gk-theme';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function currentTheme(): Theme {
|
||||
if (typeof document === 'undefined') return 'light';
|
||||
return (document.documentElement.getAttribute('data-theme') as Theme) ?? 'light';
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Read the real current theme on mount and after View Transitions navigations.
|
||||
useEffect(() => {
|
||||
setTheme(currentTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* ignore private-mode write failures */
|
||||
}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border transition-colors hover:bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)]"
|
||||
style={{
|
||||
color: 'var(--color-text)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Sun (shown in dark mode -> click to go light) */}
|
||||
{mounted && isDark && (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0-5a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1zm0 17a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1zM3 11h2a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2zm16 0h2a1 1 0 1 1 0 2h-2a1 1 0 1 1 0-2zM5.64 5.64a1 1 0 0 1 1.42 0l1.4 1.42a1 1 0 1 1-1.4 1.4l-1.42-1.4a1 1 0 0 1 0-1.42zm9.9 9.9a1 1 0 0 1 1.42 0l1.4 1.42a1 1 0 0 1-1.4 1.4l-1.42-1.4a1 1 0 0 1 0-1.42zm1.42-9.9a1 1 0 0 1 0 1.42l-1.42 1.4a1 1 0 1 1-1.4-1.4l1.4-1.42a1 1 0 0 1 1.42 0zm-9.9 9.9a1 1 0 0 1 0 1.42l-1.42 1.4a1 1 0 0 1-1.4-1.4l1.4-1.42a1 1 0 0 1 1.42 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Moon (shown in light mode -> click to go dark) */}
|
||||
{mounted && !isDark && (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Placeholder keeps button sized before mount to avoid layout shift. */}
|
||||
{!mounted && <span style={{ width: 20, height: 20, display: 'inline-block' }} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* ContactForm.tsx
|
||||
* ---------------------------------------------------------------------------
|
||||
* Accessible contact form island. Posts to /api/contact, which either sends an
|
||||
* email via SMTP or fires a webhook. Manages idle / submitting / success / error
|
||||
* states, does lightweight client validation, and includes a honeypot field for
|
||||
* crude spam defense (the server also rate-limits).
|
||||
*
|
||||
* The form is keyboard-accessible, labels every field, and announces status
|
||||
* changes via an aria-live region.
|
||||
*/
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
type Status = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
interface FieldErrors {
|
||||
name?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function ContactForm() {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
function validate(values: { name: string; email: string; message: string }): FieldErrors {
|
||||
const e: FieldErrors = {};
|
||||
if (!values.name.trim()) e.name = 'Please enter your name.';
|
||||
if (!values.email.trim()) e.email = 'Please enter your email.';
|
||||
else if (!EMAIL_RE.test(values.email)) e.email = 'That email address looks off.';
|
||||
if (!values.message.trim()) e.message = 'Please enter a message.';
|
||||
else if (values.message.trim().length < 10) e.message = 'A little more detail, please (10+ characters).';
|
||||
return e;
|
||||
}
|
||||
|
||||
async function onSubmit(ev: FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
const form = ev.currentTarget;
|
||||
const data = new FormData(form);
|
||||
const values = {
|
||||
name: String(data.get('name') ?? '').trim(),
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
message: String(data.get('message') ?? '').trim(),
|
||||
// Honeypot: bots fill this, humans never see it.
|
||||
company: String(data.get('company') ?? ''),
|
||||
};
|
||||
|
||||
const found = validate(values);
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
|
||||
setStatus('submitting');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || 'Something went wrong. Please try again.');
|
||||
}
|
||||
setStatus('success');
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
'w-full rounded-lg border bg-transparent px-3 py-2.5 text-sm outline-none transition-colors focus:border-[var(--color-accent)]';
|
||||
const labelBase = 'mb-1.5 block font-sans text-sm font-medium';
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-xl border p-8 text-center"
|
||||
style={{ borderColor: 'var(--color-accent)', background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-accent-contrast)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3 className="font-sans text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Message sent.
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Thanks for reaching out. I will get back to you soon.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus('idle')}
|
||||
className="btn btn-secondary mt-2"
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Honeypot: visually hidden, not aria-hidden so screen readers can skip it. */}
|
||||
<div style={{ position: 'absolute', left: '-9999px', top: 'auto', width: 1, height: 1, overflow: 'hidden' }} aria-hidden="true">
|
||||
<label htmlFor="company">Company (leave blank)</label>
|
||||
<input id="company" name="company" type="text" tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="name" className={labelBase} style={{ color: 'var(--color-text)' }}>Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.name ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
/>
|
||||
{errors.name && <p id="name-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="email" className={labelBase} style={{ color: 'var(--color-text)' }}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.email ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={errors.email ? 'email-error' : undefined}
|
||||
/>
|
||||
{errors.email && <p id="email-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="message" className={labelBase} style={{ color: 'var(--color-text)' }}>Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
className={inputBase}
|
||||
style={{
|
||||
borderColor: errors.message ? 'var(--color-accent)' : 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
aria-invalid={Boolean(errors.message)}
|
||||
aria-describedby={errors.message ? 'message-error' : undefined}
|
||||
/>
|
||||
{errors.message && <p id="message-error" className="mt-1 text-xs" style={{ color: 'var(--color-accent)' }}>{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="rounded-lg p-3 text-sm" style={{ background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', color: 'var(--color-accent)' }}>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="btn btn-primary self-start disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
|
||||
{/* Screen-reader status announcements. */}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{status === 'submitting' ? 'Sending your message.' : ''}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* ProjectCard.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Card for a single project entry from the `projects` collection. Renders title,
|
||||
* role, tech pills, summary, and links (repo/demo/case study). Placeholder
|
||||
* entries (frontmatter `placeholder: true`) get a subtle badge so they read as
|
||||
* "to be filled" rather than real work, per the chosen content approach.
|
||||
*/
|
||||
import { type CollectionEntry } from 'astro:content';
|
||||
import Icon from '@components/Icon.astro';
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<'projects'>;
|
||||
}
|
||||
const { project } = Astro.props;
|
||||
const d = project.data;
|
||||
|
||||
const links = [
|
||||
d.repo && { href: d.repo, label: 'GitHub', icon: 'github' },
|
||||
d.demo && { href: d.demo, label: 'Live demo', icon: 'open-in-new' },
|
||||
d.caseStudy && { href: d.caseStudy, label: 'Case study', icon: 'file-document-outline' },
|
||||
].filter(Boolean) as { href: string; label: string; icon: string }[];
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
'card card-interactive flex h-full flex-col gap-4',
|
||||
d.placeholder && 'border-dashed',
|
||||
]}
|
||||
style={d.placeholder ? 'border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);' : ''}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 style="color: var(--color-text);">{d.title}</h3>
|
||||
<p class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
{d.role}
|
||||
</p>
|
||||
</div>
|
||||
{d.placeholder && (
|
||||
<span
|
||||
class="pill"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="pencil" class="h-3.5 w-3.5" />
|
||||
Placeholder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{d.tech.length > 0 && (
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{d.tech.map((t) => <li class="pill">{t}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p class="grow text-sm leading-relaxed" style="color: var(--color-text-muted);">
|
||||
{d.summary}
|
||||
</p>
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul class="mt-1 flex flex-wrap gap-4">
|
||||
{links.map((l) => (
|
||||
<li>
|
||||
<a
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 font-sans text-sm font-semibold transition-colors"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name={l.icon} class="h-4 w-4" />
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
/**
|
||||
* SectionNav.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Sticky in-page sub-navigation for the Professional page. Jumps between the
|
||||
* six anchor sections (Summary, Experience, Skills, Projects, Education,
|
||||
* Contact) via smooth scroll. Hidden on small screens where the page reads as a
|
||||
* simple scroll. Active state is computed client-side via IntersectionObserver.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
const sections = [
|
||||
{ id: 'summary', label: 'Summary', icon: 'format-list-text' },
|
||||
{ id: 'experience', label: 'Experience', icon: 'timeline-text' },
|
||||
{ id: 'skills', label: 'Skills', icon: 'chart-bar' },
|
||||
{ id: 'projects', label: 'Projects', icon: 'view-grid' },
|
||||
{ id: 'education', label: 'Education', icon: 'school' },
|
||||
{ id: 'contact', label: 'Contact', icon: 'email' },
|
||||
];
|
||||
---
|
||||
|
||||
<nav aria-label="Professional sections" class="hidden lg:block">
|
||||
<div
|
||||
class="sticky top-24 flex flex-wrap gap-1 rounded-xl border p-1.5"
|
||||
style="border-color: var(--color-border); background: color-mix(in srgb, var(--color-surface-raised) 60%, transparent); backdrop-filter: blur(6px);"
|
||||
>
|
||||
{sections.map((s) => (
|
||||
<a
|
||||
href={`#${s.id}`}
|
||||
data-section-link={s.id}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 font-sans text-sm font-medium transition-colors"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
<Icon name={s.icon} class="h-4 w-4" />
|
||||
{s.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
// Highlight the link for whichever section is currently in view.
|
||||
function setupSectionSpy() {
|
||||
const links = Array.from(
|
||||
document.querySelectorAll<HTMLAnchorElement>('[data-section-link]'),
|
||||
);
|
||||
if (!links.length) return;
|
||||
const sections = links
|
||||
.map((l) => document.getElementById(l.dataset.sectionLink!))
|
||||
.filter((el): el is HTMLElement => Boolean(el));
|
||||
|
||||
const setActive = (id: string) => {
|
||||
for (const l of links) {
|
||||
const active = l.dataset.sectionLink === id;
|
||||
l.style.color = active ? 'var(--color-accent)' : 'var(--color-text-muted)';
|
||||
l.style.background = active
|
||||
? 'color-mix(in srgb, var(--color-accent) 14%, transparent)'
|
||||
: 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||
if (visible) setActive(visible.target.id);
|
||||
},
|
||||
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5] },
|
||||
);
|
||||
sections.forEach((s) => io.observe(s));
|
||||
}
|
||||
setupSectionSpy();
|
||||
document.addEventListener('astro:page-load', setupSectionSpy);
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
/**
|
||||
* SkillGrid.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Renders the spec's three skill tiers (Core Competencies, Technical Stack,
|
||||
* Emerging & Exploring) as categorized cards. The spec lists some items as
|
||||
* "[Fill in]" for languages/frameworks/tools; those are surfaced as Placeholder
|
||||
* slots rather than invented content.
|
||||
*
|
||||
* Items map to mdi icons for visual texture. The Core tier uses a subtle bar
|
||||
* treatment (decorative, not a proficiency claim) to feel like the spec's
|
||||
* "visual skill bars" suggestion without implying false metrics.
|
||||
*/
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Placeholder from '@components/layout/Placeholder.astro';
|
||||
|
||||
const core = [
|
||||
{ icon: 'cloud', label: 'Cloud Architecture & Development' },
|
||||
{ icon: 'cellphone-link', label: 'Mobile Application Development' },
|
||||
{ icon: 'server-network', label: 'SAP Business Technology Platform (BTP)' },
|
||||
{ icon: 'account-group', label: 'Team Leadership & Technical Mentorship' },
|
||||
{ icon: 'translate', label: 'Cross-functional Communication' },
|
||||
];
|
||||
|
||||
const emerging = [
|
||||
{ icon: 'chart-line', label: 'SAP BTP deep-dive (active expansion)' },
|
||||
{ icon: 'robot', label: 'AI-powered development tooling' },
|
||||
{ icon: 'cog-refresh', label: 'Advanced automation and workflow engineering' },
|
||||
];
|
||||
|
||||
// Technical stack mixes known items (from spec context) with explicit gaps.
|
||||
const techStack = [
|
||||
{ icon: 'code-braces', label: 'Languages', value: null, hint: 'Likely JavaScript/TypeScript, Python, Java, ABAP given SAP focus.' },
|
||||
{ icon: 'cube-outline', label: 'Frameworks', value: null, hint: 'Add your primary frameworks here.' },
|
||||
{ icon: 'microsoft-azure-devops', label: 'Cloud Platforms', value: 'SAP BTP, [others]', hint: 'List the cloud platforms you ship on.' },
|
||||
{ icon: 'git', label: 'Tools', value: 'Git, CI/CD pipelines, [fill in]', hint: 'Add the rest of your toolchain.' },
|
||||
{ icon: 'brain', label: 'AI/ML', value: 'Hands-on experience with machine learning frameworks and LLM integration', hint: null },
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- Core Competencies -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-sans" style="color: var(--color-text);">Core Competencies</h3>
|
||||
<ul class="grid gap-3 sm:grid-cols-2">
|
||||
{core.map((item, i) => (
|
||||
<li
|
||||
class="reveal card flex items-center gap-3"
|
||||
data-reveal
|
||||
style={`transition-delay:${i * 60}ms`}
|
||||
>
|
||||
<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="text-sm font-medium" style="color: var(--color-text);">{item.label}</span>
|
||||
{/* Decorative bar; intentionally not a % so we never imply false metrics. */}
|
||||
<span class="ml-auto hidden h-1.5 w-16 overflow-hidden rounded-full sm:block" style="background: var(--color-border);" aria-hidden="true">
|
||||
<span class="block h-full w-full" style="background: var(--color-accent);"></span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Technical Stack -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-sans" style="color: var(--color-text);">Technical Stack</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{techStack.map((item) => (
|
||||
<div class="card flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="inline-flex h-9 w-9 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="font-sans text-sm font-semibold" style="color: var(--color-text);">{item.label}</span>
|
||||
</div>
|
||||
{item.value
|
||||
? <p class="text-sm" style="color: var(--color-text-muted);">{item.value}</p>
|
||||
: <Placeholder label={item.label} hint={item.hint ?? ''} class="mt-1" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emerging & Exploring -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-sans" style="color: var(--color-text);">Emerging & Exploring</h3>
|
||||
<ul class="grid gap-3 sm:grid-cols-3">
|
||||
{emerging.map((item, i) => (
|
||||
<li
|
||||
class="reveal card flex items-start gap-3"
|
||||
data-reveal
|
||||
style={`transition-delay:${i * 70}ms`}
|
||||
>
|
||||
<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={item.icon} class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--color-text);">{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
/**
|
||||
* Timeline.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* Vertical timeline for the Experience section. Each entry has a marker (mdi
|
||||
* icon), a period label, company/title, focus, and rendered markdown body (the
|
||||
* bullet list from the experience collection entry).
|
||||
*
|
||||
* The vertical line is drawn with a pseudo-element; markers sit on it. Entry
|
||||
* bodies are expandable detail (the spec asks for "expandable detail"). On
|
||||
* small screens the marker line stays but spacing tightens.
|
||||
*
|
||||
* Astro 7 content layer: `render(entry)` returns `{ Content }` (an Astro
|
||||
* component), which we render with `<Content />` rather than stringifying.
|
||||
*/
|
||||
import { type CollectionEntry, render } from 'astro:content';
|
||||
import Icon from '@components/Icon.astro';
|
||||
import Reveal from '@components/motion/Reveal.astro';
|
||||
|
||||
interface Props {
|
||||
entries: CollectionEntry<'experience'>[];
|
||||
}
|
||||
|
||||
const { entries } = Astro.props;
|
||||
|
||||
// Pre-render each entry to get its Content component (Astro 7 API).
|
||||
const rendered = await Promise.all(
|
||||
entries.map(async (e) => ({
|
||||
entry: e,
|
||||
Content: (await render(e)).Content,
|
||||
})),
|
||||
);
|
||||
---
|
||||
|
||||
<ol class="relative flex flex-col gap-10">
|
||||
{/* The vertical line. */}
|
||||
<span
|
||||
class="absolute left-[1.15rem] top-2 bottom-2 w-px"
|
||||
style="background: var(--color-border);"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
{rendered.map(({ entry, Content }, i) => (
|
||||
<li class="relative pl-12">
|
||||
<Reveal delay={i * 80}>
|
||||
{/* Marker */}
|
||||
<span
|
||||
class="absolute left-0 top-0 inline-flex h-9 w-9 items-center justify-center rounded-full border-4"
|
||||
style="background: var(--color-surface-raised); border-color: var(--color-surface);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full"
|
||||
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent);"
|
||||
>
|
||||
<Icon name={entry.data.icon} class="h-4 w-4" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="card flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<h3 style="color: var(--color-text);">{entry.data.company}</h3>
|
||||
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
|
||||
{entry.data.period}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-sans font-semibold" style="color: var(--color-accent);">
|
||||
{entry.data.title}
|
||||
</p>
|
||||
{entry.data.focus && (
|
||||
<p class="text-sm italic" style="color: var(--color-text-muted);">
|
||||
Focus: {entry.data.focus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details class="group mt-1">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center gap-2 font-sans text-sm font-semibold transition-colors"
|
||||
style="color: var(--color-accent);"
|
||||
>
|
||||
<Icon name="chevron-down" class="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
Show details
|
||||
</summary>
|
||||
<div
|
||||
class="mt-4 flex flex-col gap-3 text-sm leading-relaxed [&_ul]:flex [&_ul]:flex-col [&_ul]:gap-2 [&_li]:ml-4 [&_li]:list-disc [&_strong]:font-semibold [&_strong]:text-[var(--color-text)] [&_p:first-child]:mt-0"
|
||||
style="color: var(--color-text-muted);"
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Reveal>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* content.config.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Astro Content Collections schemas. Adding a project or experience entry is as
|
||||
* simple as dropping a new MDX/markdown file in the matching folder; the type
|
||||
* system and Astro will validate it. See docs/CONTENT.md for the authoring guide.
|
||||
*
|
||||
* Collections:
|
||||
* - projects: portfolio entries shown on the Professional page
|
||||
* - experience: work history entries shown on the Experience timeline
|
||||
*/
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { z } from 'zod/v4';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const projects = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
role: z.string().default('Developer'),
|
||||
/** Short tag list rendered as pills. */
|
||||
tech: z.array(z.string()).default([]),
|
||||
/** 2-3 sentence summary. */
|
||||
summary: z.string(),
|
||||
/** Optional links. */
|
||||
repo: z.string().url().optional(),
|
||||
demo: z.string().url().optional(),
|
||||
caseStudy: z.string().url().optional(),
|
||||
/** Optional cover image (public path). */
|
||||
cover: z.string().optional(),
|
||||
/** Sort order; lower comes first. Defaults to 100. */
|
||||
order: z.number().default(100),
|
||||
/** True for the placeholder used until Greg adds real projects. */
|
||||
placeholder: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
const experience = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/experience' }),
|
||||
schema: z.object({
|
||||
company: z.string(),
|
||||
title: z.string(),
|
||||
/** Free-form date label, e.g. "2020 - Present". */
|
||||
period: z.string().optional(),
|
||||
focus: z.string().optional(),
|
||||
/** mdi icon for the timeline marker. */
|
||||
icon: z.string().default('briefcase'),
|
||||
/** Sort order; lower = more recent / first shown. */
|
||||
order: z.number().default(100),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { projects, experience };
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
# Experience entry for Implico Inc.
|
||||
# This seeds the Experience timeline. The narrative bullets below are the spec's
|
||||
# template bullets; Greg should replace the bracketed placeholders with real
|
||||
# outcomes, metrics, and dates. See docs/CONTENT.md for how to add more roles.
|
||||
company: 'Implico Inc.'
|
||||
title: 'Software Developer / Lead Developer / Team Lead'
|
||||
period: '[Greg to fill dates]'
|
||||
focus: 'Cloud and mobile application development, SAP-adjacent solutions.'
|
||||
icon: 'briefcase'
|
||||
order: 10
|
||||
---
|
||||
|
||||
Current expansion: **SAP Business Technology Platform (BTP)**.
|
||||
|
||||
Key contributions:
|
||||
|
||||
- Lead development on [cloud/mobile project], delivering [specific outcome].
|
||||
- Architect and maintain [system/platform], supporting [X users/clients].
|
||||
- Collaborate with cross-functional teams to translate business requirements into technical solutions.
|
||||
- Mentor junior developers and establish coding standards and review practices.
|
||||
- Drive adoption of new technologies and frameworks within the team.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
# Placeholder project card.
|
||||
# This exists so the Projects grid has structure to show until Greg adds real
|
||||
# entries. To replace it: copy this file, edit the frontmatter + body, and bump
|
||||
# `placeholder: false`. Or just delete this file and add your own. See
|
||||
# docs/CONTENT.md. The schema lives in src/content.config.ts.
|
||||
title: 'Your project here'
|
||||
role: 'Lead Developer'
|
||||
tech: ['TypeScript', 'Node.js', 'SAP BTP']
|
||||
summary: >
|
||||
This is a placeholder so the project grid has the right layout. Replace it
|
||||
with a real 2-3 sentence description of what the project does and your
|
||||
contribution, then add links below.
|
||||
order: 100
|
||||
placeholder: true
|
||||
---
|
||||
|
||||
Drop in a real project writeup here. Markdown is supported, including headings,
|
||||
lists, code blocks, and links. Keep the frontmatter summary short (2-3
|
||||
sentences) and use this body for the longer story if you want case-study depth.
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
/**
|
||||
* env.d.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Ambient declarations for side-effect-only CSS imports (fontsource packages)
|
||||
* and any other modules that ship no types but are imported for their effect.
|
||||
*/
|
||||
|
||||
// Fontsource variable font packages are imported purely for their CSS side
|
||||
// effect (registering the @font-face rules). They have no runtime exports we
|
||||
// use, so we declare them as side-effect modules to satisfy the TS compiler.
|
||||
declare module '@fontsource-variable/inter';
|
||||
declare module '@fontsource-variable/plus-jakarta-sans';
|
||||
declare module '@fontsource-variable/jetbrains-mono';
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* BaseLayout.astro
|
||||
* ---------------------------------------------------------------------------
|
||||
* The shared shell for every page: <html>, <head> (fonts, meta, theme-init
|
||||
* inline script), the fixed NavBar, the main content slot, and the Footer.
|
||||
*
|
||||
* Theming axis #1 (light/dark) is set on <html data-theme> by the inline
|
||||
* theme-init script before paint (no FOUC). Theming axis #2 (section tone) is
|
||||
* set on the <body data-surface> via the `surface` prop so each page can opt
|
||||
* into neutral / professional / interest styling.
|
||||
*
|
||||
* Page transitions use Astro's <ClientRouter /> (View Transitions API).
|
||||
*/
|
||||
import '@/styles/global.css';
|
||||
|
||||
// Self-hosted fonts (woff2, variable). Avoids external requests for perf + privacy.
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/plus-jakarta-sans';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import NavBar from '@components/nav/NavBar.astro';
|
||||
import Footer from '@components/layout/Footer.astro';
|
||||
import { themeInitScript } from '@/scripts/theme-init';
|
||||
import { siteIdentity } from '@lib/site';
|
||||
|
||||
type Surface = 'neutral' | 'professional' | 'interest';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Which section tone to apply. Defaults to neutral (Home/About/Contact). */
|
||||
surface?: Surface;
|
||||
/** Override default OG image path. */
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Greg Kilmer is a software developer, maker, and tabletop gamemaster based in Philadelphia. This is where his professional and personal worlds meet.',
|
||||
surface = 'neutral',
|
||||
ogImage,
|
||||
} = Astro.props;
|
||||
|
||||
const fullTitle = title
|
||||
? `${title} | ${siteIdentity.name}`
|
||||
: `${siteIdentity.name} — Developer. Maker. Storyteller.`;
|
||||
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://patiun.com');
|
||||
const ogImageUrl = ogImage
|
||||
? new URL(ogImage, Astro.site ?? 'https://patiun.com').toString()
|
||||
: new URL('/images/hero-portrait.jpg', Astro.site ?? 'https://patiun.com').toString();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<!-- `no-js` is removed by reveal-init.ts the moment JS runs. While it stays,
|
||||
the global.css `.no-js .reveal { opacity: 1 }` rule guarantees content is
|
||||
never invisible even if the reveal script fails to load. -->
|
||||
<html lang="en" class="no-js" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<meta name="theme-color" content="#0b1026" />
|
||||
|
||||
<!-- Open Graph / social cards -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:site_name" content={siteIdentity.name} />
|
||||
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Theme boot: blocking inline script prevents flash of wrong theme. -->
|
||||
<script is:inline set:html={themeInitScript} />
|
||||
|
||||
<!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal].
|
||||
Loaded as a module so it self-initializes on load + on View Transitions
|
||||
(it listens for astro:page-load internally). -->
|
||||
<script>
|
||||
import '@/scripts/reveal-init';
|
||||
</script>
|
||||
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body data-surface={surface}>
|
||||
<a href="#main" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-(--color-amber) focus:px-4 focus:py-2 focus:text-(--color-ink)">
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<NavBar />
|
||||
|
||||
<main id="main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* motion.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Shared animation helpers. Most scroll reveals are handled in CSS via the
|
||||
* `reveal` utility (IntersectionObserver in Reveal.astro, zero JS framework
|
||||
* cost). These are for the few React islands that genuinely need Framer Motion
|
||||
* (animated hero background, mobile menu, contact form states).
|
||||
*
|
||||
* Every helper here respects `prefers-reduced-motion`: when set, animations are
|
||||
* collapsed to instant final states.
|
||||
*/
|
||||
|
||||
/** True when the user has asked the OS to minimize motion. SSR-safe. */
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Standard ease curves reused across islands. */
|
||||
export const easings = {
|
||||
out: [0.22, 1, 0.36, 1] as const,
|
||||
inOut: [0.65, 0, 0.35, 1] as const,
|
||||
};
|
||||
|
||||
/** Fade + rise entrance. Used for staggered card/hero children. */
|
||||
export const fadeUp = {
|
||||
hidden: { opacity: 0, y: 24 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
/** A simple, accessible menu/framer transition. */
|
||||
export const mobileMenuTransition = {
|
||||
hidden: { opacity: 0, height: 0 },
|
||||
visible: { opacity: 1, height: 'auto' },
|
||||
} as const;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* site.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Central source of truth for site-wide config: identity, nav structure (the
|
||||
* two-group nav from the spec), social links, and the tagline.
|
||||
*
|
||||
* Components import from here rather than hard-coding values, so a single edit
|
||||
* propagates everywhere. The spec's emoji icons are mapped to mdi icon names;
|
||||
* actual icon rendering happens in components via <Icon />.
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
/** Display label. */
|
||||
label: string;
|
||||
/** Route path. */
|
||||
href: string;
|
||||
/** mdi icon name (without the `mdi:` prefix). */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface NavLinkGroup {
|
||||
/** The two visual groups in the top nav ("who I am" vs "what I do"). */
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/** Top-level identity shown in the nav/logo area. */
|
||||
export const siteIdentity = {
|
||||
name: 'Greg Kilmer',
|
||||
/** Spec tagline; appears beside the logo. */
|
||||
tagline: 'Developer. Maker. Storyteller.',
|
||||
/** Spec domain. */
|
||||
domain: 'patiun.com',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Primary navigation. Two groups separated by a subtle divider, per the spec.
|
||||
* Left group = "who I am" (Home, About)
|
||||
* Right group = "what I do" (Professional, Interests, Contact)
|
||||
*/
|
||||
export const navGroups: NavLinkGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{ label: 'Home', href: '/', icon: 'home' },
|
||||
{ label: 'About', href: '/about', icon: 'account' },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ label: 'Professional', href: '/professional', icon: 'briefcase' },
|
||||
{ label: 'Interests', href: '/interests', icon: 'dice-multiple' },
|
||||
{ label: 'Contact', href: '/contact', icon: 'email' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat list of all nav items (handy for active-link checks / mobile menus). */
|
||||
export const allNavItems: NavItem[] = navGroups.flatMap((g) => g.items);
|
||||
|
||||
/**
|
||||
* Social links for the footer and contact page.
|
||||
* TODO placeholders flagged with [Greg to fill]; URLs left generic until provided.
|
||||
*/
|
||||
export const socials = {
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
handle: '[Greg to fill]',
|
||||
href: 'https://github.com/',
|
||||
icon: 'github',
|
||||
},
|
||||
linkedin: {
|
||||
label: 'LinkedIn',
|
||||
handle: '[Greg to fill]',
|
||||
href: 'https://www.linkedin.com/',
|
||||
icon: 'linkedin',
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
handle: '[Greg to fill]',
|
||||
href: '#',
|
||||
icon: 'discord',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Spec footer microcopy. */
|
||||
export const footerMicrocopy = 'Built with intention. Powered by caffeine.';
|
||||
@@ -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 — 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 · Maker · 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* reveal-init.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* The other half of Reveal.astro. Reveal.astro stamps each wrapper with the
|
||||
* `reveal` class (opacity:0, set in global.css) and a `data-reveal` hook, but
|
||||
* it ships NO script of its own. This module is what actually flips those
|
||||
* elements to `is-visible` when they scroll into view.
|
||||
*
|
||||
* Without it every page's content is stuck at opacity:0 (the original bug).
|
||||
*
|
||||
* It also removes the `no-js` class from <html> as its first act, so that the
|
||||
* `.no-js .reveal { opacity: 1 }` guard in global.css only applies when JS is
|
||||
* genuinely unavailable — i.e. content is NEVER invisible, even if this script
|
||||
* fails to load for some reason.
|
||||
*
|
||||
* Honors prefers-reduced-motion: elements are revealed instantly.
|
||||
*/
|
||||
export function initReveals(): void {
|
||||
const root = document.documentElement;
|
||||
|
||||
// 1. Flip <html class="no-js"> -> the JS guard no longer applies; CSS reveals
|
||||
// remain at opacity:0 until we toggle them (or instantly under reduced motion).
|
||||
root.classList.remove('no-js');
|
||||
|
||||
const targets = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-reveal]'),
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Reduced motion: show everything immediately, no observer needed.
|
||||
if (reduce) {
|
||||
for (const el of targets) el.classList.add('is-visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything already in view on load should appear at once (avoid a late pop).
|
||||
const reveal = (el: HTMLElement) => el.classList.add('is-visible');
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
reveal(entry.target as HTMLElement);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -8% 0px', threshold: 0.08 },
|
||||
);
|
||||
|
||||
for (const el of targets) io.observe(el);
|
||||
}
|
||||
|
||||
// Run on first load.
|
||||
initReveals();
|
||||
|
||||
// Re-run after Astro View Transitions swap in new DOM.
|
||||
document.addEventListener('astro:page-load', initReveals);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* theme-init.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Inlined (as a string) into the <head> of BaseLayout.astro so it runs BEFORE
|
||||
* first paint. Reads the saved theme from localStorage, falling back to the OS
|
||||
* preference via prefers-color-scheme, and sets `data-theme` on <html>.
|
||||
*
|
||||
* Doing this inline + blocking is what prevents the flash-of-wrong-theme (FOUC).
|
||||
* Keep this file dependency-free and tiny: it ships to every page.
|
||||
*/
|
||||
|
||||
export const themeInitScript = `
|
||||
(function () {
|
||||
try {
|
||||
var STORAGE_KEY = 'gk-theme';
|
||||
var stored = localStorage.getItem(STORAGE_KEY);
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var theme = stored || (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// React to OS theme changes only if the user hasn't chosen explicitly.
|
||||
if (!stored) {
|
||||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var onChange = function (e) {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
} catch (e) {
|
||||
// If anything fails (private mode, etc.), default to light.
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default themeInitScript;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* animations.css
|
||||
* ---------------------------------------------------------------------------
|
||||
* Keyframes used by hero backgrounds, decorative motion, and ambient effects.
|
||||
* All animations are disabled by the global prefers-reduced-motion rule in
|
||||
* global.css, so they are safe to use freely.
|
||||
*/
|
||||
|
||||
/* Slowly drift radial-gradient stops for the hero mesh background. */
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position:
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
50% 100%;
|
||||
}
|
||||
50% {
|
||||
background-position:
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
50% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position:
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
50% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Soft vertical float for decorative shapes / icons. */
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slow rotation for ambient rings. */
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulsing glow for accent dots / status indicators. */
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Nav backdrop blur is handled via class toggle, but the entry of the nav uses
|
||||
* a fade-down so it doesn't pop on first paint. */
|
||||
@keyframes fade-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes bound to the keyframes above. */
|
||||
.animate-gradient-shift {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 18s var(--ease-in-out-soft, ease-in-out) infinite;
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 6s var(--ease-in-out-soft, ease-in-out) infinite;
|
||||
}
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 40s linear infinite;
|
||||
}
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 4s var(--ease-in-out-soft, ease-in-out) infinite;
|
||||
}
|
||||
.animate-fade-down {
|
||||
animation: fade-down 0.5s var(--ease-out-soft, ease-out) both;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* global.css
|
||||
* ---------------------------------------------------------------------------
|
||||
* Tailwind v4 entry + global base layer for Greg Kilmer's profile site.
|
||||
*
|
||||
* Tailwind v4 uses a CSS-first config. The `@import "tailwindcss"` pulls in the
|
||||
* engine and the `@theme` block below exposes the project palette as utility
|
||||
* classes (e.g. `bg-surface`, `text-accent`). Section/light-dark tokens live in
|
||||
* themes.css and are referenced here via CSS variables.
|
||||
*
|
||||
* Import order matters: theme tokens first (so @theme can reference them), then
|
||||
* Tailwind, then animations, then base.
|
||||
*/
|
||||
|
||||
/* Palette + light/dark + section surface tokens (CSS variables). */
|
||||
@import "./themes.css";
|
||||
/* Keyframes (gradient shift, fade-up, blur, float). */
|
||||
@import "./animations.css";
|
||||
|
||||
/* Tailwind engine. In v4 this is a single import. */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Theme bridge: expose CSS variables from themes.css to Tailwind utilities.
|
||||
* Each entry below generates a utility family, e.g. `--color-surface` -> the
|
||||
* `bg-surface` / `text-surface` / `border-surface` classes.
|
||||
* The values point at the CSS variables so that changing `data-theme` or the
|
||||
* section wrapper (`data-surface`) instantly restyles every component.
|
||||
* ------------------------------------------------------------------------- */
|
||||
@theme {
|
||||
/* Brand colors (fixed, do not change with theme). Night-sky mystical set:
|
||||
* star gold, mystic violet, nebula teal, midnight indigo, star-white. */
|
||||
--color-amber: #e8b54a; /* star gold */
|
||||
--color-amber-soft: #f4cd72; /* brighter gold for hovers */
|
||||
--color-violet: #8b7bd8; /* mystic violet */
|
||||
--color-violet-deep: #6f5fb6;
|
||||
--color-teal: #6fb6ad; /* nebula teal */
|
||||
--color-teal-deep: #4f8c84;
|
||||
--color-slate: #161b3a; /* deep indigo-slate */
|
||||
--color-ink: #0b1026; /* midnight indigo */
|
||||
--color-cream: #f4f1e8; /* star-white */
|
||||
|
||||
/* Semantic tokens (resolve per theme/section via variables). */
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-raised: var(--surface-raised);
|
||||
--color-surface-sunken: var(--surface-sunken);
|
||||
--color-text: var(--text);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-subtle: var(--text-subtle);
|
||||
--color-border: var(--border);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-contrast: var(--accent-contrast);
|
||||
--color-secondary: var(--secondary);
|
||||
|
||||
/* Typography. */
|
||||
--font-sans: "Plus Jakarta Sans Variable", "Inter Variable", ui-sans-serif,
|
||||
system-ui, sans-serif;
|
||||
--font-body: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", monospace;
|
||||
|
||||
/* Motion tokens (used across reveal + transition classes). */
|
||||
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in-out-soft: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Base layer: resets, defaults, focus styles.
|
||||
* ------------------------------------------------------------------------- */
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Offset so anchor jumps clear the fixed nav. */
|
||||
scroll-padding-top: 6rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* Honor reduced motion globally. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Strong typographic rhythm for headings. */
|
||||
h1 {
|
||||
font-size: clamp(2.25rem, 5vw + 1rem, 4rem);
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(1.75rem, 2.5vw + 1rem, 2.5rem);
|
||||
}
|
||||
h3 {
|
||||
font-size: clamp(1.25rem, 1vw + 1rem, 1.5rem);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s var(--ease-out-soft);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-amber-soft);
|
||||
}
|
||||
|
||||
/* Visible, on-brand focus ring for keyboard users. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-amber);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* Better default for images: never overflow, lazy by default in components. */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Component-ish utilities used across the site. Keeping them here (rather than
|
||||
* as repeated Tailwind class strings) makes tone adjustments easy.
|
||||
* ------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
/* Content width container. */
|
||||
.container-prose {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.container-prose {
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* The recurring "card" surface. */
|
||||
.card {
|
||||
background-color: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
transition:
|
||||
transform 0.3s var(--ease-out-soft),
|
||||
box-shadow 0.3s var(--ease-out-soft),
|
||||
border-color 0.3s var(--ease-out-soft);
|
||||
}
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 18px 40px -24px rgba(11, 16, 38, 0.55);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Pill / tag. */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-text);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Primary (amber) and secondary (outlined) CTA buttons. */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.6rem;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s var(--ease-out-soft),
|
||||
box-shadow 0.2s var(--ease-out-soft),
|
||||
background-color 0.2s var(--ease-out-soft),
|
||||
border-color 0.2s var(--ease-out-soft);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-amber);
|
||||
color: var(--color-ink);
|
||||
box-shadow: 0 10px 30px -12px rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-amber-soft);
|
||||
color: var(--color-ink);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1.5px solid var(--color-border);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Visually hidden but available to screen readers. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Reveal-on-scroll utility. The Reveal.astro component toggles `.is-visible`.
|
||||
*
|
||||
* The `.no-js` guard below is critical: <html> starts with class="no-js"
|
||||
* (set in BaseLayout.astro) and reveal-init.ts removes it once it has wired up
|
||||
* the observer. If JS is disabled OR the script fails to load, that class is
|
||||
* never removed — so this fallback forces reveals visible rather than leaving
|
||||
* the page blank. This is the safety net against the original invisibility bug.
|
||||
* ------------------------------------------------------------------------- */
|
||||
.no-js .reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition:
|
||||
opacity 0.7s var(--ease-out-soft),
|
||||
transform 0.7s var(--ease-out-soft);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* themes.css
|
||||
* ---------------------------------------------------------------------------
|
||||
* Two-axis theming model for the site:
|
||||
*
|
||||
* 1. Global light/dark mode -> set on <html data-theme="light|dark">
|
||||
* 2. Section surface tone -> set on a wrapper via data-surface:
|
||||
* data-surface="neutral" Home / About / Contact (deep night-sky)
|
||||
* data-surface="professional" Professional wing (polished obsidian + gold)
|
||||
* data-surface="interest" Interests wing (candlelit study under stars)
|
||||
*
|
||||
* The semantic tokens (--surface, --text, --accent, ...) are redefined for each
|
||||
* (data-theme x data-surface) combination below. Components only ever reference
|
||||
* the semantic tokens, so swapping either axis automatically restyles them.
|
||||
*
|
||||
* Palette direction: night-sky mystical — midnight indigo bases, star-gold
|
||||
* accents, mystic-violet secondary, nebula-teal tertiary. Light mode is
|
||||
* reinterpreted as a "twilight"/parchment variant so the toggle always works
|
||||
* without breaking the celestial mood.
|
||||
*
|
||||
* `theme-init.ts` (inlined in <head>) sets data-theme before paint to avoid a
|
||||
* flash of the wrong theme. data-surface is applied by the layout wrappers.
|
||||
*/
|
||||
|
||||
/* ===========================================================================
|
||||
* DEFAULT / NEUTRAL SURFACE (Home, About, Contact)
|
||||
* Deep midnight indigo with star-gold accents. The default night sky.
|
||||
* ======================================================================== */
|
||||
|
||||
/* Light + neutral — twilight parchment (warm, low-contrast, still celestial). */
|
||||
:root,
|
||||
[data-theme="light"][data-surface="neutral"],
|
||||
[data-theme="light"][data-surface="neutral"] body {
|
||||
--surface: #f3eee0; /* aged parchment / star-map paper */
|
||||
--surface-raised: #fbf7ec;
|
||||
--surface-sunken: #e7dfca;
|
||||
--text: #1c1830; /* deep indigo ink */
|
||||
--text-muted: #4a445e;
|
||||
--text-subtle: #6b6582;
|
||||
--border: #d8cdb0;
|
||||
--accent: #9a6b1a; /* deeper gold for AA on light */
|
||||
--accent-contrast: #fbf7ec;
|
||||
--secondary: #6f5fb6; /* mystic violet */
|
||||
}
|
||||
|
||||
/* Dark + neutral — the canonical night sky. */
|
||||
[data-theme="dark"][data-surface="neutral"],
|
||||
[data-theme="dark"][data-surface="neutral"] body {
|
||||
--surface: #0b1026; /* midnight indigo */
|
||||
--surface-raised: #161b3a; /* indigo panel */
|
||||
--surface-sunken: #070a1a; /* near-black void */
|
||||
--text: #f4f1e8; /* star-white */
|
||||
--text-muted: #c7c3df;
|
||||
--text-subtle: #8e89b5;
|
||||
--border: #2c3160;
|
||||
--accent: #e8b54a; /* star gold */
|
||||
--accent-contrast: #0b1026;
|
||||
--secondary: #8b7bd8; /* mystic violet */
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* PROFESSIONAL SURFACE
|
||||
* Polished obsidian: deepest indigo with gold + subtle violet. Confident,
|
||||
* precise, the "polished wing" from the spec — now with a starlit edge.
|
||||
* ======================================================================== */
|
||||
|
||||
[data-surface="professional"] {
|
||||
--surface: #0a0e22;
|
||||
--surface-raised: #141a38;
|
||||
--surface-sunken: #05081a;
|
||||
--text: #f4f1e8;
|
||||
--text-muted: #c5c0db;
|
||||
--text-subtle: #8782ad;
|
||||
--border: #292e57;
|
||||
--accent: #e8b54a;
|
||||
--accent-contrast: #0a0e22;
|
||||
--secondary: #8b7bd8;
|
||||
}
|
||||
|
||||
/* Light professional: keeps a dignified deep tone, slightly lifted so the
|
||||
* global toggle is never jarring next to a parchment Home page. */
|
||||
[data-theme="light"][data-surface="professional"] {
|
||||
--surface: #1a1f44;
|
||||
--surface-raised: #252b54;
|
||||
--surface-sunken: #141838;
|
||||
--text: #f4f1e8;
|
||||
--text-muted: #d4d0ec;
|
||||
--text-subtle: #aaa5cd;
|
||||
--border: #3a3f72;
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* INTEREST SURFACE
|
||||
* Candlelit study under a starfield: warm amber-gold bases with deep indigo
|
||||
* text by default (light), flipping to a warm brown-indigo at night. The
|
||||
* "fun wing" stays inviting, now with an esoteric warmth.
|
||||
* ======================================================================== */
|
||||
|
||||
[data-theme="light"][data-surface="interest"],
|
||||
[data-surface="interest"] {
|
||||
--surface: #f6ecd2; /* warm candlelight parchment */
|
||||
--surface-raised: #fdf5dc;
|
||||
--surface-sunken: #ecdcb4;
|
||||
--text: #241a2e; /* plum-indigo ink */
|
||||
--text-muted: #5c4a3a;
|
||||
--text-subtle: #826a52;
|
||||
--border: #ddc79a;
|
||||
--accent: #9a6b1a; /* deeper gold for AA */
|
||||
--accent-contrast: #fdf5dc;
|
||||
--secondary: #6f5fb6; /* mystic violet */
|
||||
}
|
||||
|
||||
[data-theme="dark"][data-surface="interest"] {
|
||||
--surface: #1a1320; /* warm brown-indigo night */
|
||||
--surface-raised: #261c2e;
|
||||
--surface-sunken: #120d18;
|
||||
--text: #f6ecd2; /* warm starlight */
|
||||
--text-muted: #ddcbb0;
|
||||
--text-subtle: #b09a82;
|
||||
--border: #3a2c1f;
|
||||
--accent: #e8b54a; /* star gold */
|
||||
--accent-contrast: #1a1320;
|
||||
--secondary: #b09a78; /* warm gold-bronze secondary at night */
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@lib/*": ["src/lib/*"]
|
||||
},
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||