diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py
index b41ccf0..c2b07b2 100644
--- a/backend/app/routes/games.py
+++ b/backend/app/routes/games.py
@@ -308,6 +308,9 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
+ if g.winner_user_id:
+ raise HTTPException(403, "game finished; sheet is read-only")
+
status = data.get("status")
note_tag = data.get("note_tag")
chip = data.get("chip")
diff --git a/frontend/index.html b/frontend/index.html
index b924493..bf1b8bf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -94,6 +94,36 @@
overflow: hidden;
}
+ .splash-card::after {
+ content: "";
+ position: absolute;
+ inset: 18% 28%;
+ border: 1px solid rgba(233, 216, 166, 0.24);
+ border-radius: 50%;
+ transform: rotate(-18deg);
+ animation: orbit 2.8s ease-in-out infinite;
+ pointer-events: none;
+ }
+
+ .magic-orbit {
+ position: relative;
+ width: 74px;
+ height: 42px;
+ margin: 0 auto 4px;
+ }
+
+ .magic-orbit::before,
+ .magic-orbit::after {
+ content: "✦";
+ position: absolute;
+ color: rgba(233, 216, 166, 0.92);
+ font-size: 16px;
+ animation: sparkle 1.4s ease-in-out infinite;
+ }
+
+ .magic-orbit::before { left: 8px; top: 14px; }
+ .magic-orbit::after { right: 8px; top: 3px; animation-delay: .55s; }
+
.splash-card::before {
content: "";
position: absolute;
@@ -111,6 +141,7 @@
color: rgba(245, 239, 220, 0.92);
font-size: 18px;
line-height: 1.25;
+ animation: titleRise 700ms ease-out both;
}
.splash-sub {
@@ -156,6 +187,21 @@
0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
50% { opacity: 0.85; transform: scaleX(1.02); }
}
+
+ @keyframes orbit {
+ 0%, 100% { transform: rotate(-18deg) scale(.94); opacity: .45; }
+ 50% { transform: rotate(18deg) scale(1.06); opacity: .9; }
+ }
+
+ @keyframes sparkle {
+ 0%, 100% { transform: translateY(3px) scale(.65); opacity: .25; }
+ 50% { transform: translateY(-4px) scale(1.2); opacity: 1; }
+ }
+
+ @keyframes titleRise {
+ from { opacity: 0; transform: translateY(8px); letter-spacing: .18em; }
+ to { opacity: 1; transform: translateY(0); letter-spacing: .06em; }
+ }
@@ -170,6 +216,7 @@
+
Zauber-Detektiv Notizbogen
Magie wird vorbereitet…
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 1b71f6e..45e91de 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import WinnerCelebration from "./components/WinnerCelebration";
+import GameStartCelebration from "./components/GameStartCelebration";
import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag";
@@ -91,10 +92,13 @@ export default function App() {
// ===== Winner Celebration =====
const [celebrateOpen, setCelebrateOpen] = useState(false);
const [celebrateName, setCelebrateName] = useState("");
+ const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
// baseline per game: beim ersten Meta-Load NICHT feiern
const winnerBaselineRef = useRef(false);
const lastWinnerIdRef = useRef(null);
+ const gameStartBaselineRef = useRef(false);
+ const lastGameStartedRef = useRef(false);
const showSnack = (msg) => {
setSnack(msg);
@@ -212,8 +216,11 @@ export default function App() {
// reset winner celebration baseline when switching games
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
+ gameStartBaselineRef.current = false;
+ lastGameStartedRef.current = false;
setCelebrateOpen(false);
setCelebrateName("");
+ setStartCelebrateOpen(false);
(async () => {
if (!gameId) return;
@@ -288,6 +295,23 @@ export default function App() {
}
}, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]);
+ useEffect(() => {
+ if (!gameMeta) return;
+
+ const started = !!gameMeta.started;
+ if (!gameStartBaselineRef.current) {
+ gameStartBaselineRef.current = true;
+ lastGameStartedRef.current = started;
+ return;
+ }
+
+ if (!lastGameStartedRef.current && started) {
+ setStartCelebrateOpen(true);
+ vibrate([25, 55, 25]);
+ }
+ lastGameStartedRef.current = started;
+ }, [gameMeta?.started]);
+
// ===== Auth actions =====
const doLogin = async () => {
@@ -342,8 +366,11 @@ export default function App() {
// reset winner celebration on logout
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
+ gameStartBaselineRef.current = false;
+ lastGameStartedRef.current = false;
setCelebrateOpen(false);
setCelebrateName("");
+ setStartCelebrateOpen(false);
};
// ===== Password =====
@@ -452,8 +479,11 @@ export default function App() {
// reset winner celebration baseline for the new game
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
+ gameStartBaselineRef.current = false;
+ lastGameStartedRef.current = false;
setCelebrateOpen(false);
setCelebrateName("");
+ setStartCelebrateOpen(false);
const g = await api("/games", {
method: "POST",
@@ -498,6 +528,7 @@ export default function App() {
// ===== Sheet actions =====
const cycleStatus = async (entry) => {
+ if (gameMeta?.winner_user_id) return;
let next = 0;
if (entry.status === 0) next = 2;
else if (entry.status === 2) next = 1;
@@ -510,11 +541,13 @@ export default function App() {
});
await reloadSheet();
+ vibrate(10);
setPulseId(entry.entry_id);
setTimeout(() => setPulseId(null), 220);
};
const toggleTag = async (entry) => {
+ if (gameMeta?.winner_user_id) return;
const next = cycleTag(entry.note_tag);
if (next === "s") {
@@ -535,10 +568,11 @@ export default function App() {
});
await reloadSheet();
+ vibrate(10);
};
const chooseChip = async (chip) => {
- if (!chipEntry) return;
+ if (!chipEntry || gameMeta?.winner_user_id) return;
const entry = chipEntry;
setChipOpen(false);
@@ -553,6 +587,7 @@ export default function App() {
});
} finally {
await reloadSheet();
+ vibrate(12);
}
};
@@ -637,6 +672,10 @@ export default function App() {
winnerName={celebrateName}
onClose={() => setCelebrateOpen(false)}
/>
+
setStartCelebrateOpen(false)}
+ />
@@ -694,6 +733,7 @@ export default function App() {
onCycleStatus={cycleStatus}
onToggleTag={toggleTag}
displayTag={displayTag}
+ readOnly={!!gameMeta?.winner_user_id}
/>
))}
diff --git a/frontend/src/components/GameStartCelebration.jsx b/frontend/src/components/GameStartCelebration.jsx
new file mode 100644
index 0000000..5ef3820
--- /dev/null
+++ b/frontend/src/components/GameStartCelebration.jsx
@@ -0,0 +1,45 @@
+import React, { useEffect } from "react";
+import { createPortal } from "react-dom";
+import confetti from "canvas-confetti";
+import { stylesTokens } from "../styles/theme";
+
+export default function GameStartCelebration({ open, onClose }) {
+ useEffect(() => {
+ if (!open) return;
+
+ const burst = () => {
+ confetti({
+ particleCount: 70,
+ spread: 78,
+ startVelocity: 32,
+ origin: { y: 0.62 },
+ colors: ["#e9d8a6", "#7cffa8", "#8fb6ff", "#ffffff"],
+ });
+ };
+
+ burst();
+ const second = setTimeout(burst, 480);
+ const close = setTimeout(onClose, 3000);
+ return () => {
+ clearTimeout(second);
+ clearTimeout(close);
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return createPortal(
+
+
event.stopPropagation()}>
+
✦
+
Die Ermittlungen beginnen
+
Spiel gestartet
+
Möge der beste Detektiv gewinnen.
+
+
+
,
+ document.body
+ );
+}
diff --git a/frontend/src/components/SheetSection.jsx b/frontend/src/components/SheetSection.jsx
index e16ef2e..dc72bed 100644
--- a/frontend/src/components/SheetSection.jsx
+++ b/frontend/src/components/SheetSection.jsx
@@ -10,6 +10,7 @@ export default function SheetSection({
onCycleStatus,
onToggleTag,
displayTag,
+ readOnly = false,
}) {
const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg;
@@ -70,14 +71,15 @@ export default function SheetSection({
}}
>
onCycleStatus(e)}
+ onClick={() => !readOnly && onCycleStatus(e)}
style={{
...styles.name,
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
color: getNameColor(effectiveStatus),
opacity: effectiveStatus === 1 ? 0.8 : 1,
+ cursor: readOnly ? "default" : "pointer",
}}
- title="Klick: Grün → Rot → Grau → Leer"
+ title={readOnly ? "Spiel beendet – Notizzettel ist schreibgeschützt" : "Klick: Grün → Rot → Grau → Leer"}
>
{e.label}
@@ -95,9 +97,10 @@ export default function SheetSection({
diff --git a/frontend/src/styles/hooks/useHpGlobalStyles.js b/frontend/src/styles/hooks/useHpGlobalStyles.js
index b875451..3c4b39a 100644
--- a/frontend/src/styles/hooks/useHpGlobalStyles.js
+++ b/frontend/src/styles/hooks/useHpGlobalStyles.js
@@ -89,6 +89,20 @@ export function useHpGlobalStyles() {
input:focus, select:focus { border-color: var(--hp-textGold) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--hp-textGold) 16%, transparent); }
.hp-row { transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease; }
.hp-row:hover { box-shadow: inset 0 0 0 1px rgba(233,216,166,0.12); }
+ .hp-start-overlay { position: fixed; inset: 0; z-index: 2147483646; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at center, rgba(233,216,166,0.12), rgba(4,4,7,0.88) 48%, rgba(0,0,0,0.96)); animation: hpStartFade 260ms ease-out; }
+ .hp-start-card { position: relative; width: min(420px, 100%); padding: 30px 22px 24px; border: 1px solid rgba(233,216,166,0.34); border-radius: 24px; text-align: center; background: linear-gradient(145deg, rgba(34,31,38,0.96), rgba(10,10,14,0.96)); box-shadow: 0 24px 90px rgba(0,0,0,0.72), 0 0 60px rgba(233,216,166,0.12); animation: hpStartCard 520ms cubic-bezier(.2,.8,.2,1); overflow: hidden; }
+ .hp-start-card::before, .hp-start-card::after { content: ""; position: absolute; left: 12%; right: 12%; height: 1px; background: linear-gradient(90deg, transparent, rgba(233,216,166,0.72), transparent); animation: hpStartLine 1.8s ease-in-out infinite; }
+ .hp-start-card::before { top: 12px; }
+ .hp-start-card::after { bottom: 12px; animation-delay: .45s; }
+ .hp-start-rune { color: var(--hp-textGold); font-size: 32px; line-height: 1; animation: hpStartRune 1.4s ease-in-out infinite; }
+ .hp-start-kicker { margin-top: 14px; color: var(--hp-textDim); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; }
+ .hp-start-title { margin-top: 8px; color: var(--hp-textGold); font-family: "Cinzel Decorative", "IM Fell English", system-ui; font-size: 25px; font-weight: 900; }
+ .hp-start-subtitle { margin-top: 8px; color: var(--hp-textMain); font-size: 15px; }
+ .hp-start-card button { border: 1px solid rgba(233,216,166,0.26); border-radius: 12px; background: rgba(233,216,166,0.10); padding: 10px 16px; font-weight: 900; cursor: pointer; }
+ @keyframes hpStartFade { from { opacity: 0; } to { opacity: 1; } }
+ @keyframes hpStartCard { from { opacity: 0; transform: translateY(18px) scale(.92) rotateX(8deg); } to { opacity: 1; transform: translateY(0) scale(1) rotateX(0); } }
+ @keyframes hpStartRune { 0%, 100% { transform: rotate(0) scale(1); opacity: .7; } 50% { transform: rotate(180deg) scale(1.22); opacity: 1; } }
+ @keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
.hp-topbar-actions { margin-left: auto; }
.hp-user-menu-wrap { min-width: 0; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.5fr) 70px 76px 86px; }