chesspal 0.3.0 → 0.3.1

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.
@@ -41,6 +41,8 @@ export interface Context {
41
41
  * (D-71); null in a chat no tool has shown the game yet, none outside a chat.
42
42
  */
43
43
  positionSeen?: SeenPosition | null | undefined;
44
+ /** The first position a tool showed the chat: a chat that has seen no other since has only greeted the player, and a new game keeps it (D-57). */
45
+ positionFirstSeen?: SeenPosition | undefined;
44
46
  /** The instructions the MCP server gave in this chat, by their name and the chat's start: each comes once a chat (D-68); none on the command line. */
45
47
  told?: Set<string>;
46
48
  /** Who makes this tool call, as far as the host ties it to the call (D-81); none on the command line. */
@@ -35,7 +35,7 @@ import { signature } from "../players/model-name.js";
35
35
  import { keepSettings } from "../profile/profile.js";
36
36
  import { languagesOf, saveSettings, settingsFrom, showSettings } from "../profile/settings.js";
37
37
  import { endSoundOf, playSound, soundOf, wakeSounds } from "../sounds/sounds.js";
38
- import { ChatOpener } from "../table/opener.js";
38
+ import { AiNameButton, ChatDialog, tableNote } from "../table/window.js";
39
39
  import { AiNote, AiWords, Compose, PointSquare, TalkFaces } from "../talk/talk.js";
40
40
  import { textFor } from "../text.js";
41
41
  import { anchorName, useAnchor } from "./anchor.js";
@@ -67,6 +67,18 @@ export function App({ start }) {
67
67
  };
68
68
  const [dialog, setDialog] = useState(undefined);
69
69
  const { data: served, problem, offline, reload } = useServed(() => setDialog("new-game"));
70
+ // Nuova partita with no chat at the table asks for one first (D-107): the chat's window, then the new game's, however the first closes.
71
+ const [chatFirst, setChatFirst] = useState(false);
72
+ const askNewGame = () => {
73
+ const table = offline ? undefined : served?.table;
74
+ const first = table !== undefined && !table.chat && table.opening === undefined;
75
+ setChatFirst(first);
76
+ setDialog(first ? "chat" : "new-game");
77
+ };
78
+ const closeChat = () => {
79
+ setDialog(chatFirst ? "new-game" : undefined);
80
+ setChatFirst(false);
81
+ };
70
82
  const [selected, setSelected] = useState(undefined);
71
83
  const [refusal, setRefusal] = useState(undefined);
72
84
  // A pawn taken to the last rank waits for the player to choose what it becomes.
@@ -237,7 +249,7 @@ export function App({ start }) {
237
249
  },
238
250
  };
239
251
  const { kind, verdict } = aborted && phase === "over" ? { kind: "over", verdict: text.aborted } : verdictOf(phase, how, winner, offer, declined, text);
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"),
252
+ const header = (_jsx(Header, { text: text, kind: kind, verdict: verdict, ...(!ended && (offer === "ai" || declined) ? { who: name } : {}), actions: actions, onNewGame: served === undefined ? undefined : askNewGame,
241
253
  // 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.
242
254
  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") }) }));
243
255
  const panel = _jsx(SettingsPanel, { open: setting, settings: settings, onChange: change, onClose: () => setSetting(false), text: text });
@@ -302,16 +314,20 @@ export function App({ start }) {
302
314
  const clockWaits = clock?.waiting === true && !ended && studied === undefined && viewing === undefined && live.position.turn === ai;
303
315
  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 }));
304
316
  // 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).
305
- // On the table with no chat at it, the note opens one instead (D-104).
317
+ // On the table, what its chat is doing stands there, and in a game with no chat at it a line that opens the chat's window (D-107).
306
318
  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);
319
+ const atTable = table === undefined ? null : tableNote({ table, name, inGame: going, onOpen: () => setDialog("chat"), text });
320
+ const asleepNote = atTable !== null
321
+ ? atTable
322
+ : asleep && table?.chat !== false
323
+ ? _jsx("p", { class: "asleep", role: "status", children: sides === undefined ? text.asleep(name) : text.asleepInAnalysis(name) })
324
+ : null;
309
325
  const startClocks = clockWaits
310
326
  ? (_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] }))
311
327
  : null;
312
328
  // Between games the next starts from the player's card, where the clock and the button that starts it stand in a game (D-77).
313
329
  const nextGame = between
314
- ? (_jsxs("button", { type: "button", class: "command primary next-game", onClick: () => setDialog("new-game"), children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 5V19M5 12H19" }) }), text.newGame] }))
330
+ ? (_jsxs("button", { type: "button", class: "command primary next-game", onClick: askNewGame, children: [_jsx("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 5V19M5 12H19" }) }), text.newGame] }))
315
331
  : null;
316
332
  // Words sent leave the box; what the player wrote on while they went stays.
317
333
  const clearSent = (refused, words) => {
@@ -392,14 +408,14 @@ export function App({ start }) {
392
408
  }
393
409
  return refused?.text;
394
410
  };
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
411
+ return (_jsx(ChosenSet.Provider, { value: settings.set, children: _jsxs(PointSquare.Provider, { value: pointSquare, children: [panel, _jsx(ProfileDialog, { open: dialog === "profile", profile: profile, onClose: () => setDialog(undefined), text: text }), _jsx(ProfilesDialog, { open: dialog === "profiles", profiles: served.profiles, active: profile, locked: going ? text.profile.whilePlaying : undefined, dataFolder: served.dataFolder, onEdit: () => setDialog("profile"), onClose: () => setDialog(undefined), text: text }), table === undefined ? null : _jsx(ChatDialog, { open: dialog === "chat", table: table, settings: settings, onChange: change, name: name, onClose: closeChat, text: text }), _jsx(NewGameDialog, { open: dialog === "new-game", choice: choice, going: going, onStart: startNewGame, onClose: () => setDialog(undefined), text: text }), guide, _jsx(StudyDialog, { open: dialog === "study", onStudy: studyGame, onClose: () => setDialog(undefined), 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
396
412
  ? (_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 }))
397
413
  : (_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
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) => {
414
+ ? (_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 } : {}), 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) => {
399
415
  setDraft(words);
400
416
  setUnsent(undefined);
401
417
  }, onSend: () => sendWords({ game: studied.name, at }), canMove: false, name: name, problem: unsent, about: { ...(at === 0 ? {} : { ply: plies[at - 1] }), onShow: goTo }, text: text }) }), studiedCard(player, "you")] })] }))
402
- : (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": name, children: [_jsx(PlayerCard, { place: "ai", picture: thinker(48), name: name, signed: signed, side: idle ? undefined : ai, ...(idle ? {} : { level: text.start.levels[level] }), taken: material.taken[ai], ahead: ahead(ai), clock: clockOf(ai), text: text }), asleepNote, _jsx(AiWords, { words: aiWords(plies, ai, talk), newestFirst: !flipped, ...(settings.voice ? { voice: text.language } : {}) })] }), _jsxs("section", { class: "you-block", "aria-label": profile.name, children: [_jsx(Compose, { lines: said, newestFirst: flipped,
418
+ : (_jsxs(_Fragment, { children: [_jsxs("section", { class: "ai-block", "aria-label": name, children: [_jsx(PlayerCard, { place: "ai", picture: thinker(48), name: table === undefined ? name : _jsx(AiNameButton, { name: name, table: table, onOpen: () => setDialog("chat"), text: text }), signed: signed, side: idle ? undefined : ai, ...(idle ? {} : { level: text.start.levels[level] }), taken: material.taken[ai], ahead: ahead(ai), clock: clockOf(ai), text: text }), asleepNote, _jsx(AiWords, { words: aiWords(plies, ai, talk), newestFirst: !flipped, ...(settings.voice ? { voice: text } : {}) })] }), _jsxs("section", { class: "you-block", "aria-label": profile.name, children: [_jsx(Compose, { lines: said, newestFirst: flipped,
403
419
  // Between games, and before the first, the player writes freely, a line at a time, as the AI does (D-74, D-77).
404
420
  spent: going ? playerSpent(plies, ai, talk) : 0, draft: draft, onDraft: (words) => {
405
421
  setDraft(words);
@@ -408,8 +424,8 @@ export function App({ start }) {
408
424
  // 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.
409
425
  class: between && !justEnded ? "board-wrap off" : "board-wrap", onWheel: wheel,
410
426
  // A press on the board while the player looks back brings the game as it is: the next one plays.
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] }))
427
+ 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" && (asleepNote !== null || clockWaits)
428
+ ? (_jsxs("div", { class: "over-board", children: [asleepNote, startClocks] }))
413
429
  : 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 })] })] }) }));
414
430
  }
415
431
  /** 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. */
@@ -197,8 +197,8 @@ function refusalText(refused, text) {
197
197
  }
198
198
  // Zoe's camera, on the picture to change.
199
199
  const cameraIcon = (_jsxs("svg", { class: "icon", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { d: "M4 8H7L9 5H15L17 8H20V19H4Z" }), _jsx("circle", { cx: "12", cy: "13", r: "3.5" })] }));
200
- // The profile in use, in the menu, and a field saved in the profile window.
201
- const checkIcon = (_jsx("svg", { class: "icon in-use", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M5 12.5L10 17.5L19 7.5" }) }));
200
+ /** The tick on the choice in use: the profile, a name saved, the chat at the table. */
201
+ export const checkIcon = (_jsx("svg", { class: "icon in-use", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M5 12.5L10 17.5L19 7.5" }) }));
202
202
  // The room of the tick beside a field not saved yet.
203
203
  const noTick = _jsx("svg", { class: "icon in-use", viewBox: "0 0 24 24", "aria-hidden": "true" });
204
204
  // The pencil on the player's picture and beside them in the menu: their profile opens.
@@ -602,32 +602,6 @@
602
602
  min-height: 0;
603
603
  overflow-y: auto;
604
604
  }
605
-
606
- /* On a short column the table's note keeps its buttons and drops the commands to type, then the box and the words go: they wait for a chat anyway.
607
- In analysis the cards are taller and the AI's note needs room too */
608
- @container talk (max-height: 560px) {
609
- .opener-command {
610
- display: none;
611
- }
612
- }
613
-
614
- @container talk (max-height: 500px) {
615
- .ai-block:has(> .opener) + .you-block > :is(.sent, .compose) {
616
- display: none;
617
- }
618
- }
619
-
620
- @container talk (max-height: 640px) {
621
- .talk.studied .opener-command {
622
- display: none;
623
- }
624
- }
625
-
626
- @container talk (max-height: 580px) {
627
- .talk.studied > .ai-block:has(> .opener) + .you-block > .chat {
628
- display: none;
629
- }
630
- }
631
605
  }
632
606
 
633
607
  /* The game's column, which folds from its tab */
@@ -5,7 +5,7 @@
5
5
  @import "board/board.css";
6
6
  @import "talk/talk.css";
7
7
  @import "talk/chat.css";
8
- @import "table/opener.css";
8
+ @import "table/table.css";
9
9
  @import "game/game.css";
10
10
  @import "helps/helps.css";
11
11
  @import "legend/legend.css";
@@ -0,0 +1,123 @@
1
+ /* The chat's window (D-107): narrow, a row for each host, its button with the tick of the one at the table and its model and effort under their titles,
2
+ their columns shared through the subgrid; the command to type instead */
3
+ .dialog:has(> .chat-hosts) {
4
+ width: min(460px, calc(100vw - 32px));
5
+ }
6
+
7
+ .chat-hosts {
8
+ display: grid;
9
+ grid-template-columns: minmax(0, 1fr) auto auto;
10
+ gap: 6px;
11
+ margin: 8px 0 12px;
12
+ }
13
+
14
+ .chat-head,
15
+ .chat-host {
16
+ display: grid;
17
+ grid-column: 1 / -1;
18
+ grid-template-columns: subgrid;
19
+ }
20
+
21
+ .chat-head {
22
+ color: var(--soft);
23
+ font-size: 12px;
24
+ }
25
+
26
+ .chat-host .choice {
27
+ min-width: 0;
28
+ font-size: 15px;
29
+ text-align: left;
30
+ }
31
+
32
+ .chat-host .choice span {
33
+ flex: 1;
34
+ min-width: 0;
35
+ overflow: hidden;
36
+ white-space: nowrap;
37
+ text-overflow: ellipsis;
38
+ }
39
+
40
+ .chat-host .in-use {
41
+ flex: none;
42
+ width: 18px;
43
+ height: 18px;
44
+ color: var(--accent);
45
+ }
46
+
47
+ .chat-host .choice[aria-disabled="true"] {
48
+ border-color: var(--line);
49
+ opacity: 0.6;
50
+ cursor: default;
51
+ }
52
+
53
+ .chat-host select {
54
+ padding: 4px;
55
+ border: 1px solid var(--line);
56
+ border-radius: 6px;
57
+ background: var(--card);
58
+ color: var(--ink);
59
+ font: inherit;
60
+ font-size: 13px;
61
+ }
62
+
63
+ /* Empty, the state takes no room */
64
+ .chat-state:empty {
65
+ display: none;
66
+ }
67
+
68
+ .chat-command {
69
+ color: var(--soft);
70
+ font-size: 12.5px;
71
+ }
72
+
73
+ /* Each command whole on its line; two families, so the browser does not shrink the monospace */
74
+ .chat-command code {
75
+ font-family: ui-monospace, monospace;
76
+ white-space: nowrap;
77
+ user-select: all;
78
+ }
79
+
80
+ /* The note on a start again that waits, with its ↻ and ×; in a game with no chat at the table, a line that opens the chat's window */
81
+ .table-note {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 6px;
85
+ }
86
+
87
+ .table-note > span {
88
+ flex: 1;
89
+ }
90
+
91
+ .table-note .icon-button {
92
+ flex: none;
93
+ width: 30px;
94
+ height: 30px;
95
+ }
96
+
97
+ .over-board > .table-note button {
98
+ pointer-events: auto;
99
+ }
100
+
101
+ .no-chat-line {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ padding: 0;
105
+ border: 0;
106
+ background: none;
107
+ color: inherit;
108
+ font: inherit;
109
+ text-align: left;
110
+ text-decoration: underline;
111
+ text-underline-offset: 3px;
112
+ cursor: pointer;
113
+ }
114
+
115
+ /* No chat at the table: a hollow ring before the AI's name, and before the line that says so */
116
+ .no-chat {
117
+ flex: none;
118
+ width: 8px;
119
+ height: 8px;
120
+ margin-right: 5px;
121
+ border: 1.5px solid var(--soft);
122
+ border-radius: 50%;
123
+ }
@@ -0,0 +1,130 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ import { useEffect, useState } from "preact/hooks";
3
+ import { Dialog } from "../dialogs/dialog.js";
4
+ import { checkIcon } from "../dialogs/profiles.js";
5
+ /** The chat's window: closed once a chat opens or starts again, the page then saying where it stands. */
6
+ export function ChatDialog({ open, 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 note = installed.length === 0
29
+ ? words.install(name)
30
+ : busy
31
+ ? chatState(table, name, text) ?? words.choose
32
+ : byHand
33
+ ? words.byHand(name)
34
+ : table.chat
35
+ ? words.restartHint
36
+ : words.choose;
37
+ const offersChoices = installed.some((host) => host.models.length > 0 || host.efforts.length > 0);
38
+ // 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.
39
+ const started = table.held ? table.started : undefined;
40
+ const changed = (host) => started !== undefined && (chosen(host, "model", settings) !== (started.model ?? "") || chosen(host, "effort", settings) !== (started.effort ?? ""));
41
+ return (_jsxs(Dialog, { open: open, title: words.title, onClose: onClose, text: text, children: [_jsx("p", { class: "note", children: note }), _jsxs("div", { class: "chat-hosts", role: "group", "aria-label": words.title, children: [offersChoices
42
+ ? (_jsxs("div", { class: "chat-head", "aria-hidden": "true", children: [_jsx("span", {}), _jsx("span", { children: words.model }), _jsx("span", { children: words.effort })] }))
43
+ : null, installed.map((host) => {
44
+ const atTable = table.chat && table.host === host.name;
45
+ const off = busy || byHand;
46
+ const again = !atTable || changed(host);
47
+ 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));
48
+ })] }), _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)))] }))] }));
49
+ }
50
+ /** 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. */
51
+ export function AiNameButton({ name, table, onOpen, text }) {
52
+ const title = table.chat ? text.table.change : text.table.noChat;
53
+ 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" }) })] }));
54
+ }
55
+ /**
56
+ * 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 ×
57
+ * that lets the request go; in a game with no chat at the table, a line that opens the chat's window. None otherwise.
58
+ */
59
+ export function tableNote({ table, name, inGame, onOpen, text }) {
60
+ const words = text.table;
61
+ const state = chatState(table, name, text);
62
+ if (table.waiting === true) {
63
+ 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" }) }) })] }));
64
+ }
65
+ if (state !== undefined) {
66
+ return _jsx("p", { class: "asleep", role: "status", children: state });
67
+ }
68
+ if (!table.chat && inGame) {
69
+ return (_jsx("p", { class: "asleep table-note", role: "status", children: _jsxs("button", { class: "no-chat-line", type: "button", "aria-haspopup": "dialog", onClick: onOpen, children: [_jsx("span", { class: "no-chat", "aria-hidden": "true" }), words.noChat] }) }));
70
+ }
71
+ return null;
72
+ }
73
+ /** What the page says of the chat at the table: one opening, starting again, or waiting to; nothing else. */
74
+ export function chatState(table, name, text) {
75
+ const words = text.table;
76
+ if (table.waiting === true) {
77
+ return words.waiting(name);
78
+ }
79
+ return table.opening === "chat" ? words.opening : table.opening === "again" ? words.again(name) : table.opening === "restart" ? words.restart : undefined;
80
+ }
81
+ const hostTitles = { claude: "Claude Code", codex: "Codex" };
82
+ // The settings that keep each host's choice.
83
+ const choiceKeys = {
84
+ claude: { model: "claudeModel", effort: "claudeEffort" },
85
+ codex: { model: "codexModel", effort: "codexEffort" },
86
+ };
87
+ /** The ↻ of a chat that starts again. */
88
+ 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" }) }));
89
+ /** A host's model or effort as the settings keep it: "" for the host's own, and for a choice it no longer offers. */
90
+ function chosen(host, kind, settings) {
91
+ const key = choiceKeys[host.name][kind];
92
+ const offered = kind === "model" ? host.models : host.efforts;
93
+ return offered.includes(settings[key]) ? settings[key] : "";
94
+ }
95
+ /** 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. */
96
+ function Choice({ host, kind, settings, onChange, text }) {
97
+ const key = choiceKeys[host.name][kind];
98
+ const offered = kind === "model" ? host.models : host.efforts;
99
+ const value = chosen(host, kind, settings);
100
+ const title = hostTitles[host.name] ?? host.name;
101
+ if (offered.length === 0) {
102
+ return _jsx("span", {});
103
+ }
104
+ 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))] }));
105
+ }
106
+ // Codex's models without the "gpt-" they all start with, so the choice stays narrow; the title keeps the whole name.
107
+ function shortName(value) {
108
+ return value.replace(/^gpt-/, "");
109
+ }
110
+ /** Asks the table to open a terminal with a chat in the host: whether it did. */
111
+ function openChat(host) {
112
+ return asked("/api/table/chat", { host }, (answer) => answer.opened === true);
113
+ }
114
+ /** Asks the table to start at once the chat that waits to start again, cutting its answer, or to let the request go. */
115
+ function endWaiting(now) {
116
+ return asked("/api/table/waiting", { now }, () => true);
117
+ }
118
+ /** Asks the table to start its chat again with the host, at once or once it has ended its answer: whether it took the request. */
119
+ function restartChat(host) {
120
+ return asked("/api/table/restart", { host }, (answer) => answer.restarting === true || answer.waiting === true);
121
+ }
122
+ async function asked(path, body, done) {
123
+ try {
124
+ const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
125
+ return response.ok && done((await response.json()));
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
@@ -55,10 +55,21 @@
55
55
  }
56
56
  }
57
57
 
58
- /* The AI's answers on the left, in its bubbles, without the tail that points at the Torretta: it is not by them */
58
+ /* The AI's answers on the left, in its bubbles, each with a small Torretta at its foot instead of the tail that points at the big one: it is not by them (Stefano) */
59
59
  .sent li.from-ai {
60
60
  align-self: flex-start;
61
- align-items: flex-start;
61
+ flex-direction: row;
62
+ align-items: flex-end;
63
+ gap: 6px;
64
+ }
65
+
66
+ .sent li.from-ai > .torretta {
67
+ flex: none;
68
+ }
69
+
70
+ /* The bubble's foot level with the Torretta's, spaced as the player's lines are */
71
+ .sent li.from-ai > .say {
72
+ margin: 0;
62
73
  }
63
74
 
64
75
  .talk.studied .sent .say::before,
@@ -60,7 +60,7 @@ export function Legend({ review, words }) {
60
60
  /**
61
61
  * The player's words of the game, the newest by the box they write in with how far it got, and the box: Enter or the button sends the words alone, the next
62
62
  * move takes them with it. 200 characters from one move of the AI's to the next: once they are written, the box goes until its move (D-54). In analysis the
63
- * AI's answers are in the same thread, and each line of the player's names the move it was written on (D-101).
63
+ * AI's answers are in the same thread, each by a small Torretta, and each line of the player's names the move it was written on (D-101).
64
64
  */
65
65
  export function Compose({ lines, newestFirst, spent, draft, onDraft, onSend, canMove, name, problem, about, text }) {
66
66
  const list = useRef(null);
@@ -76,7 +76,7 @@ export function Compose({ lines, newestFirst, spent, draft, onDraft, onSend, can
76
76
  }
77
77
  };
78
78
  return (_jsxs(_Fragment, { children: [_jsx("ol", { ref: list, class: "sent", "aria-live": "polite", children: inOrder(lines.map((line, index) => (_jsxs("li", { class: line.who === "ai" ? "from-ai" : undefined, style: about === undefined ? aged(lines.length - 1 - index) : reread(index, lastAnswer), children: [line.who === "ai"
79
- ? (_jsx("p", { class: "say", children: _jsx(Marked, { text: line.text }) }))
79
+ ? (_jsxs(_Fragment, { children: [_jsx(Torretta, { size: 24 }), _jsx("p", { class: "say", children: _jsx(Marked, { text: line.text }) })] }))
80
80
  : (_jsxs("p", { class: "said", children: [about === undefined || line.shown?.move === undefined ? null : _jsx(ShownMove, { move: line.shown.move, onShow: () => about.onShow(line.shown.at), text: text }), line.text] })), index === lastWritten ? _jsx(Status, { heard: line.heard, name: name, text: text }) : null] }, index))), newestFirst) }), left <= 0 ? null : (_jsxs("form", { class: about === undefined ? "compose" : "compose asking", onSubmit: (event) => {
81
81
  event.preventDefault();
82
82
  submit();
@@ -1,10 +1,9 @@
1
- // The AI's words read aloud (Stefano's idea, IDEE.md): the browser's own voices, a local one first, so the words stay on the computer where it can.
2
- /** Reads the words aloud in the language given, cutting short what was being read. */
3
- export function speak(words, language) {
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) {
4
3
  if (typeof speechSynthesis === "undefined") {
5
4
  return;
6
5
  }
7
- const said = words.replace(/\*\*/g, "").trim();
6
+ const said = spokenMoves(words.replace(/\*\*/g, ""), voice.saidMove).trim();
8
7
  speechSynthesis.cancel();
9
8
  clearTimeout(pending);
10
9
  if (said === "") {
@@ -13,10 +12,10 @@ export function speak(words, language) {
13
12
  // Chrome drops words spoken right after a cancel, and lists its voices late the first time: the words wait a moment.
14
13
  pending = setTimeout(() => {
15
14
  const utterance = new SpeechSynthesisUtterance(said);
16
- const voice = voiceFor(language);
17
- utterance.lang = voice?.lang ?? language;
18
- if (voice !== undefined) {
19
- utterance.voice = voice;
15
+ const reader = readerFor(voice.language);
16
+ utterance.lang = reader?.lang ?? voice.language;
17
+ if (reader !== undefined) {
18
+ utterance.voice = reader;
20
19
  }
21
20
  speechSynthesis.resume();
22
21
  speechSynthesis.speak(utterance);
@@ -29,9 +28,36 @@ export function hush() {
29
28
  speechSynthesis.cancel();
30
29
  }
31
30
  }
31
+ /**
32
+ * The words with each move the AI wrote said in words, since a voice reads ♟g4 as "chess pawn g4": ♞xf6+, Nxf6+, exd5, O-O, e8=♛. A square alone, e4,
33
+ * stays as it is; a pawn's move becomes words only with its figurine, a capture, a promotion or a check, and without the file it takes from.
34
+ */
35
+ export function spokenMoves(words, say) {
36
+ return words.replace(movePattern, (move, ...found) => {
37
+ const { castles, figurine, letter, from, takes, square, promotion, check } = found.at(-1);
38
+ const checked = check === undefined ? {} : { check: check === "+" ? "check" : "mate" };
39
+ if (castles !== undefined) {
40
+ return say({ castles: castles.length > 3 ? "long" : "short", ...checked });
41
+ }
42
+ if (square === undefined || (figurine === undefined && letter === undefined && takes === undefined && promotion === undefined && check === undefined)) {
43
+ return move;
44
+ }
45
+ const piece = figurine === undefined ? (letter ?? "P") : pieceOf(figurine);
46
+ const promoted = promotion === undefined ? {} : { promotion: pieceOf(promotion) };
47
+ // A pawn's file, exd5, goes unsaid: "pawn takes d5".
48
+ const said = from === undefined || from === "" || piece === "P" ? {} : { from };
49
+ return say({ piece, ...said, takes: takes !== undefined, square, ...promoted, ...checked });
50
+ });
51
+ }
32
52
  let pending;
53
+ // A move in SAN, with the pieces' letters or figurines, as the AI writes it; the parts named, castling apart.
54
+ 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;
55
+ // A piece's letter, or its figurine: White's run ♔♕♖♗♘♙ and Black's ♚♛♜♝♞♟, in SAN's order.
56
+ function pieceOf(sign) {
57
+ return ("KQRBN".includes(sign) ? sign : "KQRBNP".charAt(((sign.codePointAt(0) ?? 0) - 0x2654) % 6));
58
+ }
33
59
  /** A voice for the language: a local one first, the browser's default among them first. */
34
- function voiceFor(language) {
60
+ function readerFor(language) {
35
61
  const voices = speechSynthesis.getVoices().filter((voice) => voice.lang.toLowerCase().startsWith(language.toLowerCase()));
36
62
  const local = voices.filter((voice) => voice.localService);
37
63
  return local.find((voice) => voice.default) ?? local[0] ?? voices[0];