feat(seasonal): Halloween redesign — webs, a spider, bats, a ghost; no tint (#239)
CI / Build & Quality Checks (pull_request) Successful in 1m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Canceled after 31s
CI / Build & Quality Checks (pull_request) Successful in 1m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Canceled after 31s
Draft for human review (#239 must be approved and closed by a human). - Removed the full-screen purple wash, fog and the blurred green "moon" that sat over the member list and made it unreadable. - Corner cobwebs (generated radial threads + sagging rings), thin and readable in both themes, smaller on phones. - A spider on a thread in the empty space under the member list (near the top on phones), bobbing and swaying. - Every 30 s a group of four bats flaps across the top of the screen (a real bat silhouette; wing beat without flattening them into dashes). - Every 50 s a small, see-through ghost rises and fades (outlined so it shows on light themes; text stays readable through it). - Theme-aware ink via --hw-ink/--hw-web: dark silhouettes on light themes, pale dusk-violet on dark ones. - Reduced motion: webs and the spider at rest. Settings swatch: night sky, crescent moon with a bat across it, a corner web. Rendered and inspected: light/dark/phone at the bat and ghost moments, reduced on desktop and phone, the Settings swatch; 60 fps on vs off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6bb3f2af27
commit
14731f2229
@@ -59,7 +59,7 @@ function buildOverlayContent(
|
||||
): React.ReactNode {
|
||||
switch (theme) {
|
||||
case 'halloween':
|
||||
return <HalloweenOverlay reduced={reduced} />;
|
||||
return <HalloweenOverlay reduced={reduced} preview={preview} />;
|
||||
case 'christmas':
|
||||
return <ChristmasOverlay reduced={reduced} />;
|
||||
case 'newyear':
|
||||
|
||||
@@ -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)' },
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
`<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)}")`;
|
||||
})();
|
||||
/** 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 (
|
||||
<svg
|
||||
width="46"
|
||||
height="22"
|
||||
viewBox="0 0 46 22"
|
||||
aria-hidden="true"
|
||||
style={{ display: 'block', overflow: 'visible' }}
|
||||
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
|
||||
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"
|
||||
d={WEB}
|
||||
fill="none"
|
||||
stroke="var(--hw-web)"
|
||||
strokeWidth={0.9}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</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,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
// 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 ── */}
|
||||
<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
|
||||
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,
|
||||
marginLeft: 14.5,
|
||||
width: 1,
|
||||
height: length,
|
||||
background: 'var(--hw-web)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── 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',
|
||||
marginTop: -4,
|
||||
transformOrigin: '50% 0',
|
||||
animation: animate ? `${animSpiderSway} 5s ease-in-out infinite` : undefined,
|
||||
}}
|
||||
>
|
||||
{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`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Spider size={30} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* ── 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 (
|
||||
<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
|
||||
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`,
|
||||
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%)',
|
||||
}}
|
||||
>
|
||||
<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`,
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
{/* 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}
|
||||
/>
|
||||
|
||||
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
|
||||
{!reduced &&
|
||||
bats.map((b, i) => (
|
||||
BATS.map((bat, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
key={`bat-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${b.top}%`,
|
||||
left: 0,
|
||||
top: `${bat.top}%`,
|
||||
opacity: 0,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
|
||||
animation: `${animBatFlight} ${BAT_PERIOD}s linear ${BAT_FIRST + bat.offset}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform: `scale(${b.scale})`,
|
||||
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
|
||||
}}
|
||||
>
|
||||
<BatSilhouette />
|
||||
<div style={{ animation: `${animBatBob} ${bat.bob}s ease-in-out infinite` }}>
|
||||
<Bat width={bat.width} flap={bat.flap} />
|
||||
</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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user