chesspal 0.2.4 → 0.3.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 (183) hide show
  1. package/README.md +11 -12
  2. package/dist/cli/cli.js +83 -3
  3. package/dist/cli/command-line.js +26 -9
  4. package/dist/cli/icon.d.ts +25 -0
  5. package/dist/cli/icon.js +80 -0
  6. package/dist/commands/agents.d.ts +19 -2
  7. package/dist/commands/agents.js +39 -13
  8. package/dist/commands/ai-say.js +3 -4
  9. package/dist/commands/analyse-game.d.ts +4 -0
  10. package/dist/commands/analyse-game.js +46 -13
  11. package/dist/commands/annotate-game.d.ts +2 -0
  12. package/dist/commands/annotate-game.js +40 -18
  13. package/dist/commands/answer-lines.d.ts +2 -0
  14. package/dist/commands/answer-lines.js +13 -3
  15. package/dist/commands/coach.js +4 -2
  16. package/dist/commands/context.d.ts +9 -1
  17. package/dist/commands/context.js +5 -0
  18. package/dist/commands/delete-review.js +9 -5
  19. package/dist/commands/game-summary.d.ts +2 -0
  20. package/dist/commands/game-summary.js +4 -0
  21. package/dist/commands/index.d.ts +3 -2
  22. package/dist/commands/index.js +7 -4
  23. package/dist/commands/init.js +2 -4
  24. package/dist/commands/instructions.d.ts +9 -0
  25. package/dist/commands/instructions.js +30 -0
  26. package/dist/commands/lead-input.d.ts +1 -1
  27. package/dist/commands/lead-input.js +7 -4
  28. package/dist/commands/page-link.js +6 -5
  29. package/dist/commands/past-games.d.ts +1 -1
  30. package/dist/commands/past-games.js +12 -5
  31. package/dist/commands/player-move.js +2 -3
  32. package/dist/commands/profiles.d.ts +23 -9
  33. package/dist/commands/profiles.js +76 -35
  34. package/dist/commands/ratings.d.ts +8 -0
  35. package/dist/commands/ratings.js +45 -0
  36. package/dist/commands/registry.d.ts +1 -1
  37. package/dist/commands/registry.js +3 -2
  38. package/dist/commands/say.d.ts +1 -1
  39. package/dist/commands/say.js +1 -1
  40. package/dist/commands/status.d.ts +3 -1
  41. package/dist/commands/status.js +46 -27
  42. package/dist/commands/study.js +8 -5
  43. package/dist/core/index.d.ts +1 -1
  44. package/dist/core/index.js +1 -1
  45. package/dist/core/review.d.ts +2 -0
  46. package/dist/core/review.js +5 -1
  47. package/dist/games/ai-model.d.ts +4 -2
  48. package/dist/games/ai-model.js +10 -1
  49. package/dist/games/analysis.d.ts +26 -3
  50. package/dist/games/analysis.js +41 -6
  51. package/dist/games/archive.d.ts +6 -3
  52. package/dist/games/archive.js +13 -5
  53. package/dist/games/current-game.d.ts +10 -1
  54. package/dist/games/current-game.js +49 -15
  55. package/dist/games/files.d.ts +5 -0
  56. package/dist/games/files.js +42 -1
  57. package/dist/games/index.d.ts +3 -3
  58. package/dist/games/index.js +3 -3
  59. package/dist/home/data-folder.d.ts +2 -0
  60. package/dist/home/data-folder.js +5 -1
  61. package/dist/home/index.d.ts +1 -1
  62. package/dist/home/index.js +1 -1
  63. package/dist/host-files/agents.d.ts +2 -2
  64. package/dist/host-files/agents.js +12 -6
  65. package/dist/host-files/host-files.d.ts +5 -3
  66. package/dist/host-files/host-files.js +15 -8
  67. package/dist/host-files/index.d.ts +2 -2
  68. package/dist/host-files/index.js +2 -2
  69. package/dist/page/app/app.js +29 -16
  70. package/dist/page/app/title.js +17 -0
  71. package/dist/page/avatars/torretta.js +4 -4
  72. package/dist/page/dialogs/analyses.js +4 -4
  73. package/dist/page/dialogs/dialog.js +4 -1
  74. package/dist/page/dialogs/dialogs.css +84 -10
  75. package/dist/page/dialogs/profiles.js +95 -42
  76. package/dist/page/game/column.js +5 -1
  77. package/dist/page/game/plies.js +7 -4
  78. package/dist/page/guide/blocks.js +67 -0
  79. package/dist/page/guide/en.md +66 -0
  80. package/dist/page/guide/foot.js +18 -0
  81. package/dist/page/guide/guide.css +200 -0
  82. package/dist/page/guide/guide.js +89 -0
  83. package/dist/page/guide/it.md +66 -0
  84. package/dist/page/guide/markdown.js +67 -0
  85. package/dist/page/helps/helps.css +7 -10
  86. package/dist/page/layout/favicon.svg +13 -0
  87. package/dist/page/layout/header.js +3 -3
  88. package/dist/page/layout/icon.icns +0 -0
  89. package/dist/page/layout/icon.ico +0 -0
  90. package/dist/page/layout/icon.png +0 -0
  91. package/dist/page/layout/layout.css +111 -4
  92. package/dist/page/layout/window.js +45 -0
  93. package/dist/page/main.js +4 -1
  94. package/dist/page/players/card.js +1 -1
  95. package/dist/page/profile/profile.js +14 -9
  96. package/dist/page/profile/settings.js +9 -0
  97. package/dist/page/style.css +2 -0
  98. package/dist/page/table/opener.css +65 -0
  99. package/dist/page/table/opener.js +59 -0
  100. package/dist/page/talk/chat.css +10 -3
  101. package/dist/page/talk/talk.css +38 -26
  102. package/dist/page/talk/talk.js +4 -2
  103. package/dist/page/text.js +83 -17
  104. package/dist/page/theme.css +7 -0
  105. package/dist/profiles/index.d.ts +2 -1
  106. package/dist/profiles/index.js +2 -1
  107. package/dist/profiles/profiles.d.ts +10 -15
  108. package/dist/profiles/profiles.js +30 -15
  109. package/dist/profiles/ratings.d.ts +35 -0
  110. package/dist/profiles/ratings.js +61 -0
  111. package/dist/server/bell.d.ts +24 -3
  112. package/dist/server/bell.js +84 -10
  113. package/dist/server/caller.d.ts +2 -1
  114. package/dist/server/caller.js +3 -2
  115. package/dist/server/mcp.d.ts +7 -3
  116. package/dist/server/mcp.js +100 -52
  117. package/dist/server/page/analyses.d.ts +2 -0
  118. package/dist/server/page/analyses.js +20 -5
  119. package/dist/server/page/attach.d.ts +4 -0
  120. package/dist/server/page/attach.js +112 -0
  121. package/dist/server/page/chat.d.ts +60 -0
  122. package/dist/server/page/chat.js +79 -0
  123. package/dist/server/page/events.d.ts +7 -0
  124. package/dist/server/page/events.js +20 -0
  125. package/dist/server/page/files.js +10 -1
  126. package/dist/server/page/game.js +43 -23
  127. package/dist/server/page/page.d.ts +47 -7
  128. package/dist/server/page/page.js +69 -48
  129. package/dist/server/page/profiles.d.ts +5 -0
  130. package/dist/server/page/profiles.js +26 -10
  131. package/dist/server/page/table.d.ts +39 -0
  132. package/dist/server/page/table.js +102 -0
  133. package/dist/server/table/browser.d.ts +6 -0
  134. package/dist/server/table/browser.js +48 -0
  135. package/dist/server/table/hosts/chats.d.ts +7 -0
  136. package/dist/server/table/hosts/chats.js +31 -0
  137. package/dist/server/table/hosts/find.d.ts +2 -0
  138. package/dist/server/table/hosts/find.js +20 -0
  139. package/dist/server/table/hosts/hosts.d.ts +34 -0
  140. package/dist/server/table/hosts/hosts.js +49 -0
  141. package/dist/server/table/hosts/index.d.ts +3 -0
  142. package/dist/server/table/hosts/index.js +4 -0
  143. package/dist/server/table/hosts/play.d.ts +25 -0
  144. package/dist/server/table/hosts/play.js +152 -0
  145. package/dist/server/table/hosts/seat.d.ts +25 -0
  146. package/dist/server/table/hosts/seat.js +74 -0
  147. package/dist/server/table/hosts/terminal.d.ts +6 -0
  148. package/dist/server/table/hosts/terminal.js +63 -0
  149. package/dist/server/table/icon/index.d.ts +32 -0
  150. package/dist/server/table/icon/index.js +83 -0
  151. package/dist/server/table/icon/linux.d.ts +5 -0
  152. package/dist/server/table/icon/linux.js +47 -0
  153. package/dist/server/table/icon/macos.d.ts +3 -0
  154. package/dist/server/table/icon/macos.js +38 -0
  155. package/dist/server/table/icon/windows.d.ts +3 -0
  156. package/dist/server/table/icon/windows.js +48 -0
  157. package/dist/server/table/index.d.ts +6 -0
  158. package/dist/server/table/index.js +7 -0
  159. package/dist/server/table/launch.d.ts +19 -0
  160. package/dist/server/table/launch.js +78 -0
  161. package/dist/server/table/link.d.ts +10 -0
  162. package/dist/server/table/link.js +230 -0
  163. package/dist/server/table/table.d.ts +35 -0
  164. package/dist/server/table/table.js +94 -0
  165. package/dist/sites/chesscom.d.ts +4 -0
  166. package/dist/sites/chesscom.js +25 -0
  167. package/dist/sites/choice.d.ts +17 -0
  168. package/dist/sites/choice.js +13 -0
  169. package/dist/sites/index.d.ts +2 -0
  170. package/dist/sites/index.js +4 -0
  171. package/dist/sites/lichess.d.ts +4 -0
  172. package/dist/sites/lichess.js +32 -0
  173. package/dist/sites/request.d.ts +11 -0
  174. package/dist/sites/request.js +15 -0
  175. package/dist/sites/sites.d.ts +6 -0
  176. package/dist/sites/sites.js +33 -0
  177. package/instructions/analysis.md +1 -1
  178. package/instructions/analyst-agent.md +1 -1
  179. package/instructions/coach.md +2 -1
  180. package/instructions/opponent.md +6 -6
  181. package/instructions/ponder.md +1 -0
  182. package/instructions/start.md +1 -0
  183. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  // Writing the player's files safely: whole or not at all, with failures that say what to do (D-27).
2
- import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  /** Writes a file whole or not at all: a temporary file beside it, then renamed over it (D-27). A failure says what to do about it. */
4
4
  export function writeAtomically(path, text) {
5
5
  const temporary = `${path}.${process.pid}.tmp`;
@@ -14,6 +14,40 @@ export function writeAtomically(path, text) {
14
14
  rmSync(temporary, { force: true });
15
15
  }
16
16
  }
17
+ /**
18
+ * Runs `write` while this process alone holds the file's lock, a folder beside it made only if missing, cheaper than a file on Windows: another process
19
+ * waits, a second at most. A lock older than two seconds is of a process that stopped while it held it, and goes.
20
+ */
21
+ export function whileLocked(path, write) {
22
+ const lock = `${path}.lock`;
23
+ for (let attempt = 1;; attempt++) {
24
+ try {
25
+ mkdirSync(lock);
26
+ break;
27
+ }
28
+ catch (error) {
29
+ // Windows refuses to make a folder another process is deleting: the lock is still held.
30
+ if (!["EEXIST", "EPERM", "EACCES"].includes(error.code ?? "")) {
31
+ throw new Error(`${lock} could not be made (${error.message}): check that its folder can be written.`, { cause: error });
32
+ }
33
+ if (isStale(lock)) {
34
+ rmSync(lock, { recursive: true, force: true });
35
+ }
36
+ else if (attempt === lockAttempts) {
37
+ throw new Error(`${lock} is held by another Chesspal writing the game: try again in a moment, or delete that folder when no Chesspal runs.`);
38
+ }
39
+ else {
40
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
41
+ }
42
+ }
43
+ }
44
+ try {
45
+ return write();
46
+ }
47
+ finally {
48
+ rmdirSync(lock);
49
+ }
50
+ }
17
51
  /** Makes a folder, and those above it, when missing. A failure says what to do about it. */
18
52
  export function makeFolder(path) {
19
53
  try {
@@ -30,6 +64,13 @@ export function stampOf(path) {
30
64
  return stats === undefined ? undefined : `${stats.ino}:${stats.mtimeNs}:${stats.size}`;
31
65
  }
32
66
  const attempts = 10;
67
+ // A lock is waited for 5 ms at a time, a second at most; one older than two seconds is of a process that stopped while it held it.
68
+ const lockAttempts = 200;
69
+ const staleLock = 2_000;
70
+ function isStale(lock) {
71
+ const stats = statSync(lock, { throwIfNoEntry: false });
72
+ return stats !== undefined && Date.now() - stats.mtimeMs > staleLock;
73
+ }
33
74
  /**
34
75
  * Renames a file or a folder. Windows refuses while another program holds a file in it open (an editor, an antivirus, a sync client): Chesspal waits a
35
76
  * little and tries again, half a second at most.
@@ -1,9 +1,9 @@
1
- export { aiHostOf, aiModelOf, noteAiHost, noteAiModel } from "./ai-model.ts";
2
- export { type Analysis, endAnalysis, gameInAnalysis, startAnalysis } from "./analysis.ts";
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
3
  export { archiveCoached, archivedGame, archivedGames, archivedName, archivedWhen, archiveEnded, archiveGame, archiveName, deleteGame, gameStemOf, isGameName, isOverOnTable, keepToStudy, keptGame, keptGames, listedName, movesPgn, 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, type GameEnd, gameStamp, loadGame, removeGame, 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 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 { endTableTalk, loadTableTalk, saveTableTalk, type TableTalk, tableTalkStamp } from "./table-talk.ts";
@@ -1,10 +1,10 @@
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
- export { aiHostOf, aiModelOf, noteAiHost, noteAiModel } from "./ai-model.js";
3
- export { endAnalysis, gameInAnalysis, startAnalysis } from "./analysis.js";
2
+ export { aiHostOf, aiModelOf, forgetTheAi, noteAiHost, noteAiModel } from "./ai-model.js";
3
+ export { endAnalysis, forgetAnalyst, gameInAnalysis, startAnalysis } from "./analysis.js";
4
4
  export { archiveCoached, archivedGame, archivedGames, archivedName, archivedWhen, archiveEnded, archiveGame, archiveName, deleteGame, gameStemOf, 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
- export { bothSidesMoved, drawOfferOf, endOf, gameStamp, loadGame, removeGame, saveGame } from "./current-game.js";
7
+ export { bothSidesMoved, drawOfferOf, endOf, GameChanged, gameStamp, loadGame, removeGame, rerunIfChanged, saveGame } from "./current-game.js";
8
8
  export { makeFolder, renameOver, writeAtomically } from "./files.js";
9
9
  export { commentWithPlan, currentPlan, planOf, withoutPlans } from "./plans.js";
10
10
  export { endTableTalk, loadTableTalk, saveTableTalk, tableTalkStamp } from "./table-talk.js";
@@ -10,3 +10,5 @@ export interface System {
10
10
  }
11
11
  /** The player's data folder: CHESSPAL_HOME when set, else the system's Documents with Chesspal's folder in it. */
12
12
  export declare function dataFolder(system?: System): string;
13
+ /** The table's folder inside the data folder: one for every profile, where the chats the table opens start (D-104). */
14
+ export declare function tableFolder(home: string): string;
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { posix, win32 } from "node:path";
6
+ import { join, posix, win32 } from "node:path";
7
7
  /** The player's data folder: CHESSPAL_HOME when set, else the system's Documents with Chesspal's folder in it. */
8
8
  export function dataFolder(system = thisSystem()) {
9
9
  const chosen = system.env.CHESSPAL_HOME?.trim();
@@ -16,6 +16,10 @@ export function dataFolder(system = thisSystem()) {
16
16
  }
17
17
  return posix.join(system.platform === "darwin" ? posix.join(system.home, "Documents") : xdgDocuments(system), "Chesspal");
18
18
  }
19
+ /** The table's folder inside the data folder: one for every profile, where the chats the table opens start (D-104). */
20
+ export function tableFolder(home) {
21
+ return join(home, "table");
22
+ }
19
23
  // Where Windows keeps Documents for this user, as Explorer's known folder does: the registry names it, with variables such as %USERPROFILE%.
20
24
  const shellFolders = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders";
21
25
  function thisSystem() {
@@ -1,2 +1,2 @@
1
- export { dataFolder, type System } from "./data-folder.ts";
1
+ export { dataFolder, type System, tableFolder } from "./data-folder.ts";
2
2
  export { openFolder } from "./open-folder.ts";
@@ -1,3 +1,3 @@
1
1
  // Where the player's data is (D-71): one folder for every chat, apart from the host's folder. Below the commands, beside the games and the profiles.
2
- export { dataFolder } from "./data-folder.js";
2
+ export { dataFolder, tableFolder } from "./data-folder.js";
3
3
  export { openFolder } from "./open-folder.js";
@@ -22,5 +22,5 @@ export declare function agentFiles(folder: string): {
22
22
  path: string;
23
23
  text: string;
24
24
  }[];
25
- /** Instructions for the AI, by their file's name in the package's instructions folder. */
26
- export declare function instructions(name: string): string;
25
+ /** Instructions for the AI, by their file's name in the package's instructions folder: read once, as the version installed has them. */
26
+ export declare function instructionsFile(name: string): string;
@@ -38,10 +38,16 @@ export function agentFiles(folder) {
38
38
  const agents = agentSpecs();
39
39
  return [...agents.map((agent) => claudeAgentFile(folder, agent)), ...agents.map((agent) => codexAgentFile(folder, agent))];
40
40
  }
41
- /** Instructions for the AI, by their file's name in the package's instructions folder. */
42
- export function instructions(name) {
43
- return readFileSync(new URL(`../../instructions/${name}.md`, import.meta.url), "utf8").trim();
41
+ /** Instructions for the AI, by their file's name in the package's instructions folder: read once, as the version installed has them. */
42
+ export function instructionsFile(name) {
43
+ let text = instructionTexts.get(name);
44
+ if (text === undefined) {
45
+ text = readFileSync(new URL(`../../instructions/${name}.md`, import.meta.url), "utf8").trim();
46
+ instructionTexts.set(name, text);
47
+ }
48
+ return text;
44
49
  }
50
+ const instructionTexts = new Map();
45
51
  const toolsOf = {
46
52
  [quickAgent]: ["ponder"],
47
53
  [ownAgent]: ["ponder"],
@@ -51,7 +57,7 @@ const toolsOf = {
51
57
  // Codex's quick model for the lower levels: written in its agent, since Codex ignores a model the chat names when it starts one that has it (Priya).
52
58
  const codexQuickModel = "gpt-6-luna";
53
59
  function agentSpecs() {
54
- const ponder = instructions("ponder");
60
+ const ponder = instructionsFile("ponder");
55
61
  const thinking = "Chesspal's opponent thinking on the player's time. Started by the AI in the background after its move, as ai_move says.";
56
62
  return [
57
63
  { agent: quickAgent, claudeModel: "haiku", codexModel: codexQuickModel, description: thinking, text: ponder },
@@ -61,13 +67,13 @@ function agentSpecs() {
61
67
  agent: coachAgent,
62
68
  claudeModel: "inherit",
63
69
  description: "Chesspal's coach. Started only as Chesspal's answers say, in the background, never to answer the player.",
64
- text: instructions("coach"),
70
+ text: instructionsFile("coach"),
65
71
  },
66
72
  {
67
73
  agent: analystAgent,
68
74
  claudeModel: "inherit",
69
75
  description: "Chesspal's analyst. Started only as Chesspal's answers say, in the background, to review a game over on the board page; never to answer the player.",
70
- text: `${instructions("analyst-agent")}\n${instructions("analysis")}`,
76
+ text: `${instructionsFile("analyst-agent")}\n${instructionsFile("analysis")}`,
71
77
  },
72
78
  ];
73
79
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Sets up the player's folder for Claude Code and Codex (D-31): .mcp.json with the chesspal server, AGENTS.md with the opponent's instructions between
3
- * Chesspal's marks, CLAUDE.md importing it, the hosts' settings with the server approved, and their hooks. What the player wrote there stays; it returns
2
+ * Sets up the player's folder for Claude Code and Codex (D-31): .mcp.json with the chesspal server, AGENTS.md with the lines that start a chat between
3
+ * Chesspal's marks (D-102), CLAUDE.md importing it, the hosts' settings with the server approved, and their hooks. What the player wrote there stays; it returns
4
4
  * the files written.
5
5
  */
6
6
  export declare function setUpHosts(folder: string, platform: string): string[];
@@ -11,5 +11,7 @@ export declare function setUpHosts(folder: string, platform: string): string[];
11
11
  export declare function outdatedHostFiles(folder: string, platform: string): string[];
12
12
  /** Where Codex reads a folder's hooks, which it runs only once the player trusts them. */
13
13
  export declare function codexHooksPath(folder: string): string;
14
- /** The instructions of the AI opponent, read from the package wherever it is installed. */
14
+ /** The instructions of the AI opponent, read from the package wherever it is installed: the MCP server's `instructions` tool gives them (D-102). */
15
15
  export declare function opponentInstructions(): string;
16
+ /** The lines that start a chat, in the MCP server's instructions and in AGENTS.md: call `instructions`, then `status` (D-102). */
17
+ export declare function startInstructions(): string;
@@ -1,11 +1,11 @@
1
- // What `chesspal init` writes in a player's folder for each AI host: the opponent's instructions, the MCP server, the hooks and the agents.
1
+ // What `chesspal init` writes in a player's folder for each AI host: the lines that start a chat, the MCP server, the hooks and the agents.
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  import { makeFolder, writeAtomically } from "../games/index.js";
5
- import { agentFiles, instructions } from "./agents.js";
5
+ import { agentFiles, instructionsFile } from "./agents.js";
6
6
  /**
7
- * Sets up the player's folder for Claude Code and Codex (D-31): .mcp.json with the chesspal server, AGENTS.md with the opponent's instructions between
8
- * Chesspal's marks, CLAUDE.md importing it, the hosts' settings with the server approved, and their hooks. What the player wrote there stays; it returns
7
+ * Sets up the player's folder for Claude Code and Codex (D-31): .mcp.json with the chesspal server, AGENTS.md with the lines that start a chat between
8
+ * Chesspal's marks (D-102), CLAUDE.md importing it, the hosts' settings with the server approved, and their hooks. What the player wrote there stays; it returns
9
9
  * the files written.
10
10
  */
11
11
  export function setUpHosts(folder, platform) {
@@ -31,9 +31,13 @@ export function outdatedHostFiles(folder, platform) {
31
31
  export function codexHooksPath(folder) {
32
32
  return join(folder, ".codex", "hooks.json");
33
33
  }
34
- /** The instructions of the AI opponent, read from the package wherever it is installed. */
34
+ /** The instructions of the AI opponent, read from the package wherever it is installed: the MCP server's `instructions` tool gives them (D-102). */
35
35
  export function opponentInstructions() {
36
- return instructions("opponent");
36
+ return instructionsFile("opponent");
37
+ }
38
+ /** The lines that start a chat, in the MCP server's instructions and in AGENTS.md: call `instructions`, then `status` (D-102). */
39
+ export function startInstructions() {
40
+ return instructionsFile("start");
37
41
  }
38
42
  // The marks around Chesspal's lines in AGENTS.md: init rewrites what lies between them, and the player's own text stays around them.
39
43
  const startMark = "<!-- chesspal: these lines up to the end mark are Chesspal's, and chesspal init rewrites them; write your own after it. -->";
@@ -60,11 +64,14 @@ function mcpFile(folder, platform) {
60
64
  const before = isObject(servers.chesspal) ? servers.chesspal : {};
61
65
  return changed(path, { ...config, mcpServers: { ...servers, chesspal: { ...chesspalServer(platform), ...(isObject(before.env) ? { env: before.env } : {}) } } });
62
66
  }
63
- /** AGENTS.md with the opponent's instructions between Chesspal's marks: rewritten when they changed, put first in a file that has none. */
67
+ /**
68
+ * AGENTS.md with the lines that start a chat between Chesspal's marks, which Codex needs beside the server's instructions (D-31, D-102): rewritten when
69
+ * they changed, put first in a file that has none.
70
+ */
64
71
  function agentsFile(folder) {
65
72
  const path = join(folder, "AGENTS.md");
66
73
  const before = textOf(path);
67
- const block = [startMark, opponentInstructions(), endMark].join("\n");
74
+ const block = [startMark, startInstructions(), endMark].join("\n");
68
75
  if (before === undefined) {
69
76
  return { path, text: `${block}\n` };
70
77
  }
@@ -1,2 +1,2 @@
1
- export { agentHosts, agentTools, analystAgent, coachAgent, hasAgent, instructions, isCodex, onQuickModel, ponderAgentFor, ponderAgents } from "./agents.ts";
2
- export { codexHooksPath, opponentInstructions, outdatedHostFiles, setUpHosts } from "./host-files.ts";
1
+ export { agentHosts, agentTools, analystAgent, coachAgent, hasAgent, instructionsFile, isCodex, onQuickModel, ponderAgentFor, ponderAgents } from "./agents.ts";
2
+ export { codexHooksPath, opponentInstructions, outdatedHostFiles, setUpHosts, startInstructions } from "./host-files.ts";
@@ -1,3 +1,3 @@
1
1
  // The files through which an AI host finds Chesspal in the player's folder: the server, the opponent's instructions, the hosts' settings.
2
- export { agentHosts, agentTools, analystAgent, coachAgent, hasAgent, instructions, isCodex, onQuickModel, ponderAgentFor, ponderAgents } from "./agents.js";
3
- export { codexHooksPath, opponentInstructions, outdatedHostFiles, setUpHosts } from "./host-files.js";
2
+ export { agentHosts, agentTools, analystAgent, coachAgent, hasAgent, instructionsFile, isCodex, onQuickModel, ponderAgentFor, ponderAgents } from "./agents.js";
3
+ export { codexHooksPath, opponentInstructions, outdatedHostFiles, setUpHosts, startInstructions } from "./host-files.js";
@@ -9,7 +9,7 @@ import { explain } from "../board/explain.js";
9
9
  import { kingSigns } from "../board/signs.js";
10
10
  import { piecesOf } from "../board/squares.js";
11
11
  import { NewGameDialog } from "../dialogs/new-game.js";
12
- import { NameButton, PictureButton, PictureDialog, ProfilesDialog, Wizard } from "../dialogs/profiles.js";
12
+ import { NameButton, PictureButton, ProfileDialog, ProfilesDialog, Wizard } from "../dialogs/profiles.js";
13
13
  import { SettingsPanel } from "../dialogs/settings.js";
14
14
  import { playerIn, studiedSides } from "../dialogs/studied.js";
15
15
  import { StudyDialog } from "../dialogs/study.js";
@@ -20,6 +20,7 @@ import { howEnded, useDeclined, useJustEnded, useSawEnd } from "../game/ending.j
20
20
  import { Steps } from "../game/move-list.js";
21
21
  import { useOpeningBook } from "../game/opening-book.js";
22
22
  import { aiSpoke, aiWords, analysisTalk, judgmentsOf, playerSpent, playerWords, pliesOf, withBook, withJudgments, withoutReviews } from "../game/plies.js";
23
+ import { GuideDialog } from "../guide/guide.js";
23
24
  import { BareButton, HelpsCard, HintLine, MapButton } from "../helps/helps.js";
24
25
  import { helpsOf } from "../helps/view.js";
25
26
  import { FoldTab } from "../layout/fold-tab.js";
@@ -34,12 +35,14 @@ import { signature } from "../players/model-name.js";
34
35
  import { keepSettings } from "../profile/profile.js";
35
36
  import { languagesOf, saveSettings, settingsFrom, showSettings } from "../profile/settings.js";
36
37
  import { endSoundOf, playSound, soundOf, wakeSounds } from "../sounds/sounds.js";
38
+ import { ChatOpener } from "../table/opener.js";
37
39
  import { AiNote, AiWords, Compose, PointSquare, TalkFaces } from "../talk/talk.js";
38
40
  import { textFor } from "../text.js";
39
41
  import { anchorName, useAnchor } from "./anchor.js";
40
42
  import { useBrowseKeys, useFoldKeys, useLegendKey, useMapKey, useWheelSteps } from "./keys.js";
41
43
  import { phaseOf, verdictOf } from "./phase.js";
42
44
  import { post, sendMessage, sendMove, useServed } from "./served.js";
45
+ import { tabTitle, useTabTitle } from "./title.js";
43
46
  /**
44
47
  * The page: the board of the game in the player's folder, where the player moves on their turn, the talk with the AI beside it, and the game's column; in the
45
48
  * settings the player chose, `start` as this browser remembered them (D-56).
@@ -202,6 +205,9 @@ export function App({ start }) {
202
205
  // game a link opens is there to look at: the analyst starts on the player's yes alone (D-89; Zoe and Federico).
203
206
  const playedName = game === undefined || game === null ? undefined : anchorName(game.name);
204
207
  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);
208
+ // The tab names the game and the move shown, as the cards name its players; between games, Chesspal alone.
209
+ const titled = idle || served === undefined ? undefined : studiedSides(live.record.tags, served.profile.name, playedAs, text.sides, served.profile.accounts);
210
+ useTabTitle(titled === undefined ? undefined : tabTitle(titled.white.name, titled.black.name, at === 0 ? undefined : plies[at - 1]));
205
211
  // The header's theme switch picks the other of the two the page shows, leaving the system's.
206
212
  const switchTheme = () => change({ ...settings, theme: document.documentElement.dataset.theme === "dark" ? "light" : "dark" });
207
213
  // An action of the player's on the game: the game the server sends next shows it; a refusal shows on the board.
@@ -212,7 +218,9 @@ export function App({ start }) {
212
218
  const result = resultOf(live);
213
219
  const ended = isOver(result);
214
220
  const how = ended ? howEnded(live.outcome, live.record.tags.Termination) : undefined;
215
- const winner = result === "1-0" ? "white" : result === "0-1" ? "black" : undefined;
221
+ // A game closed before any move counts for no one, as Lichess aborts it: no winner, no kings' signs.
222
+ const aborted = ended && plies.length === 0;
223
+ const winner = aborted ? undefined : result === "1-0" ? "white" : result === "0-1" ? "black" : undefined;
216
224
  const justEnded = useJustEnded(played === undefined || played === null ? undefined : over ? "over" : "going");
217
225
  const offer = played?.drawOffer;
218
226
  const declined = useDeclined(offer, total, ended);
@@ -228,16 +236,18 @@ export function App({ start }) {
228
236
  }
229
237
  },
230
238
  };
231
- const { kind, verdict } = verdictOf(phase, how, winner, offer, declined, text);
239
+ const { kind, verdict } = aborted && phase === "over" ? { kind: "over", verdict: text.aborted } : verdictOf(phase, how, winner, offer, declined, text);
232
240
  const header = (_jsx(Header, { text: text, kind: kind, verdict: verdict, ...(!ended && (offer === "ai" || declined) ? { who: name } : {}), actions: actions, onNewGame: served === undefined ? undefined : () => setDialog("new-game"),
233
241
  // Between games the new one stands out in the player's card (D-77): in the header only when the talk, and the card with it, is folded away.
234
- standOut: studied !== undefined || (between && folds.talk !== "open"), onStudy: served === undefined || going ? undefined : () => setDialog("study"), flipped: flipped, onFlip: () => setFlipped(!flipped), sound: sound, onSound: () => change({ ...settings, sound: !sound }), onTheme: switchTheme, onSettings: () => setSetting(true), ...(served === undefined ? {} : { profile: served.profile, onProfile: () => setDialog("profiles") }) }));
242
+ standOut: studied !== undefined || (between && folds.talk !== "open"), onStudy: served === undefined || going ? undefined : () => setDialog("study"), flipped: flipped, onFlip: () => setFlipped(!flipped), sound: sound, onSound: () => change({ ...settings, sound: !sound }), onTheme: switchTheme, onSettings: () => setSetting(true), onGuide: () => setDialog("guide"), ...(served === undefined ? {} : { profile: served.profile, onProfile: () => setDialog("profiles") }) }));
235
243
  const panel = _jsx(SettingsPanel, { open: setting, settings: settings, onChange: change, onClose: () => setSetting(false), text: text });
244
+ // The guide opens before the page has its data too.
245
+ const guide = _jsx(GuideDialog, { open: dialog === "guide", onClose: () => setDialog(undefined), text: text });
236
246
  if (problem !== undefined) {
237
247
  return _jsx("p", { class: "problem", children: problem });
238
248
  }
239
249
  if (served === undefined || game === undefined) {
240
- return (_jsxs(_Fragment, { children: [header, panel] }));
250
+ return (_jsxs(_Fragment, { children: [header, panel, guide] }));
241
251
  }
242
252
  // At the first start the page asks who plays (D-23), to complete the profile Chesspal made (D-71): the game comes after.
243
253
  if (served.profile.new === true) {
@@ -261,7 +271,7 @@ export function App({ start }) {
261
271
  const drawn = hintOpen && hint !== undefined && viewing === undefined && (hint.arrows !== undefined || hint.squares !== undefined) ? hint : review;
262
272
  const drawnByCoach = hint !== undefined && drawn === hint;
263
273
  // The game over, when this is its last position: the kings' signs, on the board and in the legend.
264
- const boardEnd = ended && viewing === undefined && pending === undefined ? { how, winner, player, fresh: justEnded } : undefined;
274
+ const boardEnd = ended && !aborted && viewing === undefined && pending === undefined ? { how, winner, player, fresh: justEnded } : undefined;
265
275
  // The legend has the marks the board shows: none of the helps' or the drawings while the board is bare (D-99).
266
276
  const shownBare = bare && helps !== undefined;
267
277
  const legendRows = legendOf({
@@ -292,7 +302,10 @@ export function App({ start }) {
292
302
  const clockWaits = clock?.waiting === true && !ended && studied === undefined && viewing === undefined && live.position.turn === ai;
293
303
  const clockOf = (side) => clock === undefined ? undefined : (_jsx(ClockFace, { clock: clock, side: side, toMove: !ended && live.position.turn === side, held: pending !== undefined || (asleep && side === ai), onFlag: reload, text: text }));
294
304
  // Where to wake the chat, under the AI's card (over the board when the talk is folded); the button that starts the clocks, under the player's (Zoe).
295
- const asleepNote = asleep ? _jsx("p", { class: "asleep", role: "status", children: sides === undefined ? text.asleep(name) : text.asleepInAnalysis(name) }) : null;
305
+ // On the table with no chat at it, the note opens one instead (D-104).
306
+ const table = offline ? undefined : served.table;
307
+ const opener = table !== undefined && !table.chat ? _jsx(ChatOpener, { table: table, settings: settings, onChange: change, name: name, text: text }) : undefined;
308
+ const asleepNote = opener ?? (asleep ? _jsx("p", { class: "asleep", role: "status", children: sides === undefined ? text.asleep(name) : text.asleepInAnalysis(name) }) : null);
296
309
  const startClocks = clockWaits
297
310
  ? (_jsxs("button", { type: "button", class: "command primary start-clocks", onClick: () => act("/api/start", {}), children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M8 5.5v13l10.5-6.5z" }) }), text.startClocks] }))
298
311
  : null;
@@ -345,7 +358,7 @@ export function App({ start }) {
345
358
  }
346
359
  };
347
360
  // In analysis the words go with the game and the move shown (D-101).
348
- const sendWords = (shown) => {
361
+ const sendWords = (position) => {
349
362
  const words = draft;
350
363
  // /clear and the other commands are for Claude's app: written here they would reach the AI as words (Sebastiano's playtest).
351
364
  if (isCommand(words)) {
@@ -353,7 +366,7 @@ export function App({ start }) {
353
366
  return;
354
367
  }
355
368
  setUnsent(undefined);
356
- void sendMessage(words, text, name, shown).then((refused) => {
369
+ void sendMessage(words, text, name, position).then((refused) => {
357
370
  setUnsent(refused?.text);
358
371
  clearSent(refused?.text, words);
359
372
  });
@@ -369,8 +382,8 @@ export function App({ start }) {
369
382
  };
370
383
  // 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).
371
384
  const studiedCard = (side, place) => sides === undefined ? null : (_jsx(PlayerCard, { place: place,
372
- // The player changes their picture in analysis too, from their own side's card.
373
- picture: sides[side].who === "player" ? _jsx(PictureButton, { profile: profile, onOpen: () => setDialog("picture"), text: text }) : pictureOf(sides[side], profile, 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 }));
385
+ // The player opens their profile in analysis too, from their own side's card.
386
+ picture: sides[side].who === "player" ? _jsx(PictureButton, { profile: profile, onOpen: () => setDialog("profile"), text: text }) : pictureOf(sides[side], profile, 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 }));
374
387
  // A game brought to analyse: the page shows it once the server has it in analysis; a refusal stays in the dialog.
375
388
  const studyGame = async (studied) => {
376
389
  const refused = await post("/api/study", studied, text, name);
@@ -379,7 +392,7 @@ export function App({ start }) {
379
392
  }
380
393
  return refused?.text;
381
394
  };
382
- return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(PictureDialog, { open: dialog === "picture", 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, onClose: () => setDialog(undefined), text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), you: profile.name, accounts: profile.accounts, ai: name, text: text }), offline ? _jsx("p", { class: "offline", role: "alert", children: text.offline(name) }) : null, served.outdated === true ? _jsx("p", { class: "outdated", role: "status", children: text.outdated(name) }) : null, _jsxs("main", { class: `app${flipped ? " flipped" : ""}${folds.talk === "open" ? "" : ` talk-${folds.talk}`}${folds.game === "folded" ? " folded" : ""}`, inert: offline, children: [header, sides === undefined
395
+ 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 }), _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), you: profile.name, accounts: profile.accounts, ai: name, text: text }), offline ? _jsx("p", { class: "offline", role: "alert", children: text.offline(name) }) : null, served.outdated === true ? _jsx("p", { class: "outdated", role: "status", children: text.outdated(name) }) : null, _jsxs("main", { class: `app${flipped ? " flipped" : ""}${folds.talk === "open" ? "" : ` talk-${folds.talk}`}${folds.game === "folded" ? " folded" : ""}`, inert: offline, children: [header, sides === undefined
383
396
  ? (_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 }))
384
397
  : (_jsx(TalkFaces, { name: sides[ai].name, aiPicture: pictureOf(sides[ai], profile, 40, mood), you: sides[player].name, picture: pictureOf(sides[player], profile, 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
385
398
  ? (_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 } : {}), ...(summary === undefined ? { reviewing: true } : {}), name: analyst, by: annotator, mood: mood, ...(settings.voice ? { voice: text.language } : {}), 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) => {
@@ -391,13 +404,13 @@ export function App({ start }) {
391
404
  spent: going ? playerSpent(plies, ai, talk) : 0, draft: draft, onDraft: (words) => {
392
405
  setDraft(words);
393
406
  setUnsent(undefined);
394
- }, onSend: sendWords, canMove: playerToMove, name: name, problem: unsent, text: text }), _jsx(PlayerCard, { place: "you", picture: _jsx(PictureButton, { profile: profile, onOpen: () => setDialog("picture"), text: text }), 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", {
407
+ }, onSend: sendWords, canMove: playerToMove, name: name, problem: unsent, text: text }), _jsx(PlayerCard, { place: "you", picture: _jsx(PictureButton, { profile: profile, onOpen: () => setDialog("profile"), text: text }), 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", {
395
408
  // 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.
396
409
  class: between && !justEnded ? "board-wrap off" : "board-wrap", onWheel: wheel,
397
410
  // A press on the board while the player looks back brings the game as it is: the next one plays.
398
- 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 ? refusal : undefined, 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" && (asleep || clockWaits)
399
- ? (_jsxs("div", { class: "over-board", children: [asleep ? _jsx("p", { class: "asleep", role: "status", children: text.asleep(name) }) : null, startClocks] }))
400
- : null] }), studied === undefined ? null : (_jsx("div", { class: "board-steps", children: _jsx(Steps, { shown: at, total: total, onStep: step, 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, 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 ? {} : { analysis: summary === undefined ? { by: analyst } : { by: analyst, summary } }), 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 ? { onAnalyse: () => void studyGame({ last: true }) } : {}), text: text })] })] }) }));
411
+ 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 ? refusal : undefined, 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" && (asleep || opener !== undefined || clockWaits)
412
+ ? (_jsxs("div", { class: "over-board", children: [opener ?? (asleep ? _jsx("p", { class: "asleep", role: "status", children: text.asleep(name) }) : null), startClocks] }))
413
+ : null] }), studied === undefined ? null : (_jsx("div", { class: "board-steps", children: _jsx(Steps, { shown: at, total: total, onStep: step, 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, 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 ? {} : { analysis: summary === undefined ? { by: analyst } : { by: analyst, summary } }), 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 })] })] }) }));
401
414
  }
402
415
  /** The picture of a player of the game in analysis: the player's own, the AI's Torretta with its mood, or the plain face of someone else. */
403
416
  function pictureOf({ who }, profile, size, mood) {
@@ -0,0 +1,17 @@
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
+ import { useEffect } from "preact/hooks";
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) {
6
+ const players = `${firstName(white)} – ${firstName(black)}`;
7
+ return shown === undefined ? players : `${players}, ${shown.number}${shown.side === "white" ? "." : "..."} ${figurinesOf(shown.san)}`;
8
+ }
9
+ /** Keeps the tab's title, Chesspal's name while no game shows. */
10
+ export function useTabTitle(title) {
11
+ useEffect(() => {
12
+ document.title = title ?? "Chesspal";
13
+ }, [title]);
14
+ }
15
+ function firstName(name) {
16
+ return name.trim().split(/\s+/)[0] || name;
17
+ }
@@ -18,8 +18,8 @@ function useChanged(mood) {
18
18
  function Face({ mood }) {
19
19
  switch (mood) {
20
20
  case "happy":
21
- // Eyes closed in two arcs, a wide open smile.
22
- return (_jsxs(_Fragment, { children: [_jsx("path", { class: "eyes line", d: "M50.5 78.5a4.5 4.5 0 0 1 9 0M68.5 78.5a4.5 4.5 0 0 1 9 0" }), _jsx("path", { class: "mouth", d: "M57 84h14a7 7 0 0 1-14 0Z" })] }));
21
+ // Eyes closed in two low arcs, a wide open smile with its corners just rounded.
22
+ return (_jsxs(_Fragment, { children: [_jsx("path", { class: "eyes line", d: "M50 77.5q5-5.4 10 0M68 77.5q5-5.4 10 0" }), _jsx("path", { class: "mouth", d: "M56.6 83.2H71.4Q73 83.2 72.9 84.6A9 7.8 0 0 1 55.1 84.6Q55 83.2 56.6 83.2Z" })] }));
23
23
  case "surprised":
24
24
  // Wide eyes and a round mouth.
25
25
  return (_jsxs(_Fragment, { children: [_jsxs("g", { class: "eyes", children: [_jsx("circle", { cx: "54.5", cy: "75.5", r: "5.8" }), _jsx("circle", { cx: "73.5", cy: "75.5", r: "5.8" })] }), _jsx("ellipse", { class: "mouth", cx: "64", cy: "88.5", rx: "3.4", ry: "4" })] }));
@@ -27,8 +27,8 @@ function Face({ mood }) {
27
27
  // Brows raised in the middle, smaller eyes, a frown; the light turns red.
28
28
  return (_jsxs(_Fragment, { children: [_jsx("path", { class: "brows", d: "M50 70L58 67M70 67L78 70" }), _jsxs("g", { class: "eyes", children: [_jsx("circle", { cx: "55", cy: "78.5", r: "3.8" }), _jsx("circle", { cx: "73", cy: "78.5", r: "3.8" })] }), _jsx("path", { class: "mouth line", d: "M56.5 91q7.5-6 15 0" })] }));
29
29
  case "thoughtful":
30
- // A glance to the side under one raised brow, the mouth pushed to the other side; the light breathes.
31
- return (_jsxs(_Fragment, { children: [_jsx("path", { class: "brows", d: "M71 68L80 66" }), _jsxs("g", { class: "eyes", children: [_jsx("circle", { cx: "58", cy: "76.5", r: "3.9" }), _jsx("circle", { cx: "76", cy: "76.5", r: "3.9" })] }), _jsx("path", { class: "mouth line", d: "M52.5 89L63 87" })] }));
30
+ // The neutral face tilted on the screen, a smaller mouth: the head bent of one who wonders; the light breathes.
31
+ return (_jsxs("g", { transform: "rotate(-12 64 79)", children: [_jsxs("g", { class: "eyes", children: [_jsx("circle", { cx: "55", cy: "74.5", r: "4.4" }), _jsx("circle", { cx: "73", cy: "74.5", r: "4.4" })] }), _jsx("rect", { class: "mouth", x: "60", y: "86.3", width: "8", height: "3.4", rx: "1.7" })] }));
32
32
  case "sad":
33
33
  // No brows, the face sunk low: eyes half shut under lids that droop outwards, a deep frown; the light goes out.
34
34
  return (_jsxs(_Fragment, { children: [_jsx("path", { class: "eyes", d: "M60.3 77.1A5.6 5.6 0 0 1 49.7 80.9ZM78.3 80.9A5.6 5.6 0 0 1 67.7 77.1Z" }), _jsx("path", { class: "mouth line", d: "M57.5 92.5q6.5-6.5 13 0" })] }));
@@ -45,7 +45,7 @@ export function Analyses({ onStudy, onLoad, you, accounts, ai, text }) {
45
45
  return (_jsxs("div", { class: "analyses", children: [_jsxs("label", { class: "search", children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M10.5 17A6.5 6.5 0 1 0 10.5 4A6.5 6.5 0 0 0 10.5 17ZM15.5 15.5L20 20" }) }), _jsx("input", { type: "search", value: query, placeholder: words.search, "aria-label": words.search, spellcheck: false, onInput: (event) => setQuery(event.currentTarget.value) })] }), _jsxs("button", { class: "load-row", type: "button", onClick: onLoad, children: [_jsx("span", { class: "plus", "aria-hidden": "true", children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", children: _jsx("path", { d: "M12 5V19M5 12H19" }) }) }), _jsxs("span", { children: [_jsx("b", { children: words.bring }), _jsx("small", { children: words.bringNote })] })] }), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem }), analyses.length > 0 && found.length === 0 ? _jsx("p", { class: "none", children: words.noMatch }) : null, found.length === 0 ? null : (_jsx("ul", { class: "games", "aria-label": words.yours, children: found.map(({ item, named, outcome }) => asking?.name === item.name
46
46
  ? (_jsx(Question, { asked: asking.asked, game: named.sides, when: dayOf(item.date, text.language), by: by(item), busy: busy, onYes: () => void act(item.name, asking.asked), onNo: () => setAsking(undefined), words: words }, item.name))
47
47
  : (_jsxs("li", { class: "game", children: [_jsxs("button", { class: "reopen", type: "button", disabled: busy, onClick: () => open(item.name), children: [_jsx(ResultTile, { result: item.result }), _jsxs("span", { class: "what", children: [_jsx("b", { class: "who", children: named.sides }), _jsx("span", { class: "meta", children: [dayOf(item.date, text.language), words.moves(item.moves), item.opening].filter((part) => part !== undefined).join(" · ") }), item.reviewed ? _jsx(Reviewed, { by: by(item), words: words }) : null] }), _jsxs("span", { class: "score", children: [_jsx("b", { children: scoreOf(item.result) }), outcome === undefined ? _jsx("span", {}) : _jsx(OutcomeBadge, { outcome: outcome, words: words })] })] }), _jsx("span", { class: "row-actions", children: _jsx("button", { class: `icon-button${menu?.name === item.name ? " on" : ""}`, type: "button", title: words.more, "aria-label": words.more, "aria-expanded": menu?.name === item.name, disabled: busy, onClick: (event) => setMenu(menu?.name === item.name ? undefined : { name: item.name, anchor: event.currentTarget }), children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M5 12H5.01M12 12H12.01M19 12H19.01" }) }) }) }), menu?.name === item.name
48
- ? (_jsx(RowMenu, { anchor: menu.anchor, reviewed: item.reviewed, onPick: (asked) => {
48
+ ? (_jsx(RowMenu, { anchor: menu.anchor, withReview: item.reviewed || item.begun === true, onPick: (asked) => {
49
49
  setMenu(undefined);
50
50
  setAsking({ name: item.name, asked });
51
51
  }, onClose: () => setMenu(undefined), words: words }))
@@ -53,9 +53,9 @@ export function Analyses({ onStudy, onLoad, you, accounts, ai, text }) {
53
53
  }
54
54
  /**
55
55
  * A row's menu, over the dialog and the page in the browser's top layer, under its button or above it when there is no room: analyse again and delete the
56
- * analysis, for a game reviewed; delete the game, apart and in red. Scrolling closes it, which would leave its button.
56
+ * analysis, for a game reviewed or whose review was begun; delete the game, apart and in red. Scrolling closes it, which would leave its button.
57
57
  */
58
- function RowMenu({ anchor, reviewed, onPick, onClose, words }) {
58
+ function RowMenu({ anchor, withReview, onPick, onClose, words }) {
59
59
  const menu = useRef(null);
60
60
  useLayoutEffect(() => {
61
61
  const element = menu.current;
@@ -69,7 +69,7 @@ function RowMenu({ anchor, reviewed, onPick, onClose, words }) {
69
69
  window.removeEventListener("resize", onClose);
70
70
  };
71
71
  }, []);
72
- return (_jsxs("div", { ref: menu, class: "row-menu", popover: "manual", children: [reviewed
72
+ return (_jsxs("div", { ref: menu, class: "row-menu", popover: "manual", children: [withReview
73
73
  ? (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", onClick: () => onPick("redo"), children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M19 12A7 7 0 1 1 16.5 6.6M17 3V7H13" }) }), _jsxs("span", { children: [words.redo, _jsx("small", { children: words.redoNote })] })] }), _jsxs("button", { type: "button", onClick: () => onPick("unreview"), children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M5 19H19M8 15L4.5 11.5L12 4L18 10L11 17H8ZM8.5 7.5L14.5 13.5" }) }), _jsxs("span", { children: [words.unreview, _jsx("small", { children: words.unreviewNote })] })] }), _jsx("hr", {})] }))
74
74
  : null, _jsxs("button", { class: "danger", type: "button", onClick: () => onPick("remove"), children: [_jsx(TrashIcon, {}), _jsx("span", { children: words.remove })] })] }));
75
75
  }
@@ -3,6 +3,9 @@ import { useEffect, useRef } from "preact/hooks";
3
3
  /** A dialog over the page, as the settings' one: Esc and a click outside close it; a step inside it goes back with the arrow beside its title. */
4
4
  export function Dialog({ open, title, onBack, onClose, text, children }) {
5
5
  const dialog = useRef(null);
6
+ // Open as the page wants it: a dialog the page closed itself, to open another, does not close that one when the browser says it closed.
7
+ const wanted = useRef(open);
8
+ wanted.current = open;
6
9
  useEffect(() => {
7
10
  const element = dialog.current;
8
11
  if (open && !element.open) {
@@ -22,5 +25,5 @@ export function Dialog({ open, title, onBack, onClose, text, children }) {
22
25
  onClose();
23
26
  }
24
27
  };
25
- return (_jsxs("dialog", { ref: dialog, class: "dialog", "aria-label": title, onClose: onClose, onClick: closeOutside, children: [_jsxs("header", { children: [onBack === undefined ? null : (_jsx("button", { class: "icon-button", type: "button", title: text.profile.back, "aria-label": text.profile.back, onClick: onBack, children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M15 6L9 12L15 18" }) }) })), _jsx("h2", { children: title }), _jsx("button", { class: "icon-button", type: "button", title: text.close, "aria-label": text.close, onClick: onClose, children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 6L18 18M18 6L6 18" }) }) })] }), open ? children : null] }));
28
+ return (_jsxs("dialog", { ref: dialog, class: "dialog", "aria-label": title, onClose: () => wanted.current && onClose(), onClick: closeOutside, children: [_jsxs("header", { children: [onBack === undefined ? null : (_jsx("button", { class: "icon-button", type: "button", title: text.profile.back, "aria-label": text.profile.back, onClick: onBack, children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M15 6L9 12L15 18" }) }) })), _jsx("h2", { children: title }), _jsx("button", { class: "icon-button", type: "button", title: text.close, "aria-label": text.close, onClick: onClose, children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 6L18 18M18 6L6 18" }) }) })] }), open ? children : null] }));
26
29
  }