Initial Commit

This commit is contained in:
Greg Kilmer
2026-07-04 07:42:24 -04:00
commit 71bffad507
77 changed files with 14437 additions and 0 deletions
+47
View File
@@ -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 ?? '')}
/>
+57
View File
@@ -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>
+353
View File
@@ -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>
);
}
+49
View File
@@ -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);">
&copy; {year} {siteIdentity.name}. All rights reserved.
</div>
</footer>
+104
View File
@@ -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>
+49
View File
@@ -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 &middot; {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>
+50
View File
@@ -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>
+39
View File
@@ -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>
+76
View File
@@ -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>
+39
View File
@@ -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>
+60
View File
@@ -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>
+153
View File
@@ -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>
);
}
+105
View File
@@ -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>
+73
View File
@@ -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>
);
}
+193
View File
@@ -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>
+115
View File
@@ -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 &amp; 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>