diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx index 49e3a4394..de42ec775 100644 --- a/src/app/components/seasonal/SeasonalEffect.tsx +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -59,7 +59,7 @@ function buildOverlayContent( ): React.ReactNode { switch (theme) { case 'halloween': - return ; + return ; case 'christmas': return ; case 'newyear': diff --git a/src/app/components/seasonal/themes/Halloween.css.ts b/src/app/components/seasonal/themes/Halloween.css.ts index cd8897e12..110e80265 100644 --- a/src/app/components/seasonal/themes/Halloween.css.ts +++ b/src/app/components/seasonal/themes/Halloween.css.ts @@ -1,56 +1,146 @@ -import { keyframes } from '@vanilla-extract/css'; +import { keyframes, style } from '@vanilla-extract/css'; /** - * Halloween overlay keyframes. Every animation touches ONLY `transform` and - * `opacity` so the compositor can run them on the GPU without layout/paint. - * keyframes() returns the generated animation-name string, applied inline. + * Halloween overlay styles. Motion is transform/opacity only. + * + * [#239] Colours come from two custom properties set on the overlay root so + * the bats/spider stay dark silhouettes on light themes and turn to a pale + * dusk-violet on dark themes (a black bat on a black theme is invisible). */ - -/** Slow breathing of the sickly moon-glow vignette. */ -export const animMoonPulse = keyframes({ - '0%': { transform: 'scale(1)', opacity: '0.55' }, - '50%': { transform: 'scale(1.06)', opacity: '0.8' }, - '100%': { transform: 'scale(1)', opacity: '0.55' }, +export const halloweenRoot = style({ + position: 'absolute', + inset: 0, + vars: { + '--hw-ink': 'oklch(0.24 0.04 300)', + '--hw-web': 'oklch(0.42 0.02 290 / 0.42)', + '--hw-eye': 'oklch(0.72 0.18 55)', + }, + selectors: { + 'body.dark-theme &, body.butter-theme &': { + vars: { + '--hw-ink': 'oklch(0.72 0.06 300)', + '--hw-web': 'oklch(0.85 0.02 290 / 0.36)', + '--hw-eye': 'oklch(0.8 0.17 60)', + }, + }, + }, }); -/** Low fog band: drifts sideways while gently rising and swelling. */ -export const animFogDrift = keyframes({ - '0%': { transform: 'translate3d(-12%, 6%, 0) scale(1.1)', opacity: '0' }, - '15%': { opacity: '0.5' }, - '50%': { transform: 'translate3d(6%, -2%, 0) scale(1.25)', opacity: '0.65' }, - '85%': { opacity: '0.45' }, - '100%': { transform: 'translate3d(18%, 4%, 0) scale(1.1)', opacity: '0' }, +/** A bat group crosses the top of the screen in ~9 s, then a long calm. */ +export const animBatFlight = keyframes({ + '0%': { transform: 'translate3d(-10vw, 0, 0)', opacity: '0' }, + '1%': { opacity: '1' }, + '29%': { opacity: '1' }, + '30%': { transform: 'translate3d(110vw, -6vh, 0)', opacity: '0' }, + '100%': { transform: 'translate3d(110vw, -6vh, 0)', opacity: '0' }, }); -/** A bat flaps slowly across the sky in a shallow arc. */ -export const animBatGlide = keyframes({ - '0%': { transform: 'translate3d(-12vw, 8vh, 0) scale(0.9)', opacity: '0' }, - '10%': { opacity: '0.7' }, - '45%': { transform: 'translate3d(45vw, -4vh, 0) scale(1)' }, - '80%': { transform: 'translate3d(85vw, 6vh, 0) scale(0.95)', opacity: '0.6' }, - '100%': { transform: 'translate3d(112vw, 2vh, 0) scale(0.9)', opacity: '0' }, +/** Up-and-down bobbing on each bat, so the group doesn't move as one block. */ +export const animBatBob = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0)' }, + '50%': { transform: 'translate3d(0, 14px, 0)' }, + '100%': { transform: 'translate3d(0, 0, 0)' }, }); -/** The bat's wings beat — fast vertical squash of the wing element. */ -export const animWingFlap = keyframes({ - '0%': { transform: 'scaleY(1) scaleX(1)' }, - '50%': { transform: 'scaleY(0.35) scaleX(1.08)' }, - '100%': { transform: 'scaleY(1) scaleX(1)' }, +/** Wing beat: the silhouette squashes vertically around the body. */ +export const animBatFlap = keyframes({ + '0%': { transform: 'scaleY(1)' }, + '50%': { transform: 'scaleY(0.62)' }, + '100%': { transform: 'scaleY(1)' }, }); -/** Will-o'-wisp ember: floats upward, swaying, pulsing in brightness. */ -export const animEmberFloat = keyframes({ - '0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' }, - '12%': { opacity: '0.85' }, - '35%': { transform: 'translate3d(14px, -28vh, 0) scale(1)' }, - '65%': { transform: 'translate3d(-12px, -55vh, 0) scale(0.9)', opacity: '0.7' }, - '90%': { opacity: '0.25' }, - '100%': { transform: 'translate3d(8px, -82vh, 0) scale(0.6)', opacity: '0' }, +/** The spider bobs on its thread (thread and spider move together). */ +export const animSpiderBob = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0)' }, + '50%': { transform: 'translate3d(0, 18px, 0)' }, + '100%': { transform: 'translate3d(0, 0, 0)' }, }); -/** Soft twinkle for embers — independent opacity flicker layered on top. */ -export const animEmberTwinkle = keyframes({ - '0%': { opacity: '0.6' }, - '50%': { opacity: '1' }, - '100%': { opacity: '0.6' }, +/** A slow sway of the spider itself on the end of the thread. */ +export const animSpiderSway = keyframes({ + '0%': { transform: 'rotate(-6deg)' }, + '50%': { transform: 'rotate(6deg)' }, + '100%': { transform: 'rotate(-6deg)' }, +}); + +/** + * A small ghost rises from the lower part of the screen, drifting sideways, + * and fades out — ~8 s in a 50 s cycle. + */ +export const animGhost = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0' }, + '3%': { opacity: '0.8' }, + '8%': { transform: 'translate3d(6vw, -20vh, 0) scale(1)' }, + '13%': { opacity: '0.75' }, + '16%': { transform: 'translate3d(-2vw, -42vh, 0) scale(1.05)', opacity: '0' }, + '100%': { transform: 'translate3d(-2vw, -42vh, 0) scale(1.05)', opacity: '0' }, +}); + +/** The ghost's tail wisps, a gentle wobble. */ +export const animGhostWobble = keyframes({ + '0%': { transform: 'rotate(-4deg)' }, + '50%': { transform: 'rotate(4deg)' }, + '100%': { transform: 'rotate(-4deg)' }, +}); + +/** [#239] Will-o'-the-wisp: a slow wandering loop (paired with a pulse). */ +export const animWispWander = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0)' }, + '25%': { transform: 'translate3d(40px, -30px, 0)' }, + '50%': { transform: 'translate3d(-10px, -60px, 0)' }, + '75%': { transform: 'translate3d(-45px, -20px, 0)' }, + '100%': { transform: 'translate3d(0, 0, 0)' }, +}); + +/** Soft breathing glow of a wisp. */ +export const animWispPulse = keyframes({ + '0%': { opacity: '0.35', transform: 'scale(0.85)' }, + '50%': { opacity: '0.9', transform: 'scale(1.1)' }, + '100%': { opacity: '0.35', transform: 'scale(0.85)' }, +}); + +/** A witch on a broom crosses high up, right to left, in ~11 s of 60 s. */ +export const animWitch = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0) rotate(-4deg)', opacity: '0' }, + '1%': { opacity: '1' }, + '9%': { transform: 'translate3d(-60vw, -3vh, 0) rotate(3deg)' }, + '17.5%': { opacity: '1' }, + '18%': { transform: 'translate3d(-118vw, 2vh, 0) rotate(-3deg)', opacity: '0' }, + '100%': { transform: 'translate3d(-118vw, 2vh, 0) rotate(-3deg)', opacity: '0' }, +}); + +/** + * The big spider: bobs for most of the cycle, then climbs up its thread, + * pauses, and drops back with a little bounce (24 s cycle). + */ +export const animSpiderClimb = keyframes({ + '0%': { transform: 'translate3d(0, 0, 0)' }, + '20%': { transform: 'translate3d(0, 14px, 0)' }, + '40%': { transform: 'translate3d(0, 0, 0)' }, + '55%': { transform: 'translate3d(0, -22vh, 0)' }, + '68%': { transform: 'translate3d(0, -22vh, 0)' }, + '71%': { transform: 'translate3d(0, 24px, 0)' }, + '74%': { transform: 'translate3d(0, -8px, 0)' }, + '77%': { transform: 'translate3d(0, 6px, 0)' }, + '80%': { transform: 'translate3d(0, 0, 0)' }, + '100%': { transform: 'translate3d(0, 0, 0)' }, +}); + +/** Jack-o'-lantern candle flicker (opacity of the glow behind the face). */ +export const animCandle = keyframes({ + '0%': { opacity: '0.75' }, + '20%': { opacity: '1' }, + '35%': { opacity: '0.6' }, + '55%': { opacity: '0.95' }, + '70%': { opacity: '0.7' }, + '100%': { opacity: '0.75' }, +}); + +/** The swarm crosses faster than the regular bats: ~7 s of a 60 s cycle. */ +export const animSwarmFlight = keyframes({ + '0%': { transform: 'translate3d(-10vw, 0, 0)', opacity: '0' }, + '0.5%': { opacity: '1' }, + '11.5%': { opacity: '1' }, + '12%': { transform: 'translate3d(110vw, -8vh, 0)', opacity: '0' }, + '100%': { transform: 'translate3d(110vw, -8vh, 0)', opacity: '0' }, }); diff --git a/src/app/components/seasonal/themes/Halloween.tsx b/src/app/components/seasonal/themes/Halloween.tsx index 5502890c3..3b29db4b6 100644 --- a/src/app/components/seasonal/themes/Halloween.tsx +++ b/src/app/components/seasonal/themes/Halloween.tsx @@ -1,267 +1,486 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { SeasonalOverlayProps } from '../types'; import { - animMoonPulse, - animFogDrift, - animBatGlide, - animWingFlap, - animEmberFloat, - animEmberTwinkle, + animBatBob, + animBatFlap, + animBatFlight, + animCandle, + animGhost, + animGhostWobble, + animSpiderBob, + animSpiderClimb, + animSpiderSway, + animSwarmFlight, + animWispPulse, + animWispWander, + animWitch, + halloweenRoot, } 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)'; +/** + * 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. + */ -// 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 = - `` + - `` + - // radial threads - `` + - `` + - `` + - `` + - `` + - // concentric catch-threads (gentle sag via quadratic curves) - `` + - `` + - `` + - `` + - `` + - ``; - return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; -})(); +/** 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(); -// 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() { +function Cobweb({ corner, size }: { corner: 'left' | 'right'; size: number }) { return ( ); } -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, - })), - [], - ); +// 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 ( - <> - {/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */} + + ); +} + +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 ( +