General Update

This commit is contained in:
Greg Kilmer
2026-07-04 10:53:14 -04:00
parent 71bffad507
commit 33fa312645
18 changed files with 921 additions and 346 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 641 KiB

+152 -82
View File
@@ -1,18 +1,19 @@
/** /**
* AnimatedBackground.tsx * AnimatedBackground.tsx
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* The hero background island. Three layers: * The hero background island. Layers:
* *
* 1. A CSS-driven mesh-gradient layer (always on, GPU-friendly, animated via * 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near
* the `animate-gradient-shift` keyframes in global.css). Tuned to the * horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula
* night-sky palette: midnight indigo with violet + gold nebular edges. * clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes.
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) baked * 2. A faint static SVG ornament layer (astrolabe + sacred geometry) for
* in as absolutely-positioned decorations for esoteric texture. * esoteric texture.
* 3. An optional lightweight <canvas> star field: dozens of twinkling stars * 3. An optional <canvas> star field: a dense field of slowly-twinkling stars
* with thin constellation lines drawn between near neighbors, plus the * with thin constellation lines between near neighbors, plus the occasional
* occasional slow shooting-star streak. We render at devicePixelRatio and * shooting star. The WHOLE field rotates together very slowly around the
* cap the count for perf. It pauses when off-screen and is fully skipped * field center — reading as a slice of sky turning as the planet rotates.
* when prefers-reduced-motion is set. * We render at devicePixelRatio and cap the count for perf. It pauses when
* off-screen and is fully skipped when prefers-reduced-motion is set.
* *
* The component is presentational only; it reads CSS variables for colors so it * The component is presentational only; it reads CSS variables for colors so it
* adapts to the section surface tone automatically. * adapts to the section surface tone automatically.
@@ -24,9 +25,21 @@ interface Props {
particles?: boolean; particles?: boolean;
/** Tailwind/className passthrough for the root. */ /** Tailwind/className passthrough for the root. */
className?: string; className?: string;
/**
* Opt-in View Transition name. Unset by default. The View Transitions API
* requires this name to be UNIQUE within a page snapshot, so the page must
* opt in on AT MOST ONE field (typically the hero). Applied via the `.vt-sky`
* CSS class rather than React's inline style to avoid an SSR/hydration
* attribute mismatch.
*/
transitionName?: 'sky';
} }
export default function AnimatedBackground({ particles = true, className = '' }: Props) { export default function AnimatedBackground({
particles = true,
className = '',
transitionName,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => { useEffect(() => {
@@ -45,15 +58,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
interface Star { interface Star {
x: number; // Position in sky coordinates (pre-rotation). The whole field rotates.
y: number; rx: number; // offset from field center (cx, cy)
ry: number;
r: number; // base radius r: number; // base radius
vx: number; // slow drift
vy: number;
tw: number; // twinkle phase tw: number; // twinkle phase
tws: number; // twinkle speed tws: number; // twinkle speed (slow)
baseA: number; // base alpha baseA: number; // base alpha
anchor: boolean; // brighter "anchor" star used for constellations anchor: boolean; // brighter "anchor" star used for constellations
hue: 'white' | 'gold' | 'violet' | 'teal';
} }
interface Shooter { interface Shooter {
@@ -61,15 +74,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
y: number; y: number;
len: number; len: number;
speed: number; speed: number;
angle: number; // direction of travel (radians) angle: number;
life: number; // 0..1 remaining life: number;
maxLife: number;
} }
let stars: Star[] = []; let stars: Star[] = [];
let shooters: Shooter[] = []; let shooters: Shooter[] = [];
let nextShooterAt = 0; let nextShooterAt = 0;
let t0 = performance.now(); let t0 = performance.now();
let angle = 0; // current field rotation (radians)
// Resolve star colors from CSS variables so the field matches the theme. // Resolve star colors from CSS variables so the field matches the theme.
const gold = const gold =
@@ -85,10 +98,25 @@ export default function AnimatedBackground({ particles = true, className = '' }:
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() || getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
'#f4f1e8'; '#f4f1e8';
const hueColor = (h: Star['hue']) => (h === 'gold' ? gold : h === 'violet' ? violet : h === 'teal' ? teal : white);
// Max distance for two stars to be connected by a constellation line. // Max distance for two stars to be connected by a constellation line.
const LINK_DIST = 132; const LINK_DIST = 168;
const LINK_DIST_SQ = LINK_DIST * LINK_DIST; const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
// Rotation speed: a full turn takes ~6 minutes, so the drift is felt but
// never distracting — "as the planet rotates".
const ROT_SPEED = (Math.PI * 2) / (6 * 60); // radians per second
function pickHue(): Star['hue'] {
const n = Math.random();
// Mostly white stars, with occasional colored ones for richness.
if (n < 0.7) return 'white';
if (n < 0.85) return 'gold';
if (n < 0.94) return 'violet';
return 'teal';
}
function resize() { function resize() {
if (!canvas || !ctx) return; if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -98,97 +126,117 @@ export default function AnimatedBackground({ particles = true, className = '' }:
canvas.height = Math.floor(height * dpr); canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Cap density: one star per ~9k px^2, max 140. Anchor stars are rarer. // Dense field tuned for ~40% constellation coverage on any slice. Stars
const count = Math.min(140, Math.floor((width * height) / 9000)); // live in a sky disk larger than the viewport so rotation never reveals
// an empty edge. Radius = distance to the far viewport corner.
const cx = width / 2;
const cy = height / 2;
const maxR = Math.sqrt(cx * cx + cy * cy) + 40;
// One star per ~4000 px^2 of the disk, capped for perf (~12% anchors).
const diskArea = Math.PI * maxR * maxR;
const count = Math.min(420, Math.floor(diskArea / 4000));
stars = Array.from({ length: count }, (_, i) => { stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 11 === 0; // ~9% anchors -> the brighter backbone stars const anchor = i % 8 === 0; // ~12% anchors -> denser constellation backbone
const r = anchor ? 1.4 + Math.random() * 1.3 : 0.4 + Math.random() * 1.1; // Uniform-ish distribution across the disk via sqrt on radius.
const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2;
const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1;
return { return {
x: Math.random() * width, rx: cx + rr * Math.cos(a),
y: Math.random() * height, ry: cy + rr * Math.sin(a),
r, r,
vx: (Math.random() - 0.5) * 0.06,
vy: (Math.random() - 0.5) * 0.06,
tw: Math.random() * Math.PI * 2, tw: Math.random() * Math.PI * 2,
tws: 0.4 + Math.random() * 1.1, // twinkle cycles/sec-ish // Slow twinkle: 0.15–0.5 cycles/sec.
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.18 + Math.random() * 0.4, tws: 0.15 + Math.random() * 0.35,
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4,
anchor, anchor,
hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(),
}; };
}); });
} }
function spawnShooter(now: number) { function spawnShooter(now: number) {
// Diagonal streak starting somewhere in the top half.
const startX = Math.random() * width * 0.8; const startX = Math.random() * width * 0.8;
const startY = Math.random() * height * 0.5; const startY = Math.random() * height * 0.5;
const angle = Math.PI * (0.18 + Math.random() * 0.12); // ~32-54deg down-right const a = Math.PI * (0.18 + Math.random() * 0.12);
const speed = 5.5 + Math.random() * 3.5;
shooters.push({ shooters.push({
x: startX, x: startX,
y: startY, y: startY,
len: 90 + Math.random() * 120, len: 90 + Math.random() * 120,
speed, speed: 5.5 + Math.random() * 3.5,
angle, angle: a,
life: 1, life: 1,
maxLife: 1,
}); });
// Next shooter in 5-13s.
nextShooterAt = now + 5000 + Math.random() * 8000; nextShooterAt = now + 5000 + Math.random() * 8000;
} }
function frame(now: number) { function frame(now: number) {
if (!ctx) return; if (!ctx) return;
const dt = Math.min(0.05, (now - t0) / 1000); // seconds, clamped const dt = Math.min(0.05, (now - t0) / 1000);
t0 = now; t0 = now;
angle += ROT_SPEED * dt; // whole sky rotates together
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// 1) Constellation lines (drawn first, behind stars). Connect near anchors. const cx = width / 2;
const cy = height / 2;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
// Rotate each sky point about the field center into screen space.
const project = (rx: number, ry: number): [number, number] => [
cx + (rx - cx) * cosA - (ry - cy) * sinA,
cy + (rx - cx) * sinA + (ry - cy) * cosA,
];
// Project every star's sky position into screen space once.
// Each entry carries the source star + its rotated (x, y).
const proj = stars.map((s) => {
const [x, y] = project(s.rx, s.ry);
return { s, x, y };
});
// 1) Constellation lines (behind stars) — connect near anchor pairs.
ctx.lineWidth = 0.6; ctx.lineWidth = 0.6;
for (let i = 0; i < stars.length; i++) { for (let i = 0; i < proj.length; i++) {
const a = stars[i]; const A = proj[i];
if (!a.anchor) continue; // only anchors backbone the constellations if (!A.s.anchor) continue;
for (let j = i + 1; j < stars.length; j++) { for (let j = i + 1; j < proj.length; j++) {
const b = stars[j]; const B = proj[j];
if (!b.anchor) continue; if (!B.s.anchor) continue;
const dx = a.x - b.x; const ddx = A.x - B.x;
const dy = a.y - b.y; const ddy = A.y - B.y;
const d2 = dx * dx + dy * dy; const d2 = ddx * ddx + ddy * ddy;
if (d2 > LINK_DIST_SQ) continue; if (d2 > LINK_DIST_SQ) continue;
// Alpha falls off with distance; gentle pulse tied to time.
const proximity = 1 - Math.sqrt(d2) / LINK_DIST; const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7); const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse); const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`; // gold, rgb fixed for perf ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.moveTo(A.x, A.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(B.x, B.y);
ctx.stroke(); ctx.stroke();
} }
} }
// 2) Stars (twinkle + drift). // 2) Stars (slow twinkle; positions already rotated).
for (const s of stars) { for (const p of proj) {
s.x += s.vx; const s = p.s;
s.y += s.vy;
s.tw += s.tws * dt; s.tw += s.tws * dt;
// Wrap softly at edges. // Cull stars rotated off-screen for perf.
if (s.x < -4) s.x = width + 4; if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
else if (s.x > width + 4) s.x = -4;
if (s.y < -4) s.y = height + 4;
else if (s.y > height + 4) s.y = -4;
const twinkle = 0.6 + 0.4 * Math.sin(s.tw); const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
const alpha = Math.max(0, Math.min(1, s.baseA * twinkle)); const alpha = Math.max(0, Math.min(1, s.baseA * twinkle));
const color = s.anchor ? gold : s.r > 1 ? white : s.r > 0.7 ? violet : teal; const color = hueColor(s.hue);
if (s.anchor) { if (s.anchor) {
// Glow halo for anchors.
ctx.beginPath(); ctx.beginPath();
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.globalAlpha = alpha * 0.5; ctx.globalAlpha = alpha * 0.5;
ctx.shadowBlur = 10; ctx.shadowBlur = 10;
ctx.shadowColor = color; ctx.shadowColor = color;
ctx.arc(s.x, s.y, s.r * 1.8, 0, Math.PI * 2); ctx.arc(p.x, p.y, s.r * 1.8, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
@@ -197,19 +245,19 @@ export default function AnimatedBackground({ particles = true, className = '' }:
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
ctx.shadowBlur = s.anchor ? 6 : 3; ctx.shadowBlur = s.anchor ? 6 : 3;
ctx.shadowColor = color; ctx.shadowColor = color;
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// 3) Shooting stars. // 3) Shooting stars (screen-space; not part of the rotating sky).
if (now >= nextShooterAt) spawnShooter(now); if (now >= nextShooterAt) spawnShooter(now);
for (let i = shooters.length - 1; i >= 0; i--) { for (let i = shooters.length - 1; i >= 0; i--) {
const sh = shooters[i]; const sh = shooters[i];
sh.x += Math.cos(sh.angle) * sh.speed; sh.x += Math.cos(sh.angle) * sh.speed;
sh.y += Math.sin(sh.angle) * sh.speed; sh.y += Math.sin(sh.angle) * sh.speed;
sh.life -= dt * 0.5; // fade over ~2s sh.life -= dt * 0.5;
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) { if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
shooters.splice(i, 1); shooters.splice(i, 1);
continue; continue;
@@ -218,8 +266,8 @@ export default function AnimatedBackground({ particles = true, className = '' }:
const tailY = sh.y - Math.sin(sh.angle) * sh.len; const tailY = sh.y - Math.sin(sh.angle) * sh.len;
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY); const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
const headA = Math.max(0, Math.min(1, sh.life)); const headA = Math.max(0, Math.min(1, sh.life));
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`); // star-white head grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`); // gold mid grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`);
grad.addColorStop(1, 'rgba(232, 181, 74, 0)'); grad.addColorStop(1, 'rgba(232, 181, 74, 0)');
ctx.strokeStyle = grad; ctx.strokeStyle = grad;
ctx.lineWidth = 1.6; ctx.lineWidth = 1.6;
@@ -228,7 +276,6 @@ export default function AnimatedBackground({ particles = true, className = '' }:
ctx.lineTo(tailX, tailY); ctx.lineTo(tailX, tailY);
ctx.stroke(); ctx.stroke();
// Bright head dot.
ctx.beginPath(); ctx.beginPath();
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`; ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
ctx.shadowBlur = 8; ctx.shadowBlur = 8;
@@ -271,26 +318,49 @@ export default function AnimatedBackground({ particles = true, className = '' }:
}; };
}, [particles]); }, [particles]);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return ( return (
<div <div
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`} className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${skyClass} ${className}`}
aria-hidden="true" aria-hidden="true"
> >
{/* Layer 1: animated mesh gradient (midnight indigo -> violet + gold edges). */} {/* Layer 1: night-sky gradient — deep blue, darker at top, lighter toward
a low horizon. Evokes a true clear night sky rather than a flat color. */}
<div <div
className="animate-gradient-shift absolute inset-0 opacity-95" className="absolute inset-0"
style={{
background:
'linear-gradient(180deg, #04061a 0%, #070b24 32%, #0a1230 58%, #0e1a3e 82%, #12224a 100%)',
}}
/>
{/* Milky Way galaxy band — a soft diagonal luminous streak with a denser
core, blending teal + violet + faint gold. Sits over the base sky. */}
<div
className="animate-gradient-shift absolute inset-0 opacity-90"
style={{ style={{
backgroundColor: 'var(--color-surface-sunken)',
backgroundImage: ` backgroundImage: `
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--color-amber) 18%, transparent), transparent 60%), radial-gradient(38% 70% at 68% 38%, rgba(111, 182, 173, 0.22), transparent 62%),
radial-gradient(55% 70% at 85% 0%, color-mix(in srgb, var(--color-violet, var(--color-secondary)) 30%, transparent), transparent 60%), radial-gradient(30% 55% at 72% 44%, rgba(139, 123, 216, 0.26), transparent 60%),
radial-gradient(70% 90% at 50% 110%, color-mix(in srgb, var(--color-amber) 14%, transparent), transparent 60%) radial-gradient(22% 40% at 76% 50%, rgba(232, 181, 74, 0.12), transparent 60%),
linear-gradient(115deg, transparent 30%, rgba(236, 233, 255, 0.06) 45%, rgba(236, 233, 255, 0.09) 52%, rgba(236, 233, 255, 0.05) 60%, transparent 72%)
`,
backgroundSize: '200% 200%',
}}
/>
{/* Nebula clouds near the corners for color depth. */}
<div
className="absolute inset-0"
style={{
backgroundImage: `
radial-gradient(45% 55% at 12% 12%, rgba(139, 123, 216, 0.18), transparent 60%),
radial-gradient(40% 50% at 88% 88%, rgba(111, 182, 173, 0.16), transparent 62%)
`, `,
}} }}
/> />
{/* Faint astrolabe + sacred-geometry ornaments for esoteric texture. */} {/* Faint astrolabe + sacred-geometry ornaments for esoteric texture. */}
<svg <svg
className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.08]" className="absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow text-(--color-amber) opacity-[0.11]"
viewBox="0 0 100 100" viewBox="0 0 100 100"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -317,7 +387,7 @@ export default function AnimatedBackground({ particles = true, className = '' }:
})} })}
</svg> </svg>
<svg <svg
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.08]" className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.11]"
viewBox="0 0 100 100" viewBox="0 0 100 100"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -343,10 +413,10 @@ export default function AnimatedBackground({ particles = true, className = '' }:
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: background:
'radial-gradient(120% 80% at 50% 30%, transparent 40%, color-mix(in srgb, var(--color-surface-sunken) 70%, transparent) 100%)', 'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(4, 6, 26, 0.7) 100%)',
}} }}
/> />
{/* Layer 3: canvas star field (skipped under reduced motion). */} {/* Canvas star field (skipped under reduced motion). */}
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" /> <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div> </div>
); );
+17 -5
View File
@@ -2,20 +2,31 @@
/** /**
* Footer.astro * Footer.astro
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* Minimal footer: social links (mdi icons), copyright, and the spec microcopy * Minimal footer on a deep night-sky surface: a faint static starfield accent,
* "Built with intention. Powered by caffeine." No emojis per spec. * a StarBurst mark beside the name, social links (mdi icons), copyright, and
* the spec microcopy "Built with intention. Powered by caffeine." No emojis.
*/ */
import Icon from '@components/Icon.astro'; import Icon from '@components/Icon.astro';
import StarBurst from '@components/mystic/StarBurst.astro';
import StarField from '@components/mystic/StarField.astro';
import { socials, footerMicrocopy, siteIdentity } from '@lib/site'; import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const socialList = Object.values(socials); const socialList = Object.values(socials);
--- ---
<footer <footer
class="border-t" class="relative overflow-hidden border-t"
style="border-color: var(--color-border); background: var(--color-surface-sunken);" style="border-color: var(--color-border); background: var(--color-surface-sunken);"
> >
<div class="container-prose flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left"> {/* Faint static starfield — carries the night-sky treatment into the footer. */}
<StarField
class="pointer-events-none absolute inset-0 h-full w-full"
style="color: var(--color-accent); opacity:.35;"
density="sparse"
/>
<div class="container-prose relative z-10 flex flex-col items-center gap-6 py-10 text-center sm:flex-row sm:justify-between sm:text-left">
<div class="flex items-center gap-3">
<StarBurst class="h-5 w-5 shrink-0" style="color: var(--color-accent);" />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="font-sans text-sm font-semibold" style="color: var(--color-text);"> <p class="font-sans text-sm font-semibold" style="color: var(--color-text);">
{siteIdentity.name} {siteIdentity.name}
@@ -24,6 +35,7 @@ const socialList = Object.values(socials);
{footerMicrocopy} {footerMicrocopy}
</p> </p>
</div> </div>
</div>
<ul class="flex items-center gap-2"> <ul class="flex items-center gap-2">
{socialList.map((s) => ( {socialList.map((s) => (
@@ -43,7 +55,7 @@ const socialList = Object.values(socials);
))} ))}
</ul> </ul>
</div> </div>
<div class="container-prose pb-8 text-center text-xs" style="color: var(--color-text-subtle);"> <div class="container-prose relative z-10 pb-8 text-center text-xs" style="color: var(--color-text-subtle);">
&copy; {year} {siteIdentity.name}. All rights reserved. &copy; {year} {siteIdentity.name}. All rights reserved.
</div> </div>
</footer> </footer>
+5 -4
View File
@@ -41,11 +41,12 @@ const {
} = Astro.props; } = Astro.props;
--- ---
<section class="relative overflow-hidden pt-28 sm:pt-32"> <section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
<!-- Calmer background: gradient only, no particles on interest pages. --> <!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
<AnimatedBackground client:idle particles={false} /> Carries the per-page morph name (one .vt-sky per page snapshot). -->
<AnimatedBackground client:idle transitionName="sky" />
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-12"> <div class="container-prose relative z-10 flex flex-col items-start gap-6 py-16">
<Reveal> <Reveal>
<a <a
href="/interests" href="/interests"
+240
View File
@@ -0,0 +1,240 @@
/**
* CircuitField.tsx
* ---------------------------------------------------------------------------
* The Professional page's counterpart to ConstellationField. It draws a faint
* circuit-board pattern — orthogonal traces connecting node pads, with a slow
* "signal pulse" traveling the traces — instead of stars and constellations.
*
* Crucially it carries the SAME `view-transition-name` ("sky") as the star
* fields on other pages. So when you navigate from, say, the Home hero
* (ConstellationField / AnimatedBackground) to the Professional page, the
* browser's View Transition morphs the star field into the circuit field as
* one element — constellations visibly transform into circuitry.
*
* Honors prefers-reduced-motion (renders a static board) and pauses off-screen.
*/
import { useEffect, useRef } from 'react';
interface Props {
className?: string;
/**
* Opt-in View Transition name. Unset by default. The page should set this on
* AT MOST ONE element per page (the hero field). Applied via the `.vt-sky`
* CSS class to avoid an SSR/hydration mismatch with React's inline style.
*/
transitionName?: 'sky';
}
interface Trace {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface Node {
x: number;
y: number;
}
export default function CircuitField({
className = '',
transitionName,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let raf = 0;
let width = 0;
let height = 0;
let t0 = performance.now();
let traces: Trace[] = [];
let nodes: Node[] = [];
const gold = '#e8b54a';
const violet = '#8b7bd8';
const teal = '#6fb6ad';
function build() {
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Build an orthogonal circuit on a grid: place nodes at grid intersections
// (with jitter + random omissions), then connect neighbors with right-angle
// traces. This reads as a PCB / schematic, not a star map.
const step = 64;
const cols = Math.ceil(width / step) + 1;
const rows = Math.ceil(height / step) + 1;
const present: boolean[][] = [];
nodes = [];
for (let r = 0; r < rows; r++) {
present[r] = [];
for (let c = 0; c < cols; c++) {
// ~62% of intersections get a pad.
present[r][c] = Math.random() < 0.62;
if (present[r][c]) {
const jx = (Math.random() - 0.5) * step * 0.25;
const jy = (Math.random() - 0.5) * step * 0.25;
nodes.push({ x: c * step + jx, y: r * step + jy });
}
}
}
traces = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!present[r][c]) continue;
const here = { x: c * step, y: r * step };
// Right neighbor.
if (c + 1 < cols && present[r][c + 1] && Math.random() < 0.7) {
traces.push({ x1: here.x, y1: here.y, x2: (c + 1) * step, y2: r * step });
}
// Down neighbor.
if (r + 1 < rows && present[r + 1][c] && Math.random() < 0.7) {
traces.push({ x1: here.x, y1: here.y, x2: c * step, y2: (r + 1) * step });
}
}
}
}
function frame(now: number) {
if (!ctx) return;
const dt = Math.min(0.05, (now - t0) / 1000);
t0 = now;
ctx.clearRect(0, 0, width, height);
// Traces. A signal pulse brightens a moving window along each trace, with
// traces staggered so pulses are distributed across the board over time.
for (let i = 0; i < traces.length; i++) {
const t = traces[i];
// Base trace — clearly visible violet conductor line.
ctx.strokeStyle = 'rgba(168, 158, 220, 0.55)';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(t.x1, t.y1);
ctx.lineTo(t.x2, t.y2);
ctx.stroke();
// Pulse: a bright gold "signal" traveling the trace. position 0..1,
// looping, with traces staggered so pulses spread across the board.
const period = 6 + (i % 7);
const phase = ((now / 1000) % period) / period;
const px = t.x1 + (t.x2 - t.x1) * phase;
const py = t.y1 + (t.y2 - t.y1) * phase;
ctx.strokeStyle = gold;
ctx.globalAlpha = 0.95;
ctx.lineWidth = 2.2;
ctx.shadowBlur = 10;
ctx.shadowColor = gold;
ctx.beginPath();
ctx.moveTo(px - (t.x2 - t.x1) * 0.08, py - (t.y2 - t.y1) * 0.08);
ctx.lineTo(px, py);
ctx.stroke();
// Bright pulse head dot.
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff4d6';
ctx.beginPath();
ctx.arc(px, py, 1.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
}
// Node pads — solid teal fill with a brighter violet ring (solder pad).
for (const n of nodes) {
ctx.beginPath();
ctx.fillStyle = teal;
ctx.globalAlpha = 0.95;
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.85;
ctx.strokeStyle = violet;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(n.x, n.y, 4.5, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
void dt;
raf = requestAnimationFrame(frame);
}
function drawStatic() {
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
for (const t of traces) {
ctx.strokeStyle = 'rgba(168, 158, 220, 0.5)';
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(t.x1, t.y1);
ctx.lineTo(t.x2, t.y2);
ctx.stroke();
}
for (const n of nodes) {
ctx.fillStyle = teal;
ctx.globalAlpha = 0.95;
ctx.beginPath();
ctx.arc(n.x, n.y, 2.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
build();
if (reduce) {
drawStatic();
} else {
t0 = performance.now();
raf = requestAnimationFrame(frame);
}
const onResize = () => {
build();
if (reduce) drawStatic();
};
window.addEventListener('resize', onResize);
const io = new IntersectionObserver((entries) => {
if (reduce) return;
for (const e of entries) {
if (e.isIntersecting) {
if (!raf) {
t0 = performance.now();
raf = requestAnimationFrame(frame);
}
} else {
cancelAnimationFrame(raf);
raf = 0;
}
}
});
io.observe(canvas);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
io.disconnect();
};
}, []);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return (
<canvas
ref={canvasRef}
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`}
aria-hidden="true"
/>
);
}
@@ -0,0 +1,255 @@
/**
* ConstellationField.tsx
* ---------------------------------------------------------------------------
* A calmer, reusable star-field island for SECTION backgrounds (everything that
* isn't the hero). Compared to AnimatedBackground it is:
* - sparser and dimmer (so foreground content stays readable),
* - slow-blink only (no shooting stars / nebula),
* - the whole field rotates together very slowly, reading as a slice of sky
* turning as the planet rotates.
*
* Carries a `view-transition-name` (default "sky") so that when the browser
* morphs between pages via Astro's <ClientRouter />, the hero's star field and
* a section's constellation field cross-fade/translate as one continuous sky.
* On the Professional page we render CircuitField instead, which shares the
* same transition name — so constellations visibly MORPH into circuit traces.
*
* Honors prefers-reduced-motion (renders a static field) and pauses when the
* section is off-screen.
*/
import { useEffect, useRef } from 'react';
interface Props {
/** Density multiplier. 1 = default; lower for quieter sections. */
density?: number;
/** Tailwind/className passthrough for the root. */
className?: string;
/**
* Opt-in View Transition name. DEFAULT IS UNSET so the field carries NO
* view-transition-name. The View Transitions API requires the name to be
* UNIQUE within a single page snapshot; assigning "sky" to every instance
* caused "Unexpected duplicate view-transition-name: sky" + transition
* aborts. The page should set this on AT MOST ONE field per page (typically
* the hero), and it must match the corresponding element on the destination
* page for the morph (e.g. constellations -> circuitry) to play.
*
* Set via the `data-sky` attribute + the `.vt-sky` CSS class (see global.css)
* rather than React's inline style, which avoids an SSR/hydration mismatch.
*/
transitionName?: 'sky';
/** Star color theme. 'gold' leans warm, 'violet' leans mystic. */
tint?: 'gold' | 'violet' | 'teal';
}
export default function ConstellationField({
density = 1,
className = '',
transitionName,
tint = 'gold',
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let raf = 0;
let width = 0;
let height = 0;
let angle = 0;
let t0 = performance.now();
interface Star {
rx: number;
ry: number;
r: number;
tw: number;
tws: number;
baseA: number;
anchor: boolean;
}
let stars: Star[] = [];
const gold = '#e8b54a';
const violet = '#8b7bd8';
const teal = '#6fb6ad';
const white = '#f4f1e8';
const accent = tint === 'violet' ? violet : tint === 'teal' ? teal : gold;
// One full rotation every ~10 minutes — barely perceptible, "planet turning".
const ROT_SPEED = (Math.PI * 2) / (10 * 60);
const LINK_DIST = 175;
const LINK_DIST_SQ = LINK_DIST * LINK_DIST;
function resize() {
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const cx = width / 2;
const cy = height / 2;
const maxR = Math.sqrt(cx * cx + cy * cy) + 40;
// Tuned for ~40% constellation coverage. One star per ~6500 px^2, scaled
// by density; ~14% anchors for a rich constellation backbone.
const count = Math.min(
260,
Math.floor(((Math.PI * maxR * maxR) / 6500) * density),
);
stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 7 === 0;
const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2;
return {
rx: cx + rr * Math.cos(a),
ry: cy + rr * Math.sin(a),
r: anchor ? 1.1 + Math.random() * 1.0 : 0.3 + Math.random() * 0.9,
tw: Math.random() * Math.PI * 2,
tws: 0.1 + Math.random() * 0.25,
baseA: anchor ? 0.4 + Math.random() * 0.3 : 0.1 + Math.random() * 0.3,
anchor,
};
});
}
function drawStatic() {
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
for (const s of stars) {
ctx.beginPath();
ctx.fillStyle = s.anchor ? accent : white;
ctx.globalAlpha = s.baseA;
ctx.arc(s.rx, s.ry, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
void cx;
void cy;
}
function frame(now: number) {
if (!ctx) return;
const dt = Math.min(0.05, (now - t0) / 1000);
t0 = now;
angle += ROT_SPEED * dt;
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const project = (rx: number, ry: number): [number, number] => [
cx + (rx - cx) * cosA - (ry - cy) * sinA,
cy + (rx - cx) * sinA + (ry - cy) * cosA,
];
const proj = stars.map((s) => {
const [x, y] = project(s.rx, s.ry);
return { s, x, y };
});
// Constellation lines between near anchors.
ctx.lineWidth = 0.5;
for (let i = 0; i < proj.length; i++) {
const A = proj[i];
if (!A.s.anchor) continue;
for (let j = i + 1; j < proj.length; j++) {
const B = proj[j];
if (!B.s.anchor) continue;
const ddx = A.x - B.x;
const ddy = A.y - B.y;
const d2 = ddx * ddx + ddy * ddy;
if (d2 > LINK_DIST_SQ) continue;
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
const pulse = 0.5 + 0.5 * Math.sin(now / 1600 + (i + j) * 0.7);
const alpha = proximity * 0.24 * (0.55 + 0.45 * pulse);
ctx.strokeStyle = `rgba(232, 181, 74, ${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.stroke();
}
}
// Slow-blink stars.
for (const p of proj) {
const s = p.s;
s.tw += s.tws * dt;
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
const blink = 0.55 + 0.45 * Math.sin(s.tw);
const alpha = Math.max(0, Math.min(1, s.baseA * blink));
ctx.beginPath();
ctx.fillStyle = s.anchor ? accent : white;
ctx.globalAlpha = alpha;
if (s.anchor) {
ctx.shadowBlur = 6;
ctx.shadowColor = accent;
} else {
ctx.shadowBlur = 0;
}
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
raf = requestAnimationFrame(frame);
}
resize();
if (reduce) {
drawStatic();
} else {
t0 = performance.now();
raf = requestAnimationFrame(frame);
}
const onResize = () => {
resize();
if (reduce) drawStatic();
};
window.addEventListener('resize', onResize);
const io = new IntersectionObserver((entries) => {
if (reduce) return;
for (const e of entries) {
if (e.isIntersecting) {
if (!raf) {
t0 = performance.now();
raf = requestAnimationFrame(frame);
}
} else {
cancelAnimationFrame(raf);
raf = 0;
}
}
});
io.observe(canvas);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
io.disconnect();
};
}, [density, tint]);
// Apply the view-transition-name via a CSS class (global.css `.vt-sky`) rather
// than React's inline style: avoids an SSR/hydration attribute mismatch and
// keeps the name off by default (only one field per page should opt in).
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return (
<canvas
ref={canvasRef}
className={`pointer-events-none absolute inset-0 h-full w-full ${skyClass} ${className}`}
aria-hidden="true"
/>
);
}
+7 -7
View File
@@ -11,8 +11,8 @@
* 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 ThemeToggle from './ThemeToggle.tsx';
import MobileMenu from './MobileMenu.tsx'; import MobileMenu from './MobileMenu.tsx';
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;
@@ -31,13 +31,16 @@ const isActive = (href: string) =>
aria-label="Primary" aria-label="Primary"
> >
<!-- Logo / identity --> <!-- Logo / identity -->
<a href="/" class="group flex flex-col leading-tight" aria-label={`${siteIdentity.name} home`}> <a href="/" class="group flex items-center gap-2 leading-tight" aria-label={`${siteIdentity.name} home`}>
<StarBurst class="h-4 w-4 shrink-0 transition-transform duration-700 group-hover:rotate-90" style="color: var(--color-accent);" />
<span class="flex flex-col">
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);"> <span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);">
{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>
</a> </a>
<!-- Desktop nav: two groups with a divider --> <!-- Desktop nav: two groups with a divider -->
@@ -63,14 +66,11 @@ const isActive = (href: string) =>
</div> </div>
))} ))}
<div class="ml-1"> <div class="ml-1" />
<ThemeToggle client:idle />
</div>
</div> </div>
<!-- Mobile controls --> <!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
<div class="flex items-center gap-2 md:hidden"> <div class="flex items-center gap-2 md:hidden">
<ThemeToggle client:idle />
<MobileMenu client:idle groups={navGroups} currentPath={path} /> <MobileMenu client:idle groups={navGroups} currentPath={path} />
</div> </div>
</nav> </nav>
+11 -5
View File
@@ -54,10 +54,13 @@ const ogImageUrl = ogImage
--- ---
<!doctype html> <!doctype html>
<!-- `no-js` is removed by reveal-init.ts the moment JS runs. While it stays, <!-- no-js guard: removed by reveal-init.ts once JS runs; while present, the
the global.css `.no-js .reveal { opacity: 1 }` rule guarantees content is global.css `.no-js .reveal { opacity:1 }` rule keeps content visible even
never invisible even if the reveal script fails to load. --> if the reveal script fails. Theme model: data-theme on <html>, data-surface
<html lang="en" class="no-js" data-theme="light"> on <body>. DARK MODE IS CURRENTLY FORCED — light mode is deferred (the
site is designed for dark first). theme-init.ts pins data-theme="dark" and
ignores any stored/OS preference until light mode is reintroduced. -->
<html lang="en" class="no-js" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -79,7 +82,10 @@ const ogImageUrl = ogImage
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Theme boot: blocking inline script prevents flash of wrong theme. --> <!-- Theme boot: blocking inline script prevents flash of the wrong theme.
Dark mode is currently forced (light mode deferred), so this just pins
data-theme="dark" before paint. No data-astro-rerun needed since the
theme never changes across navigations. -->
<script is:inline set:html={themeInitScript} /> <script is:inline set:html={themeInitScript} />
<!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal]. <!-- Reveal-on-scroll observer. Pairs with Reveal.astro / [data-reveal].
+5 -1
View File
@@ -14,6 +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';
// Spec "Quick Facts", emojis -> mdi icons. // Spec "Quick Facts", emojis -> mdi icons.
const quickFacts = [ const quickFacts = [
@@ -31,7 +32,9 @@ const quickFacts = [
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 --> <!-- Header -->
<section class="container-prose flex flex-col gap-10 pb-10 pt-28 sm:pt-32"> <section class="relative overflow-hidden pb-10 pt-28 sm:pt-32">
<ConstellationField client:idle className="opacity-40" tint="violet" transitionName="sky" />
<div class="container-prose relative z-10 flex flex-col gap-10">
<Reveal> <Reveal>
<SectionHeader <SectionHeader
kicker="About" kicker="About"
@@ -40,6 +43,7 @@ const quickFacts = [
subtitle="Two wings of the same house. This page is the bridge between them." subtitle="Two wings of the same house. This page is the bridge between them."
/> />
</Reveal> </Reveal>
</div>
</section> </section>
<!-- Section 1: The Short Version --> <!-- Section 1: The Short Version -->
+61 -82
View File
@@ -1,14 +1,14 @@
--- ---
/** /**
* index.astro — Home (Landing) * index.astro — Home (Landing)
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* First impression page. Full-width hero with the animated background, the spec * First impression page. Full-width hero with the animated background, the spec
* headline/subheadline/intro paragraph, and the two CTA buttons. Below the fold * headline/subheadline/intro paragraph, and the two CTA buttons. Below the fold
* is the 3-column snapshot grid (Professional, Tabletop, Making) plus the wider * is the 3-column snapshot grid (Professional, Tabletop, Making) plus the wider
* Reading & Media strip. All copy is verbatim from the spec. * Reading & Media strip. All copy is verbatim from the spec.
* *
* Emojis from the spec table are rendered as mdi icons (no emojis per spec). * Emojis from the spec table are rendered as mdi icons (no emojis per spec).
*/ */
import BaseLayout from '@layouts/BaseLayout.astro'; import BaseLayout from '@layouts/BaseLayout.astro';
import Reveal from '@components/motion/Reveal.astro'; import Reveal from '@components/motion/Reveal.astro';
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx'; import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
@@ -17,68 +17,63 @@ import SectionHeader from '@components/layout/SectionHeader.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 SacredGeometry from '@components/mystic/SacredGeometry.astro'; import SacredGeometry from '@components/mystic/SacredGeometry.astro';
import ConstellationField from '@components/mystic/ConstellationField.tsx';
// Snapshot grid content from the spec, with spec emojis mapped to mdi icons. // Snapshot grid content from the spec, with spec emojis mapped to mdi icons.
// Each card now carries a vibe-matched stock image (swap for real photos later). // Each card now carries a vibe-matched stock image (swap for real photos later).
const cards = [ const cards = [
{ {
icon: 'briefcase', icon: 'briefcase',
title: 'Professional', title: 'Professional',
copy: 'From SAP-adjacent cloud apps to full-stack mobile — a decade of building software that businesses actually use.', copy: 'From SAP-adjacent cloud apps to full-stack mobile — a decade of building software that businesses actually use.',
href: '/professional', href: '/professional',
image: '/images/pro-workspace.jpg', image: '/images/pro-workspace.jpg',
alt: 'Developer workspace with code on screen', alt: 'Developer workspace with code on screen',
}, },
{ {
icon: 'dice-multiple', icon: 'dice-multiple',
title: 'Tabletop', title: 'Tabletop',
copy: "Currently running two campaigns in Foundry VTT and playtesting MCDM's Crows. Always rolling dice.", copy: "Currently running two campaigns in Foundry VTT and playtesting MCDM's Crows. Always rolling dice.",
href: '/interests/ttrpgs', href: '/interests/ttrpgs',
image: '/images/ttrpg-dice.jpg', image: '/images/ttrpg-dice.jpg',
alt: 'Polyhedral dice on a dark surface', alt: 'Polyhedral dice on a dark surface',
}, },
{ {
icon: 'palette', icon: 'palette',
title: 'Making', title: 'Making',
copy: 'Resin dice, 3D prints, and whatever else catches my eye. If it involves layers and precision, I am in.', copy: 'Resin dice, 3D prints, and whatever else catches my eye. If it involves layers and precision, I am in.',
href: '/interests/making', href: '/interests/making',
image: '/images/making-resin.jpg', image: '/images/making-resin.jpg',
alt: 'Resin crafting in progress', alt: 'Resin crafting in progress',
}, },
]; ];
--- ---
<BaseLayout> <BaseLayout>
<!-- ===================== HERO ===================== --> <!-- ===================== HERO ===================== -->
<section class="relative flex min-h-[88vh] items-center overflow-hidden pt-16"> <section class="relative flex min-h-[88vh] items-center overflow-hidden pt-16">
<AnimatedBackground client:idle /> <AnimatedBackground client:idle transitionName="sky" />
<div class="container-prose relative z-10 flex flex-col items-start gap-8 py-20"> <div class="container-prose relative z-10 flex flex-col items-start gap-8 py-20">
<div class="flex w-full flex-col gap-10 lg:flex-row lg:items-center"> <div class="flex w-full flex-col gap-10 lg:flex-row lg:items-center">
<Reveal as="div" class="max-w-2xl grow"> <Reveal as="div" class="max-w-2xl grow">
<span <span
class="mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 font-mono text-xs uppercase tracking-[0.18em]" class="mb-5 inline-flex items-center gap-2 rounded-full border px-3 py-1 font-mono text-xs uppercase tracking-[0.18em]"
style="border-color: var(--color-border); color: var(--color-text-muted); background: color-mix(in srgb, var(--color-surface-raised) 60%, transparent);" style="border-color: var(--color-border); color: var(--color-text-muted); background: color-mix(in srgb, var(--color-surface-raised) 60%, transparent);">
>
<span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span> <span class="h-2 w-2 rounded-full animate-pulse-glow" style="background: var(--color-accent);"></span>
Developer. Maker. Storyteller. Developer. Maker. Storyteller.
</span> </span>
<h1 class="text-balance" style="color: var(--color-text);"> <h1 class="text-balance" style="color: var(--text);">
Greg Kilmer Greg Kilmer
</h1> </h1>
<p <p class="mt-3 font-sans text-xl font-medium sm:text-2xl" style="color: var(--color-accent);">
class="mt-3 font-sans text-xl font-medium sm:text-2xl"
style="color: var(--color-accent);"
>
Software Developer &middot; Maker &middot; Tabletop Gamemaster Software Developer &middot; Maker &middot; Tabletop Gamemaster
</p> </p>
<blockquote <blockquote class="mt-7 max-w-2xl border-l-2 pl-5 text-lg leading-relaxed"
class="mt-7 max-w-2xl border-l-2 pl-5 text-lg leading-relaxed" style="border-color: var(--color-accent); color: var(--text-muted);">
style="border-color: var(--color-accent); color: var(--color-text-muted);"
>
I build things with code during the day and with resin, plastic, and I build things with code during the day and with resin, plastic, and
imagination at night. I lead cloud and mobile development at Implico, imagination at night. I lead cloud and mobile development at Implico,
run D&D campaigns that span years, and fill my shelves with Brandon run D&D campaigns that span years, and fill my shelves with Brandon
@@ -100,37 +95,29 @@ const cards = [
{/* Hero portrait — framed in celestial ornament. Swap for a real photo of Greg. */} {/* Hero portrait — framed in celestial ornament. Swap for a real photo of Greg. */}
<Reveal as="div" delay={120} class="w-full max-w-sm shrink-0 lg:w-80"> <Reveal as="div" delay={120} class="w-full max-w-sm shrink-0 lg:w-80">
<CelestialImage <CelestialImage src="/images/hero-portrait.jpg" alt="Greg at his desk" aspect="4/5" />
src="/images/hero-portrait.jpg"
alt="Greg at his desk"
aspect="4/5"
/>
</Reveal> </Reveal>
</div> </div>
</div> </div>
<!-- Scroll cue --> <!-- Scroll cue -->
<div class="pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2 animate-float" style="color: var(--color-text-subtle);" aria-hidden="true"> <div class="pointer-events-none absolute bottom-6 left-1/2 -translate-x-1/2 animate-float"
style="color: var(--color-text-subtle);" aria-hidden="true">
<Icon name="chevron-down" class="h-7 w-7" /> <Icon name="chevron-down" class="h-7 w-7" />
</div> </div>
</section> </section>
<!-- ===================== SNAPSHOT GRID ===================== --> <!-- ===================== SNAPSHOT GRID ===================== -->
<section class="relative overflow-hidden py-20 sm:py-28"> <section class="relative overflow-hidden py-20 sm:py-28">
{/* Slow-rotating constellation field behind the section — a slice of sky. */}
<ConstellationField client:idle className="opacity-60" />
{/* Faint sacred-geometry ornament behind the section. */} {/* Faint sacred-geometry ornament behind the section. */}
<SacredGeometry <SacredGeometry figure="flower" class="pointer-events-none absolute -right-40 top-10 z-0 h-[40rem] w-[40rem]"
figure="flower" style="color: var(--color-violet); opacity:.08;" />
class="pointer-events-none absolute -right-40 top-10 h-[40rem] w-[40rem]" <div class="container-prose relative z-10 flex flex-col gap-12">
style="color: var(--color-violet); opacity:.06;"
/>
<div class="container-prose relative flex flex-col gap-12">
<Reveal> <Reveal>
<SectionHeader <SectionHeader kicker="The two wings" icon="home-heart" title="A snapshot"
kicker="The two wings" subtitle="One house, two wings. Polished and precise on the professional side; warm and inviting on the personal side." />
icon="home-heart"
title="A snapshot"
subtitle="One house, two wings. Polished and precise on the professional side; warm and inviting on the personal side."
/>
</Reveal> </Reveal>
<div class="grid gap-5 md:grid-cols-3"> <div class="grid gap-5 md:grid-cols-3">
@@ -138,20 +125,13 @@ const cards = [
<Reveal delay={i * 90}> <Reveal delay={i * 90}>
<a href={card.href} class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0"> <a href={card.href} class="card card-interactive group flex h-full flex-col gap-4 overflow-hidden p-0">
<div class="aspect-[16/10] w-full overflow-hidden"> <div class="aspect-[16/10] w-full overflow-hidden">
<img <img src={card.image} alt={card.alt} loading="lazy" decoding="async"
src={card.image} class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105" />
alt={card.alt}
loading="lazy"
decoding="async"
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div> </div>
<div class="flex flex-col gap-4 px-5 pb-5"> <div class="flex flex-col gap-4 px-5 pb-5">
<span <span class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
class="inline-flex h-12 w-12 items-center justify-center rounded-xl"
style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);" style="background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent);"
aria-hidden="true" aria-hidden="true">
>
<Icon name={card.icon} class="h-6 w-6" /> <Icon name={card.icon} class="h-6 w-6" />
</span> </span>
<h3 class="font-sans" style="color: var(--color-text);">{card.title}</h3> <h3 class="font-sans" style="color: var(--color-text);">{card.title}</h3>
@@ -160,8 +140,7 @@ const cards = [
</p> </p>
<span <span
class="mt-1 inline-flex items-center gap-1 font-sans text-sm font-semibold transition-transform group-hover:translate-x-1" class="mt-1 inline-flex items-center gap-1 font-sans text-sm font-semibold transition-transform group-hover:translate-x-1"
style="color: var(--color-accent);" style="color: var(--color-accent);">
>
Learn more Learn more
<Icon name="arrow-right" class="h-4 w-4" /> <Icon name="arrow-right" class="h-4 w-4" />
</span> </span>
@@ -177,13 +156,12 @@ const cards = [
{/* Wider strip: Reading & Media */} {/* Wider strip: Reading & Media */}
<Reveal delay={120}> <Reveal delay={120}>
<a <a href="/interests/reading"
href="/interests/reading" class="card card-interactive group flex flex-col gap-4 overflow-hidden p-0 sm:flex-row sm:items-center sm:justify-between">
class="card card-interactive group flex flex-col gap-4 overflow-hidden p-0 sm:flex-row sm:items-center sm:justify-between"
>
<span class="flex items-center gap-4"> <span class="flex items-center gap-4">
<span class="hidden aspect-square w-32 shrink-0 overflow-hidden sm:block"> <span class="hidden aspect-square w-32 shrink-0 overflow-hidden sm:block">
<img src="/images/fantasy-books.jpg" alt="Fantasy bookshelf" loading="lazy" decoding="async" class="h-full w-full object-cover" /> <img src="/images/fantasy-books.jpg" alt="Fantasy bookshelf" loading="lazy" decoding="async"
class="h-full w-full object-cover" />
</span> </span>
<span class="flex flex-col gap-1 px-5 py-4 sm:px-0 sm:py-0"> <span class="flex flex-col gap-1 px-5 py-4 sm:px-0 sm:py-0">
<span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);"> <span class="font-mono text-xs uppercase tracking-[0.18em]" style="color: var(--color-text-subtle);">
@@ -194,7 +172,8 @@ const cards = [
</span> </span>
</span> </span>
</span> </span>
<Icon name="arrow-right" class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1 sm:ml-4" style="color: var(--color-accent);" /> <Icon name="arrow-right" class="h-6 w-6 shrink-0 transition-transform group-hover:translate-x-1 sm:ml-4"
style="color: var(--color-accent);" />
</a> </a>
</Reveal> </Reveal>
</div> </div>
+9 -6
View File
@@ -12,6 +12,7 @@ import Icon from '@components/Icon.astro';
import SectionHeader from '@components/layout/SectionHeader.astro'; import SectionHeader from '@components/layout/SectionHeader.astro';
import AnimatedBackground from '@components/hero/AnimatedBackground.tsx'; import AnimatedBackground from '@components/hero/AnimatedBackground.tsx';
import SacredGeometry from '@components/mystic/SacredGeometry.astro'; import SacredGeometry from '@components/mystic/SacredGeometry.astro';
import ConstellationField from '@components/mystic/ConstellationField.tsx';
const interests = [ const interests = [
{ {
@@ -68,9 +69,10 @@ const interests = [
description="The fun wing: tabletop RPGs, gaming, reading, resin dice and 3D printing, and Pokémon. Where Greg's passions live as stories and collections." description="The fun wing: tabletop RPGs, gaming, reading, resin dice and 3D printing, and Pokémon. Where Greg's passions live as stories and collections."
> >
<!-- Hero --> <!-- Hero -->
<section class="relative overflow-hidden pt-28 sm:pt-32"> <section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
<AnimatedBackground client:idle particles={false} /> {/* Full animated Milky Way field + stars, matching the Home hero treatment. */}
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-12"> <AnimatedBackground client:idle transitionName="sky" />
<div class="container-prose relative z-10 flex flex-col items-start gap-6 py-16">
<Reveal> <Reveal>
<SectionHeader <SectionHeader
kicker="The personal wing" kicker="The personal wing"
@@ -84,12 +86,13 @@ const interests = [
<!-- Interest grid --> <!-- Interest grid -->
<section class="relative overflow-hidden py-12 sm:py-16"> <section class="relative overflow-hidden py-12 sm:py-16">
<ConstellationField client:idle className="opacity-45" tint="teal" />
<SacredGeometry <SacredGeometry
figure="metatron" figure="metatron"
class="pointer-events-none absolute -left-48 bottom-0 h-[38rem] w-[38rem]" class="pointer-events-none absolute -left-48 bottom-0 z-0 h-[38rem] w-[38rem]"
style="color: var(--color-accent); opacity:.05;" style="color: var(--color-accent); opacity:.07;"
/> />
<div class="container-prose relative"> <div class="container-prose relative z-10">
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{interests.map((item, i) => ( {interests.map((item, i) => (
<Reveal delay={i * 80}> <Reveal delay={i * 80}>
+8 -3
View File
@@ -21,6 +21,7 @@ import SkillGrid from '@components/professional/SkillGrid.astro';
import ProjectCard from '@components/professional/ProjectCard.astro'; import ProjectCard from '@components/professional/ProjectCard.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 { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
const experience = (await getCollection('experience')).sort( const experience = (await getCollection('experience')).sort(
@@ -38,12 +39,16 @@ 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
fields on other pages, so navigating here morphs constellations into
circuitry. */}
<CircuitField client:idle className="opacity-70" transitionName="sky" />
{/* Slowly rotating astrolabe ornament behind the header. */} {/* Slowly rotating astrolabe ornament behind the header. */}
<Astrolabe <Astrolabe
class="pointer-events-none absolute -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"
style="color: var(--color-accent); opacity:.10;" style="color: var(--color-accent); opacity:.13;"
/> />
<div class="container-prose relative flex flex-col gap-10"> <div class="container-prose relative z-10 flex flex-col gap-10">
<Reveal> <Reveal>
<SectionHeader <SectionHeader
kicker="Professional" kicker="Professional"
+10 -21
View File
@@ -2,34 +2,23 @@
* theme-init.ts * theme-init.ts
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* Inlined (as a string) into the <head> of BaseLayout.astro so it runs BEFORE * Inlined (as a string) into the <head> of BaseLayout.astro so it runs BEFORE
* first paint. Reads the saved theme from localStorage, falling back to the OS * first paint. Doing this inline + blocking is what prevents the flash-of-wrong-
* preference via prefers-color-scheme, and sets `data-theme` on <html>. * theme (FOUC). Keep this file dependency-free and tiny: it ships to every page.
* *
* Doing this inline + blocking is what prevents the flash-of-wrong-theme (FOUC). * DARK MODE IS CURRENTLY FORCED. The site is designed dark-first; light mode is
* Keep this file dependency-free and tiny: it ships to every page. * deferred. This script pins data-theme="dark" and ignores any stored/OS
* preference, so the page never flashes light and the (removed) toggle can't
* desync it. To reintroduce light mode later: restore the stored/prefers-color-
* scheme logic and re-add the ThemeToggle.
*/ */
export const themeInitScript = ` export const themeInitScript = `
(function () { (function () {
try { try {
var STORAGE_KEY = 'gk-theme'; // Dark mode is forced for now (light mode is deferred).
var stored = localStorage.getItem(STORAGE_KEY); document.documentElement.setAttribute('data-theme', 'dark');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
// React to OS theme changes only if the user hasn't chosen explicitly.
if (!stored) {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
var onChange = function (e) {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
} catch (e) { } catch (e) {
// If anything fails (private mode, etc.), default to light. document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-theme', 'light');
} }
})(); })();
`; `;
+18 -1
View File
@@ -236,7 +236,7 @@
} }
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: var(--color-text); color: var(--text);
border: 1.5px solid var(--color-border); border: 1.5px solid var(--color-border);
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -291,3 +291,20 @@
transition: none; transition: none;
} }
} }
/* ---------------------------------------------------------------------------
* View Transition name utility. Assigns `view-transition-name: sky` to a single
* hero/section field per page so the browser morphs the star/circuit field as
* one element across navigations (constellations <-> circuitry).
*
* IMPORTANT: the View Transitions API requires this name to be UNIQUE within a
* page snapshot. Assigning it to more than one element per page throws
* "Unexpected duplicate view-transition-name: sky" and aborts the transition.
* Only ONE field per page should ever carry `.vt-sky`.
*
* Implemented as a CSS class (not React inline style) so SSR + hydration
* attributes match exactly and React never rewrites the property.
* ------------------------------------------------------------------------- */
.vt-sky {
view-transition-name: sky;
}
+82 -88
View File
@@ -4,121 +4,115 @@
* Two-axis theming model for the site: * Two-axis theming model for the site:
* *
* 1. Global light/dark mode -> set on <html data-theme="light|dark"> * 1. Global light/dark mode -> set on <html data-theme="light|dark">
* 2. Section surface tone -> set on a wrapper via data-surface: * 2. Section surface tone -> set on <body data-surface="...">
* data-surface="neutral" Home / About / Contact (deep night-sky) * data-surface="neutral" Home / About / Contact (deep night-sky)
* data-surface="professional" Professional wing (polished obsidian + gold) * data-surface="professional" Professional wing (obsidian + violet/gold)
* data-surface="interest" Interests wing (candlelit study under stars) * data-surface="interest" Interests wing (candlelit study)
* *
* The semantic tokens (--surface, --text, --accent, ...) are redefined for each * SELECTOR MODEL: data-theme lives on <html>, data-surface on <body>. We match
* (data-theme x data-surface) combination below. Components only ever reference * the descendant combination `html[data-theme=...] body[data-surface=...]`.
* the semantic tokens, so swapping either axis automatically restyles them. * `:root` holds the light/neutral FALLBACK only; every other rule beats it via
* specificity (0,2,0) > (0,1,0).
* *
* Palette direction: night-sky mystical — midnight indigo bases, star-gold * STATUS: DARK MODE IS FORCED (light mode deferred). The dark rules below are
* accents, mystic-violet secondary, nebula-teal tertiary. Light mode is * tuned for contrast first — --text / --text-muted / --text-subtle are kept
* reinterpreted as a "twilight"/parchment variant so the toggle always works * bright against the deep surfaces so content always stands out. Light rules
* without breaking the celestial mood. * are retained for the day light mode is reintroduced.
* *
* `theme-init.ts` (inlined in <head>) sets data-theme before paint to avoid a * `theme-init.ts` (inlined in <head>) pins data-theme="dark" before paint.
* flash of the wrong theme. data-surface is applied by the layout wrappers.
*/ */
/* =========================================================================== /* ===========================================================================
* DEFAULT / NEUTRAL SURFACE (Home, About, Contact) * DEFAULT / NEUTRAL SURFACE (Home, About, Contact)
* Deep midnight indigo with star-gold accents. The default night sky.
* ======================================================================== */ * ======================================================================== */
/* Light + neutral — twilight parchment (warm, low-contrast, still celestial). */ /* Light + neutral — fallback (not currently used; dark is forced). */
:root, :root,
[data-theme="light"][data-surface="neutral"], html[data-theme='light'] body[data-surface='neutral'] {
[data-theme="light"][data-surface="neutral"] body { --surface: #efe7d2;
--surface: #f3eee0; /* aged parchment / star-map paper */ --surface-raised: #f7f0dc;
--surface-raised: #fbf7ec; --surface-sunken: #e3d9bd;
--surface-sunken: #e7dfca; --text: #1b1730;
--text: #1c1830; /* deep indigo ink */
--text-muted: #4a445e; --text-muted: #4a445e;
--text-subtle: #6b6582; --text-subtle: #6b6582;
--border: #d8cdb0; --border: #d2c6a2;
--accent: #9a6b1a; /* deeper gold for AA on light */ --accent: #7e5512;
--accent-contrast: #fbf7ec; --accent-contrast: #f7f0dc;
--secondary: #6f5fb6; /* mystic violet */ --secondary: #5e4f9e;
} }
/* Dark + neutral — the canonical night sky. */ /* Dark + neutral — the canonical deep mystical nightscape. High-contrast text. */
[data-theme="dark"][data-surface="neutral"], html[data-theme='dark'] body[data-surface='neutral'] {
[data-theme="dark"][data-surface="neutral"] body { --surface: #070a1c; /* near-void midnight indigo */
--surface: #0b1026; /* midnight indigo */ --surface-raised: #11163a; /* indigo panel, violet-edged */
--surface-raised: #161b3a; /* indigo panel */ --surface-sunken: #04061a; /* deep cosmic void */
--surface-sunken: #070a1a; /* near-black void */ --text: #f6f4ff; /* near-white for max legibility */
--text: #f4f1e8; /* star-white */ --text-muted: #d4d0f0; /* bright enough to read clearly on deep blue */
--text-muted: #c7c3df; --text-subtle: #a8a2d8; /* lifted from the old #7e79b8 for contrast */
--text-subtle: #8e89b5; --border: #2e3470;
--border: #2c3160; --accent: #f0c262; /* slightly lifted gold for pop on dark */
--accent: #e8b54a; /* star gold */ --accent-contrast: #070a1c;
--accent-contrast: #0b1026; --secondary: #b3a4ec; /* brighter violet, easier on the eye */
--secondary: #8b7bd8; /* mystic violet */
} }
/* =========================================================================== /* ===========================================================================
* PROFESSIONAL SURFACE * PROFESSIONAL SURFACE — violet-dominant obsidian, dark by default.
* Polished obsidian: deepest indigo with gold + subtle violet. Confident,
* precise, the "polished wing" from the spec — now with a starlit edge.
* ======================================================================== */ * ======================================================================== */
[data-surface="professional"] { body[data-surface='professional'],
--surface: #0a0e22; html[data-theme='dark'] body[data-surface='professional'] {
--surface-raised: #141a38; --surface: #080a20;
--surface-sunken: #05081a; --surface-raised: #121638;
--text: #f4f1e8; --surface-sunken: #040517;
--text-muted: #c5c0db; --text: #f6f4ff;
--text-subtle: #8782ad; --text-muted: #cec9f2;
--border: #292e57; --text-subtle: #a59fd6;
--accent: #e8b54a; --border: #2e3270;
--accent-contrast: #0a0e22; --accent: #f0c262;
--secondary: #8b7bd8; --accent-contrast: #080a20;
--secondary: #b3a4ec;
} }
/* Light professional: keeps a dignified deep tone, slightly lifted so the html[data-theme='light'] body[data-surface='professional'] {
* global toggle is never jarring next to a parchment Home page. */ --surface: #21183f;
[data-theme="light"][data-surface="professional"] { --surface-raised: #2c2152;
--surface: #1a1f44; --surface-sunken: #18113a;
--surface-raised: #252b54; --text: #f1edff;
--surface-sunken: #141838; --text-muted: #d5cff0;
--text: #f4f1e8; --text-subtle: #aaa3d6;
--text-muted: #d4d0ec; --border: #3c3170;
--text-subtle: #aaa5cd; --accent: #7e5512;
--border: #3a3f72; --accent-contrast: #f1edff;
--secondary: #b6a6e8;
} }
/* =========================================================================== /* ===========================================================================
* INTEREST SURFACE * INTEREST SURFACE — warm plum-brown night, dark by default.
* Candlelit study under a starfield: warm amber-gold bases with deep indigo
* text by default (light), flipping to a warm brown-indigo at night. The
* "fun wing" stays inviting, now with an esoteric warmth.
* ======================================================================== */ * ======================================================================== */
[data-theme="light"][data-surface="interest"], body[data-surface='interest'],
[data-surface="interest"] { html[data-theme='dark'] body[data-surface='interest'] {
--surface: #f6ecd2; /* warm candlelight parchment */ --surface: #170f24; /* warm plum-brown night */
--surface-raised: #fdf5dc; --surface-raised: #221630;
--surface-sunken: #ecdcb4; --surface-sunken: #100920;
--text: #241a2e; /* plum-indigo ink */ --text: #fbf2e4; /* warm near-white */
--text-muted: #5c4a3a; --text-muted: #ecdcc6; /* lifted for contrast */
--text-subtle: #826a52; --text-subtle: #c4ab8e; /* lifted from #a99a86 */
--border: #ddc79a; --border: #3e2f4a;
--accent: #9a6b1a; /* deeper gold for AA */ --accent: #f0c262;
--accent-contrast: #fdf5dc; --accent-contrast: #170f24;
--secondary: #6f5fb6; /* mystic violet */ --secondary: #c8abe0;
} }
[data-theme="dark"][data-surface="interest"] { html[data-theme='light'] body[data-surface='interest'] {
--surface: #1a1320; /* warm brown-indigo night */ --surface: #f4e9cf;
--surface-raised: #261c2e; --surface-raised: #fdf4d9;
--surface-sunken: #120d18; --surface-sunken: #e8d8b2;
--text: #f6ecd2; /* warm starlight */ --text: #22152e;
--text-muted: #ddcbb0; --text-muted: #5c4733;
--text-subtle: #b09a82; --text-subtle: #826a4f;
--border: #3a2c1f; --border: #d8c290;
--accent: #e8b54a; /* star gold */ --accent: #7e5512;
--accent-contrast: #1a1320; --accent-contrast: #fdf4d9;
--secondary: #b09a78; /* warm gold-bronze secondary at night */ --secondary: #5e4f9e;
} }