diff --git a/public/images/OLDhero-portrait.jpg b/public/images/OLDhero-portrait.jpg new file mode 100644 index 0000000..5a12c04 Binary files /dev/null and b/public/images/OLDhero-portrait.jpg differ diff --git a/public/images/hero-portrait.jpg b/public/images/hero-portrait.jpg index 5a12c04..c1e32b5 100644 Binary files a/public/images/hero-portrait.jpg and b/public/images/hero-portrait.jpg differ diff --git a/public/images/making-resin.jpg b/public/images/making-resin.jpg index 8409bb9..e37e561 100644 Binary files a/public/images/making-resin.jpg and b/public/images/making-resin.jpg differ diff --git a/public/images/ttrpg-dice.jpg b/public/images/ttrpg-dice.jpg index a878ce1..c968e41 100644 Binary files a/public/images/ttrpg-dice.jpg and b/public/images/ttrpg-dice.jpg differ diff --git a/src/components/hero/AnimatedBackground.tsx b/src/components/hero/AnimatedBackground.tsx index ee5da10..dfd0429 100644 --- a/src/components/hero/AnimatedBackground.tsx +++ b/src/components/hero/AnimatedBackground.tsx @@ -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 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 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(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 (