CI / Build & Quality Checks (pull_request) Successful in 1m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m39s
Jared felt the first redesign was too sparse for a full theme: most of the time only two webs and a spider were on screen. - Will-o'-the-wisps: six soft green/orange glows wandering and pulsing, always present while animating (not in the still scene, where static glows would sit on text). - A witch on a broom crosses just below the header about once a minute (redrawn so she reads as a seated figure, not a blob). - Bats every 20 s (was 30), plus a faster swarm of eight every 60 s. - The big spider now climbs up its thread and drops back with a bounce. - A second, small spider on the left web; a tiny jack-o'-lantern with a flickering candle hangs by the right web (desktop). Rendered and inspected at the witch/swarm moments in light, dark and phone, plus reduced; still 60 fps on vs off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
487 lines
15 KiB
TypeScript
487 lines
15 KiB
TypeScript
import React from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import {
|
|
animBatBob,
|
|
animBatFlap,
|
|
animBatFlight,
|
|
animCandle,
|
|
animGhost,
|
|
animGhostWobble,
|
|
animSpiderBob,
|
|
animSpiderClimb,
|
|
animSpiderSway,
|
|
animSwarmFlight,
|
|
animWispPulse,
|
|
animWispWander,
|
|
animWitch,
|
|
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 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 (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{ position: 'absolute', top: 0, left, width: size, animation: motion }}
|
|
>
|
|
<div
|
|
style={{
|
|
marginLeft: size / 2 - 0.5,
|
|
width: 1,
|
|
height: length,
|
|
background: 'var(--hw-web)',
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
marginTop: -size * 0.13,
|
|
transformOrigin: '50% 0',
|
|
animation: animate ? `${animSpiderSway} 5s ease-in-out infinite` : undefined,
|
|
}}
|
|
>
|
|
<Spider size={size} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Small jack-o'-lantern hanging on a short thread, candle flickering. */
|
|
function HangingLantern({ right, length, size }: { right: number; length: number; size: number }) {
|
|
return (
|
|
<div aria-hidden="true" style={{ position: 'absolute', top: 0, right, width: size }}>
|
|
<div
|
|
style={{
|
|
marginLeft: size / 2 - 0.5,
|
|
width: 1,
|
|
height: length,
|
|
background: 'var(--hw-web)',
|
|
}}
|
|
/>
|
|
<svg viewBox="0 0 60 56" width={size} height={size * 0.93} style={{ display: 'block' }}>
|
|
<path
|
|
d="M30 2 C31 6 33 8 36 9"
|
|
stroke="oklch(0.45 0.08 140)"
|
|
strokeWidth="3"
|
|
fill="none"
|
|
strokeLinecap="round"
|
|
/>
|
|
<path
|
|
d="M30 10 C18 8 4 16 4 32 C4 46 16 54 30 54 C44 54 56 46 56 32 C56 16 42 8 30 10 Z"
|
|
fill="oklch(0.66 0.17 50)"
|
|
/>
|
|
<path
|
|
d="M30 11 C24 20 24 44 30 53 M30 11 C36 20 36 44 30 53"
|
|
stroke="oklch(0.55 0.15 45)"
|
|
strokeWidth="1.5"
|
|
fill="none"
|
|
/>
|
|
<g style={{ animation: `${animCandle} 1.6s ease-in-out infinite` }}>
|
|
<path d="M14 26 L22 22 L22 30 Z M46 26 L38 22 L38 30 Z" fill="oklch(0.93 0.14 95)" />
|
|
<path
|
|
d="M14 38 C20 46 40 46 46 38 L41 41 L37 38 L33 42 L30 38 L27 42 L23 38 L19 41 Z"
|
|
fill="oklch(0.93 0.14 95)"
|
|
/>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Witch on a broom, flying left (viewBox 0 0 120 60).
|
|
const WITCH_PATH = [
|
|
'M4 42 L80 37 L80 40 L4 45 Z', // broom handle
|
|
'M80 34 C88 32 98 28 108 24 C104 32 106 38 116 46 C104 44 92 42 80 42 Z', // bristles
|
|
'M32 20 C36 17 50 16 58 19 L55 21 C50 19 40 20 35 22 Z', // hat brim
|
|
'M40 19 L64 2 L52 19 Z', // hat cone, leaning back
|
|
'M39 25 A5 5 0 1 0 49 25 A5 5 0 1 0 39 25 Z', // head
|
|
'M39 24 L34 26 L39 27 Z', // nose
|
|
'M40 29 C44 28 52 28 56 30 C64 30 72 28 80 24 C76 30 70 34 62 38 L44 38 C40 36 38 32 40 29 Z', // cloak
|
|
'M42 31 L32 37 L34 39 L44 34 Z', // arm on the broom
|
|
'M48 38 L44 50 L38 52 L38 54 L47 53 L52 39 Z', // leg and boot
|
|
].join(' ');
|
|
|
|
function Witch({ width }: { width: number }) {
|
|
return (
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 120 60"
|
|
width={width}
|
|
height={width / 2}
|
|
style={{ display: 'block' }}
|
|
>
|
|
<path d={WITCH_PATH} fill="var(--hw-ink)" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const WISPS = [
|
|
{ left: 12, top: 62, size: 30, hue: 'oklch(0.82 0.19 145)', wander: 26, pulse: 3.2, delay: -3 },
|
|
{ left: 34, top: 80, size: 24, hue: 'oklch(0.8 0.16 60)', wander: 31, pulse: 2.6, delay: -11 },
|
|
{ left: 55, top: 48, size: 28, hue: 'oklch(0.82 0.19 145)', wander: 28, pulse: 3.8, delay: -7 },
|
|
{ left: 72, top: 72, size: 34, hue: 'oklch(0.8 0.16 60)', wander: 34, pulse: 2.9, delay: -19 },
|
|
{ left: 88, top: 55, size: 24, hue: 'oklch(0.82 0.19 145)', wander: 24, pulse: 3.4, delay: -5 },
|
|
{ left: 46, top: 30, size: 22, hue: 'oklch(0.8 0.16 60)', wander: 29, pulse: 2.4, delay: -15 },
|
|
];
|
|
|
|
const BAT_PERIOD = 20; // 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 },
|
|
];
|
|
|
|
// Every 60 s a bigger, faster swarm of small bats, offset from the regular group.
|
|
const SWARM_PERIOD = 60;
|
|
const SWARM_FIRST = 38;
|
|
const SWARM = Array.from({ length: 8 }, (_, i) => ({
|
|
top: 4 + ((i * 7) % 22),
|
|
width: 22 + (i % 3) * 8,
|
|
offset: (i * 0.23) % 1.4,
|
|
bob: 0.9 + (i % 3) * 0.25,
|
|
flap: 0.18 + (i % 3) * 0.03,
|
|
}));
|
|
|
|
const WITCH_PERIOD = 60;
|
|
const WITCH_FIRST = 22;
|
|
|
|
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}
|
|
climb
|
|
/>
|
|
{/* A second, smaller spider on the left web. */}
|
|
<HangingSpider
|
|
left={narrow ? '30px' : '46px'}
|
|
length={narrow ? '9vh' : '13vh'}
|
|
animate={!reduced}
|
|
size={20}
|
|
/>
|
|
{!narrow && <HangingLantern right={126} length={30} size={26} />}
|
|
|
|
{/* Will-o'-the-wisps: soft glows wandering slowly, always present while
|
|
animating. Not in the still scene: static glows would sit on text. */}
|
|
{!reduced &&
|
|
WISPS.map((w, i) => (
|
|
<div
|
|
key={`wisp-${i}`}
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${w.left}%`,
|
|
top: `${w.top}%`,
|
|
animation: `${animWispWander} ${w.wander}s ease-in-out ${w.delay}s infinite`,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: w.size,
|
|
height: w.size,
|
|
borderRadius: '50%',
|
|
background: `radial-gradient(circle, oklch(0.97 0.05 100) 0%, ${w.hue} 22%, ${w.hue.replace(')', ' / 0.35)')} 48%, transparent 72%)`,
|
|
animation: `${animWispPulse} ${w.pulse}s ease-in-out ${w.delay}s infinite`,
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{!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 &&
|
|
SWARM.map((bat, i) => (
|
|
<div
|
|
key={`swarm-${i}`}
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: `${bat.top}%`,
|
|
opacity: 0,
|
|
willChange: 'transform, opacity',
|
|
animation: `${animSwarmFlight} ${SWARM_PERIOD}s linear ${SWARM_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',
|
|
right: '-14vw',
|
|
top: narrow ? '11%' : '12%',
|
|
opacity: 0,
|
|
willChange: 'transform, opacity',
|
|
animation: `${animWitch} ${WITCH_PERIOD}s linear ${WITCH_FIRST}s infinite`,
|
|
}}
|
|
>
|
|
<Witch width={narrow ? 84 : 120} />
|
|
</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>
|
|
);
|
|
}
|