mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
header pass
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,18 +73,31 @@ 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) => (
|
||||||
|
<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) => {
|
{group.items.map((item) => {
|
||||||
const active = isActive(item.href);
|
const active = isActive(item.href);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class:list={[
|
class:list={[
|
||||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
"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);'}
|
style={
|
||||||
aria-current={active ? 'page' : undefined}
|
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" />
|
<Icon name={item.icon} class="h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -73,9 +105,10 @@ const heroSkyClass = isHome ? '' : 'vt-hero-sky';
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,17 +54,23 @@ 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}>
|
</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 />
|
<SectionNav />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<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 ===== */}
|
||||||
|
|||||||
+13
-5
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user