feat(seasonal): Halloween — more going on: wisps, a witch, a swarm (#239)
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
14731f2229
commit
fda6e0f74e
@@ -82,3 +82,65 @@ export const animGhostWobble = keyframes({
|
||||
'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' },
|
||||
});
|
||||
|
||||
@@ -4,10 +4,16 @@ import {
|
||||
animBatBob,
|
||||
animBatFlap,
|
||||
animBatFlight,
|
||||
animCandle,
|
||||
animGhost,
|
||||
animGhostWobble,
|
||||
animSpiderBob,
|
||||
animSpiderClimb,
|
||||
animSpiderSway,
|
||||
animSwarmFlight,
|
||||
animWispPulse,
|
||||
animWispWander,
|
||||
animWitch,
|
||||
halloweenRoot,
|
||||
} from './Halloween.css';
|
||||
|
||||
@@ -134,30 +140,34 @@ function Spider({ size }: { size: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Spider hanging from the top edge; thread and body bob together. */
|
||||
/** 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: 30,
|
||||
animation: animate ? `${animSpiderBob} 7s ease-in-out infinite` : undefined,
|
||||
}}
|
||||
style={{ position: 'absolute', top: 0, left, width: size, animation: motion }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 14.5,
|
||||
marginLeft: size / 2 - 0.5,
|
||||
width: 1,
|
||||
height: length,
|
||||
background: 'var(--hw-web)',
|
||||
@@ -165,18 +175,96 @@ function HangingSpider({
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: -4,
|
||||
marginTop: -size * 0.13,
|
||||
transformOrigin: '50% 0',
|
||||
animation: animate ? `${animSpiderSway} 5s ease-in-out infinite` : undefined,
|
||||
}}
|
||||
>
|
||||
<Spider size={30} />
|
||||
<Spider size={size} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BAT_PERIOD = 30; // s between flights
|
||||
/** 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 },
|
||||
@@ -185,6 +273,20 @@ const BATS = [
|
||||
{ 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;
|
||||
|
||||
@@ -269,7 +371,41 @@ export function HalloweenOverlay({ reduced, preview }: SeasonalOverlayProps) {
|
||||
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) => (
|
||||
@@ -290,6 +426,40 @@ export function HalloweenOverlay({ reduced, preview }: SeasonalOverlayProps) {
|
||||
</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={{
|
||||
|
||||
Reference in New Issue
Block a user