chesspal 0.3.6 → 0.4.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 (136) hide show
  1. package/dist/cli/cli.js +7 -2
  2. package/dist/cli/command-line.js +5 -3
  3. package/dist/cli/prompt-hook.d.ts +17 -0
  4. package/dist/cli/prompt-hook.js +100 -0
  5. package/dist/commands/analysis/analyse.js +8 -14
  6. package/dist/commands/analysis/annotate.d.ts +8 -0
  7. package/dist/commands/analysis/annotate.js +19 -3
  8. package/dist/commands/answers/agents.d.ts +7 -5
  9. package/dist/commands/answers/agents.js +16 -9
  10. package/dist/commands/answers/clock.js +2 -1
  11. package/dist/commands/answers/drawings.js +6 -2
  12. package/dist/commands/answers/index.d.ts +4 -3
  13. package/dist/commands/answers/index.js +4 -3
  14. package/dist/commands/answers/lines.d.ts +5 -3
  15. package/dist/commands/answers/lines.js +14 -7
  16. package/dist/commands/answers/opening-book.d.ts +13 -7
  17. package/dist/commands/answers/opening-book.js +34 -38
  18. package/dist/commands/answers/opening-tags.d.ts +4 -0
  19. package/dist/commands/answers/opening-tags.js +11 -0
  20. package/dist/commands/answers/play-move.d.ts +3 -1
  21. package/dist/commands/answers/play-move.js +9 -6
  22. package/dist/commands/answers/say.js +2 -1
  23. package/dist/commands/answers/summary.d.ts +5 -2
  24. package/dist/commands/answers/summary.js +16 -5
  25. package/dist/commands/game/ai-move.js +4 -4
  26. package/dist/commands/game/coach.js +7 -6
  27. package/dist/commands/game/end.js +2 -2
  28. package/dist/commands/game/export.d.ts +1 -1
  29. package/dist/commands/game/index.d.ts +1 -1
  30. package/dist/commands/game/index.js +1 -1
  31. package/dist/commands/game/new.js +41 -11
  32. package/dist/commands/game/player-move.d.ts +16 -0
  33. package/dist/commands/game/player-move.js +69 -19
  34. package/dist/commands/game/ponder.js +4 -1
  35. package/dist/commands/game/status.d.ts +1 -1
  36. package/dist/commands/game/status.js +6 -6
  37. package/dist/commands/index.d.ts +3 -3
  38. package/dist/commands/index.js +3 -3
  39. package/dist/commands/profiles/past-games.d.ts +1 -1
  40. package/dist/commands/profiles/past-games.js +21 -10
  41. package/dist/commands/registry/context.d.ts +2 -0
  42. package/dist/commands/registry/context.js +5 -0
  43. package/dist/commands/registry/index.d.ts +1 -1
  44. package/dist/commands/registry/index.js +1 -1
  45. package/dist/commands/talk/ai-say.d.ts +1 -1
  46. package/dist/commands/talk/ai-say.js +6 -5
  47. package/dist/core/game.d.ts +13 -0
  48. package/dist/core/game.js +29 -3
  49. package/dist/core/index.d.ts +4 -4
  50. package/dist/core/index.js +3 -3
  51. package/dist/core/openings.d.ts +20 -6
  52. package/dist/core/openings.js +45 -22
  53. package/dist/core/pgn.d.ts +4 -0
  54. package/dist/core/pgn.js +35 -6
  55. package/dist/games/archive.d.ts +25 -5
  56. package/dist/games/archive.js +52 -19
  57. package/dist/games/chat-start.d.ts +2 -2
  58. package/dist/games/chat-start.js +6 -1
  59. package/dist/games/current-game.d.ts +2 -2
  60. package/dist/games/current-game.js +3 -2
  61. package/dist/games/index.d.ts +2 -1
  62. package/dist/games/index.js +2 -1
  63. package/dist/games/position-shown.d.ts +24 -0
  64. package/dist/games/position-shown.js +51 -0
  65. package/dist/home/index.d.ts +1 -1
  66. package/dist/home/index.js +1 -1
  67. package/dist/home/same-path.d.ts +6 -1
  68. package/dist/home/same-path.js +22 -3
  69. package/dist/host-files/host-files.d.ts +5 -0
  70. package/dist/host-files/host-files.js +13 -4
  71. package/dist/host-files/index.d.ts +1 -1
  72. package/dist/host-files/index.js +1 -1
  73. package/dist/page/app/app.js +30 -7
  74. package/dist/page/app/served.js +9 -4
  75. package/dist/page/avatars/torretta.css +4 -4
  76. package/dist/page/base.css +1 -1
  77. package/dist/page/board/board.css +4 -2
  78. package/dist/page/dialogs/dialogs.css +3 -1
  79. package/dist/page/game/column.js +10 -8
  80. package/dist/page/game/game.css +86 -6
  81. package/dist/page/game/plies.js +4 -0
  82. package/dist/page/game/review-progress.js +11 -0
  83. package/dist/page/guide/drawings/drawings.css +0 -1
  84. package/dist/page/guide/en.md +2 -2
  85. package/dist/page/guide/it.md +2 -2
  86. package/dist/page/layout/icon.ico +0 -0
  87. package/dist/page/layout/layout.css +32 -15
  88. package/dist/page/layout/window.js +41 -2
  89. package/dist/page/legend/legend.css +4 -16
  90. package/dist/page/legend/legend.js +28 -15
  91. package/dist/page/profile/settings.js +1 -0
  92. package/dist/page/table/action.js +42 -0
  93. package/dist/page/table/table.css +57 -39
  94. package/dist/page/table/window.js +67 -67
  95. package/dist/page/talk/talk.css +52 -9
  96. package/dist/page/talk/talk.js +65 -20
  97. package/dist/page/talk/voice.js +92 -21
  98. package/dist/page/text.js +24 -8
  99. package/dist/server/bell/bell.d.ts +24 -27
  100. package/dist/server/bell/bell.js +79 -143
  101. package/dist/server/bell/index.d.ts +1 -1
  102. package/dist/server/bell/index.js +2 -2
  103. package/dist/server/mcp/mcp.js +49 -7
  104. package/dist/server/page/analyses.js +15 -4
  105. package/dist/server/page/files.js +3 -1
  106. package/dist/server/page/game.d.ts +5 -0
  107. package/dist/server/page/game.js +78 -13
  108. package/dist/server/page/http.d.ts +1 -1
  109. package/dist/server/page/page.d.ts +14 -9
  110. package/dist/server/page/page.js +111 -26
  111. package/dist/server/page/table.d.ts +38 -4
  112. package/dist/server/page/table.js +61 -23
  113. package/dist/server/page/windows.d.ts +22 -0
  114. package/dist/server/page/windows.js +45 -0
  115. package/dist/server/ports/index.d.ts +1 -0
  116. package/dist/server/ports/index.js +2 -0
  117. package/dist/server/ports/ports.d.ts +78 -0
  118. package/dist/server/ports/ports.js +222 -0
  119. package/dist/server/table/browser.d.ts +22 -6
  120. package/dist/server/table/browser.js +98 -20
  121. package/dist/server/table/hosts/hooks.d.ts +6 -0
  122. package/dist/server/table/hosts/hooks.js +38 -0
  123. package/dist/server/table/hosts/hosts.d.ts +13 -5
  124. package/dist/server/table/hosts/hosts.js +16 -10
  125. package/dist/server/table/hosts/index.d.ts +1 -0
  126. package/dist/server/table/hosts/index.js +1 -0
  127. package/dist/server/table/hosts/play.d.ts +6 -4
  128. package/dist/server/table/hosts/play.js +58 -16
  129. package/dist/server/table/index.d.ts +1 -1
  130. package/dist/server/table/launch.d.ts +5 -4
  131. package/dist/server/table/launch.js +18 -5
  132. package/dist/server/table/link.js +1 -0
  133. package/dist/server/table/table.d.ts +7 -0
  134. package/dist/server/table/table.js +39 -11
  135. package/instructions/opponent.md +1 -1
  136. package/package.json +1 -1
@@ -1,52 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
2
  import { useEffect, useState } from "preact/hooks";
3
3
  import { Dialog } from "../dialogs/dialog.js";
4
4
  import { checkIcon } from "../dialogs/profiles.js";
5
+ import { canAct, chatAction, choiceKeys, chosen, firstHost, offeredHosts, startedWith } from "./action.js";
5
6
  /** The chat's window: closed once a chat opens or starts again, the page then saying where it stands; `review` when it opened to have a game analysed. */
6
7
  export function ChatDialog({ open, review = false, table, settings, onChange, name, onClose, text }) {
7
- const [sending, setSending] = useState(false);
8
- const [failed, setFailed] = useState(false);
9
- useEffect(() => setFailed(false), [open]);
10
- const words = text.table;
11
- const installed = table.hosts.filter((host) => host.installed && host.name in choiceKeys);
12
- // A chat opened by hand is the player's: Chesspal cannot start it again. Its rows stay readable, as the ones waiting.
13
- const byHand = table.chat && !table.held;
14
- const busy = sending || table.opening !== undefined || table.waiting === true;
15
- const choose = async (host) => {
16
- setSending(true);
17
- setFailed(false);
18
- const done = table.chat ? await restartChat(host) : await openChat(host);
19
- setSending(false);
20
- if (done) {
21
- onClose();
22
- }
23
- else {
24
- setFailed(true);
25
- }
26
- };
27
- // While a chat opens or waits to start again, the rows wait too, and the note says why.
28
- const chooseNote = review ? words.reviewChoose : words.choose;
29
- const note = installed.length === 0
30
- ? words.install(name)
31
- : busy
32
- ? chatState(table, name, text) ?? chooseNote
33
- : byHand
34
- ? words.byHand(name)
35
- : table.chat
36
- ? words.restartHint
37
- : chooseNote;
38
- const offersChoices = installed.some((host) => host.models.length > 0 || host.efforts.length > 0);
39
- // The chat at the table started with other choices than the ones kept now: its row starts it again with these, else it only closes the window.
40
- const started = table.held ? table.started : undefined;
41
- const changed = (host) => started !== undefined && (chosen(host, "model", settings) !== (started.model ?? "") || chosen(host, "effort", settings) !== (started.effort ?? ""));
42
- return (_jsxs(Dialog, { open: open, title: review ? words.reviewTitle : words.title, onClose: onClose, text: text, children: [_jsx("p", { class: "note", children: note }), _jsxs("div", { class: "chat-hosts", role: "group", "aria-label": review ? words.reviewTitle : words.title, children: [offersChoices
43
- ? (_jsxs("div", { class: "chat-head", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", { children: words.model }), _jsx("span", { children: words.effort })] }))
44
- : null, installed.map((host) => {
45
- const atTable = table.chat && table.host === host.name;
46
- const off = busy || byHand;
47
- const again = !atTable || changed(host);
48
- return (_jsxs("div", { class: "chat-host", children: [_jsxs("button", { class: "choice", type: "button", title: atTable && again ? words.restartChanged : undefined, "aria-current": atTable ? "true" : undefined, "aria-disabled": off, onClick: off ? undefined : again ? () => void choose(host.name) : onClose, children: [_jsx("span", { children: hostTitles[host.name] ?? host.name }), atTable ? (again ? restartIcon : checkIcon) : null] }), _jsx(Choice, { host: host, kind: "model", settings: settings, onChange: onChange, text: text }), _jsx(Choice, { host: host, kind: "effort", settings: settings, onChange: onChange, text: text })] }, host.name));
49
- })] }), _jsx("p", { class: "chat-state", role: "status", children: failed ? (table.chat ? words.notRestarted : words.failed) : null }), table.chat || installed.length === 0 ? null : (_jsxs("p", { class: "chat-command", children: [words.typeIt, " ", installed.map((host, index) => (_jsxs("span", { children: [index === 0 ? "" : " · ", _jsx("code", { children: host.command })] }, host.name)))] }))] }));
8
+ return (_jsx(Dialog, { open: open, title: review ? text.table.reviewTitle : text.table.title, onClose: onClose, text: text, children: _jsx(ChatForm, { review: review, table: table, settings: settings, onChange: onChange, name: name, onClose: onClose, text: text }) }));
50
9
  }
51
10
  /** The AI's name on its card, which opens the chat's window: with no chat at the table, a hollow ring before it says so. */
52
11
  export function AiNameButton({ name, table, onOpen, text }) {
@@ -54,14 +13,17 @@ export function AiNameButton({ name, table, onOpen, text }) {
54
13
  return (_jsxs("button", { class: "profile-name ai-name", type: "button", title: title, "aria-label": `${name}: ${title}`, "aria-haspopup": "dialog", onClick: onOpen, children: [table.chat ? null : _jsx("span", { class: "no-chat", "aria-hidden": "true" }), _jsx("span", { children: name }), _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M7 10L12 15L17 10" }) })] }));
55
14
  }
56
15
  /**
57
- * The note on the chat at the table where the asleep note stands: one opening or starting again; one waiting to, with a ↻ that starts it at once and a ×
58
- * that lets the request go; in a game with no chat at the table, a line that opens the chat's window. None otherwise.
16
+ * The note on the chat at the table where the asleep note stands: one opening or starting again; one waiting to, or a new game waiting for it, with a ↻
17
+ * that starts it at once and a × that lets the request go; in a game with no chat at the table, a line that opens the chat's window. None otherwise.
59
18
  */
60
19
  export function tableNote({ table, name, inGame, onOpen, text }) {
61
20
  const words = text.table;
62
21
  const state = chatState(table, name, text);
63
- if (table.waiting === true) {
64
- return (_jsxs("p", { class: "asleep table-note", role: "status", children: [_jsx("span", { children: state }), _jsx("button", { class: "icon-button", type: "button", title: words.restartNow, "aria-label": words.restartNow, onClick: () => void endWaiting(true), children: restartIcon }), _jsx("button", { class: "icon-button", type: "button", title: words.cancel, "aria-label": words.cancel, onClick: () => void endWaiting(false), children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 6L18 18M18 6L6 18" }) }) })] }));
22
+ if (table.waiting !== undefined) {
23
+ const newGame = table.waiting === "new-game";
24
+ const now = newGame ? words.newGameNow : words.restartNow;
25
+ const cancel = newGame ? words.newGameCancel : words.cancel;
26
+ return (_jsxs("p", { class: "asleep table-note", role: "status", children: [_jsx("span", { children: state }), _jsx("button", { class: "icon-button", type: "button", title: now, "aria-label": now, onClick: () => void endWaiting(true), children: restartIcon }), _jsx("button", { class: "icon-button", type: "button", title: cancel, "aria-label": cancel, onClick: () => void endWaiting(false), children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M6 6L18 18M18 6L6 18" }) }) })] }));
65
27
  }
66
28
  if (state !== undefined) {
67
29
  return _jsx("p", { class: "asleep", role: "status", children: state });
@@ -71,38 +33,76 @@ export function tableNote({ table, name, inGame, onOpen, text }) {
71
33
  }
72
34
  return null;
73
35
  }
74
- /** What the page says of the chat at the table: one opening, starting again, or waiting to; nothing else. */
36
+ /** What the page says of the chat at the table: one opening, starting again, or waiting to, for a new game too; nothing else. */
75
37
  export function chatState(table, name, text) {
76
38
  const words = text.table;
77
- if (table.waiting === true) {
78
- return words.waiting(name);
39
+ if (table.waiting !== undefined) {
40
+ return table.waiting === "new-game" ? words.newGameWaiting(name) : words.waiting(name);
79
41
  }
80
42
  return table.opening === "chat" ? words.opening : table.opening === "again" ? words.again(name) : table.opening === "restart" ? words.restart : undefined;
81
43
  }
82
44
  const hostTitles = { claude: "Claude Code", codex: "Codex" };
83
- // The settings that keep each host's choice.
84
- const choiceKeys = {
85
- claude: { model: "claudeModel", effort: "claudeEffort" },
86
- codex: { model: "codexModel", effort: "codexEffort" },
87
- };
88
45
  /** The ↻ of a chat that starts again. */
89
46
  const restartIcon = (_jsx("svg", { class: "icon in-use", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4" }) }));
90
- /** A host's model or effort as the settings keep it: "" for the host's own, and for a choice it no longer offers. */
91
- function chosen(host, kind, settings) {
92
- const key = choiceKeys[host.name][kind];
93
- const offered = kind === "model" ? host.models : host.efforts;
94
- return offered.includes(settings[key]) ? settings[key] : "";
47
+ /** The window's form, made again at each opening with the host at the table selected, else the last the button used, else the first. */
48
+ function ChatForm({ review, table, settings, onChange, name, onClose, text }) {
49
+ const words = text.table;
50
+ const installed = offeredHosts(table);
51
+ const [selected, setSelected] = useState(() => firstHost(table, settings));
52
+ const [sending, setSending] = useState(false);
53
+ const [failed, setFailed] = useState(false);
54
+ // The host at the table may come or go while the window is open: the selection follows it only if it was on none offered.
55
+ const host = installed.find((one) => one.name === selected) ?? installed.find((one) => one.name === firstHost(table, settings));
56
+ useEffect(() => setFailed(false), [selected]);
57
+ const action = chatAction(table, host, review);
58
+ // A chat opened by hand is the player's: Chesspal cannot start it again, so the button keeps its word, disabled, and the note says why.
59
+ const byHand = table.chat && !table.held;
60
+ const busy = table.opening !== undefined || table.waiting !== undefined;
61
+ const chooseNote = review ? words.reviewChoose : words.choose;
62
+ const note = installed.length === 0 ? words.install(name) : busy ? chatState(table, name, text) ?? chooseNote : byHand ? words.byHand(name) : table.chat ? words.restartHint : chooseNote;
63
+ // What the chat held started with, marked among the choices of its host.
64
+ const started = (kind) => (host !== undefined && table.held && table.host === host.name ? startedWith(table, host, kind) : undefined);
65
+ const submit = async (event) => {
66
+ event.preventDefault();
67
+ if (action === undefined || host === undefined || !canAct(table)) {
68
+ return;
69
+ }
70
+ setSending(true);
71
+ setFailed(false);
72
+ const done = table.chat ? await restartChat(host.name) : await openChat(host.name);
73
+ setSending(false);
74
+ if (done) {
75
+ onChange({ ...settings, chatHost: host.name });
76
+ onClose();
77
+ }
78
+ else {
79
+ setFailed(true);
80
+ }
81
+ };
82
+ return (_jsxs("form", { class: "profile-form chat-window", onSubmit: (event) => void submit(event), onKeyDown: submitOnEnter, children: [_jsx("p", { class: "note", children: note }), installed.length === 0 ? null : (_jsxs("fieldset", { children: [_jsx("legend", { children: words.chat }), _jsx("div", { class: "chat-hosts", children: installed.map((one) => {
83
+ const atTable = table.chat && table.host === one.name;
84
+ return (_jsxs("label", { class: "level", children: [_jsx("input", { type: "radio", name: "chat-host", checked: one.name === host?.name, onChange: () => setSelected(one.name), autofocus: one.name === host?.name }), _jsxs("span", { children: [_jsx("b", { children: hostTitles[one.name] ?? one.name }), atTable ? _jsx("span", { class: "at-table", title: words.atTable, role: "img", "aria-label": words.atTable, children: checkIcon }) : null] })] }, one.name));
85
+ }) })] })), host === undefined ? null : (_jsxs(_Fragment, { children: [_jsx(Choices, { host: host, kind: "model", settings: settings, started: started("model"), onChange: onChange, text: text }), _jsx(Choices, { host: host, kind: "effort", settings: settings, started: started("effort"), onChange: onChange, text: text })] })), _jsx("p", { class: "problem chat-state", role: "status", children: failed ? (table.chat ? words.notRestarted : words.failed) : null }), action === undefined || host === undefined ? null : (_jsx("button", { class: "start", type: "submit", disabled: sending || !canAct(table), "aria-busy": sending, children: text.table.act[action](hostTitles[host.name] ?? host.name) })), table.chat || host === undefined ? null : (_jsxs("p", { class: "chat-command", children: [words.typeIt, " ", _jsx("code", { children: host.command })] }))] }));
86
+ }
87
+ /** Enter on a choice presses the button, the window's default action, as it would in a text field: the browser does not for radios. */
88
+ function submitOnEnter(event) {
89
+ if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
90
+ event.preventDefault();
91
+ event.currentTarget.requestSubmit();
92
+ }
95
93
  }
96
- /** A host's model or effort: a dash keeps the host's own; a choice it no longer offers shows as the dash. Its title says what it stands at, the dash too. */
97
- function Choice({ host, kind, settings, onChange, text }) {
94
+ /** A host's model or effort as a row of choices, its middle one chosen until the player chooses; the one the chat at the table `started` with bears a dot. */
95
+ function Choices({ host, kind, settings, started, onChange, text }) {
98
96
  const key = choiceKeys[host.name][kind];
99
97
  const offered = kind === "model" ? host.models : host.efforts;
100
98
  const value = chosen(host, kind, settings);
101
- const title = hostTitles[host.name] ?? host.name;
102
99
  if (offered.length === 0) {
103
- return _jsx("span", {});
100
+ return null;
104
101
  }
105
- return (_jsxs("select", { "aria-label": `${text.table[kind]}, ${title}`, title: `${text.table[kind]}: ${value === "" ? text.table.asHost(title) : value}`, value: value, onChange: (event) => onChange({ ...settings, [key]: event.currentTarget.value }), children: [_jsx("option", { value: "", children: "\u2014" }), offered.map((one) => _jsx("option", { value: one, children: shortName(one) }, one))] }));
102
+ return (_jsxs("fieldset", { children: [_jsx("legend", { children: text.table[kind] }), _jsx("div", { class: "chat-choices", children: offered.map((one) => {
103
+ const words = one + (one === started ? ` · ${text.table.atTable}` : "");
104
+ return (_jsxs("label", { class: one === started ? "level started" : "level", title: words, children: [_jsx("input", { type: "radio", name: `chat-${kind}`, checked: value === one, onChange: () => onChange({ ...settings, [key]: one }), "aria-label": words }), _jsx("span", { children: shortName(one) })] }, one));
105
+ }) })] }));
106
106
  }
107
107
  // Codex's models without the "gpt-" they all start with, so the choice stays narrow; the title keeps the whole name.
108
108
  function shortName(value) {
@@ -112,7 +112,7 @@ function shortName(value) {
112
112
  function openChat(host) {
113
113
  return asked("/api/table/chat", { host }, (answer) => answer.opened === true);
114
114
  }
115
- /** Asks the table to start at once the chat that waits to start again, cutting its answer, or to let the request go. */
115
+ /** Asks the table to start at once the chat or the new game that waits, cutting the chat's answer, or to let the request go. */
116
116
  function endWaiting(now) {
117
117
  return asked("/api/table/waiting", { now }, () => true);
118
118
  }
@@ -294,6 +294,14 @@
294
294
  flex: none;
295
295
  }
296
296
 
297
+ /* The newest, longer than its room, scrolls under the wheel or the finger with no scrollbar, fading where it is cut */
298
+ .says > li.scrolls,
299
+ .sent > li.scrolls {
300
+ overflow-y: auto;
301
+ scrollbar-width: none;
302
+ mask-image: linear-gradient(#000 calc(100% - 20px), transparent);
303
+ }
304
+
297
305
  .say {
298
306
  position: relative;
299
307
  background: var(--say);
@@ -306,6 +314,42 @@
306
314
  overflow-wrap: anywhere;
307
315
  }
308
316
 
317
+ /* The mark that skips the bubble read aloud, in its corner; the corner is kept while the voice is on, so the words never move when it comes and goes (Zoe) */
318
+ .skip-reading {
319
+ float: right;
320
+ width: 22px;
321
+ height: 20px;
322
+ margin: 0 -7px 0 6px;
323
+ padding: 0;
324
+ border: 0;
325
+ border-radius: 6px;
326
+ background: none;
327
+ color: var(--accent);
328
+ cursor: pointer;
329
+ }
330
+
331
+ .skip-reading .icon {
332
+ display: block;
333
+ width: 16px;
334
+ height: 16px;
335
+ margin: auto;
336
+ fill: currentColor;
337
+ stroke: currentColor;
338
+ stroke-width: 1.5;
339
+ stroke-linejoin: round;
340
+ }
341
+
342
+ .skip-reading:hover,
343
+ .skip-reading:focus-visible {
344
+ color: var(--say-ink);
345
+ background: color-mix(in oklab, currentColor 14%, transparent);
346
+ }
347
+
348
+ .skip-reading:focus-visible {
349
+ outline: 2px solid var(--accent);
350
+ outline-offset: 1px;
351
+ }
352
+
309
353
  .say:first-child::before {
310
354
  content: "";
311
355
  position: absolute;
@@ -343,12 +387,11 @@
343
387
  overflow-wrap: anywhere;
344
388
  }
345
389
 
390
+ /* A mark under the player's last line, its words in its title; empty once answered, still as high */
346
391
  .status {
347
392
  display: inline-flex;
348
- align-items: center;
349
- gap: 3px;
393
+ min-height: 15px;
350
394
  margin-top: 3px;
351
- font-size: 12px;
352
395
  color: var(--soft);
353
396
  }
354
397
 
@@ -360,7 +403,6 @@
360
403
 
361
404
  .status.read {
362
405
  color: var(--accent);
363
- font-weight: 600;
364
406
  }
365
407
 
366
408
  /* The box the player writes in: a bubble whose tail points at their picture */
@@ -544,7 +586,8 @@
544
586
 
545
587
  /* Only the note's first bubble has a tail, pointing up at the Torretta beside the AI's name, the board turned round or not (Stefano) */
546
588
  .ai-note > .say::before,
547
- .ai-note > .say::after {
589
+ .ai-note > .say::after,
590
+ .flipped .ai-note > .say::after {
548
591
  content: none;
549
592
  }
550
593
 
@@ -660,7 +703,7 @@
660
703
  background: var(--card);
661
704
  color: var(--ink);
662
705
  /* The dialogs' backdrop, over the whole window */
663
- box-shadow: 0 0 0 100vmax rgba(16, 22, 29, 0.5), 0 16px 40px rgb(0 0 0 / 22%);
706
+ box-shadow: 0 0 0 100vmax rgba(16, 22, 29, 0.5), 0 16px 40px rgba(0, 0, 0, 0.22);
664
707
  font-size: 15px;
665
708
  line-height: 1.5;
666
709
  text-align: center;
@@ -675,7 +718,7 @@
675
718
  margin: 0 auto 12px;
676
719
  border-radius: 50%;
677
720
  background: var(--accent);
678
- animation: reconnecting 1.6s ease-in-out infinite;
721
+ animation: reconnecting 1600ms ease-in-out infinite;
679
722
  }
680
723
 
681
724
  @keyframes reconnecting {
@@ -737,13 +780,13 @@
737
780
 
738
781
  .over-board > .asleep {
739
782
  margin: 0;
740
- box-shadow: 0 2px 6px rgb(0 0 0 / 14%), 0 10px 28px rgb(0 0 0 / 20%);
783
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14), 0 10px 28px rgba(0, 0, 0, 0.2);
741
784
  }
742
785
 
743
786
  .over-board > .start-clocks {
744
787
  flex: none;
745
788
  padding: 9px 22px;
746
- box-shadow: 0 2px 6px rgb(0 0 0 / 14%), 0 8px 20px rgb(0 0 0 / 22%);
789
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14), 0 8px 20px rgba(0, 0, 0, 0.22);
747
790
  pointer-events: auto;
748
791
  }
749
792
 
@@ -1,41 +1,51 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
2
  // The talk on the board page (D-51, D-53): the AI's words in bubbles that point at the Torretta, the player's above the box they write in.
3
3
  import { createContext } from "preact";
4
- import { useContext, useEffect, useLayoutEffect, useRef } from "preact/hooks";
4
+ import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
5
5
  import { Torretta } from "../avatars/torretta.js";
6
6
  import { BadgeIcon, NoteIcon } from "../game/badges.js";
7
7
  import { Figurines } from "../game/move-list.js";
8
8
  import { talkLimits } from "../game/plies.js";
9
+ import { ReviewProgress } from "../game/review-progress.js";
9
10
  import { signature } from "../players/model-name.js";
10
- import { hush, speak } from "./voice.js";
11
+ import { hasVoice, hush, onReading, reading, skip, speak } from "./voice.js";
11
12
  /** The AI's words of the game, a bubble each, the newest by the Torretta: below it, or above it on a board turned round; the first points at it. */
12
13
  export function AiWords({ words, newestFirst, voice }) {
13
14
  const list = useRef(null);
14
15
  useOnlyWhole(list, words, newestFirst);
15
16
  useSpokenNews(words, voice);
16
- return (_jsx("ol", { ref: list, class: "says", "aria-live": "polite", children: inOrder(words.map((word, index) => (_jsx("li", { class: "say", style: aged(words.length - 1 - index), children: _jsx(Marked, { text: word }) }, index))), newestFirst) }));
17
+ const read = useReading(voice);
18
+ return (_jsx("ol", { ref: list, class: "says", "aria-live": "polite", children: inOrder(words.map((word, index) => (_jsxs("li", { class: "say", style: aged(words.length - 1 - index), children: [_jsx(SkipReading, { at: wordKey(index, word), read: read, voice: voice }), _jsx(Marked, { text: word })] }, index))), newestFirst) }));
17
19
  }
18
20
  /**
19
21
  * The AI's word on the move shown of a game in analysis (D-69), in a bubble under the Torretta beside the AI's name, pointing up at it: the move, its badge by name,
20
22
  * and its note when it wrote one; read out as the player steps through the game. The Torretta and the name stay when the move has no word.
21
23
  */
22
- export function AiNote({ ply, opening, closing, summary, reviewing, onComplete, name, by, mood, voice, text }) {
24
+ export function AiNote({ ply, opening, closing, summary, reviewing, count, onComplete, name, by, mood, voice, text }) {
23
25
  // On a move of the book without the AI's note, the AI reports what the book says: the opening in the title, the variation under it.
24
26
  const book = ply?.review?.note === undefined ? opening : undefined;
25
27
  const shown = ply?.review ?? (book === undefined ? undefined : { badge: "book" });
26
28
  const title = [shown?.badge === undefined || (book !== undefined && shown.badge === "book") ? undefined : text.badges[shown.badge], book?.family].filter((part) => part !== undefined).join(" · ");
27
29
  const note = shown?.note ?? book?.variation;
28
- const spoken = [ply === undefined || shown === undefined ? undefined : title, ply === undefined ? undefined : note, summary?.replace(/\s*•\s+/g, ". "), closing].filter((part) => part !== undefined && part !== "").join(". ");
30
+ // Each bubble read on its own, so the mark that skips one sits on the one read: the move's, the game's review, the end.
31
+ const moveWords = ply === undefined || shown === undefined ? "" : [title, note].filter((part) => part !== undefined && part !== "").join(". ");
32
+ const spoken = [
33
+ { key: `move:${moveWords}`, words: moveWords },
34
+ { key: `review:${summary ?? ""}`, words: summary?.replace(/\s*•\s+/g, ". ") ?? "" },
35
+ { key: `closing:${closing ?? ""}`, words: closing ?? "" },
36
+ ];
37
+ const keys = spoken.map((part) => part.key).join("\n");
38
+ const read = useReading(voice);
29
39
  useEffect(() => {
30
40
  if (voice !== undefined) {
31
41
  speak(spoken, voice);
32
42
  }
33
43
  return hush;
34
- }, [spoken, voice]);
44
+ }, [keys, voice]);
35
45
  // The Torretta stays in sight through the whole analysis, over its bubbles, which show only where it wrote something.
36
- return (_jsxs("div", { class: "ai-note", "aria-live": "polite", children: [_jsxs("p", { class: "who", children: [_jsx(Torretta, { size: 36, mood: mood }), _jsxs("span", { class: "signed", children: [name, by === undefined ? null : _jsx("small", { children: signature(by.annotator, by.effort) })] })] }), ply === undefined || shown === undefined ? null : (_jsxs("p", { class: `say move-note ${shown.badge ?? ""}`, children: [_jsxs("span", { class: "head", children: [shown.badge === undefined ? _jsx(NoteIcon, {}) : _jsx(BadgeIcon, { badge: shown.badge }), _jsxs("b", { children: [_jsxs("span", { class: "visually-hidden", children: [ply.number, ply.side === "white" ? "." : "...", ply.san] }), _jsxs("span", { "aria-hidden": "true", children: [_jsxs("span", { class: "move-number", children: [ply.number, ply.side === "white" ? "." : "..."] }), _jsx(Figurines, { san: ply.san, side: ply.side })] })] }), " ", title] }), note === undefined ? null : (_jsx("span", { class: "note", children: _jsx(Marked, { text: note }) })), shown.arrows === undefined && shown.squares === undefined ? null : _jsx(Legend, { review: shown, words: text.drawn })] })), summary === undefined ? null : _jsx(GameReview, { by: name, summary: summary, text: text }), closing === undefined ? null : (_jsx("p", { class: "say closing", children: _jsx(Marked, { text: closing }) })), reviewing === true
37
- ? (_jsxs("p", { class: "say reviewing", children: [_jsx("span", { class: "spinner", "aria-hidden": "true" }), text.reviewing(name)] }))
38
- : null, onComplete === undefined ? null : (_jsxs("p", { class: "say incomplete", children: [_jsx("span", { role: "status", children: text.incomplete }), _jsx("button", { type: "button", title: text.completeHint, "aria-label": text.completeHint, onClick: onComplete, children: text.complete })] }))] }));
46
+ return (_jsxs("div", { class: "ai-note", "aria-live": "polite", children: [_jsxs("p", { class: "who", children: [_jsx(Torretta, { size: 36, mood: mood }), _jsxs("span", { class: "signed", children: [name, by === undefined ? null : _jsx("small", { children: signature(by.annotator, by.effort) })] })] }), ply === undefined || shown === undefined ? null : (_jsxs("p", { class: `say move-note ${shown.badge ?? ""}`, children: [_jsx(SkipReading, { at: spoken[0].key, read: read, voice: voice }), _jsxs("span", { class: "head", children: [shown.badge === undefined ? _jsx(NoteIcon, {}) : _jsx(BadgeIcon, { badge: shown.badge }), _jsxs("b", { children: [_jsxs("span", { class: "visually-hidden", children: [ply.number, ply.side === "white" ? "." : "...", ply.san] }), _jsxs("span", { "aria-hidden": "true", children: [_jsxs("span", { class: "move-number", children: [ply.number, ply.side === "white" ? "." : "..."] }), _jsx(Figurines, { san: ply.san, side: ply.side })] })] }), " ", title] }), note === undefined ? null : (_jsx("span", { class: "note", children: _jsx(Marked, { text: note }) })), shown.arrows === undefined && shown.squares === undefined ? null : _jsx(Legend, { review: shown, words: text.drawn })] })), summary === undefined ? null : _jsx(GameReview, { by: name, summary: summary, skipping: _jsx(SkipReading, { at: spoken[1].key, read: read, voice: voice }), text: text }), closing === undefined ? null : (_jsxs("p", { class: "say closing", children: [_jsx(SkipReading, { at: spoken[2].key, read: read, voice: voice }), _jsx(Marked, { text: closing })] })), reviewing === true
47
+ ? (_jsxs("div", { class: "say reviewing", children: [_jsx("p", { role: "status", children: text.reviewing(name) }), count === undefined ? null : _jsx(ReviewProgress, { count: count, label: text.reviewing(name) })] }))
48
+ : null, onComplete === undefined ? null : (_jsxs("div", { class: "say incomplete", children: [_jsxs("p", { children: [_jsx("span", { role: "status", children: text.incomplete }), _jsx("button", { type: "button", title: text.completeHint, "aria-label": text.completeHint, onClick: onComplete, children: text.complete })] }), count === undefined || count.done === 0 ? null : _jsx(ReviewProgress, { count: count, label: text.incomplete, stopped: true })] }))] }));
39
49
  }
40
50
  /** The squares and moves in the AI's words, such as e3, Cxe4, 7.Nd4 or O-O: in bold, as the player looks for them on the board. */
41
51
  export function Marked({ text }) {
@@ -123,30 +133,58 @@ function squareIn(word) {
123
133
  return /[a-h][1-8](?!.*[a-h][1-8])/.exec(word)?.[0];
124
134
  }
125
135
  /** The AI's word on the whole game (D-69), in the Torretta's bubble at the start of the game: a paragraph, and its points as a list. */
126
- function GameReview({ by, summary, text }) {
136
+ function GameReview({ by, summary, skipping, text }) {
127
137
  const [lead = "", ...points] = summary.split(/\s*•\s+/);
128
- return (_jsxs("section", { class: "say review", "aria-label": text.reviewBy(by), children: [_jsxs("p", { class: "head", children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 4V20M8 20H16M5 7H19M5 7L2.5 13A2.5 2 0 0 0 7.5 13ZM19 7L16.5 13A2.5 2 0 0 0 21.5 13Z" }) }), text.reviewBy(by)] }), lead === "" ? null : (_jsx("p", { children: _jsx(Marked, { text: lead }) })), points.length === 0 ? null : (_jsx("ul", { children: points.map((point, index) => (_jsx("li", { children: _jsx(Marked, { text: point }) }, index))) }))] }));
138
+ return (_jsxs("section", { class: "say review", "aria-label": text.reviewBy(by), children: [skipping, _jsxs("p", { class: "head", children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 4V20M8 20H16M5 7H19M5 7L2.5 13A2.5 2 0 0 0 7.5 13ZM19 7L16.5 13A2.5 2 0 0 0 21.5 13Z" }) }), text.reviewBy(by)] }), lead === "" ? null : (_jsx("p", { children: _jsx(Marked, { text: lead }) })), points.length === 0 ? null : (_jsx("ul", { children: points.map((point, index) => (_jsx("li", { children: _jsx(Marked, { text: point }) }, index))) }))] }));
129
139
  }
130
140
  function RailPlayer({ place, picture, name, side, moves, unread = false }) {
131
141
  return (_jsxs("div", { class: `rail-player ${place}${moves ? " moving" : ""}`, children: [_jsx("span", { class: `face${unread ? " unread" : ""}`, children: picture }), _jsxs("span", { class: "rail-name", children: [name, " ", side === undefined ? null : _jsxs("span", { class: "note", children: ["\u00B7 ", side] })] })] }));
132
142
  }
133
- /** Reads aloud each word the AI adds while the page is open, not those already there. */
143
+ /** Reads aloud each word the AI adds while the page is open, not those already there, a bubble after the other; the voice turned off stops it. */
134
144
  function useSpokenNews(words, voice) {
135
145
  const heard = useRef(words.length);
146
+ const speaking = voice !== undefined;
136
147
  useEffect(() => {
137
148
  if (voice !== undefined && words.length > heard.current) {
138
- speak(words.slice(heard.current).join(" "), voice);
149
+ speak(words.slice(heard.current).map((word, index) => ({ key: wordKey(heard.current + index, word), words: word })), voice);
139
150
  }
140
151
  heard.current = words.length;
141
152
  }, [words.length, voice]);
153
+ useEffect(() => speaking ? hush : undefined, [speaking]);
154
+ }
155
+ /** A word of the AI's by its place in the game and its text, so a word of another game in the same place is not taken for it. */
156
+ function wordKey(index, word) {
157
+ return `${index}:${word}`;
158
+ }
159
+ /** The key of the bubble the voice reads, none while it reads nothing or is off. */
160
+ function useReading(voice) {
161
+ const [key, setKey] = useState(reading);
162
+ useEffect(() => onReading(setKey), []);
163
+ return voice === undefined ? undefined : key;
164
+ }
165
+ /**
166
+ * In the corner of a bubble the voice may read, a mark that stops reading it while it is read, and the next goes on; its room is kept while the voice is on,
167
+ * so the words never move when it comes and goes.
168
+ */
169
+ function SkipReading({ at, read, voice }) {
170
+ if (voice === undefined || (read !== at && !hasVoice(voice.language))) {
171
+ return null;
172
+ }
173
+ if (read !== at) {
174
+ return _jsx("span", { class: "skip-reading", "aria-hidden": "true" });
175
+ }
176
+ return (_jsx("button", { type: "button", class: "skip-reading", title: voice.skipReading, "aria-label": voice.skipReading, onClick: () => skip(at), children: _jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M5 7L12 12L5 17ZM12 7L19 12L12 17ZM19 6.5V17.5" }) }) }));
142
177
  }
143
178
  /** A list's items, the newest first when it asks. */
144
179
  function inOrder(items, newestFirst) {
145
180
  return newestFirst ? items.reverse() : items;
146
181
  }
147
- /** A word fades with age: the newest whole, the older ones far back, so they never pass for the newest (Stefano), each fainter down to a floor. */
182
+ /**
183
+ * A word fades with age: the newest whole, the older ones well back, so they never pass for the newest (Stefano), each fainter down to a floor where its text still
184
+ * keeps 3.5:1 on its bubble in the light theme.
185
+ */
148
186
  function aged(age) {
149
- return { opacity: age === 0 ? 1 : Math.max(0.2, 0.4 - (age - 1) * 0.1) };
187
+ return { opacity: age === 0 ? 1 : Math.max(0.6, 0.7 - (age - 1) * 0.05) };
150
188
  }
151
189
  /** In analysis the talk is read again: the AI's last answer and what follows it whole, the older lines a little back, still readable (Zoe). */
152
190
  function reread(index, lastAnswer) {
@@ -170,9 +208,9 @@ function useOnlyWhole(list, lines, newestFirst = false) {
170
208
  for (const item of element.children) {
171
209
  const place = item.getBoundingClientRect();
172
210
  const style = item.style;
173
- // The newest never hides: one longer than the column, as a note with the helps on (D-78), scrolls in itself.
211
+ // The newest never hides: one longer than the column, as a note with the helps on (D-78), scrolls in itself with no scrollbar (talk.css).
174
212
  style.maxHeight = item === newest ? room + "px" : "";
175
- style.overflowY = item === newest && item.scrollHeight > room ? "auto" : "";
213
+ item.classList.toggle("scrolls", item === newest && item.scrollHeight > room);
176
214
  style.visibility = item === newest || (place.top >= box.top - 1 && place.bottom <= box.bottom + 1) ? "" : "hidden";
177
215
  }
178
216
  };
@@ -182,8 +220,15 @@ function useOnlyWhole(list, lines, newestFirst = false) {
182
220
  return () => observer.disconnect();
183
221
  }, [list, lines.length, lines.at(-1)]);
184
222
  }
185
- /** Under the player's last line: one check once written, two once the AI has read it. */
223
+ /**
224
+ * Under the player's last line, a mark with its words as title: one check once written, two once the AI has read it; once it has answered, its answer says it and
225
+ * the mark leaves, its room kept so the line does not move.
226
+ */
186
227
  function Status({ heard, name, text }) {
187
- const read = heard === "read" || heard === "answered";
188
- return (_jsxs("span", { class: read ? "status read" : "status", children: [_jsx("svg", { class: "icon", viewBox: "0 0 21 24", "aria-hidden": "true", children: read ? _jsx("path", { d: "M2 12.5L6 16.5L13.5 8M10 15.5L11 16.5L18.5 8" }) : _jsx("path", { d: "M3.5 12.5L7.5 16.5L15 8" }) }), read ? text.readBy(name) : text.sent] }));
228
+ if (heard === "answered") {
229
+ return _jsx("span", { class: "status", "aria-hidden": "true" });
230
+ }
231
+ const read = heard === "read";
232
+ const label = read ? text.readBy(name) : text.sent;
233
+ return (_jsx("span", { class: read ? "status read" : "status", role: "img", title: label, "aria-label": label, children: _jsx("svg", { class: "icon", viewBox: "0 0 21 24", "aria-hidden": "true", children: read ? _jsx("path", { d: "M2 12.5L6 16.5L13.5 8M10 15.5L11 16.5L18.5 8" }) : _jsx("path", { d: "M3.5 12.5L7.5 16.5L15 8" }) }) }));
189
234
  }
@@ -1,33 +1,38 @@
1
- /** Reads the words aloud in the voice's language, the moves in words, cutting short what was being read. */
2
- export function speak(words, voice) {
1
+ /** Reads the bubbles aloud one after the other in the voice's language, the moves in words, cutting short what was being read. */
2
+ export function speak(readings, voice) {
3
3
  if (typeof speechSynthesis === "undefined") {
4
4
  return;
5
5
  }
6
- const said = spokenMoves(words.replace(/\*\*/g, ""), voice.saidMove).trim();
7
- speechSynthesis.cancel();
8
- clearTimeout(pending);
9
- if (said === "") {
10
- return;
6
+ stop();
7
+ queue = readings.map((reading) => ({ key: reading.key, said: spokenMoves(reading.words.replace(/\*\*/g, ""), voice.saidMove).trim() })).filter((reading) => reading.said !== "");
8
+ language = voice.language;
9
+ readNextSoon();
10
+ }
11
+ /** Stops reading the bubble the key names, when it is the one read, and goes on with the next. */
12
+ export function skip(key) {
13
+ if (current?.key === key) {
14
+ stop();
15
+ readNextSoon();
11
16
  }
12
- // Chrome drops words spoken right after a cancel, and lists its voices late the first time: the words wait a moment.
13
- pending = setTimeout(() => {
14
- const utterance = new SpeechSynthesisUtterance(said);
15
- const reader = readerFor(voice.language);
16
- utterance.lang = reader?.lang ?? voice.language;
17
- if (reader !== undefined) {
18
- utterance.voice = reader;
19
- }
20
- speechSynthesis.resume();
21
- speechSynthesis.speak(utterance);
22
- }, 150);
23
17
  }
24
- /** Stops reading. */
18
+ /** Stops reading, and forgets what was waiting. */
25
19
  export function hush() {
26
20
  if (typeof speechSynthesis !== "undefined") {
27
- clearTimeout(pending);
28
- speechSynthesis.cancel();
21
+ queue = [];
22
+ stop();
29
23
  }
30
24
  }
25
+ /** The key of the bubble being read, from the moment the voice starts it to its end; none while nothing is read. */
26
+ export function reading() {
27
+ return current?.started === true ? current.key : undefined;
28
+ }
29
+ /** Calls the listener whenever the bubble being read changes: what it returns stops it. */
30
+ export function onReading(listener) {
31
+ listeners.add(listener);
32
+ return () => {
33
+ listeners.delete(listener);
34
+ };
35
+ }
31
36
  /** Whether the browser has a voice for the language: without one the words cannot be read. */
32
37
  export function hasVoice(language) {
33
38
  return typeof speechSynthesis !== "undefined" && readerFor(language) !== undefined;
@@ -61,7 +66,73 @@ export function spokenMoves(words, say) {
61
66
  return say({ piece, ...said, takes: takes !== undefined, square, ...promoted, ...checked });
62
67
  });
63
68
  }
69
+ // What waits to be read, what is read and in which language, and who wants to know which bubble is read.
70
+ let queue = [];
71
+ let current;
72
+ let language = "";
64
73
  let pending;
74
+ const listeners = new Set();
75
+ // Chrome drops words spoken right after a cancel, and lists its voices late the first time: the words wait a moment.
76
+ function readNextSoon() {
77
+ pending = setTimeout(readNext, 150);
78
+ }
79
+ function readNext() {
80
+ const next = queue.shift();
81
+ if (next === undefined) {
82
+ return;
83
+ }
84
+ const utterance = new SpeechSynthesisUtterance(next.said);
85
+ const reader = readerFor(language);
86
+ utterance.lang = reader?.lang ?? language;
87
+ if (reader !== undefined) {
88
+ utterance.voice = reader;
89
+ }
90
+ const reading = { key: next.key, utterance, started: false };
91
+ current = reading;
92
+ // A cancel fires end or error in some browsers and nothing in others: only the events of the utterance read now count, and the page drops its own at once.
93
+ utterance.onstart = () => {
94
+ if (current === reading) {
95
+ reading.started = true;
96
+ // The voice may stop with no event at all: a look each second at whether it still speaks, so no mark stays on a bubble nobody reads.
97
+ reading.watch = setInterval(() => {
98
+ if (current === reading && !speechSynthesis.speaking) {
99
+ finished(reading);
100
+ }
101
+ }, 1000);
102
+ tell();
103
+ }
104
+ };
105
+ utterance.onend = () => finished(reading);
106
+ utterance.onerror = () => finished(reading);
107
+ speechSynthesis.resume();
108
+ speechSynthesis.speak(utterance);
109
+ }
110
+ function finished(reading) {
111
+ if (current === reading) {
112
+ drop();
113
+ readNext();
114
+ }
115
+ }
116
+ // Cuts short what is read, keeping what waits.
117
+ function stop() {
118
+ clearTimeout(pending);
119
+ drop();
120
+ speechSynthesis.cancel();
121
+ }
122
+ function drop() {
123
+ const was = current;
124
+ clearInterval(was?.watch);
125
+ current = undefined;
126
+ if (was?.started === true) {
127
+ tell();
128
+ }
129
+ }
130
+ function tell() {
131
+ const key = reading();
132
+ for (const listener of listeners) {
133
+ listener(key);
134
+ }
135
+ }
65
136
  // A move in SAN, with the pieces' letters or figurines, as the AI writes it; the parts named, castling apart.
66
137
  const movePattern = /(?<![\w♔-♟])(?:(?<castles>O-O(?:-O)?|0-0(?:-0)?)|(?:(?<figurine>[♔-♟])[\uFE0E\uFE0F]?|(?<letter>[KQRBN]))?(?<from>[a-h]?[1-8]?)(?<takes>[x×])?(?<square>[a-h][1-8])(?:=?(?<promotion>[QRBN]|[♔-♟]))?[\uFE0E\uFE0F]?)(?<check>\+\+|[+#])?(?![\w♔-♟])/gu;
67
138
  // A piece's letter, or its figurine: White's run ♔♕♖♗♘♙ and Black's ♚♛♜♝♞♟, in SAN's order.