diff --git a/party/static/party/menu.js b/party/static/party/menu.js
new file mode 100644
index 0000000..237f173
--- /dev/null
+++ b/party/static/party/menu.js
@@ -0,0 +1,164 @@
+/* Spilmenuen på storskærmen (/host/): et kort pr. spil fra registret (party/games.py), og en intro med trin,
+ spillets egne indstillinger, Start og Tilbage. Fjernbetjening/tastatur: pilene flytter fokus mellem det, man
+ kan vælge, Enter/OK vælger, Tilbage/Escape går til menuen. Mus og touch virker som altid.
+ Eksporterer window.WPPMenu = {render, mount}. */
+(function () {
+ 'use strict';
+ var WPP = window.WPP, esc = WPP.esc;
+
+ function facts(g) {
+ var players = g.min_players === g.max_players ? g.min_players : g.min_players + '-' + g.max_players;
+ return '👥 ' + players + ' spillere⏱ ca. ' + esc(g.minutes) + ' min';
+ }
+
+ function menuScreen(games) {
+ return 'Weirsøe Party Protocol
Vælg et spil
' +
+ '' + games.map(function (g, i) {
+ return '';
+ }).join('') + '';
+ }
+
+ function introScreen(g, values) {
+ var sets = (g.settings || []).map(function (s) {
+ var cur = values[s.name] != null ? values[s.name] : s.default;
+ var hint = s.options.filter(function (o) { return o.value === cur; })[0];
+ return '
' + esc(s.label) + '' +
+ s.options.map(function (o) {
+ return '';
+ }).join('') + '
' + (hint && hint.hint ? '
' + esc(hint.hint) + '
' : '') + '
';
+ }).join('');
+ /* nøgler pr. del: et skift af indstilling tegner kun den række igen (ingen ny indgangsanimation) */
+ return '' +
+ ' + ')
' +
+ '
' + WPP.brandHtml(g.name) + '
' + esc(g.tagline) + '
' +
+ '
' + facts(g) + '
' +
+ '' + g.steps.map(function (s, i) { return '- ' + esc(s) + '
'; }).join('') + '
' +
+ (sets ? '' + sets + '
' : '') +
+ '' +
+ '
';
+ }
+
+ /* view = {screen: 'menu'} eller {screen: 'intro', key, values} */
+ function render(root, view) {
+ var games = WPP.games, g = view.screen === 'intro' && WPP.game(view.key);
+ var sh = WPP.shell(root);
+ sh.screen.className = 'screen mn mn-' + (g ? 'intro' : 'menu');
+ WPP.patch(sh.screen, '' +
+ (g ? introScreen(g, view.values || {}) : menuScreen(games)) + '');
+ }
+
+ /* pilene: nærmeste valgbare element i den retning (målt fra midten), så både kort i et gitter og knapper
+ i rækker virker uden at vide, hvordan skærmen er bygget */
+ function move(root, key) {
+ var items = Array.prototype.slice.call(root.querySelectorAll('[data-nav]'));
+ if (!items.length) return;
+ var cur = document.activeElement;
+ if (items.indexOf(cur) < 0) { items[0].focus(); return; }
+ var r = cur.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
+ var dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[key];
+ var best = null, bestScore = Infinity;
+ items.forEach(function (el) {
+ if (el === cur) return;
+ var b = el.getBoundingClientRect(), dx = b.left + b.width / 2 - cx, dy = b.top + b.height / 2 - cy;
+ var along = dx * dir[0] + dy * dir[1], across = Math.abs(dx * dir[1] - dy * dir[0]);
+ if (along <= 1) return;
+ var score = along + across * 2;
+ if (score < bestScore) { bestScore = score; best = el; }
+ });
+ if (best) best.focus();
+ }
+
+ var BACK_KEYS = { Escape: 1, Backspace: 1, GoBack: 1, BrowserBack: 1 };
+ var BACK_CODES = { 461: 1, 10009: 1 }; /* fjernbetjeningens Tilbage på nogle TV'er */
+
+ function mount(root) {
+ var form = document.getElementById('mn-form');
+ var view = { screen: 'menu' };
+
+ function focusFirst(sel) {
+ var el = root.querySelector(sel) || root.querySelector('[data-nav]');
+ if (el) el.focus();
+ }
+ function speak(key) {
+ WPP.voice.stop();
+ if (!window.fetch) return;
+ fetch('/host/intro/' + encodeURIComponent(key), { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : {}; })
+ .then(function (a) { if (a && a.url && view.screen === 'intro' && view.key === key) WPP.voice.now([a.url]); })
+ .catch(function () { /* uden stemme */ });
+ }
+ function show(next, push) {
+ var from = view;
+ view = next;
+ render(root, view);
+ if (view.screen === 'intro') {
+ if (push) try { history.pushState({ intro: view.key }, '', '#' + view.key); } catch (e) { /* */ }
+ if (from.screen !== 'intro' || from.key !== view.key) speak(view.key);
+ focusFirst(view.focusSet ? '[data-set="' + view.focusSet + '"][aria-pressed=true]' : '[data-start]');
+ } else {
+ WPP.voice.stop();
+ focusFirst(from.key ? '[data-pick="' + from.key + '"]' : null);
+ }
+ }
+ function back() {
+ if (view.screen !== 'intro') return;
+ if (history.state && history.state.intro) history.back(); else show({ screen: 'menu' });
+ }
+ function start() {
+ var g = WPP.game(view.key);
+ if (!g || !form) return;
+ WPP.voice.stop();
+ form.querySelector('[name=game]').value = g.key;
+ Array.prototype.forEach.call(form.querySelectorAll('[data-setting]'), function (el) { el.parentNode.removeChild(el); });
+ (g.settings || []).forEach(function (s) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden'; inp.name = s.name; inp.setAttribute('data-setting', '');
+ inp.value = view.values[s.name] != null ? view.values[s.name] : s.default;
+ form.appendChild(inp);
+ });
+ root.classList.add('mn-busy');
+ form.submit();
+ }
+
+ root.addEventListener('click', function (e) {
+ var b = e.target.closest('button');
+ if (!b || !root.contains(b)) return;
+ if (b.hasAttribute('data-pick')) show({ screen: 'intro', key: b.getAttribute('data-pick'), values: {} }, true);
+ else if (b.hasAttribute('data-back')) back();
+ else if (b.hasAttribute('data-start')) start();
+ else if (b.hasAttribute('data-set')) {
+ var values = Object.assign({}, view.values);
+ values[b.getAttribute('data-set')] = b.getAttribute('data-val');
+ show({ screen: 'intro', key: view.key, values: values, focusSet: b.getAttribute('data-set') });
+ }
+ });
+ window.addEventListener('keydown', function (e) {
+ if (e.key && e.key.indexOf('Arrow') === 0) { e.preventDefault(); move(root, e.key); return; }
+ if (view.screen === 'intro' && (BACK_KEYS[e.key] || BACK_CODES[e.keyCode])) { e.preventDefault(); back(); }
+ });
+ window.addEventListener('popstate', function (e) {
+ var key = (e.state && e.state.intro) || (location.hash || '').slice(1);
+ if (WPP.game(key)) show({ screen: 'intro', key: key, values: {} });
+ else show({ screen: 'menu' });
+ });
+ /* bfcache: kommer man tilbage fra lobbyen, må siden ikke stå som "starter…" */
+ window.addEventListener('pageshow', function () { root.classList.remove('mn-busy'); });
+
+ var key = (location.hash || '').slice(1);
+ if (WPP.game(key)) {
+ try { history.replaceState({ intro: key }, '', '#' + key); } catch (e) { /* */ }
+ show({ screen: 'intro', key: key, values: {} });
+ } else show({ screen: 'menu' });
+ }
+
+ window.WPPMenu = { render: render, mount: mount };
+ if (!window.WPP_HARNESS) {
+ var go = function () { var r = document.getElementById('menu'); if (r) mount(r); };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go();
+ }
+})();
diff --git a/party/static/party/party.css b/party/static/party/party.css
index 49d40fc..c53808e 100644
--- a/party/static/party/party.css
+++ b/party/static/party/party.css
@@ -900,21 +900,8 @@ button, input { font-family: inherit; }
.rl-row b { display: block; }
.rl-row span { font-size: .95rem; color: var(--muted); }
-/* ---------- nyt spil: vælg spørgsmålspakke ---------- */
-.new-game { display: flex; flex-direction: column; align-items: center; gap: 20px; }
-.new-game .games { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }
-.packs { display: flex; gap: 8px; margin: 0; padding: 0; border: 0; flex-wrap: wrap; justify-content: center; }
-.packs legend { width: 100%; margin-bottom: 8px; color: var(--muted); font-weight: 600; }
-.packs label { cursor: pointer; }
-.packs input { position: absolute; opacity: 0; pointer-events: none; }
-.packs span { display: inline-block; padding: 10px 20px; border-radius: 999px; font-weight: 700; background: var(--card); box-shadow: inset 0 0 0 2px var(--card-b); }
-.packs input:checked + span { background: var(--yellow); color: var(--navy); box-shadow: 0 4px 0 var(--yellow-d); }
-.packs input:focus-visible + span { outline: 3px solid #fff; outline-offset: 2px; }
-.more-packs { width: 100%; margin-top: 6px; }
-.more-packs summary { cursor: pointer; color: var(--muted); font-size: .95rem; list-style: none; }
-.more-packs summary::-webkit-details-marker { display: none; }
-.more-packs[open] summary { margin-bottom: 8px; }
-.pack-hint { margin: 8px 0 0; font-size: .9rem; color: var(--muted); }
+/* ---------- nyt spil uden JavaScript: én startknap pr. spil ---------- */
+.new-game { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; }
/* fest: tår */
.rv-res .sips { display: flex; flex-wrap: wrap; justify-content: center; gap: .6em; font-size: 1.35em; margin-top: .4em; animation: rise .4s .7s both; }
@@ -1009,3 +996,51 @@ button, input { font-family: inherit; }
.tg-end.survived .stamp { color: var(--green); border-color: var(--green); }
.tg-end.survived .tg-vsbox { border-color: var(--green); }
@media (prefers-reduced-motion: reduce) { .tg-bigicon, .tg-entry.now, .tg-intro, .tg-theme { animation: none; } }
+
+/* ---------- spilmenuen (/host/): kort pr. spil og intro; fjernbetjening, tastatur og mus ---------- */
+.mn { min-height: 100vh; min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 32px 24px; }
+.mn-main { width: 100%; max-width: 1400px; }
+.mn-head { text-align: center; margin-bottom: clamp(20px, 4vh, 48px); }
+.mn-head h1 { margin: 0; font-size: clamp(2rem, 5vw, 4.4rem); line-height: 1; text-shadow: 0 6px 0 rgba(0, 0, 0, .35); }
+.mn-head p { margin: 10px 0 0; font-size: clamp(1.2rem, 2.4vw, 2rem); color: var(--muted); font-weight: 600; }
+.mn-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: clamp(16px, 3vw, 40px); }
+.mn-card { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: clamp(20px, 3vw, 40px) 24px; border: 0; border-radius: 28px;
+ background: var(--card); box-shadow: inset 0 0 0 3px var(--card-b), 0 10px 30px rgba(0, 0, 0, .35); color: var(--ink); font: inherit; cursor: pointer;
+ transition: transform .15s ease, box-shadow .15s ease, background .15s ease; animation: mn-in .4s ease both; animation-delay: calc(var(--i, 0) * 80ms); }
+.mn-card img { width: clamp(90px, 12vw, 160px); height: auto; filter: drop-shadow(0 6px 0 rgba(0, 0, 0, .3)); }
+.mn-name { font-size: clamp(1.8rem, 3.6vw, 3.2rem); font-weight: 700; line-height: 1; }
+.mn-name .b-og, .mn-iname .b-og { font-size: .6em; }
+.mn-tag { font-size: clamp(1rem, 1.6vw, 1.4rem); color: var(--muted); text-align: center; margin: 0; }
+.mn-facts { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin: 0; }
+.mn-fact { padding: 6px 14px; border-radius: 999px; background: var(--faint); font-weight: 600; font-size: clamp(.95rem, 1.3vw, 1.15rem); }
+.mn-card:hover, .mn-card:focus { background: rgba(255, 255, 255, .16); transform: translateY(-4px) scale(1.02); outline: none;
+ box-shadow: inset 0 0 0 4px var(--yellow), 0 16px 40px rgba(0, 0, 0, .45); }
+.mn-intro { display: flex; flex-direction: column; gap: clamp(16px, 3vh, 32px); max-width: 1100px; margin: 0 auto; animation: mn-in .35s ease both; }
+.mn-intro-top { display: flex; gap: 28px; align-items: center; }
+.mn-intro-top img { width: clamp(90px, 12vw, 160px); height: auto; flex: none; }
+.mn-iname { margin: 0 0 6px; font-size: clamp(2.2rem, 5vw, 4.4rem); line-height: 1; }
+.mn-intro .mn-tag { text-align: left; margin-bottom: 10px; }
+.mn-intro .mn-facts { justify-content: flex-start; }
+.mn-steps { margin: 0; padding: 0; list-style: none; counter-reset: st; display: grid; gap: 12px; }
+.mn-steps li { counter-increment: st; display: flex; gap: 16px; align-items: center; padding: 14px 20px; border-radius: 18px; background: var(--card);
+ font-size: clamp(1.1rem, 2vw, 1.7rem); font-weight: 600; animation: mn-in .4s ease both; animation-delay: calc(200ms + var(--i, 0) * 120ms); }
+.mn-steps li::before { content: counter(st); flex: none; width: 1.8em; height: 1.8em; border-radius: 50%; display: grid; place-items: center;
+ background: var(--yellow); color: var(--navy); font-weight: 700; }
+.mn-sets { display: grid; gap: 12px; }
+.mn-set { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; }
+.mn-set-l { color: var(--muted); font-weight: 600; min-width: 7em; }
+.mn-opts { display: flex; flex-wrap: wrap; gap: 8px; }
+.mn-opt { padding: 10px 20px; border: 0; border-radius: 999px; font: inherit; font-weight: 700; color: var(--ink); background: var(--card);
+ box-shadow: inset 0 0 0 2px var(--card-b); cursor: pointer; }
+.mn-opt[aria-pressed=true] { background: var(--yellow); color: var(--navy); box-shadow: 0 4px 0 var(--yellow-d); }
+.mn-opt:focus { outline: 3px solid #fff; outline-offset: 3px; }
+.mn-hint { width: 100%; margin: 0; color: var(--muted); font-size: .95rem; }
+.mn-actions { display: flex; gap: 16px; justify-content: flex-end; align-items: center; flex-wrap: wrap; }
+.mn-actions .btn-xl { width: auto; }
+.mn-back { background: var(--card); color: var(--ink); box-shadow: inset 0 0 0 2px var(--card-b); }
+.mn-actions .btn:focus { outline: 4px solid #fff; outline-offset: 4px; }
+.mn-busy .mn-main { opacity: .6; pointer-events: none; }
+.mn-who { position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; color: var(--muted); margin: 0; }
+@keyframes mn-in { from { opacity: 0; transform: translateY(14px); } }
+@media (max-width: 640px) { .mn-who { position: static; padding-bottom: 12px; } .mn-intro-top { flex-direction: column; text-align: center; } .mn-intro .mn-tag { text-align: center; } .mn-intro .mn-facts { justify-content: center; } }
+@media (prefers-reduced-motion: reduce) { .mn-card, .mn-intro, .mn-steps li { animation: none; } }
diff --git a/party/templates/party/host_new.html b/party/templates/party/host_new.html
index a528660..c65c6b8 100644
--- a/party/templates/party/host_new.html
+++ b/party/templates/party/host_new.html
@@ -1,29 +1,25 @@
{% extends "party/base.html" %}
-{% block title %}Weirsøe Party Protocol{% endblock %}
+{% load static %}
+{% block body_class %}page-menu{% endblock %}
{% block content %}
-
- Weirsøe Party Protocol
- Fup og Fakta
-
+
+
+{% if username %}Logget ind som {{ username }}
{% endif %}
+{% endblock %}
+{% block scripts %}
+{{ assets|json_script:"wpp-assets" }}
+{{ games|json_script:"wpp-games" }}
+
+
{% endblock %}
diff --git a/party/tests/test_games.py b/party/tests/test_games.py
index 46a21de..d20e1af 100644
--- a/party/tests/test_games.py
+++ b/party/tests/test_games.py
@@ -112,3 +112,34 @@ class StateNamesTheGameTests(TransactionTestCase):
await phone.disconnect()
await host.disconnect()
room.close()
+
+
+@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
+class MenuViewTests(TestCase):
+ def test_menu_page_has_the_register_and_menu_js(self):
+ page = self.client.get("/host/", **HOST)
+ self.assertEqual(page.status_code, 200)
+ self.assertEqual(page.context["games"], games.menu_data())
+ html = page.content.decode()
+ self.assertIn('id="wpp-games"', html)
+ self.assertIn("party/menu", html)
+ self.assertIn('id="mn-form"', html)
+
+ def test_intro_is_the_fixed_line(self):
+ said = []
+
+ async def fake_synth(text, voice=None):
+ said.append((text, voice))
+ return {"url": "/tts/x.mp3", "ms": 1234}
+
+ with mock.patch("party.tts.synth", side_effect=fake_synth):
+ r = self.client.get("/host/intro/tegn", **HOST)
+ self.assertEqual(r.json(), {"url": "/tts/x.mp3", "ms": 1234})
+ self.assertEqual(said, [(games.GAMES["tegn"].intro(), None)]) # standardstemmen, som tts_warm
+
+ def test_intro_without_voice_unknown_game_and_no_host(self):
+ with mock.patch("party.tts.enabled", return_value=False), \
+ mock.patch("party.tts._fetch", side_effect=AssertionError("netværk")):
+ self.assertEqual(self.client.get("/host/intro/fof", **HOST).json(), {})
+ self.assertEqual(self.client.get("/host/intro/skak", **HOST).status_code, 404)
+ self.assertEqual(self.client.get("/host/intro/fof").status_code, 403)
diff --git a/party/tests/test_tegn.py b/party/tests/test_tegn.py
index 10607b1..973ee5b 100644
--- a/party/tests/test_tegn.py
+++ b/party/tests/test_tegn.py
@@ -2,6 +2,7 @@
import asyncio
import random
+from pathlib import Path
from unittest import mock
from channels.testing.websocket import WebsocketCommunicator
@@ -209,6 +210,8 @@ class TegnViewTests(TestCase):
afkrydse, og Enter/OK på den fokuserede "Tegn og Gæt" sendte formularen med game=fof."""
page = self.client.get("/host/", headers={"x-authentik-username": "agw"}, REMOTE_ADDR="127.0.0.1")
html = page.content.decode()
- self.assertNotRegex(html, r']*name="game"')
- for key in ("fof", "tegn"):
+ menu = (Path(__file__).resolve().parents[1] / "static" / "party" / "menu.js").read_text(encoding="utf-8")
+ self.assertNotRegex(html + menu, r'radio') # menuen vælger med knapper, også spillets indstillinger
+ self.assertNotRegex(html, r']*type="hidden")[^>]*name="game"')
+ for key in ("fof", "tegn"): # uden JavaScript: én startknap pr. spil
self.assertRegex(html, rf'