# 6. Frontend (`wizard_website`) React 19 + TypeScript, built with Vite, styled with CSS modules, served by nginx. No router and no state library: the server state is the app state. ## Structure ``` src/ ├── main.tsx # mounts in StrictMode ├── App.tsx # picks the screen, connection banner, toasts, URL sync ├── App.module.css # design tokens (CSS variables) + global resets ├── api/ │ ├── types.ts # TypeScript mirror of the protocol and engine JSON │ └── useWizard.ts # THE connection hook: websocket, state, actions ├── components/ # reusable, game-agnostic-ish UI │ ├── Button/ # variants primary | secondary | ghost | danger, sizes sm | md | lg │ ├── PlayingCard/ # PlayingCard (face) and CardBack │ ├── Modal/ # dialog with backdrop, Escape to close │ └── Toasts/ # error messages bottom right ├── screens/ │ ├── HomeScreen/ # name, create lobby, join by code │ ├── LobbyScreen/ # code, players, AI, options, start │ └── GameScreen/ # the table and its parts (see below) └── util/cards.ts # suit names, card labels, hand sorting, legal-action helpers ``` Every component has its own `*.module.css` next to it. ## Data flow ```mermaid flowchart LR WS[(WebSocket)] -- state / welcome / error --> H[useWizard] H -- lobby, game, completedTrick, toasts --> App App --> Home[HomeScreen] App --> Lobby[LobbyScreen] App --> Game[GameScreen] Home -- actions.createLobby / joinLobby --> H Lobby -- actions.addAI / startGame / … --> H Game -- actions.play({type:'bid'|'play'}) --> H H -- JSON messages --> WS ``` Components never talk to the websocket directly. They receive data as props and call functions from `actions`. ## `useWizard` (`src/api/useWizard.ts`) The only stateful piece of the app. **State it exposes** | Value | Meaning | |-------|---------| | `status` | `connecting` \| `open` \| `closed` (will retry) \| `replaced` (opened in another tab, no retry) | | `playerId` | from `welcome` | | `lobby`, `game` | exactly what the last `state` message contained | | `completedTrick` | the last `trick_completed` event, kept for 2.2 s so the trick stays visible | | `toasts`, `dismissToast` | error messages, auto-removed after 4 s | | `actions` | `createLobby`, `joinLobby`, `leaveLobby`, `addAI`, `removePlayer`, `setOptions`, `startGame`, `play(action)`, `returnToLobby` | **Connection behaviour** - URL: `VITE_WS_URL` if set at build time, otherwise `new URL('../api/ws', location.href)` with `ws:`/`wss:`. On `https://game.marvinkrausser.com/wizard/website/` this gives `wss://game.marvinkrausser.com/wizard/api/ws`; in development (`http://localhost:5173/`) it gives `ws://localhost:5173/api/ws`. - On open it sends `hello` with the token from `sessionStorage` (key `wizard.token`); the `welcome` reply stores the (possibly new) token. Because `sessionStorage` is per tab, a reload resumes the same seat, while a second tab is a different player. - On close it reconnects with exponential backoff (1, 2, 4, 8, 10, 10 … s), except after close code 4000. - `send()` shows a toast if the socket is not open. ## Screen selection (`App.tsx`) | Condition | Screen | |-----------|--------| | no lobby (or no player id yet) | `HomeScreen` | | lobby `waiting` or no game | `LobbyScreen` | | otherwise | `GameScreen` | `App` also keeps `?lobby=CODE` in the address bar while you are in a lobby (so the URL is an invite link) and removes it when you are not. A red banner shows the connection status when it is not `open`. ## Screens ### HomeScreen - Name input (max 20 chars), remembered in `localStorage` (`wizard.name`). - "Create lobby" → `actions.createLobby(name)`. - Code input + "Join" → `actions.joinLobby(code, name)`. The code is prefilled from `?lobby=` so invite links work. - Buttons are disabled while disconnected or without a name. ### LobbyScreen - Big lobby code with "Copy code" / "Copy invite link" (clipboard API). - Player list with badges (Host, AI, offline, "(you)") and empty "Free seat" rows up to 6. - Host only: "+ Add AI player", "Remove" per player, the "Hidden bids" checkbox, and "Start game" (disabled with "Need N more players" until 3 seats are filled). Others see the options read-only and "Waiting for the host…". - Shows a warning if AI players are present but the AI service is offline. - Explains the fixed bidding rule (bids may not add up to the cards in hand). ### GameScreen (`screens/GameScreen/`) ``` ┌──────────────────────────────────────────────────────────────┐ │ Round 3 / 20 Lobby 7C7TAJ [Scoreboard] [Leave]│ top bar │ ┌ Round 2 complete ┐ │ RoundSummary (while bidding) │ [PlayerSeat] [PlayerSeat] … │ opponents in play order │ ┌────────────────────────────────────────────────────────┐ │ │ │ [trump card] [TrickArea: cards + owner names] │ │ table │ │ Trump: Blue │ │ │ └────────────────────────────────────────────────────────┘ │ │ Your turn to bid │ status line │ [BidPanel 0 1 2 3] │ only when you bid │ [your PlayerSeat] [Hand: clickable cards] │ └──────────────────────────────────────────────────────────────┘ ``` | Part | File | Details | |------|------|---------| | `GameScreen` | `GameScreen.tsx` | layout, status text, trump display, which panels/modals to show | | `PlayerSeat` | `PlayerSeat.tsx` | name, AI/dealer(D)/offline chips, score, "tricks / bid" (green when on target, red when over), card backs for the hand size; gold glow when it is that player's turn | | `TrickArea` | `TrickArea.tsx` | cards of the current trick with owner names; right after a trick is complete it shows the completed trick with the winner highlighted and "X wins the trick" | | `Hand` | `Hand.tsx` | your cards sorted (wizards, trump suit, other suits, jesters); clickable only on your turn in the playing phase; illegal cards are dimmed and disabled | | `BidPanel` | `BidPanel.tsx` | buttons 0…round number; illegal bids disabled; shows the running sum of bids and explains a forbidden value | | `RoundSummary` | `RoundSummary.tsx` | result of the previous round (tricks/bid and score change per player) | | `Scoreboard` | `Scoreboard.tsx` | modal "block of truth": every round with total, change and tricks/bid, plus the current round | | `GameOver` | `GameOver.tsx` | ranking, winner, "Back to lobby" (host), "Leave", "Scoreboard" | Derived display logic worth knowing (all in `GameScreen.tsx`): - **Completed trick**: `TrickArea` shows `completedTrick` while the table is empty and the 2.2 s timer has not expired. - **Trump display**: normally `observation.trump_card`. While the last trick of the *previous* round is still displayed (`completedTrick.round_number !== round_number`), the trick's own `trump_card` is shown instead, so the table never shows a trick next to the wrong trump. The trump suit is derived with the same rule as the engine (only number cards define a suit), and the label says "Wizard turned up — no trump", "Jester turned up — no trump" or "Last round — no trump" when there is none. - **Round summary**: shown while `phase === 'bidding'` and the last entry of `round_history` is the previous round — i.e. from the start of a round until the last bid is placed. Derived from server state, so it survives reloads. - **Leaving** asks for confirmation (an AI takes the seat mid-game). ## Styling - **CSS modules only.** Each component imports `styles` from its `*.module.css`; class names are scoped automatically. - **Design tokens** are CSS variables on the `.app` root element in `App.module.css` (`--bg`, `--surface`, `--accent`, `--gold`, `--suit-red`, …). All components use them, so the theme can be changed in one place. - The only global rules (`box-sizing`, `body { margin: 0 }`) are written with `:global(...)` inside `App.module.css`. - Responsive breakpoint: 720 px (cards overlap in the hand, the round summary moves into the page flow, the table stacks vertically). - Card corners are **not** rotated, so 6 and 9 cannot be confused. ## Build and configuration | File | Purpose | |------|---------| | `vite.config.ts` | `base: './'` → relative asset URLs, so the build works under any path prefix; dev server proxies `/api/*` to `WIZARD_SERVER_URL` (default `http://localhost:8000`) and strips `/api`, like Caddy does in production | | `tsconfig.app.json` | strict TypeScript for `src/` (no unused locals/params, erasable syntax only → no enums) | | `eslint.config.js` | typescript-eslint + react-hooks + react-refresh | | `Dockerfile` | `node:22-alpine` runs `npm ci && npm run build` (optional build arg `VITE_WS_URL`), then `nginx:1.27-alpine` serves `dist/` | | `nginx.conf` | listens on **8082**; `/assets/` cached forever (hashed names), everything else falls back to `index.html` with `no-cache` | Commands (in `wizard_website/`): ```bash npm install npm run dev # http://localhost:5173 npm run build # type-check (tsc -b) + production build into dist/ npm run lint ``` ## Keeping types in sync `src/api/types.ts` mirrors: - `wizard_server/app/protocol.py` (client messages), - `Lobby.view()` / `Lobby.state_for()` in `wizard_server/app/lobby.py` (server messages), - the `to_dict()` methods in `wizard_game` (cards, actions, observation, events). When you change one of those, update `types.ts`; `npm run build` then shows every place in the UI that needs to follow.