@johnmorrisdotca/kumimoji 1.2.2 → 1.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/CONTRIBUTING.md +11 -2
  3. package/README.md +80 -39
  4. package/dist/check.d.ts +26 -1
  5. package/dist/check.js +30 -3
  6. package/dist/computer.types.d.ts +24 -2
  7. package/dist/computerPlay.d.ts +45 -2
  8. package/dist/computerPlay.js +56 -6
  9. package/dist/computerTurn.d.ts +42 -3
  10. package/dist/computerTurn.js +47 -5
  11. package/dist/daily.d.ts +22 -2
  12. package/dist/daily.js +23 -3
  13. package/dist/element-define.d.ts +1 -1
  14. package/dist/element-define.js +3 -13
  15. package/dist/element.d.ts +85 -4
  16. package/dist/element.js +359 -77
  17. package/dist/embed-day.d.ts +4 -0
  18. package/dist/embed-day.js +23 -0
  19. package/dist/export.d.ts +123 -6
  20. package/dist/export.js +109 -9
  21. package/dist/generate.d.ts +14 -0
  22. package/dist/generate.js +32 -5
  23. package/dist/grid.d.ts +181 -10
  24. package/dist/grid.js +148 -11
  25. package/dist/help.d.ts +36 -3
  26. package/dist/help.js +36 -3
  27. package/dist/index.d.ts +22 -22
  28. package/dist/index.js +22 -22
  29. package/dist/judge.d.ts +14 -0
  30. package/dist/judge.js +14 -0
  31. package/dist/kana.d.ts +23 -1
  32. package/dist/kana.js +62 -7
  33. package/dist/kumimoji.types.d.ts +70 -5
  34. package/dist/party.d.ts +268 -14
  35. package/dist/party.js +296 -19
  36. package/dist/party.types.d.ts +50 -2
  37. package/dist/partyKept.d.ts +74 -4
  38. package/dist/partyKept.js +127 -12
  39. package/dist/partySeats.d.ts +92 -6
  40. package/dist/partySeats.js +83 -8
  41. package/dist/partyTurns.d.ts +171 -5
  42. package/dist/partyTurns.js +187 -8
  43. package/dist/placement.d.ts +50 -2
  44. package/dist/placement.js +35 -2
  45. package/dist/play.d.ts +247 -15
  46. package/dist/play.js +251 -18
  47. package/dist/random.d.ts +34 -3
  48. package/dist/random.js +23 -2
  49. package/dist/react.d.ts +42 -4
  50. package/dist/react.js +54 -7
  51. package/dist/showcase.d.ts +83 -7
  52. package/dist/showcase.js +61 -7
  53. package/dist/strings.d.ts +54 -5
  54. package/dist/strings.js +34 -3
  55. package/dist/tableView.d.ts +151 -8
  56. package/dist/tableView.js +129 -6
  57. package/dist/tileFace.d.ts +59 -5
  58. package/dist/tileFace.js +48 -4
  59. package/dist/tileFamily.d.ts +9 -0
  60. package/dist/tileFamily.js +9 -0
  61. package/dist/tileWords.d.ts +93 -6
  62. package/dist/tileWords.js +94 -12
  63. package/dist/tileWordsModule.d.ts +12 -2
  64. package/dist/tileWordsModule.js +14 -2
  65. package/dist/tiles.constants.d.ts +99 -5
  66. package/dist/tiles.constants.js +171 -13
  67. package/dist/turn.d.ts +82 -4
  68. package/dist/turn.js +88 -5
  69. package/dist/ui/board.d.ts +60 -5
  70. package/dist/ui/board.js +44 -4
  71. package/dist/ui/draw.d.ts +62 -0
  72. package/dist/ui/draw.js +229 -0
  73. package/dist/ui/mount.d.ts +41 -2
  74. package/dist/ui/mount.js +190 -265
  75. package/dist/ui/style.d.ts +6 -1
  76. package/dist/ui/style.js +6 -0
  77. package/dist/ui.d.ts +3 -3
  78. package/dist/ui.js +2 -2
  79. package/dist/version.d.ts +9 -2
  80. package/dist/version.js +9 -2
  81. package/dist/wilds.d.ts +22 -1
  82. package/dist/wilds.js +25 -3
  83. package/package.json +68 -61
package/dist/ui/mount.js CHANGED
@@ -1,31 +1,12 @@
1
- import { kumimojiClock, kumimojiFromJSON, kumimojiToJSON, kumimojiToText } from "../export.js";
1
+ import { kumimojiFromJSON, kumimojiToJSON, kumimojiToText } from "../export.js";
2
2
  import { generateKumimoji } from "../generate.js";
3
3
  import { judgeWithWords } from "../judge.js";
4
- import { assignHandTile, deal, draw, isFinished, liftAll, liftToHand, mayDraw, mayTrade, moveOnTable, placeFromHand, sortHand, swapWithHand, tilesLeft, trade } from "../play.js";
5
- import { kumimojiSay, kumimojiStrings } from "../strings.js";
4
+ import { deal, isFinished, liftToHand, moveOnTable, placeFromHand, swapWithHand } from "../play.js";
5
+ import { kumimojiStrings } from "../strings.js";
6
6
  import { KUMIMOJI_HANDS } from "../tiles.constants.js";
7
- import { tileFace } from "../tileFace.js";
8
7
  import { loadTileWords } from "../tileWords.js";
9
- import { boardModel } from "./board.js";
8
+ import { button, node, tableDrawing } from "./draw.js";
10
9
  import { KUMIMOJI_STYLE } from "./style.js";
11
- /** What a wild nobody has given a letter yet shows in the hand. */
12
- const WILD_MARK = "★";
13
- function node(name, className, text) {
14
- const made = document.createElement(name);
15
- if (className !== undefined)
16
- made.className = className;
17
- if (text !== undefined)
18
- made.textContent = text;
19
- return made;
20
- }
21
- function button(text, onClick, primary = false, testId) {
22
- const made = node("button", primary ? "km-button km-primary" : "km-button", text);
23
- made.type = "button";
24
- if (testId !== undefined)
25
- made.dataset.testid = testId;
26
- made.addEventListener("click", onClick);
27
- return made;
28
- }
29
10
  /** Hand a string to the person as a file of that name. */
30
11
  function save(name, type, text) {
31
12
  const url = URL.createObjectURL(new Blob([text], { type }));
@@ -46,30 +27,60 @@ function save(name, type, text) {
46
27
  * game loads back.
47
28
  *
48
29
  * The word list is fetched the first time a game in its language is dealt.
30
+ * @param target - The host element for this table.
31
+ * @param options - Initial deal or saved state, presentation and callbacks.
32
+ * @returns The native handle, including ready and presentation updates without redealing.
33
+ * @example
34
+ * ```ts
35
+ * import { mountKumimoji } from "@johnmorrisdotca/kumimoji/ui";
36
+ * if (typeof document !== "undefined") {
37
+ * const table = mountKumimoji(document.createElement("div"), { keep: false });
38
+ * void table.ready.then(() => table.destroy());
39
+ * }
40
+ * console.log(typeof mountKumimoji);
41
+ * ```
49
42
  */
50
43
  export function mountKumimoji(target, options = {}) {
51
- let settings = { language: options.language, hand: options.hand, level: options.level, gameLength: options.gameLength, diagonals: options.diagonals, seed: options.seed };
52
- const pageLocale = () => (typeof document !== "undefined" && document.documentElement.lang.toLowerCase().startsWith("ja") ? "ja" : "en");
53
- let locale = options.locale ?? pageLocale();
54
- let own = options.strings ?? {};
55
- let say = kumimojiStrings(locale, own);
56
- let note = "";
57
- let words = null;
58
- let dealt = null;
59
- let play = null;
60
- let held = null;
44
+ let current = options;
45
+ let settings = {
46
+ language: current.language,
47
+ hand: current.hand,
48
+ level: current.level,
49
+ gameLength: current.gameLength,
50
+ diagonals: current.diagonals,
51
+ seed: current.seed,
52
+ };
53
+ const pageLocale = () => typeof document !== "undefined" && document.documentElement.lang.toLowerCase().startsWith("ja") ? "ja" : "en";
54
+ let own = current.strings ?? {};
55
+ const openingLocale = current.locale ?? pageLocale();
61
56
  // Where a keyboard is: the square and the hand tile that Tab lands on, and the one the arrow keys move from.
62
- let onSquare = null;
63
- let onHand = 0;
64
- let started = 0;
65
- let finishedAt = null;
66
- let destroyed = false;
57
+ const session = {
58
+ play: null,
59
+ finishedAt: null,
60
+ started: 0,
61
+ words: null,
62
+ held: null,
63
+ onSquare: null,
64
+ onHand: 0,
65
+ say: kumimojiStrings(openingLocale, own),
66
+ locale: openingLocale,
67
+ note: "",
68
+ destroyed: false,
69
+ };
70
+ let dealt = null;
67
71
  // Each deal and each game put on the table takes a number; one that is overtaken while its list loads gives way.
68
72
  let asked = 0;
73
+ // The drawing is built once the elements exist. Until then nothing calls these.
74
+ const bridge = {
75
+ render: () => { },
76
+ renderStatus: () => { },
77
+ newGame: () => { },
78
+ };
79
+ const render = () => bridge.render();
69
80
  const root = node("div", "km-root");
70
81
  root.dataset.testid = "km-root";
71
82
  root.dataset.state = "dealing";
72
- for (const [name, value] of Object.entries(options.theme ?? {}))
83
+ for (const [name, value] of Object.entries(current.theme ?? {}))
73
84
  root.style.setProperty(name, value);
74
85
  const style = node("style");
75
86
  style.textContent = KUMIMOJI_STYLE;
@@ -87,14 +98,20 @@ export function mountKumimoji(target, options = {}) {
87
98
  const picker = node("div", "km-picker");
88
99
  picker.dataset.testid = "km-picker";
89
100
  const controls = node("div", "km-controls");
101
+ controls.hidden = current.controls === false;
90
102
  controls.dataset.testid = "km-controls";
91
103
  const keep = node("details", "km-keep");
92
104
  keep.dataset.testid = "km-keep";
93
105
  root.append(style, status, board, notes, picker, hand, controls);
94
- if (options.keep !== false)
106
+ if (current.keep !== false)
95
107
  root.append(keep);
96
108
  target.append(root);
97
- const savedNow = () => (play === null || dealt === null ? null : { deal: dealt, play, elapsedMs: (finishedAt ?? Date.now()) - started });
109
+ const savedNow = () => {
110
+ const now = session.play;
111
+ if (now === null || dealt === null)
112
+ return null;
113
+ return { deal: dealt, play: now, elapsedMs: (session.finishedAt ?? Date.now()) - session.started };
114
+ };
98
115
  // The saving and loading are made once, so the fold stays open or shut as the person left it; only their words change.
99
116
  const keepTitle = node("summary", "km-keep-title");
100
117
  const keepNote = node("p", "km-note");
@@ -107,15 +124,15 @@ export function mountKumimoji(target, options = {}) {
107
124
  file.dataset.testid = "km-file";
108
125
  const read = async (text) => {
109
126
  const loaded = await kumimojiFromJSON(text).catch(() => null);
110
- if (destroyed)
127
+ if (session.destroyed)
111
128
  return;
112
129
  if (loaded === null) {
113
- note = say.loadBad;
130
+ session.note = session.say.loadBad;
114
131
  render();
115
132
  return;
116
133
  }
117
134
  await handle.setGame(loaded);
118
- note = say.loaded;
135
+ session.note = session.say.loaded;
119
136
  render();
120
137
  };
121
138
  file.addEventListener("change", () => {
@@ -133,60 +150,59 @@ export function mountKumimoji(target, options = {}) {
133
150
  const saveText = button("", () => {
134
151
  const now = savedNow();
135
152
  if (now !== null)
136
- save(`kumimoji-${now.deal.seed}.txt`, "text/plain", kumimojiToText(now, say));
153
+ save(`kumimoji-${now.deal.seed}.txt`, "text/plain", kumimojiToText(now, session.say));
137
154
  }, false, "km-save-text");
138
155
  const load = button("", () => file.click(), false, "km-load");
139
156
  const keepButtons = node("div", "km-controls");
140
157
  keepButtons.append(saveJson, saveText, load);
141
158
  keep.append(keepTitle, keepButtons, keepNote, file);
142
- const tick = setInterval(() => {
143
- if (play !== null && finishedAt === null)
144
- renderStatus();
145
- }, 1000);
146
- const verdictOf = (now) => judgeWithWords(now.tiles, words, { diagonals: settings.diagonals === true });
159
+ const verdictOf = (now) => judgeWithWords(now.tiles, session.words, { diagonals: settings.diagonals === true });
147
160
  const change = (next) => {
148
- play = next;
161
+ session.play = next;
149
162
  const verdict = verdictOf(next);
150
- if (finishedAt === null && isFinished(next, verdict)) {
151
- finishedAt = Date.now();
152
- options.onFinish?.({ deal: dealt, play: next, elapsedMs: finishedAt - started });
163
+ if (session.finishedAt === null && isFinished(next, verdict)) {
164
+ session.finishedAt = Date.now();
165
+ current.onFinish?.({ deal: dealt, play: next, elapsedMs: session.finishedAt - session.started });
153
166
  }
154
- note = "";
167
+ session.note = "";
155
168
  const now = savedNow();
156
169
  if (now !== null)
157
- options.onChange?.(now);
170
+ current.onChange?.(now);
158
171
  render();
159
172
  };
160
173
  const tapSquare = (square) => {
161
- if (play === null || finishedAt !== null)
174
+ const now = session.play;
175
+ if (now === null || session.finishedAt !== null)
162
176
  return;
163
- const there = play.tiles.get(square);
177
+ const there = now.tiles.get(square);
178
+ const held = session.held;
164
179
  if (held === null) {
165
180
  if (there !== undefined)
166
- held = { from: "table", square };
181
+ session.held = { from: "table", square };
167
182
  render();
168
183
  return;
169
184
  }
170
185
  if (held.from === "hand") {
171
186
  const at = held.at;
172
- const tile = play.hand[at];
187
+ const tile = now.hand[at];
173
188
  if (tile === undefined)
174
189
  return;
175
190
  // A wild is given a letter before it is laid.
176
191
  if (tile === "*")
177
192
  return;
178
- held = null;
179
- change(there === undefined ? placeFromHand(play, at, square) : swapWithHand(play, at, square));
193
+ session.held = null;
194
+ change(there === undefined ? placeFromHand(now, at, square) : swapWithHand(now, at, square));
180
195
  return;
181
196
  }
182
197
  const from = held.square;
183
- held = null;
184
- change(from === square ? liftToHand(play, square) : moveOnTable(play, from, square));
198
+ session.held = null;
199
+ change(from === square ? liftToHand(now, square) : moveOnTable(now, from, square));
185
200
  };
186
201
  const tapHand = (at) => {
187
- if (play === null || finishedAt !== null)
202
+ if (session.play === null || session.finishedAt !== null)
188
203
  return;
189
- held = held?.from === "hand" && held.at === at ? null : { from: "hand", at };
204
+ const held = session.held;
205
+ session.held = held?.from === "hand" && held.at === at ? null : { from: "hand", at };
190
206
  render();
191
207
  };
192
208
  board.addEventListener("click", (event) => {
@@ -209,7 +225,23 @@ export function mountKumimoji(target, options = {}) {
209
225
  const cells = [...grid.children];
210
226
  const cols = Number(grid.dataset.cols);
211
227
  const at = cells.indexOf(cell);
212
- const step = event.key === "ArrowLeft" ? (at % cols > 0 ? -1 : 0) : event.key === "ArrowRight" ? (at % cols < cols - 1 ? 1 : 0) : event.key === "ArrowUp" ? (at - cols >= 0 ? -cols : 0) : event.key === "ArrowDown" ? (at + cols < cells.length ? cols : 0) : null;
228
+ const step = event.key === "ArrowLeft"
229
+ ? at % cols > 0
230
+ ? -1
231
+ : 0
232
+ : event.key === "ArrowRight"
233
+ ? at % cols < cols - 1
234
+ ? 1
235
+ : 0
236
+ : event.key === "ArrowUp"
237
+ ? at - cols >= 0
238
+ ? -cols
239
+ : 0
240
+ : event.key === "ArrowDown"
241
+ ? at + cols < cells.length
242
+ ? cols
243
+ : 0
244
+ : null;
213
245
  if (step === null)
214
246
  return;
215
247
  event.preventDefault();
@@ -218,18 +250,18 @@ export function mountKumimoji(target, options = {}) {
218
250
  return;
219
251
  cell.tabIndex = -1;
220
252
  next.tabIndex = 0;
221
- onSquare = next.dataset.square ?? null;
253
+ session.onSquare = next.dataset.square ?? null;
222
254
  next.focus();
223
255
  });
224
256
  board.addEventListener("focusin", (event) => {
225
257
  const square = event.target.closest("[data-square]")?.getAttribute("data-square");
226
258
  if (square !== null && square !== undefined)
227
- onSquare = square;
259
+ session.onSquare = square;
228
260
  });
229
261
  hand.addEventListener("focusin", (event) => {
230
262
  const at = event.target.closest("[data-hand]")?.getAttribute("data-hand");
231
263
  if (at !== null && at !== undefined)
232
- onHand = Number(at);
264
+ session.onHand = Number(at);
233
265
  });
234
266
  hand.addEventListener("keydown", (event) => {
235
267
  const tile = event.target.closest("[data-hand]");
@@ -237,7 +269,15 @@ export function mountKumimoji(target, options = {}) {
237
269
  return;
238
270
  const tiles = [...hand.querySelectorAll("[data-hand]")];
239
271
  const at = tiles.indexOf(tile);
240
- const to = event.key === "ArrowLeft" ? at - 1 : event.key === "ArrowRight" ? at + 1 : event.key === "Home" ? 0 : event.key === "End" ? tiles.length - 1 : null;
272
+ const to = event.key === "ArrowLeft"
273
+ ? at - 1
274
+ : event.key === "ArrowRight"
275
+ ? at + 1
276
+ : event.key === "Home"
277
+ ? 0
278
+ : event.key === "End"
279
+ ? tiles.length - 1
280
+ : null;
241
281
  if (to === null)
242
282
  return;
243
283
  event.preventDefault();
@@ -246,212 +286,83 @@ export function mountKumimoji(target, options = {}) {
246
286
  return;
247
287
  tile.tabIndex = -1;
248
288
  next.tabIndex = 0;
249
- onHand = to;
289
+ session.onHand = to;
250
290
  next.focus();
251
291
  });
252
292
  root.addEventListener("keydown", (event) => {
253
- if (event.key !== "Escape" || held === null)
293
+ if (event.key !== "Escape" || session.held === null)
254
294
  return;
255
- held = null;
295
+ session.held = null;
256
296
  render();
257
297
  });
258
- function renderStatus() {
259
- root.dataset.state = play === null ? "dealing" : finishedAt !== null ? "finished" : "playing";
260
- if (play === null) {
261
- status.textContent = say.dealing;
262
- return;
263
- }
264
- const left = tilesLeft(play);
265
- root.dataset.left = String(left);
266
- const time = kumimojiClock((finishedAt ?? Date.now()) - started);
267
- status.textContent = finishedAt !== null ? kumimojiSay(say.finished, { time }) : kumimojiSay(left === 1 ? say.leftOne : say.left, { n: left, time });
268
- }
269
- function renderBoard(verdict) {
270
- const model = boardModel(play.tiles, play.tiles.size === 0 ? null : verdict, words.glyphOf);
271
- const grid = node("div", "km-grid");
272
- grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
273
- grid.setAttribute("role", "group");
274
- grid.setAttribute("aria-label", say.tableLabel);
275
- grid.setAttribute("aria-description", say.tableKeys);
276
- grid.dataset.cols = String(model.cols);
277
- const cells = [];
278
- for (const square of model.squares) {
279
- const cell = node("button", "km-square");
280
- cell.type = "button";
281
- cell.dataset.square = square.square;
282
- if (square.tile !== null) {
283
- cell.classList.add("km-tile");
284
- if (square.wild)
285
- cell.classList.add("km-wild");
286
- if (square.mark !== null)
287
- cell.classList.add(`km-${square.mark}`);
288
- cell.textContent = square.glyph;
289
- cell.dataset.tile = square.glyph;
290
- cell.setAttribute("aria-label", square.wild ? kumimojiSay(say.wildTile, { tile: square.glyph }) : square.glyph);
291
- }
292
- else {
293
- cell.setAttribute("aria-label", say.emptySquare);
294
- }
295
- if (held?.from === "table" && held.square === square.square)
296
- cell.classList.add("km-held");
297
- cell.tabIndex = -1;
298
- cells.push(cell);
299
- grid.append(cell);
300
- }
301
- // The one Tab lands on: where the keyboard was, else a tile, else the middle.
302
- const stop = cells.find((cell) => cell.dataset.square === onSquare) ?? cells.find((cell) => cell.classList.contains("km-tile")) ?? cells[Math.floor(cells.length / 2)];
303
- if (stop !== undefined) {
304
- stop.tabIndex = 0;
305
- onSquare = stop.dataset.square ?? null;
306
- }
307
- board.replaceChildren(grid);
308
- notes.textContent = verdict.notWords.length > 0 ? kumimojiSay(say.notWords, { words: verdict.notWords.join(", ") }) : verdict.apart.size > 0 ? say.apart : "";
309
- }
310
- function renderHand() {
311
- hand.replaceChildren();
312
- hand.setAttribute("aria-label", say.handLabel);
313
- play.hand.forEach((tile, at) => {
314
- const face = tileFace(tile);
315
- const made = node("button", "km-tile km-in-hand");
316
- made.type = "button";
317
- made.dataset.hand = String(at);
318
- made.textContent = face.blank ? WILD_MARK : words.glyphOf(tile);
319
- if (face.wild)
320
- made.classList.add("km-wild");
321
- if (held?.from === "hand" && held.at === at)
322
- made.classList.add("km-held");
323
- made.dataset.tile = face.blank ? "*" : words.glyphOf(tile);
324
- made.setAttribute("aria-label", face.blank ? say.wildBlank : face.wild ? kumimojiSay(say.wildTile, { tile: words.glyphOf(tile) }) : words.glyphOf(tile));
325
- made.tabIndex = at === Math.min(onHand, play.hand.length - 1) ? 0 : -1;
326
- hand.append(made);
327
- });
328
- if (play.hand.length === 0 && finishedAt === null)
329
- hand.append(node("span", "km-empty", say.noTiles));
330
- }
331
- function renderPicker() {
332
- picker.replaceChildren();
333
- if (held?.from !== "hand" || play === null)
334
- return;
335
- const at = held.at;
336
- const tile = play.hand[at];
337
- if (tile === undefined || !tileFace(tile).wild)
338
- return;
339
- picker.append(node("span", "km-picker-title", tile === "*" ? say.giveWild : say.changeWild));
340
- for (const sound of words.wildOptions) {
341
- const code = words.wildFor(sound);
342
- if (code === null)
343
- continue;
344
- picker.append(button(sound, () => {
345
- held = { from: "hand", at };
346
- change(assignHandTile(play, at, code));
347
- }));
348
- }
349
- }
350
- function renderControls(verdict) {
351
- controls.replaceChildren();
352
- if (finishedAt !== null) {
353
- controls.append(button(say.newGame, () => void handle.newGame(), true, "km-new"));
354
- return;
355
- }
356
- const now = play;
357
- const drawButton = button(say.draw, () => {
358
- held = null;
359
- change(draw(now));
360
- }, true, "km-draw");
361
- drawButton.disabled = !mayDraw(now, verdict);
362
- const tradeButton = button(say.trade, () => {
363
- if (held?.from !== "hand")
364
- return;
365
- const at = held.at;
366
- held = null;
367
- change(trade(now, at));
368
- }, false, "km-trade");
369
- tradeButton.disabled = held?.from !== "hand" || !mayTrade(now);
370
- controls.append(drawButton, tradeButton, button(say.sort, () => change(sortHand(now)), false, "km-sort"), button(say.liftAll, () => {
371
- held = null;
372
- change(liftAll(now));
373
- }, false, "km-lift"), button(say.newGame, () => void handle.newGame(), false, "km-new"));
374
- }
375
- function renderKeep() {
376
- keepTitle.textContent = say.keep;
377
- saveJson.textContent = say.saveJson;
378
- saveText.textContent = say.saveText;
379
- load.textContent = say.load;
380
- saveJson.disabled = play === null;
381
- saveText.disabled = play === null;
382
- keepNote.textContent = note;
383
- }
384
- /** Where the keyboard is, so that it can be put back after everything is drawn again: the zone, and which of it. */
385
- function keyboardAt() {
386
- const on = document.activeElement;
387
- if (on === null || !root.contains(on))
388
- return null;
389
- const mark = (zone, id) => (id === undefined ? null : { zone, id });
390
- if (board.contains(on))
391
- return mark("board", on.dataset.square);
392
- if (hand.contains(on))
393
- return mark("hand", on.dataset.hand);
394
- if (controls.contains(on))
395
- return mark("controls", on.dataset.testid);
396
- if (picker.contains(on))
397
- return mark("picker", String([...picker.querySelectorAll("button")].indexOf(on)));
398
- return null;
399
- }
400
- function keyboardBack(at) {
401
- if (at === null)
402
- return;
403
- const zone = { board, hand, controls, picker }[at.zone];
404
- const found = at.zone === "board" ? zone.querySelector(`[data-square="${at.id}"]`) : at.zone === "hand" ? zone.querySelector(`[data-hand="${at.id}"]`) : at.zone === "controls" ? zone.querySelector(`[data-testid="${at.id}"]`) : zone.querySelectorAll("button")[Number(at.id)];
405
- // A button that has become disabled cannot hold the focus; the table keeps it rather than dropping it on the page.
406
- if (found !== undefined && found !== null && !found.disabled)
407
- found.focus({ preventScroll: true });
408
- }
409
- function render() {
410
- if (destroyed)
411
- return;
412
- const where = keyboardAt();
413
- root.lang = locale;
414
- renderStatus();
415
- renderKeep();
416
- if (play === null || words === null)
417
- return;
418
- const verdict = verdictOf(play);
419
- renderBoard(verdict);
420
- renderPicker();
421
- renderHand();
422
- renderControls(verdict);
423
- keyboardBack(where);
424
- }
298
+ const drawing = tableDrawing(session, { root, status, board, notes, hand, picker, controls, keepTitle, saveJson, saveText, load, keepNote }, { verdictOf, change, newGame: () => bridge.newGame() });
299
+ bridge.render = () => drawing.render();
300
+ bridge.renderStatus = () => drawing.renderStatus();
301
+ const tick = setInterval(() => {
302
+ if (session.play !== null && session.finishedAt === null)
303
+ bridge.renderStatus();
304
+ }, 1000);
305
+ let initialReady = Promise.resolve();
425
306
  const handle = {
426
- play: () => play,
307
+ get ready() {
308
+ return initialReady;
309
+ },
310
+ set: (changes) => {
311
+ for (const name of Object.keys(current.theme ?? {}))
312
+ if (changes.theme !== undefined && !(name in changes.theme))
313
+ root.style.removeProperty(name);
314
+ if (changes.theme !== undefined)
315
+ for (const [name, value] of Object.entries(changes.theme))
316
+ root.style.setProperty(name, value);
317
+ current = { ...current, ...changes };
318
+ if (changes.locale !== undefined)
319
+ session.locale = changes.locale;
320
+ if (changes.strings !== undefined)
321
+ own = changes.strings;
322
+ session.say = kumimojiStrings(session.locale, own);
323
+ if (current.keep === false)
324
+ keep.remove();
325
+ else if (keep.parentNode !== root)
326
+ root.append(keep);
327
+ controls.hidden = current.controls === false;
328
+ render();
329
+ },
330
+ play: () => session.play,
427
331
  saved: savedNow,
428
332
  setGame: async (saved) => {
429
333
  const mine = ++asked;
430
334
  const language = saved.deal.language;
431
- settings = { language, hand: saved.deal.size, level: saved.deal.level, gameLength: saved.deal.gameLength, diagonals: saved.deal.diagonals === true, seed: undefined };
335
+ settings = {
336
+ language,
337
+ hand: saved.deal.size,
338
+ level: saved.deal.level,
339
+ gameLength: saved.deal.gameLength,
340
+ diagonals: saved.deal.diagonals === true,
341
+ seed: undefined,
342
+ };
432
343
  const list = await loadTileWords(language);
433
- if (destroyed || mine !== asked)
344
+ if (session.destroyed || mine !== asked)
434
345
  return;
435
- words = list;
346
+ session.words = list;
436
347
  root.classList.toggle("km-japanese", language === "japanese");
437
348
  dealt = saved.deal;
438
349
  root.dataset.seed = String(saved.deal.seed);
439
- held = null;
440
- finishedAt = null;
441
- started = Date.now() - saved.elapsedMs;
350
+ session.held = null;
351
+ session.finishedAt = null;
352
+ session.started = Date.now() - saved.elapsedMs;
442
353
  // A game loaded already finished stays finished, at the time it took, and is not announced again.
443
- if (isFinished(saved.play, judgeWithWords(saved.play.tiles, words, { diagonals: settings.diagonals === true })))
444
- finishedAt = Date.now();
445
- play = saved.play;
446
- note = "";
447
- options.onChange?.(saved);
354
+ if (isFinished(saved.play, judgeWithWords(saved.play.tiles, list, { diagonals: settings.diagonals === true })))
355
+ session.finishedAt = Date.now();
356
+ session.play = saved.play;
357
+ session.note = "";
358
+ current.onChange?.(saved);
448
359
  render();
449
360
  },
450
361
  setLocale: (next, strings) => {
451
- locale = next;
362
+ session.locale = next;
452
363
  if (strings !== undefined)
453
364
  own = strings;
454
- say = kumimojiStrings(locale, own);
365
+ session.say = kumimojiStrings(session.locale, own);
455
366
  render();
456
367
  },
457
368
  newGame: async (changed = {}) => {
@@ -459,29 +370,43 @@ export function mountKumimoji(target, options = {}) {
459
370
  settings = { ...settings, ...changed, seed: undefined };
460
371
  const language = settings.language ?? "english";
461
372
  const size = settings.hand ?? KUMIMOJI_HANDS.quick;
462
- play = null;
463
- held = null;
464
- finishedAt = null;
465
- note = "";
373
+ session.play = null;
374
+ session.held = null;
375
+ session.finishedAt = null;
376
+ session.note = "";
466
377
  render();
467
378
  const list = await loadTileWords(language);
468
- if (destroyed || mine !== asked)
379
+ if (session.destroyed || mine !== asked)
469
380
  return;
470
- words = list;
381
+ session.words = list;
471
382
  // A seed deals one game: the next is dealt from a new one unless asked for by seed again.
472
383
  const seed = changed.seed ?? Math.floor(Math.random() * 2 ** 31);
473
384
  root.classList.toggle("km-japanese", language === "japanese");
474
385
  root.dataset.seed = String(seed);
475
- dealt = generateKumimoji(size, settings.level ?? "medium", seed, { language, gameLength: settings.gameLength, diagonals: settings.diagonals });
476
- started = Date.now();
386
+ dealt = generateKumimoji(size, settings.level ?? "medium", seed, {
387
+ language,
388
+ gameLength: settings.gameLength,
389
+ diagonals: settings.diagonals,
390
+ });
391
+ session.started = Date.now();
477
392
  change(deal(dealt.givens, size));
478
393
  },
479
394
  destroy: () => {
480
- destroyed = true;
395
+ session.destroyed = true;
481
396
  clearInterval(tick);
482
397
  root.remove();
483
398
  },
484
399
  };
485
- void handle.newGame(settings);
400
+ bridge.newGame = () => {
401
+ void handle.newGame();
402
+ };
403
+ initialReady = current.saved === undefined ? handle.newGame(settings) : handle.setGame(current.saved);
404
+ void initialReady.catch((error) => {
405
+ if (session.destroyed)
406
+ return;
407
+ const problem = error instanceof Error ? error : new Error(String(error));
408
+ status.textContent = session.say.loadError;
409
+ current.onError?.(problem);
410
+ });
486
411
  return handle;
487
412
  }
@@ -3,5 +3,10 @@
3
3
  * variable so a page can wear it in its own colours (set them on the element
4
4
  * or any ancestor, or pass them as `theme`). Light and dark follow the
5
5
  * reader's system. Everything to be tapped is at least 44px.
6
+ * @example
7
+ * ```ts
8
+ * import { KUMIMOJI_STYLE } from "@johnmorrisdotca/kumimoji/ui";
9
+ * console.log(KUMIMOJI_STYLE.includes(".km-root")); // true
10
+ * ```
6
11
  */
7
- export declare const KUMIMOJI_STYLE = "\n.km-root {\n --km-square: 44px; --km-felt: #2f5d4a; --km-grid-line: rgba(255,255,255,.12); --km-tile: #f3e6c8; --km-tile-ink: #2a2118;\n --km-wild: #f6d27a; --km-wrong: #d9534f; --km-held: #ffcf3f; --km-ink: #1f2320; --km-panel: #f7f3ea;\n --km-line: rgba(20,20,20,.35); --km-accent: #2f5d4a; --km-accent-ink: #fff;\n --km-radius: 12px; --km-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n display: grid; gap: 10px; min-width: 0; color: var(--km-ink); font-family: var(--km-font); font-size: 15px;\n}\n@media (prefers-color-scheme: dark) {\n .km-root { --km-felt: #1e3a2f; --km-ink: #ece8dc; --km-panel: #1d201e; --km-line: rgba(255,255,255,.25); --km-accent: #6fb08f; --km-accent-ink: #10150f; }\n}\n.km-root > * { min-width: 0; }\n.km-status { margin: 0; min-height: 1.4em; font-variant-numeric: tabular-nums; }\n.km-board { background: var(--km-felt); border-radius: var(--km-radius); padding: 10px; overflow: auto; max-height: 70vh; }\n.km-grid { display: grid; gap: 2px; width: max-content; margin: 0 auto; }\n.km-square { width: var(--km-square); height: var(--km-square); border: 0; border-radius: 5px; background: var(--km-grid-line); padding: 0; cursor: pointer; touch-action: manipulation; }\n.km-tile { display: grid; place-items: center; width: var(--km-square); height: var(--km-square); border-radius: 6px; border: 0; padding: 0;\n background: var(--km-tile); color: var(--km-tile-ink); font: 700 calc(var(--km-square) * .55)/1 var(--km-font); text-transform: uppercase;\n box-shadow: 0 2px 0 rgba(0,0,0,.35); cursor: pointer; touch-action: manipulation; }\n.km-japanese .km-tile { font-size: calc(var(--km-square) * .52); text-transform: none; }\n.km-wild { background: var(--km-wild); }\n.km-misspelt { box-shadow: 0 0 0 3px var(--km-wrong) inset, 0 2px 0 rgba(0,0,0,.35); }\n.km-apart { opacity: .6; }\n.km-held { outline: 3px solid var(--km-held); outline-offset: 1px; }\n.km-square:focus-visible, .km-in-hand:focus-visible, .km-button:focus-visible, .km-keep-title:focus-visible { outline: 3px solid var(--km-held); outline-offset: 2px; }\n.km-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; overflow-wrap: anywhere; }\n.km-hand { display: flex; flex-wrap: wrap; gap: 6px; min-height: calc(var(--km-square) + 16px); align-items: center; padding: 8px; border-radius: 10px; background: var(--km-panel); }\n.km-empty { opacity: .6; font-size: 14px; }\n.km-picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }\n.km-picker:empty { display: none; }\n.km-picker-title { width: 100%; font-size: 14px; }\n.km-picker .km-button { padding: 0 8px; text-transform: uppercase; }\n.km-japanese .km-picker .km-button { text-transform: none; }\n.km-controls { display: flex; flex-wrap: wrap; gap: 8px; }\n.km-button { font: inherit; min-height: 44px; min-width: 44px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; touch-action: manipulation; }\n.km-button:disabled { opacity: .45; cursor: default; }\n.km-primary { background: var(--km-accent); color: var(--km-accent-ink); border-color: transparent; }\n.km-keep { border-radius: 10px; background: var(--km-panel); padding: 0 10px; }\n.km-keep[open] { padding-bottom: 10px; }\n.km-keep-title { min-height: 44px; display: flex; align-items: center; font-weight: 600; cursor: pointer; }\n.km-note { margin: 8px 0 0; min-height: 1.4em; font-size: 13px; }\n";
12
+ export declare const KUMIMOJI_STYLE = "\n.km-root {\n --km-square: 44px; --km-felt: #2f5d4a; --km-grid-line: rgba(255,255,255,.12); --km-tile: #f3e6c8; --km-tile-ink: #2a2118;\n --km-wild: #f6d27a; --km-wrong: #d9534f; --km-held: #ffcf3f; --km-ink: #1f2320; --km-panel: #f7f3ea;\n --km-line: rgba(20,20,20,.35); --km-accent: #2f5d4a; --km-accent-ink: #fff;\n --km-radius: 12px; --km-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n display: grid; gap: 10px; min-width: 0; color: var(--km-ink); font-family: var(--km-font); font-size: 15px;\n}\n@media (prefers-color-scheme: dark) {\n .km-root { --km-felt: #1e3a2f; --km-ink: #ece8dc; --km-panel: #1d201e; --km-line: rgba(255,255,255,.25); --km-accent: #6fb08f; --km-accent-ink: #10150f; }\n}\n.km-root > * { min-width: 0; }\n.km-status { margin: 0; min-height: 1.4em; font-variant-numeric: tabular-nums; }\n.km-board { background: var(--km-felt); border-radius: var(--km-radius); padding: 10px; overflow: auto; max-height: 70vh; }\n.km-grid { display: grid; gap: 2px; width: max-content; margin: 0 auto; }\n.km-square { width: var(--km-square); height: var(--km-square); border: 0; border-radius: 5px; background: var(--km-grid-line); padding: 0; cursor: pointer; touch-action: manipulation; }\n.km-tile { display: grid; place-items: center; width: var(--km-square); height: var(--km-square); border-radius: 6px; border: 0; padding: 0;\n background: var(--km-tile); color: var(--km-tile-ink); font: 700 calc(var(--km-square) * .55)/1 var(--km-font); text-transform: uppercase;\n box-shadow: 0 2px 0 rgba(0,0,0,.35); cursor: pointer; touch-action: manipulation; }\n.km-japanese .km-tile { font-size: calc(var(--km-square) * .52); text-transform: none; }\n.km-wild { background: var(--km-wild); }\n.km-misspelt { box-shadow: 0 0 0 3px var(--km-wrong) inset, 0 2px 0 rgba(0,0,0,.35); }\n.km-apart { opacity: .6; }\n.km-held { outline: 3px solid var(--km-held); outline-offset: 1px; }\n.km-square:focus-visible, .km-in-hand:focus-visible, .km-button:focus-visible, .km-keep-title:focus-visible { outline: 3px solid var(--km-held); outline-offset: 2px; }\n.km-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; overflow-wrap: anywhere; }\n.km-hand { display: flex; flex-wrap: wrap; gap: 6px; min-height: calc(var(--km-square) + 16px); align-items: center; padding: 8px; border-radius: 10px; background: var(--km-panel); }\n.km-empty { opacity: .6; font-size: 14px; }\n.km-picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }\n.km-picker:empty { display: none; }\n.km-picker-title { width: 100%; font-size: 14px; }\n.km-picker .km-button { padding: 0 8px; text-transform: uppercase; }\n.km-japanese .km-picker .km-button { text-transform: none; }\n.km-controls[hidden] { display: none; }\n.km-controls { display: flex; flex-wrap: wrap; gap: 8px; }\n.km-button { font: inherit; min-height: 44px; min-width: 44px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; touch-action: manipulation; }\n.km-button:disabled { opacity: .45; cursor: default; }\n.km-primary { background: var(--km-accent); color: var(--km-accent-ink); border-color: transparent; }\n.km-keep { border-radius: 10px; background: var(--km-panel); padding: 0 10px; }\n.km-keep[open] { padding-bottom: 10px; }\n.km-keep-title { min-height: 44px; display: flex; align-items: center; font-weight: 600; cursor: pointer; }\n.km-note { margin: 8px 0 0; min-height: 1.4em; font-size: 13px; }\n";
package/dist/ui/style.js CHANGED
@@ -3,6 +3,11 @@
3
3
  * variable so a page can wear it in its own colours (set them on the element
4
4
  * or any ancestor, or pass them as `theme`). Light and dark follow the
5
5
  * reader's system. Everything to be tapped is at least 44px.
6
+ * @example
7
+ * ```ts
8
+ * import { KUMIMOJI_STYLE } from "@johnmorrisdotca/kumimoji/ui";
9
+ * console.log(KUMIMOJI_STYLE.includes(".km-root")); // true
10
+ * ```
6
11
  */
7
12
  export const KUMIMOJI_STYLE = `
8
13
  .km-root {
@@ -37,6 +42,7 @@ export const KUMIMOJI_STYLE = `
37
42
  .km-picker-title { width: 100%; font-size: 14px; }
38
43
  .km-picker .km-button { padding: 0 8px; text-transform: uppercase; }
39
44
  .km-japanese .km-picker .km-button { text-transform: none; }
45
+ .km-controls[hidden] { display: none; }
40
46
  .km-controls { display: flex; flex-wrap: wrap; gap: 8px; }
41
47
  .km-button { font: inherit; min-height: 44px; min-width: 44px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; touch-action: manipulation; }
42
48
  .km-button:disabled { opacity: .45; cursor: default; }