9-task plan with complete code for the frog/maypole theme: scoped CSS overlay, useMidsummer provider, dancing maypole, crown/frog dots, banner + confetti, frog-hop easter egg, WebAudio jingle. Spec updated to synthesize the jingle (no mp3 asset / licensing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
30 KiB
Midsummer "Små grodorna" Theme Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A full-takeover Swedish-midsummer frog/maypole theme for the Overlord React dashboard, toggled per browser (default on), with a dancing maypole, frog + flower-crown player dots, a Glad midsommar banner + confetti, a frog-hop easter egg replacing the rickroll, and a WebAudio-synthesized Små grodorna jingle.
Architecture: A data-midsummer attribute on <html> gates a scoped CSS overlay (midsummer.css, all rules under :root[data-midsummer]) layered over the untouched base map-layout.css. A MidsummerProvider/useMidsummer context holds the on/off + sound state in localStorage. Dynamic pieces (maypole, banner, confetti, toggle, jingle) are small React components/hooks gated by the flag; palette, crowns and the hop are pure CSS.
Tech Stack: React 19 + Vite + TypeScript, plain CSS, WebAudio API. No new dependencies, no audio asset.
Testing note: This repo has no automated frontend test runner (verification is build + manual browser checks, per the repo's own docs). Each task therefore verifies via npm run build and a dev-server browser check rather than a unit-test runner. Run the dev server once up front: cd frontend && npm run dev (Vite on :5173, /api proxied to :8765) and keep it open across tasks.
File structure
New files:
frontend/src/hooks/useMidsummer.tsx— context + provider +useMidsummer()hook (state, persistence, attribute).frontend/src/hooks/useMidsummerSound.ts— WebAudio jingle synth + first-gesture hook.frontend/src/styles/midsummer.css— entire scoped theme overlay (palette, maypole, dots, banner, confetti, hop).frontend/src/components/midsummer/Maypole.tsx— the dancing maypole (mounted in the map group).frontend/src/components/midsummer/MidsummerBanner.tsx— banner + first-load confetti.frontend/src/components/midsummer/FrogToggle.tsx— 🐸 theme + 🔊 sound toggle links.frontend/src/components/midsummer/confetti.ts— DOM confetti burst helper.
Modified files:
frontend/src/App.tsx— wrap inMidsummerProvider, importmidsummer.css.frontend/src/components/map/MapView.tsx— mount<Maypole>inside.ml-map-group.frontend/src/components/map/MapLayout.tsx— mount<MidsummerBanner>, calluseMidsummerSound().frontend/src/components/PlayerDashboardFullPage.tsx— same banner + sound for the new-tab dashboard.frontend/src/components/sidebar/SidebarWindowButtons.tsx— add<FrogToggle/>.frontend/src/components/map/Sidebar.tsx— replace rickroll easter egg with frog-hop.
Task 1: Theme state — provider, hook, attribute
Files:
-
Create:
frontend/src/hooks/useMidsummer.tsx -
Create (empty):
frontend/src/styles/midsummer.css -
Modify:
frontend/src/App.tsx -
Step 1: Create the context/provider/hook
Create frontend/src/hooks/useMidsummer.tsx:
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);
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;
if (enabled) el.setAttribute('data-midsummer', '');
else el.removeAttribute('data-midsummer');
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 }}>
{children}
</Ctx.Provider>
);
};
export function useMidsummer(): MidsummerCtx {
const c = useContext(Ctx);
if (!c) throw new Error('useMidsummer must be used within MidsummerProvider');
return c;
}
- Step 2: Create the (empty) overlay stylesheet
Create frontend/src/styles/midsummer.css with a single header comment so the import resolves:
/* Midsummer "Små grodorna" theme overlay. All rules scoped under
:root[data-midsummer] so they only apply when the theme is on. */
- Step 3: Wrap the app in the provider and import the stylesheet
Replace the entire contents of frontend/src/App.tsx with:
import { MapLayout } from './components/map/MapLayout';
import { PlayerDashboardFullPage } from './components/PlayerDashboardFullPage';
import { MidsummerProvider } from './hooks/useMidsummer';
import { useLiveData } from './hooks/useLiveData';
import './styles/map-layout.css';
import './styles/midsummer.css';
/**
* Single SPA entry. Branches on `?view=` query param:
* /?view=dashboard → fullscreen PlayerDashboardFullPage (new-tab target)
* / → default map + sidebar layout
*/
export default function App() {
const view = new URLSearchParams(window.location.search).get('view');
return (
<MidsummerProvider>
{view === 'dashboard' ? <PlayerDashboardFullPage /> : <DefaultApp />}
</MidsummerProvider>
);
}
/** Default map-and-sidebar layout. Split out so the dashboard tab doesn't
* spin up useLiveData twice for the same render. */
function DefaultApp() {
const data = useLiveData();
return <MapLayout data={data} />;
}
- Step 4: Verify build + attribute toggling
Run: cd frontend && npm run build
Expected: build succeeds, no TS errors.
In the dev server browser console, run document.documentElement.hasAttribute('data-midsummer') → expect true (default on). Run localStorage.setItem('mo-midsummer','off') then reload → expect false. Set back to 'on'.
- Step 5: Commit
git add frontend/src/hooks/useMidsummer.tsx frontend/src/styles/midsummer.css frontend/src/App.tsx
git commit -m "feat(midsummer): theme state provider + data-midsummer attribute"
Task 2: Base palette overlay (pond-green takeover)
Files:
-
Modify:
frontend/src/styles/midsummer.css -
Step 1: Append the palette overlay
Append to frontend/src/styles/midsummer.css:
:root[data-midsummer] .ml-sidebar {
background: #0a1f16;
border-right: 2px solid #1c5a2c;
}
:root[data-midsummer] .ml-map-container {
background: #0e2a1e;
}
:root[data-midsummer] .ml-sidebar-title {
color: #7ed957;
text-shadow: 0 0 6px rgba(126, 217, 87, 0.35);
}
:root[data-midsummer] .ml-tool-link {
color: #bfe9a8;
}
:root[data-midsummer] .ml-tool-link:hover {
color: #eafbe0;
}
:root[data-midsummer] .ml-server-status,
:root[data-midsummer] .ml-counters,
:root[data-midsummer] .ml-player-row {
border-color: #1c5a2c;
}
:root[data-midsummer] .ml-player-row.ml-player-selected {
background: rgba(126, 217, 87, 0.14);
outline: 1px solid rgba(126, 217, 87, 0.5);
}
:root[data-midsummer] .ml-sort-btn,
:root[data-midsummer] .ml-btn {
border-color: #2c6e36;
}
- Step 2: Verify in browser
Reload the dev server with the theme on. Expect: sidebar turns deep pond-green, title turns lime with a glow, map background darkens to forest green, tool links go pale green. Toggle data-midsummer off in console → expect the original dark theme returns exactly.
Run: cd frontend && npm run build → expect success.
- Step 3: Commit
git add frontend/src/styles/midsummer.css
git commit -m "feat(midsummer): pond-green palette overlay for sidebar and map"
Task 3: 🐸 toggle (+ 🔊 sound) in the sidebar
Files:
- Create:
frontend/src/components/midsummer/FrogToggle.tsx - Modify:
frontend/src/components/sidebar/SidebarWindowButtons.tsx
Note:
FrogToggleimportsplaySmaGrodornafromuseMidsummerSound, which is created in Task 8. To keep tasks independently buildable, this task includes a minimal stub of that module; Task 8 replaces the stub with the full synth. If executing in order, create the stub now.
- Step 1: Create the jingle module stub (replaced fully in Task 8)
Create frontend/src/hooks/useMidsummerSound.ts:
// Stub — replaced with the full WebAudio synth in Task 8.
export function playSmaGrodorna(): void {}
- Step 2: Create the toggle component
Create frontend/src/components/midsummer/FrogToggle.tsx:
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
import { playSmaGrodorna } from '../../hooks/useMidsummerSound';
/** 🐸 theme toggle + 🔊 jingle toggle, rendered among the sidebar tool links. */
export const FrogToggle: React.FC = () => {
const { enabled, toggle, soundOn, toggleSound } = 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>
)}
</>
);
};
- Step 3: Mount it in the sidebar tool links
In frontend/src/components/sidebar/SidebarWindowButtons.tsx, add the import at the top:
import { FrogToggle } from '../midsummer/FrogToggle';
Then add <FrogToggle /> as the first child inside the <div className="ml-tool-links"> (immediately before the 🤖 Assistant span):
<div className="ml-tool-links">
<FrogToggle />
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('agent', 'Overlord Assistant')}>🤖 Assistant</span>
- Step 4: Verify
Run: cd frontend && npm run build → expect success.
In the browser: the sidebar shows 🐸 Midsommar on and 🔊 Jingle. Click 🐸 → theme turns off, label becomes Midsommar off, the 🔊 Jingle link disappears, and the page reverts to the base dark theme. Click again → back on, preference survives reload.
- Step 5: Commit
git add frontend/src/components/midsummer/FrogToggle.tsx frontend/src/components/sidebar/SidebarWindowButtons.tsx frontend/src/hooks/useMidsummerSound.ts
git commit -m "feat(midsummer): sidebar frog toggle + jingle toggle (sound stubbed)"
Task 4: Dancing maypole on the map
Files:
-
Create:
frontend/src/components/midsummer/Maypole.tsx -
Modify:
frontend/src/styles/midsummer.css -
Modify:
frontend/src/components/map/MapView.tsx -
Step 1: Create the Maypole component
Create frontend/src/components/midsummer/Maypole.tsx:
import React from 'react';
import { useMidsummer } from '../../hooks/useMidsummer';
interface Props {
imgW: number;
imgH: number;
}
// Kept small for perf — these orbit the pole via one CSS animation.
const FROG_COUNT = 6;
// Default: dead centre of the Dereth map image. To plant at a landmark,
// import { worldToPx } from '../../utils/coordinates' and compute from
// world coords instead.
const center = (imgW: number, imgH: number) => ({ x: imgW / 2, y: imgH / 2 });
/**
* Midsommarstång planted inside the map's pan/zoom group, so it scales and
* pans with the world automatically. Carries its own ring of dancing frogs
* (one CSS rotation) so the spectacle is independent of who is online.
*/
export const Maypole: React.FC<Props> = ({ imgW, imgH }) => {
const { enabled } = useMidsummer();
if (!enabled || imgW === 0) return null;
const { x, y } = center(imgW, imgH);
return (
<div className="ms-maypole" style={{ left: x, top: y }} aria-hidden="true">
<div className="ms-maypole-pole" />
<div className="ms-maypole-ring">
{Array.from({ length: FROG_COUNT }).map((_, i) => (
<span
key={i}
className="ms-frog"
style={{ transform: `rotate(${(360 / FROG_COUNT) * i}deg) translateY(-40px)` }}
>
🐸
</span>
))}
</div>
</div>
);
};
- Step 2: Append maypole styles
Append to frontend/src/styles/midsummer.css:
.ms-maypole {
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 6;
}
.ms-maypole-pole {
position: absolute;
left: -2px;
top: -64px;
width: 4px;
height: 70px;
background: #6b4f2a;
border-radius: 2px;
}
.ms-maypole-pole::before {
content: '';
position: absolute;
top: 4px;
left: -16px;
width: 36px;
height: 4px;
background: #3b6d11;
border-radius: 2px;
}
.ms-maypole-pole::after {
content: '🌼';
position: absolute;
top: -16px;
left: -8px;
font-size: 16px;
line-height: 1;
}
.ms-maypole-ring {
position: absolute;
left: 0;
top: -30px;
width: 0;
height: 0;
animation: ms-spin 12s linear infinite;
}
.ms-frog {
position: absolute;
font-size: 13px;
line-height: 1;
}
@keyframes ms-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.ms-maypole-ring { animation: none; }
}
- Step 3: Mount the maypole inside the map group
In frontend/src/components/map/MapView.tsx, add the import near the other layer imports:
import { Maypole } from '../midsummer/Maypole';
Then, inside the {imgSize.w > 0 && ( … )} block, add <Maypole> as the last layer after <PortalMarkers … />:
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
<Maypole imgW={imgSize.w} imgH={imgSize.h} />
- Step 4: Verify
Run: cd frontend && npm run build → expect success.
In the browser with the theme on: a maypole with a flower on top and 6 frogs orbiting it sits at the centre of the Dereth map. Pan and zoom the map → the maypole stays pinned to the same map location and scales with the world. Toggle theme off → maypole disappears. In DevTools, emulate prefers-reduced-motion: reduce → frogs stop orbiting (pole still shown).
- Step 5: Commit
git add frontend/src/components/midsummer/Maypole.tsx frontend/src/styles/midsummer.css frontend/src/components/map/MapView.tsx
git commit -m "feat(midsummer): dancing maypole pinned to map centre"
Task 5: Frog + flower-crown player dots
Files:
-
Modify:
frontend/src/styles/midsummer.css -
Step 1: Append dot decoration styles
Append to frontend/src/styles/midsummer.css:
:root[data-midsummer] .ml-dot {
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%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 15px;
line-height: 1;
pointer-events: none;
}
- Step 2: Verify
Run: cd frontend && npm run build → expect success.
In the browser with players online: every dot wears a small flower crown above it. Click a dot to select it → a frog appears on it (the base blink animation still runs). Toggle theme off → dots return to plain markers.
(If no players are online in the dev environment, point the dev server's /api proxy at the live backend or verify against production after deploy — the CSS is data-independent.)
- Step 3: Commit
git add frontend/src/styles/midsummer.css
git commit -m "feat(midsummer): flower-crown dots, frog on selected"
Task 6: Glad midsummer banner + confetti
Files:
-
Create:
frontend/src/components/midsummer/confetti.ts -
Create:
frontend/src/components/midsummer/MidsummerBanner.tsx -
Modify:
frontend/src/styles/midsummer.css -
Modify:
frontend/src/components/map/MapLayout.tsx -
Modify:
frontend/src/components/PlayerDashboardFullPage.tsx -
Step 1: Create the confetti helper
Create frontend/src/components/midsummer/confetti.ts:
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);
}
- Step 2: Create the banner component
Create frontend/src/components/midsummer/MidsummerBanner.tsx:
import React, { useEffect } 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. */
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">
🐸 Glad midsommar! 🌼 Små grodorna, små grodorna… 🥂
</div>
);
};
- Step 3: Append banner + confetti styles
Append to frontend/src/styles/midsummer.css:
.ms-banner {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 50;
margin-top: 6px;
padding: 4px 16px;
border-radius: 14px;
background: rgba(20, 64, 31, 0.92);
border: 1px solid #7ed957;
color: #eafbe0;
font-size: 0.8rem;
white-space: nowrap;
pointer-events: none;
}
.ms-confetti {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 999998;
}
.ms-confetti span {
position: absolute;
top: -32px;
line-height: 1;
animation: ms-fall 3.6s linear forwards;
}
@keyframes ms-fall {
to { transform: translateY(112vh) rotate(360deg); opacity: 0.25; }
}
- Step 4: Mount the banner in the map layout
In frontend/src/components/map/MapLayout.tsx, add the import:
import { MidsummerBanner } from '../midsummer/MidsummerBanner';
Add <MidsummerBanner /> as the first child inside <div className="ml-layout">:
<div className="ml-layout">
<MidsummerBanner />
<Sidebar
- Step 5: Mount the banner on the dashboard page
In frontend/src/components/PlayerDashboardFullPage.tsx, add the import:
import { MidsummerBanner } from './midsummer/MidsummerBanner';
Add <MidsummerBanner /> as the first child inside the <div className="ml-dashboard-page">:
<div className="ml-dashboard-page">
<MidsummerBanner />
<header className="ml-dashboard-header">
- Step 6: Verify
Run: cd frontend && npm run build → expect success.
In the browser with the theme on, on first load of a fresh tab: a "Glad midsummer!" banner shows at the top and a one-shot emoji confetti burst falls once. Reload in the same tab → banner persists, confetti does NOT re-fire (sessionStorage guard). Open a new tab → confetti fires again. Toggle theme off → banner disappears.
- Step 7: Commit
git add frontend/src/components/midsummer/confetti.ts frontend/src/components/midsummer/MidsummerBanner.tsx frontend/src/styles/midsummer.css frontend/src/components/map/MapLayout.tsx frontend/src/components/PlayerDashboardFullPage.tsx
git commit -m "feat(midsummer): glad midsommar banner + one-shot confetti"
Task 7: Frog-hop easter egg (replaces the rickroll)
Files:
-
Modify:
frontend/src/components/map/Sidebar.tsx -
Modify:
frontend/src/styles/midsummer.css -
Step 1: Replace the rickroll onClick with the frog-hop
In frontend/src/components/map/Sidebar.tsx, replace the entire <span className="ml-sidebar-title" …> element (the title click handler that currently builds the /rick.mp4 overlay, roughly lines 62-80) with:
<span className="ml-sidebar-title" style={{ cursor: 'pointer' }} onClick={() => {
// 🐸 Små grodorna hop — bounce the whole layout and send frogs
// leaping up the screen. Replaces the old rickroll.
const layout = document.querySelector('.ml-layout') as HTMLElement | null;
if (layout) {
layout.classList.remove('ms-hop');
void layout.offsetWidth; // force reflow so the animation restarts
layout.classList.add('ms-hop');
}
const frogs = document.createElement('div');
frogs.className = 'ms-hop-frogs';
for (let i = 0; i < 9; i++) {
const f = document.createElement('span');
f.textContent = '🐸';
f.style.left = (i * 11 + 3) + 'vw';
f.style.animationDelay = (i * 0.07).toFixed(2) + 's';
frogs.appendChild(f);
}
document.body.appendChild(frogs);
window.setTimeout(() => {
layout?.classList.remove('ms-hop');
frogs.remove();
}, 2600);
}}>Active Mosswart Enjoyers ({players.length})</span>
- Step 2: Append the hop styles
Append to frontend/src/styles/midsummer.css:
.ml-layout.ms-hop {
animation: ms-bounce 0.6s ease-in-out 3;
}
@keyframes ms-bounce {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-16px); }
40% { transform: translateY(0); }
60% { transform: translateY(-9px); }
80% { transform: translateY(0); }
}
.ms-hop-frogs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999999;
}
.ms-hop-frogs span {
position: absolute;
bottom: -40px;
font-size: 34px;
line-height: 1;
animation: ms-hop-up 2.4s ease-in forwards;
}
@keyframes ms-hop-up {
to { bottom: 114vh; transform: rotate(18deg); }
}
@media (prefers-reduced-motion: reduce) {
.ml-layout.ms-hop { animation: none; }
.ms-hop-frogs span { animation-duration: 0.01s; }
}
The hop classes are NOT scoped under
:root[data-midsummer]on purpose — the easter egg works regardless of the theme toggle (it's a gag, not a palette). The.ml-layoutbounce class is applied directly by the click handler.
- Step 3: Verify the rickroll is gone
Run: cd frontend && npm run build → expect success.
In the browser, open DevTools Network, click the "Active Mosswart Enjoyers" title: expect the whole layout to bounce and ~9 frogs to leap up the screen, then clean up after ~2.6s. Confirm NO request for /rick.mp4 is made. Click again → it re-triggers cleanly without stacking.
- Step 4: Commit
git add frontend/src/components/map/Sidebar.tsx frontend/src/styles/midsummer.css
git commit -m "feat(midsummer): frog-hop easter egg replaces the rickroll"
Task 8: Små grodorna jingle (WebAudio synth)
Files:
-
Modify (replace stub):
frontend/src/hooks/useMidsummerSound.ts -
Modify:
frontend/src/components/map/MapLayout.tsx -
Modify:
frontend/src/components/PlayerDashboardFullPage.tsx -
Step 1: Replace the stub with the full synth + gesture hook
Replace the entire contents of frontend/src/hooks/useMidsummerSound.ts with:
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]);
}
- Step 2: Call the hook in the map layout
In frontend/src/components/map/MapLayout.tsx, add the import:
import { useMidsummerSound } from '../../hooks/useMidsummerSound';
Inside MapLayout, call the hook near the top of the component body (e.g. right after const getColor = usePlayerColors();):
const getColor = usePlayerColors();
useMidsummerSound();
- Step 3: Call the hook on the dashboard page
In frontend/src/components/PlayerDashboardFullPage.tsx, add the import:
import { useMidsummerSound } from '../hooks/useMidsummerSound';
Call it near the top of the PlayerDashboardFullPage component body (e.g. right after const data = useLiveData();):
const data = useLiveData();
useMidsummerSound();
- Step 4: Verify
Run: cd frontend && npm run build → expect success.
In a fresh tab with theme + sound on: the Små grodorna phrase plays once on your first click anywhere. Reload → it does NOT replay (sessionStorage guard). Click 🔊 Jingle to mute → label becomes 🔇; click again to unmute → it plays immediately as confirmation. Toggle theme off then on → no audio-context errors in console across repeated toggles.
- Step 5: Commit
git add frontend/src/hooks/useMidsummerSound.ts frontend/src/components/map/MapLayout.tsx frontend/src/components/PlayerDashboardFullPage.tsx
git commit -m "feat(midsummer): WebAudio Sma grodorna jingle, plays once on first gesture"
Task 9: Build, deploy, verify in production
Files: none (deploy only)
- Step 1: Full production build via the deploy script
From the repo root:
Run: bash deploy-frontend.sh
Expected: it runs npm run build, copies _build/ into static/, removes _build/. No errors.
- Step 2: Commit the built static assets and push
git add static/ frontend/
git commit -m "build(midsummer): deploy Sma grodorna theme to static bundle"
git push origin master
- Step 3: Pull on the host (bind-mounted static, no restart)
Run: ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"
Expected: fast-forward updating static/.
- Step 4: Verify in production
Hard-refresh https://overlord.snakedesert.se/ (Ctrl+Shift+R). Expect: pond-green theme on by default, maypole dancing at map centre, crowned dots, banner + confetti on first load, jingle on first click, 🐸 Midsommar on toggle in the sidebar. Toggle off → base dark theme returns. Open /?view=dashboard → banner shows there too. Click the sidebar title → frog-hop, no /rick.mp4 request.
- Step 5: Confirmation
No further commit. Report the deployed state to the user and confirm the 🐸 toggle defaults on.
Self-review (completed during planning)
- Spec coverage: scoped overlay + state (Tasks 1–2), 🐸 toggle default-on (Task 3), dancing maypole at map centre (Task 4), frog/crown dots (Task 5), banner + confetti (Task 6), frog-hop replacing rickroll (Task 7), play-once unmuted WebAudio jingle with 🔇 control (Task 8), dashboard-page parity (Tasks 6 & 8), deploy (Task 9). All spec sections map to a task.
- Type consistency:
useMidsummer()returns{ enabled, toggle, soundOn, toggleSound }— consumed identically in FrogToggle, Maypole, MidsummerBanner, useMidsummerSound.playSmaGrodorna()is stubbed in Task 3 and fully defined in Task 8 with the same signature(): void.burstConfetti(count?)and CSS class names (ms-maypole,ms-banner,ms-confetti,ms-hop,ms-hop-frogs,ms-frog) are consistent between the TS that adds them and the CSS that styles them. - Placeholder scan: every code step contains complete code; no TBD/TODO.
- Decisions honored: maypole at map centre (constant), jingle plays once, toggle default on, no auto-date-gating.