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>
This commit is contained in:
parent
b3753d1ab0
commit
e803c35af9
2 changed files with 910 additions and 15 deletions
895
docs/superpowers/plans/2026-06-19-midsummer-theme.md
Normal file
895
docs/superpowers/plans/2026-06-19-midsummer-theme.md
Normal file
|
|
@ -0,0 +1,895 @@
|
||||||
|
# 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 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.
|
||||||
|
|
@ -92,17 +92,18 @@ future change. The scoped overlay avoids duplication.
|
||||||
a palette), but uses the same frog assets.
|
a palette), but uses the same frog assets.
|
||||||
|
|
||||||
### 5. Jingle — `hooks/useMidsummerSound.ts`
|
### 5. Jingle — `hooks/useMidsummerSound.ts`
|
||||||
- Plays a short *Små grodorna* clip **once** (not looping — a looping jingle
|
- Plays the *Små grodorna* melody **once** (not looping — a looping jingle
|
||||||
on a left-open dashboard is grating).
|
on a left-open dashboard is grating).
|
||||||
- Asset: `static/midsummer/sma-grodorna.mp3` (a short royalty-free / public
|
- **No audio asset**: the melody is synthesized with WebAudio oscillators
|
||||||
clip sourced during implementation; documented in the plan).
|
from the public-domain folk tune (note frequencies in code). This removes
|
||||||
- Browser reality: unmuted audio cannot autoplay before a user gesture, so
|
any licensing question and ships nothing for the service worker to cache.
|
||||||
the clip fires on the **first user interaction (any click) or the moment
|
- Browser reality: WebAudio cannot start before a user gesture, so the tune
|
||||||
the 🐸/🔊 control is used** — never silently on page-paint. A 🔇 control
|
fires on the **first user interaction (any click) or the moment the 🐸/🔊
|
||||||
stops/disables it; preference persisted (`soundOn`).
|
control is used** — never silently on page-paint. A 🔇 control disables
|
||||||
- Single module-level `Audio`/`AudioContext`, reused — no per-play leak (the
|
it; preference persisted (`soundOn`, default on).
|
||||||
audit flagged per-notification `AudioContext` leaks elsewhere; don't repeat
|
- Single module-level `AudioContext`, reused and `resume()`d on gesture — no
|
||||||
that pattern).
|
per-play allocation (the audit flagged per-notification `AudioContext`
|
||||||
|
leaks elsewhere; don't repeat that pattern).
|
||||||
|
|
||||||
## File plan
|
## File plan
|
||||||
|
|
||||||
|
|
@ -114,7 +115,7 @@ New:
|
||||||
- `frontend/src/components/midsummer/MidsummerBanner.tsx`
|
- `frontend/src/components/midsummer/MidsummerBanner.tsx`
|
||||||
- `frontend/src/components/midsummer/FrogToggle.tsx`
|
- `frontend/src/components/midsummer/FrogToggle.tsx`
|
||||||
- `frontend/src/components/midsummer/confetti.ts` (tiny helper)
|
- `frontend/src/components/midsummer/confetti.ts` (tiny helper)
|
||||||
- `static/midsummer/sma-grodorna.mp3` (audio asset)
|
- (no audio asset — jingle is WebAudio-synthesized)
|
||||||
|
|
||||||
Edited:
|
Edited:
|
||||||
- `App.tsx` — wrap in `MidsummerProvider`; import `midsummer.css`.
|
- `App.tsx` — wrap in `MidsummerProvider`; import `midsummer.css`.
|
||||||
|
|
@ -136,10 +137,9 @@ Edited:
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
`bash deploy-frontend.sh && git add static/ && git commit && git push`, then
|
`bash deploy-frontend.sh && git add static/ && git commit && git push`, then
|
||||||
`git pull` on the host (bind-mounted `static/`). No container restart. The
|
`git pull` on the host (bind-mounted `static/`). No container restart. No new
|
||||||
new audio file lives under `static/midsummer/` so it ships with the static
|
runtime assets (jingle is synthesized), so the service worker needs no
|
||||||
bundle; confirm the service worker (`sw.js`) either ignores it or caches it
|
changes.
|
||||||
intentionally.
|
|
||||||
|
|
||||||
## Testing / verification
|
## Testing / verification
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue