mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Initial Commit
This commit is contained in:
@@ -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.
|
||||
Vendored
+16
@@ -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 */
|
||||
}
|
||||
Reference in New Issue
Block a user