header pass

This commit is contained in:
Greg Kilmer
2026-07-04 16:40:02 -04:00
parent db6ff9f213
commit 1e8f5f1f01
13 changed files with 250 additions and 141 deletions
+13 -15
View File
@@ -23,22 +23,21 @@ import { useEffect, useRef } from 'react';
interface Props { interface Props {
/** Show the canvas star field. Off on interest pages for a calmer feel. */ /** Show the canvas star field. Off on interest pages for a calmer feel. */
particles?: boolean; particles?: boolean;
/** Tailwind/className passthrough for the root. */
className?: string;
/** /**
* Opt-in View Transition name. Unset by default. The View Transitions API * Tailwind/className passthrough for the root. The CALL SITE (in .astro) is
* requires this name to be UNIQUE within a page snapshot, so the page must * responsible for including `vt-sky` here when this element should carry the
* opt in on AT MOST ONE field (typically the hero). Applied via the `.vt-sky` * `view-transition-name: sky` morph. We intentionally do NOT compute the
* CSS class rather than React's inline style to avoid an SSR/hydration * transition class inside this React component: any conditional className
* attribute mismatch. * interpolation causes a server/client hydration attribute mismatch under
* Astro's client:idle. Passing a static, fully-formed string guarantees the
* server-rendered and hydrated class attributes are byte-identical.
*/ */
transitionName?: 'sky'; className?: string;
} }
export default function AnimatedBackground({ export default function AnimatedBackground({
particles = true, particles = true,
className = '', className = '',
transitionName,
}: Props) { }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -135,11 +134,12 @@ export default function AnimatedBackground({
const cy = 0; const cy = 0;
const maxR = Math.sqrt(cx * cx + height * height) + 40; const maxR = Math.sqrt(cx * cx + height * height) + 40;
// One star per ~3200 px^2 of the disk, capped for perf (~14% anchors). // Dense starfield: ~3x the previous density for a richly star-packed sky.
// One star per ~1100 px^2 of the disk, capped for perf (~15% anchors).
const diskArea = Math.PI * maxR * maxR; const diskArea = Math.PI * maxR * maxR;
const count = Math.min(520, Math.floor(diskArea / 3200)); const count = Math.min(1500, Math.floor(diskArea / 1100));
stars = Array.from({ length: count }, (_, i) => { stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 7 === 0; // ~14% anchors -> dense constellation backbone const anchor = i % 7 === 0; // ~15% anchors -> dense constellation backbone
// Uniform-ish distribution across the disk via sqrt on radius. // Uniform-ish distribution across the disk via sqrt on radius.
const rr = maxR * Math.sqrt(Math.random()); const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
@@ -321,11 +321,9 @@ export default function AnimatedBackground({
}; };
}, [particles]); }, [particles]);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return ( return (
<div <div
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${skyClass} ${className}`} className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
aria-hidden="true" aria-hidden="true"
> >
{/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward {/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward
+1 -1
View File
@@ -44,7 +44,7 @@ const {
<section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32"> <section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
<!-- Full animated Milky Way field + stars (matches the Home/Interests hero). <!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
Carries the per-page morph name (one .vt-sky per page snapshot). --> Carries the per-page morph name (one .vt-sky per page snapshot). -->
<AnimatedBackground client:idle transitionName="sky" /> <AnimatedBackground client:idle className="vt-sky" />
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-16"> <div class="container-prose relative z-10 flex flex-col items-start gap-6 py-16">
<Reveal> <Reveal>
+20 -5
View File
@@ -5,8 +5,12 @@
* Reusable section title block: a mono "kicker" (e.g. "02"), an h2 heading, and * Reusable section title block: a mono "kicker" (e.g. "02"), an h2 heading, and
* an optional supporting line. Keeps section openers visually consistent. * an optional supporting line. Keeps section openers visually consistent.
* *
* The kicker is rendered as a monospace number/label per the spec's "Accent/ * `tone` controls contrast:
* quotes: a monospace or display font sparingly for section numbers" guidance. * - "default" for headers on lighter CONTENT surfaces (uses --color-text /
* --color-text-muted, which are tuned for the page body).
* - "hero" for headers that sit on a dark hero / starfield background: forces
* near-white title + bright subtitle + a glowing kicker so the text stands
* out against the deep Milky Way field.
*/ */
import Icon from "@components/Icon.astro"; import Icon from "@components/Icon.astro";
@@ -20,6 +24,8 @@ interface Props {
icon?: string; icon?: string;
class?: string; class?: string;
align?: "left" | "center"; align?: "left" | "center";
/** "hero" = bright text for dark backgrounds; "default" = body content. */
tone?: "default" | "hero";
} }
const { const {
@@ -29,7 +35,12 @@ const {
icon, icon,
class: className = "", class: className = "",
align = "left", align = "left",
tone = "default",
} = Astro.props; } = Astro.props;
const isHero = tone === "hero";
const titleColor = isHero ? "var(--color-cream)" : undefined;
const subtitleColor = isHero ? "var(--color-cream)" : "var(--color-text-muted)";
--- ---
<div <div
@@ -43,19 +54,23 @@ const {
kicker && ( kicker && (
<span <span
class="inline-flex items-center gap-2 font-mono text-xs uppercase tracking-[0.22em]" class="inline-flex items-center gap-2 font-mono text-xs uppercase tracking-[0.22em]"
style="color: var(--color-accent);" style={
isHero
? "color: var(--color-accent); text-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 45%, transparent);"
: "color: var(--color-accent);"
}
> >
{icon && <Icon name={icon} class="h-4 w-4" />} {icon && <Icon name={icon} class="h-4 w-4" />}
{kicker} {kicker}
</span> </span>
) )
} }
<h2>{title}</h2> <h2 style={titleColor ? `color: ${titleColor};` : undefined}>{title}</h2>
{ {
subtitle && ( subtitle && (
<p <p
class="max-w-2xl text-base sm:text-lg" class="max-w-2xl text-base sm:text-lg"
style="color: var(--color-text-muted);" style={`color: ${subtitleColor};${isHero ? " opacity: 0.92;" : ""}`}
> >
{subtitle} {subtitle}
</p> </p>
+32 -12
View File
@@ -16,13 +16,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
interface Props { interface Props {
className?: string;
/** /**
* Opt-in View Transition name. Unset by default. The page should set this on * Tailwind/className passthrough. The .astro call site includes `vt-sky` when
* AT MOST ONE element per page (the hero field). Applied via the `.vt-sky` * this element should carry the view-transition morph. Not computed here to
* CSS class to avoid an SSR/hydration mismatch with React's inline style. * avoid a server/client hydration mismatch (see AnimatedBackground).
*/ */
transitionName?: 'sky'; className?: string;
} }
interface Trace { interface Trace {
@@ -38,7 +37,6 @@ interface Node {
export default function CircuitField({ export default function CircuitField({
className = '', className = '',
transitionName,
}: Props) { }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -228,13 +226,35 @@ export default function CircuitField({
}; };
}, []); }, []);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return ( return (
<canvas // Root container mirrors AnimatedBackground's structure (gradient layers +
ref={canvasRef} // canvas all INSIDE the named element) so the view-transition morph from a
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`} // star hero into this circuit field is element-to-element equivalent and
// nothing flashes separately. The `vt-sky`/opacity classes are passed via
// className from the .astro call site.
<div
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
aria-hidden="true" aria-hidden="true"
/> >
{/* Base dark purple/blue gradient — the professional hero's atmosphere. */}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(160deg, #0a0e2a 0%, #14123a 38%, #1a1648 64%, #0e0c28 100%)',
}}
/>
{/* Animated violet/teal nebula clouds for depth + motion. */}
<div
className="animate-gradient-shift absolute inset-0"
style={{
backgroundImage:
'radial-gradient(50% 60% at 18% 20%, rgba(139, 123, 216, 0.28), transparent 62%), radial-gradient(45% 55% at 85% 15%, rgba(111, 182, 173, 0.16), transparent 60%), radial-gradient(55% 70% at 70% 110%, rgba(139, 123, 216, 0.20), transparent 60%)',
backgroundSize: '200% 200%',
}}
/>
{/* Circuit traces + nodes + signal pulses. */}
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div>
); );
} }
+12 -23
View File
@@ -22,21 +22,15 @@ import { useEffect, useRef } from 'react';
interface Props { interface Props {
/** Density multiplier. 1 = default; lower for quieter sections. */ /** Density multiplier. 1 = default; lower for quieter sections. */
density?: number; density?: number;
/** Tailwind/className passthrough for the root. */
className?: string;
/** /**
* Opt-in View Transition name. DEFAULT IS UNSET so the field carries NO * Tailwind/className passthrough. The .astro call site is responsible for
* view-transition-name. The View Transitions API requires the name to be * including `vt-sky` when this element should carry the view-transition
* UNIQUE within a single page snapshot; assigning "sky" to every instance * morph. We do NOT compute the transition class here: conditional className
* caused "Unexpected duplicate view-transition-name: sky" + transition * interpolation triggers a server/client hydration attribute mismatch under
* aborts. The page should set this on AT MOST ONE field per page (typically * Astro's client:idle. A static, fully-formed string keeps SSR and hydration
* the hero), and it must match the corresponding element on the destination * byte-identical.
* page for the morph (e.g. constellations -> circuitry) to play.
*
* Set via the `data-sky` attribute + the `.vt-sky` CSS class (see global.css)
* rather than React's inline style, which avoids an SSR/hydration mismatch.
*/ */
transitionName?: 'sky'; className?: string;
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */ /** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
tint?: 'gold' | 'violet' | 'teal'; tint?: 'gold' | 'violet' | 'teal';
} }
@@ -44,7 +38,6 @@ interface Props {
export default function ConstellationField({ export default function ConstellationField({
density = 1, density = 1,
className = '', className = '',
transitionName,
tint = 'gold', tint = 'gold',
}: Props) { }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -100,10 +93,11 @@ export default function ConstellationField({
const cy = 0; const cy = 0;
const maxR = Math.sqrt(cx * cx + height * height) + 40; const maxR = Math.sqrt(cx * cx + height * height) + 40;
// Denser field for richer constellations. One star per ~4500 px^2, scaled // Denser field for richer constellations. One star per ~4500 px^2, scaled
// by density; ~15% anchors for a dense constellation backbone. // ~3x density for a richly star-packed sky. One star per ~1500 px^2,
// scaled by density; ~15% anchors for a dense constellation backbone.
const count = Math.min( const count = Math.min(
360, 900,
Math.floor(((Math.PI * maxR * maxR) / 4500) * density), Math.floor(((Math.PI * maxR * maxR) / 1500) * density),
); );
stars = Array.from({ length: count }, (_, i) => { stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 7 === 0; const anchor = i % 7 === 0;
@@ -243,15 +237,10 @@ export default function ConstellationField({
}; };
}, [density, tint]); }, [density, tint]);
// Apply the view-transition-name via a CSS class (global.css `.vt-sky`) rather
// than React's inline style: avoids an SSR/hydration attribute mismatch and
// keeps the name off by default (only one field per page should opt in).
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`} className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
aria-hidden="true" aria-hidden="true"
/> />
); );
+109 -43
View File
@@ -10,14 +10,14 @@
* hairline border + subtle shadow, so content stays readable under the bar. * hairline border + subtle shadow, so content stays readable under the bar.
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure. * Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
*/ */
import Icon from '@components/Icon.astro'; import Icon from "@components/Icon.astro";
import MobileMenu from './MobileMenu.tsx'; import MobileMenu from "./MobileMenu.tsx";
import StarBurst from '@components/mystic/StarBurst.astro'; import StarBurst from "@components/mystic/StarBurst.astro";
import { navGroups, siteIdentity } from '@lib/site'; import { navGroups, siteIdentity } from "@lib/site";
const path = Astro.url.pathname; const path = Astro.url.pathname;
const isActive = (href: string) => const isActive = (href: string) =>
href === '/' ? path === '/' : path === href || path.startsWith(href + '/'); href === "/" ? path === "/" : path === href || path.startsWith(href + "/");
interface Props { interface Props {
/** True on the Home page. When false, the nav carries the hero-sky transition /** True on the Home page. When false, the nav carries the hero-sky transition
@@ -26,27 +26,46 @@ interface Props {
} }
const { isHome = false } = Astro.props; const { isHome = false } = Astro.props;
// On non-Home pages the NavBar is the morph target for the Home hero. // On non-Home pages the NavBar is the morph target for the Home hero.
const heroSkyClass = isHome ? '' : 'vt-hero-sky'; const heroSkyClass = isHome ? "" : "vt-hero-sky";
--- ---
<header <header
id="site-nav" id="site-nav"
data-nav data-nav
class:list={['fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300', heroSkyClass]} class:list={[
"fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300",
heroSkyClass,
]}
> >
<div class="border-b border-transparent transition-colors duration-300" data-nav-inner> <div
class="border-b border-transparent transition-colors duration-300"
data-nav-inner
>
<nav <nav
class="container-prose flex h-16 items-center justify-between gap-4" class="container-prose flex h-16 items-center justify-between gap-4"
aria-label="Primary" aria-label="Primary"
> >
<!-- Logo / identity --> <!-- Logo / identity -->
<a href="/" class="group flex items-center gap-2 leading-tight" aria-label={`${siteIdentity.name} home`}> <a
<StarBurst class="h-4 w-4 shrink-0 transition-transform duration-700 group-hover:rotate-90" style="color: var(--color-accent);" /> href="/"
class="group flex items-center gap-2 leading-tight"
aria-label={`${siteIdentity.name} home`}
>
<StarBurst
class="h-4 w-4 shrink-0 transition-transform duration-700 group-hover:rotate-90"
style="color: var(--color-accent);"
/>
<span class="flex flex-col"> <span class="flex flex-col">
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);"> <span
class="font-sans text-base font-bold tracking-tight"
style="color: var(--accent);"
>
{siteIdentity.name} {siteIdentity.name}
</span> </span>
<span class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block" style="color: var(--color-text-subtle);"> <span
class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block"
style="color: var(--color-text-subtle);"
>
{siteIdentity.tagline} {siteIdentity.tagline}
</span> </span>
</span> </span>
@@ -54,28 +73,42 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
<!-- Desktop nav: two groups with a divider --> <!-- Desktop nav: two groups with a divider -->
<div class="hidden items-center gap-5 md:flex"> <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);' : ''}> navGroups.map((group, gi) => (
{group.items.map((item) => { <div
const active = isActive(item.href); class:list={[
return ( "flex items-center gap-1",
<a { "border-l pl-5": gi > 0 },
href={item.href} ]}
class:list={[ style={gi > 0 ? "border-color: var(--color-border);" : ""}
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors', >
]} {group.items.map((item) => {
style={active ? 'color: var(--color-accent);' : 'color: var(--color-text-muted);'} const active = isActive(item.href);
aria-current={active ? 'page' : undefined} return (
> <a
<Icon name={item.icon} class="h-4 w-4" /> href={item.href}
{item.label} class:list={[
</a> "inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
); ]}
})} style={
</div> active
))} ? "color: var(--color-accent);"
: "color: var(--color-cream); opacity: 0.85;"
}
onmouseover="this.style.opacity='1'"
onmouseout="this.style.opacity='0.85'"
aria-current={active ? "page" : undefined}
>
<Icon name={item.icon} class="h-4 w-4" />
{item.label}
</a>
);
})}
</div>
))
}
<div class="ml-1" /> <div class="ml-1"></div>
</div> </div>
<!-- Mobile controls (theme toggle removed: dark mode is forced for now) --> <!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
@@ -87,13 +120,46 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
</header> </header>
<style> <style>
/* Scrolled state: blur + hairline border + shadow. Toggled by the script below. */ /* The nav bar ALWAYS carries the dark purple/blue nebula treatment + lighter
text, so it reads as a continuous band of night sky across the top of every
page (matching the hero fields). The scrolled state just adds blur + a
hairline border + shadow for definition over content. */
[data-nav-inner] {
background: linear-gradient(
180deg,
rgba(8, 10, 32, 0.92) 0%,
rgba(12, 14, 40, 0.86) 100%
);
}
/* Scrolled state: stronger blur + hairline border + shadow. */
[data-nav].scrolled [data-nav-inner] { [data-nav].scrolled [data-nav-inner] {
background-color: color-mix(in srgb, var(--color-surface) 78%, transparent); backdrop-filter: saturate(150%) blur(12px);
backdrop-filter: saturate(150%) blur(10px); -webkit-backdrop-filter: saturate(150%) blur(12px);
-webkit-backdrop-filter: saturate(150%) blur(10px); border-bottom-color: color-mix(
border-bottom-color: var(--color-border); in srgb,
box-shadow: 0 8px 30px -20px rgba(11, 16, 38, 0.6); var(--color-accent) 30%,
var(--color-border)
);
box-shadow: 0 8px 30px -20px rgba(11, 16, 38, 0.7);
}
/* A subtle violet/gold nebula glow behind the nav, fixed to the top. */
[data-nav]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(
60% 120% at 85% -20%,
rgba(139, 123, 216, 0.22),
transparent 70%
),
radial-gradient(
50% 120% at 10% -10%,
rgba(232, 181, 74, 0.1),
transparent 70%
);
} }
</style> </style>
@@ -103,12 +169,12 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
* View Transitions navigations so it keeps working across page changes. * View Transitions navigations so it keeps working across page changes.
*/ */
function setupNavScroll() { function setupNavScroll() {
const nav = document.getElementById('site-nav'); const nav = document.getElementById("site-nav");
if (!nav) return; if (!nav) return;
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8); const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 8);
onScroll(); onScroll();
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
} }
setupNavScroll(); setupNavScroll();
document.addEventListener('astro:page-load', setupNavScroll); document.addEventListener("astro:page-load", setupNavScroll);
</script> </script>
+23 -16
View File
@@ -6,29 +6,33 @@
* six anchor sections (Summary, Experience, Skills, Projects, Education, * six anchor sections (Summary, Experience, Skills, Projects, Education,
* Contact) via smooth scroll. Hidden on small screens where the page reads as a * Contact) via smooth scroll. Hidden on small screens where the page reads as a
* simple scroll. Active state is computed client-side via IntersectionObserver. * simple scroll. Active state is computed client-side via IntersectionObserver.
*
* Sits BELOW the hero and is OPAQUE (a solid dark nebula surface) so it remains
* readable while scrolling over content. Sticky so it tracks the viewport once
* the user scrolls past it.
*/ */
import Icon from '@components/Icon.astro'; import Icon from "@components/Icon.astro";
const sections = [ const sections = [
{ id: 'summary', label: 'Summary', icon: 'format-list-text' }, { id: "summary", label: "Summary", icon: "format-list-text" },
{ id: 'experience', label: 'Experience', icon: 'timeline-text' }, { id: "experience", label: "Experience", icon: "timeline-text" },
{ id: 'skills', label: 'Skills', icon: 'chart-bar' }, { id: "skills", label: "Skills", icon: "chart-bar" },
{ id: 'projects', label: 'Projects', icon: 'view-grid' }, { id: "projects", label: "Projects", icon: "view-grid" },
{ id: 'education', label: 'Education', icon: 'school' }, { id: "education", label: "Education", icon: "school" },
{ id: 'contact', label: 'Contact', icon: 'email' }, { id: "contact", label: "Contact", icon: "email" },
]; ];
--- ---
<nav aria-label="Professional sections" class="hidden lg:block"> <nav aria-label="Professional sections" class="hidden lg:block">
<div <div
class="sticky top-24 flex flex-wrap gap-1 rounded-xl border p-1.5" class="sticky top-20 z-30 flex flex-wrap gap-1 rounded-xl border p-1.5 shadow-lg"
style="border-color: var(--color-border); background: color-mix(in srgb, var(--color-surface-raised) 60%, transparent); backdrop-filter: blur(6px);" style="border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); background: linear-gradient(180deg, rgba(12, 14, 40, 0.96), rgba(8, 10, 32, 0.96)); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);"
> >
{sections.map((s) => ( {sections.map((s) => (
<a <a
href={`#${s.id}`} href={`#${s.id}`}
data-section-link={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" 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);" style="color: var(--color-cream); opacity: 0.7;"
> >
<Icon name={s.icon} class="h-4 w-4" /> <Icon name={s.icon} class="h-4 w-4" />
{s.label} {s.label}
@@ -41,7 +45,7 @@ const sections = [
// Highlight the link for whichever section is currently in view. // Highlight the link for whichever section is currently in view.
function setupSectionSpy() { function setupSectionSpy() {
const links = Array.from( const links = Array.from(
document.querySelectorAll<HTMLAnchorElement>('[data-section-link]'), document.querySelectorAll<HTMLAnchorElement>("[data-section-link]"),
); );
if (!links.length) return; if (!links.length) return;
const sections = links const sections = links
@@ -51,10 +55,13 @@ const sections = [
const setActive = (id: string) => { const setActive = (id: string) => {
for (const l of links) { for (const l of links) {
const active = l.dataset.sectionLink === id; const active = l.dataset.sectionLink === id;
l.style.color = active ? 'var(--color-accent)' : 'var(--color-text-muted)'; l.style.color = active
? "var(--color-accent)"
: "var(--color-cream)";
l.style.opacity = active ? "1" : "0.7";
l.style.background = active l.style.background = active
? 'color-mix(in srgb, var(--color-accent) 14%, transparent)' ? "color-mix(in srgb, var(--color-accent) 16%, transparent)"
: 'transparent'; : "transparent";
} }
}; };
@@ -65,10 +72,10 @@ const sections = [
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) setActive(visible.target.id); if (visible) setActive(visible.target.id);
}, },
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5] }, { rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5] },
); );
sections.forEach((s) => io.observe(s)); sections.forEach((s) => io.observe(s));
} }
setupSectionSpy(); setupSectionSpy();
document.addEventListener('astro:page-load', setupSectionSpy); document.addEventListener("astro:page-load", setupSectionSpy);
</script> </script>
+6 -10
View File
@@ -14,7 +14,7 @@ import SectionHeader from "@components/layout/SectionHeader.astro";
import SplitTransition from "@components/layout/SplitTransition.astro"; import SplitTransition from "@components/layout/SplitTransition.astro";
import CelestialImage from "@components/mystic/CelestialImage.astro"; import CelestialImage from "@components/mystic/CelestialImage.astro";
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro"; import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import ConstellationField from "@components/mystic/ConstellationField.tsx"; import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
// Spec "Quick Facts", emojis -> mdi icons. // Spec "Quick Facts", emojis -> mdi icons.
const quickFacts = [ const quickFacts = [
@@ -37,17 +37,13 @@ const quickFacts = [
title="About" title="About"
description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games." description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games."
> >
<!-- Header --> <!-- Hero — full animated Milky Way field, matching the Interests hero. -->
<section class="relative overflow-hidden pb-10 pt-28 sm:pt-32"> <section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
<ConstellationField <AnimatedBackground client:idle className="vt-sky" />
client:idle <div class="container-prose relative z-10 flex flex-col gap-10 py-16">
className="opacity-40"
tint="violet"
transitionName="sky"
/>
<div class="container-prose relative z-10 flex flex-col gap-10">
<Reveal> <Reveal>
<SectionHeader <SectionHeader
tone="hero"
kicker="About" kicker="About"
icon="account-details" icon="account-details"
title="The person behind the projects" title="The person behind the projects"
+4 -2
View File
@@ -155,7 +155,8 @@ const social = [
</section> </section>
</Reveal> </Reveal>
{/* Availability note */} {
/* Availability note
<Reveal delay={120}> <Reveal delay={120}>
<p <p
class="rounded-xl border-l-2 p-4 text-sm" class="rounded-xl border-l-2 p-4 text-sm"
@@ -165,7 +166,8 @@ const social = [
opportunities in AI/ML, app and cloud development, SAP BTP, and technical opportunities in AI/ML, app and cloud development, SAP BTP, and technical
leadership. leadership.
</p> </p>
</Reveal> </Reveal>*/
}
</div> </div>
{/* Right: contact form */} {/* Right: contact form */}
+1 -1
View File
@@ -54,7 +54,7 @@ const cards = [
<section <section
class="vt-hero-sky relative flex min-h-[88vh] items-center overflow-hidden pt-16" class="vt-hero-sky relative flex min-h-[88vh] items-center overflow-hidden pt-16"
> >
<AnimatedBackground client:idle transitionName="sky" /> <AnimatedBackground client:idle className="vt-sky" />
<div <div
class="container-prose relative z-10 flex flex-col items-start gap-8 py-20" class="container-prose relative z-10 flex flex-col items-start gap-8 py-20"
+2 -1
View File
@@ -78,12 +78,13 @@ const interests = [
{ {
/* Full animated Milky Way field + stars, matching the Home hero treatment. */ /* Full animated Milky Way field + stars, matching the Home hero treatment. */
} }
<AnimatedBackground client:idle transitionName="sky" /> <AnimatedBackground client:idle className="vt-sky" />
<div <div
class="container-prose relative z-10 flex flex-col items-start gap-6 py-16" class="container-prose relative z-10 flex flex-col items-start gap-6 py-16"
> >
<Reveal> <Reveal>
<SectionHeader <SectionHeader
tone="hero"
kicker="The personal wing" kicker="The personal wing"
icon="heart" icon="heart"
title="Interests" title="Interests"
+14 -7
View File
@@ -40,11 +40,12 @@ const projects = (await getCollection("projects")).sort(
<!-- Page header --> <!-- Page header -->
<section class="relative overflow-hidden pt-28 sm:pt-32"> <section class="relative overflow-hidden pt-28 sm:pt-32">
{ {
/* Circuit-board field. Shares view-transition-name "sky" with the star /* Circuit-board field. Now self-contained (nebula gradient + canvas inside
fields on other pages, so navigating here morphs constellations into one root div), mirroring AnimatedBackground's structure so the morph from
circuitry. */ a star hero is element-to-element equivalent — no separate gradient
flash. Shares view-transition-name "sky" via vt-sky. */
} }
<CircuitField client:idle className="opacity-70" transitionName="sky" /> <CircuitField client:idle className="vt-sky opacity-80" />
{/* Slowly rotating astrolabe ornament behind the header. */} {/* Slowly rotating astrolabe ornament behind the header. */}
<Astrolabe <Astrolabe
class="pointer-events-none absolute z-0 -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow" class="pointer-events-none absolute z-0 -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow"
@@ -53,18 +54,24 @@ const projects = (await getCollection("projects")).sort(
<div class="container-prose relative z-10 flex flex-col gap-10"> <div class="container-prose relative z-10 flex flex-col gap-10">
<Reveal> <Reveal>
<SectionHeader <SectionHeader
tone="hero"
kicker="Professional" kicker="Professional"
icon="briefcase" icon="briefcase"
title="Resume & Portfolio" title="Resume & Portfolio"
subtitle="Clean, confident, skimmable." subtitle="Clean, confident, skimmable."
/> />
</Reveal> </Reveal>
<Reveal delay={80}>
<SectionNav />
</Reveal>
</div> </div>
</section> </section>
{/* In-page section nav — sits BELOW the hero (out of the morphing region),
opaque against the dark surface so it stays readable while scrolling. */}
<div class="container-prose relative z-20 pt-4">
<Reveal>
<SectionNav />
</Reveal>
</div>
<div class="container-prose flex flex-col gap-20 py-16 sm:gap-24 sm:py-20"> <div class="container-prose flex flex-col gap-20 py-16 sm:gap-24 sm:py-20">
{/* ===== 1. Summary ===== */} {/* ===== 1. Summary ===== */}
<section id="summary" class="scroll-mt-28 flex flex-col gap-6"> <section id="summary" class="scroll-mt-28 flex flex-col gap-6">
+13 -5
View File
@@ -272,22 +272,29 @@
opacity: 1 !important; opacity: 1 !important;
transform: none !important; transform: none !important;
} }
/* Elegant entrance: a slow rise + soft focus-clear, with a gentle scale settle.
* Slower and smoother than a plain fade-up so text "settles" into place rather
* than popping. The slight blur-to-sharp adds a dreamy, mystical quality. */
.reveal { .reveal {
opacity: 0; opacity: 0;
transform: translateY(24px); transform: translateY(18px) scale(0.985);
filter: blur(6px);
transition: transition:
opacity 0.7s var(--ease-out-soft), opacity 0.9s var(--ease-out-soft),
transform 0.7s var(--ease-out-soft); transform 1s var(--ease-out-soft),
will-change: opacity, transform; filter 0.9s var(--ease-out-soft);
will-change: opacity, transform, filter;
} }
.reveal.is-visible { .reveal.is-visible {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0) scale(1);
filter: blur(0px);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.reveal { .reveal {
opacity: 1; opacity: 1;
transform: none; transform: none;
filter: none;
transition: none; transition: none;
} }
} }
@@ -366,6 +373,7 @@
html.vt-transitioning .reveal { html.vt-transitioning .reveal {
opacity: 1 !important; opacity: 1 !important;
transform: none !important; transform: none !important;
filter: none !important;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {