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>
|
||||
Reference in New Issue
Block a user