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,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]);
}