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 {
/** Show the canvas star field. Off on interest pages for a calmer feel. */
particles?: boolean;
/** Tailwind/className passthrough for the root. */
className?: string;
/**
* Opt-in View Transition name. Unset by default. The View Transitions API
* requires this name to be UNIQUE within a page snapshot, so the page must
* opt in on AT MOST ONE field (typically the hero). Applied via the `.vt-sky`
* CSS class rather than React's inline style to avoid an SSR/hydration
* attribute mismatch.
* Tailwind/className passthrough for the root. The CALL SITE (in .astro) is
* responsible for including `vt-sky` here when this element should carry the
* `view-transition-name: sky` morph. We intentionally do NOT compute the
* transition class inside this React component: any conditional className
* 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({
particles = true,
className = '',
transitionName,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -135,11 +134,12 @@ export default function AnimatedBackground({
const cy = 0;
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 count = Math.min(520, Math.floor(diskArea / 3200));
const count = Math.min(1500, Math.floor(diskArea / 1100));
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.
const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2;
@@ -321,11 +321,9 @@ export default function AnimatedBackground({
};
}, [particles]);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return (
<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"
>
{/* 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">
<!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
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">
<Reveal>
+20 -5
View File
@@ -5,8 +5,12 @@
* 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.
* `tone` controls contrast:
* - "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";
@@ -20,6 +24,8 @@ interface Props {
icon?: string;
class?: string;
align?: "left" | "center";
/** "hero" = bright text for dark backgrounds; "default" = body content. */
tone?: "default" | "hero";
}
const {
@@ -29,7 +35,12 @@ const {
icon,
class: className = "",
align = "left",
tone = "default",
} = Astro.props;
const isHero = tone === "hero";
const titleColor = isHero ? "var(--color-cream)" : undefined;
const subtitleColor = isHero ? "var(--color-cream)" : "var(--color-text-muted)";
---
<div
@@ -43,19 +54,23 @@ const {
kicker && (
<span
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" />}
{kicker}
</span>
)
}
<h2>{title}</h2>
<h2 style={titleColor ? `color: ${titleColor};` : undefined}>{title}</h2>
{
subtitle && (
<p
class="max-w-2xl text-base sm:text-lg"
style="color: var(--color-text-muted);"
style={`color: ${subtitleColor};${isHero ? " opacity: 0.92;" : ""}`}
>
{subtitle}
</p>
+32 -12
View File
@@ -16,13 +16,12 @@
import { useEffect, useRef } from 'react';
interface Props {
className?: string;
/**
* Opt-in View Transition name. Unset by default. The page should set this on
* AT MOST ONE element per page (the hero field). Applied via the `.vt-sky`
* CSS class to avoid an SSR/hydration mismatch with React's inline style.
* Tailwind/className passthrough. The .astro call site includes `vt-sky` when
* this element should carry the view-transition morph. Not computed here to
* avoid a server/client hydration mismatch (see AnimatedBackground).
*/
transitionName?: 'sky';
className?: string;
}
interface Trace {
@@ -38,7 +37,6 @@ interface Node {
export default function CircuitField({
className = '',
transitionName,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -228,13 +226,35 @@ export default function CircuitField({
};
}, []);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return (
<canvas
ref={canvasRef}
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`}
// Root container mirrors AnimatedBackground's structure (gradient layers +
// canvas all INSIDE the named element) so the view-transition morph from a
// 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"
/>
>
{/* 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 {
/** Density multiplier. 1 = default; lower for quieter sections. */
density?: number;
/** Tailwind/className passthrough for the root. */
className?: string;
/**
* Opt-in View Transition name. DEFAULT IS UNSET so the field carries NO
* view-transition-name. The View Transitions API requires the name to be
* UNIQUE within a single page snapshot; assigning "sky" to every instance
* caused "Unexpected duplicate view-transition-name: sky" + transition
* aborts. The page should set this on AT MOST ONE field per page (typically
* the hero), and it must match the corresponding element on the destination
* 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.
* Tailwind/className passthrough. The .astro call site is responsible for
* including `vt-sky` when this element should carry the view-transition
* morph. We do NOT compute the transition class here: conditional className
* interpolation triggers a server/client hydration attribute mismatch under
* Astro's client:idle. A static, fully-formed string keeps SSR and hydration
* byte-identical.
*/
transitionName?: 'sky';
className?: string;
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
tint?: 'gold' | 'violet' | 'teal';
}
@@ -44,7 +38,6 @@ interface Props {
export default function ConstellationField({
density = 1,
className = '',
transitionName,
tint = 'gold',
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -100,10 +93,11 @@ export default function ConstellationField({
const cy = 0;
const maxR = Math.sqrt(cx * cx + height * height) + 40;
// 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(
360,
Math.floor(((Math.PI * maxR * maxR) / 4500) * density),
900,
Math.floor(((Math.PI * maxR * maxR) / 1500) * density),
);
stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 7 === 0;
@@ -243,15 +237,10 @@ export default function ConstellationField({
};
}, [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 (
<canvas
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"
/>
);
+109 -43
View File
@@ -10,14 +10,14 @@
* 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 MobileMenu from './MobileMenu.tsx';
import StarBurst from '@components/mystic/StarBurst.astro';
import { navGroups, siteIdentity } from '@lib/site';
import Icon from "@components/Icon.astro";
import MobileMenu from "./MobileMenu.tsx";
import StarBurst from "@components/mystic/StarBurst.astro";
import { navGroups, siteIdentity } from "@lib/site";
const path = Astro.url.pathname;
const isActive = (href: string) =>
href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
href === "/" ? path === "/" : path === href || path.startsWith(href + "/");
interface Props {
/** 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;
// 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
id="site-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
class="container-prose flex h-16 items-center justify-between gap-4"
aria-label="Primary"
>
<!-- Logo / identity -->
<a 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);" />
<a
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="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}
</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}
</span>
</span>
@@ -54,28 +73,42 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
<!-- 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>
))}
{
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-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>
<!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
@@ -87,13 +120,46 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
</header>
<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] {
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);
backdrop-filter: saturate(150%) blur(12px);
-webkit-backdrop-filter: saturate(150%) blur(12px);
border-bottom-color: color-mix(
in srgb,
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>
@@ -103,12 +169,12 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
* View Transitions navigations so it keeps working across page changes.
*/
function setupNavScroll() {
const nav = document.getElementById('site-nav');
const nav = document.getElementById("site-nav");
if (!nav) return;
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 8);
const onScroll = () => nav.classList.toggle("scrolled", window.scrollY > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
}
setupNavScroll();
document.addEventListener('astro:page-load', setupNavScroll);
document.addEventListener("astro:page-load", setupNavScroll);
</script>
+23 -16
View File
@@ -6,29 +6,33 @@
* 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.
*
* 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 = [
{ 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' },
{ 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);"
class="sticky top-20 z-30 flex flex-wrap gap-1 rounded-xl border p-1.5 shadow-lg"
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) => (
<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);"
style="color: var(--color-cream); opacity: 0.7;"
>
<Icon name={s.icon} class="h-4 w-4" />
{s.label}
@@ -41,7 +45,7 @@ const sections = [
// Highlight the link for whichever section is currently in view.
function setupSectionSpy() {
const links = Array.from(
document.querySelectorAll<HTMLAnchorElement>('[data-section-link]'),
document.querySelectorAll<HTMLAnchorElement>("[data-section-link]"),
);
if (!links.length) return;
const sections = links
@@ -51,10 +55,13 @@ const sections = [
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.color = active
? "var(--color-accent)"
: "var(--color-cream)";
l.style.opacity = active ? "1" : "0.7";
l.style.background = active
? 'color-mix(in srgb, var(--color-accent) 14%, transparent)'
: 'transparent';
? "color-mix(in srgb, var(--color-accent) 16%, transparent)"
: "transparent";
}
};
@@ -65,10 +72,10 @@ const sections = [
.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] },
{ rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5] },
);
sections.forEach((s) => io.observe(s));
}
setupSectionSpy();
document.addEventListener('astro:page-load', setupSectionSpy);
document.addEventListener("astro:page-load", setupSectionSpy);
</script>
+6 -10
View File
@@ -14,7 +14,7 @@ import SectionHeader from "@components/layout/SectionHeader.astro";
import SplitTransition from "@components/layout/SplitTransition.astro";
import CelestialImage from "@components/mystic/CelestialImage.astro";
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import ConstellationField from "@components/mystic/ConstellationField.tsx";
import AnimatedBackground from "@components/hero/AnimatedBackground.tsx";
// Spec "Quick Facts", emojis -> mdi icons.
const quickFacts = [
@@ -37,17 +37,13 @@ const quickFacts = [
title="About"
description="The human story behind the resume: Greg Kilmer on curiosity, tabletop RPGs, resin dice, and a Philadelphia apartment with too many board games."
>
<!-- Header -->
<section class="relative overflow-hidden pb-10 pt-28 sm:pt-32">
<ConstellationField
client:idle
className="opacity-40"
tint="violet"
transitionName="sky"
/>
<div class="container-prose relative z-10 flex flex-col gap-10">
<!-- Hero — full animated Milky Way field, matching the Interests hero. -->
<section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
<AnimatedBackground client:idle className="vt-sky" />
<div class="container-prose relative z-10 flex flex-col gap-10 py-16">
<Reveal>
<SectionHeader
tone="hero"
kicker="About"
icon="account-details"
title="The person behind the projects"
+4 -2
View File
@@ -155,7 +155,8 @@ const social = [
</section>
</Reveal>
{/* Availability note */}
{
/* Availability note
<Reveal delay={120}>
<p
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
leadership.
</p>
</Reveal>
</Reveal>*/
}
</div>
{/* Right: contact form */}
+1 -1
View File
@@ -54,7 +54,7 @@ const cards = [
<section
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
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. */
}
<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"
>
<Reveal>
<SectionHeader
tone="hero"
kicker="The personal wing"
icon="heart"
title="Interests"
+14 -7
View File
@@ -40,11 +40,12 @@ const projects = (await getCollection("projects")).sort(
<!-- Page header -->
<section class="relative overflow-hidden pt-28 sm:pt-32">
{
/* Circuit-board field. Shares view-transition-name "sky" with the star
fields on other pages, so navigating here morphs constellations into
circuitry. */
/* Circuit-board field. Now self-contained (nebula gradient + canvas inside
one root div), mirroring AnimatedBackground's structure so the morph from
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. */}
<Astrolabe
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">
<Reveal>
<SectionHeader
tone="hero"
kicker="Professional"
icon="briefcase"
title="Resume & Portfolio"
subtitle="Clean, confident, skimmable."
/>
</Reveal>
<Reveal delay={80}>
<SectionNav />
</Reveal>
</div>
</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">
{/* ===== 1. Summary ===== */}
<section id="summary" class="scroll-mt-28 flex flex-col gap-6">
+13 -5
View File
@@ -272,22 +272,29 @@
opacity: 1 !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 {
opacity: 0;
transform: translateY(24px);
transform: translateY(18px) scale(0.985);
filter: blur(6px);
transition:
opacity 0.7s var(--ease-out-soft),
transform 0.7s var(--ease-out-soft);
will-change: opacity, transform;
opacity 0.9s var(--ease-out-soft),
transform 1s var(--ease-out-soft),
filter 0.9s var(--ease-out-soft);
will-change: opacity, transform, filter;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
transform: translateY(0) scale(1);
filter: blur(0px);
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
filter: none;
transition: none;
}
}
@@ -366,6 +373,7 @@
html.vt-transitioning .reveal {
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
@media (prefers-reduced-motion: reduce) {