Content update and Professional details

This commit is contained in:
Greg Kilmer
2026-07-04 18:58:16 -04:00
parent 35d561525f
commit cf9eeb865e
6 changed files with 396 additions and 161 deletions
+57 -45
View File
@@ -2,16 +2,14 @@
/** /**
* InterestHero.astro * InterestHero.astro
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* Shared header for every interest sub-page, implementing the spec's * Shared header for every interest sub-page. Compact hero on the animated
* per-interest template (Header: title + tagline; Overview: narrative). Uses the * Milky Way field: a back-to-hub breadcrumb, a small icon mark, the title, and
* warm "interest" surface tone via InterestLayout. * the italic tagline.
* *
* The decorative icon and the tagline (in mono, as a pull quote) give each page * Tuned for the dark starfield: text uses bright cream tones (not the dim
* a consistent rhythm while the page supplies its own title + overview copy. * --color-text-subtle/--color-text which vanish on the deep sky). The title is
* * h2-sized (the page is a sub-page, not Home) so it doesn't dominate. An
* An optional `image` prop renders a wide celestial-framed banner behind / above * optional banner image sits above the heading.
* the heading so each interest page leads with a vibe-matched photo. A faint
* starfield + alchemical accent reinforces the esoteric mood.
*/ */
import Icon from '@components/Icon.astro'; import Icon from '@components/Icon.astro';
import Reveal from '@components/motion/Reveal.astro'; import Reveal from '@components/motion/Reveal.astro';
@@ -46,20 +44,57 @@ const {
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 className="vt-sky" /> <AnimatedBackground client:idle className="vt-sky" />
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-8"> {/* Two-column layout: text on the left, image on the right. Putting the image
<Reveal> BESIDE the text (instead of stacked above it) means the hero height is
<a driven by the text column and stays consistent across interest pages
href="/interests" regardless of image presence. On small screens it stacks (image below). */}
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em] transition-colors" <div class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]">
style="color: var(--color-text-subtle);" {/* --- Left: text column --- */}
> <div class="flex flex-col items-start gap-4">
<Icon name="arrow-left" class="h-4 w-4" /> <Reveal>
{eyebrow} <a
</a> href="/interests"
</Reveal> class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em] transition-colors"
style="color: var(--color-cream); opacity: 0.8;"
>
<Icon name="arrow-left" class="h-4 w-4" />
{eyebrow}
</a>
</Reveal>
<Reveal delay={60}>
<span
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={icon} class="h-6 w-6" />
</span>
</Reveal>
<Reveal delay={100}>
<h2
class="font-sans font-bold tracking-tight"
style="color: var(--color-cream); font-size: clamp(1.75rem, 2vw + 1rem, 2.5rem); line-height: 1.15;"
>
{title}
</h2>
</Reveal>
<Reveal delay={140}>
<p
class="max-w-xl border-l-2 pl-4 font-sans text-base italic sm:text-lg"
style="border-color: var(--color-accent); color: var(--color-cream); opacity: 0.92;"
>
{tagline}
</p>
</Reveal>
</div>
{/* --- Right: image column (only when an image is provided). A fixed
aspect keeps every interest hero the same height. --- */}
{image && ( {image && (
<Reveal delay={40} class="w-full"> <Reveal delay={80} class="w-full">
<figure <figure
class="relative w-full overflow-hidden rounded-2xl border" class="relative w-full overflow-hidden rounded-2xl border"
style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);" style="border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);"
@@ -69,7 +104,7 @@ const {
alt={imageAlt} alt={imageAlt}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
class="aspect-[21/9] w-full object-cover" class="aspect-[4/3] w-full object-cover"
/> />
<StarBurst <StarBurst
class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-accent)_60%,transparent)]" class="pointer-events-none absolute left-3 top-3 h-5 w-5 drop-shadow-[0_0_6px_color-mix(in_srgb,var(--color-accent)_60%,transparent)]"
@@ -78,28 +113,5 @@ const {
</figure> </figure>
</Reveal> </Reveal>
)} )}
<Reveal delay={60}>
<span
class="inline-flex h-16 w-16 items-center justify-center rounded-2xl"
style="background: color-mix(in srgb, var(--color-accent) 18%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={icon} class="h-8 w-8" />
</span>
</Reveal>
<Reveal delay={120}>
<h1 class="text-balance">{title}</h1>
</Reveal>
<Reveal delay={160}>
<p
class="max-w-2xl border-l-2 pl-5 font-sans text-lg italic sm:text-xl"
style="border-color: var(--color-accent); color: var(--color-text);"
>
{tagline}
</p>
</Reveal>
</div> </div>
</section> </section>
@@ -0,0 +1,77 @@
---
/**
* Certifications.astro
* ---------------------------------------------------------------------------
* Card grid for the Certifications section of the Professional page. Each entry
* is a cert with name, issuer, year, and optional status. Seeded with SAP/cloud
* placeholders (Greg to fill); swap the array below.
*
* Card aesthetic matches EducationHistory / ProjectCard.
*/
import Icon from "@components/Icon.astro";
interface CertEntry {
icon: string;
name: string;
issuer: string;
year: string;
status?: "Active" | "Expired" | "In progress";
}
// Placeholder entries — replace with Greg's real certifications.
const entries: CertEntry[] = [
/*{
icon: "microsoft-azure-devops",
name: "SAP BTP Certification",
issuer: "SAP",
year: "[Year]",
status: "Active",
},*/
{
icon: "cloud",
name: "Planet 9 Devlopment",
issuer: "Neptune",
year: "2019",
status: "Active",
},
/*{
icon: "robot",
name: "[AI / ML cert]",
issuer: "[Issuer]",
year: "[Year]",
},*/
];
---
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{
entries.map((c) => (
<article class="card flex flex-col gap-3">
<div class="flex items-start justify-between gap-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-secondary) 18%, transparent); color: var(--color-secondary);"
aria-hidden="true"
>
<Icon name={c.icon} class="h-5 w-5" />
</span>
{c.status && <span class="pill">{c.status}</span>}
</div>
<div class="flex flex-col gap-1">
<h3 class="font-sans text-base" style="color: var(--color-text);">
{c.name}
</h3>
<p class="text-sm font-medium" style="color: var(--color-accent);">
{c.issuer}
</p>
<span
class="font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-text-subtle);"
>
{c.year}
</span>
</div>
</article>
))
}
</div>
@@ -0,0 +1,76 @@
---
/**
* EducationHistory.astro
* ---------------------------------------------------------------------------
* Card grid for the Education section of the Professional page. Each entry is a
* degree/diploma with institution, period, and optional detail. Data is seeded
* with placeholder rows (Greg to fill); swap the array below with real entries.
*
* Mirrors the Timeline/ProjectCard card aesthetic: raised surface, hairline
* border, gold accent for the credential, mono period label.
*/
import Icon from "@components/Icon.astro";
interface EduEntry {
icon: string;
credential: string;
institution: string;
period: string;
detail?: string;
}
// Placeholder entries — replace with Greg's real education history.
const entries: EduEntry[] = [
{
icon: "school",
credential: "B.S., Computer Science",
institution: "Drexel University",
period: "2014 – 2018",
detail:
"Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.",
},
];
---
<div class="grid gap-4 sm:grid-cols-2">
{
entries.map((e) => (
<article class="card flex flex-col gap-3">
<div class="flex items-start gap-3">
<span
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true"
>
<Icon name={e.icon} class="h-5 w-5" />
</span>
<div class="flex flex-col gap-1">
<h3 class="font-sans text-lg" style="color: var(--color-text);">
{e.credential}
</h3>
<p
class="font-sans font-semibold"
style="color: var(--color-accent);"
>
{e.institution}
</p>
<span
class="font-mono text-xs uppercase tracking-[0.18em]"
style="color: var(--color-text-subtle);"
>
{e.period}
</span>
</div>
</div>
{e.detail && (
<p
class="text-sm leading-relaxed"
style="color: var(--color-text-muted);"
>
{e.detail}
</p>
)}
</article>
))
}
</div>
+126 -68
View File
@@ -2,15 +2,23 @@
/** /**
* SectionNav.astro * SectionNav.astro
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* Fixed LEFT-SIDE vertical rail for the Professional page. Shows the six * Fixed LEFT-SIDE vertical rail for the Professional page. Six sections as a
* sections as a stacked icon+label list, highlights the section currently in * stacked icon+label list; highlights the current section; click to smooth-
* view, and lets the user click any item to smooth-scroll to it. A vertical * scroll. A vertical progress line LEFT of the icons fills as you scroll.
* progress line beside the items fills as the user scrolls down the page.
* *
* Stylistically it mirrors the page's `.card` language (same raised surface, * Three bugs previously fixed here:
* border, radius) so it reads as a sibling of the content cards rather than a * 1. Contrast: the rail used surface-relative tokens (--color-surface-raised /
* separate chrome element. Icons + labels use bright tokens so they pop on the * --color-cream) which collapsed to the same tone on light surfaces (the
* dark surface. Hidden below `lg`. * interest-surface cream made icons/text invisible). The rail now uses
* FIXED dark colors regardless of the page's surface tone.
* 2. Progress line clipping: the line sat at the icon column's horizontal
* center, running THROUGH the circles. It now sits in a dedicated left
* gutter, clear of the icons.
* 3. Last section (Contact) not activating: the IntersectionObserver band was
* too thin for the short final section to land in. The rootMargin is
* widened + click handlers set active immediately so feedback is instant.
*
* Hidden below `lg`.
*/ */
import Icon from "@components/Icon.astro"; import Icon from "@components/Icon.astro";
const sections = [ const sections = [
@@ -28,70 +36,79 @@ const sections = [
class="pro-section-nav fixed left-4 top-1/2 z-40 hidden -translate-y-1/2 flex-col lg:flex" class="pro-section-nav fixed left-4 top-1/2 z-40 hidden -translate-y-1/2 flex-col lg:flex"
data-pro-nav data-pro-nav
> >
{/* Rail container — matches the .card aesthetic: raised surface, hairline {/* Rail container. FIXED dark colors (not surface tokens) so it's always
border, 1rem radius. Bright icons + labels inside. */} legible regardless of the page's data-surface tone. Layout: a left gutter
for the progress line, then the icon+label list. */}
<div <div
class="card relative flex flex-col gap-1 p-3" class="relative flex gap-2 rounded-2xl border p-3 shadow-2xl"
style="border-color: var(--color-border); background: var(--color-surface-raised); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 18px 40px -24px rgba(11, 16, 38, 0.6);" style="border-color: rgba(232, 181, 74, 0.22); background: linear-gradient(180deg, rgba(12, 14, 40, 0.97), rgba(8, 10, 32, 0.97)); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 18px 40px -24px rgba(11, 16, 38, 0.7);"
> >
{/* Vertical progress track behind the dots. */} {/* --- Left gutter: the progress track + fill. Sits LEFT of the icons. --- */}
<div <div class="relative my-1 w-2 shrink-0">
class="pointer-events-none absolute left-[1.55rem] top-6 bottom-6 w-px" {/* Track (full height of the gutter). */}
style="background: color-mix(in srgb, var(--color-border) 80%, transparent);" <div
aria-hidden="true" class="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2"
> style="background: rgba(255, 255, 255, 0.12);"
</div> aria-hidden="true"
{/* The fill bar — height set by JS as the user scrolls. */} >
<div </div>
class="pointer-events-none absolute left-[1.55rem] top-6 w-px origin-top" {/* Fill — height set by JS. Anchored to the top of the track. */}
style="background: var(--color-accent); height: 0%; box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent) 60%, transparent);" <div
data-pro-fill class="pointer-events-none absolute left-1/2 top-0 w-px -translate-x-1/2 origin-top"
aria-hidden="true" style="background: var(--color-accent); height: 0%; box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent) 60%, transparent);"
> data-pro-fill
aria-hidden="true"
>
</div>
</div> </div>
{sections.map((s) => ( {/* --- The icon+label list. --- */}
<a <div class="flex flex-col gap-1">
href={`#${s.id}`} {sections.map((s) => (
data-section-link={s.id} <a
class="group relative flex items-center gap-3 rounded-lg px-2.5 py-2 font-sans text-sm font-semibold transition-all" href={`#${s.id}`}
style="color: var(--color-cream); opacity: 0.85;" data-section-link={s.id}
> class="group relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 font-sans text-sm font-semibold transition-all"
<span style="color: #e8e6f5;"
class="relative z-10 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors"
style="background: color-mix(in srgb, var(--color-accent) 14%, transparent); color: var(--color-cream);"
data-section-dot={s.id}
> >
<Icon name={s.icon} class="h-[1.15rem] w-[1.15rem]" /> <span
</span> class="relative z-10 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors"
<span class="relative z-10 tracking-wide">{s.label}</span> style="background: rgba(232, 181, 74, 0.14); color: #f4cd72;"
</a> data-section-dot={s.id}
))} >
<Icon name={s.icon} class="h-[1.15rem] w-[1.15rem]" />
</span>
<span class="relative z-10 tracking-wide">{s.label}</span>
</a>
))}
</div>
</div> </div>
</nav> </nav>
<style> <style>
/* Active link: full opacity + accent text + subtle accent wash. */ /* Inactive items: readable but slightly dimmed. */
.pro-section-nav a[data-section-link] {
opacity: 0.82;
}
.pro-section-nav a[data-section-link]:hover {
opacity: 1;
background: rgba(232, 181, 74, 0.08);
}
/* Active link: bright accent text + accent wash. */
.pro-section-nav a[data-active="true"] { .pro-section-nav a[data-active="true"] {
opacity: 1 !important; opacity: 1 !important;
color: var(--color-accent) !important; color: var(--color-accent) !important;
background: color-mix(in srgb, var(--color-accent) 14%, transparent); background: rgba(232, 181, 74, 0.14);
} }
/* Active dot: filled accent, bright icon, glow ring. */ /* Active dot: filled accent, dark icon, glow ring. */
.pro-section-nav a[data-active="true"] [data-section-dot] { .pro-section-nav a[data-active="true"] [data-section-dot] {
background: color-mix(in srgb, var(--color-accent) 80%, transparent) !important; background: var(--color-accent) !important;
color: var(--color-ink) !important; color: #0b1026 !important;
box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 65%, transparent); box-shadow: 0 0 12px rgba(232, 181, 74, 0.65);
}
/* Hover lift on inactive items. */
.pro-section-nav a:hover {
opacity: 1 !important;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
} }
</style> </style>
<script> <script>
// Spy: highlight the link for whichever section is in view + fill the rail.
function setupSectionSpy() { function setupSectionSpy() {
const nav = document.querySelector<HTMLElement>("[data-pro-nav]"); const nav = document.querySelector<HTMLElement>("[data-pro-nav]");
if (!nav) return; if (!nav) return;
@@ -107,40 +124,81 @@ 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; l.setAttribute("data-active", l.dataset.sectionLink === id ? "true" : "false");
l.setAttribute("data-active", active ? "true" : "false");
} }
}; };
// Highlight the most-visible section. // Manual-scroll suppression. When the user clicks a nav link we set active
// immediately AND suppress IO updates until the smooth-scroll settles. This
// is what stops the flicker: without it, the IO callback and the scroll
// handler fight during the animated scroll, toggling active state rapidly.
let suppressIo = false;
let suppressTimer: number | undefined;
// Click → set active immediately + suppress IO until scrolling settles.
for (const l of links) {
l.addEventListener("click", () => {
const id = l.dataset.sectionLink!;
setActive(id);
suppressIo = true;
window.clearTimeout(suppressTimer);
// Re-enable IO once the smooth-scroll has had time to finish.
suppressTimer = window.setTimeout(() => {
suppressIo = false;
}, 900);
});
}
// The IntersectionObserver is the SINGLE source of truth for the active
// section during free scrolling. (Previously the scroll handler also set
// active, which caused the two systems to thrash.)
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
if (suppressIo) return;
const visible = entries const visible = entries
.filter((e) => e.isIntersecting) .filter((e) => e.isIntersecting)
.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] }, // Band spans roughly the middle 50% of the viewport vertically.
{ rootMargin: "-25% 0px -25% 0px", threshold: [0, 0.1, 0.25, 0.5] },
); );
sections.forEach((s) => io.observe(s)); sections.forEach((s) => io.observe(s));
// Progress fill: fraction of the page scrolled. // Progress fill ONLY. The scroll handler must never set active state — that
const trackTop = 24; // px — matches top-6 on the track/fill // was the other half of the flicker. Last-section-at-bottom is handled by
const trackBottom = 24; // px — matches bottom-6 // a dedicated, debounced check below instead of on every scroll tick.
const updateFill = () => { const updateFill = () => {
if (!fill) return; if (!fill) return;
const track = fill.parentElement; // the .w-2 gutter
if (!track) return;
const doc = document.documentElement; const doc = document.documentElement;
const max = doc.scrollHeight - doc.clientHeight; const max = doc.scrollHeight - doc.clientHeight;
const frac = max > 0 ? Math.max(0, Math.min(1, window.scrollY / max)) : 0; const frac = max > 0 ? Math.max(0, Math.min(1, window.scrollY / max)) : 0;
// Fill height as a percentage of the track length (which spans the card fill.style.height = `${(frac * track.clientHeight).toFixed(1)}px`;
// minus top/bottom padding).
const cardEl = fill.parentElement;
if (!cardEl) return;
const trackPx = cardEl.clientHeight - trackTop - trackBottom;
fill.style.height = `${(frac * trackPx).toFixed(1)}px`;
}; };
// Debounced at-bottom check: pin the last section active when truly at the
// bottom, but only after scrolling has paused (not on every tick).
let bottomTimer: number | undefined;
const checkBottom = () => {
window.clearTimeout(bottomTimer);
bottomTimer = window.setTimeout(() => {
if (suppressIo) return;
const doc = document.documentElement;
const atBottom =
window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
if (atBottom && sections.length) {
setActive(sections[sections.length - 1].id);
}
}, 120);
};
updateFill(); updateFill();
window.addEventListener("scroll", updateFill, { passive: true }); window.addEventListener("scroll", () => {
updateFill();
checkBottom();
}, { passive: true });
window.addEventListener("resize", updateFill); window.addEventListener("resize", updateFill);
} }
setupSectionSpy(); setupSectionSpy();
+2 -17
View File
@@ -32,25 +32,10 @@ const rendered = await Promise.all(
); );
--- ---
<ol class="relative flex flex-col gap-10"> <ol class="flex flex-col gap-10">
{/* The vertical line. */}
<span
class="absolute left-[1.15rem] top-2 bottom-2 w-px"
style="background: var(--color-border);"
aria-hidden="true"
></span>
{rendered.map(({ entry, Content }, i) => ( {rendered.map(({ entry, Content }, i) => (
<li class="relative pl-12"> <li class="relative">
<Reveal delay={i * 80}> <Reveal delay={i * 80}>
{/* Marker dot on the timeline line (kept small; the icon now lives inline
with the company name inside the card so it never clips the card). */}
<span
class="absolute left-[1.15rem] top-7 h-3 w-3 -translate-x-1/2 rounded-full"
style="background: var(--color-accent); box-shadow: 0 0 0 4px var(--color-surface);"
aria-hidden="true"
>
</span>
<div class="card flex flex-col gap-3"> <div class="card flex flex-col gap-3">
{/* Header row: icon + company name inline, period on the right. */} {/* Header row: icon + company name inline, period on the right. */}
+58 -31
View File
@@ -14,14 +14,16 @@ import BaseLayout from "@layouts/BaseLayout.astro";
import Reveal from "@components/motion/Reveal.astro"; import Reveal from "@components/motion/Reveal.astro";
import Icon from "@components/Icon.astro"; import Icon from "@components/Icon.astro";
import SectionHeader from "@components/layout/SectionHeader.astro"; import SectionHeader from "@components/layout/SectionHeader.astro";
import Placeholder from "@components/layout/Placeholder.astro";
import SectionNav from "@components/professional/SectionNav.astro"; import SectionNav from "@components/professional/SectionNav.astro";
import Timeline from "@components/professional/Timeline.astro"; import Timeline from "@components/professional/Timeline.astro";
import SkillGrid from "@components/professional/SkillGrid.astro"; import SkillGrid from "@components/professional/SkillGrid.astro";
import ProjectCard from "@components/professional/ProjectCard.astro"; import ProjectCard from "@components/professional/ProjectCard.astro";
import EducationHistory from "@components/professional/EducationHistory.astro";
import Certifications from "@components/professional/Certifications.astro";
import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro"; import AlchemicalDivider from "@components/mystic/AlchemicalDivider.astro";
import Astrolabe from "@components/mystic/Astrolabe.astro"; import Astrolabe from "@components/mystic/Astrolabe.astro";
import CircuitField from "@components/mystic/CircuitField.tsx"; import CircuitField from "@components/mystic/CircuitField.tsx";
import CelestialFrame from "@components/mystic/CelestialFrame.astro";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
const experience = (await getCollection("experience")).sort( const experience = (await getCollection("experience")).sort(
@@ -37,24 +39,43 @@ const projects = (await getCollection("projects")).sort(
title="Professional" title="Professional"
description="Software developer and team lead: cloud and mobile development, SAP Business Technology Platform, AI/ML, full-stack work. Resume and portfolio for Greg Kilmer." description="Software developer and team lead: cloud and mobile development, SAP Business Technology Platform, AI/ML, full-stack work. Resume and portfolio for Greg Kilmer."
> >
<!-- Page header --> {/* Page header + overlapping headshot.
<section class="relative overflow-hidden pt-28 sm:pt-32"> The headshot is anchored to the HERO SECTION (not container-prose) so its
{ straddle point is the true hero/next-section boundary. It's positioned on
/* Circuit-board field. Now self-contained (nebula gradient + canvas inside the right, aligned with the content column's right edge via the same
one root div), mirroring AnimatedBackground's structure so the morph from container-prose centering math (a centered max-width column). translate-y-1/2
a star hero is element-to-element equivalent — no separate gradient puts its center on the boundary: half in the hero, half over the next
flash. Shares view-transition-name "sky" via vt-sky. */ section, in front of both (z-50). */}
} <section class="relative overflow-visible pt-28 pb-28 sm:pt-32 sm:pb-36">
<CircuitField client:idle className="vt-sky opacity-100" /> {/* Background layer — clipped to the hero so the circuit canvas/astrolabe
{/* Slowly rotating astrolabe ornament behind the header. */} don't bleed into the content below. */}
<Astrolabe <div class="pointer-events-none absolute inset-0 overflow-hidden">
class="pointer-events-none absolute z-0 -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow" <CircuitField client:idle className="vt-sky opacity-100" />
style="color: var(--color-accent); opacity:.13;" <Astrolabe
/> class="absolute z-0 -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow"
<div style="color: var(--color-accent); opacity:.13;"
class="container-prose relative z-10 flex flex-col gap-10 lg:ml-56 lg:max-w-[calc(72rem+14rem)]" />
> </div>
<Reveal>
{/* Headshot — anchored to the hero section's bottom edge (the true boundary
with the next section), aligned right with the content column. Wrapped in
a centered max-width box that mirrors container-prose so the headshot's
right edge lines up with the content's right edge. */}
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-50 mx-auto max-w-[72rem] px-5 lg:ml-56 lg:max-w-[calc(72rem+14rem)] sm:px-8">
<div class="pointer-events-auto ml-auto w-44 translate-y-1/2 sm:w-52 md:w-60">
<CelestialFrame rounded="2xl" emphasis="strong">
<img
src="/images/about-portrait.jpg"
alt="Greg Kilmer headshot"
class="h-full w-full object-cover"
style="aspect-ratio: 4/5;"
/>
</CelestialFrame>
</div>
</div>
<div class="container-prose relative z-10 lg:ml-56 lg:max-w-[calc(72rem+14rem)]">
<Reveal class="max-w-2xl">
<SectionHeader <SectionHeader
tone="hero" tone="hero"
kicker="Professional" kicker="Professional"
@@ -80,7 +101,7 @@ const projects = (await getCollection("projects")).sort(
It keeps container-prose's max-width but lets it sit beside the rail. */ It keeps container-prose's max-width but lets it sit beside the rail. */
} }
<div <div
class="container-prose flex flex-col gap-20 py-16 sm:gap-24 sm:py-20 lg:ml-56 lg:max-w-[calc(72rem+14rem)]" class="container-prose flex flex-col gap-20 pt-28 pb-16 sm:gap-24 sm:pt-40 sm:pb-20 lg:ml-56 lg:max-w-[calc(72rem+14rem)]"
> >
{/* ===== 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">
@@ -185,7 +206,7 @@ const projects = (await getCollection("projects")).sort(
</section> </section>
{/* ===== 5. Education ===== */} {/* ===== 5. Education ===== */}
<section id="education" class="scroll-mt-28 flex flex-col gap-6"> <section id="education" class="scroll-mt-28 flex flex-col gap-8">
<Reveal> <Reveal>
<SectionHeader <SectionHeader
kicker="05" kicker="05"
@@ -194,16 +215,22 @@ const projects = (await getCollection("projects")).sort(
subtitle="My foundation." subtitle="My foundation."
/> />
</Reveal> </Reveal>
<div class="grid gap-4 sm:grid-cols-2"> <Reveal delay={60}>
<Placeholder <div class="flex flex-col gap-3">
label="Education history" <h3 class="font-mono text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">
hint="Add degrees, institutions, and dates." Education history
/> </h3>
<Placeholder <EducationHistory />
label="Certifications" </div>
hint="SAP, cloud, or other relevant certifications." </Reveal>
/> <Reveal delay={120}>
</div> <div class="flex flex-col gap-3">
<h3 class="font-mono text-xs uppercase tracking-[0.22em]" style="color: var(--color-accent);">
Certifications
</h3>
<Certifications />
</div>
</Reveal>
</section> </section>
{/* ===== 6. Contact ===== */} {/* ===== 6. Contact ===== */}