MosswartOverlord/docs/superpowers/plans/2026-06-19-midsummer-theme.md
Erik e803c35af9 docs(plan): Sma Grodorna midsummer theme implementation plan (+ spec: WebAudio jingle)
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>
2026-06-19 09:22:54 +02:00

895 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 in `MidsummerProvider`, import `midsummer.css`.
- `frontend/src/components/map/MapView.tsx` — mount `<Maypole>` inside `.ml-map-group`.
- `frontend/src/components/map/MapLayout.tsx` — mount `<MidsummerBanner>`, call `useMidsummerSound()`.
- `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`:
```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:
```css
/* 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:
```tsx
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**
```bash
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`:
```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**
```bash
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: `FrogToggle` imports `playSmaGrodorna` from `useMidsummerSound`, 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`:
```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`:
```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:
```tsx
import { FrogToggle } from '../midsummer/FrogToggle';
```
Then add `<FrogToggle />` as the first child inside the `<div className="ml-tool-links">` (immediately before the `🤖 Assistant` span):
```tsx
<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**
```bash
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`:
```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`:
```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:
```tsx
import { Maypole } from '../midsummer/Maypole';
```
Then, inside the `{imgSize.w > 0 && ( … )}` block, add `<Maypole>` as the last layer after `<PortalMarkers … />`:
```tsx
<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**
```bash
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`:
```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**
```bash
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`:
```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`:
```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`:
```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:
```tsx
import { MidsummerBanner } from '../midsummer/MidsummerBanner';
```
Add `<MidsummerBanner />` as the first child inside `<div className="ml-layout">`:
```tsx
<div className="ml-layout">
<MidsummerBanner />
<Sidebar
```
- [ ] **Step 5: Mount the banner on the dashboard page**
In `frontend/src/components/PlayerDashboardFullPage.tsx`, add the import:
```tsx
import { MidsummerBanner } from './midsummer/MidsummerBanner';
```
Add `<MidsummerBanner />` as the first child inside the `<div className="ml-dashboard-page">`:
```tsx
<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**
```bash
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:
```tsx
<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`:
```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-layout` bounce 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**
```bash
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:
```ts
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:
```tsx
import { useMidsummerSound } from '../../hooks/useMidsummerSound';
```
Inside `MapLayout`, call the hook near the top of the component body (e.g. right after `const getColor = usePlayerColors();`):
```tsx
const getColor = usePlayerColors();
useMidsummerSound();
```
- [ ] **Step 3: Call the hook on the dashboard page**
In `frontend/src/components/PlayerDashboardFullPage.tsx`, add the import:
```tsx
import { useMidsummerSound } from '../hooks/useMidsummerSound';
```
Call it near the top of the `PlayerDashboardFullPage` component body (e.g. right after `const data = useLiveData();`):
```tsx
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**
```bash
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**
```bash
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 12), 🐸 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.