Initial Commit

This commit is contained in:
Greg Kilmer
2026-07-04 07:42:24 -04:00
commit 71bffad507
77 changed files with 14437 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
/**
* AnimatedBackground.tsx
* ---------------------------------------------------------------------------
* The hero background island. Three 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.
*
* The component is presentational only; it reads CSS variables for colors so it
* adapts to the section surface tone automatically.
*/
import { useEffect, useRef } from 'react';
interface Props {
/** Show the canvas star field. Off on interest pages for a calmer feel. */
particles?: boolean;
/** Tailwind/className passthrough for the root. */
className?: string;
}
export default function AnimatedBackground({ particles = true, className = '' }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !particles) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let raf = 0;
let width = 0;
let height = 0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
interface Star {
x: number;
y: number;
r: number; // base radius
vx: number; // slow drift
vy: number;
tw: number; // twinkle phase
tws: number; // twinkle speed
baseA: number; // base alpha
anchor: boolean; // brighter "anchor" star used for constellations
}
interface Shooter {
x: number;
y: number;
len: number;
speed: number;
angle: number; // direction of travel (radians)
life: number; // 0..1 remaining
maxLife: number;
}
let stars: Star[] = [];
let shooters: Shooter[] = [];
let nextShooterAt = 0;
let t0 = performance.now();
// Resolve star colors from CSS variables so the field matches the theme.
const gold =
getComputedStyle(document.documentElement).getPropertyValue('--color-amber').trim() ||
'#e8b54a';
const violet =
getComputedStyle(document.documentElement).getPropertyValue('--color-violet').trim() ||
'#8b7bd8';
const teal =
getComputedStyle(document.documentElement).getPropertyValue('--color-teal').trim() ||
'#6fb6ad';
const white =
getComputedStyle(document.documentElement).getPropertyValue('--color-cream').trim() ||
'#f4f1e8';
// Max distance for two stars to be connected by a constellation line.
const LINK_DIST = 132;
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);
// Cap density: one star per ~9k px^2, max 140. Anchor stars are rarer.
const count = Math.min(140, Math.floor((width * height) / 9000));
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;
return {
x: Math.random() * width,
y: Math.random() * height,
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,
anchor,
};
});
}
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;
shooters.push({
x: startX,
y: startY,
len: 90 + Math.random() * 120,
speed,
angle,
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
t0 = now;
ctx.clearRect(0, 0, width, height);
// 1) Constellation lines (drawn first, behind stars). Connect near anchors.
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;
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.beginPath();
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;
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;
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;
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.fill();
}
ctx.beginPath();
ctx.fillStyle = color;
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.fill();
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
// 3) Shooting stars.
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
if (sh.life <= 0 || sh.x > width + 60 || sh.y > height + 60) {
shooters.splice(i, 1);
continue;
}
const tailX = sh.x - Math.cos(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 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(1, 'rgba(232, 181, 74, 0)');
ctx.strokeStyle = grad;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(sh.x, sh.y);
ctx.lineTo(tailX, tailY);
ctx.stroke();
// Bright head dot.
ctx.beginPath();
ctx.fillStyle = `rgba(244, 241, 232, ${headA.toFixed(3)})`;
ctx.shadowBlur = 8;
ctx.shadowColor = gold;
ctx.arc(sh.x, sh.y, 1.4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
raf = requestAnimationFrame(frame);
}
resize();
t0 = performance.now();
nextShooterAt = t0 + 3000 + Math.random() * 4000;
raf = requestAnimationFrame(frame);
const onResize = () => resize();
window.addEventListener('resize', onResize);
// Pause when off-screen to save cycles.
const io = new IntersectionObserver((entries) => {
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();
};
}, [particles]);
return (
<div
className={`pointer-events-none absolute inset-0 -z-10 overflow-hidden ${className}`}
aria-hidden="true"
>
{/* Layer 1: animated mesh gradient (midnight indigo -> violet + gold edges). */}
<div
className="animate-gradient-shift absolute inset-0 opacity-95"
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%)
`,
}}
/>
{/* 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]"
viewBox="0 0 100 100"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
aria-hidden="true"
>
<circle cx="50" cy="50" r="49" strokeWidth="0.8" />
<circle cx="50" cy="50" r="42" />
<circle cx="50" cy="50" r="30" />
<circle cx="50" cy="50" r="18" />
{Array.from({ length: 36 }).map((_, i) => {
const rad = (i * 10 * Math.PI) / 180;
const major = i % 3 === 0;
return (
<line
key={i}
x1={50 + 49 * Math.cos(rad)}
y1={50 + 49 * Math.sin(rad)}
x2={50 + (major ? 43 : 45.5) * Math.cos(rad)}
y2={50 + (major ? 43 : 45.5) * Math.sin(rad)}
strokeWidth={major ? 0.8 : 0.4}
/>
);
})}
</svg>
<svg
className="absolute -right-44 bottom-[-12rem] h-[30rem] w-[30rem] text-(--color-violet) opacity-[0.08]"
viewBox="0 0 100 100"
fill="none"
stroke="currentColor"
strokeWidth="0.5"
aria-hidden="true"
>
{/* Compact flower-of-life cluster. */}
<circle cx="50" cy="50" r="10" />
{Array.from({ length: 6 }).map((_, i) => {
const rad = (Math.PI / 3) * i;
return <circle key={i} cx={50 + 10 * Math.cos(rad)} cy={50 + 10 * Math.sin(rad)} r="10" />;
})}
{Array.from({ length: 6 }).map((_, i) => {
const rad = (Math.PI / 3) * i + Math.PI / 6;
return (
<circle key={`o${i}`} cx={50 + 10 * Math.sqrt(3) * Math.cos(rad)} cy={50 + 10 * Math.sqrt(3) * Math.sin(rad)} r="10" />
);
})}
</svg>
{/* Subtle vignette for text legibility. */}
<div
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%)',
}}
/>
{/* Layer 3: canvas star field (skipped under reduced motion). */}
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div>
);
}