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
+152 -82
View File
@@ -1,18 +1,19 @@
/**
* 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
* the `animate-gradient-shift` keyframes in global.css). Tuned to the
* night-sky palette: midnight indigo with violet + gold nebular edges.
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) baked
* in as absolutely-positioned decorations for esoteric texture.
* 3. An optional lightweight <canvas> star field: dozens of twinkling stars
* with thin constellation lines drawn between near neighbors, plus the
* occasional slow shooting-star streak. 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.
* 1. A CSS-driven night-sky gradient (deep blue, darker at top → lighter near
* horizon) plus a diagonal Milky Way galaxy band and teal/violet nebula
* clouds. GPU-friendly, animated via `animate-gradient-shift` keyframes.
* 2. A faint static SVG ornament layer (astrolabe + sacred geometry) for
* esoteric texture.
* 3. An optional <canvas> star field: a dense field of slowly-twinkling stars
* with thin constellation lines between near neighbors, plus the occasional
* shooting star. The WHOLE field rotates together very slowly around the
* field center — reading as a slice of sky turning as the planet rotates.
* 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
* adapts to the section surface tone automatically.
@@ -24,9 +25,21 @@ interface Props {
particles?: boolean;
/** Tailwind/className passthrough for the root. */
className?: string;
/**
* Opt-in View Transition name. Unset by default. The View Transitions API
* requires this name to be UNIQUE within a page snapshot, so the page must
* opt in on AT MOST ONE field (typically the hero). Applied via the `.vt-sky`
* CSS class rather than React's inline style to avoid an SSR/hydration
* attribute mismatch.
*/
transitionName?: 'sky';
}
export default function AnimatedBackground({ particles = true, className = '' }: Props) {
export default function AnimatedBackground({
particles = true,
className = '',
transitionName,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
@@ -45,15 +58,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
const dpr = Math.min(window.devicePixelRatio || 1, 2);
interface Star {
x: number;
y: number;
// Position in sky coordinates (pre-rotation). The whole field rotates.
rx: number; // offset from field center (cx, cy)
ry: number;
r: number; // base radius
vx: number; // slow drift
vy: number;
tw: number; // twinkle phase
tws: number; // twinkle speed
tws: number; // twinkle speed (slow)
baseA: number; // base alpha
anchor: boolean; // brighter "anchor" star used for constellations
hue: 'white' | 'gold' | 'violet' | 'teal';
}
interface Shooter {
@@ -61,15 +74,15 @@ export default function AnimatedBackground({ particles = true, className = '' }:
y: number;
len: number;
speed: number;
angle: number; // direction of travel (radians)
life: number; // 0..1 remaining
maxLife: number;
angle: number;
life: number;
}
let stars: Star[] = [];
let shooters: Shooter[] = [];
let nextShooterAt = 0;
let t0 = performance.now();
let angle = 0; // current field rotation (radians)
// Resolve star colors from CSS variables so the field matches the theme.
const gold =
@@ -85,10 +98,25 @@ export default function AnimatedBackground({ particles = true, className = '' }:
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
'#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.
const LINK_DIST = 132;
const LINK_DIST = 168;
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() {
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
@@ -98,97 +126,117 @@ export default function AnimatedBackground({ particles = true, className = '' }:
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Cap density: one star per ~9k px^2, max 140. Anchor stars are rarer.
const count = Math.min(140, Math.floor((width * height) / 9000));
// Dense field tuned for ~40% constellation coverage on any slice. Stars
// 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) => {
const anchor = i % 11 === 0; // ~9% anchors -> the brighter backbone stars
const r = anchor ? 1.4 + Math.random() * 1.3 : 0.4 + Math.random() * 1.1;
const anchor = i % 8 === 0; // ~12% anchors -> denser constellation backbone
// Uniform-ish distribution across the disk via sqrt on radius.
const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2;
const r = anchor ? 1.4 + Math.random() * 1.4 : 0.35 + Math.random() * 1.1;
return {
x: Math.random() * width,
y: Math.random() * height,
rx: cx + rr * Math.cos(a),
ry: cy + rr * Math.sin(a),
r,
vx: (Math.random() - 0.5) * 0.06,
vy: (Math.random() - 0.5) * 0.06,
tw: Math.random() * Math.PI * 2,
tws: 0.4 + Math.random() * 1.1, // twinkle cycles/sec-ish
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.18 + Math.random() * 0.4,
// Slow twinkle: 0.15–0.5 cycles/sec.
tws: 0.15 + Math.random() * 0.35,
baseA: anchor ? 0.55 + Math.random() * 0.35 : 0.16 + Math.random() * 0.4,
anchor,
hue: anchor ? (Math.random() < 0.5 ? 'gold' : 'white') : pickHue(),
};
});
}
function spawnShooter(now: number) {
// Diagonal streak starting somewhere in the top half.
const startX = Math.random() * width * 0.8;
const startY = Math.random() * height * 0.5;
const angle = Math.PI * (0.18 + Math.random() * 0.12); // ~32-54deg down-right
const speed = 5.5 + Math.random() * 3.5;
const a = Math.PI * (0.18 + Math.random() * 0.12);
shooters.push({
x: startX,
y: startY,
len: 90 + Math.random() * 120,
speed,
angle,
speed: 5.5 + Math.random() * 3.5,
angle: a,
life: 1,
maxLife: 1,
});
// Next shooter in 5-13s.
nextShooterAt = now + 5000 + Math.random() * 8000;
}
function frame(now: number) {
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;
angle += ROT_SPEED * dt; // whole sky rotates together
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;
for (let i = 0; i < stars.length; i++) {
const a = stars[i];
if (!a.anchor) continue; // only anchors backbone the constellations
for (let j = i + 1; j < stars.length; j++) {
const b = stars[j];
if (!b.anchor) continue;
const dx = a.x - b.x;
const dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
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;
// Alpha falls off with distance; gentle pulse tied to time.
const proximity = 1 - Math.sqrt(d2) / LINK_DIST;
const pulse = 0.5 + 0.5 * Math.sin(now / 1400 + (i + j) * 0.7);
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.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.stroke();
}
}
// 2) Stars (twinkle + drift).
for (const s of stars) {
s.x += s.vx;
s.y += s.vy;
// 2) Stars (slow twinkle; positions already rotated).
for (const p of proj) {
const s = p.s;
s.tw += s.tws * dt;
// Wrap softly at edges.
if (s.x < -4) s.x = width + 4;
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;
// Cull stars rotated off-screen for perf.
if (p.x < -8 || p.x > width + 8 || p.y < -8 || p.y > height + 8) continue;
const twinkle = 0.6 + 0.4 * Math.sin(s.tw);
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) {
// Glow halo for anchors.
ctx.beginPath();
ctx.fillStyle = color;
ctx.globalAlpha = alpha * 0.5;
ctx.shadowBlur = 10;
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();
}
@@ -197,19 +245,19 @@ export default function AnimatedBackground({ particles = true, className = '' }:
ctx.globalAlpha = alpha;
ctx.shadowBlur = s.anchor ? 6 : 3;
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.globalAlpha = 1;
ctx.shadowBlur = 0;
// 3) Shooting stars.
// 3) Shooting stars (screen-space; not part of the rotating sky).
if (now >= nextShooterAt) spawnShooter(now);
for (let i = shooters.length - 1; i >= 0; i--) {
const sh = shooters[i];
sh.x += Math.cos(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) {
shooters.splice(i, 1);
continue;
@@ -218,8 +266,8 @@ export default function AnimatedBackground({ particles = true, className = '' }:
const tailY = sh.y - Math.sin(sh.angle) * sh.len;
const grad = ctx.createLinearGradient(sh.x, sh.y, tailX, tailY);
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.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`); // gold mid
grad.addColorStop(0, `rgba(244, 241, 232, ${headA.toFixed(3)})`);
grad.addColorStop(0.4, `rgba(232, 181, 74, ${(headA * 0.6).toFixed(3)})`);
grad.addColorStop(1, 'rgba(232, 181, 74, 0)');
ctx.strokeStyle = grad;
ctx.lineWidth = 1.6;
@@ -228,7 +276,6 @@ export default function AnimatedBackground({ particles = true, className = '' }:
ctx.lineTo(tailX, tailY);
ctx.stroke();
// Bright head dot.
ctx.beginPath();
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
ctx.shadowBlur = 8;
@@ -271,26 +318,49 @@ export default function AnimatedBackground({ particles = true, className = '' }:
};
}, [particles]);
const skyClass = transitionName === 'sky' ? 'vt-sky' : '';
return (
<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"
>
{/* 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
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={{
backgroundColor: 'var(--color-surface-sunken)',
backgroundImage: `
radial-gradient(60% 80% at 15% 10%, color-mix(in srgb, var(--color-amber) 18%, transparent), transparent 60%),
radial-gradient(55% 70% at 85% 0%, color-mix(in srgb, var(--color-violet, var(--color-secondary)) 30%, transparent), transparent 60%),
radial-gradient(70% 90% at 50% 110%, color-mix(in srgb, var(--color-amber) 14%, transparent), transparent 60%)
radial-gradient(38% 70% at 68% 38%, rgba(111, 182, 173, 0.22), transparent 62%),
radial-gradient(30% 55% at 72% 44%, rgba(139, 123, 216, 0.26), 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. */}
<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"
fill="none"
stroke="currentColor"
@@ -317,7 +387,7 @@ export default function AnimatedBackground({ particles = true, className = '' }:
})}
</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"
fill="none"
stroke="currentColor"
@@ -343,10 +413,10 @@ export default function AnimatedBackground({ particles = true, className = '' }:
className="absolute inset-0"
style={{
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" />
</div>
);
+24 -12
View File
@@ -2,27 +2,39 @@
/**
* Footer.astro
* ---------------------------------------------------------------------------
* Minimal footer: social links (mdi icons), copyright, and the spec microcopy
* "Built with intention. Powered by caffeine." No emojis per spec.
* Minimal footer on a deep night-sky surface: a faint static starfield accent,
* 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 StarBurst from '@components/mystic/StarBurst.astro';
import StarField from '@components/mystic/StarField.astro';
import { socials, footerMicrocopy, siteIdentity } from '@lib/site';
const year = new Date().getFullYear();
const socialList = Object.values(socials);
---
<footer
class="border-t"
class="relative overflow-hidden border-t"
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">
<div class="flex flex-col gap-1">
<p class="font-sans text-sm font-semibold" style="color: var(--color-text);">
{siteIdentity.name}
</p>
<p class="font-mono text-xs italic" style="color: var(--color-text-subtle);">
{footerMicrocopy}
</p>
{/* 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">
<p class="font-sans text-sm font-semibold" style="color: var(--color-text);">
{siteIdentity.name}
</p>
<p class="font-mono text-xs italic" style="color: var(--color-text-subtle);">
{footerMicrocopy}
</p>
</div>
</div>
<ul class="flex items-center gap-2">
@@ -43,7 +55,7 @@ const socialList = Object.values(socials);
))}
</ul>
</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.
</div>
</footer>
+5 -4
View File
@@ -41,11 +41,12 @@ const {
} = Astro.props;
---
<section class="relative overflow-hidden pt-28 sm:pt-32">
<!-- Calmer background: gradient only, no particles on interest pages. -->
<AnimatedBackground client:idle particles={false} />
<section class="relative min-h-[60vh] overflow-hidden pt-28 sm:pt-32">
<!-- Full animated Milky Way field + stars (matches the Home/Interests hero).
Carries the per-page morph name (one .vt-sky per page snapshot). -->
<AnimatedBackground client:idle transitionName="sky" />
<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>
<a
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"
/>
);
}
+12 -12
View File
@@ -11,8 +11,8 @@
* Mobile renders a hamburger (MobileMenu.tsx) with the same two-group structure.
*/
import Icon from '@components/Icon.astro';
import ThemeToggle from './ThemeToggle.tsx';
import MobileMenu from './MobileMenu.tsx';
import StarBurst from '@components/mystic/StarBurst.astro';
import { navGroups, siteIdentity } from '@lib/site';
const path = Astro.url.pathname;
@@ -31,12 +31,15 @@ const isActive = (href: string) =>
aria-label="Primary"
>
<!-- Logo / identity -->
<a href="/" class="group flex flex-col leading-tight" aria-label={`${siteIdentity.name} home`}>
<span class="font-sans text-base font-bold tracking-tight" style="color: var(--color-text);">
{siteIdentity.name}
</span>
<span class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block" style="color: var(--color-text-subtle);">
{siteIdentity.tagline}
<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);">
{siteIdentity.name}
</span>
<span class="hidden font-mono text-[0.65rem] uppercase tracking-[0.18em] sm:block" style="color: var(--color-text-subtle);">
{siteIdentity.tagline}
</span>
</span>
</a>
@@ -63,14 +66,11 @@ const isActive = (href: string) =>
</div>
))}
<div class="ml-1">
<ThemeToggle client:idle />
</div>
<div class="ml-1" />
</div>
<!-- Mobile controls -->
<!-- Mobile controls (theme toggle removed: dark mode is forced for now) -->
<div class="flex items-center gap-2 md:hidden">
<ThemeToggle client:idle />
<MobileMenu client:idle groups={navGroups} currentPath={path} />
</div>
</nav>