refactor impstr to react/vite stack, update deploy target

migrate from vanilla TypeScript + Bun bundler to React 19, Vite, TanStack Router,
Zustand, shadcn-style components, Tailwind v4, vite-plugin-pwa.

game logic moves into a Zustand store, UI into React feature components with
file-based routing. all 27 tests pass, biome lint clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 13:02:11 +01:00
parent 80bc40da82
commit f867934295
39 changed files with 2473 additions and 688 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
dist/
node_modules/
.DS_Store
*.local

30
biome.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"formatter": {
"indentStyle": "tab",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useSemanticElements": "off"
},
"suspicious": {
"noArrayIndexKey": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"files": {
"ignore": ["dist/", "node_modules/", "src/routeTree.gen.ts", ".claude/"]
}
}

1185
bun.lock

File diff suppressed because it is too large Load Diff

23
index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f8f3e7" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="icon" type="image/svg+xml" href="/impstr/icon.svg" />
<link rel="apple-touch-icon" href="/impstr/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Unbounded:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<title>Imposter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,14 +1,37 @@
{
"name": "impstr",
"version": "2026.02.26",
"version": "2026.03.03",
"type": "module",
"scripts": {
"build": "bun run build:js && bun run build:css && bun run build:assets",
"build:js": "bun build src/client/main.ts --outfile dist/main.js --minify",
"build:css": "tailwindcss -i src/client/input.css -o dist/styles.css --minify",
"build:assets": "bun run scripts/copy-assets.ts"
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-router": "^1.114.0",
"clsx": "^2.1.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.0.2",
"zustand": "^5.0.5"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0"
"@biomejs/biome": "^1.9.4",
"@tailwindcss/vite": "^4.1.3",
"@tanstack/router-plugin": "^1.114.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"jsdom": "^26.1.0",
"tailwindcss": "^4.1.3",
"typescript": "^5.8.3",
"vite": "^6.3.2",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.1.1"
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "Imposter",
"short_name": "Imposter",
"start_url": ".",
"display": "standalone",
"background_color": "#f8f3e7",
"theme_color": "#f8f3e7",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View File

@@ -1,45 +0,0 @@
const CACHE_NAME = "impstr-v1";
const ASSETS = [
"./",
"./index.html",
"./main.js",
"./styles.css",
"./manifest.webmanifest",
"./icon-192.png",
"./icon-512.png",
"./apple-touch-icon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
),
);
});
self.addEventListener("message", (event) => {
if (event.data === "SKIP_WAITING") {
self.skipWaiting();
}
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((response) => response || fetch(event.request)),
);
});

View File

@@ -1,18 +0,0 @@
import { copyFileSync, mkdirSync } from "fs";
mkdirSync("dist", { recursive: true });
const assets: [string, string][] = [
["src/index.html", "dist/index.html"],
["public/sw.js", "dist/sw.js"],
["public/manifest.webmanifest", "dist/manifest.webmanifest"],
["public/icon-192.png", "dist/icon-192.png"],
["public/icon-512.png", "dist/icon-512.png"],
["public/apple-touch-icon.png", "dist/apple-touch-icon.png"],
["public/icon.svg", "dist/icon.svg"],
];
for (const [src, dest] of assets) {
copyFileSync(src, dest);
console.log(`Copied ${src}${dest}`);
}

14
src/app.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree, basepath: "/impstr" });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
export function App() {
return <RouterProvider router={router} />;
}

View File

@@ -1,179 +0,0 @@
@import "tailwindcss";
@source "./main.ts";
@source "../index.html";
@layer base {
html {
color-scheme: light;
background-color: #f8f3e7;
}
body {
margin: 0;
font-family: "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
color: #1a1a1a;
background: radial-gradient(circle at top, #fff4dc, #f8f3e7);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
* {
box-sizing: border-box;
}
h1,
h2 {
font-family: "Unbounded", "Trebuchet MS", sans-serif;
margin: 0;
line-height: 1.08;
}
h1 {
font-size: clamp(2.2rem, 5vw, 3.2rem);
}
h2 {
font-size: clamp(1.5rem, 3.5vw, 2rem);
}
p {
margin: 0;
line-height: 1.5;
}
input {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(26, 26, 26, 0.2);
font-size: 1rem;
font-family: "Space Grotesk", sans-serif;
background: #fff;
width: 100%;
}
}
@layer components {
.screen {
min-height: 100vh;
min-height: 100dvh;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 24px;
}
.screen.hidden {
display: none;
}
.card {
background: #fffaf0;
border-radius: 24px;
padding: 24px;
box-shadow: 0 20px 45px rgba(26, 26, 26, 0.12);
border: 1px solid rgba(26, 26, 26, 0.08);
animation: fadeIn 0.35s ease;
}
.btn {
border: none;
padding: 14px 20px;
border-radius: 9999px;
font-weight: 600;
cursor: pointer;
background: #f0e6d1;
color: #1a1a1a;
transition: box-shadow 0.2s ease;
font-family: "Space Grotesk", sans-serif;
min-width: 0;
min-height: 52px;
font-size: 1rem;
line-height: 1;
}
.btn:hover {
box-shadow: 0 10px 20px rgba(26, 26, 26, 0.15);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn--primary {
background: #ff7b54;
color: #fffaf0;
}
.btn--ghost {
background: transparent;
border: 1px dashed rgba(26, 26, 26, 0.25);
}
.flip-card {
perspective: 600px;
width: min(320px, 80vw);
height: 180px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.flip-card__inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.5s ease;
transform-style: preserve-3d;
}
.flip-card.flipped .flip-card__inner {
transform: rotateY(180deg);
}
.flip-card--instant .flip-card__inner {
transition: none;
}
.flip-card__front,
.flip-card__back {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
background: linear-gradient(135deg, #fff4dc, #ffe2c0);
box-shadow: inset 0 0 0 1px rgba(26, 26, 26, 0.08);
}
.flip-card__back {
transform: rotateY(180deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Outside any layer — wins over all Tailwind utilities */
.hidden {
display: none !important;
}

View File

@@ -1,269 +0,0 @@
const WORDS: string[] = [
"Kekse",
"Schach",
"Giraffe",
"Spaghetti",
"Rakete",
"Blume",
"Kino",
"Drachen",
"Buch",
"Kaffee",
"Berg",
"Pizza",
"Wolke",
"Bus",
"Zitrone",
"Gitarre",
"Meer",
"Bananen",
"Löwe",
"Regen",
];
const MIN_PLAYERS = 3;
const STORAGE_KEY = "impstr-players";
interface State {
players: string[];
currentIndex: number;
imposterIndex: number;
secretWord: string;
revealed: boolean;
discussionIndex: number;
}
const state: State = {
players: [],
currentIndex: 0,
imposterIndex: -1,
secretWord: "",
revealed: false,
discussionIndex: 0,
};
function q<T extends HTMLElement>(id: string): T {
return document.getElementById(id) as T;
}
const el = {
playerInput: q<HTMLInputElement>("player-input"),
addPlayer: q<HTMLButtonElement>("add-player"),
playerList: q<HTMLUListElement>("player-list"),
startGame: q<HTMLButtonElement>("start-game"),
clearPlayers: q<HTMLButtonElement>("clear-players"),
setupHint: q<HTMLParagraphElement>("setup-hint"),
setup: q<HTMLElement>("setup"),
round: q<HTMLElement>("round"),
discussion: q<HTMLElement>("discussion"),
done: q<HTMLElement>("done"),
roundCount: q<HTMLElement>("round-count"),
restart: q<HTMLButtonElement>("restart"),
currentPlayer: q<HTMLElement>("current-player"),
handoff: q<HTMLButtonElement>("handoff"),
revealCard: q<HTMLElement>("reveal-card"),
revealWord: q<HTMLElement>("reveal-word"),
discussionCount: q<HTMLElement>("discussion-count"),
discussionPlayer: q<HTMLElement>("discussion-player"),
discussionNext: q<HTMLButtonElement>("discussion-next"),
discussionRestart: q<HTMLButtonElement>("discussion-restart"),
imposterFound: q<HTMLButtonElement>("imposter-found"),
playAgain: q<HTMLButtonElement>("play-again"),
editPlayers: q<HTMLButtonElement>("edit-players"),
};
function show(...els: HTMLElement[]): void {
els.forEach((e) => e.classList.remove("hidden"));
}
function hide(...els: HTMLElement[]): void {
els.forEach((e) => e.classList.add("hidden"));
}
function updatePlayerList(): void {
el.playerList.innerHTML = "";
state.players.forEach((player, index) => {
const item = document.createElement("li");
item.className =
"flex items-center justify-between px-3 py-2.5 rounded-xl bg-white border border-[rgba(26,26,26,0.08)]";
const name = document.createElement("span");
name.textContent = player;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Entfernen";
remove.className =
"bg-transparent border-0 text-[#d95c38] font-semibold cursor-pointer";
remove.addEventListener("click", () => {
state.players.splice(index, 1);
updatePlayerList();
});
item.append(name, remove);
el.playerList.appendChild(item);
});
const ready = state.players.length >= MIN_PLAYERS;
el.startGame.disabled = !ready;
el.setupHint.textContent = ready
? `${state.players.length} Spieler bereit.`
: `Mindestens ${MIN_PLAYERS} Spieler.`;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.players));
}
function addPlayer(): void {
const raw = el.playerInput.value.trim();
if (!raw) return;
state.players.push(raw);
el.playerInput.value = "";
updatePlayerList();
el.playerInput.focus();
}
function startRound(): void {
state.currentIndex = 0;
state.imposterIndex = Math.floor(Math.random() * state.players.length);
state.secretWord = WORDS[Math.floor(Math.random() * WORDS.length)];
state.revealed = false;
el.revealCard.classList.add("flip-card--instant");
el.revealCard.classList.remove("flipped");
show(el.round);
hide(el.setup, el.discussion, el.done);
updateRoundView();
requestAnimationFrame(() => {
el.revealCard.classList.remove("flip-card--instant");
});
}
function updateRoundView(): void {
const total = state.players.length;
const current = state.currentIndex + 1;
el.roundCount.textContent = `Phase 1: Zuweisung \u2014 Spieler ${current}\u00a0/\u00a0${total}`;
el.currentPlayer.textContent = state.players[state.currentIndex];
el.revealWord.textContent =
state.currentIndex === state.imposterIndex ? "IMPOSTER" : state.secretWord;
el.revealCard.classList.remove("flipped");
state.revealed = false;
}
function toggleReveal(): void {
el.revealCard.classList.toggle("flipped");
state.revealed = !state.revealed;
}
function handoffPlayer(): void {
el.revealCard.classList.add("flip-card--instant");
el.revealCard.classList.remove("flipped");
state.currentIndex += 1;
if (state.currentIndex >= state.players.length) {
startDiscussion();
return;
}
updateRoundView();
requestAnimationFrame(() => {
el.revealCard.classList.remove("flip-card--instant");
});
}
function startDiscussion(): void {
state.discussionIndex = 0;
show(el.discussion);
hide(el.round, el.done);
updateDiscussionView();
}
function updateDiscussionView(): void {
const total = state.players.length;
const current = state.discussionIndex + 1;
el.discussionCount.textContent = `Phase 2: Diskussion \u2014 Spieler ${current}\u00a0/\u00a0${total}`;
el.discussionPlayer.textContent = state.players[state.discussionIndex];
}
function nextDiscussion(): void {
if (state.players.length === 0) return;
state.discussionIndex = (state.discussionIndex + 1) % state.players.length;
updateDiscussionView();
}
function endDiscussion(): void {
show(el.done);
hide(el.discussion);
}
function resetToSetup(): void {
show(el.setup);
hide(el.round, el.discussion, el.done);
}
function clearPlayers(): void {
state.players = [];
updatePlayerList();
}
function loadPlayers(): void {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
try {
const parsed: unknown = JSON.parse(saved);
if (Array.isArray(parsed)) {
state.players = parsed.filter(
(name): name is string => typeof name === "string",
);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
// Event listeners
el.addPlayer.addEventListener("click", addPlayer);
el.playerInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") addPlayer();
});
el.clearPlayers.addEventListener("click", clearPlayers);
el.startGame.addEventListener("click", startRound);
el.handoff.addEventListener("click", handoffPlayer);
el.revealCard.addEventListener("click", toggleReveal);
el.discussionNext.addEventListener("click", nextDiscussion);
el.discussionRestart.addEventListener("click", startRound);
el.imposterFound.addEventListener("click", endDiscussion);
el.restart.addEventListener("click", startRound);
el.playAgain.addEventListener("click", startRound);
el.editPlayers.addEventListener("click", resetToSetup);
// Init
loadPlayers();
updatePlayerList();
// Service worker
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
const reg = await navigator.serviceWorker.register("./sw.js");
const showUpdate = () => {
const banner = document.getElementById("update-banner");
if (!banner) return;
banner.classList.remove("hidden");
document.getElementById("update-btn")?.addEventListener("click", () => {
(reg.waiting as ServiceWorker | null)?.postMessage("SKIP_WAITING");
});
};
if (reg.waiting) showUpdate();
reg.addEventListener("updatefound", () => {
const newSW = reg.installing;
if (!newSW) return;
newSW.addEventListener("statechange", () => {
if (newSW.state === "installed" && navigator.serviceWorker.controller) {
showUpdate();
}
});
});
navigator.serviceWorker.addEventListener("controllerchange", () => {
window.location.reload();
});
});
}

View File

@@ -0,0 +1,46 @@
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { useGameStore } from "@/shared/stores/game-store";
export function DiscussionScreen() {
const players = useGameStore((s) => s.players);
const discussionIndex = useGameStore((s) => s.discussionIndex);
const nextDiscussion = useGameStore((s) => s.nextDiscussion);
const endDiscussion = useGameStore((s) => s.endDiscussion);
const resetToSetup = useGameStore((s) => s.resetToSetup);
const currentPlayer = players[discussionIndex];
const isLast = discussionIndex >= players.length - 1;
return (
<div className="flex w-full max-w-md flex-col items-center gap-6 animate-fade-in">
<header className="flex w-full items-center justify-between">
<span className="text-sm text-[#5b5b5b]">
{discussionIndex + 1} / {players.length}
</span>
<button
type="button"
onClick={resetToSetup}
className="text-sm text-[#5b5b5b] hover:text-[#1a1a1a] transition-colors cursor-pointer"
>
Neue Runde
</button>
</header>
<h2>Wer ist dran?</h2>
<Badge className="text-xl px-6 py-2.5">{currentPlayer}</Badge>
<div className="flex w-full gap-3">
<Button variant="primary" onClick={endDiscussion} className="flex-1">
Imposter gefunden
</Button>
{!isLast && (
<Button variant="ghost" onClick={nextDiscussion} className="flex-1">
Weitergeben
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Button } from "@/shared/components/ui/button";
import { useGameStore } from "@/shared/stores/game-store";
export function DoneScreen() {
const startRound = useGameStore((s) => s.startRound);
const resetToSetup = useGameStore((s) => s.resetToSetup);
return (
<div className="flex w-full max-w-md flex-col items-center gap-6 animate-fade-in">
<div className="w-full rounded-3xl bg-surface p-8 shadow-[0_2px_16px_rgba(0,0,0,0.06)] animate-fade-in">
<h2 className="mb-6">Runde beendet!</h2>
<div className="flex flex-col gap-3">
<Button variant="ghost" onClick={resetToSetup} className="w-full">
Spieler bearbeiten
</Button>
<Button variant="primary" onClick={startRound} className="w-full">
Neue Runde
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlipCard } from "./flip-card";
describe("FlipCard", () => {
afterEach(() => {
cleanup();
});
it("renders with front text when not flipped", () => {
render(<FlipCard word="Kekse" flipped={false} onFlip={() => {}} />);
expect(screen.getByText("Antippen")).toBeInTheDocument();
expect(screen.getByText("Kekse")).toBeInTheDocument();
});
it("has flipped class when flipped", () => {
render(<FlipCard word="Kekse" flipped={true} onFlip={() => {}} />);
const card = screen.getByRole("button");
expect(card).toHaveClass("flipped");
});
it("calls onFlip when clicked", () => {
const onFlip = vi.fn();
render(<FlipCard word="Kekse" flipped={false} onFlip={onFlip} />);
fireEvent.click(screen.getByRole("button"));
expect(onFlip).toHaveBeenCalledOnce();
});
it("displays IMPOSTER for the imposter", () => {
render(<FlipCard word="IMPOSTER" flipped={true} onFlip={() => {}} />);
expect(screen.getByText("IMPOSTER")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
import { cn } from "@/shared/lib/utils";
import { useEffect, useState } from "react";
interface FlipCardProps {
word: string;
flipped: boolean;
onFlip: () => void;
}
export function FlipCard({ word, flipped, onFlip }: FlipCardProps) {
const [instant, setInstant] = useState(false);
// When word changes (new player), instantly reset to front without animation.
// word is intentionally a dependency — we want to trigger on player change.
// biome-ignore lint/correctness/useExhaustiveDependencies: word triggers the reset
useEffect(() => {
setInstant(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setInstant(false);
});
});
}, [word]);
return (
<div
className={cn("flip-card", flipped && "flipped", instant && "flip-card--instant")}
onClick={onFlip}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onFlip();
}}
role="button"
tabIndex={0}
aria-label={flipped ? word : "Karte antippen zum Aufdecken"}
>
<div className="flip-card-inner">
<div className="flip-card-front">Antippen</div>
<div className="flip-card-back">{word}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { useGameStore } from "@/shared/stores/game-store";
import { FlipCard } from "./flip-card";
export function RoundScreen() {
const players = useGameStore((s) => s.players);
const currentIndex = useGameStore((s) => s.currentIndex);
const imposterIndex = useGameStore((s) => s.imposterIndex);
const secretWord = useGameStore((s) => s.secretWord);
const revealed = useGameStore((s) => s.revealed);
const toggleReveal = useGameStore((s) => s.toggleReveal);
const handoff = useGameStore((s) => s.handoff);
const resetToSetup = useGameStore((s) => s.resetToSetup);
const currentPlayer = players[currentIndex];
const isImposter = currentIndex === imposterIndex;
const displayWord = isImposter ? "IMPOSTER" : secretWord;
return (
<div className="flex w-full max-w-md flex-col items-center gap-6 animate-fade-in">
<header className="flex w-full items-center justify-between">
<span className="text-sm text-[#5b5b5b]">
{currentIndex + 1} / {players.length}
</span>
<button
type="button"
onClick={resetToSetup}
className="text-sm text-[#5b5b5b] hover:text-[#1a1a1a] transition-colors cursor-pointer"
>
Neue Runde
</button>
</header>
<Badge className="text-base px-4 py-1.5">{currentPlayer}</Badge>
<FlipCard word={displayWord} flipped={revealed} onFlip={toggleReveal} />
<p className="text-sm text-[#5b5b5b]">
{revealed
? "Merke dir dein Wort und gib weiter!"
: "Tippe auf die Karte, um dein Wort zu sehen."}
</p>
<Button variant="primary" onClick={handoff}>
Weitergeben
</Button>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useGameStore } from "@/shared/stores/game-store";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SetupScreen } from "./setup-screen";
describe("SetupScreen", () => {
beforeEach(() => {
localStorage.clear();
useGameStore.setState({
players: [],
currentIndex: 0,
imposterIndex: -1,
secretWord: "",
revealed: false,
discussionIndex: 0,
phase: "setup",
});
});
afterEach(() => {
cleanup();
});
it("renders the setup screen", () => {
render(<SetupScreen />);
expect(screen.getByText("Imposter")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Spieler hinzufügen…")).toBeInTheDocument();
});
it("adds a player via button click", () => {
render(<SetupScreen />);
const input = screen.getByPlaceholderText("Spieler hinzufügen…");
fireEvent.change(input, { target: { value: "Alice" } });
fireEvent.click(screen.getByText("+"));
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("adds a player via Enter key", () => {
render(<SetupScreen />);
const input = screen.getByPlaceholderText("Spieler hinzufügen…");
fireEvent.change(input, { target: { value: "Bob" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("Bob")).toBeInTheDocument();
});
it("removes a player", () => {
useGameStore.setState({ players: ["Alice", "Bob"] });
render(<SetupScreen />);
const removeButtons = screen.getAllByText("×");
fireEvent.click(removeButtons[0]);
expect(screen.queryByText("Alice")).not.toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
it("disables start button with fewer than 3 players", () => {
useGameStore.setState({ players: ["Alice", "Bob"] });
render(<SetupScreen />);
const startButton = screen.getByText("Spiel starten");
expect(startButton).toBeDisabled();
});
it("enables start button with 3+ players", () => {
useGameStore.setState({ players: ["Alice", "Bob", "Charlie"] });
render(<SetupScreen />);
const startButton = screen.getByText("Spiel starten");
expect(startButton).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,97 @@
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { MIN_PLAYERS, useGameStore } from "@/shared/stores/game-store";
import { useEffect, useRef, useState } from "react";
export function SetupScreen() {
const players = useGameStore((s) => s.players);
const addPlayer = useGameStore((s) => s.addPlayer);
const removePlayer = useGameStore((s) => s.removePlayer);
const clearPlayers = useGameStore((s) => s.clearPlayers);
const loadPlayers = useGameStore((s) => s.loadPlayers);
const startRound = useGameStore((s) => s.startRound);
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadPlayers();
}, [loadPlayers]);
const canStart = players.length >= MIN_PLAYERS;
function handleAdd() {
if (!input.trim()) return;
addPlayer(input);
setInput("");
inputRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
handleAdd();
}
}
return (
<div className="flex w-full max-w-md flex-col gap-6 animate-fade-in">
<header className="flex flex-col gap-3">
<Badge className="self-center">Partyspiel</Badge>
<h1>Imposter</h1>
<p className="text-[#5b5b5b]">Findet heraus, wer das geheime Wort nicht kennt!</p>
</header>
<div className="rounded-3xl bg-surface p-6 shadow-[0_2px_16px_rgba(0,0,0,0.06)] animate-fade-in">
<div className="flex gap-2">
<Input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Spieler hinzufügen…"
autoComplete="off"
/>
<Button onClick={handleAdd} className="shrink-0">
+
</Button>
</div>
{players.length > 0 && (
<ul className="mt-4 flex flex-col gap-2">
{players.map((player, i) => (
<li
key={`${player}-${i}`}
className="flex items-center justify-between rounded-xl bg-surface-dark px-4 py-2.5"
>
<span className="font-medium">{player}</span>
<button
type="button"
onClick={() => removePlayer(i)}
className="text-[#5b5b5b] hover:text-[#1a1a1a] transition-colors cursor-pointer text-lg leading-none"
>
×
</button>
</li>
))}
</ul>
)}
<div className="mt-4 flex gap-2">
{players.length > 0 && (
<Button variant="ghost" onClick={clearPlayers} className="flex-1">
Alle löschen
</Button>
)}
<Button variant="primary" onClick={startRound} disabled={!canStart} className="flex-1">
Spiel starten
</Button>
</div>
</div>
{!canStart && players.length > 0 && (
<p className="text-sm text-[#5b5b5b]">Mindestens {MIN_PLAYERS} Spieler benötigt</p>
)}
</div>
);
}

101
src/index.css Normal file
View File

@@ -0,0 +1,101 @@
@import "tailwindcss";
@theme {
--color-primary: #ff7b54;
--color-bg: #f8f3e7;
--color-surface: #fffaf0;
--color-surface-dark: #f0e6d1;
--font-heading: "Unbounded", sans-serif;
--font-body: "Space Grotesk", sans-serif;
--animate-fade-in: fade-in 0.35s ease;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* flip-card — 3D CSS that can't be expressed as Tailwind utilities */
.flip-card {
perspective: 800px;
width: 100%;
max-width: 320px;
height: 180px;
cursor: pointer;
}
.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.5s ease;
transform-style: preserve-3d;
}
.flip-card.flipped .flip-card-inner {
transform: rotateY(180deg);
}
.flip-card--instant .flip-card-inner {
transition: none;
}
.flip-card-front,
.flip-card-back {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
backface-visibility: hidden;
border-radius: 16px;
font-family: var(--font-heading);
font-weight: 700;
font-size: clamp(1.3rem, 4vw, 1.8rem);
}
.flip-card-front {
background: var(--color-surface-dark);
color: #5b5b5b;
}
.flip-card-back {
background: var(--color-primary);
color: white;
transform: rotateY(180deg);
}
@layer base {
body {
font-family: var(--font-body);
background: var(--color-bg);
color: #1a1a1a;
min-height: 100dvh;
margin: 0;
}
h1,
h2 {
font-family: var(--font-heading);
}
h1 {
font-size: clamp(2.2rem, 5vw, 3.2rem);
font-weight: 700;
line-height: 1.1;
}
h2 {
font-size: clamp(1.5rem, 3.5vw, 2rem);
font-weight: 600;
line-height: 1.2;
}
}

View File

@@ -1,126 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f8f3e7" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Imposter</title>
<link rel="stylesheet" href="styles.css" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Unbounded:wght@300;500;700&family=Space+Grotesk:wght@400;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<main class="w-full">
<!-- Setup -->
<section id="setup" class="screen" style="justify-content:flex-start;padding-top:40px;">
<header class="grid gap-3 w-full max-w-[560px]">
<span class="inline-flex items-center self-start px-3 py-1.5 rounded-full bg-[#ff7b54] text-[#fffaf0] font-semibold text-sm tracking-wide">
Handy weiterreichen
</span>
<h1>Imposter</h1>
<p class="text-[#5b5b5b]">
Trage Spieler ein, starte die Runde, und zeige jedem sein Wort oder
&ldquo;IMPOSTER&rdquo;. Danach findet ihr heraus, wer das Wort nicht kennt.
</p>
</header>
<div class="card w-full max-w-[560px]">
<h2>Spieler</h2>
<div class="grid grid-cols-[1fr_auto] max-sm:grid-cols-1 gap-3 mt-4">
<input
id="player-input"
type="text"
placeholder="Name eingeben"
autocomplete="off"
/>
<button id="add-player" class="btn">Hinzufügen</button>
</div>
<ul id="player-list" class="list-none p-0 m-0 mt-4 grid gap-2.5"></ul>
<div class="flex max-sm:flex-col-reverse max-sm:items-stretch justify-between items-center gap-3 mt-5 flex-wrap">
<button id="clear-players" class="btn btn--ghost">Liste leeren</button>
<button id="start-game" class="btn btn--primary" disabled>Runde starten</button>
</div>
<p id="setup-hint" class="mt-3 text-[0.95rem] text-[#5b5b5b]">Mindestens 3 Spieler.</p>
</div>
</section>
<!-- Round -->
<section id="round" class="screen hidden">
<div class="card w-full max-w-[560px] flex flex-col" style="min-height:480px;">
<div class="flex max-sm:flex-col max-sm:items-stretch flex-wrap items-center justify-between gap-3">
<div id="round-count" class="font-semibold">Phase 1: Zuweisung — Spieler 1 / 1</div>
<button id="restart" class="btn btn--ghost">Neue Runde</button>
</div>
<div id="round-screen" class="flex flex-col items-center gap-4 mt-4 text-center flex-1">
<div id="current-player" class="px-5 py-2.5 rounded-full bg-[#f0e6d1] font-semibold" style="font-size:clamp(1.2rem,3.8vw,1.8rem);"></div>
<div id="reveal-card" class="flip-card">
<div class="flip-card__inner">
<div class="flip-card__front">
<div class="text-[0.95rem] uppercase tracking-[0.08em] text-[#5b5b5b]">Antippen zum Aufdecken</div>
</div>
<div class="flip-card__back">
<div class="text-[0.95rem] uppercase tracking-[0.08em] text-[#5b5b5b]">Dein Hinweis</div>
<div id="reveal-word" class="mt-2.5 text-center" style="font-family:'Unbounded',sans-serif;font-size:clamp(1.8rem,6vw,3rem);"></div>
</div>
</div>
</div>
<p class="text-[0.95rem] text-[#5b5b5b]">
Tippe auf die Karte, um dein Wort zu sehen. Gib das Handy danach weiter.
</p>
<div class="flex justify-end items-center gap-3 mt-auto w-full max-sm:flex-col-reverse max-sm:items-stretch flex-wrap">
<button id="handoff" class="btn btn--primary" style="min-width:180px;">Weitergeben</button>
</div>
</div>
</div>
</section>
<!-- Discussion -->
<section id="discussion" class="screen hidden">
<div class="card w-full max-w-[560px] flex flex-col" style="min-height:480px;">
<div class="flex max-sm:flex-col max-sm:items-stretch flex-wrap items-center justify-between gap-3">
<div id="discussion-count" class="font-semibold">Phase 2: Diskussion — Spieler 1 / 1</div>
<button id="discussion-restart" class="btn btn--ghost">Neue Runde</button>
</div>
<div class="flex flex-col items-center gap-4 mt-4 text-center flex-1">
<h2>Wer ist dran?</h2>
<div id="discussion-player" class="px-5 py-2.5 rounded-full bg-[#f0e6d1] font-semibold text-center tracking-[0.01em]" style="font-size:clamp(1.8rem,5vw,2.8rem);"></div>
<p class="text-[0.95rem] text-[#5b5b5b]">Gib das Handy weiter.</p>
<div class="flex justify-between items-center gap-3 mt-auto w-full max-sm:flex-col-reverse max-sm:items-stretch flex-wrap">
<button id="imposter-found" class="btn btn--ghost">Imposter gefunden</button>
<button id="discussion-next" class="btn btn--primary" style="min-width:180px;">Weitergeben</button>
</div>
</div>
</div>
</section>
<!-- Done -->
<section id="done" class="screen hidden">
<div class="card w-full max-w-[560px]">
<h2>Runde beendet</h2>
<p class="mt-2 text-[#5b5b5b]">Optional: Neue Runde starten oder Spieler bearbeiten.</p>
<div class="flex justify-between items-center gap-3 mt-5 max-sm:flex-col-reverse max-sm:items-stretch flex-wrap">
<button id="edit-players" class="btn btn--ghost">Spieler bearbeiten</button>
<button id="play-again" class="btn btn--primary">Neue Runde</button>
</div>
</div>
</section>
</main>
<!-- Update banner -->
<div id="update-banner" class="hidden fixed bottom-0 left-0 right-0 flex items-center justify-center gap-4 px-6 py-4 bg-[#1a1a1a] text-[#fffaf0] font-semibold z-50">
Neue Version verfügbar
<button id="update-btn" class="btn btn--primary" style="min-height:40px;padding:8px 20px;">Aktualisieren</button>
</div>
<script src="main.js"></script>
</body>
</html>

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./index.css";
// biome-ignore lint/style/noNonNullAssertion: root element is guaranteed by index.html
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

113
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,113 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as RoundRouteImport } from './routes/round'
import { Route as DoneRouteImport } from './routes/done'
import { Route as DiscussionRouteImport } from './routes/discussion'
import { Route as IndexRouteImport } from './routes/index'
const RoundRoute = RoundRouteImport.update({
id: '/round',
path: '/round',
getParentRoute: () => rootRouteImport,
} as any)
const DoneRoute = DoneRouteImport.update({
id: '/done',
path: '/done',
getParentRoute: () => rootRouteImport,
} as any)
const DiscussionRoute = DiscussionRouteImport.update({
id: '/discussion',
path: '/discussion',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/discussion': typeof DiscussionRoute
'/done': typeof DoneRoute
'/round': typeof RoundRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/discussion': typeof DiscussionRoute
'/done': typeof DoneRoute
'/round': typeof RoundRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/discussion': typeof DiscussionRoute
'/done': typeof DoneRoute
'/round': typeof RoundRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/discussion' | '/done' | '/round'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/discussion' | '/done' | '/round'
id: '__root__' | '/' | '/discussion' | '/done' | '/round'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DiscussionRoute: typeof DiscussionRoute
DoneRoute: typeof DoneRoute
RoundRoute: typeof RoundRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/round': {
id: '/round'
path: '/round'
fullPath: '/round'
preLoaderRoute: typeof RoundRouteImport
parentRoute: typeof rootRouteImport
}
'/done': {
id: '/done'
path: '/done'
fullPath: '/done'
preLoaderRoute: typeof DoneRouteImport
parentRoute: typeof rootRouteImport
}
'/discussion': {
id: '/discussion'
path: '/discussion'
fullPath: '/discussion'
preLoaderRoute: typeof DiscussionRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DiscussionRoute: DiscussionRoute,
DoneRoute: DoneRoute,
RoundRoute: RoundRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

18
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { UpdateBanner } from "../shared/components/update-banner";
import { usePhaseNavigation } from "../shared/hooks/use-phase-navigation";
function RootLayout() {
usePhaseNavigation();
return (
<main className="flex min-h-dvh flex-col items-center justify-center px-5 py-10 text-center">
<Outlet />
<UpdateBanner />
</main>
);
}
export const Route = createRootRoute({
component: RootLayout,
});

12
src/routes/discussion.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { DiscussionScreen } from "../features/discussion/discussion-screen";
import { useGameStore } from "../shared/stores/game-store";
export const Route = createFileRoute("/discussion")({
beforeLoad: () => {
if (useGameStore.getState().phase !== "discussion") {
throw redirect({ to: "/" });
}
},
component: DiscussionScreen,
});

12
src/routes/done.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { DoneScreen } from "../features/done/done-screen";
import { useGameStore } from "../shared/stores/game-store";
export const Route = createFileRoute("/done")({
beforeLoad: () => {
if (useGameStore.getState().phase !== "done") {
throw redirect({ to: "/" });
}
},
component: DoneScreen,
});

15
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { SetupScreen } from "../features/setup/setup-screen";
import { useGameStore } from "../shared/stores/game-store";
export const Route = createFileRoute("/")({
beforeLoad: () => {
const phase = useGameStore.getState().phase;
if (phase !== "setup") {
throw redirect({
to: `/${phase === "round" ? "round" : phase === "discussion" ? "discussion" : "done"}`,
});
}
},
component: SetupScreen,
});

12
src/routes/round.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { RoundScreen } from "../features/round/round-screen";
import { useGameStore } from "../shared/stores/game-store";
export const Route = createFileRoute("/round")({
beforeLoad: () => {
if (useGameStore.getState().phase !== "round") {
throw redirect({ to: "/" });
}
},
component: RoundScreen,
});

View File

@@ -0,0 +1,14 @@
import { cn } from "@/shared/lib/utils";
import type { HTMLAttributes } from "react";
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn(
"inline-flex items-center rounded-full bg-surface-dark px-3 py-1 text-sm font-medium text-[#5b5b5b]",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,31 @@
import { cn } from "@/shared/lib/utils";
import { type ButtonHTMLAttributes, forwardRef } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "primary" | "ghost";
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full font-semibold text-base transition-all cursor-pointer",
"min-h-[52px] px-8",
"disabled:opacity-40 disabled:pointer-events-none",
variant === "default" &&
"bg-[#1a1a1a] text-white hover:shadow-[0_4px_16px_rgba(0,0,0,0.18)]",
variant === "primary" &&
"bg-primary text-white hover:shadow-[0_4px_16px_rgba(255,123,84,0.35)]",
variant === "ghost" &&
"bg-transparent border-2 border-[rgba(26,26,26,0.2)] text-[#1a1a1a] hover:border-[rgba(26,26,26,0.4)]",
className,
)}
{...props}
/>
);
},
);
Button.displayName = "Button";

View File

@@ -0,0 +1,21 @@
import { cn } from "@/shared/lib/utils";
import { type InputHTMLAttributes, forwardRef } from "react";
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
"w-full rounded-2xl border-2 border-[rgba(26,26,26,0.08)] bg-surface px-5 py-3.5",
"font-body text-base text-[#1a1a1a] placeholder:text-[#5b5b5b]",
"outline-none transition-colors focus:border-primary",
className,
)}
{...props}
/>
);
},
);
Input.displayName = "Input";

View File

@@ -0,0 +1,24 @@
import { useRegisterSW } from "virtual:pwa-register/react";
import { Button } from "./ui/button";
export function UpdateBanner() {
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW();
if (!needRefresh) return null;
return (
<div className="fixed bottom-4 left-4 right-4 z-50 flex items-center justify-between gap-4 rounded-2xl bg-[#1a1a1a] px-5 py-3 text-white shadow-lg animate-fade-in">
<span className="text-sm">Neue Version verfügbar!</span>
<Button
variant="primary"
className="min-h-[40px] px-5 text-sm"
onClick={() => updateServiceWorker(true)}
>
Aktualisieren
</Button>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { type Phase, useGameStore } from "../stores/game-store";
const phaseToRoute: Record<Phase, string> = {
setup: "/",
round: "/round",
discussion: "/discussion",
done: "/done",
};
export function usePhaseNavigation() {
const phase = useGameStore((s) => s.phase);
const navigate = useNavigate();
useEffect(() => {
navigate({ to: phaseToRoute[phase] });
}, [phase, navigate]);
}

6
src/shared/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MIN_PLAYERS, useGameStore } from "./game-store";
function getState() {
return useGameStore.getState();
}
describe("game-store", () => {
beforeEach(() => {
localStorage.clear();
useGameStore.setState({
players: [],
currentIndex: 0,
imposterIndex: -1,
secretWord: "",
revealed: false,
discussionIndex: 0,
phase: "setup",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("addPlayer", () => {
it("adds a player and persists to localStorage", () => {
getState().addPlayer("Alice");
expect(getState().players).toEqual(["Alice"]);
expect(JSON.parse(localStorage.getItem("impstr-players") ?? "[]")).toEqual(["Alice"]);
});
it("trims whitespace", () => {
getState().addPlayer(" Bob ");
expect(getState().players).toEqual(["Bob"]);
});
it("ignores empty input", () => {
getState().addPlayer("");
getState().addPlayer(" ");
expect(getState().players).toEqual([]);
});
});
describe("removePlayer", () => {
it("removes player by index", () => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().addPlayer("Charlie");
getState().removePlayer(1);
expect(getState().players).toEqual(["Alice", "Charlie"]);
});
});
describe("clearPlayers", () => {
it("removes all players", () => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().clearPlayers();
expect(getState().players).toEqual([]);
expect(JSON.parse(localStorage.getItem("impstr-players") ?? "[]")).toEqual([]);
});
});
describe("loadPlayers", () => {
it("loads players from localStorage", () => {
localStorage.setItem("impstr-players", JSON.stringify(["Alice", "Bob"]));
getState().loadPlayers();
expect(getState().players).toEqual(["Alice", "Bob"]);
});
it("handles corrupt data gracefully", () => {
localStorage.setItem("impstr-players", "not json");
getState().loadPlayers();
expect(getState().players).toEqual([]);
});
it("filters out non-string entries", () => {
localStorage.setItem("impstr-players", JSON.stringify(["Alice", 42, null, "Bob"]));
getState().loadPlayers();
expect(getState().players).toEqual(["Alice", "Bob"]);
});
});
describe("startRound", () => {
it("does nothing with fewer than MIN_PLAYERS", () => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().startRound();
expect(getState().phase).toBe("setup");
});
it("transitions to round phase with enough players", () => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().addPlayer("Charlie");
getState().startRound();
const s = getState();
expect(s.phase).toBe("round");
expect(s.currentIndex).toBe(0);
expect(s.imposterIndex).toBeGreaterThanOrEqual(0);
expect(s.imposterIndex).toBeLessThan(3);
expect(s.secretWord.length).toBeGreaterThan(0);
expect(s.revealed).toBe(false);
});
});
describe("toggleReveal", () => {
it("toggles the revealed state", () => {
expect(getState().revealed).toBe(false);
getState().toggleReveal();
expect(getState().revealed).toBe(true);
getState().toggleReveal();
expect(getState().revealed).toBe(false);
});
});
describe("handoff", () => {
beforeEach(() => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().addPlayer("Charlie");
getState().startRound();
});
it("advances to next player", () => {
getState().handoff();
expect(getState().currentIndex).toBe(1);
expect(getState().revealed).toBe(false);
});
it("transitions to discussion after last player", () => {
getState().handoff(); // → Bob
getState().handoff(); // → Charlie
getState().handoff(); // → all seen → discussion
expect(getState().phase).toBe("discussion");
expect(getState().discussionIndex).toBe(0);
});
});
describe("nextDiscussion", () => {
beforeEach(() => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().addPlayer("Charlie");
getState().startRound();
getState().handoff();
getState().handoff(); // → discussion
});
it("advances discussion index", () => {
getState().nextDiscussion();
expect(getState().discussionIndex).toBe(1);
});
it("does not advance past last player", () => {
getState().nextDiscussion(); // → 1
getState().nextDiscussion(); // → 2 (last)
getState().nextDiscussion(); // → still 2
expect(getState().discussionIndex).toBe(2);
});
});
describe("endDiscussion", () => {
it("transitions to done phase", () => {
getState().endDiscussion();
expect(getState().phase).toBe("done");
});
});
describe("resetToSetup", () => {
it("resets state to setup phase", () => {
getState().addPlayer("Alice");
getState().addPlayer("Bob");
getState().addPlayer("Charlie");
getState().startRound();
getState().resetToSetup();
const s = getState();
expect(s.phase).toBe("setup");
expect(s.currentIndex).toBe(0);
expect(s.imposterIndex).toBe(-1);
expect(s.secretWord).toBe("");
expect(s.revealed).toBe(false);
expect(s.discussionIndex).toBe(0);
// players should still be there
expect(s.players).toEqual(["Alice", "Bob", "Charlie"]);
});
});
});

View File

@@ -0,0 +1,155 @@
import { create } from "zustand";
const WORDS = [
"Kekse",
"Schach",
"Giraffe",
"Spaghetti",
"Rakete",
"Blume",
"Kino",
"Drachen",
"Buch",
"Kaffee",
"Berg",
"Pizza",
"Wolke",
"Bus",
"Zitrone",
"Gitarre",
"Meer",
"Bananen",
"Löwe",
"Regen",
];
export const MIN_PLAYERS = 3;
const STORAGE_KEY = "impstr-players";
export type Phase = "setup" | "round" | "discussion" | "done";
export interface GameState {
players: string[];
currentIndex: number;
imposterIndex: number;
secretWord: string;
revealed: boolean;
discussionIndex: number;
phase: Phase;
}
export interface GameActions {
addPlayer: (name: string) => void;
removePlayer: (index: number) => void;
clearPlayers: () => void;
loadPlayers: () => void;
startRound: () => void;
toggleReveal: () => void;
handoff: () => void;
nextDiscussion: () => void;
endDiscussion: () => void;
resetToSetup: () => void;
}
function savePlayers(players: string[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(players));
} catch {
// storage full or unavailable — ignore
}
}
export const useGameStore = create<GameState & GameActions>((set, get) => ({
players: [],
currentIndex: 0,
imposterIndex: -1,
secretWord: "",
revealed: false,
discussionIndex: 0,
phase: "setup",
addPlayer: (name) => {
const trimmed = name.trim();
if (!trimmed) return;
const players = [...get().players, trimmed];
savePlayers(players);
set({ players });
},
removePlayer: (index) => {
const players = get().players.filter((_, i) => i !== index);
savePlayers(players);
set({ players });
},
clearPlayers: () => {
savePlayers([]);
set({ players: [] });
},
loadPlayers: () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
set({ players: parsed.filter((p): p is string => typeof p === "string") });
}
}
} catch {
// corrupt data — ignore
}
},
startRound: () => {
const { players } = get();
if (players.length < MIN_PLAYERS) return;
const imposterIndex = Math.floor(Math.random() * players.length);
const secretWord = WORDS[Math.floor(Math.random() * WORDS.length)];
set({
currentIndex: 0,
imposterIndex,
secretWord,
revealed: false,
discussionIndex: 0,
phase: "round",
});
},
toggleReveal: () => {
set((s) => ({ revealed: !s.revealed }));
},
handoff: () => {
const { currentIndex, players } = get();
const next = currentIndex + 1;
if (next < players.length) {
set({ currentIndex: next, revealed: false });
} else {
set({ discussionIndex: 0, phase: "discussion" });
}
},
nextDiscussion: () => {
const { discussionIndex, players } = get();
const next = discussionIndex + 1;
if (next < players.length) {
set({ discussionIndex: next });
}
},
endDiscussion: () => {
set({ phase: "done" });
},
resetToSetup: () => {
set({
currentIndex: 0,
imposterIndex: -1,
secretWord: "",
revealed: false,
discussionIndex: 0,
phase: "setup",
});
},
}));

23
src/test-setup.ts Normal file
View File

@@ -0,0 +1,23 @@
import "@testing-library/jest-dom/vitest";
// Node.js 22+ has a built-in localStorage with limited API that conflicts with jsdom's.
// Provide a full in-memory implementation for tests.
const store = new Map<string, string>();
const localStorageMock: Storage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
get length() {
return store.size;
},
key: (index: number) => [...store.keys()][index] ?? null,
};
Object.defineProperty(globalThis, "localStorage", { value: localStorageMock, writable: true });

View File

@@ -1,10 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"lib": ["ES2020", "DOM"]
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
"include": ["src", "vite-env.d.ts"]
}

2
vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />

56
vite.config.ts Normal file
View File

@@ -0,0 +1,56 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
base: "/impstr/",
plugins: [
TanStackRouterVite(),
react(),
tailwindcss(),
VitePWA({
registerType: "prompt",
manifest: {
name: "Imposter",
short_name: "Imposter",
start_url: "/impstr/",
display: "standalone",
background_color: "#f8f3e7",
theme_color: "#f8f3e7",
icons: [
{
src: "icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,png,svg,woff2}"],
},
}),
],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["src/test-setup.ts"],
},
});