Files
cinny/src/app/components/seasonal/themes/Halloween.tsx
T
jaredandClaude Fable 5.1 a6ddafb446 fix(seasonal): Halloween fog layers drop willChange under reduced motion
The three fog bands pinned compositor layers even when their animation was
'none' (always the case in the settings swatch). Match the sibling layers.

Fixes #73

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:04:55 -04:00

268 lines
8.8 KiB
TypeScript

import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animMoonPulse,
animFogDrift,
animBatGlide,
animWingFlap,
animEmberFloat,
animEmberTwinkle,
} from './Halloween.css';
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
const cobwebUri = (() => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
// radial threads
`<line x1='0' y1='0' x2='180' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='90'/>` +
`<line x1='0' y1='0' x2='90' y2='180'/>` +
`<line x1='0' y1='0' x2='180' y2='40'/>` +
`<line x1='0' y1='0' x2='40' y2='180'/>` +
// concentric catch-threads (gentle sag via quadratic curves)
`<path d='M40 0 Q22 22 0 40'/>` +
`<path d='M85 0 Q48 48 0 85'/>` +
`<path d='M130 0 Q74 74 0 130'/>` +
`<path d='M180 0 Q104 104 0 180'/>` +
`<path d='M180 60 Q120 120 60 180'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
// glide while an inner element flaps independently — we re-use one body shape.
function BatSilhouette() {
return (
<svg
width="46"
height="22"
viewBox="0 0 46 22"
aria-hidden="true"
style={{ display: 'block', overflow: 'visible' }}
>
<path
fill="oklch(0.12 0.04 300 / 0.85)"
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
/>
</svg>
);
}
export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic per-mount generation — never per-frame React state.
const embers = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => {
const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
return {
left: (i * 6151 + 113) % 100,
bottom: (i * 3137 + 47) % 28, // start near floor
size: 3 + (i % 4),
duration: 11 + (i % 6) * 2.2,
delay: (i * 0.83) % 11,
twinkle: 2.4 + (i % 5) * 0.6,
color: green ? TOXIC_GREEN : EMBER_ORANGE,
};
}),
[],
);
const bats = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
top: 8 + i * 13,
duration: 22 + i * 7,
delay: i * 6.5,
flap: 0.5 + i * 0.12,
scale: 0.7 + i * 0.18,
})),
[],
);
const fogBands = useMemo(
() =>
Array.from({ length: 3 }, (_, i) => ({
bottom: -6 + i * 9,
duration: 26 + i * 8,
delay: i * 5,
height: 130 + i * 30,
})),
[],
);
return (
<>
{/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundColor: 'transparent',
backgroundImage: [
// sickly moon glow, upper-right
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
// cold counter-glow lower-left for depth
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
// overall indigo→black wash, darker toward edges (vignette)
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
].join(', '),
opacity: 0.5,
}}
/>
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
right: '12%',
width: '160px',
height: '160px',
borderRadius: '50%',
willChange: 'transform, opacity',
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
filter: 'blur(2px)',
backdropFilter: 'saturate(1.15)',
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
}}
/>
{/* ── Low drifting fog bands ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
overflow: 'hidden',
}}
>
{fogBands.map((f, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: '-15%',
right: '-15%',
bottom: `${f.bottom}%`,
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: reduced ? undefined : 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
? 'none'
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
}}
/>
))}
</div>
{/* ── Will-o'-wisps / floating embers ── */}
{embers.map((e, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
bottom: `${e.bottom}%`,
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(0.9)' : undefined,
opacity: reduced ? 0.4 : undefined,
animation: reduced
? 'none'
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
width: `${e.size}px`,
height: `${e.size}px`,
borderRadius: '50%',
backgroundColor: e.color,
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
animation: reduced
? 'none'
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
}}
/>
</div>
))}
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
{!reduced &&
bats.map((b, i) => (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: `${b.top}%`,
left: 0,
willChange: 'transform, opacity',
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
}}
>
<div
aria-hidden="true"
style={{
transform: `scale(${b.scale})`,
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
}}
>
<BatSilhouette />
</div>
</div>
))}
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
opacity: 0.7,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
right: 0,
width: '180px',
height: '180px',
backgroundImage: cobwebUri,
backgroundRepeat: 'no-repeat',
transform: 'scaleX(-1)',
opacity: 0.7,
}}
/>
</>
);
}