chesspal 0.3.4 → 0.3.6

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 (104) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/cli.js +10 -2
  3. package/dist/cli/command-line.js +17 -9
  4. package/dist/cli/icon.d.ts +2 -0
  5. package/dist/cli/icon.js +11 -1
  6. package/dist/commands/analysis/analyse.d.ts +2 -2
  7. package/dist/commands/analysis/analyse.js +3 -3
  8. package/dist/commands/answers/agents.d.ts +10 -2
  9. package/dist/commands/answers/agents.js +30 -7
  10. package/dist/commands/answers/index.d.ts +1 -1
  11. package/dist/commands/answers/index.js +1 -1
  12. package/dist/commands/answers/lines.js +3 -2
  13. package/dist/commands/answers/summary.d.ts +2 -1
  14. package/dist/commands/answers/view.d.ts +1 -1
  15. package/dist/commands/answers/view.js +2 -2
  16. package/dist/commands/game/look.d.ts +7 -3
  17. package/dist/commands/game/look.js +9 -4
  18. package/dist/commands/game/new.js +1 -1
  19. package/dist/commands/game/ponder.js +3 -3
  20. package/dist/commands/game/position.d.ts +2 -1
  21. package/dist/commands/game/position.js +9 -4
  22. package/dist/commands/game/status.d.ts +3 -1
  23. package/dist/commands/game/status.js +19 -9
  24. package/dist/commands/profiles/past-games.d.ts +7 -2
  25. package/dist/commands/profiles/past-games.js +26 -10
  26. package/dist/commands/talk/ai-say.js +1 -1
  27. package/dist/games/analysis.d.ts +7 -4
  28. package/dist/games/analysis.js +5 -3
  29. package/dist/games/index.d.ts +1 -1
  30. package/dist/games/index.js +1 -1
  31. package/dist/games/thoughts.d.ts +12 -4
  32. package/dist/games/thoughts.js +27 -10
  33. package/dist/home/index.d.ts +1 -0
  34. package/dist/home/index.js +1 -0
  35. package/dist/home/same-path.d.ts +2 -0
  36. package/dist/home/same-path.js +5 -0
  37. package/dist/host-files/host-files.js +8 -3
  38. package/dist/page/app/anchor.js +15 -11
  39. package/dist/page/app/app.js +30 -17
  40. package/dist/page/app/served.js +23 -36
  41. package/dist/page/app/watch.js +97 -0
  42. package/dist/page/avatars/torretta.css +108 -0
  43. package/dist/page/board/board.css +7 -6
  44. package/dist/page/board/marks.js +2 -2
  45. package/dist/page/board/pointer.js +2 -2
  46. package/dist/page/dialogs/analyses.js +9 -7
  47. package/dist/page/dialogs/rows.js +72 -0
  48. package/dist/page/dialogs/site-games.js +3 -1
  49. package/dist/page/dialogs/studied.js +11 -6
  50. package/dist/page/dialogs/study.js +2 -2
  51. package/dist/page/game/game.css +51 -10
  52. package/dist/page/game/move-list.js +7 -5
  53. package/dist/page/guide/en.md +1 -1
  54. package/dist/page/guide/it.md +1 -1
  55. package/dist/page/helps/helps.css +5 -5
  56. package/dist/page/helps/helps.js +3 -181
  57. package/dist/page/helps/rows.js +16 -0
  58. package/dist/page/helps/strategy.js +105 -0
  59. package/dist/page/helps/tactics.js +69 -0
  60. package/dist/page/layout/header.js +49 -6
  61. package/dist/page/layout/layout.css +17 -2
  62. package/dist/page/layout/window.js +4 -4
  63. package/dist/page/lead/lead.js +41 -4
  64. package/dist/page/sounds/sounds.js +1 -1
  65. package/dist/page/style.css +1 -0
  66. package/dist/page/talk/talk.css +0 -109
  67. package/dist/page/text.js +10 -0
  68. package/dist/page/theme.css +13 -0
  69. package/dist/profiles/profiles.js +5 -1
  70. package/dist/server/bell/bell.d.ts +0 -2
  71. package/dist/server/bell/bell.js +17 -7
  72. package/dist/server/bell/index.d.ts +1 -1
  73. package/dist/server/bell/index.js +1 -1
  74. package/dist/server/page/chat.d.ts +5 -1
  75. package/dist/server/page/chat.js +5 -1
  76. package/dist/server/page/game.js +18 -3
  77. package/dist/server/page/table.d.ts +2 -1
  78. package/dist/server/page/table.js +4 -2
  79. package/dist/server/page/update.d.ts +1 -1
  80. package/dist/server/page/update.js +4 -4
  81. package/dist/server/table/hosts/chats.d.ts +5 -0
  82. package/dist/server/table/hosts/chats.js +11 -4
  83. package/dist/server/table/hosts/play.d.ts +3 -1
  84. package/dist/server/table/hosts/play.js +17 -5
  85. package/dist/server/table/icon/index.d.ts +2 -0
  86. package/dist/server/table/icon/index.js +16 -2
  87. package/dist/server/table/index.d.ts +1 -1
  88. package/dist/server/table/index.js +1 -1
  89. package/dist/server/table/link.js +3 -1
  90. package/dist/server/table/table.js +2 -2
  91. package/dist/sites/index.d.ts +1 -1
  92. package/dist/sites/index.js +1 -1
  93. package/dist/sites/{chesscom.d.ts → ratings/chesscom.d.ts} +2 -2
  94. package/dist/sites/{chesscom.js → ratings/chesscom.js} +1 -1
  95. package/dist/sites/{choice.d.ts → ratings/choice.d.ts} +1 -1
  96. package/dist/sites/{choice.js → ratings/choice.js} +1 -1
  97. package/dist/sites/ratings/index.d.ts +1 -0
  98. package/dist/sites/ratings/index.js +2 -0
  99. package/dist/sites/{lichess.d.ts → ratings/lichess.d.ts} +2 -2
  100. package/dist/sites/{lichess.js → ratings/lichess.js} +1 -1
  101. package/dist/sites/{sites.d.ts → ratings/ratings.d.ts} +2 -2
  102. package/dist/sites/{sites.js → ratings/ratings.js} +1 -1
  103. package/instructions/start.md +1 -1
  104. package/package.json +1 -1
@@ -0,0 +1,97 @@
1
+ // How the page follows the server's game: it reads it when the server says it changed, tries a read lost on the way again, and connects again when the
2
+ // browser gives the server up for good, as when an old table hands the page to the new one. Without Preact, so that a test drives it with fake timers.
3
+ /** How many times, and how far apart in milliseconds, a read lost on the way is tried again; how long before a stream given up is opened again. */
4
+ export const readTries = 5;
5
+ export const readTryWait = 1000;
6
+ export const reconnectWait = 3000;
7
+ /** Follows the server's game: the first read when the events connect, another at each change, until `stop`. */
8
+ export function watchServed(tools, listener) {
9
+ let latest = 0;
10
+ // The Chesspal version of the first answer: a server of another, after an update, sends data this page may not read.
11
+ let pageVersion;
12
+ // The reads of the game asked last that failed, and the next try.
13
+ let failures = 0;
14
+ let retry;
15
+ let reconnect;
16
+ let stopped = false;
17
+ // Whether the page shows a game: then a read that fails leaves it shown, offline, rather than replacing it with the error.
18
+ let shown = false;
19
+ let events = open();
20
+ function load(again = false) {
21
+ tools.cancel(retry);
22
+ failures = again ? failures : 0;
23
+ // Two reads in flight may answer out of order: only the last one asked counts.
24
+ const asked = ++latest;
25
+ tools.read()
26
+ .then((body) => {
27
+ if (asked !== latest || stopped) {
28
+ return;
29
+ }
30
+ pageVersion ??= body.version;
31
+ if (body.version !== undefined && body.version !== pageVersion) {
32
+ listener.otherVersion();
33
+ return;
34
+ }
35
+ listener.problem(body.error);
36
+ if (body.error === undefined) {
37
+ shown = true;
38
+ listener.offline(false);
39
+ listener.read(body);
40
+ }
41
+ })
42
+ .catch((error) => {
43
+ if (asked !== latest || stopped) {
44
+ return;
45
+ }
46
+ // With the server gone the page waits for it and says so: connecting again reads the game.
47
+ if (events.readyState !== streamOpen) {
48
+ listener.offline(true);
49
+ return;
50
+ }
51
+ // A read lost on the way while the server is there is tried again shortly.
52
+ if (++failures <= readTries) {
53
+ retry = tools.later(() => load(true), readTryWait);
54
+ return;
55
+ }
56
+ if (shown) {
57
+ listener.offline(true);
58
+ }
59
+ else {
60
+ listener.problem(String(error));
61
+ }
62
+ });
63
+ }
64
+ function open() {
65
+ const stream = tools.connect({
66
+ open: () => {
67
+ listener.offline(false);
68
+ load();
69
+ },
70
+ // The chat asks for a new game: the player chooses it in the New game window.
71
+ message: (data) => data === "new-game" ? listener.newGame() : load(),
72
+ // The browser connects again by itself, but not after a server that answered with an error: then the page does, a little later.
73
+ error: () => {
74
+ listener.offline(stream.readyState !== streamOpen);
75
+ if (stream.readyState === streamClosed && !stopped) {
76
+ tools.cancel(reconnect);
77
+ reconnect = tools.later(() => {
78
+ events = open();
79
+ }, reconnectWait);
80
+ }
81
+ },
82
+ });
83
+ return stream;
84
+ }
85
+ return {
86
+ reload: () => load(),
87
+ stop: () => {
88
+ stopped = true;
89
+ tools.cancel(retry);
90
+ tools.cancel(reconnect);
91
+ events.close();
92
+ },
93
+ };
94
+ }
95
+ // EventSource's states, which a test's fake stream has no class for.
96
+ const streamOpen = 1;
97
+ const streamClosed = 2;
@@ -0,0 +1,108 @@
1
+ /* The Torretta, whose eyes blink now and then, and its moods (D-70): a new face that pops in, never fading out, a red light when worried, out when sad, a light that breathes when
2
+ thoughtful */
3
+ .torretta .antenna {
4
+ fill: none;
5
+ stroke: var(--torretta-shade);
6
+ stroke-width: 4;
7
+ stroke-linecap: round;
8
+ }
9
+
10
+ .torretta .light {
11
+ fill: var(--torretta-light);
12
+ transition: fill 0.2s;
13
+ }
14
+
15
+ .torretta.mood-worried .light {
16
+ fill: var(--torretta-alarm);
17
+ }
18
+
19
+ .torretta.mood-thoughtful .light {
20
+ animation: ponder 2.4s ease-in-out infinite;
21
+ }
22
+
23
+ .torretta.mood-sad .light {
24
+ fill: var(--torretta-shade);
25
+ }
26
+
27
+ @keyframes ponder {
28
+ 50% {
29
+ fill: var(--torretta-ponder);
30
+ }
31
+ }
32
+
33
+ .torretta .expression.changed {
34
+ transform-box: fill-box;
35
+ transform-origin: center;
36
+ animation: face-in 0.16s ease-out;
37
+ }
38
+
39
+ @keyframes face-in {
40
+ from {
41
+ transform: scale(0.8);
42
+ }
43
+ }
44
+
45
+ /* Its bust on a player's card: a disc of the player's picture's colour, which cuts what falls out of it */
46
+ .torretta.bust {
47
+ clip-path: circle(50%);
48
+ }
49
+
50
+ .torretta .back {
51
+ fill: var(--say);
52
+ }
53
+
54
+ .torretta .shade {
55
+ fill: var(--torretta-shade);
56
+ }
57
+
58
+ .torretta .body {
59
+ fill: var(--torretta-body);
60
+ }
61
+
62
+ .torretta .screen {
63
+ fill: var(--torretta-screen);
64
+ }
65
+
66
+ .torretta .eyes,
67
+ .torretta .mouth {
68
+ fill: var(--torretta-face);
69
+ }
70
+
71
+ .torretta .expression .line,
72
+ .torretta .brows {
73
+ fill: none;
74
+ stroke: var(--torretta-face);
75
+ stroke-width: 3.4;
76
+ stroke-linecap: round;
77
+ stroke-linejoin: round;
78
+ }
79
+
80
+ .torretta .eyes {
81
+ transform-box: fill-box;
82
+ transform-origin: center;
83
+ animation: blink 6s infinite;
84
+ }
85
+
86
+ @keyframes blink {
87
+ 0%,
88
+ 95%,
89
+ 100% {
90
+ transform: scaleY(1);
91
+ }
92
+
93
+ 97% {
94
+ transform: scaleY(0.1);
95
+ }
96
+ }
97
+
98
+ @media (prefers-reduced-motion: reduce) {
99
+ .torretta .eyes,
100
+ .torretta .expression.changed,
101
+ .torretta.mood-thoughtful .light {
102
+ animation: none;
103
+ }
104
+
105
+ .torretta .light {
106
+ transition: none;
107
+ }
108
+ }
@@ -375,23 +375,23 @@
375
375
  }
376
376
 
377
377
  .badge.crown {
378
- background: #e0a526;
379
- color: #3b2a00;
378
+ background: var(--king-crown);
379
+ color: var(--king-crown-ink);
380
380
  }
381
381
 
382
382
  /* The mate white on the red square, edged in red */
383
383
  .badge.mate {
384
384
  background: #fff;
385
- color: #c81e1e;
386
- box-shadow: 0 0 0 max(1.5px, 0.25cqw) #c81e1e, 0 0.2cqw 0.6cqw rgba(0, 0, 0, 0.4);
385
+ color: var(--king-mate);
386
+ box-shadow: 0 0 0 max(1.5px, 0.25cqw) var(--king-mate), 0 0.2cqw 0.6cqw rgba(0, 0, 0, 0.4);
387
387
  }
388
388
 
389
389
  .badge.flag {
390
- background: #4a4f57;
390
+ background: var(--king-flag);
391
391
  }
392
392
 
393
393
  .badge.half {
394
- background: #69727d;
394
+ background: var(--king-draw);
395
395
  }
396
396
 
397
397
  /* A move's badge (D-69): 16px beside a move, a third of a square in the corner of its arrival, edged in white as the kings' badges */
@@ -842,6 +842,7 @@
842
842
  left: 50%;
843
843
  top: 50%;
844
844
  transform: translate(-50%, -50%);
845
+ text-wrap: balance;
845
846
  }
846
847
 
847
848
  .notice.centre::after {
@@ -110,8 +110,8 @@ export function PromotionChoice({ side, column, down, onPick, text }) {
110
110
  onPick(undefined);
111
111
  }
112
112
  };
113
- addEventListener("keydown", escape);
114
- return () => removeEventListener("keydown", escape);
113
+ window.addEventListener("keydown", escape);
114
+ return () => window.removeEventListener("keydown", escape);
115
115
  }, []);
116
116
  return (_jsxs("div", { class: "promotion", role: "group", "aria-label": text.promoteTo, children: [_jsx("div", { class: "promotion-shade", onPointerDown: () => onPick(undefined) }), promotionPieces.map((piece, index) => (_jsx("button", { ref: index === 0 ? first : undefined, type: "button", class: "promotion-piece", style: { "--column": column, "--row": down ? index : 7 - index }, "aria-label": text.piece(piece.toUpperCase(), side), onClick: () => onPick(piece), children: _jsx(Piece, { letter: side === "white" ? piece.toUpperCase() : piece }) }, piece)))] }));
117
117
  }
@@ -96,10 +96,10 @@ export function useBoardPointer({ columns, ranks, pieces, player, selected, targ
96
96
  }
97
97
  };
98
98
  const landed = drag.back ? setTimeout(() => setDrag(undefined), reducedMotion() ? 0 : 260) : undefined;
99
- addEventListener("keydown", escape);
99
+ window.addEventListener("keydown", escape);
100
100
  document.documentElement.classList.add("dragging");
101
101
  return () => {
102
- removeEventListener("keydown", escape);
102
+ window.removeEventListener("keydown", escape);
103
103
  document.documentElement.classList.remove("dragging");
104
104
  clearTimeout(landed);
105
105
  };
@@ -6,10 +6,11 @@ import { accountSide } from "../../core/index.js";
6
6
  import { ResultTile } from "../game/result-tile.js";
7
7
  import { signature } from "../players/model-name.js";
8
8
  import { OutcomeBadge, outcomeOf } from "./outcome.js";
9
+ import { useRowKeys } from "./rows.js";
9
10
  import { searched } from "./search.js";
10
11
  import { playerIn, studiedSides } from "./studied.js";
11
12
  /** The list, read anew at each opening: the search, the row that loads a game, then a row for each game found, with its menu or its question. */
12
- export function Analyses({ onStudy, onLoad, you, accounts, ai, text }) {
13
+ export function Analyses({ onStudy, onLoad, you, accounts, others, ai, text }) {
13
14
  const words = text.study;
14
15
  const [analyses, reload] = useAnalyses();
15
16
  const [query, setQuery] = useState("");
@@ -17,13 +18,14 @@ export function Analyses({ onStudy, onLoad, you, accounts, ai, text }) {
17
18
  const [asking, setAsking] = useState(undefined);
18
19
  const [busy, setBusy] = useState(false);
19
20
  const [problem, setProblem] = useState(undefined);
21
+ const rowKeys = useRowKeys();
20
22
  useClosing(menu !== undefined, () => setMenu(undefined));
21
23
  useClosing(asking !== undefined, () => setAsking(undefined));
22
24
  const act = async (name, asked) => {
23
25
  setBusy(true);
24
26
  const refused = asked === "remove" ? await deletion("/api/delete-game", name) : await deletion("/api/delete-review", name);
25
- // Analysing again opens the game without its review: the chat's analyst goes over it (D-89).
26
- const after = refused === undefined && asked === "redo" ? await onStudy({ game: name }) : refused;
27
+ // Analysing again opens the game without its review: the chat's analyst goes over it (D-89), asked for by the player.
28
+ const after = refused === undefined && asked === "redo" ? await onStudy({ game: name, redo: true }) : refused;
27
29
  setBusy(false);
28
30
  setAsking(undefined);
29
31
  setProblem(after);
@@ -37,14 +39,14 @@ export function Analyses({ onStudy, onLoad, you, accounts, ai, text }) {
37
39
  });
38
40
  };
39
41
  const rows = analyses.map((item) => {
40
- const named = namedSides(item, you, accounts, ai, text);
42
+ const named = namedSides(item, you, accounts, others, ai, text);
41
43
  // How it went only for the player's own games: played here, or loaded and named by their name on the site (D-93).
42
44
  const played = item.name.startsWith("games/") ? named.you : accountSide(tagsOf(item), accounts);
43
45
  const outcome = played === undefined ? undefined : outcomeOf(item.result, played);
44
46
  return { item, named, outcome };
45
47
  });
46
48
  const found = searched(rows, query, ({ item, named, outcome }) => searchText(item, named.sides, outcome, text));
47
- 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
49
+ 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, ...rowKeys, children: found.map(({ item, named, outcome }) => asking?.name === item.name
48
50
  ? (_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))
49
51
  : (_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
50
52
  ? (_jsx(RowMenu, { anchor: menu.anchor, withReview: item.reviewed || item.begun === true, onPick: (asked) => {
@@ -98,9 +100,9 @@ function placeUnder(menu, anchor) {
98
100
  menu.style.left = `${Math.max(edge, button.right - box.width)}px`;
99
101
  }
100
102
  /** Who played a game listed, as its analysis names them, «Stefano – Opus 5.5 · xhigh», and the player's side when they played one. */
101
- function namedSides(item, you, accounts, ai, text) {
103
+ function namedSides(item, you, accounts, others, ai, text) {
102
104
  const model = item.aiModel === undefined ? ai : signature(item.aiModel.model, item.aiModel.effort);
103
- const sides = studiedSides(tagsOf(item), you, model, text.sides, accounts);
105
+ const sides = studiedSides(tagsOf(item), you, model, text.sides, accounts, others);
104
106
  const side = playerIn(sides);
105
107
  return { sides: `${sides.white.name} – ${sides.black.name}`, ...(side === undefined ? {} : { you: side }) };
106
108
  }
@@ -0,0 +1,72 @@
1
+ import { useLayoutEffect, useRef } from "preact/hooks";
2
+ /**
3
+ * Makes the list's rows one stop of the Tab: the row's button focused last, or the first row's, takes the Tab, the others only the arrows. Up and down go
4
+ * to the row above and below, Home and End to the first and the last, left and right between a row's own buttons. A row that asks a question in its place
5
+ * keeps its buttons in the Tab's order.
6
+ */
7
+ export function useRowKeys() {
8
+ const ref = useRef(null);
9
+ const current = useRef(undefined);
10
+ // After each render the rows may have changed, by a search or a game deleted: the one stop is the button focused last, if still there, or the first.
11
+ useLayoutEffect(() => {
12
+ if (ref.current !== null) {
13
+ setStop(rowsOf(ref.current).flat(), current.current);
14
+ }
15
+ });
16
+ const onFocusIn = (event) => {
17
+ const buttons = ref.current === null ? [] : rowsOf(ref.current).flat();
18
+ if (buttons.includes(event.target)) {
19
+ current.current = event.target;
20
+ setStop(buttons, current.current);
21
+ }
22
+ };
23
+ const onKeyDown = (event) => {
24
+ const rows = ref.current === null ? [] : rowsOf(ref.current);
25
+ const row = rows.findIndex((buttons) => buttons.includes(event.target));
26
+ if (row === -1) {
27
+ return;
28
+ }
29
+ const next = target(event.key, rows, row, rows[row].indexOf(event.target));
30
+ if (next !== undefined) {
31
+ event.preventDefault();
32
+ next.focus();
33
+ }
34
+ };
35
+ return { ref, onKeyDown, onFocusIn };
36
+ }
37
+ /** Gives the Tab to the button wanted, if it is still in the list, else to the first. */
38
+ function setStop(buttons, wanted) {
39
+ const stop = wanted !== undefined && buttons.includes(wanted) ? wanted : buttons[0];
40
+ for (const button of buttons) {
41
+ button.tabIndex = button === stop ? 0 : -1;
42
+ }
43
+ }
44
+ /** The button a key goes to from the row and column focused, or none for a key the list leaves to the page. */
45
+ function target(key, rows, row, column) {
46
+ const at = (index) => {
47
+ const buttons = rows[Math.max(0, Math.min(rows.length - 1, index))];
48
+ return buttons[Math.min(column, buttons.length - 1)];
49
+ };
50
+ switch (key) {
51
+ case "ArrowDown":
52
+ return at(row + 1);
53
+ case "ArrowUp":
54
+ return at(row - 1);
55
+ case "Home":
56
+ return at(0);
57
+ case "End":
58
+ return at(rows.length - 1);
59
+ case "ArrowRight":
60
+ return rows[row][Math.min(column + 1, rows[row].length - 1)];
61
+ case "ArrowLeft":
62
+ return rows[row][Math.max(column - 1, 0)];
63
+ default:
64
+ return undefined;
65
+ }
66
+ }
67
+ /** The rows' own buttons, row by row, those that can take the focus: the row's, and its menu's button, never a menu's or a question's. */
68
+ function rowsOf(list) {
69
+ return [...list.querySelectorAll(":scope > li:not([role])")]
70
+ .map((row) => [...row.querySelectorAll(":scope > button, :scope > .row-actions > button")].filter((button) => !button.disabled))
71
+ .filter((buttons) => buttons.length > 0);
72
+ }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-ru
3
3
  import { useEffect, useState } from "preact/hooks";
4
4
  import { Piece } from "../pieces/pieces.js";
5
5
  import { OutcomeBadge, outcomeOf } from "./outcome.js";
6
+ import { useRowKeys } from "./rows.js";
6
7
  /**
7
8
  * The list, asked of the server when the step opens and the profile has a name on a site. `picked` is the game being sent, whose row keeps the accent and
8
9
  * a spinner while the others wait; `problem`, why the server refused it, shown under the list; `onProfile` opens the profile, where the names go.
@@ -11,6 +12,7 @@ export function SiteGameList({ accounts, picked, problem, onPick, onProfile, tex
11
12
  const words = text.study;
12
13
  const named = accounts?.lichess !== undefined || accounts?.chesscom !== undefined;
13
14
  const answer = useSiteGames(named, words.siteGamesRefused);
15
+ const rowKeys = useRowKeys();
14
16
  if (!named) {
15
17
  return (_jsxs("div", { class: "field analyses site-games", children: [_jsx("span", { children: words.siteGames }), _jsxs("p", { class: "none", children: [`${words.siteGamesNoAccount} `, _jsx("button", { class: "add-names", type: "button", onClick: onProfile, children: words.siteGamesProfile })] })] }));
16
18
  }
@@ -20,7 +22,7 @@ export function SiteGameList({ accounts, picked, problem, onPick, onProfile, tex
20
22
  ? _jsx("p", { class: "site-problem", children: answer.refusal })
21
23
  : (_jsxs(_Fragment, { children: [_jsx(SiteProblems, { answer: answer, text: text }), answer.games.length === 0
22
24
  ? (answer.answered.length === 0 ? null : _jsx("p", { class: "none", children: words.siteGamesNone }))
23
- : (_jsx("ul", { class: `games${picked === undefined ? "" : " picking"}`, "aria-label": words.siteGames, children: answer.games.map((game) => _jsx(GameRow, { game: game, picked: picked, onPick: onPick, text: text }, game.name)) })), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem })] }))] }));
25
+ : (_jsx("ul", { class: `games${picked === undefined ? "" : " picking"}`, "aria-label": words.siteGames, ...rowKeys, children: answer.games.map((game) => _jsx(GameRow, { game: game, picked: picked, onPick: onPick, text: text }, game.name)) })), problem === undefined ? null : _jsx("p", { class: "problem", role: "alert", children: problem })] }))] }));
24
26
  }
25
27
  const siteNames = { "lichess": "Lichess", "chess.com": "Chess.com" };
26
28
  /** The sites that did not answer, both in one line, and those without a player by the profile's name, in the profile window's words. */
@@ -1,6 +1,10 @@
1
1
  // The players of a game in analysis (D-69), as the page shows them: a game played here names them Player and AI, one played elsewhere by their names; the
2
- // player's own, by their names on Lichess and chess.com (D-93).
2
+ // player's own, and another profile's, by their names on Lichess and chess.com (D-93), shown with the profile's name.
3
3
  import { accountSide } from "../../core/index.js";
4
+ /** The profiles here other than the one in use. */
5
+ export function othersOf(profiles, inUse) {
6
+ return (profiles ?? []).filter(({ id }) => id !== inUse);
7
+ }
4
8
  /** The player's side in a game in analysis: none when they played neither side, or both. */
5
9
  export function playerIn(sides) {
6
10
  const white = sides.white.who === "player";
@@ -8,16 +12,17 @@ export function playerIn(sides) {
8
12
  }
9
13
  /**
10
14
  * Both sides of a game in analysis, and the one at the bottom: the player's, when one of them is, else White. The player is the side named Player, or by
11
- * their name, or by their name on the site the game was played on (D-93).
15
+ * their name, or by their name on the site the game was played on (D-93); a side that is not theirs and bears another profile's name there is that profile.
12
16
  */
13
- export function studiedSides(tags, you, ai, sides, accounts) {
17
+ export function studiedSides(tags, you, ai, sides, accounts, others = []) {
14
18
  const sideOf = (side) => {
15
19
  const tag = (side === "white" ? tags.White : tags.Black)?.trim() ?? "";
16
- if (tag === "Player" || tag.toLowerCase() === you.trim().toLowerCase()) {
20
+ if (tag === "Player" || tag.toLowerCase() === you.trim().toLowerCase() || accountSide(tags, accounts) === side) {
17
21
  return { name: you, who: "player" };
18
22
  }
19
- if (accountSide(tags, accounts) === side) {
20
- return { name: tag, who: "player" };
23
+ const other = others.find((profile) => accountSide(tags, profile.accounts) === side);
24
+ if (other !== undefined) {
25
+ return { name: other.name, who: "other", profile: other.id };
21
26
  }
22
27
  // A tag PGN leaves unknown names the side.
23
28
  return tag === "AI" ? { name: ai, who: "ai" } : { name: /^[?\-\s]*$/.test(tag) ? sides[side] : tag, who: "other" };
@@ -9,7 +9,7 @@ import { SiteGameList } from "./site-games.js";
9
9
  * The dialog the header's button opens; `onStudy` sends the choice and gives back why the server refused it, or undefined once it took it. The games played
10
10
  * here name their sides as their analysis does: the player by `you`, the AI by its model when the game keeps it (D-82), else by `ai`.
11
11
  */
12
- export function StudyDialog({ open, onStudy, onClose, onProfile, you, accounts, ai, text }) {
12
+ export function StudyDialog({ open, onStudy, onClose, onProfile, you, accounts, others, ai, text }) {
13
13
  const [loading, setLoading] = useState(false);
14
14
  // Each opening starts from the list.
15
15
  useEffect(() => {
@@ -17,7 +17,7 @@ export function StudyDialog({ open, onStudy, onClose, onProfile, you, accounts,
17
17
  setLoading(false);
18
18
  }
19
19
  }, [open]);
20
- return (_jsx(Dialog, { open: open, title: loading ? text.study.bring : text.study.title, onBack: loading ? () => setLoading(false) : undefined, onClose: onClose, text: text, children: loading ? _jsx(LoadForm, { onStudy: onStudy, onProfile: onProfile, accounts: accounts, text: text }) : _jsx(Analyses, { onStudy: onStudy, onLoad: () => setLoading(true), you: you, accounts: accounts, ai: ai, text: text }) }));
20
+ return (_jsx(Dialog, { open: open, title: loading ? text.study.bring : text.study.title, onBack: loading ? () => setLoading(false) : undefined, onClose: onClose, text: text, children: loading ? _jsx(LoadForm, { onStudy: onStudy, onProfile: onProfile, accounts: accounts, text: text }) : _jsx(Analyses, { onStudy: onStudy, onLoad: () => setLoading(true), you: you, accounts: accounts, others: others, ai: ai, text: text }) }));
21
21
  }
22
22
  /** The step that loads a game played elsewhere: one of the latest on the sites, or its PGN pasted, or read from a file. */
23
23
  function LoadForm({ onStudy, onProfile, accounts, text }) {
@@ -129,8 +129,8 @@
129
129
  height: 14px;
130
130
  margin-top: 6px;
131
131
  border-radius: 7px;
132
- background: #3b4048;
133
- box-shadow: 0 0 0 1px rgb(138 144 153 / 0.6);
132
+ background: var(--side-black);
133
+ box-shadow: 0 0 0 1px var(--side-edge);
134
134
  overflow: hidden;
135
135
  }
136
136
 
@@ -139,7 +139,7 @@
139
139
  position: absolute;
140
140
  inset: 3px auto 3px calc(50% - 0.5px);
141
141
  width: 1px;
142
- background: #8a9099;
142
+ background: var(--side-both);
143
143
  }
144
144
 
145
145
  /* On a position after the one the AI estimated: the player's move, until the coach speaks again */
@@ -150,7 +150,7 @@
150
150
  .lead-bar .white {
151
151
  position: absolute;
152
152
  inset: 0 auto 0 0;
153
- background: #f5f5f2;
153
+ background: var(--side-white);
154
154
  transition: width 400ms ease;
155
155
  }
156
156
 
@@ -172,12 +172,12 @@
172
172
  .lead-bar .figure.white,
173
173
  .lead-bar .figure.even {
174
174
  left: 6px;
175
- color: #2a2f36;
175
+ color: var(--fixed-ink);
176
176
  }
177
177
 
178
178
  .lead-bar .figure.black {
179
179
  right: 6px;
180
- color: #f5f5f2;
180
+ color: var(--side-white);
181
181
  }
182
182
 
183
183
  /* The chart of the game in analysis: the white area is White's share, the line in the middle an even game; a dot on a move a badge marks, a line on the move
@@ -191,10 +191,13 @@
191
191
  position: relative;
192
192
  height: 72px;
193
193
  border-radius: 6px;
194
- background: #3b4048;
195
- box-shadow: 0 0 0 1px rgb(138 144 153 / 0.6);
194
+ background: var(--side-black);
195
+ box-shadow: 0 0 0 1px var(--side-edge);
196
196
  overflow: hidden;
197
197
  cursor: pointer;
198
+ /* A finger drawn across it runs through the game; up and down it scrolls the page */
199
+ touch-action: pan-y;
200
+ user-select: none;
198
201
  }
199
202
 
200
203
  .lead-chart svg {
@@ -204,7 +207,7 @@
204
207
  }
205
208
 
206
209
  .lead-chart .white-area {
207
- fill: #f5f5f2;
210
+ fill: var(--side-white);
208
211
  }
209
212
 
210
213
  .lead-chart .pending {
@@ -212,7 +215,7 @@
212
215
  }
213
216
 
214
217
  .lead-chart .even {
215
- stroke: #8a9099;
218
+ stroke: var(--side-both);
216
219
  stroke-width: 1;
217
220
  stroke-dasharray: 3 3;
218
221
  fill: none;
@@ -229,6 +232,11 @@
229
232
  pointer-events: none;
230
233
  }
231
234
 
235
+ /* The move a click would show, under the mouse: the same line, fainter (Zoe) */
236
+ .lead-chart .ghost {
237
+ opacity: 0.5;
238
+ }
239
+
232
240
  .lead-chart .dot {
233
241
  position: absolute;
234
242
  width: 8px;
@@ -394,6 +402,8 @@
394
402
  overflow: hidden auto;
395
403
  scrollbar-width: none;
396
404
  font-size: 14px;
405
+ /* A row too long for a phone never makes the page wider */
406
+ contain: inline-size;
397
407
  }
398
408
 
399
409
  .moves ol {
@@ -438,6 +448,11 @@
438
448
  cursor: pointer;
439
449
  }
440
450
 
451
+ /* A move never breaks, its piece always beside its square: a long one takes room from its neighbour in the row */
452
+ .moves .ply {
453
+ white-space: nowrap;
454
+ }
455
+
441
456
  /* The move under the pointer in a neutral grey, never the accent, so it never reads as the move shown (Stefano) */
442
457
  .moves .ply:hover {
443
458
  background: color-mix(in oklab, var(--ink) 9%, transparent);
@@ -552,6 +567,32 @@
552
567
  }
553
568
  }
554
569
 
570
+ /* On a phone the rows give the moves all the room they can, so that two columns hold a move with its badge on one line (Zoe) */
571
+ @media (max-width: 479px) {
572
+ .moves ol {
573
+ column-gap: 8px;
574
+ }
575
+
576
+ .moves li {
577
+ grid-template-columns: 22px 1fr 1fr;
578
+ column-gap: 4px;
579
+ padding: 3px 4px;
580
+ }
581
+
582
+ .moves .ply {
583
+ gap: 3px;
584
+ padding: 1px 4px;
585
+ margin-left: -4px;
586
+ }
587
+ }
588
+
589
+ /* On the narrowest phones two columns would leave a move no room: one column */
590
+ @media (max-width: 359px) {
591
+ .moves ol {
592
+ columns: 1;
593
+ }
594
+ }
595
+
555
596
  /* A card's title in the game's column, its icon in the accent as the opening's book: the helps' lifebuoy, the moves' score sheet (Stefano) */
556
597
  .card-title {
557
598
  display: flex;
@@ -48,14 +48,16 @@ function Move({ ply, current, onShow, text }) {
48
48
  // A screen reader reads the move in SAN and the AI's badge by its name; the eye sees each piece's letter as its figure, and the badge's disc (D-69).
49
49
  return (_jsxs("button", { type: "button", class: current ? "ply current" : "ply", "aria-current": current ? "step" : undefined, onClick: onShow, children: [_jsx("span", { class: "visually-hidden", children: ply.review?.badge === undefined ? ply.san : `${ply.san}, ${text.badges[ply.review.badge]}` }), _jsx("span", { "aria-hidden": "true", children: _jsx(Figurines, { san: ply.san, side: ply.side }) }), ply.review?.badge !== undefined ? _jsx(BadgeIcon, { badge: ply.review.badge }) : ply.review?.note !== undefined ? _jsx(NoteIcon, {}) : null] }));
50
50
  }
51
- /** Scrolls the list, and only the list, as little as it takes to show its current move. */
51
+ /** Scrolls the list, and only the list, as little as it takes to show the whole row of its current move, and the result under the last one. */
52
52
  function keepInSight(list) {
53
- const box = list?.getBoundingClientRect();
54
- const move = list?.querySelector(".current")?.getBoundingClientRect();
55
- if (box === undefined || move === undefined) {
53
+ const row = list?.querySelector(".current")?.closest("li");
54
+ if (list === null || !row) {
56
55
  return;
57
56
  }
58
- list.scrollTop += Math.min(0, move.top - box.top) + Math.max(0, move.bottom - box.bottom);
57
+ const box = list.getBoundingClientRect();
58
+ const top = row.getBoundingClientRect().top;
59
+ const bottom = (row.nextElementSibling === null ? list.lastElementChild ?? row : row).getBoundingClientRect().bottom;
60
+ list.scrollTop += Math.min(0, top - box.top) + Math.max(0, bottom - box.bottom);
59
61
  }
60
62
  /** The plies in rows of one number: White's move and Black's. */
61
63
  function rowsOf(plies) {
@@ -18,7 +18,7 @@ No chess engine plays for the AI: every move and every judgment is its own. Ches
18
18
 
19
19
  ### Getting started
20
20
 
21
- 1. Click the **AI's name**, at the top left, and choose its chat among those on your computer: **Claude Code** or **Codex**. It opens in a window of its own: keep it open while you play.
21
+ 1. Click the **AI's name**, at the top left, and choose its chat among those on your computer: **Claude Code** or **Codex**. It opens in a window of its own: keep it open while you play. The first time it asks some questions. Whether you trust the folder: **Yes**. With Codex, about the hooks, Chesspal's two helpers: **Trust all and continue**. On Windows Codex also asks whether to set up its sandbox, the protected space it works in: choose the first option, and yes when Windows asks for permission.
22
22
  2. Press **New game**, choose your colour, the clock and the AI's level, and press **Start**.
23
23
  3. Move: the AI answers on the board, and with a few words under its name.
24
24