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
+48 -36
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
BESIDE the text (instead of stacked above it) means the hero height is
driven by the text column and stays consistent across interest pages
regardless of image presence. On small screens it stacks (image below). */}
<div class="container-prose relative z-10 grid items-center gap-6 py-6 sm:grid-cols-[1.4fr_1fr]">
{/* --- Left: text column --- */}
<div class="flex flex-col items-start gap-4">
<Reveal> <Reveal>
<a <a
href="/interests" href="/interests"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em] transition-colors" class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.18em] transition-colors"
style="color: var(--color-text-subtle);" style="color: var(--color-cream); opacity: 0.8;"
> >
<Icon name="arrow-left" class="h-4 w-4" /> <Icon name="arrow-left" class="h-4 w-4" />
{eyebrow} {eyebrow}
</a> </a>
</Reveal> </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>
+104 -46
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,38 +36,44 @@ 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 class="relative my-1 w-2 shrink-0">
{/* Track (full height of the gutter). */}
<div <div
class="pointer-events-none absolute left-[1.55rem] top-6 bottom-6 w-px" class="pointer-events-none absolute left-1/2 top-0 h-full w-px -translate-x-1/2"
style="background: color-mix(in srgb, var(--color-border) 80%, transparent);" style="background: rgba(255, 255, 255, 0.12);"
aria-hidden="true" aria-hidden="true"
> >
</div> </div>
{/* The fill bar — height set by JS as the user scrolls. */} {/* Fill — height set by JS. Anchored to the top of the track. */}
<div <div
class="pointer-events-none absolute left-[1.55rem] top-6 w-px origin-top" class="pointer-events-none absolute left-1/2 top-0 w-px -translate-x-1/2 origin-top"
style="background: var(--color-accent); height: 0%; box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent) 60%, transparent);" style="background: var(--color-accent); height: 0%; box-shadow: 0 0 8px color-mix(in srgb, var(--color-accent) 60%, transparent);"
data-pro-fill data-pro-fill
aria-hidden="true" aria-hidden="true"
> >
</div> </div>
</div>
{/* --- The icon+label list. --- */}
<div class="flex flex-col gap-1">
{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="group relative flex items-center gap-3 rounded-lg px-2.5 py-2 font-sans text-sm font-semibold transition-all" class="group relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 font-sans text-sm font-semibold transition-all"
style="color: var(--color-cream); opacity: 0.85;" style="color: #e8e6f5;"
> >
<span <span
class="relative z-10 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors" 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);" style="background: rgba(232, 181, 74, 0.14); color: #f4cd72;"
data-section-dot={s.id} data-section-dot={s.id}
> >
<Icon name={s.icon} class="h-[1.15rem] w-[1.15rem]" /> <Icon name={s.icon} class="h-[1.15rem] w-[1.15rem]" />
@@ -68,30 +82,33 @@ const sections = [
</a> </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. */}
+53 -26
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">
{/* Background layer — clipped to the hero so the circuit canvas/astrolabe
don't bleed into the content below. */}
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<CircuitField client:idle className="vt-sky opacity-100" /> <CircuitField client:idle className="vt-sky opacity-100" />
{/* 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="absolute z-0 -right-48 -top-24 h-[34rem] w-[34rem] animate-spin-slow"
style="color: var(--color-accent); opacity:.13;" style="color: var(--color-accent); opacity:.13;"
/> />
<div </div>
class="container-prose relative z-10 flex flex-col gap-10 lg:ml-56 lg:max-w-[calc(72rem+14rem)]"
> {/* Headshot — anchored to the hero section's bottom edge (the true boundary
<Reveal> 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"
hint="SAP, cloud, or other relevant certifications."
/>
</div> </div>
</Reveal>
<Reveal delay={120}>
<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 ===== */}