feat(frontend): draw the new board

Pits and stores are wells with scattered stones and count badges, the
viewer's store on the right. Below 600 px the board stands upright.
Playable pits preview their move on hover, animated pits are ringed and
the pit of the last move has a dashed ring.
This commit is contained in:
Halit Aksoy 2026-10-16 09:37:50 +03:00
parent 6279b5a820
commit e3e7916265
10 changed files with 387 additions and 220 deletions

View File

@ -0,0 +1,187 @@
/* The board. Sized by its container: pits are a ninth of the width, up to 124 px. Below 600 px the board stands
upright: the top player's store at the top, the viewer's at the bottom, the viewer's pits in the left column. */
.bwrap { container-type: inline-size; }
.board {
--pit: min(124px, calc(100cqw / 9.7));
--pw: var(--pit);
--ph: var(--pit);
--stone: calc(var(--ph) * .21);
display: grid;
grid-template-columns: calc(var(--pit) * 1.1) repeat(6, var(--pit)) calc(var(--pit) * 1.1);
grid-template-rows: var(--pit) var(--pit);
column-gap: calc(var(--pit) * .13);
row-gap: calc(var(--pit) * .26);
width: max-content;
margin: 0 auto;
padding: calc(var(--pit) * .22);
border-radius: calc(var(--pit) * .34);
background: var(--boardBg);
box-shadow: var(--boardShadow);
transition: background .3s;
}
.pit {
position: relative;
grid-row: var(--hr);
grid-column: var(--hc);
width: var(--pw);
height: var(--ph);
border-radius: 50%;
border: none;
padding: 0;
margin: 0;
background: var(--pit);
box-shadow: var(--pitShadow);
font: inherit;
color: inherit;
cursor: default;
transition: transform .18s ease, box-shadow .18s ease;
}
.pit.mine { cursor: pointer; }
@media (hover: hover) {
.pit.mine:hover { transform: translateY(-2px); box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); }
}
.pit:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
.pit.pv, .store.pv, .pit.sel { box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); }
.pit.land, .store.land, .pit.drop, .store.drop { box-shadow: var(--pitShadow), 0 0 0 3px var(--accent), 0 0 22px var(--accentGlow); }
.pit.cap, .store.cap { box-shadow: var(--pitShadow), 0 0 0 3px var(--live), 0 0 22px var(--live); }
.pit.last::after {
content: '';
position: absolute;
inset: -7px;
border-radius: 50%;
border: 2px dashed var(--lastRing);
pointer-events: none;
}
.store {
position: relative;
grid-row: 1 / 3;
border-radius: 999px;
background: var(--pit);
box-shadow: var(--pitShadow);
transition: box-shadow .18s;
}
.store.l { grid-column: 1; }
.store.r { grid-column: 8; }
.st {
position: absolute;
width: var(--stone);
height: var(--stone);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: var(--stoneShadow), inset 0 -2px 4px rgba(0, 0, 0, .25);
transition: left .3s ease, top .3s ease;
pointer-events: none;
}
.st::after {
content: '';
position: absolute;
left: 22%;
top: 15%;
width: 32%;
height: 20%;
border-radius: 50%;
background: rgba(255, 255, 255, .6);
}
.badge {
position: absolute;
left: 50%;
transform: translateX(-50%);
min-width: 30px;
height: 22px;
padding: 0 8px;
border-radius: 11px;
background: var(--chip);
color: var(--chipInk);
font-weight: 700;
font-size: 13px;
line-height: 22px;
box-sizing: border-box;
z-index: 2;
box-shadow: 0 0 0 2px var(--rim);
text-align: center;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.badge.t { top: -11px; }
.badge.b { bottom: -11px; }
.sbadge {
position: absolute;
left: 50%;
bottom: -14px;
transform: translateX(-50%);
min-width: 44px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: var(--chip);
color: var(--chipInk);
font-weight: 800;
font-size: 16px;
line-height: 28px;
box-sizing: border-box;
box-shadow: 0 0 0 2px var(--rim);
text-align: center;
z-index: 2;
font-variant-numeric: tabular-nums;
}
.store.r .sbadge { background: var(--accent); color: var(--onAccent); }
.pvchip {
position: absolute;
right: -6px;
top: -6px;
height: 22px;
min-width: 26px;
padding: 0 6px;
box-sizing: border-box;
border-radius: 11px;
background: var(--accent);
color: var(--onAccent);
font-weight: 800;
font-size: 12px;
line-height: 22px;
z-index: 3;
text-align: center;
pointer-events: none;
}
@container (max-width: 600px) {
.board {
--pw: min(170px, calc((100cqw - 70px) / 2));
--ph: clamp(52px, 15cqw, 66px);
--stone: calc(var(--ph) * .24);
grid-template-columns: var(--pw) var(--pw);
grid-template-rows: calc(var(--ph) * 1.05) repeat(6, var(--ph)) calc(var(--ph) * 1.05);
column-gap: 22px;
row-gap: 10px;
padding: 24px 22px;
border-radius: 32px;
}
.pit { grid-row: var(--vr); grid-column: var(--vc); }
.store.l, .store.r { grid-column: 1 / 3; }
.store.l { grid-row: 1; }
.store.r { grid-row: 8; }
.badge.t { top: 50%; left: auto; right: -13px; transform: translateY(-50%); }
.badge.b { top: 50%; bottom: auto; left: -13px; transform: translateY(-50%); }
.store.l .sbadge { bottom: auto; top: -14px; }
}

View File

@ -1,81 +1,88 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import { BoardViewModel, PitViewModel } from "@mancala/core";
import PitView from "./PitView";
import StoreView from "./StoreView";
import { Game } from "@mancala/core";
import { FunctionComponent, useState } from "react";
import { useTranslation } from "react-i18next";
import { BoardViewModel, Game, getSowingPath } from "@mancala/core";
import { Pit } from "mancala.js";
import { Context } from "../../context/context";
import { getWebTheme } from "../../theme/themes";
import PitView, { PitMarks } from "./PitView";
import StoreView from "./StoreView";
import "./BoardView.css";
// The board as the bottom player sees it: their pits along the bottom from left to right, their store on the right.
// It fills its container's width.
const BoardView: FunctionComponent<{
game: Game;
context: Context;
boardId: string;
boardViewModel: BoardViewModel;
// Show player 2 at the bottom.
revert: boolean;
onPitSelect: (index: number, pit: Pit) => void;
// Width in px, e.g. for previews; by default the board is as wide as the viewport.
width?: number;
}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect, width }) => {
const mancalaGame = game?.mancalaGame;
const theme = context.themeManager.theme;
topName: string;
bottomName: string;
// Makes the bottom player's pits buttons.
onPitSelect?: (index: number, pit: Pit) => void;
// The bottom player can move now: their pits with stones are highlighted and preview their move.
canPlay?: boolean;
// Board index of the pit the last move was played from.
lastMoveIndex?: number;
// Board index where the shown move's last stone landed.
landIndex?: number;
}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => {
const { t } = useTranslation();
const [previewIndex, setPreviewIndex] = useState<number | undefined>(undefined);
const board = game.mancalaGame.board;
const themeId = getWebTheme(context.themeManager.theme).id;
const stones = boardViewModel.pits.map((pit) => pit.stoneCount);
// The board is 92 units wide (8 columns of 11 plus padding); all board parts are sized in units.
const unit = width !== undefined ? width / 92 : (typeof window !== "undefined" ? window.innerWidth / 100 : 10);
const pitWidth = unit * 11;
const sizeVariables = {
"--board-unit": width !== undefined ? `${unit}px` : "1vw",
"--board-spacing": width !== undefined ? `${unit * 0.3}px` : "5px",
} as React.CSSProperties;
// Pits the hovered move drops stones into, with how many each, and where its last stone lands.
const preview: Record<number, number> = {};
const path = canPlay && previewIndex !== undefined ? getSowingPath(stones, previewIndex) : [];
path.forEach((index) => (preview[index] = (preview[index] ?? 0) + 1));
const previewLand = path.length > 0 ? path[path.length - 1] : undefined;
const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => (
const marksOf = (index: number): PitMarks => ({
highlight: boardViewModel.pits[index]?.highlight,
preview: preview[index],
land: previewLand !== undefined ? index === previewLand : index === landIndex,
last: index === lastMoveIndex,
});
const topPits = revert ? board.player1Pits : board.player2Pits;
const bottomPits = revert ? board.player2Pits : board.player1Pits;
const [topStore, bottomStore] = revert
? [board.player1BankIndex(), board.player2BankIndex()]
: [board.player2BankIndex(), board.player1BankIndex()];
const pitView = (pit: Pit, position: number, top: boolean) => {
const stoneCount = stones[pit.index];
const playable = !top && !!canPlay && stoneCount > 0;
const pitNumber = (top ? 5 - position : position) + 1;
return (
<PitView
key={key}
pitViewModel={pitViewModel}
context={context}
onClick={() => onPitSelect(pit.index, pit)}
fontSize={pitWidth / 6}
/>
key={pit.index}
index={pit.index}
stoneCount={stoneCount}
top={top}
position={position}
themeId={themeId}
label={t("PitLabel", { name: top ? topName : bottomName, pit: pitNumber, count: stoneCount })}
marks={marksOf(pit.index)}
playable={playable}
onClick={!top && onPitSelect ? () => onPitSelect(pit.index, board.pits[pit.index]) : undefined}
onPreview={(active) => setPreviewIndex(active ? pit.index : undefined)} />
);
const createPitViewList = (pits: Pit[]) =>
pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index]));
const player1Pits = createPitViewList(mancalaGame?.board.player1Pits);
const player2Pits = createPitViewList(mancalaGame?.board.player2Pits);
const player1BankIndex = mancalaGame?.board.player1BankIndex();
const player2BankIndex = mancalaGame?.board.player2BankIndex();
const player1BankViewModel = boardViewModel.pits[player1BankIndex];
const player2BankViewModel = boardViewModel.pits[player2BankIndex];
};
return (
<div className="board" style={{ background: theme.boardColor, ...sizeVariables }}>
<StoreView
context={context}
pitViewModel={revert ? player2BankViewModel : player1BankViewModel}
gridColumn="8 / 9"
gridRow="1 / 3"
fontSize={pitWidth / 6}
/>
<StoreView
context={context}
pitViewModel={revert ? player1BankViewModel : player2BankViewModel}
gridColumn="1 / 2"
gridRow="1 / 3"
fontSize={pitWidth / 6}
/>
{revert ? player1Pits?.reverse() : player2Pits?.reverse()}
{revert ? player2Pits : player1Pits}
<style jsx>{`
.board {
padding: calc(2 * var(--board-unit));
display: grid;
grid-template-columns: repeat(8, calc(11 * var(--board-unit)));
grid-template-rows: repeat(2, calc(11 * var(--board-unit)));
border-radius: calc(3 * var(--board-unit));
transition: background-color 0.5s;
}
`}</style>
<div className="bwrap">
<div className="board">
<StoreView index={topStore} stoneCount={stones[topStore]} side="l" themeId={themeId}
label={t("StoreLabel", { name: topName, count: stones[topStore] })} marks={marksOf(topStore)} />
{[...topPits].reverse().map((pit, position) => pitView(pit, position, true))}
{bottomPits.map((pit, position) => pitView(pit, position, false))}
<StoreView index={bottomStore} stoneCount={stones[bottomStore]} side="r" themeId={themeId}
label={t("StoreLabel", { name: bottomName, count: stones[bottomStore] })} marks={marksOf(bottomStore)} />
</div>
</div>
);
};

View File

@ -1,82 +1,70 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { getColorByBrightness, PitViewModel } from "@mancala/core";
import { Context } from "../../context/context";
import Util from "../../util/Util";
import StoneView from "./StoneView";
import { PitHighlight } from "@mancala/core";
import Stones from "./Stones";
interface PitViewProps {
pitViewModel: PitViewModel;
context: Context;
onClick: () => void;
fontSize: number;
export type PitMarks = {
highlight?: PitHighlight;
// Stones a hovered move would drop here.
preview?: number;
// The hovered move's last stone lands here, or the shown move's did.
land?: boolean;
// The last move was played from here.
last?: boolean;
};
export function getMarkClasses(marks: PitMarks): string {
const highlight = marks.highlight === "capture" ? " cap" : marks.highlight === "selected" ? " sel"
: marks.highlight ? " drop" : "";
return highlight + (marks.preview ? " pv" : "") + (marks.land ? " land" : "") + (marks.last ? " last" : "");
}
const PitView: FunctionComponent<PitViewProps> = ({ pitViewModel, context, onClick, fontSize }) => {
const textColor = getColorByBrightness(
pitViewModel.pitColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
// A pit in the top or bottom row. Position is its column from the left, 0-5. The viewer's pits are buttons.
const PitView: FunctionComponent<{
index: number;
stoneCount: number;
top: boolean;
position: number;
themeId: string;
label: string;
marks: PitMarks;
// The viewer can play this pit now.
playable?: boolean;
// Set for pits the viewer can click.
onClick?: () => void;
// Called with true when a playable pit is hovered or focused, with false when that ends.
onPreview?: (active: boolean) => void;
}> = ({ index, stoneCount, top, position, themeId, label, marks, playable, onClick, onPreview }) => {
const className = "pit" + (playable ? " mine" : "") + getMarkClasses(marks);
const style = {
"--hr": top ? "1" : "2",
"--hc": String(position + 2),
"--vr": String(position + 2),
"--vc": top ? "2" : "1",
} as React.CSSProperties;
const content = (
<>
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
</>
);
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
if (!onClick) {
return <div className={className} style={style} role="img" aria-label={label}>{content}</div>;
}
return (
<div
className="pit"
<button
className={className}
style={style}
aria-label={label}
aria-disabled={!playable}
onClick={onClick}
style={{
background: pitViewModel.pitColor,
position: "relative",
}}
>
{stones}
{pitViewModel.stoneCount > 0 && (
<span
className="stone-count"
style={{
color: textColor,
position: "absolute",
bottom: fontSize / 2,
left: "50%",
transform: "translateX(-50%)",
fontFamily: "monospace",
fontWeight: "bold",
fontSize: fontSize,
pointerEvents: "none",
}}
>
{pitViewModel.stoneCount}
</span>
)}
<style jsx>{`
.pit {
margin: var(--board-spacing);
padding: var(--board-spacing);
border-radius: calc(10 * var(--board-unit));
transition: background-color 0.5s;
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
flex-wrap: wrap;
position: relative;
}
.stone-count {
position: absolute;
bottom: calc(1.0 * var(--board-unit));
left: 50%;
transform: translateX(-50%);
font-family: monospace;
font-size: calc(1.5 * var(--board-unit));
font-weight: bold;
pointer-events: none;
}
`}</style>
</div>
onMouseEnter={() => playable && onPreview?.(true)}
onMouseLeave={() => onPreview?.(false)}
onFocus={() => playable && onPreview?.(true)}
onBlur={() => onPreview?.(false)}>
{content}
</button>
);
};

View File

@ -1,20 +0,0 @@
import * as React from "react";
import { FunctionComponent } from "react";
const StoneView: FunctionComponent<{ color: string }> = ({ color }) => {
return (
<div className="stone" style={{ background: color }}>
<style jsx>{`
.stone {
margin: 1px;
width: calc(1 * var(--board-unit));
height: calc(1 * var(--board-unit));
border-radius: calc(10 * var(--board-unit));
transition: background-color 0.5s;
}
`}</style>
</div>
);
};
export default StoneView;

View File

@ -0,0 +1,37 @@
import * as React from "react";
import { FunctionComponent } from "react";
// Stone colours per theme: highlight, body and shadow of a radial gradient.
const PALETTES: Record<string, string[][]> = {
ceviz: [["#fff1c9", "#e39a32", "#7d4610"], ["#d4fff5", "#2fa597", "#0c4a42"], ["#fffaf0", "#e2d3b6", "#94815f"], ["#ffd0c7", "#c94a3b", "#621a12"], ["#e6ecff", "#6b84c9", "#263a73"]],
gece: [["#ffffff", "#dfe3ee", "#8a92ad"], ["#f3f6ff", "#8fa8ff", "#2b3f92"], ["#fff6e6", "#e8bd78", "#8b5d1f"], ["#ffeef6", "#e7a3c4", "#8a3c62"]],
yesim: [["#e2ffee", "#3f9f6f", "#134a2d"], ["#8d939a", "#2b2f35", "#08090b"], ["#ffffff", "#efe9df", "#a59a88"], ["#ffe8c7", "#d38a3a", "#6e3c0d"]],
};
// A repeatable pseudo random number in [0, 1).
function random(seed: number): number {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
// The stones of a pit or store, scattered around its centre on a sunflower spiral. The same count and seed always
// give the same picture, so stones only move when the count changes.
const Stones: FunctionComponent<{ count: number, seed: number, themeId: string }> = ({ count, seed, themeId }) => {
const palette = PALETTES[themeId] ?? PALETTES.ceviz;
const stones = [];
for (let i = 0; i < count; i++) {
const angle = i * 2.39996 + seed;
const distance = count === 1 ? 0 : Math.sqrt((i + 0.6) / count);
const x = 50 + Math.cos(angle) * distance * 33 + (random(seed + i) - 0.5) * 3;
const y = 50 + Math.sin(angle) * distance * 33 + (random(seed + i * 3) - 0.5) * 3;
const [light, body, dark] = palette[Math.floor(random(seed * 5 + i * 7.1) * palette.length)];
stones.push(<span key={i} className="st" style={{
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
background: `radial-gradient(circle at 34% 28%, ${light} 0%, ${body} 55%, ${dark} 100%)`,
}} />);
}
return <>{stones}</>;
};
export default Stones;

View File

@ -1,59 +1,22 @@
import * as React from "react";
import { FunctionComponent } from "react";
import { Context } from "../../context/context";
import { getColorByBrightness } from "@mancala/core";
import Util from "../../util/Util";
import { PitViewModel } from "@mancala/core";
import StoneView from "./StoneView";
import { getMarkClasses, PitMarks } from "./PitView";
import Stones from "./Stones";
// A store: the top player's on the left ("l"), the viewer's on the right ("r").
const StoreView: FunctionComponent<{
context: Context;
pitViewModel: PitViewModel;
gridColumn: string;
gridRow: string;
}> = ({ context, pitViewModel, gridColumn, gridRow }) => {
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
<StoneView key={index} color={pitViewModel.stoneColor} />
));
const textColor = getColorByBrightness(
pitViewModel.pitColor,
context.themeManager.theme.textColor,
context.themeManager.theme.textLightColor
);
return (
<div
className="store"
style={{
background: pitViewModel.pitColor,
gridColumn: gridColumn,
gridRow: gridRow
}}>
{stones}
<span className="store-stone-count-text" style={{ color: textColor, transition: 'color 0.5s' }}>
{stones.length}
</span>
<style jsx>{`
.store {
margin: var(--board-spacing);
border-radius: calc(10 * var(--board-unit));
display: flex;
align-items: center;
justify-content: center;
align-content: center;
flex-wrap: wrap;
position: relative;
transition: background-color 0.5s;
}
.store-stone-count-text {
position: absolute;
bottom: calc(2 * var(--board-unit));
font-family: monospace;
font-weight: bold;
font-size: calc(2 * var(--board-unit));
}
`}</style>
index: number;
stoneCount: number;
side: "l" | "r";
themeId: string;
label: string;
marks: PitMarks;
}> = ({ index, stoneCount, side, themeId, label, marks }) => (
<div className={`store ${side}${getMarkClasses(marks)}`} role="img" aria-label={label}>
<Stones count={stoneCount} seed={index * 3.1 + 7} themeId={themeId} />
<span className="sbadge">{stoneCount}</span>
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
</div>
);
};
export default StoreView;

View File

@ -185,10 +185,11 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
<BoardView
game={createGameAtMove(baseGame, moves, moveNumber)}
context={context}
boardId={boardViewModel.id}
boardViewModel={boardViewModel}
revert={revert}
onPitSelect={() => undefined} />
topName={name(topPlayer)}
bottomName={name(bottomPlayer)}
lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
<div className="controls" style={{ color: textColor }}>

View File

@ -55,11 +55,10 @@ const GamePreviewCard: FunctionComponent<{
<BoardView
game={game}
context={context}
boardId={id}
boardViewModel={boardViewModel}
revert={!!revert}
onPitSelect={() => undefined}
width={PREVIEW_BOARD_WIDTH} />
topName={name(topPlayer)}
bottomName={name(bottomPlayer)} />
</div>
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
<style jsx>{`
@ -90,6 +89,7 @@ const GamePreviewCard: FunctionComponent<{
opacity: 0.6;
}
.board {
width: 100%;
pointer-events: none;
}
/* Built in a map callback, which styled-jsx does not scope. */

View File

@ -230,8 +230,9 @@ const GamePage: FunctionComponent<{
return;
}
setHasOngoingAction(true);
boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor =
context.themeManager.theme.pitSelectedColor;
const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)];
selectedPit.pitColor = context.themeManager.theme.pitSelectedColor;
selectedPit.highlight = "selected";
updateBoardViewModel(boardViewModel);
const gameMove: GameMove = { index: pitIndexForUser };
context.rtmt.sendMessage(channel_game_move, gameMove);
@ -350,10 +351,13 @@ const GamePage: FunctionComponent<{
{showBoardView && (
<BoardView
game={game}
boardId={isReviewing ? `review-${viewedMove}` : boardId}
boardViewModel={shownBoardViewModel!}
context={context}
onPitSelect={onPitSelect}
onPitSelect={isSpectator ? undefined : onPitSelect}
canPlay={isYourTurn && !hasOngoingAction && !isReviewing}
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
topName={topLocatedUser.name}
bottomName={bottomLocatedUser.name}
revert={isPlayer2} />
)}
{showBoardView && (

View File

@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test.
- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
action) on every page; restyle the lobby, privacy page, loading and connection panels.
- [ ] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
- [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner.
- [ ] 6. **Web app replay page**: summary card, controls card, move list.
- [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards.