Files
cinny/src/app/components/seasonal/themes/Halloween.tsx
T

317 lines
9.6 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animBatBob,
animBatFlap,
animBatFlight,
animGhost,
animGhostWobble,
animSpiderBob,
animSpiderSway,
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 (
<svg
aria-hidden="true"
viewBox="0 0 100 100"
width={size}
height={size}
style={{
position: 'absolute',
top: 0,
[corner]: 0,
transform: corner === 'right' ? 'scaleX(-1)' : undefined,
overflow: 'visible',
}}
>
<path
d={WEB}
fill="none"
stroke="var(--hw-web)"
strokeWidth={0.9}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
// 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 (
<svg
aria-hidden="true"
viewBox="0 0 100 50"
width={width}
height={width / 2}
style={{
display: 'block',
transformOrigin: '50% 60%',
animation: flap ? `${animBatFlap} ${flap}s ease-in-out infinite` : undefined,
}}
>
<path d={BAT_PATH} fill="var(--hw-ink)" />
</svg>
);
}
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 (
<svg
aria-hidden="true"
viewBox="0 0 100 100"
width={size}
height={size}
style={{ display: 'block' }}
>
<path d={legs} fill="none" stroke="var(--hw-ink)" strokeWidth={4} strokeLinecap="round" />
<ellipse cx="50" cy="52" rx="13" ry="16" fill="var(--hw-ink)" />
<circle cx="50" cy="33" r="8" fill="var(--hw-ink)" />
<circle cx="47" cy="32" r="1.8" fill="var(--hw-eye)" />
<circle cx="53" cy="32" r="1.8" fill="var(--hw-eye)" />
</svg>
);
}
/** Spider hanging from the top edge; thread and body bob together. */
function HangingSpider({
left,
length,
animate,
}: {
left: string;
length: string;
animate: boolean;
}) {
return (
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left,
width: 30,
animation: animate ? `${animSpiderBob} 7s ease-in-out infinite` : undefined,
}}
>
<div
style={{
marginLeft: 14.5,
width: 1,
height: length,
background: 'var(--hw-web)',
}}
/>
<div
style={{
marginTop: -4,
transformOrigin: '50% 0',
animation: animate ? `${animSpiderSway} 5s ease-in-out infinite` : undefined,
}}
>
<Spider size={30} />
</div>
</div>
);
}
const BAT_PERIOD = 30; // 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 },
];
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 (
<svg
aria-hidden="true"
viewBox="0 0 60 72"
width={size}
height={size * 1.2}
style={{ display: 'block' }}
>
<path
d="M30 4 C14 4 6 16 6 32 L6 62 L13 55 L20 63 L27 55 L34 63 L41 55 L48 63 L54 56 L54 32 C54 16 46 4 30 4 Z"
fill="oklch(0.98 0.01 290 / 0.55)"
stroke="var(--hw-ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
<ellipse cx="22" cy="28" rx="3.5" ry="5" fill="var(--hw-ink)" />
<ellipse cx="38" cy="28" rx="3.5" ry="5" fill="var(--hw-ink)" />
<ellipse cx="30" cy="41" rx="4" ry="3" fill="var(--hw-ink)" opacity="0.8" />
</svg>
);
}
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 (
<div className={halloweenRoot} aria-hidden="true">
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(180deg, oklch(0.22 0.08 300) 0%, oklch(0.16 0.06 290) 70%, oklch(0.12 0.04 280) 100%)',
}}
/>
{/* Crescent moon */}
<div
style={{
position: 'absolute',
right: '14%',
top: '14%',
width: '30%',
aspectRatio: '1',
borderRadius: '50%',
boxShadow: 'inset -9px 4px 0 0 oklch(0.93 0.06 95), 0 0 18px oklch(0.9 0.08 95 / 0.35)',
}}
/>
{/* The big bat crosses the moon, the classic silhouette. */}
<div style={{ position: 'absolute', left: '52%', top: '22%', width: '34%' }}>
<svg viewBox="0 0 100 50" style={{ width: '100%' }}>
<path d={BAT_PATH} fill="oklch(0.1 0.02 300)" />
</svg>
</div>
<div style={{ position: 'absolute', left: '26%', top: '56%', width: '20%' }}>
<svg viewBox="0 0 100 50" style={{ width: '100%' }}>
<path d={BAT_PATH} fill="oklch(0.1 0.02 300)" />
</svg>
</div>
<svg
viewBox="0 0 100 100"
style={{ position: 'absolute', left: 0, top: 0, width: '42%', overflow: 'visible' }}
>
<path d={WEB} fill="none" stroke="oklch(0.85 0.02 290 / 0.6)" strokeWidth={1.2} />
</svg>
</div>
);
}
return (
<div className={halloweenRoot} aria-hidden="true">
<Cobweb corner="left" size={narrow ? 64 : 88} />
<Cobweb corner="right" size={narrow ? 76 : 108} />
{/* In the empty space under the member list on desktop; near the right
edge on narrow screens. */}
<HangingSpider
left="calc(100% - 64px)"
length={narrow ? '22vh' : '50vh'}
animate={!reduced}
/>
{!reduced &&
BATS.map((bat, i) => (
<div
key={`bat-${i}`}
style={{
position: 'absolute',
left: 0,
top: `${bat.top}%`,
opacity: 0,
willChange: 'transform, opacity',
animation: `${animBatFlight} ${BAT_PERIOD}s linear ${BAT_FIRST + bat.offset}s infinite`,
}}
>
<div style={{ animation: `${animBatBob} ${bat.bob}s ease-in-out infinite` }}>
<Bat width={bat.width} flap={bat.flap} />
</div>
</div>
))}
{!reduced && (
<div
style={{
position: 'absolute',
left: '18%',
top: '78%',
opacity: 0,
willChange: 'transform, opacity',
animation: `${animGhost} ${GHOST_PERIOD}s linear ${GHOST_FIRST}s infinite`,
}}
>
<div
style={{
transformOrigin: '50% 20%',
animation: `${animGhostWobble} 2.4s ease-in-out infinite`,
}}
>
<Ghost size={narrow ? 34 : 44} />
</div>
</div>
)}
</div>
);
}