import React from 'react'; import { SeasonalOverlayProps } from '../types'; import { animBatBob, animBatFlap, animBatFlight, animCandle, animGhost, animGhostWobble, animSpiderBob, animSpiderClimb, animSpiderSway, animSwarmFlight, animWispPulse, animWispWander, animWitch, halloweenRoot, } from './Halloween.css'; /** * HalloweenOverlay — cobwebs, a dangling spider and bats, over the app as is. * * The parent supplies a fixed inset:0 overflow:hidden pointer-events:none * container; everything here is aria-hidden and absolutely positioned. * * [#239] No full-screen purple wash or blurred "moon": they tinted the whole * app and the moon glow sat over the member list, making it unreadable. * Colours are theme-aware via --hw-ink / --hw-web (see Halloween.css.ts). * * Composition: * 1. cobwebs in the two top corners (thin threads, readable in both themes) * 2. a spider on a thread in the empty space under the member list, bobbing * 3. every 30 s a small group of bats flaps across the top of the screen * `reduced`: webs and the spider at rest, no bats flying. * `preview`: a night-sky swatch for Settings. */ /** Corner web (corner at 0,0): radial threads + sagging catch-rings. */ function webPath(size = 100): string { const angles = [0, 16, 32, 48, 64, 80, 90].map((a) => (a * Math.PI) / 180); const spokes = angles .map((a) => `M0 0 L${(Math.cos(a) * size).toFixed(1)} ${(Math.sin(a) * size).toFixed(1)}`) .join(' '); const rings = [0.28, 0.48, 0.68, 0.88].map((f) => { const r = f * size; const pts = angles.map((a) => [Math.cos(a) * r, Math.sin(a) * r]); let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; for (let i = 1; i < pts.length; i += 1) { const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i]; // Control point pulled toward the corner so each segment sags. const cx = ((x0 + x1) / 2) * 0.86; const cy = ((y0 + y1) / 2) * 0.86; d += ` Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`; } return d; }); return `${spokes} ${rings.join(' ')}`; } const WEB = webPath(); function Cobweb({ corner, size }: { corner: 'left' | 'right'; size: number }) { return ( ); } // Classic bat silhouette, wings spread (viewBox 0 0 100 50). const BAT_PATH = 'M50 18 L46 10 L45 17 C40 17 36 19 33 23 C28 15 18 11 4 14 C12 18 14 23 12 29 ' + 'C7 27 3 28 0 31 C10 31 17 35 21 42 C25 36 31 34 37 36 C41 41 46 43 50 46 ' + 'C54 43 59 41 63 36 C69 34 75 36 79 42 C83 35 90 31 100 31 C97 28 93 27 88 29 ' + 'C86 23 88 18 96 14 C82 11 72 15 67 23 C64 19 60 17 55 17 L54 10 Z'; function Bat({ width, flap }: { width: number; flap?: number }) { return ( ); } function Spider({ size }: { size: number }) { const legs = [-1, 1] .flatMap((side) => [0, 1, 2, 3].map((i) => { const y = 38 + i * 7; const kx = 50 + side * (16 + i * 2); const fx = 50 + side * (30 + (i === 1 || i === 2 ? 6 : 0)); const fy = y + (i < 2 ? -8 : 10); return `M${50 + side * 7} ${y} Q${kx} ${y - 12} ${fx} ${fy}`; }), ) .join(' '); return ( ); } /** Spider hanging from the top edge; thread and body move together. */ function HangingSpider({ left, length, animate, size = 30, climb = false, }: { left: string; length: string; animate: boolean; size?: number; climb?: boolean; }) { let motion: string | undefined; if (animate) { motion = climb ? `${animSpiderClimb} 24s ease-in-out 6s infinite` : `${animSpiderBob} 7s ease-in-out infinite`; } return (
); } /** Small jack-o'-lantern hanging on a short thread, candle flickering. */ function HangingLantern({ right, length, size }: { right: number; length: number; size: number }) { return ( ); } // Witch on a broom, flying left (viewBox 0 0 120 60). const WITCH_PATH = [ 'M4 42 L80 37 L80 40 L4 45 Z', // broom handle 'M80 34 C88 32 98 28 108 24 C104 32 106 38 116 46 C104 44 92 42 80 42 Z', // bristles 'M32 20 C36 17 50 16 58 19 L55 21 C50 19 40 20 35 22 Z', // hat brim 'M40 19 L64 2 L52 19 Z', // hat cone, leaning back 'M39 25 A5 5 0 1 0 49 25 A5 5 0 1 0 39 25 Z', // head 'M39 24 L34 26 L39 27 Z', // nose 'M40 29 C44 28 52 28 56 30 C64 30 72 28 80 24 C76 30 70 34 62 38 L44 38 C40 36 38 32 40 29 Z', // cloak 'M42 31 L32 37 L34 39 L44 34 Z', // arm on the broom 'M48 38 L44 50 L38 52 L38 54 L47 53 L52 39 Z', // leg and boot ].join(' '); function Witch({ width }: { width: number }) { return ( ); } const WISPS = [ { left: 12, top: 62, size: 30, hue: 'oklch(0.82 0.19 145)', wander: 26, pulse: 3.2, delay: -3 }, { left: 34, top: 80, size: 24, hue: 'oklch(0.8 0.16 60)', wander: 31, pulse: 2.6, delay: -11 }, { left: 55, top: 48, size: 28, hue: 'oklch(0.82 0.19 145)', wander: 28, pulse: 3.8, delay: -7 }, { left: 72, top: 72, size: 34, hue: 'oklch(0.8 0.16 60)', wander: 34, pulse: 2.9, delay: -19 }, { left: 88, top: 55, size: 24, hue: 'oklch(0.82 0.19 145)', wander: 24, pulse: 3.4, delay: -5 }, { left: 46, top: 30, size: 22, hue: 'oklch(0.8 0.16 60)', wander: 29, pulse: 2.4, delay: -15 }, ]; const BAT_PERIOD = 20; // s between flights const BAT_FIRST = 5; // s after load const BATS = [ { top: 9, width: 64, offset: 0, bob: 1.3, flap: 0.3 }, { top: 16, width: 46, offset: 0.5, bob: 1.1, flap: 0.26 }, { top: 6, width: 38, offset: 0.9, bob: 1.5, flap: 0.24 }, { top: 20, width: 54, offset: 1.4, bob: 1.2, flap: 0.28 }, ]; // Every 60 s a bigger, faster swarm of small bats, offset from the regular group. const SWARM_PERIOD = 60; const SWARM_FIRST = 38; const SWARM = Array.from({ length: 8 }, (_, i) => ({ top: 4 + ((i * 7) % 22), width: 22 + (i % 3) * 8, offset: (i * 0.23) % 1.4, bob: 0.9 + (i % 3) * 0.25, flap: 0.18 + (i % 3) * 0.03, })); const WITCH_PERIOD = 60; const WITCH_FIRST = 22; const GHOST_PERIOD = 50; const GHOST_FIRST = 16; /** A small, friendly ghost: pale body with an ink outline (shows on light). */ function Ghost({ size }: { size: number }) { return ( ); } export function HalloweenOverlay({ reduced, preview }: SeasonalOverlayProps) { // Phones: smaller webs, and a shorter thread so the spider hangs near the top. const narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 750px)').matches; if (preview) { return ( ); } return ( ); }