Files
com.gregkilmer.www/src/components/hero/AnimatedBackground.tsx
T
Claude 9ecd4d0e59 Use Patiun icons, motifs, seal logo and gold accents across the site
- Icons: Patiun stroke icon set (13 approved + 73 new) in src/lib/brand-icons.ts;
  Icon.astro and a React BrandIcon resolve mdi names to brand icons, keeping
  mdi only for third-party logos. Inline island icons (mobile menu, carousel,
  form success, board game shelf, product tile) switched too.
- Motifs: layered seal turns in the hero corner; astrolabe on the Professional
  hero; Starlight recolors of the transmutation seal and tria prima array
  behind light sections (outlined ring text, no fonts needed). Removed the
  generic sacred-geometry and astrolabe drawings.
- Gold accents: image frames and corner stars, quote bars, dividers (new
  star-line divider), nav group divider, scrolled nav rule, footer rule.
- Nav and footer use the Patiun seal; nav tagline back to "Developer. Maker.
  Storyteller."; "Web Development · a Patiun LLC company" moved to the
  footer copyright line.
- Mobile menu: Midnight drawer, visible close button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 02:20:55 +00:00

387 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AnimatedBackground.tsx
* ---------------------------------------------------------------------------
* The hero background island. Layers:
*
* 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. The Patiun layered magic seal, faint and slowly turning in the top-left
* corner (the surface's one focal motif).
* 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.
*/
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. The CALL SITE (in .astro) is
* responsible for including `vt-sky` here when this element should carry the
* `view-transition-name: sky` morph. We intentionally do NOT compute the
* transition class inside this React component: any conditional className
* interpolation causes a server/client hydration attribute mismatch under
* Astro's client:idle. Passing a static, fully-formed string guarantees the
* server-rendered and hydrated class attributes are byte-identical.
*/
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 {
// Position in sky coordinates (pre-rotation). The whole field rotates.
rx: number; // offset from field center (cx, cy)
ry: number;
r: number; // base radius
tw: number; // twinkle phase
tws: number; // twinkle speed (slow)
baseA: number; // base alpha
anchor: boolean; // brighter "anchor" star used for constellations
hue: 'white' | 'gold' | 'violet' | 'teal';
}
interface Shooter {
x: number;
y: number;
len: number;
speed: 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 =
getComputedStyle(document.documentElement).getPropertyValue('--color-gold').trim() ||
'#c9a24a';
// Nebula Violet is background light only, so violet stars use a lifted
// tint of it; teal stars use the on-dark teal so they read at dot size.
const violet = '#8a63ee';
const teal =
getComputedStyle(document.documentElement).getPropertyValue('--color-teal-bright').trim() ||
'#2bc2b7';
const white =
getComputedStyle(document.documentElement).getPropertyValue('--color-starlight').trim() ||
'#f4f2ed';
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 = 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();
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);
// Dense field tuned for ~40% constellation coverage on any slice. Stars
// live in a sky disk large enough that rotation never reveals an empty
// edge. The disk is centered on the PIVOT (the top-right corner) so the
// whole field rotates around that corner like a star chart turning about
// its corner mount; maxR reaches the far (bottom-left) corner.
const cx = width;
const cy = 0;
const maxR = Math.sqrt(cx * cx + height * height) + 40;
// Ultra-dense starfield: ~5x the previous density for a richly packed sky.
// One star per ~220 px^2 of the disk, capped for perf (~17% anchors).
const diskArea = Math.PI * maxR * maxR;
const count = Math.min(3000, Math.floor(diskArea / 220));
stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 6 === 0; // ~17% anchors -> very dense 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 {
rx: cx + rr * Math.cos(a),
ry: cy + rr * Math.sin(a),
r,
tw: Math.random() * Math.PI * 2,
// 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) {
const startX = Math.random() * width * 0.8;
const startY = Math.random() * height * 0.5;
const a = Math.PI * (0.18 + Math.random() * 0.12);
shooters.push({
x: startX,
y: startY,
len: 90 + Math.random() * 120,
speed: 5.5 + Math.random() * 3.5,
angle: a,
life: 1,
});
nextShooterAt = now + 5000 + Math.random() * 8000;
}
function frame(now: number) {
if (!ctx) return;
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);
// Pivot = top-right corner (matches the disk center used in resize()).
const cx = width;
const cy = 0;
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 < 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 / 1400 + (i + j) * 0.7);
const alpha = proximity * 0.22 * (0.55 + 0.45 * pulse);
ctx.strokeStyle = `rgba(201, 162, 74, ${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.stroke();
}
}
// 2) Stars (slow twinkle; positions already rotated).
for (const p of proj) {
const s = p.s;
s.tw += s.tws * dt;
// 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 = hueColor(s.hue);
if (s.anchor) {
ctx.beginPath();
ctx.fillStyle = color;
ctx.globalAlpha = alpha * 0.5;
ctx.shadowBlur = 10;
ctx.shadowColor = color;
ctx.arc(p.x, p.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(p.x, p.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
// 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;
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)})`);
grad.addColorStop(0.4, `rgba(201, 162, 74, ${(headA * 0.6).toFixed(3)})`);
grad.addColorStop(1, 'rgba(201, 162, 74, 0)');
ctx.strokeStyle = grad;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(sh.x, sh.y);
ctx.lineTo(tailX, tailY);
ctx.stroke();
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: 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="absolute inset-0"
style={{
background:
'linear-gradient(180deg, #080c14 0%, #0b111c 32%, #0e1522 58%, #131c2b 82%, #1a2a3a 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={{
backgroundImage: `
radial-gradient(38% 70% at 68% 38%, rgba(20, 163, 154, 0.22), transparent 62%),
radial-gradient(30% 55% at 72% 44%, rgba(106, 47, 224, 0.26), transparent 60%),
radial-gradient(22% 40% at 76% 50%, rgba(201, 162, 74, 0.12), transparent 60%),
linear-gradient(115deg, transparent 30%, rgba(236, 233, 226, 0.06) 45%, rgba(236, 233, 226, 0.09) 52%, rgba(236, 233, 226, 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(106, 47, 224, 0.18), transparent 60%),
radial-gradient(40% 50% at 88% 88%, rgba(20, 163, 154, 0.16), transparent 62%)
`,
}}
/>
{/* The layered magic seal, turning slowly in the top-left corner: the
one focal motif on the night sky. Its ring text and symbols are
outlined to paths (public/motifs/), so no symbol font is needed. */}
<img
src="/motifs/layered-seal.svg"
alt=""
aria-hidden="true"
decoding="async"
draggable={false}
className="pointer-events-none absolute -left-40 -top-40 h-[34rem] w-[34rem] animate-spin-slow select-none opacity-[0.2]"
/>
{/* Subtle vignette for text legibility. */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(120% 80% at 50% 30%, transparent 40%, rgba(8, 12, 20, 0.7) 100%)',
}}
/>
{/* Canvas star field (skipped under reduced motion). */}
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
</div>
);
}