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..70ce577ce 100644
--- a/src/app/components/seasonal/themes/Halloween.css.ts
+++ b/src/app/components/seasonal/themes/Halloween.css.ts
@@ -1,56 +1,84 @@
-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)' },
});
diff --git a/src/app/components/seasonal/themes/Halloween.tsx b/src/app/components/seasonal/themes/Halloween.tsx
index 5502890c3..dc3298e81 100644
--- a/src/app/components/seasonal/themes/Halloween.tsx
+++ b/src/app/components/seasonal/themes/Halloween.tsx
@@ -1,267 +1,316 @@
-import React, { useMemo } from 'react';
+import React from 'react';
import { SeasonalOverlayProps } from '../types';
import {
- animMoonPulse,
- animFogDrift,
- animBatGlide,
- animWingFlap,
- animEmberFloat,
- animEmberTwinkle,
+ animBatBob,
+ animBatFlap,
+ animBatFlight,
+ animGhost,
+ animGhostWobble,
+ animSpiderBob,
+ animSpiderSway,
+ 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 =
- ``;
- 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 bob together. */
+function HangingSpider({
+ left,
+ length,
+ animate,
+}: {
+ left: string;
+ length: string;
+ animate: boolean;
+}) {
+ return (
+
-
- {/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
-
- {/* ── Low drifting fog bands ── */}
-
- {fogBands.map((f, i) => (
-
- ))}
+
+
+ );
+}
- {/* ── Will-o'-wisps / floating embers ── */}
- {embers.map((e, i) => (
+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 (
+
+ );
+}
+
+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 (
+
-
+ />
+ {/* Crescent moon */}
+
+ {/* The big bat crosses the moon, the classic silhouette. */}
+
+
- ))}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* In the empty space under the member list on desktop; near the right
+ edge on narrow screens. */}
+
- {/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
{!reduced &&
- bats.map((b, i) => (
+ BATS.map((bat, i) => (