diff --git a/apps/frontend/src/components/history/ReplayView.css b/apps/frontend/src/components/history/ReplayView.css
new file mode 100644
index 0000000..b8d8ffa
--- /dev/null
+++ b/apps/frontend/src/components/history/ReplayView.css
@@ -0,0 +1,32 @@
+.sum { display: flex; align-items: center; gap: 28px; padding: 22px 26px; border-radius: 28px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
+.sum-info { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
+.sum-info .pill { align-self: flex-start; }
+.sum-meta { font-size: 14px; color: var(--muted); }
+.sum-vs { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; flex: 1; min-width: 0; }
+.sum-vs .pn { font-weight: 700; font-size: 17px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.sum-vs .pn.r { text-align: right; }
+.big { font-weight: 800; font-size: 34px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; }
+
+.ctl { display: flex; flex-direction: column; gap: 14px; padding: 18px 20px; border-radius: 24px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }
+.ctlrow { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+.tbtns { display: flex; align-items: center; gap: 8px; }
+.tb { width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--surface2); color: var(--ink); display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
+.tb.big { width: 54px; height: 54px; background: var(--accent); color: var(--onAccent); }
+.tb:disabled { opacity: .35; cursor: default; }
+.speed { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); }
+.segs { display: flex; gap: 2px; padding: 3px; border-radius: 24px; background: var(--surface2); }
+.seg { min-width: 46px; height: 38px; border: none; border-radius: 19px; background: transparent; color: var(--muted); font: inherit; font-weight: 700; font-size: 14px; cursor: pointer; }
+.seg.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); }
+.range { width: 100%; height: 28px; margin: 0; accent-color: var(--accent); cursor: pointer; }
+.desc { display: flex; align-items: center; gap: 8px 10px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
+.desc b { color: var(--ink); }
+.tb:focus-visible, .seg:focus-visible, .range:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
+
+@media (max-width: 640px) {
+ .sum { flex-direction: column; align-items: stretch; gap: 16px; padding: 18px; border-radius: 24px; }
+ .sum .btn { width: 100%; }
+ .big { font-size: 28px; }
+ .sum-vs .pn { font-size: 15px; }
+ .ctlrow { justify-content: center; }
+ .tbtns { width: 100%; justify-content: space-between; }
+}
diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx
index 0885f72..61e0973 100644
--- a/apps/frontend/src/components/history/ReplayView.tsx
+++ b/apps/frontend/src/components/history/ReplayView.tsx
@@ -7,28 +7,31 @@ import {
DEFAULT_ANIMATION_INTERVAL,
GameRecord,
getBoardAfterMove,
+ describeMove,
getClockAtMove,
- getColorByBrightness,
getPlayerDisplayName,
PitAnimator,
PlayerSummary,
Theme,
} from "@mancala/core";
import { useTranslation } from "react-i18next";
-import { Link, useNavigate } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
import { v4 } from "uuid";
import { Context } from "../../context/context";
import useMoveKeys from "../../hooks/useMoveKeys";
import { createBoardViewModel } from "../../util/BoardViewModels";
-import { formatDate, formatDuration, getResultText, getScoreText, getViewerSeat } from "../../util/GameResultText";
-import { getMoveText } from "../../util/MoveText";
+import { formatDate, formatDuration, getResultPillClass, getResultText, getViewerSeat } from "../../util/GameResultText";
import BoardView from "../board/BoardView";
-import Button from "../Button";
import PageContainer from "../PageContainer";
import AppHeader from "../headerbar/AppHeader";
-import { PlusIcon } from "../Icons";
+import { FirstIcon, LastIcon, NextIcon, PauseIcon, PlayIcon, PlusIcon, PreviousIcon, ReplayIcon } from "../Icons";
+import PlayerCard from "../game/PlayerCard";
import GameClockView from "../GameClockView";
import MoveList from "./MoveList";
+import MoveTags from "./MoveTags";
+import { GAME_STEP_GAME_MOVE } from "mancala.js";
+import "../../styles/game.css";
+import "./ReplayView.css";
const SPEEDS = [1, 2, 4];
// Extra pause after a move's animation while playing, at 1x.
@@ -137,29 +140,42 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
togglePlay,
});
- const textColor = getColorByBrightness(theme.background, theme.textColor, theme.textLightColor);
- const textColorOnAppBar = getColorByBrightness(theme.appBarBgColor, theme.textColor, theme.textLightColor);
- const panelTextColor = getColorByBrightness(theme.boardColor, theme.textColor, theme.textLightColor);
const name = (player: PlayerSummary) => getPlayerDisplayName(player, t) ?? t("Anonymous");
const playerName = (playerId: string) =>
name(playerId === record.player1.userId ? record.player1 : record.player2);
const stones = getBoardAfterMove(moves, moveNumber);
const [topPlayer, topStore, bottomPlayer, bottomStore] = revert
- ? [record.player1, stones[PLAYER1_STORE], record.player2, stones[PLAYER2_STORE]]
- : [record.player2, stones[PLAYER2_STORE], record.player1, stones[PLAYER1_STORE]];
+ ? [record.player1, PLAYER1_STORE, record.player2, PLAYER2_STORE]
+ : [record.player2, PLAYER2_STORE, record.player1, PLAYER1_STORE];
const atEnd = moveNumber >= total;
+ const shownMove = moveNumber > 0 ? moves[moveNumber - 1] : undefined;
+ const nextMove = moves[moveNumber];
+ // Where the shown move's last stone landed; not while a move is being animated.
+ const isAnimating = boardViewModel.pits.some((pit) => pit.highlight);
+ const sownSteps = shownMove?.gameSteps.filter((step) => step.type === GAME_STEP_GAME_MOVE) ?? [];
+ const landIndex = !isAnimating && sownSteps.length > 0
+ ? sownSteps[sownSteps.length - 1].index % stones.length : undefined;
// Each player's clock after the shown move; games saved before the game clock have none.
const clockTimes = getClockAtMove(record, moveNumber);
const renderClock = (player: PlayerSummary) => clockTimes && (