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

30 KiB
Raw Blame History

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:

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: 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:

// 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-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
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 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.