feat(midsummer): rain of flowers/frogs/Swedish flags, dots become frogs, drop jingle

Per request: remove the WebAudio jingle (+ its 🔊 toggle and sound state);
replace the one-shot confetti with a continuous rain of 🌼🌸🐸🇸🇪🌿 over the
screen (MidsummerRain, gated by the theme, reduced-motion aware, leak-free);
and replace player-dot markers with frogs themselves (override the inline
dot color/border) instead of a flower-crown on top. Still toggled by the
🐸 Midsommar switch. Includes rebuilt static bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-19 09:47:39 +02:00
parent 7141a38c5c
commit d86bc48862
24 changed files with 129 additions and 202 deletions

View file

@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import { useLiveData } from '../hooks/useLiveData';
import { useMidsummerSound } from '../hooks/useMidsummerSound';
import { PlayerDashboardContent } from './windows/PlayerDashboardWindow';
import { MidsummerBanner } from './midsummer/MidsummerBanner';
import { MidsummerRain } from './midsummer/MidsummerRain';
/**
* Fullscreen "Player Dashboard" page rendered when the React app loads
@ -16,7 +16,6 @@ import { MidsummerBanner } from './midsummer/MidsummerBanner';
*/
export const PlayerDashboardFullPage: React.FC = () => {
const data = useLiveData();
useMidsummerSound();
const [version, setVersion] = useState('');
// Set tab title.
@ -41,6 +40,7 @@ export const PlayerDashboardFullPage: React.FC = () => {
return (
<div className="ml-dashboard-page">
<MidsummerBanner />
<MidsummerRain />
<header className="ml-dashboard-header">
<span className="ml-dashboard-title">👥 Player Dashboard</span>
<span className="ml-dashboard-count">{count} online</span>

View file

@ -7,8 +7,8 @@ import { WindowRenderer } from '../windows/WindowRenderer';
import { RareNotification } from '../effects/RareNotification';
import { DeathNotification } from '../effects/DeathNotification';
import { usePlayerColors } from '../../hooks/usePlayerColors';
import { useMidsummerSound } from '../../hooks/useMidsummerSound';
import { MidsummerBanner } from '../midsummer/MidsummerBanner';
import { MidsummerRain } from '../midsummer/MidsummerRain';
import type { DashboardState } from '../../hooks/useLiveData';
interface Props {
@ -17,7 +17,6 @@ interface Props {
export const MapLayout: React.FC<Props> = ({ data }) => {
const getColor = usePlayerColors();
useMidsummerSound();
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPortals, setShowPortals] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<string | null>(null);
@ -45,6 +44,7 @@ export const MapLayout: React.FC<Props> = ({ data }) => {
<WindowManagerProvider>
<div className="ml-layout">
<MidsummerBanner />
<MidsummerRain />
<Sidebar
players={players}
vitals={vitalsMap}

View file

@ -1,34 +1,17 @@
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
import { playSmaGrodorna } from '../../hooks/useMidsummerSound';
/** 🐸 theme toggle + 🔊 jingle toggle, rendered among the sidebar tool links. */
/** 🐸 midsummer theme toggle, rendered among the sidebar tool links. */
export const FrogToggle: React.FC = () => {
const { enabled, toggle, soundOn, toggleSound } = useMidsummer();
const { enabled, toggle } = useMidsummer();
return (
<>
<span
className="ml-tool-link"
style={{ cursor: 'pointer' }}
title={enabled ? 'Turn off the midsummer theme' : 'Turn on the midsummer theme'}
onClick={toggle}
>
🐸 Midsommar {enabled ? 'on' : 'off'}
</span>
{enabled && (
<span
className="ml-tool-link"
style={{ cursor: 'pointer' }}
title={soundOn ? 'Mute the Små grodorna jingle' : 'Unmute the Små grodorna jingle'}
onClick={() => {
const turningOn = !soundOn;
toggleSound();
if (turningOn) playSmaGrodorna(); // this click is a user gesture
}}
>
{soundOn ? '🔊' : '🔇'} Jingle
</span>
)}
</>
<span
className="ml-tool-link"
style={{ cursor: 'pointer' }}
title={enabled ? 'Turn off the midsummer theme' : 'Turn on the midsummer theme'}
onClick={toggle}
>
🐸 Midsommar {enabled ? 'on' : 'off'}
</span>
);
};

View file

@ -1,23 +1,9 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
import { burstConfetti } from './confetti';
const CONFETTI_FLAG = 'mo-midsummer-confetti';
/** Festive top strip + a one-shot confetti burst on the first load of a
* session while the theme is on. */
/** Festive top strip shown while the theme is on. */
export const MidsummerBanner: React.FC = () => {
const { enabled } = useMidsummer();
useEffect(() => {
if (!enabled) return;
if (sessionStorage.getItem(CONFETTI_FLAG)) return;
sessionStorage.setItem(CONFETTI_FLAG, '1');
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
burstConfetti();
}
}, [enabled]);
if (!enabled) return null;
return (
<div className="ms-banner" role="status">

View file

@ -0,0 +1,44 @@
import React, { useEffect } from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
// Flowers, frogs and Swedish flags drifting down over the screen.
const PIECES = ['🐸', '🌼', '🌸', '🇸🇪', '🌿'];
/**
* Continuous gentle rain of flowers, frogs and Swedish flags while the theme
* is on. Pure DOM + CSS (no React re-renders) so it doesn't fight the map's
* high-frequency telemetry updates. Cleans up its layer, interval and any
* in-flight pieces when the theme is toggled off or the component unmounts.
* Skipped entirely under prefers-reduced-motion.
*/
export const MidsummerRain: React.FC = () => {
const { enabled } = useMidsummer();
useEffect(() => {
if (!enabled) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const layer = document.createElement('div');
layer.className = 'ms-rain';
document.body.appendChild(layer);
const spawn = () => {
const piece = document.createElement('span');
piece.textContent = PIECES[Math.floor(Math.random() * PIECES.length)];
piece.style.left = Math.floor(Math.random() * 100) + 'vw';
const dur = 6 + Math.random() * 5; // 611s to fall
piece.style.animationDuration = dur.toFixed(2) + 's';
piece.style.fontSize = 14 + Math.floor(Math.random() * 16) + 'px';
layer.appendChild(piece);
window.setTimeout(() => piece.remove(), dur * 1000 + 250);
};
const id = window.setInterval(spawn, 450);
return () => {
window.clearInterval(id);
layer.remove();
};
}, [enabled]);
return null;
};

View file

@ -1,17 +0,0 @@
const EMOJIS = ['🐸', '🌼', '🌸', '🥂', '🌿'];
/** One-shot falling-emoji burst. Self-removes after the animation. */
export function burstConfetti(count = 28): void {
const layer = document.createElement('div');
layer.className = 'ms-confetti';
for (let i = 0; i < count; i++) {
const p = document.createElement('span');
p.textContent = EMOJIS[i % EMOJIS.length];
p.style.left = Math.floor(Math.random() * 100) + 'vw';
p.style.animationDelay = (Math.random() * 0.6).toFixed(2) + 's';
p.style.fontSize = 12 + Math.floor(Math.random() * 14) + 'px';
layer.appendChild(p);
}
document.body.appendChild(layer);
window.setTimeout(() => layer.remove(), 4200);
}

View file

@ -1,13 +1,10 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const KEY = 'mo-midsummer';
const SOUND_KEY = 'mo-midsummer-sound';
interface MidsummerCtx {
enabled: boolean;
toggle: () => void;
soundOn: boolean;
toggleSound: () => void;
}
const Ctx = createContext<MidsummerCtx | null>(null);
@ -15,7 +12,6 @@ const Ctx = createContext<MidsummerCtx | null>(null);
export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Default ON: only the literal "off" disables it.
const [enabled, setEnabled] = useState<boolean>(() => localStorage.getItem(KEY) !== 'off');
const [soundOn, setSoundOn] = useState<boolean>(() => localStorage.getItem(SOUND_KEY) !== 'off');
useEffect(() => {
const el = document.documentElement;
@ -24,15 +20,10 @@ export const MidsummerProvider: React.FC<{ children: React.ReactNode }> = ({ chi
localStorage.setItem(KEY, enabled ? 'on' : 'off');
}, [enabled]);
useEffect(() => {
localStorage.setItem(SOUND_KEY, soundOn ? 'on' : 'off');
}, [soundOn]);
const toggle = useCallback(() => setEnabled(e => !e), []);
const toggleSound = useCallback(() => setSoundOn(s => !s), []);
return (
<Ctx.Provider value={{ enabled, toggle, soundOn, toggleSound }}>
<Ctx.Provider value={{ enabled, toggle }}>
{children}
</Ctx.Provider>
);

View file

@ -1,64 +0,0 @@
import { useEffect } from 'react';
import { useMidsummer } from './useMidsummer';
const JINGLE_FLAG = 'mo-midsummer-jingle';
let ctx: AudioContext | null = null;
// Public-domain "Små grodorna" opening phrase (approximation), as
// [frequencyHz, durationSeconds]. Cheerful major-key triangle tones.
const MELODY: [number, number][] = [
[392, 0.22], [392, 0.22], [392, 0.22], [440, 0.22], [494, 0.42],
[494, 0.22], [440, 0.22], [494, 0.22], [523, 0.22], [587, 0.5],
];
/** Play the jingle once. Safe to call from any user gesture. No-op if
* WebAudio is unavailable. Reuses a single AudioContext (no leak). */
export function playSmaGrodorna(): void {
try {
const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
if (!ctx) ctx = new AC();
if (ctx.state === 'suspended') void ctx.resume();
let t = ctx.currentTime + 0.05;
for (const [freq, dur] of MELODY) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.18, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(t);
osc.stop(t + dur);
t += dur;
}
} catch {
/* audio not available — ignore */
}
}
/** Plays the jingle once per session, on the first user gesture, when the
* theme and sound are both on. Browsers block audio before a gesture, so
* we wait for the first pointerdown/keydown. */
export function useMidsummerSound(): void {
const { enabled, soundOn } = useMidsummer();
useEffect(() => {
if (!enabled || !soundOn) return;
if (sessionStorage.getItem(JINGLE_FLAG)) return;
const fire = () => {
sessionStorage.setItem(JINGLE_FLAG, '1');
playSmaGrodorna();
cleanup();
};
const cleanup = () => {
window.removeEventListener('pointerdown', fire);
window.removeEventListener('keydown', fire);
};
window.addEventListener('pointerdown', fire);
window.addEventListener('keydown', fire);
return cleanup;
}, [enabled, soundOn]);
}

View file

@ -83,20 +83,15 @@
.ms-maypole-ring { animation: none; }
}
/* Replace each player marker with a frog (instead of the coloured dot). The
inline backgroundColor + base border on .ml-dot are overridden so only the
frog shows; the base blink animation on the selected dot still applies. */
:root[data-midsummer] .ml-dot {
background: transparent !important;
border: none !important;
overflow: visible;
}
:root[data-midsummer] .ml-dot::before {
content: '🌸';
position: absolute;
left: 50%;
top: -9px;
transform: translateX(-50%);
font-size: 9px;
line-height: 1;
pointer-events: none;
}
:root[data-midsummer] .ml-dot.ml-dot-selected::after {
content: '🐸';
position: absolute;
left: 50%;
@ -106,6 +101,10 @@
line-height: 1;
pointer-events: none;
}
:root[data-midsummer] .ml-dot.ml-dot-selected::before {
font-size: 21px;
filter: drop-shadow(0 0 3px #7ed957);
}
.ms-banner {
position: absolute;
@ -123,21 +122,26 @@
white-space: nowrap;
pointer-events: none;
}
.ms-confetti {
/* Continuous rain of flowers / frogs / Swedish flags (MidsummerRain). Each
piece sets its own animation-duration inline; pointer-events:none so it
never blocks the UI underneath. */
.ms-rain {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 999998;
z-index: 9000;
}
.ms-confetti span {
.ms-rain span {
position: absolute;
top: -32px;
top: -40px;
line-height: 1;
animation: ms-fall 3.6s linear forwards;
animation-name: ms-fall;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@keyframes ms-fall {
to { transform: translateY(112vh) rotate(360deg); opacity: 0.25; }
to { transform: translateY(112vh) rotate(360deg); opacity: 0.3; }
}
.ml-layout.ms-hop {