From 961982560547ab0d82e9ac6ba53ad812bec5e1aa Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 10:21:46 +0300 Subject: [PATCH] feat(frontend): lay out the home page in the new design The game in progress comes first with a large mini board; live, popular and recent games are grids of cards with the score, a mini board and a status pill. Live and popular games are shown together; live games are hidden while nothing is being played. --- apps/frontend/src/components/Button.tsx | 41 ------- .../src/components/home/ActiveGameBanner.tsx | 54 ++++----- .../frontend/src/components/home/GameCard.tsx | 42 +++++++ .../src/components/home/GamePreviewCard.tsx | 105 ------------------ apps/frontend/src/components/home/Home.css | 70 ++++++++++++ .../src/components/home/HomeSection.tsx | 42 +------ .../src/components/home/LiveGamesSection.tsx | 62 ++--------- .../src/components/home/MiniBoard.tsx | 29 +++++ .../components/home/PopularGamesSection.tsx | 43 +++++++ .../components/home/RecentGamesSection.tsx | 19 ++-- apps/frontend/src/routes/Home.tsx | 22 ++-- apps/frontend/src/store/GameStore.ts | 10 +- docs/specs/web-redesign/tasks.md | 2 +- 13 files changed, 250 insertions(+), 291 deletions(-) delete mode 100644 apps/frontend/src/components/Button.tsx create mode 100644 apps/frontend/src/components/home/GameCard.tsx delete mode 100644 apps/frontend/src/components/home/GamePreviewCard.tsx create mode 100644 apps/frontend/src/components/home/Home.css create mode 100644 apps/frontend/src/components/home/MiniBoard.tsx create mode 100644 apps/frontend/src/components/home/PopularGamesSection.tsx diff --git a/apps/frontend/src/components/Button.tsx b/apps/frontend/src/components/Button.tsx deleted file mode 100644 index 105917e..0000000 --- a/apps/frontend/src/components/Button.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../context/context"; -import { getColorByBrightness } from "@mancala/core"; - -const Button: FunctionComponent<{ - context: Context; - text: String; - onClick: () => void; - color: string; -}> = ({ context, text, color, onClick }) => { - const textColor = getColorByBrightness( - color, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( - - ); -}; - -export default Button; diff --git a/apps/frontend/src/components/home/ActiveGameBanner.tsx b/apps/frontend/src/components/home/ActiveGameBanner.tsx index e8d0b78..8b6219c 100644 --- a/apps/frontend/src/components/home/ActiveGameBanner.tsx +++ b/apps/frontend/src/components/home/ActiveGameBanner.tsx @@ -1,49 +1,39 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness, getPlayerDisplayName, LiveGameSummary } from "@mancala/core"; +import { getPlayerDisplayName, LiveGameSummary } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { Context } from "../../context/context"; -import Button from "../Button"; +import { Link } from "react-router-dom"; +import MiniBoard, { getStores } from "./MiniBoard"; // Shown at the top of the home page while the user has a game in progress. const ActiveGameBanner: FunctionComponent<{ - context: Context, game: LiveGameSummary, userId: string, onContinue: () => void, -}> = ({ context, game, userId, onContinue }) => { +}> = ({ game, userId, onContinue }) => { const { t } = useTranslation(); - const theme = context.themeManager.theme; - const textColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor); const opponent = game.player1.userId === userId ? game.player2 : game.player1; - const turn = game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn"); + const revert = game.player2.userId === userId; + const stores = getStores(game.board, revert); + const details = [ + game.turnPlayerId === userId ? t("YourTurn") : t("OpponentTurn"), + t("MoveNumber", { number: game.moveCount }), + t("StoreScores", { mine: stores.bottom, theirs: stores.top }), + ].join(" · "); return ( -
+
-
{t("GameInProgress")}
-
{`${t("Versus")} ${getPlayerDisplayName(opponent, t) ?? t("Anonymous")} · ${turn}`}
+ {t("GameInProgress")} +

{t("GameWithOpponent", { name: getPlayerDisplayName(opponent, t) ?? t("Anonymous") })}

+

{details}

+
+ +
-
+ + + + ); }; diff --git a/apps/frontend/src/components/home/GameCard.tsx b/apps/frontend/src/components/home/GameCard.tsx new file mode 100644 index 0000000..ce4e445 --- /dev/null +++ b/apps/frontend/src/components/home/GameCard.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { getPlayerDisplayName, PlayerSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import MiniBoard, { getStores } from "./MiniBoard"; + +// A game as a card: the players with the score, a mini board, a status pill and details. Links to the game. +const GameCard: FunctionComponent<{ + id: string, + player1: PlayerSummary, + player2: PlayerSummary, + board: number[], + // Show player 2's side at the bottom. + revert?: boolean, + status: string, + // Class of the status pill: "live", "win", "lose" or "". + statusKind: string, + details: string, +}> = ({ id, player1, player2, board, revert, status, statusKind, details }) => { + const { t } = useTranslation(); + const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); + const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2]; + const stores = getStores(board, revert); + return ( + +
+ {name(topPlayer)} + {`${stores.top} – ${stores.bottom}`} + {name(bottomPlayer)} +
+ +
+ {status} + {details} +
+ + ); +}; + +export default GameCard; diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx deleted file mode 100644 index 3708e03..0000000 --- a/apps/frontend/src/components/home/GamePreviewCard.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { FunctionComponent, useMemo } from "react"; -import { - BoardViewModelFactory, - createGameFromBoard, - getColorByBrightness, - getPlayerDisplayName, - PitViewModelFactory, - PlayerSummary, -} from "@mancala/core"; -import { useTranslation } from "react-i18next"; -import { Context } from "../../context/context"; -import BoardView from "../board/BoardView"; - -export const PREVIEW_BOARD_WIDTH = 260; - -// A game as a card: the players, the real board at preview size, and a few lines of details. -const GamePreviewCard: FunctionComponent<{ - context: Context, - id: string, - player1: PlayerSummary, - player2: PlayerSummary, - board: number[], - // Highlighted as the player to move. - turnPlayerId?: string, - // Show player 2's side at the bottom. - revert?: boolean, - lines: string[], - onClick: () => void, -}> = ({ context, id, player1, player2, board, turnPlayerId, revert, lines, onClick }) => { - const { t } = useTranslation(); - const theme = context.themeManager.theme; - const textColor = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor); - - const game = useMemo(() => createGameFromBoard({ - id, player1Id: player1.userId, player2Id: player2.userId, turnPlayerId: turnPlayerId ?? player1.userId, board, - }), [id, player1.userId, player2.userId, turnPlayerId, board.join(",")]); - const boardViewModel = BoardViewModelFactory.create(id, board.map((stoneCount, index) => PitViewModelFactory.create({ - id: String(index), stoneCount, stoneColor: theme.stoneColor, pitColor: theme.pitColor, - }))); - - const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous"); - const nameStyle = (player: PlayerSummary): React.CSSProperties => - ({ fontWeight: player.userId === turnPlayerId ? "bold" : "normal" }); - const [bottomPlayer, topPlayer] = revert ? [player2, player1] : [player1, player2]; - - return ( -
-
- {name(topPlayer)} - {t("Versus")} - {name(bottomPlayer)} -
-
- -
- {lines.map((line, index) =>
{line}
)} - -
- ); -}; - -export default GamePreviewCard; diff --git a/apps/frontend/src/components/home/Home.css b/apps/frontend/src/components/home/Home.css new file mode 100644 index 0000000..74fc073 --- /dev/null +++ b/apps/frontend/src/components/home/Home.css @@ -0,0 +1,70 @@ +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); + gap: 40px; + align-items: center; + padding: 32px; + border-radius: 32px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); +} + +.hero h1 { margin: 14px 0 8px; font-size: 34px; line-height: 1.1; letter-spacing: -.02em; } +.hero p { margin: 0 0 22px; color: var(--muted); font-size: 16px; } +.hero .row { display: flex; gap: 10px; flex-wrap: wrap; } +.hero-board { display: block; text-decoration: none; border-radius: 26px; } +.hero-board:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } + +.sec { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 18px 0 -10px; } +.sec h2 { margin: 0; font-size: 22px; font-weight: 800; letter-spacing: -.01em; } +.secmeta { font-size: 14px; color: var(--muted); } +.empty { margin: 0; color: var(--muted); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } + +.card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + border-radius: 24px; + background: var(--surface); + color: var(--ink); + text-decoration: none; + box-shadow: inset 0 0 0 1px var(--line); + transition: transform .2s, box-shadow .2s; +} + +@media (hover: hover) { + .card:hover { transform: translateY(-3px); box-shadow: inset 0 0 0 1px var(--accentSoft), 0 16px 30px rgba(0, 0, 0, .18); } +} + +.vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 10px; font-weight: 700; font-size: 15px; } +.pn { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pn.r { text-align: right; } +.score { font-weight: 800; font-size: 17px; font-variant-numeric: tabular-nums; padding: 2px 10px; border-radius: 10px; background: var(--surface2); white-space: nowrap; } +.meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); min-width: 0; } +.meta .pill { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.meta > span:last-child { text-align: right; } + +/* Mini board: the top player's store on the left, the bottom player's on the right */ + +.mini { display: grid; grid-template-columns: 1.15fr repeat(6, minmax(0, 1fr)) 1.15fr; gap: 5px; padding: 8px; border-radius: 16px; background: var(--miniBg); } +.mini .mp { aspect-ratio: 1; border-radius: 50%; background: var(--miniPit); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms { grid-row: 1 / 3; border-radius: 999px; background: var(--miniPit); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 8px; font-size: 13px; font-weight: 800; color: var(--ink); box-shadow: inset 0 2px 4px rgba(0, 0, 0, .35); } +.mini .ms.l { grid-column: 1; } +.mini .ms.r { grid-column: 8; color: var(--accent); } +.mini.lg { gap: 9px; padding: 16px; border-radius: 26px; } +.mini.lg .mp { font-size: 16px; } +.mini.lg .ms { font-size: 20px; padding-bottom: 14px; } + +@media (max-width: 640px) { + .hero { grid-template-columns: 1fr; padding: 20px; gap: 20px; border-radius: 26px; } + .hero h1 { font-size: 25px; } + .hero .btn { flex: 1; } + .mini.lg { gap: 6px; padding: 10px; border-radius: 20px; } + .mini.lg .mp { font-size: 13px; } + .mini.lg .ms { font-size: 15px; padding-bottom: 10px; } + .grid { grid-template-columns: 1fr; gap: 14px; } + .sec { margin: 12px 0 -4px; } +} diff --git a/apps/frontend/src/components/home/HomeSection.tsx b/apps/frontend/src/components/home/HomeSection.tsx index 6f7a7a2..778f3f9 100644 --- a/apps/frontend/src/components/home/HomeSection.tsx +++ b/apps/frontend/src/components/home/HomeSection.tsx @@ -1,44 +1,14 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness } from "@mancala/core"; -import { Context } from "../../context/context"; -// A titled block of the home page; children are laid out as wrapping cards. -const HomeSection: FunctionComponent<{ context: Context, title: string, emptyText?: string }> = (props) => { - const theme = props.context.themeManager.theme; - const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor); +// A titled block of the home page; children are laid out as a grid of cards. +const HomeSection: FunctionComponent<{ title: string, subtitle: string, emptyText?: string }> = (props) => { const isEmpty = React.Children.count(props.children) === 0; return ( -
-

{props.title}

- {isEmpty && props.emptyText ?

{props.emptyText}

:
{props.children}
} - -
+ <> +

{props.title}

{props.subtitle}
+ {isEmpty && props.emptyText ?

{props.emptyText}

:
{props.children}
} + ); }; diff --git a/apps/frontend/src/components/home/LiveGamesSection.tsx b/apps/frontend/src/components/home/LiveGamesSection.tsx index a52ffec..7d8ad65 100644 --- a/apps/frontend/src/components/home/LiveGamesSection.tsx +++ b/apps/frontend/src/components/home/LiveGamesSection.tsx @@ -7,80 +7,42 @@ import { LiveGameSummary, } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { Context } from "../../context/context"; -import { PopularGames } from "../../store/GameStore"; -import { formatDate, getResultText, getScoreText } from "../../util/GameResultText"; -import GamePreviewCard from "./GamePreviewCard"; +import GameCard from "./GameCard"; import HomeSection from "./HomeSection"; -const GAME_COUNT = 5; - -// Live games, updated as they are played; when nothing is live, the most watched finished games. +// Live games, updated as they are played; hidden while nothing is being played. const LiveGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => { - const { t, i18n } = useTranslation(); - const navigate = useNavigate(); - const [popular, setPopular] = useState(undefined); + const { t } = useTranslation(); + const [games, setGames] = useState([]); useEffect(() => { - let disposed = false; - const loadPopular = () => context.gameStore.getPopularGames(GAME_COUNT) - .then((games) => !disposed && setPopular(games)) - .catch((error) => console.error("(Home) Could not load popular games", error)); - const onLiveGames = (message: { games: LiveGameSummary[] }) => { - if (message.games.length > 0) setPopular({ kind: "live", games: message.games }); - else loadPopular(); - }; + const onLiveGames = (message: { games: LiveGameSummary[] }) => setGames(message.games); // Subscribe whenever the connection is (re)established; the server forgets subscriptions of offline users. const subscribe = () => context.rtmt.sendMessage(channel_subscribe_live_games, {}); context.rtmt.addMessageListener(channel_on_live_games, onLiveGames); context.rtmt.on("open", subscribe); if (context.rtmt.connectionState === "connected") subscribe(); - loadPopular(); return () => { - disposed = true; context.rtmt.off("open", subscribe); context.rtmt.removeMessageListener(channel_on_live_games, onLiveGames); if (context.rtmt.connectionState === "connected") context.rtmt.sendMessage(channel_unsubscribe_live_games, {}); }; }, []); - if (!popular) return <>; - const openGame = (id: string) => navigate(`/game/${id}`); - - if (popular.kind === "live") { - return ( - - {popular.games.map((game) => ( - openGame(game.id)} /> - ))} - - ); - } + if (games.length === 0) return <>; return ( - - {popular.games.map((game) => ( - + {games.map((game) => ( + openGame(game.id)} /> + status={t("Live")} + statusKind="live" + details={`${t("Watching", { count: game.spectatorCount })} · ${t("MoveNumber", { number: game.moveCount })}`} /> ))} ); diff --git a/apps/frontend/src/components/home/MiniBoard.tsx b/apps/frontend/src/components/home/MiniBoard.tsx new file mode 100644 index 0000000..b6e1332 --- /dev/null +++ b/apps/frontend/src/components/home/MiniBoard.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; + +const PLAYER1_PITS = [0, 1, 2, 3, 4, 5]; +const PLAYER2_PITS = [7, 8, 9, 10, 11, 12]; +const PLAYER1_STORE = 6; +const PLAYER2_STORE = 13; + +// Stone counts on a small board, as the bottom player sees it. Empty pits show no number. +const MiniBoard: FunctionComponent<{ board: number[], revert?: boolean, large?: boolean }> = ({ board, revert, large }) => { + const [topPits, topStore, bottomPits, bottomStore] = revert + ? [PLAYER1_PITS, PLAYER1_STORE, PLAYER2_PITS, PLAYER2_STORE] + : [PLAYER2_PITS, PLAYER2_STORE, PLAYER1_PITS, PLAYER1_STORE]; + const pit = (index: number) =>
{board[index] || ""}
; + return ( + + ); +}; + +export const getStores = (board: number[], revert?: boolean) => revert + ? { top: board[PLAYER1_STORE], bottom: board[PLAYER2_STORE] } + : { top: board[PLAYER2_STORE], bottom: board[PLAYER1_STORE] }; + +export default MiniBoard; diff --git a/apps/frontend/src/components/home/PopularGamesSection.tsx b/apps/frontend/src/components/home/PopularGamesSection.tsx new file mode 100644 index 0000000..7e453d7 --- /dev/null +++ b/apps/frontend/src/components/home/PopularGamesSection.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { FunctionComponent, useEffect, useState } from "react"; +import { FinishedGameSummary } from "@mancala/core"; +import { useTranslation } from "react-i18next"; +import { Context } from "../../context/context"; +import { formatDate, getResultPillClass, getResultText } from "../../util/GameResultText"; +import GameCard from "./GameCard"; +import HomeSection from "./HomeSection"; + +const GAME_COUNT = 6; + +// The most watched finished games. +const PopularGamesSection: FunctionComponent<{ context: Context }> = ({ context }) => { + const { t, i18n } = useTranslation(); + const [games, setGames] = useState(undefined); + + useEffect(() => { + let disposed = false; + context.gameStore.getPopularFinishedGames(GAME_COUNT) + .then((popular) => !disposed && setGames(popular)) + .catch((error) => console.error("(Home) Could not load popular games", error)); + return () => { disposed = true; }; + }, []); + + if (!games) return <>; + return ( + + {games.map((game) => ( + + ))} + + ); +}; + +export default PopularGamesSection; diff --git a/apps/frontend/src/components/home/RecentGamesSection.tsx b/apps/frontend/src/components/home/RecentGamesSection.tsx index 240111a..8201fc3 100644 --- a/apps/frontend/src/components/home/RecentGamesSection.tsx +++ b/apps/frontend/src/components/home/RecentGamesSection.tsx @@ -2,10 +2,9 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { FinishedGameSummary } from "@mancala/core"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { Context } from "../../context/context"; -import { formatDate, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText"; -import GamePreviewCard from "./GamePreviewCard"; +import { formatDate, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText"; +import GameCard from "./GameCard"; import HomeSection from "./HomeSection"; const GAME_COUNT = 3; @@ -13,7 +12,6 @@ const GAME_COUNT = 3; // The user's last finished games, each shown from their side of the board. const RecentGamesSection: FunctionComponent<{ context: Context, userId: string }> = ({ context, userId }) => { const { t, i18n } = useTranslation(); - const navigate = useNavigate(); const [games, setGames] = useState(undefined); useEffect(() => { @@ -27,21 +25,18 @@ const RecentGamesSection: FunctionComponent<{ context: Context, userId: string } if (!games) return <>; return ( - + {games.map((game) => ( - navigate(`/game/${game.id}`)} /> + status={getResultText(game, userId, t)} + statusKind={getResultPillClass(game, userId)} + details={formatDate(game.endedAt, i18n.language)} /> ))} ); diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index b9bc59f..e61acf6 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -8,6 +8,8 @@ import { PlusIcon } from "../components/Icons"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; +import PopularGamesSection from "../components/home/PopularGamesSection"; +import "../components/home/Home.css"; import { Context } from "../context/context"; import { useNavigate } from "react-router-dom"; import Util from "../util/Util"; @@ -48,15 +50,17 @@ const Home: FunctionComponent<{ {t("NewGame")} - {activeGame && authUser && ( - navigate(`/game/${activeGame.id}`)} /> - )} - - {authUser && } +
+ {activeGame && authUser && ( + navigate(`/game/${activeGame.id}`)} /> + )} + + + {authUser && } +
); }; diff --git a/apps/frontend/src/store/GameStore.ts b/apps/frontend/src/store/GameStore.ts index 9fccc8a..0c8ca73 100644 --- a/apps/frontend/src/store/GameStore.ts +++ b/apps/frontend/src/store/GameStore.ts @@ -4,14 +4,13 @@ import { HttpServiceImpl } from "../service/HttpService"; export type LoadedGame = { kind: "live", game: Game } | { kind: "finished", record: GameRecord }; -export type PopularGames = { kind: "live", games: LiveGameSummary[] } | { kind: "finished", games: FinishedGameSummary[] }; - export interface GameStore { // A live game, or the saved record of a finished one; undefined if there is no such game. get(id: string): Promise; getActiveGame(token: string): Promise; getMyRecentGames(token: string, limit: number): Promise; - getPopularGames(limit: number): Promise; + // The most watched finished games, also while games are live. + getPopularFinishedGames(limit: number): Promise; } export class GameStoreImpl implements GameStore { @@ -43,8 +42,9 @@ export class GameStoreImpl implements GameStore { return json.games; } - async getPopularGames(limit: number): Promise { - return this.getJson(`/games/popular?limit=${limit}`); + async getPopularFinishedGames(limit: number): Promise { + const json = await this.getJson(`/games/popular?kind=finished&limit=${limit}`); + return json.games; } private async getJson(route: string, token?: string): Promise { diff --git a/docs/specs/web-redesign/tasks.md b/docs/specs/web-redesign/tasks.md index 7e39c7f..49ddd0f 100644 --- a/docs/specs/web-redesign/tasks.md +++ b/docs/specs/web-redesign/tasks.md @@ -10,6 +10,6 @@ Work through the tasks in order; each ends with its checks passing and a commit. - [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview. - [x] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner. - [x] 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. +- [x] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards. - [ ] 8. **Checks and docs**: build, Chrome check of every page at desktop and phone width in all three themes; `AGENTS.md` and `TODO.md`.