chesspal 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/analysis/analyse.js +7 -2
  3. package/dist/commands/analysis/annotate.js +6 -2
  4. package/dist/commands/answers/agents.d.ts +2 -0
  5. package/dist/commands/answers/agents.js +5 -0
  6. package/dist/commands/answers/index.d.ts +2 -2
  7. package/dist/commands/answers/index.js +2 -2
  8. package/dist/commands/answers/lines.js +3 -1
  9. package/dist/commands/answers/say.d.ts +7 -1
  10. package/dist/commands/answers/say.js +25 -7
  11. package/dist/commands/answers/study.d.ts +27 -5
  12. package/dist/commands/answers/study.js +121 -10
  13. package/dist/commands/game/look.d.ts +1 -0
  14. package/dist/commands/game/look.js +8 -8
  15. package/dist/commands/game/position.d.ts +1 -0
  16. package/dist/commands/game/position.js +5 -5
  17. package/dist/commands/index.d.ts +1 -1
  18. package/dist/core/index.d.ts +1 -1
  19. package/dist/core/index.js +1 -1
  20. package/dist/core/moods.d.ts +2 -0
  21. package/dist/core/moods.js +11 -2
  22. package/dist/games/analysis.d.ts +11 -0
  23. package/dist/games/analysis.js +23 -0
  24. package/dist/games/archive.d.ts +9 -1
  25. package/dist/games/archive.js +18 -1
  26. package/dist/games/current-game.d.ts +23 -1
  27. package/dist/games/current-game.js +6 -1
  28. package/dist/games/index.d.ts +3 -3
  29. package/dist/games/index.js +2 -2
  30. package/dist/page/app/app.js +148 -29
  31. package/dist/page/app/keys.js +16 -1
  32. package/dist/page/app/title.js +6 -3
  33. package/dist/page/board/board.css +6 -0
  34. package/dist/page/game/column.js +5 -4
  35. package/dist/page/game/game.css +150 -1
  36. package/dist/page/game/move-list.js +48 -10
  37. package/dist/page/game/variation.js +65 -0
  38. package/dist/page/guide/en.md +1 -0
  39. package/dist/page/guide/it.md +1 -0
  40. package/dist/page/table/window.js +8 -5
  41. package/dist/page/talk/chat.css +8 -0
  42. package/dist/page/talk/talk.js +20 -2
  43. package/dist/page/text.js +20 -0
  44. package/dist/server/bell/bell.d.ts +5 -2
  45. package/dist/server/bell/bell.js +36 -12
  46. package/dist/server/page/game.js +38 -7
  47. package/dist/server/page/page.js +1 -1
  48. package/dist/server/page/table.d.ts +12 -2
  49. package/dist/server/page/table.js +18 -6
  50. package/dist/server/table/hosts/chats.js +1 -1
  51. package/dist/server/table/hosts/play.d.ts +3 -1
  52. package/dist/server/table/hosts/play.js +6 -5
  53. package/dist/server/table/hosts/seat.d.ts +5 -1
  54. package/dist/server/table/hosts/seat.js +1 -1
  55. package/instructions/analysis.md +2 -2
  56. package/instructions/opponent.md +1 -1
  57. package/package.json +1 -1
@@ -38,5 +38,16 @@ export declare function startAnalysis(folder: string, game: string, now: Date, {
38
38
  export declare function forgetAnalyst(folder: string): void;
39
39
  /** The game in analysis, or none when there is none, or analysis.json cannot be read. */
40
40
  export declare function gameInAnalysis(folder: string): Analysis | undefined;
41
+ /**
42
+ * Stops the analysis of the game in analysis at the player's word on the board page: the page goes back to the game over, status no longer names it, and
43
+ * the review written so far stays on the game. analysis.json keeps the game stopped, with the analyst that held it, until the next analysis starts: their
44
+ * later writes are refused. False when another game, or none, is in analysis.
45
+ */
46
+ export declare function stopAnalysis(folder: string, game: string): boolean;
47
+ /** The game whose analysis the player stopped on the board page, with the analyst that held it then; none once another analysis starts. */
48
+ export declare function stoppedAnalysis(folder: string): {
49
+ game: string;
50
+ analyst?: AnalystRun;
51
+ } | undefined;
41
52
  /** Ends the analysis, when a new game starts or its review is deleted. */
42
53
  export declare function endAnalysis(folder: string): void;
@@ -50,6 +50,29 @@ export function gameInAnalysis(folder) {
50
50
  return undefined;
51
51
  }
52
52
  }
53
+ /**
54
+ * Stops the analysis of the game in analysis at the player's word on the board page: the page goes back to the game over, status no longer names it, and
55
+ * the review written so far stays on the game. analysis.json keeps the game stopped, with the analyst that held it, until the next analysis starts: their
56
+ * later writes are refused. False when another game, or none, is in analysis.
57
+ */
58
+ export function stopAnalysis(folder, game) {
59
+ const analysis = gameInAnalysis(folder);
60
+ if (analysis?.game !== game) {
61
+ return false;
62
+ }
63
+ writeAtomically(join(folder, fileName), `${JSON.stringify({ stopped: game, ...withAnalyst(analysis.analyst) }, null, 2)}\n`);
64
+ return true;
65
+ }
66
+ /** The game whose analysis the player stopped on the board page, with the analyst that held it then; none once another analysis starts. */
67
+ export function stoppedAnalysis(folder) {
68
+ try {
69
+ const stopped = JSON.parse(readFileSync(join(folder, fileName), "utf8"));
70
+ return typeof stopped.stopped === "string" ? { game: stopped.stopped, ...withAnalyst(isAnalystRun(stopped.analyst) ? stopped.analyst : undefined) } : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
53
76
  /** Ends the analysis, when a new game starts or its review is deleted. */
54
77
  export function endAnalysis(folder) {
55
78
  rmSync(join(folder, fileName), { force: true });
@@ -1,4 +1,4 @@
1
- import { type GameRecord } from "../core/index.ts";
1
+ import { type GameRecord, type Mood } from "../core/index.ts";
2
2
  import { type CurrentGame } from "./current-game.ts";
3
3
  /** Where the game goes in the archive, in the player's folder: named by when it started, in the player's time, games/2026-11-02-153012.pgn. */
4
4
  export declare function archiveName(current: CurrentGame): string;
@@ -22,6 +22,14 @@ export declare function archiveEnded(folder: string, current: CurrentGame, openi
22
22
  archive?: string;
23
23
  warning?: string;
24
24
  };
25
+ /**
26
+ * Gives the mood the AI ends with to a game over already in the archive, on its last move, beside the review the analyst and Chesspal may have written there
27
+ * since; archives the game when it is not there.
28
+ */
29
+ export declare function archiveMood(folder: string, current: CurrentGame, mood: Mood, opening: OpeningTags): {
30
+ archive?: string;
31
+ warning?: string;
32
+ };
25
33
  /**
26
34
  * Writes the coach's judgments into a game over already in the archive, beside what the analyst may have written there since (Federico);
27
35
  * archives the game when it is not there.
@@ -1,7 +1,7 @@
1
1
  // The finished games, as PGN in games/ (D-9, D-41), with the AI's plans for the analyst (D-21); and the games played elsewhere, kept to study in study/ (D-68).
2
2
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
- import { movesOnly, pgnDate, readPgn, readPgnTags, readPgnUnplayed, unsigned, withAiHost, withAiModel, writePgn } from "../core/index.js";
4
+ import { movesOnly, pgnDate, readPgn, readPgnTags, readPgnUnplayed, recordWithMood, unsigned, withAiHost, withAiModel, writePgn } from "../core/index.js";
5
5
  import { aiHostOf, aiModelOf } from "./ai-model.js";
6
6
  import { coachWords, withCoach } from "./coach.js";
7
7
  import { endOf, loadGame } from "./current-game.js";
@@ -65,6 +65,23 @@ export function archiveEnded(folder, current, opening) {
65
65
  return { warning: `${error.message} The game is saved in game.json: it goes to the archive when a new game starts.` };
66
66
  }
67
67
  }
68
+ /**
69
+ * Gives the mood the AI ends with to a game over already in the archive, on its last move, beside the review the analyst and Chesspal may have written there
70
+ * since; archives the game when it is not there.
71
+ */
72
+ export function archiveMood(folder, current, mood, opening) {
73
+ const name = archivedName(folder, current);
74
+ if (name === undefined) {
75
+ return archiveEnded(folder, current, opening);
76
+ }
77
+ try {
78
+ writeAtomically(join(folder, name), writePgn(recordWithMood(archivedGame(folder, name), mood)));
79
+ return { archive: name };
80
+ }
81
+ catch (error) {
82
+ return { warning: `Chesspal could not write the mood into ${name}: ${error.message}` };
83
+ }
84
+ }
68
85
  /**
69
86
  * Writes the coach's judgments into a game over already in the archive, beside what the analyst may have written there since (Federico);
70
87
  * archives the game when it is not there.
@@ -28,11 +28,26 @@ export interface TalkLine {
28
28
  readonly agent?: true;
29
29
  readonly shown?: Shown;
30
30
  }
31
- /** A position of a game in analysis: the game's name, how many of its moves the page showed, and the last of them numbered, 52...Kf6; none at the start. */
31
+ /**
32
+ * A position of a game in analysis: the game's name, how many of its moves the page showed, and the last of them numbered, 52...Kf6; none at the start. With
33
+ * the variation the player played there (D-116), on the board or set aside.
34
+ */
32
35
  export interface Shown {
33
36
  readonly game: string;
34
37
  readonly at: number;
35
38
  readonly move?: string;
39
+ readonly variation?: ShownVariation;
40
+ }
41
+ /**
42
+ * A variation of the page's (D-116): how many of the game's moves come before it, its moves in SAN, how many of them the board showed (0 with the board back
43
+ * on the game), the line as PGN writes it, 12...Kf8 13.Nxe5 Bxe2, and the move shown numbered, 13.Nxe5.
44
+ */
45
+ export interface ShownVariation {
46
+ readonly at: number;
47
+ readonly moves: readonly string[];
48
+ readonly cursor: number;
49
+ readonly text: string;
50
+ readonly move?: string;
36
51
  }
37
52
  /** How a game ended: its result, and why when the record says it or the board decided it. */
38
53
  export interface GameEnd {
@@ -76,5 +91,12 @@ export declare const savedLine: z.ZodObject<{
76
91
  game: z.ZodString;
77
92
  at: z.ZodInt;
78
93
  move: z.ZodOptional<z.ZodString>;
94
+ variation: z.ZodOptional<z.ZodObject<{
95
+ at: z.ZodInt;
96
+ moves: z.ZodReadonly<z.ZodArray<z.ZodString>>;
97
+ cursor: z.ZodInt;
98
+ text: z.ZodString;
99
+ move: z.ZodOptional<z.ZodString>;
100
+ }, z.core.$strip>>;
79
101
  }, z.core.$strip>>;
80
102
  }, z.core.$strip>;
@@ -94,7 +94,12 @@ export const savedLine = z.object({
94
94
  ply: z.int().min(0),
95
95
  at: z.iso.datetime(),
96
96
  agent: z.literal(true).optional(),
97
- shown: z.object({ game: z.string(), at: z.int().min(0), move: z.string().optional() }).optional(),
97
+ shown: z.object({
98
+ game: z.string(),
99
+ at: z.int().min(0),
100
+ move: z.string().optional(),
101
+ variation: z.object({ at: z.int().min(0), moves: z.array(z.string()).min(1).readonly(), cursor: z.int().min(0), text: z.string(), move: z.string().optional() }).optional(),
102
+ }).optional(),
98
103
  });
99
104
  const savedClock = z.object({
100
105
  base: z.int().positive(),
@@ -1,9 +1,9 @@
1
1
  export { aiHostOf, aiModelOf, forgetTheAi, noteAiHost, noteAiModel } from "./ai-model.ts";
2
- export { type Analysis, type AnalysisStart, endAnalysis, forgetAnalyst, gameInAnalysis, startAnalysis } from "./analysis.ts";
3
- export { archiveCoached, archivedGame, archivedGames, archivedGameUnplayed, archivedName, archivedTags, archivedWhen, archiveEnded, archiveGame, archiveName, deleteGame, GameMissing, gameStemOf, hasListTags, isGameName, isOverOnTable, keepToStudy, keptGame, keptGames, listedName, movesPgn, type OpeningTags, playerPgn, rewriteGame, startOfNewGame, } from "./archive.ts";
2
+ export { type Analysis, type AnalysisStart, endAnalysis, forgetAnalyst, gameInAnalysis, startAnalysis, stopAnalysis, stoppedAnalysis } from "./analysis.ts";
3
+ export { archiveCoached, archivedGame, archivedGames, archivedGameUnplayed, archivedName, archivedTags, archivedWhen, archiveEnded, archiveGame, archiveMood, archiveName, deleteGame, GameMissing, gameStemOf, hasListTags, isGameName, isOverOnTable, keepToStudy, keptGame, keptGames, listedName, movesPgn, type OpeningTags, playerPgn, rewriteGame, startOfNewGame, } from "./archive.ts";
4
4
  export { type ChatStart, cleanSince, lastChatStart, saveChatStart } from "./chat-start.ts";
5
5
  export { type CoachHint, type CoachJudgment, type CoachLead, coachShown, coachStarted, coachWaitingSince, type CoachWords, coachWords, forgetCoach, keepHint, keepJudgment, keepLead, startCoach, withCoach } from "./coach.ts";
6
- export { bothSidesMoved, type CurrentGame, drawOfferOf, endOf, GameChanged, type GameEnd, gameStamp, loadGame, removeGame, rerunIfChanged, saveGame, type Shown, type Speaker, type TalkLine } from "./current-game.ts";
6
+ export { bothSidesMoved, type CurrentGame, drawOfferOf, endOf, GameChanged, type GameEnd, gameStamp, loadGame, removeGame, rerunIfChanged, saveGame, type Shown, type ShownVariation, type Speaker, type TalkLine } from "./current-game.ts";
7
7
  export { makeFolder, renameOver, writeAtomically } from "./files.ts";
8
8
  export { commentWithPlan, currentPlan, planOf, withoutPlans } from "./plans.ts";
9
9
  export { forgetPositionShown, hostProcess, notePositionShown, type PositionShown, positionShownNote } from "./position-shown.ts";
@@ -1,7 +1,7 @@
1
1
  // The player's games on disk: the current one in game.json, the finished ones in games/ (D-27, D-41), the games kept to study in study/ (D-68). Below the commands, above the core.
2
2
  export { aiHostOf, aiModelOf, forgetTheAi, noteAiHost, noteAiModel } from "./ai-model.js";
3
- export { endAnalysis, forgetAnalyst, gameInAnalysis, startAnalysis } from "./analysis.js";
4
- export { archiveCoached, archivedGame, archivedGames, archivedGameUnplayed, archivedName, archivedTags, archivedWhen, archiveEnded, archiveGame, archiveName, deleteGame, GameMissing, gameStemOf, hasListTags, isGameName, isOverOnTable, keepToStudy, keptGame, keptGames, listedName, movesPgn, playerPgn, rewriteGame, startOfNewGame, } from "./archive.js";
3
+ export { endAnalysis, forgetAnalyst, gameInAnalysis, startAnalysis, stopAnalysis, stoppedAnalysis } from "./analysis.js";
4
+ export { archiveCoached, archivedGame, archivedGames, archivedGameUnplayed, archivedName, archivedTags, archivedWhen, archiveEnded, archiveGame, archiveMood, archiveName, deleteGame, GameMissing, gameStemOf, hasListTags, isGameName, isOverOnTable, keepToStudy, keptGame, keptGames, listedName, movesPgn, playerPgn, rewriteGame, startOfNewGame, } from "./archive.js";
5
5
  export { cleanSince, lastChatStart, saveChatStart } from "./chat-start.js";
6
6
  export { coachShown, coachStarted, coachWaitingSince, coachWords, forgetCoach, keepHint, keepJudgment, keepLead, startCoach, withCoach } from "./coach.js";
7
7
  export { bothSidesMoved, drawOfferOf, endOf, GameChanged, gameStamp, loadGame, removeGame, rerunIfChanged, saveGame } from "./current-game.js";
@@ -17,9 +17,10 @@ import { BadgeCounts } from "../game/badges.js";
17
17
  import { stepped } from "../game/browse.js";
18
18
  import { GameColumn } from "../game/column.js";
19
19
  import { howEnded, useDeclined, useJustEnded, useSawEnd } from "../game/ending.js";
20
- import { Steps } from "../game/move-list.js";
20
+ import { Steps, stepsLine } from "../game/move-list.js";
21
21
  import { useOpeningBook } from "../game/opening-book.js";
22
22
  import { aiSpoke, aiWords, analysisTalk, judgmentsOf, playerSpent, playerWords, pliesOf, reviewCount, withBook, withJudgments, withoutReviews } from "../game/plies.js";
23
+ import { afterMove, isJump, leaveLine, linePlies, lineRecord, onLine, stepLine } from "../game/variation.js";
23
24
  import { GuideDialog } from "../guide/guide.js";
24
25
  import { BareButton, HelpsCard, HintLine, MapButton } from "../helps/helps.js";
25
26
  import { helpsOf } from "../helps/view.js";
@@ -42,7 +43,7 @@ import { AiNameButton, ChatDialog, tableNote } from "../table/window.js";
42
43
  import { AiNote, AiWords, Compose, PointSquare, TalkFaces } from "../talk/talk.js";
43
44
  import { textFor } from "../text.js";
44
45
  import { anchorName, useAnchor } from "./anchor.js";
45
- import { useBrowseKeys, useFoldKeys, useLegendKey, useMapKey, useWheelSteps } from "./keys.js";
46
+ import { useBrowseKeys, useEscapeKey, useFoldKeys, useLegendKey, useMapKey, useWheelSteps } from "./keys.js";
46
47
  import { helpsShown, phaseOf, verdictOf } from "./phase.js";
47
48
  import { ask, post, refusedText, sendMessage, sendMove, useServed } from "./served.js";
48
49
  import { tabTitle, useTabTitle } from "./title.js";
@@ -72,7 +73,7 @@ export function App({ start }) {
72
73
  };
73
74
  const [dialog, setDialog] = useState(undefined);
74
75
  const { data: served, problem, offline, reload } = useServed(() => setDialog("new-game"));
75
- // Nuova partita with no chat at the table asks for one first (D-107): the chat's window, then the new game's, however the first closes.
76
+ // Nuova partita with no chat at the table asks for one first (D-107): the chat's window, then the new game's once a chat starts; closed without, neither.
76
77
  const [chatFirst, setChatFirst] = useState(false);
77
78
  // At the table with no chat, and none opening: what asks for the AI opens the chat's window first, to choose its host and model (D-107, D-109).
78
79
  const chatMissing = () => {
@@ -92,10 +93,14 @@ export function App({ start }) {
92
93
  setChatToReview(served?.analysis !== undefined);
93
94
  setDialog("chat");
94
95
  };
95
- const closeChat = () => {
96
+ const chatStarted = () => {
96
97
  setDialog(chatFirst ? "new-game" : undefined);
97
98
  setChatFirst(false);
98
99
  };
100
+ const closeChat = () => {
101
+ setDialog(undefined);
102
+ setChatFirst(false);
103
+ };
99
104
  const [selected, setSelected] = useState(undefined);
100
105
  const [refusal, setRefusal] = useState(undefined);
101
106
  // A link the page did not follow says why on the board, in the language of the settings the page has by then, until the player's next click or key.
@@ -169,7 +174,7 @@ export function App({ start }) {
169
174
  const playedAs = studied === undefined ? name : aiNameOf(aiHostIn(live.record), aiModelIn(live.record)?.model) ?? text.ai;
170
175
  const analyst = annotator === undefined ? name : aiNameOf(annotator.annotator, annotator.annotator) ?? text.ai;
171
176
  const material = useMemo(() => materialOf(live.position.fen), [live]);
172
- const pieces = useMemo(() => piecesOf(live.position.fen), [live]);
177
+ const piecesOnBoard = useMemo(() => piecesOf(live.position.fen), [live]);
173
178
  // How many of the game's first plies a line of the book reaches, as the server counts them (D-68).
174
179
  const bookPlies = studied !== undefined ? studied.book : game?.book ?? 0;
175
180
  // While the game goes on, the book's first moves carry its badge, when the player wants it, as the AI's review gives it after (D-69).
@@ -203,13 +208,27 @@ export function App({ start }) {
203
208
  // The legend of the marks on the board, open until the player closes it (D-99).
204
209
  const [legendOpen, setLegendOpen] = useState(false);
205
210
  useLegendKey(() => setLegendOpen((open) => !open));
211
+ // In analysis the player plays a variation from the move shown, White's moves and Black's (D-116): one line, kept by the page alone, faded once the board
212
+ // is back on the game.
213
+ const [line, setLine] = useState(undefined);
214
+ const analysing = studied !== undefined;
215
+ const onTheLine = analysing && onLine(line);
206
216
  const past = useMemo(() => viewing === undefined ? undefined : new Game({ tags: live.record.tags, moves: live.record.moves.slice(0, viewing) }), [live, viewing]);
207
- const next = useMemo(() => viewing === undefined ? undefined : new Game({ tags: live.record.tags, moves: live.record.moves.slice(0, viewing + 1) }).lastMove, [live, viewing]);
208
- const shown = useMemo(() => past ?? (pending === undefined ? live : withMove(live, pending)), [live, pending, past]);
209
- // A square lit from the AI's words goes off with the position: a touch keeps it lit until then, a mouse until it leaves the word.
210
- useEffect(() => setPointed(noSquares), [shown]);
217
+ const lineGame = useMemo(() => onTheLine ? new Game(lineRecord(live.record, line)) : undefined, [live, onTheLine ? line : undefined]);
218
+ const shown = useMemo(() => lineGame ?? past ?? (pending === undefined ? live : withMove(live, pending)), [live, pending, past, lineGame]);
219
+ // The move after the position shown, which a step back slides home: the line's own on the line.
220
+ const next = useMemo(() => onTheLine ? shown.legalMoves().find((move) => move.san === line.moves[line.cursor]) : viewing === undefined ? undefined : new Game({ tags: live.record.tags, moves: live.record.moves.slice(0, viewing + 1) }).lastMove, [live, viewing, shown]);
221
+ // Where the line starts its numbers when it leaves from the game's first position.
222
+ const firstPosition = useMemo(() => new Game({ tags: live.record.tags, moves: [] }).position, [live]);
223
+ // A square lit from the AI's words goes off with the position: a touch keeps it lit until then, a mouse until it leaves the word; in analysis a refusal
224
+ // goes off with it too.
225
+ useEffect(() => {
226
+ setPointed(noSquares);
227
+ if (analysing) {
228
+ setRefusal(undefined);
229
+ }
230
+ }, [shown]);
211
231
  // The player sees all of Chesspal's facts, whatever the AI's level (D-79): in a game with the helps on, always in analysis (D-114).
212
- const analysing = studied !== undefined;
213
232
  const helped = helpsShown(phase, settings.helps);
214
233
  // Seen from the player's side (D-91): theirs in a game, in analysis the side the game names theirs, none when it names neither.
215
234
  const you = analysing ? playerIn(studiedSides(live.record.tags, served?.profile.name ?? "", playedAs, text.sides, served?.profile.accounts)) : game?.ai === undefined ? undefined : game.ai === "white" ? "black" : "white";
@@ -225,6 +244,7 @@ export function App({ start }) {
225
244
  setPending(undefined);
226
245
  setViewing(undefined);
227
246
  setPromoting(undefined);
247
+ setLine(undefined);
228
248
  }, [gameKey]);
229
249
  const played = studied === undefined ? game : null;
230
250
  useGameSounds(played, replayed, sound);
@@ -252,16 +272,49 @@ export function App({ start }) {
252
272
  const shownOpening = useMemo(() => bookAt === undefined || book === undefined ? undefined : openingOf(bookAt, book), [live, book, bookAt === undefined ? 0 : at]);
253
273
  // Whether the move shown is still the book's, whatever the badges show: the card says the move the game left it at, once it is past it.
254
274
  const leftBook = outOfBookMove(at, bookPlies);
255
- // A jump of more than one move shows the position at once: only a step slides its pieces.
256
- const shownBefore = useRef(at);
257
- const jumped = Math.abs(shownBefore.current - at) > 1;
258
- shownBefore.current = at;
275
+ // A jump of more than one move shows the position at once: only a step slides its pieces, on the game or on a variation.
276
+ const place = { at, cursor: onTheLine ? line.cursor : 0 };
277
+ const shownBefore = useRef(place);
278
+ const jumped = isJump(shownBefore.current, place);
279
+ shownBefore.current = place;
280
+ // A move of the game shown takes the board off a variation, which stays faded.
259
281
  const goTo = (count) => {
260
282
  setSelected(undefined);
283
+ setLine(leaveLine);
261
284
  setViewing(count >= total ? undefined : Math.max(0, count));
262
285
  };
263
- const step = (to) => goTo(stepped(to, at, total));
286
+ // On a variation the arrows back and forward step through it; to either end of the game they leave it.
287
+ const step = (to) => {
288
+ if (onTheLine && (to === "back" || to === "forward")) {
289
+ setSelected(undefined);
290
+ setLine(stepLine(line, to));
291
+ }
292
+ else {
293
+ goTo(stepped(to, at, total));
294
+ }
295
+ };
264
296
  useBrowseKeys(step, () => setFlipped(!flipped));
297
+ // A move of the variation shown: the board goes back on it, at the game's move it leaves from.
298
+ const showLine = (cursor) => {
299
+ if (line !== undefined) {
300
+ setSelected(undefined);
301
+ setLine({ ...line, cursor });
302
+ setViewing(line.at >= total ? undefined : line.at);
303
+ }
304
+ };
305
+ // A variation a line of the talk was written on goes back on the board, at the move it showed, or at its end when it was set aside (D-116).
306
+ const putLineBack = (sent) => {
307
+ setSelected(undefined);
308
+ setLine({ at: sent.at, moves: sent.moves, cursor: sent.cursor > 0 ? sent.cursor : sent.moves.length });
309
+ setViewing(sent.at >= total ? undefined : sent.at);
310
+ };
311
+ // The cross and Esc close the variation: the board shows the game where it left it.
312
+ const closeLine = () => {
313
+ setSelected(undefined);
314
+ setLine(undefined);
315
+ };
316
+ // Esc first lets go of a piece picked, and leaves the legend, a promotion's choice, the settings and the dialogs to close themselves.
317
+ useEscapeKey(line === undefined || legendOpen || promoting !== undefined || setting || dialog !== undefined ? undefined : () => selected === undefined ? closeLine() : setSelected(undefined));
265
318
  const wheel = useWheelSteps(step);
266
319
  // The move shown stays in the page's address, for a reload and a link to it; after the reset on a new game, which would undo the move it goes to. A
267
320
  // game a link opens is there to look at: the analyst starts on the player's yes alone (D-89; Zoe and Federico).
@@ -269,7 +322,8 @@ export function App({ start }) {
269
322
  useAnchor({ ready: served !== undefined, shown: studied !== undefined ? anchorName(studied.name) : idle ? undefined : playedName, played: playedName, going }, viewing, (plies) => goTo(plies ?? total), async (wanted) => await post("/api/study", { game: wanted, look: true }, text, name) === undefined, setLinkIgnored);
270
323
  // The tab names the game and the move shown, as the cards name its players; between games, Chesspal alone.
271
324
  const titled = idle || served === undefined ? undefined : studiedSides(live.record.tags, served.profile.name, playedAs, text.sides, served.profile.accounts, othersOf(served.profiles, served.profile.id));
272
- useTabTitle(titled === undefined ? undefined : tabTitle(titled.white.name, titled.black.name, at === 0 ? undefined : plies[at - 1]));
325
+ const lineNumbered = line === undefined ? undefined : linePlies(line, lineStartOf(plies, line.at, firstPosition));
326
+ useTabTitle(titled === undefined ? undefined : onTheLine ? tabTitle(titled.white.name, titled.black.name, lineNumbered?.[line.cursor - 1], true) : tabTitle(titled.white.name, titled.black.name, at === 0 ? undefined : plies[at - 1]));
273
327
  // The header's theme switch picks the other of the two the page shows, leaving the system's.
274
328
  const switchTheme = () => change({ ...settings, theme: document.documentElement.dataset.theme === "dark" ? "light" : "dark" });
275
329
  // An action of the player's on the game: the game the server sends next shows it; a refusal shows on the board.
@@ -322,7 +376,8 @@ export function App({ start }) {
322
376
  const mood = moodAt(live.record, sides?.white.who === "ai" ? "white" : sides?.black.who === "ai" ? "black" : ai, at).mood;
323
377
  const playerToMove = going && live.position.turn === player;
324
378
  const ahead = (side) => side === "white" ? material.balance : -material.balance;
325
- const reviewed = judged || at <= inBook ? plies[at - 1] : undefined;
379
+ // On a variation the AI's badges, notes and drawings say nothing: they are of the game's moves (D-116).
380
+ const reviewed = onTheLine ? undefined : judged || at <= inBook ? plies[at - 1] : undefined;
326
381
  // With the helps on, the coach's judgments of the player's last move, once the AI has answered it, and of one a held note let go only now (D-78, D-88).
327
382
  const judgments = going && settings.helps ? judgmentsOf(plies, player, coached ?? noCoach) : [];
328
383
  const review = reviewed?.review;
@@ -330,7 +385,7 @@ export function App({ start }) {
330
385
  const drawn = hintOpen && hint !== undefined && viewing === undefined && (hint.arrows !== undefined || hint.squares !== undefined) ? hint : review;
331
386
  const drawnByCoach = hint !== undefined && drawn === hint;
332
387
  // The game over, when this is its last position: the kings' signs, on the board and in the legend.
333
- const boardEnd = ended && !aborted && viewing === undefined && pending === undefined ? { how, winner, player, fresh: justEnded } : undefined;
388
+ const boardEnd = ended && !aborted && viewing === undefined && pending === undefined && !onTheLine ? { how, winner, player, fresh: justEnded } : undefined;
334
389
  // The legend has the marks the board shows: none of the helps' or the drawings while the board is bare (D-99).
335
390
  const shownBare = bare && helps !== undefined;
336
391
  const legendRows = legendOf({
@@ -349,6 +404,8 @@ export function App({ start }) {
349
404
  const reviewing = summary === undefined && studied?.reviewing === true && !offline;
350
405
  // With nobody at it, a review not finished says so, and the player brings the game back to the AI (D-109).
351
406
  const completeIt = summary === undefined && studied?.incomplete === true && !offline ? () => void studyGame({ game: studied.name }) : undefined;
407
+ // While it waits, the player may cancel the analysis asked, one click: what is written of the review stays, and the page goes back to the game.
408
+ const stopIt = reviewing ? () => act("/api/stop-analysis", { game: studied.name }) : undefined;
352
409
  const closing = studied === undefined ? undefined : closingOf(live.record);
353
410
  // How far the review has got, counted as status counts it for the AI: who is ahead after how many of the game's moves.
354
411
  const count = reviewing || completeIt !== undefined ? reviewCount(plies) : undefined;
@@ -397,7 +454,7 @@ export function App({ start }) {
397
454
  const play = (from, to, piece) => {
398
455
  setSelected(undefined);
399
456
  setRefusal(undefined);
400
- if (piece === undefined && promotes(pieces.get(from), to)) {
457
+ if (piece === undefined && promotes(piecesOnBoard.get(from), to)) {
401
458
  setPromoting({ from, to });
402
459
  return;
403
460
  }
@@ -416,9 +473,35 @@ export function App({ start }) {
416
473
  }
417
474
  });
418
475
  };
419
- // A move the board refuses says why, without the AI (D-18), unless the player turned it off: the piece goes back, the reason over it.
476
+ // In analysis the player moves from the position shown, White's pieces and Black's, and a variation grows (D-116); the game's own next move only steps
477
+ // forward through it. The move sounds as the board stands after it.
478
+ const explore = (from, to, piece) => {
479
+ setSelected(undefined);
480
+ setRefusal(undefined);
481
+ if (piece === undefined && promotes(piecesOf(shown.position.fen).get(from), to)) {
482
+ setPromoting({ from, to });
483
+ return;
484
+ }
485
+ setPromoting(undefined);
486
+ const move = shown.legalMoves(from).find((legal) => legal.to === to && legal.promotion === piece);
487
+ if (move === undefined) {
488
+ return;
489
+ }
490
+ const after = afterMove(line, live.record.moves.map(({ san }) => san), at, move.san);
491
+ setLine(after.line);
492
+ setViewing(after.shown >= total ? undefined : after.shown);
493
+ const played = new Game(shown.record);
494
+ const moveSound = played.play(move.san).played && sound ? soundOf(played) : undefined;
495
+ if (moveSound !== undefined) {
496
+ playSound(moveSound);
497
+ }
498
+ };
499
+ // A move the board refuses says why, without the AI (D-18), unless the player turned it off: the piece goes back, the reason over it. In analysis, from
500
+ // the position shown.
420
501
  const refuse = (from, to) => {
421
- const illegal = settings.explain ? live.whyNot(from, to) : undefined;
502
+ const board = analysing ? shown : live;
503
+ const pieces = analysing ? piecesOf(shown.position.fen) : piecesOnBoard;
504
+ const illegal = settings.explain ? board.whyNot(from, to) : undefined;
422
505
  if (illegal !== undefined) {
423
506
  const letterOn = (square) => pieces.get(square).toUpperCase();
424
507
  const sideOn = (square) => pieces.get(square) === letterOn(square) ? "white" : "black";
@@ -428,7 +511,7 @@ export function App({ start }) {
428
511
  setRefusal({ text: words, square: from, piece: pieces.get(from), explanation: explain(illegal, to, king) });
429
512
  }
430
513
  };
431
- // In analysis the words go with the game and the move shown (D-101).
514
+ // In analysis the words go with the game and the move shown (D-101), and the variation played there, on the board or set aside (D-116).
432
515
  const sendWords = (position) => {
433
516
  const words = draft;
434
517
  // /clear and the other commands are for Claude's app: written here they would reach the AI as words (Sebastiano's playtest).
@@ -453,6 +536,29 @@ export function App({ start }) {
453
536
  };
454
537
  // In analysis the AI's note goes with the other side's card and the talk with the player's, turned round with the board as in a game (D-101, Zoe).
455
538
  const studiedCard = (side, place) => sides === undefined ? null : (_jsx(PlayerCard, { place: place, picture: pictureOf(sides[side], profile, served.profiles, 48, mood), name: sides[side].name, signed: sides[side].who === "ai" && playedBy !== undefined ? signature(playedBy.model, playedBy.effort) : undefined, side: side, taken: material.taken[side], ahead: ahead(side), badges: _jsx(BadgeCounts, { plies: plies, side: side, names: text.badges }), text: text }));
539
+ // Where the words go in analysis: the game, the move shown, and the variation, on the board or set aside (D-116).
540
+ const shownIn = (game) => line === undefined ? { game, at } : { game, at, variation: line };
541
+ // «Analyse» on the variation sends the player's request, as their words would (D-116).
542
+ const analyseLine = () => {
543
+ if (studied !== undefined) {
544
+ setUnsent(undefined);
545
+ void sendMessage(text.variation.analyseWords, text, name, shownIn(studied.name)).then((refused) => setUnsent(refused?.text));
546
+ }
547
+ };
548
+ // The variation as the column and the steps show it.
549
+ const lineView = !analysing || line === undefined || lineNumbered === undefined
550
+ ? undefined
551
+ : {
552
+ plies: lineNumbered,
553
+ at: line.at,
554
+ cursor: line.cursor,
555
+ onShow: showLine,
556
+ onClose: closeLine,
557
+ onBack: () => goTo(line.at),
558
+ onAnalyse: analyseLine,
559
+ canAnalyse: !asleep && !offline,
560
+ analyst: name,
561
+ };
456
562
  // A game brought to analyse: the page shows it once the server has it in analysis; a refusal stays in the dialog. One the AI has to go over, with no
457
563
  // chat at the table, opens the chat's window: the analyst works with the chat's model (D-109).
458
564
  const studyGame = async (studied) => {
@@ -465,7 +571,7 @@ export function App({ start }) {
465
571
  setDialog(toReview ? "chat" : undefined);
466
572
  return undefined;
467
573
  };
468
- return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(ProfileDialog, { open: dialog === "profile", profile: profile, onClose: () => setDialog(undefined), text: text }), _jsx(ProfilesDialog, { open: dialog === "profiles", profiles: served.profiles, active: profile, locked: going ? text.profile.whilePlaying : undefined, dataFolder: served.dataFolder, onEdit: () => setDialog("profile"), onClose: () => setDialog(undefined), text: text }), table === undefined ? null : _jsx(ChatDialog, { open: dialog === "chat", review: chatToReview, table: table, settings: settings, onChange: change, name: name, onClose: closeChat, text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), guide, _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), onProfile: () => setDialog("profile"), you: profile.name, accounts: profile.accounts, others: othersOf(served.profiles, profile.id), ai: name, text: text }), served.update === undefined || served.table === undefined
574
+ return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(ProfileDialog, { open: dialog === "profile", profile: profile, onClose: () => setDialog(undefined), text: text }), _jsx(ProfilesDialog, { open: dialog === "profiles", profiles: served.profiles, active: profile, locked: going ? text.profile.whilePlaying : undefined, dataFolder: served.dataFolder, onEdit: () => setDialog("profile"), onClose: () => setDialog(undefined), text: text }), table === undefined ? null : _jsx(ChatDialog, { open: dialog === "chat", review: chatToReview, table: table, settings: settings, onChange: change, name: name, onStarted: chatStarted, onClose: closeChat, text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), guide, _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), onProfile: () => setDialog("profile"), you: profile.name, accounts: profile.accounts, others: othersOf(served.profiles, profile.id), ai: name, text: text }), served.update === undefined || served.table === undefined
469
575
  ? null
470
576
  : (_jsx(UpdateDialog, { open: dialog === "update", version: served.update.version, current: served.version, installing: served.update.installing === true, installed: served.update.installed === true, going: going, chat: served.table.chat ? (served.table.held ? "held" : "byHand") : undefined, name: name, onClose: () => setDialog(undefined), text: text })), offline ? _jsx("p", { class: "offline", role: "alert", children: text.offline(name) }) : null, served.outdated === true
471
577
  ? _jsx("p", { class: "outdated", role: "status", children: text.outdated(name) })
@@ -474,10 +580,10 @@ export function App({ start }) {
474
580
  : null, _jsxs("main", { class: `app${flipped ? " flipped" : ""}${folds.talk === "open" ? "" : ` talk-${folds.talk}`}${folds.game === "folded" ? " folded" : ""}`, inert: offline, children: [header, sides === undefined
475
581
  ? (_jsx(TalkFaces, { name: name, aiPicture: thinker(40), you: profile.name, picture: picture, ...(idle ? {} : { ai, player }), aiMoves: going && !playerToMove, playerMoves: playerToMove, unread: unread || asleep, text: text }))
476
582
  : (_jsx(TalkFaces, { name: sides[ai].name, aiPicture: pictureOf(sides[ai], profile, served.profiles, 40, mood), you: sides[player].name, picture: pictureOf(sides[player], profile, served.profiles, 40, mood), ai: ai, player: player, aiMoves: false, playerMoves: false, unread: asleep, text: text })), _jsx(FoldTab, { column: "talk", folded: folds.talk === "folded", label: thinking === undefined || folds.talk !== "folded" ? text.talk : `${text.talk}: ${text.thinking[thinking]}`, unread: (unread || asleep) && folds.talk === "folded", ...(thinking === undefined || folds.talk !== "folded" ? {} : { mark: _jsx(Thought, { thinking: thinking, words: text.thinking }) }), onFold: fold }), _jsx(FoldTab, { column: "game", folded: folds.game === "folded", label: text.game, onFold: fold }), _jsx("span", { class: "visually-hidden", "aria-live": "polite", children: unread ? text.wrote(name) : "" }), _jsx("div", { class: sides === undefined ? "talk" : "talk studied", id: "talk", children: sides !== undefined && studied !== undefined
477
- ? (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": sides[ai].name, children: [studiedCard(ai, "ai"), asleepNote, _jsx(AiNote, { ...(reviewed === undefined ? {} : { ply: reviewed }), ...(shownOpening === undefined ? {} : { opening: shownOpening }), ...(at === total && closing !== undefined ? { closing } : {}), ...(at === 0 && summary !== undefined ? { summary } : {}), ...(reviewing ? { reviewing: true } : {}), ...(count === undefined ? {} : { count }), ...(completeIt === undefined ? {} : { onComplete: completeIt }), name: analyst, by: annotator, mood: mood, ...(voiced ? { voice: text } : {}), text: text })] }), _jsxs("section", { class: "you-block", "aria-label": sides[player].name, children: [_jsx("div", { class: "chat", children: _jsx(Compose, { lines: analysisTalk(talk, studied.name), newestFirst: false, spent: 0, draft: draft, onDraft: (words) => {
583
+ ? (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": sides[ai].name, children: [studiedCard(ai, "ai"), asleepNote, _jsx(AiNote, { ...(reviewed === undefined ? {} : { ply: reviewed }), ...(shownOpening === undefined || onTheLine ? {} : { opening: shownOpening }), ...(at === total && closing !== undefined && !onTheLine ? { closing } : {}), ...(at === 0 && summary !== undefined && !onTheLine ? { summary } : {}), ...(reviewing ? { reviewing: true } : {}), ...(count === undefined ? {} : { count }), ...(completeIt === undefined ? {} : { onComplete: completeIt }), name: analyst, by: annotator, mood: mood, ...(voiced ? { voice: text } : {}), text: text })] }), _jsxs("section", { class: "you-block", "aria-label": sides[player].name, children: [_jsx("div", { class: "chat", children: _jsx(Compose, { lines: analysisTalk(talk, studied.name), newestFirst: false, spent: 0, draft: draft, onDraft: (words) => {
478
584
  setDraft(words);
479
585
  setUnsent(undefined);
480
- }, onSend: () => sendWords({ game: studied.name, at }), canMove: false, name: name, problem: unsent, about: { ...(at === 0 ? {} : { ply: plies[at - 1] }), onShow: goTo }, ...(chatName === undefined ? {} : { to: chatName }), text: text }) }), studiedCard(player, "you")] })] }))
586
+ }, onSend: () => sendWords(shownIn(studied.name)), canMove: false, name: name, problem: unsent, about: { ...(at === 0 ? {} : { ply: plies[at - 1] }), onShow: goTo, onLine: putLineBack }, ...(chatName === undefined ? {} : { to: chatName }), text: text }) }), studiedCard(player, "you")] })] }))
481
587
  : (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": name, children: [_jsx(PlayerCard, { place: "ai", picture: thinker(48), name: table === undefined ? name : _jsx(AiNameButton, { name: name, table: table, onOpen: askChat, text: text }), signed: signed, side: idle ? undefined : ai, ...(idle ? {} : { level: text.start.levels[level] }), taken: material.taken[ai], ahead: ahead(ai), clock: clockOf(ai), text: text }), asleepNote, _jsx(AiWords, { words: aiWords(plies, ai, talk), newestFirst: !flipped, ...(voiced ? { voice: text } : {}) })] }), _jsxs("section", { class: "you-block", "aria-label": profile.name, children: [_jsx(Compose, { lines: said, newestFirst: flipped,
482
588
  // Between games, and before the first, the player writes freely, a line at a time, as the AI does (D-74, D-77).
483
589
  spent: going ? playerSpent(plies, ai, talk) : 0, draft: draft, onDraft: (words) => {
@@ -485,19 +591,27 @@ export function App({ start }) {
485
591
  setUnsent(undefined);
486
592
  }, onSend: sendWords, canMove: playerToMove, name: name, problem: unsent, text: text }), _jsx(PlayerCard, { place: "you", picture: picture, name: _jsx(NameButton, { profile: profile, onOpen: () => setDialog("profiles"), text: text }), side: idle ? undefined : player, taken: material.taken[player], ahead: ahead(player), turn: playerToMove, clock: _jsxs(_Fragment, { children: [clockOf(player), startClocks, nextGame] }), text: text })] })] })) }), _jsxs("div", {
487
593
  // Between games the board goes off (D-77): a game the page saw end once its effects are over, its final position less off than the table set.
488
- class: between && !justEnded ? "board-wrap off" : "board-wrap", onWheel: wheel,
594
+ // A variation rings the board in the accent (D-116).
595
+ class: between && !justEnded ? "board-wrap off" : onTheLine ? "board-wrap variation" : "board-wrap", onWheel: wheel,
489
596
  // A press on the board while the player looks back brings the game as it is: the next one plays.
490
- onPointerDownCapture: viewing === undefined || studied !== undefined ? undefined : (event) => event.button === 0 && goTo(total), children: [_jsx(Board, { fen: shown.position.fen, bottom: flipped ? ai : player, lastMove: shown.lastMove, pointed: pointed, ...(helps === undefined ? {} : { helps }), ...(mapping && helps !== undefined ? { control: helps.strategy.control } : {}), bare: bare && helps !== undefined, ...(review?.badge === undefined ? {} : { badge: { badge: review.badge, name: text.badges[review.badge] } }), ...(drawn === undefined ? {} : { drawn }), nextMove: next, jumped: jumped, check: shown.position.check, end: boardEnd, player: playerToMove && pending === undefined && viewing === undefined ? player : undefined, selected: selected, targets: selected === undefined || viewing !== undefined ? [] : live.legalMoves(selected), dots: settings.dots, names: settings.squareNames, notice: viewing !== undefined ? undefined : refusal ?? (linkIgnored === undefined ? undefined : { text: text.link[linkIgnored] }), promotion: promoting === undefined || viewing !== undefined || !playerToMove ? undefined : { ...promoting, side: player }, onPromote: (piece) => piece === undefined ? setPromoting(undefined) : play(promoting.from, promoting.to, piece), onSelect: select, onMove: play, onRefuse: refuse, text: text }), folds.talk !== "open" && (asleepNote !== null || clockWaits)
597
+ onPointerDownCapture: viewing === undefined || studied !== undefined ? undefined : (event) => event.button === 0 && goTo(total), children: [_jsx(Board, { fen: shown.position.fen, bottom: flipped ? ai : player, lastMove: shown.lastMove, pointed: pointed, ...(helps === undefined ? {} : { helps }), ...(mapping && helps !== undefined ? { control: helps.strategy.control } : {}), bare: bare && helps !== undefined, ...(review?.badge === undefined ? {} : { badge: { badge: review.badge, name: text.badges[review.badge] } }), ...(drawn === undefined ? {} : { drawn }), nextMove: next, jumped: jumped, check: shown.position.check, end: boardEnd, player: analysing ? (shown.outcome.over ? undefined : shown.position.turn) : playerToMove && pending === undefined && viewing === undefined ? player : undefined, selected: selected, targets: selected === undefined ? [] : analysing ? shown.legalMoves(selected) : viewing !== undefined ? [] : live.legalMoves(selected), dots: settings.dots, names: settings.squareNames, notice: viewing !== undefined && !analysing ? undefined : refusal ?? (linkIgnored === undefined ? undefined : { text: text.link[linkIgnored] }), promotion: promoting === undefined
598
+ ? undefined
599
+ : analysing
600
+ ? { ...promoting, side: shown.position.turn }
601
+ : viewing !== undefined || !playerToMove
602
+ ? undefined
603
+ : { ...promoting, side: player }, onPromote: (piece) => piece === undefined ? setPromoting(undefined) : (analysing ? explore : play)(promoting.from, promoting.to, piece), onSelect: select, onMove: analysing ? explore : play, onRefuse: refuse, text: text }), folds.talk !== "open" && (asleepNote !== null || clockWaits)
491
604
  ? (_jsxs("div", { class: "over-board", children: [asleepNote, startClocks] }))
492
- : null] }), studied === undefined ? null : (_jsxs("div", { class: "board-steps", children: [_jsx(Steps, { shown: at, total: total, onStep: step, text: text }), standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: at, onShow: goTo, text: text })] })), helps === undefined ? null : (_jsxs("div", { class: "board-map", children: [legendAt(true), _jsx(BareButton, { bare: bare, onBare: setBare, text: text }), _jsx(MapButton, { mapping: mapping, onMap: setMapping, text: text }), hint === undefined || viewing !== undefined ? null : _jsx(HintLine, { hint: hint, open: hintOpen, onOpen: openTheHint, text: text })] })), _jsx(GameColumn, { plies: idle ? [] : plies, result: ended ? result : undefined, how: how, lit: justEnded, started: idle ? undefined : played?.started, move: live.position.moveNumber, toMove: live.position.turn, opening: opening, ...(leftBook === undefined ? {} : { outOfBook: leftBook }), helps: helps === undefined ? undefined : (_jsx(HelpsCard, { helps: helps, judged: judgments, hint: viewing === undefined ? hint : undefined, hintOpen: hintOpen, onHint: openTheHint, onPoint: setPointed, onMap: setMapping, mapping: mapping, onBare: setBare, bare: bare, legend: legend, text: text })), ...(helps === undefined && legendRows.length > 0 ? { legend } : {}), ...(standings === undefined ? {} : { standings }), ...(studied === undefined ? {} : {
605
+ : null] }), studied === undefined ? null : (_jsxs("div", { class: "board-steps", children: [_jsx(Steps, { shown: at, total: total, ...stepsLine(lineView, plies), onStep: step, text: text }), standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: at, onShow: goTo, text: text })] })), helps === undefined ? null : (_jsxs("div", { class: "board-map", children: [legendAt(true), _jsx(BareButton, { bare: bare, onBare: setBare, text: text }), _jsx(MapButton, { mapping: mapping, onMap: setMapping, text: text }), hint === undefined || viewing !== undefined ? null : _jsx(HintLine, { hint: hint, open: hintOpen, onOpen: openTheHint, text: text })] })), _jsx(GameColumn, { plies: idle ? [] : plies, result: ended ? result : undefined, how: how, lit: justEnded, started: idle ? undefined : played?.started, move: live.position.moveNumber, toMove: live.position.turn, opening: opening, ...(leftBook === undefined ? {} : { outOfBook: leftBook }), helps: helps === undefined ? undefined : (_jsx(HelpsCard, { helps: helps, judged: judgments, hint: viewing === undefined ? hint : undefined, hintOpen: hintOpen, onHint: openTheHint, onPoint: setPointed, onMap: setMapping, mapping: mapping, onBare: setBare, bare: bare, legend: legend, text: text })), ...(helps === undefined && legendRows.length > 0 ? { legend } : {}), ...(standings === undefined ? {} : { standings }), ...(studied === undefined ? {} : {
493
606
  analysis: {
494
607
  by: analyst,
495
608
  ...(summary === undefined ? {} : { summary }),
496
609
  ...(reviewing ? { reviewing: true } : {}),
497
610
  ...(count === undefined ? {} : { count }),
611
+ ...(stopIt === undefined ? {} : { onStop: stopIt }),
498
612
  ...(completeIt === undefined ? {} : { onComplete: completeIt }),
499
613
  },
500
- }), shownPlies: at, onShow: goTo, onStep: step, ...(studied === undefined ? {} : { onDownload: () => download(studied.pgn, pgnName(live.record.tags, "analysis")) }), ...(total === 0 ? {} : { onSaveMoves: () => download(writePgn(movesOnly(live.record)), pgnName(live.record.tags)) }), ...(between && game !== null && replayed.record.moves.length > 0 ? { onAnalyse: () => void studyGame({ last: true }) } : {}), text: text }), _jsx(PageFoot, { version: served.version, text: text })] })] }) }));
614
+ }), shownPlies: at, ...(lineView === undefined ? {} : { line: lineView }), onShow: goTo, onStep: step, ...(studied === undefined ? {} : { onDownload: () => download(studied.pgn, pgnName(live.record.tags, "analysis")) }), ...(total === 0 ? {} : { onSaveMoves: () => download(writePgn(movesOnly(live.record)), pgnName(live.record.tags)) }), ...(between && game !== null && replayed.record.moves.length > 0 ? { onAnalyse: () => void studyGame({ last: true }) } : {}), text: text }), _jsx(PageFoot, { version: served.version, text: text })] })] }) }));
501
615
  }
502
616
  /** The picture of a player of the game in analysis: the player's own, the AI's Torretta with its mood, another profile's, or the plain face of someone else. */
503
617
  function pictureOf({ who, profile: other }, profile, profiles, size, mood) {
@@ -580,6 +694,11 @@ function withMove(game, move) {
580
694
  const copy = new Game(game.record);
581
695
  return copy.play(move).played ? copy : game;
582
696
  }
697
+ /** Where a variation that leaves the game after `at` moves starts its numbers: after the game's move there, or from the game's first position. */
698
+ function lineStartOf(plies, at, first) {
699
+ const fork = plies[at - 1];
700
+ return fork === undefined ? first : fork.side === "white" ? { moveNumber: fork.number, turn: "black" } : { moveNumber: fork.number + 1, turn: "white" };
701
+ }
583
702
  /** Whether a pawn's move takes it to the last rank, where the player chooses what it becomes. */
584
703
  function promotes(piece, to) {
585
704
  return (piece === "P" && to.endsWith("8")) || (piece === "p" && to.endsWith("1"));
@@ -1,5 +1,5 @@
1
1
  // The page's keys and wheel: [ and ] fold the side columns, M holds the helps' map (D-78), ? opens the legend (D-99), the arrows, Home and End step
2
- // through the moves and f and x turn the board, and the wheel over the board steps too.
2
+ // through the moves and f and x turn the board, Esc closes a variation, and the wheel over the board steps too.
3
3
  import { useEffect, useRef } from "preact/hooks";
4
4
  import { flipKeys, stepKeys } from "../game/browse.js";
5
5
  import { foldKeys, gameUnderBoard, oneColumn } from "../layout/folds.js";
@@ -87,6 +87,21 @@ export function useBrowseKeys(step, flip) {
87
87
  return () => window.removeEventListener("keydown", press);
88
88
  });
89
89
  }
90
+ /** Esc closes what `close` closes, a variation in analysis (D-116), when nothing else on the page takes it: none while it is undefined. */
91
+ export function useEscapeKey(close) {
92
+ useEffect(() => {
93
+ if (close === undefined) {
94
+ return;
95
+ }
96
+ const press = (event) => {
97
+ if (event.key === "Escape" && forThePage(event)) {
98
+ close();
99
+ }
100
+ };
101
+ window.addEventListener("keydown", press);
102
+ return () => window.removeEventListener("keydown", press);
103
+ });
104
+ }
90
105
  /** The mouse wheel over the board steps through the moves, as on Lichess: down forward, a step a notch, however finely a touchpad turns it. */
91
106
  export function useWheelSteps(step) {
92
107
  const turned = useRef(0);
@@ -1,10 +1,13 @@
1
1
  // The tab's title names the game and the move shown, Stefano – Claude, 16. ♞f3, so that the player's bookmarks and tabs tell its games apart.
2
2
  import { useEffect } from "preact/hooks";
3
3
  import { figurinesOf } from "../../core/index.js";
4
- /** The title of a game: its players by their first names, White first, so that the move fits in a tab (Zoe), and the move shown with its number, none before the first. */
5
- export function tabTitle(white, black, shown) {
4
+ /**
5
+ * The title of a game: its players by their first names, White first, so that the move fits in a tab (Zoe), and the move shown with its number, none before
6
+ * the first; a move of a variation in analysis after its fork, ⑂ (D-116).
7
+ */
8
+ export function tabTitle(white, black, shown, line = false) {
6
9
  const players = `${firstName(white)} – ${firstName(black)}`;
7
- return shown === undefined ? players : `${players}, ${shown.number}${shown.side === "white" ? "." : "..."} ${figurinesOf(shown.san)}`;
10
+ return shown === undefined ? players : `${players}, ${line ? "⑂ " : ""}${shown.number}${shown.side === "white" ? "." : "..."} ${figurinesOf(shown.san)}`;
8
11
  }
9
12
  /** Keeps the tab's title, Chesspal's name while no game shows. */
10
13
  export function useTabTitle(title) {
@@ -960,3 +960,9 @@
960
960
  transition: opacity 400ms ease, --kept 400ms ease;
961
961
  }
962
962
  }
963
+
964
+ /* A variation in analysis rings the board in the accent, just outside its edge, on the page, where every skin leaves it in sight: nothing else changes (D-116) */
965
+ .board-wrap.variation > .board {
966
+ outline: 3px solid var(--accent);
967
+ outline-offset: 2px;
968
+ }