chesspal 0.4.1 → 0.6.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 +37 -8
  2. package/dist/cli/chat-hook.d.ts +4 -1
  3. package/dist/cli/chat-hook.js +12 -5
  4. package/dist/cli/cli.js +3 -3
  5. package/dist/cli/command-line.js +9 -9
  6. package/dist/commands/game/new.js +3 -5
  7. package/dist/commands/registry/context.d.ts +5 -0
  8. package/dist/commands/registry/context.js +2 -1
  9. package/dist/commands/setup/init.d.ts +4 -1
  10. package/dist/commands/setup/init.js +30 -10
  11. package/dist/core/review.d.ts +2 -2
  12. package/dist/core/review.js +7 -3
  13. package/dist/host-files/host-files.d.ts +10 -6
  14. package/dist/host-files/host-files.js +57 -9
  15. package/dist/host-files/index.d.ts +1 -1
  16. package/dist/host-files/index.js +1 -1
  17. package/dist/page/app/app.js +112 -25
  18. package/dist/page/app/keys.js +16 -1
  19. package/dist/page/app/title.js +6 -3
  20. package/dist/page/board/board.css +6 -0
  21. package/dist/page/game/column.js +3 -3
  22. package/dist/page/game/game.css +121 -1
  23. package/dist/page/game/move-list.js +48 -10
  24. package/dist/page/game/variation.js +65 -0
  25. package/dist/page/guide/en.md +3 -2
  26. package/dist/page/guide/it.md +3 -2
  27. package/dist/page/profile/settings.js +2 -0
  28. package/dist/page/table/action.js +19 -7
  29. package/dist/page/table/window.js +7 -5
  30. package/dist/page/text.js +4 -2
  31. package/dist/server/bell/antigravity.d.ts +10 -0
  32. package/dist/server/bell/antigravity.js +75 -0
  33. package/dist/server/bell/bell.d.ts +10 -2
  34. package/dist/server/bell/bell.js +33 -10
  35. package/dist/server/bell/index.d.ts +1 -1
  36. package/dist/server/bell/index.js +2 -1
  37. package/dist/server/mcp/caller.js +26 -1
  38. package/dist/server/page/game.js +4 -3
  39. package/dist/server/page/page.js +1 -0
  40. package/dist/server/page/table.d.ts +14 -2
  41. package/dist/server/page/table.js +17 -5
  42. package/dist/server/table/hosts/chats.d.ts +4 -2
  43. package/dist/server/table/hosts/chats.js +24 -11
  44. package/dist/server/table/hosts/find.d.ts +6 -0
  45. package/dist/server/table/hosts/find.js +19 -1
  46. package/dist/server/table/hosts/hooks.d.ts +5 -2
  47. package/dist/server/table/hosts/hooks.js +6 -3
  48. package/dist/server/table/hosts/hosts.d.ts +19 -10
  49. package/dist/server/table/hosts/hosts.js +34 -16
  50. package/dist/server/table/hosts/models.d.ts +19 -0
  51. package/dist/server/table/hosts/models.js +95 -0
  52. package/dist/server/table/hosts/play.d.ts +7 -2
  53. package/dist/server/table/hosts/play.js +22 -12
  54. package/dist/server/table/hosts/seat.d.ts +5 -1
  55. package/dist/server/table/hosts/seat.js +1 -1
  56. package/dist/server/table/table.js +1 -1
  57. package/package.json +1 -1
@@ -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";
@@ -131,7 +132,7 @@ export function App({ start }) {
131
132
  apply(settingsFrom(served.settings));
132
133
  }
133
134
  }, [profileId]);
134
- // The AI as the host names it (Claude, Codex), also where the page sends the player to write; the last known while the page is offline.
135
+ // The AI as the host names it (Claude, Codex, Gemini), also where the page sends the player to write; the last known while the page is offline.
135
136
  const name = served?.opponent ?? text.ai;
136
137
  // Under it, the model and effort the AI played its last move with (D-82).
137
138
  const signed = served?.aiModel === undefined ? undefined : signature(served.aiModel.model, served.aiModel.effort);
@@ -169,7 +170,7 @@ export function App({ start }) {
169
170
  const playedAs = studied === undefined ? name : aiNameOf(aiHostIn(live.record), aiModelIn(live.record)?.model) ?? text.ai;
170
171
  const analyst = annotator === undefined ? name : aiNameOf(annotator.annotator, annotator.annotator) ?? text.ai;
171
172
  const material = useMemo(() => materialOf(live.position.fen), [live]);
172
- const pieces = useMemo(() => piecesOf(live.position.fen), [live]);
173
+ const piecesOnBoard = useMemo(() => piecesOf(live.position.fen), [live]);
173
174
  // How many of the game's first plies a line of the book reaches, as the server counts them (D-68).
174
175
  const bookPlies = studied !== undefined ? studied.book : game?.book ?? 0;
175
176
  // 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 +204,27 @@ export function App({ start }) {
203
204
  // The legend of the marks on the board, open until the player closes it (D-99).
204
205
  const [legendOpen, setLegendOpen] = useState(false);
205
206
  useLegendKey(() => setLegendOpen((open) => !open));
207
+ // 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
208
+ // is back on the game.
209
+ const [line, setLine] = useState(undefined);
210
+ const analysing = studied !== undefined;
211
+ const onTheLine = analysing && onLine(line);
206
212
  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]);
213
+ const lineGame = useMemo(() => onTheLine ? new Game(lineRecord(live.record, line)) : undefined, [live, onTheLine ? line : undefined]);
214
+ const shown = useMemo(() => lineGame ?? past ?? (pending === undefined ? live : withMove(live, pending)), [live, pending, past, lineGame]);
215
+ // The move after the position shown, which a step back slides home: the line's own on the line.
216
+ 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]);
217
+ // Where the line starts its numbers when it leaves from the game's first position.
218
+ const firstPosition = useMemo(() => new Game({ tags: live.record.tags, moves: [] }).position, [live]);
219
+ // 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
220
+ // goes off with it too.
221
+ useEffect(() => {
222
+ setPointed(noSquares);
223
+ if (analysing) {
224
+ setRefusal(undefined);
225
+ }
226
+ }, [shown]);
211
227
  // 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
228
  const helped = helpsShown(phase, settings.helps);
214
229
  // 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
230
  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 +240,7 @@ export function App({ start }) {
225
240
  setPending(undefined);
226
241
  setViewing(undefined);
227
242
  setPromoting(undefined);
243
+ setLine(undefined);
228
244
  }, [gameKey]);
229
245
  const played = studied === undefined ? game : null;
230
246
  useGameSounds(played, replayed, sound);
@@ -252,16 +268,43 @@ export function App({ start }) {
252
268
  const shownOpening = useMemo(() => bookAt === undefined || book === undefined ? undefined : openingOf(bookAt, book), [live, book, bookAt === undefined ? 0 : at]);
253
269
  // 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
270
  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;
271
+ // 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.
272
+ const place = { at, cursor: onTheLine ? line.cursor : 0 };
273
+ const shownBefore = useRef(place);
274
+ const jumped = isJump(shownBefore.current, place);
275
+ shownBefore.current = place;
276
+ // A move of the game shown takes the board off a variation, which stays faded.
259
277
  const goTo = (count) => {
260
278
  setSelected(undefined);
279
+ setLine(leaveLine);
261
280
  setViewing(count >= total ? undefined : Math.max(0, count));
262
281
  };
263
- const step = (to) => goTo(stepped(to, at, total));
282
+ // On a variation the arrows back and forward step through it; to either end of the game they leave it.
283
+ const step = (to) => {
284
+ if (onTheLine && (to === "back" || to === "forward")) {
285
+ setSelected(undefined);
286
+ setLine(stepLine(line, to));
287
+ }
288
+ else {
289
+ goTo(stepped(to, at, total));
290
+ }
291
+ };
264
292
  useBrowseKeys(step, () => setFlipped(!flipped));
293
+ // A move of the variation shown: the board goes back on it, at the game's move it leaves from.
294
+ const showLine = (cursor) => {
295
+ if (line !== undefined) {
296
+ setSelected(undefined);
297
+ setLine({ ...line, cursor });
298
+ setViewing(line.at >= total ? undefined : line.at);
299
+ }
300
+ };
301
+ // The cross and Esc close the variation: the board shows the game where it left it.
302
+ const closeLine = () => {
303
+ setSelected(undefined);
304
+ setLine(undefined);
305
+ };
306
+ // Esc first lets go of a piece picked, and leaves the legend, a promotion's choice, the settings and the dialogs to close themselves.
307
+ useEscapeKey(line === undefined || legendOpen || promoting !== undefined || setting || dialog !== undefined ? undefined : () => selected === undefined ? closeLine() : setSelected(undefined));
265
308
  const wheel = useWheelSteps(step);
266
309
  // 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
310
  // 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 +312,8 @@ export function App({ start }) {
269
312
  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
313
  // The tab names the game and the move shown, as the cards name its players; between games, Chesspal alone.
271
314
  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]));
315
+ const lineNumbered = line === undefined ? undefined : linePlies(line, lineStartOf(plies, line.at, firstPosition));
316
+ 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
317
  // The header's theme switch picks the other of the two the page shows, leaving the system's.
274
318
  const switchTheme = () => change({ ...settings, theme: document.documentElement.dataset.theme === "dark" ? "light" : "dark" });
275
319
  // 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 +366,8 @@ export function App({ start }) {
322
366
  const mood = moodAt(live.record, sides?.white.who === "ai" ? "white" : sides?.black.who === "ai" ? "black" : ai, at).mood;
323
367
  const playerToMove = going && live.position.turn === player;
324
368
  const ahead = (side) => side === "white" ? material.balance : -material.balance;
325
- const reviewed = judged || at <= inBook ? plies[at - 1] : undefined;
369
+ // On a variation the AI's badges, notes and drawings say nothing: they are of the game's moves (D-116).
370
+ const reviewed = onTheLine ? undefined : judged || at <= inBook ? plies[at - 1] : undefined;
326
371
  // 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
372
  const judgments = going && settings.helps ? judgmentsOf(plies, player, coached ?? noCoach) : [];
328
373
  const review = reviewed?.review;
@@ -330,7 +375,7 @@ export function App({ start }) {
330
375
  const drawn = hintOpen && hint !== undefined && viewing === undefined && (hint.arrows !== undefined || hint.squares !== undefined) ? hint : review;
331
376
  const drawnByCoach = hint !== undefined && drawn === hint;
332
377
  // 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;
378
+ const boardEnd = ended && !aborted && viewing === undefined && pending === undefined && !onTheLine ? { how, winner, player, fresh: justEnded } : undefined;
334
379
  // The legend has the marks the board shows: none of the helps' or the drawings while the board is bare (D-99).
335
380
  const shownBare = bare && helps !== undefined;
336
381
  const legendRows = legendOf({
@@ -397,7 +442,7 @@ export function App({ start }) {
397
442
  const play = (from, to, piece) => {
398
443
  setSelected(undefined);
399
444
  setRefusal(undefined);
400
- if (piece === undefined && promotes(pieces.get(from), to)) {
445
+ if (piece === undefined && promotes(piecesOnBoard.get(from), to)) {
401
446
  setPromoting({ from, to });
402
447
  return;
403
448
  }
@@ -416,9 +461,35 @@ export function App({ start }) {
416
461
  }
417
462
  });
418
463
  };
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.
464
+ // 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
465
+ // forward through it. The move sounds as the board stands after it.
466
+ const explore = (from, to, piece) => {
467
+ setSelected(undefined);
468
+ setRefusal(undefined);
469
+ if (piece === undefined && promotes(piecesOf(shown.position.fen).get(from), to)) {
470
+ setPromoting({ from, to });
471
+ return;
472
+ }
473
+ setPromoting(undefined);
474
+ const move = shown.legalMoves(from).find((legal) => legal.to === to && legal.promotion === piece);
475
+ if (move === undefined) {
476
+ return;
477
+ }
478
+ const after = afterMove(line, live.record.moves.map(({ san }) => san), at, move.san);
479
+ setLine(after.line);
480
+ setViewing(after.shown >= total ? undefined : after.shown);
481
+ const played = new Game(shown.record);
482
+ const moveSound = played.play(move.san).played && sound ? soundOf(played) : undefined;
483
+ if (moveSound !== undefined) {
484
+ playSound(moveSound);
485
+ }
486
+ };
487
+ // 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
488
+ // the position shown.
420
489
  const refuse = (from, to) => {
421
- const illegal = settings.explain ? live.whyNot(from, to) : undefined;
490
+ const board = analysing ? shown : live;
491
+ const pieces = analysing ? piecesOf(shown.position.fen) : piecesOnBoard;
492
+ const illegal = settings.explain ? board.whyNot(from, to) : undefined;
422
493
  if (illegal !== undefined) {
423
494
  const letterOn = (square) => pieces.get(square).toUpperCase();
424
495
  const sideOn = (square) => pieces.get(square) === letterOn(square) ? "white" : "black";
@@ -453,6 +524,10 @@ export function App({ start }) {
453
524
  };
454
525
  // 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
526
  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 }));
527
+ // The variation as the column and the steps show it.
528
+ const lineView = !analysing || line === undefined || lineNumbered === undefined
529
+ ? undefined
530
+ : { plies: lineNumbered, at: line.at, cursor: line.cursor, onShow: showLine, onClose: closeLine, onBack: () => goTo(line.at) };
456
531
  // 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
532
  // chat at the table, opens the chat's window: the analyst works with the chat's model (D-109).
458
533
  const studyGame = async (studied) => {
@@ -474,7 +549,7 @@ export function App({ start }) {
474
549
  : null, _jsxs("main", { class: `app${flipped ? " flipped" : ""}${folds.talk === "open" ? "" : ` talk-${folds.talk}`}${folds.game === "folded" ? " folded" : ""}`, inert: offline, children: [header, sides === undefined
475
550
  ? (_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
551
  : (_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) => {
552
+ ? (_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
553
  setDraft(words);
479
554
  setUnsent(undefined);
480
555
  }, 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")] })] }))
@@ -485,11 +560,18 @@ export function App({ start }) {
485
560
  setUnsent(undefined);
486
561
  }, 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
562
  // 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,
563
+ // A variation rings the board in the accent (D-116).
564
+ class: between && !justEnded ? "board-wrap off" : onTheLine ? "board-wrap variation" : "board-wrap", onWheel: wheel,
489
565
  // 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)
566
+ 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
567
+ ? undefined
568
+ : analysing
569
+ ? { ...promoting, side: shown.position.turn }
570
+ : viewing !== undefined || !playerToMove
571
+ ? undefined
572
+ : { ...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
573
  ? (_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 ? {} : {
574
+ : 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
575
  analysis: {
494
576
  by: analyst,
495
577
  ...(summary === undefined ? {} : { summary }),
@@ -497,7 +579,7 @@ export function App({ start }) {
497
579
  ...(count === undefined ? {} : { count }),
498
580
  ...(completeIt === undefined ? {} : { onComplete: completeIt }),
499
581
  },
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 })] })] }) }));
582
+ }), 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
583
  }
502
584
  /** 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
585
  function pictureOf({ who, profile: other }, profile, profiles, size, mood) {
@@ -580,6 +662,11 @@ function withMove(game, move) {
580
662
  const copy = new Game(game.record);
581
663
  return copy.play(move).played ? copy : game;
582
664
  }
665
+ /** 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. */
666
+ function lineStartOf(plies, at, first) {
667
+ const fork = plies[at - 1];
668
+ return fork === undefined ? first : fork.side === "white" ? { moveNumber: fork.number, turn: "black" } : { moveNumber: fork.number + 1, turn: "white" };
669
+ }
583
670
  /** Whether a pawn's move takes it to the last rank, where the player chooses what it becomes. */
584
671
  function promotes(piece, to) {
585
672
  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
+ }
@@ -4,18 +4,18 @@ import { LeadBar, LeadChart } from "../lead/lead.js";
4
4
  import { standingAt } from "../lead/view.js";
5
5
  import { Piece } from "../pieces/pieces.js";
6
6
  import { FoldButton, useCardFold } from "./card-fold.js";
7
- import { MoveList, Steps } from "./move-list.js";
7
+ import { MoveList, Steps, stepsLine } from "./move-list.js";
8
8
  import { ResultTile } from "./result-tile.js";
9
9
  import { ReviewProgress } from "./review-progress.js";
10
10
  /**
11
11
  * The column, which the player folds with its tab; under the board, where three columns would leave the board small, always open. A game in analysis
12
12
  * (D-69) says above the moves that the AI is going over it, until its word on the whole game comes, which the talk shows.
13
13
  */
14
- export function GameColumn({ plies, result, how, lit, started, move, toMove, opening, outOfBook, helps, legend, standings, analysis, shownPlies, onShow, onStep, onDownload, onSaveMoves, onAnalyse, text }) {
14
+ export function GameColumn({ plies, result, how, lit, started, move, toMove, opening, outOfBook, helps, legend, standings, analysis, shownPlies, line, onShow, onStep, onDownload, onSaveMoves, onAnalyse, text }) {
15
15
  const bar = standings === undefined ? undefined : _jsx(LeadBar, { standing: standingAt(standings, shownPlies), shown: shownPlies, text: text });
16
16
  return (_jsx("aside", { class: "side", id: "game", "aria-label": text.game, children: started === undefined && analysis === undefined
17
17
  ? (_jsxs(_Fragment, { children: [_jsx("p", { class: "card empty", children: text.noGame }), onAnalyse === undefined ? null : _jsx(AnalyseButton, { label: text.study.analyseLast, onAnalyse: onAnalyse })] }))
18
- : (_jsxs(_Fragment, { children: [_jsxs("div", { class: "side-cards", children: [_jsx(GameCard, { plies: plies, result: result, how: how, lit: lit, started: started, move: move, toMove: toMove, bar: bar, text: text }), analysis?.reviewing !== true ? null : (_jsxs("div", { class: "card review waiting", children: [_jsx("p", { role: "status", children: text.reviewing(analysis.by) }), analysis.count === undefined ? null : _jsx(ReviewProgress, { count: analysis.count, label: text.reviewing(analysis.by) })] })), analysis?.onComplete === undefined ? null : (_jsxs("div", { class: "card review incomplete", children: [_jsxs("p", { children: [_jsx("span", { role: "status", children: text.incomplete }), _jsx("button", { type: "button", title: text.completeHint, "aria-label": text.completeHint, onClick: analysis.onComplete, children: text.complete })] }), analysis.count === undefined || analysis.count.done === 0 ? null : _jsx(ReviewProgress, { count: analysis.count, label: text.incomplete, stopped: true })] })), analysis === undefined || standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: shownPlies, onShow: onShow, text: text }), opening === undefined ? null : _jsx(OpeningCard, { opening: opening, ...(outOfBook === undefined ? {} : { outOfBook }), text: text }), helps] }), _jsxs("div", { class: "side-foot", children: [result !== undefined && plies.length === 0 ? null : _jsx(MoveList, { plies: plies, result: result, shown: shownPlies, onShow: onShow, tools: legend, text: text }), onDownload === undefined && onSaveMoves === undefined ? null : (_jsxs("div", { class: "card analysis-actions", children: [onDownload === undefined ? null : _jsx(SaveButton, { label: text.study.download, hint: text.study.downloadHint, onSave: onDownload }), onSaveMoves === undefined ? null : _jsx(SaveButton, { label: text.saveMoves, hint: text.saveMovesHint, onSave: onSaveMoves })] })), onAnalyse === undefined ? null : _jsx(AnalyseButton, { label: text.study.analyseIt, onAnalyse: onAnalyse }), _jsx(Steps, { shown: shownPlies, total: plies.length, onStep: onStep, text: text })] })] })) }));
18
+ : (_jsxs(_Fragment, { children: [_jsxs("div", { class: "side-cards", children: [_jsx(GameCard, { plies: plies, result: result, how: how, lit: lit, started: started, move: move, toMove: toMove, bar: bar, text: text }), analysis?.reviewing !== true ? null : (_jsxs("div", { class: "card review waiting", children: [_jsx("p", { role: "status", children: text.reviewing(analysis.by) }), analysis.count === undefined ? null : _jsx(ReviewProgress, { count: analysis.count, label: text.reviewing(analysis.by) })] })), analysis?.onComplete === undefined ? null : (_jsxs("div", { class: "card review incomplete", children: [_jsxs("p", { children: [_jsx("span", { role: "status", children: text.incomplete }), _jsx("button", { type: "button", title: text.completeHint, "aria-label": text.completeHint, onClick: analysis.onComplete, children: text.complete })] }), analysis.count === undefined || analysis.count.done === 0 ? null : _jsx(ReviewProgress, { count: analysis.count, label: text.incomplete, stopped: true })] })), analysis === undefined || standings === undefined ? null : _jsx(LeadChart, { plies: plies, standings: standings, shown: shownPlies, onShow: onShow, text: text }), opening === undefined ? null : _jsx(OpeningCard, { opening: opening, ...(outOfBook === undefined ? {} : { outOfBook }), text: text }), helps] }), _jsxs("div", { class: "side-foot", children: [result !== undefined && plies.length === 0 ? null : _jsx(MoveList, { plies: plies, result: result, shown: shownPlies, ...(line === undefined ? {} : { line }), onShow: onShow, tools: legend, text: text }), onDownload === undefined && onSaveMoves === undefined ? null : (_jsxs("div", { class: "card analysis-actions", children: [onDownload === undefined ? null : _jsx(SaveButton, { label: text.study.download, hint: text.study.downloadHint, onSave: onDownload }), onSaveMoves === undefined ? null : _jsx(SaveButton, { label: text.saveMoves, hint: text.saveMovesHint, onSave: onSaveMoves })] })), onAnalyse === undefined ? null : _jsx(AnalyseButton, { label: text.study.analyseIt, onAnalyse: onAnalyse }), _jsx(Steps, { shown: shownPlies, total: plies.length, ...stepsLine(line, plies), onStep: onStep, text: text })] })] })) }));
19
19
  }
20
20
  /** A button that saves the game as a file in the player's downloads, its tooltip saying what the file holds. */
21
21
  function SaveButton({ label, hint, onSave }) {
@@ -504,7 +504,7 @@
504
504
  font-weight: 600;
505
505
  }
506
506
 
507
- .moves li:nth-child(even) {
507
+ .moves li:nth-child(even of :not(.line)) {
508
508
  background: var(--page);
509
509
  }
510
510
 
@@ -555,6 +555,97 @@
555
555
  box-shadow: 0 0 0 1.5px var(--card);
556
556
  }
557
557
 
558
+ /* A variation in analysis (D-116): one row under the move it leaves from, indented, an accent edge on its left, its moves wrapping; the move it leaves
559
+ from underlined in the accent while the board shows the line */
560
+ .moves li.line {
561
+ display: flex;
562
+ align-items: flex-start;
563
+ gap: 2px;
564
+ margin: 2px 0 2px 24px;
565
+ padding: 2px 2px 2px 8px;
566
+ border-left: 3px solid var(--accent);
567
+ border-radius: 0 6px 6px 0;
568
+ background: color-mix(in oklab, var(--accent) 8%, var(--card));
569
+ font-size: 13px;
570
+ }
571
+
572
+ .line-moves {
573
+ flex: 1;
574
+ display: flex;
575
+ flex-wrap: wrap;
576
+ gap: 1px 2px;
577
+ }
578
+
579
+ .moves .line .ply {
580
+ margin-left: 0;
581
+ padding: 1px 4px;
582
+ }
583
+
584
+ .line-number {
585
+ color: var(--soft);
586
+ margin-right: 1px;
587
+ }
588
+
589
+ .moves .line .current .line-number {
590
+ color: #4d5866;
591
+ }
592
+
593
+ .moves .ply.fork {
594
+ box-shadow: inset 0 -2px 0 var(--accent);
595
+ border-radius: 5px 5px 0 0;
596
+ }
597
+
598
+ /* Back on the game the line fades, its moves still a click away */
599
+ .moves li.line.faded {
600
+ opacity: 0.55;
601
+ }
602
+
603
+ .moves li.line.faded:hover,
604
+ .moves li.line.faded:focus-within {
605
+ opacity: 0.85;
606
+ }
607
+
608
+ /* The cross that closes it, at the row's end; a finger's 32 pixels on a touch screen */
609
+ .close-line {
610
+ display: grid;
611
+ place-items: center;
612
+ width: 24px;
613
+ height: 24px;
614
+ flex: none;
615
+ padding: 0;
616
+ border: 0;
617
+ border-radius: 50%;
618
+ background: none;
619
+ color: var(--soft);
620
+ cursor: pointer;
621
+ }
622
+
623
+ .close-line:hover {
624
+ background: color-mix(in oklab, var(--ink) 9%, transparent);
625
+ color: var(--ink);
626
+ }
627
+
628
+ .close-line .icon {
629
+ width: 14px;
630
+ height: 14px;
631
+ stroke-width: 2.2;
632
+ }
633
+
634
+ @media (pointer: coarse) {
635
+ .close-line {
636
+ width: 32px;
637
+ height: 32px;
638
+ margin-block: -4px;
639
+ }
640
+ }
641
+
642
+ @media (max-width: 479px) {
643
+ .moves li.line {
644
+ margin-left: 12px;
645
+ padding-left: 6px;
646
+ }
647
+ }
648
+
558
649
  /* The buttons that step through the moves, under them as on Lichess and chess.com */
559
650
  .steps {
560
651
  display: flex;
@@ -591,6 +682,35 @@
591
682
  stroke-width: 2;
592
683
  }
593
684
 
685
+ /* On a variation, the fork with the move it leaves from, in the accent: it takes the board back to the game */
686
+ .steps .fork-back {
687
+ flex: 1.6;
688
+ display: inline-flex;
689
+ align-items: center;
690
+ justify-content: center;
691
+ gap: 3px;
692
+ padding: 4px 6px;
693
+ border-color: var(--accent);
694
+ color: var(--accent);
695
+ font: inherit;
696
+ font-size: 13px;
697
+ white-space: nowrap;
698
+ }
699
+
700
+ .steps .fork-back:hover {
701
+ border-color: var(--accent);
702
+ }
703
+
704
+ .steps .fork-back span {
705
+ color: var(--ink);
706
+ }
707
+
708
+ .steps .fork-icon {
709
+ width: 16px;
710
+ height: 16px;
711
+ flex: none;
712
+ }
713
+
594
714
  /* A piece in a move: the set's small one, White's white and Black's black, edged so either shows on either theme */
595
715
  .figurine {
596
716
  display: inline-block;
@@ -1,22 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ // The moves of the game beside the board, in pairs, with the pieces drawn as figures, the same in every language (D-50); the player looks back through them
3
+ // with a click on one or with the buttons under them. In analysis a variation the player plays is one row under the move it leaves from (D-116).
4
+ import { Fragment } from "preact";
2
5
  import { useEffect, useRef } from "preact/hooks";
3
6
  import { Figurine } from "../pieces/pieces.js";
4
7
  import { BadgeIcon, NoteIcon } from "./badges.js";
5
8
  import { FoldButton, useCardFold } from "./card-fold.js";
9
+ import { lineText, moveNumberOf } from "./variation.js";
6
10
  /**
7
11
  * The moves numbered in pairs, the one the board shows marked (`shown`, how many moves it shows), and the result once the game is over; a click on a move
8
12
  * shows it. The list keeps its last move in sight, and the one shown while the player looks back.
9
13
  */
10
- export function MoveList({ plies, result, shown, onShow, tools, text }) {
14
+ export function MoveList({ plies, result, shown, line, onShow, tools, text }) {
11
15
  const list = useRef(null);
12
16
  const [folded, toggleFold] = useCardFold("moves");
13
17
  const rows = rowsOf(plies);
14
- const current = plies[shown - 1];
18
+ const onTheLine = line !== undefined && line.cursor > 0;
19
+ const current = onTheLine ? undefined : plies[shown - 1];
20
+ const fork = onTheLine ? plies[line.at - 1] : undefined;
21
+ // The line goes under the row of the move it leaves from, before the first row when it leaves from the start.
22
+ const lineAfter = line === undefined ? undefined : line.at === 0 ? -1 : rows.findIndex((row) => row.white === plies[line.at - 1] || row.black === plies[line.at - 1]);
15
23
  useEffect(() => {
16
24
  list.current?.scrollTo({ top: list.current.scrollHeight });
17
25
  }, [plies.length]);
18
26
  // Only the list scrolls to the move shown: the page under the board stays where the player has it.
19
- useEffect(() => keepInSight(list.current), [shown]);
27
+ useEffect(() => keepInSight(list.current), [shown, line?.cursor, line?.plies.length]);
20
28
  // The cards above it change height with the position, the helps' card above all: the list follows, its move still in sight.
21
29
  useEffect(() => {
22
30
  const element = list.current;
@@ -27,26 +35,56 @@ export function MoveList({ plies, result, shown, onShow, tools, text }) {
27
35
  observer.observe(element);
28
36
  return () => observer.disconnect();
29
37
  }, []);
30
- const move = (ply) => _jsx(Move, { ply: ply, current: ply !== undefined && ply === current, onShow: () => onShow(plies.indexOf(ply) + 1), text: text });
38
+ const move = (ply) => _jsx(Move, { ply: ply, current: ply !== undefined && ply === current, fork: ply !== undefined && ply === fork, onShow: () => onShow(plies.indexOf(ply) + 1), text: text });
39
+ const lineRow = line === undefined ? null : _jsx(LineRow, { line: line, text: text });
31
40
  // The card's title with its icon, a score sheet, as the other cards of the column, which folds the card to it (Stefano); only the list under it
32
41
  // scrolls, and it stays in the page while folded, so that it keeps its move in sight.
33
- return (_jsxs("section", { class: `card moves-card${folded ? " folded" : ""}`, "aria-label": text.moves, children: [_jsxs("div", { class: "moves-head", children: [_jsx("h3", { class: "card-title", children: _jsxs(FoldButton, { folded: folded, onFold: toggleFold, text: text, children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 3H15L19 7V21H6ZM15 3V7H19M9 11H16M9 14.5H16M9 18H13" }) }), text.moves] }) }), tools] }), _jsxs("div", { ref: list, class: "moves", hidden: folded, children: [_jsx("ol", { children: rows.map(({ number, white, black }) => (_jsxs("li", { children: [_jsxs("span", { class: "number", children: [number, "."] }), move(white), move(black)] }, number))) }), result === undefined ? null : _jsx("p", { class: "result", children: result })] })] }));
42
+ return (_jsxs("section", { class: `card moves-card${folded ? " folded" : ""}`, "aria-label": text.moves, children: [_jsxs("div", { class: "moves-head", children: [_jsx("h3", { class: "card-title", children: _jsxs(FoldButton, { folded: folded, onFold: toggleFold, text: text, children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 3H15L19 7V21H6ZM15 3V7H19M9 11H16M9 14.5H16M9 18H13" }) }), text.moves] }) }), tools] }), _jsxs("div", { ref: list, class: "moves", hidden: folded, children: [_jsxs("ol", { children: [lineAfter === -1 ? lineRow : null, rows.map(({ number, white, black }, index) => (_jsxs(Fragment, { children: [_jsxs("li", { children: [_jsxs("span", { class: "number", children: [number, "."] }), move(white), move(black)] }), index === lineAfter ? lineRow : null] }, number)))] }), result === undefined ? null : _jsx("p", { class: "result", children: result })] })] }));
34
43
  }
35
- /** The buttons that step through the moves: to the start, back, forward, and to the game as it is; each one off where it could not go. */
36
- export function Steps({ shown, total, onStep, text }) {
44
+ /**
45
+ * The buttons that step through the moves: to the start, back, forward, and to the game as it is; each one off where it could not go. On a variation,
46
+ * back and forward step through it, and the fork with the move it leaves from takes the board back to the game.
47
+ */
48
+ export function Steps({ shown, total, line, fork, onStep, text }) {
49
+ const onTheLine = line !== undefined && line.cursor > 0;
37
50
  const button = (step, path, off) => (_jsx("button", { type: "button", title: text.steps[step], "aria-label": text.steps[step], disabled: off, onClick: () => onStep(step), children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: path }) }) }));
38
- return (_jsxs("nav", { class: "steps card", "aria-label": text.moves, children: [button("first", "M7 6V18M17 6L11 12L17 18", shown === 0), button("back", "M14.5 6L8.5 12L14.5 18", shown === 0), button("forward", "M9.5 6L15.5 12L9.5 18", shown === total), button("last", "M17 6V18M7 6L13 12L7 18", shown === total)] }));
51
+ return (_jsxs("nav", { class: "steps card", "aria-label": text.moves, children: [button("first", "M7 6V18M17 6L11 12L17 18", !onTheLine && shown === 0), button("back", "M14.5 6L8.5 12L14.5 18", !onTheLine && shown === 0), button("forward", "M9.5 6L15.5 12L9.5 18", onTheLine ? line.cursor === line.plies.length : shown === total), button("last", "M17 6V18M7 6L13 12L7 18", !onTheLine && shown === total), onTheLine ? _jsx(ForkButton, { fork: fork, onBack: line.onBack, text: text }) : null] }));
52
+ }
53
+ /** The variation for the steps, with the game's move it leaves from. */
54
+ export function stepsLine(line, plies) {
55
+ const fork = line === undefined ? undefined : plies[line.at - 1];
56
+ return line === undefined ? {} : fork === undefined ? { line } : { line, fork };
39
57
  }
40
58
  /** A move in SAN with each piece's letter drawn as its figure, the same in every language (D-50), and a capture's x in grey, so the squares stand out. */
41
59
  export function Figurines({ san, side }) {
42
60
  return (_jsx(_Fragment, { children: san.split(/([KQRBNx])/).map((part, index) => index % 2 === 0 ? part : part === "x" ? _jsx("span", { class: "takes", children: "x" }, index) : _jsx(Figurine, { letter: part, side: side }, index)) }));
43
61
  }
44
- function Move({ ply, current, onShow, text }) {
62
+ function Move({ ply, current, fork, onShow, text }) {
45
63
  if (ply === undefined) {
46
64
  return _jsx("span", { children: "\u2026" });
47
65
  }
48
66
  // A screen reader reads the move in SAN and the AI's badge by its name; the eye sees each piece's letter as its figure, and the badge's disc (D-69).
49
- return (_jsxs("button", { type: "button", class: current ? "ply current" : "ply", "aria-current": current ? "step" : undefined, onClick: onShow, children: [_jsx("span", { class: "visually-hidden", children: ply.review?.badge === undefined ? ply.san : `${ply.san}, ${text.badges[ply.review.badge]}` }), _jsx("span", { "aria-hidden": "true", children: _jsx(Figurines, { san: ply.san, side: ply.side }) }), ply.review?.badge !== undefined ? _jsx(BadgeIcon, { badge: ply.review.badge }) : ply.review?.note !== undefined ? _jsx(NoteIcon, {}) : null] }));
67
+ return (_jsxs("button", { type: "button", class: current ? "ply current" : fork ? "ply fork" : "ply", "aria-current": current ? "step" : undefined, onClick: onShow, children: [_jsx("span", { class: "visually-hidden", children: ply.review?.badge === undefined ? ply.san : `${ply.san}, ${text.badges[ply.review.badge]}` }), _jsx("span", { "aria-hidden": "true", children: _jsx(Figurines, { san: ply.san, side: ply.side }) }), ply.review?.badge !== undefined ? _jsx(BadgeIcon, { badge: ply.review.badge }) : ply.review?.note !== undefined ? _jsx(NoteIcon, {}) : null] }));
68
+ }
69
+ /**
70
+ * The variation's row: its moves numbered as a PGN writes them, the one shown in the gold of the moves, and a cross that closes it; faded while the board
71
+ * shows the game, its moves still a click away.
72
+ */
73
+ function LineRow({ line, text }) {
74
+ return (_jsxs("li", { class: line.cursor > 0 ? "line" : "line faded", "aria-label": `${text.variation.line}: ${lineText(line.plies)}`, children: [_jsx("span", { class: "line-moves", children: line.plies.map((ply, index) => {
75
+ const current = index + 1 === line.cursor;
76
+ const number = moveNumberOf(ply, index === 0);
77
+ return (_jsxs("button", { type: "button", class: current ? "ply current" : "ply", "aria-current": current ? "step" : undefined, onClick: () => line.onShow(index + 1), children: [_jsxs("span", { class: "visually-hidden", children: [number, ply.san] }), _jsxs("span", { "aria-hidden": "true", children: [number === "" ? null : _jsx("span", { class: "line-number", children: number.replace("...", "…") }), _jsx(Figurines, { san: ply.san, side: ply.side })] })] }, index));
78
+ }) }), _jsx("button", { type: "button", class: "close-line", title: text.variation.close, "aria-label": text.variation.close, onClick: line.onClose, children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M7 7L17 17M17 7L7 17" }) }) })] }));
79
+ }
80
+ /** The fork with the game's move the variation leaves from: it takes the board back to the game there. */
81
+ function ForkButton({ fork, onBack, text }) {
82
+ const move = fork === undefined ? undefined : `${fork.number}${fork.side === "white" ? "." : "..."}${fork.san}`;
83
+ return (_jsxs("button", { type: "button", class: "fork-back", title: text.variation.back(move), "aria-label": text.variation.back(move), onClick: onBack, children: [_jsx(ForkIcon, {}), fork === undefined ? null : (_jsxs("span", { "aria-hidden": "true", children: [fork.number, fork.side === "white" ? "." : "…", _jsx(Figurines, { san: fork.san, side: fork.side })] }))] }));
84
+ }
85
+ /** The sign of a variation: a line that forks from the game's. */
86
+ export function ForkIcon() {
87
+ return (_jsx("svg", { class: "icon fork-icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M7 4V20M7 12C7 8 17 9 17 4M17 4L14 6.5M17 4L19.5 7" }) }));
50
88
  }
51
89
  /** Scrolls the list, and only the list, as little as it takes to show the whole row of its current move, and the result under the last one. */
52
90
  function keepInSight(list) {