@johnmorrisdotca/kumimoji 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/mount.js CHANGED
@@ -1,31 +1,13 @@
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 { clothVars, isCloth } from "./cloth.js";
9
+ import { button, node, tableDrawing } from "./draw.js";
10
10
  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
11
  /** Hand a string to the person as a file of that name. */
30
12
  function save(name, type, text) {
31
13
  const url = URL.createObjectURL(new Blob([text], { type }));
@@ -60,29 +42,56 @@ function save(name, type, text) {
60
42
  * ```
61
43
  */
62
44
  export function mountKumimoji(target, options = {}) {
63
- let settings = { language: options.language, hand: options.hand, level: options.level, gameLength: options.gameLength, diagonals: options.diagonals, seed: options.seed };
64
- const pageLocale = () => (typeof document !== "undefined" && document.documentElement.lang.toLowerCase().startsWith("ja") ? "ja" : "en");
65
- let locale = options.locale ?? pageLocale();
66
- let own = options.strings ?? {};
67
- let say = kumimojiStrings(locale, own);
68
- let note = "";
69
- let words = null;
70
- let dealt = null;
71
- let play = null;
72
- let held = null;
45
+ let current = options;
46
+ let settings = {
47
+ language: current.language,
48
+ hand: current.hand,
49
+ level: current.level,
50
+ gameLength: current.gameLength,
51
+ diagonals: current.diagonals,
52
+ seed: current.seed,
53
+ };
54
+ const pageLocale = () => typeof document !== "undefined" && document.documentElement.lang.toLowerCase().startsWith("ja") ? "ja" : "en";
55
+ let own = current.strings ?? {};
56
+ const openingLocale = current.locale ?? pageLocale();
73
57
  // Where a keyboard is: the square and the hand tile that Tab lands on, and the one the arrow keys move from.
74
- let onSquare = null;
75
- let onHand = 0;
76
- let started = 0;
77
- let finishedAt = null;
78
- let destroyed = false;
58
+ const session = {
59
+ play: null,
60
+ finishedAt: null,
61
+ started: 0,
62
+ words: null,
63
+ held: null,
64
+ onSquare: null,
65
+ onHand: 0,
66
+ say: kumimojiStrings(openingLocale, own),
67
+ locale: openingLocale,
68
+ note: "",
69
+ destroyed: false,
70
+ };
71
+ let dealt = null;
79
72
  // Each deal and each game put on the table takes a number; one that is overtaken while its list loads gives way.
80
73
  let asked = 0;
74
+ // The drawing is built once the elements exist. Until then nothing calls these.
75
+ const bridge = {
76
+ render: () => { },
77
+ renderStatus: () => { },
78
+ newGame: () => { },
79
+ };
80
+ const render = () => bridge.render();
81
81
  const root = node("div", "km-root");
82
82
  root.dataset.testid = "km-root";
83
83
  root.dataset.state = "dealing";
84
- for (const [name, value] of Object.entries(options.theme ?? {}))
85
- root.style.setProperty(name, value);
84
+ const wear = (cloth, theme) => {
85
+ for (const name of Object.keys(clothVars("green")))
86
+ root.style.removeProperty(name);
87
+ for (const [name, value] of Object.entries({ ...clothVars(cloth), ...theme }))
88
+ root.style.setProperty(name, value);
89
+ if (isCloth(cloth))
90
+ root.dataset.cloth = cloth;
91
+ else
92
+ delete root.dataset.cloth;
93
+ };
94
+ wear(current.cloth, current.theme ?? {});
86
95
  const style = node("style");
87
96
  style.textContent = KUMIMOJI_STYLE;
88
97
  const status = node("p", "km-status");
@@ -99,15 +108,20 @@ export function mountKumimoji(target, options = {}) {
99
108
  const picker = node("div", "km-picker");
100
109
  picker.dataset.testid = "km-picker";
101
110
  const controls = node("div", "km-controls");
102
- controls.hidden = options.controls === false;
111
+ controls.hidden = current.controls === false;
103
112
  controls.dataset.testid = "km-controls";
104
113
  const keep = node("details", "km-keep");
105
114
  keep.dataset.testid = "km-keep";
106
115
  root.append(style, status, board, notes, picker, hand, controls);
107
- if (options.keep !== false)
116
+ if (current.keep !== false)
108
117
  root.append(keep);
109
118
  target.append(root);
110
- const savedNow = () => (play === null || dealt === null ? null : { deal: dealt, play, elapsedMs: (finishedAt ?? Date.now()) - started });
119
+ const savedNow = () => {
120
+ const now = session.play;
121
+ if (now === null || dealt === null)
122
+ return null;
123
+ return { deal: dealt, play: now, elapsedMs: (session.finishedAt ?? Date.now()) - session.started };
124
+ };
111
125
  // The saving and loading are made once, so the fold stays open or shut as the person left it; only their words change.
112
126
  const keepTitle = node("summary", "km-keep-title");
113
127
  const keepNote = node("p", "km-note");
@@ -120,15 +134,15 @@ export function mountKumimoji(target, options = {}) {
120
134
  file.dataset.testid = "km-file";
121
135
  const read = async (text) => {
122
136
  const loaded = await kumimojiFromJSON(text).catch(() => null);
123
- if (destroyed)
137
+ if (session.destroyed)
124
138
  return;
125
139
  if (loaded === null) {
126
- note = say.loadBad;
140
+ session.note = session.say.loadBad;
127
141
  render();
128
142
  return;
129
143
  }
130
144
  await handle.setGame(loaded);
131
- note = say.loaded;
145
+ session.note = session.say.loaded;
132
146
  render();
133
147
  };
134
148
  file.addEventListener("change", () => {
@@ -146,60 +160,59 @@ export function mountKumimoji(target, options = {}) {
146
160
  const saveText = button("", () => {
147
161
  const now = savedNow();
148
162
  if (now !== null)
149
- save(`kumimoji-${now.deal.seed}.txt`, "text/plain", kumimojiToText(now, say));
163
+ save(`kumimoji-${now.deal.seed}.txt`, "text/plain", kumimojiToText(now, session.say));
150
164
  }, false, "km-save-text");
151
165
  const load = button("", () => file.click(), false, "km-load");
152
166
  const keepButtons = node("div", "km-controls");
153
167
  keepButtons.append(saveJson, saveText, load);
154
168
  keep.append(keepTitle, keepButtons, keepNote, file);
155
- const tick = setInterval(() => {
156
- if (play !== null && finishedAt === null)
157
- renderStatus();
158
- }, 1000);
159
- const verdictOf = (now) => judgeWithWords(now.tiles, words, { diagonals: settings.diagonals === true });
169
+ const verdictOf = (now) => judgeWithWords(now.tiles, session.words, { diagonals: settings.diagonals === true });
160
170
  const change = (next) => {
161
- play = next;
171
+ session.play = next;
162
172
  const verdict = verdictOf(next);
163
- if (finishedAt === null && isFinished(next, verdict)) {
164
- finishedAt = Date.now();
165
- options.onFinish?.({ deal: dealt, play: next, elapsedMs: finishedAt - started });
173
+ if (session.finishedAt === null && isFinished(next, verdict)) {
174
+ session.finishedAt = Date.now();
175
+ current.onFinish?.({ deal: dealt, play: next, elapsedMs: session.finishedAt - session.started });
166
176
  }
167
- note = "";
177
+ session.note = "";
168
178
  const now = savedNow();
169
179
  if (now !== null)
170
- options.onChange?.(now);
180
+ current.onChange?.(now);
171
181
  render();
172
182
  };
173
183
  const tapSquare = (square) => {
174
- if (play === null || finishedAt !== null)
184
+ const now = session.play;
185
+ if (now === null || session.finishedAt !== null)
175
186
  return;
176
- const there = play.tiles.get(square);
187
+ const there = now.tiles.get(square);
188
+ const held = session.held;
177
189
  if (held === null) {
178
190
  if (there !== undefined)
179
- held = { from: "table", square };
191
+ session.held = { from: "table", square };
180
192
  render();
181
193
  return;
182
194
  }
183
195
  if (held.from === "hand") {
184
196
  const at = held.at;
185
- const tile = play.hand[at];
197
+ const tile = now.hand[at];
186
198
  if (tile === undefined)
187
199
  return;
188
200
  // A wild is given a letter before it is laid.
189
201
  if (tile === "*")
190
202
  return;
191
- held = null;
192
- change(there === undefined ? placeFromHand(play, at, square) : swapWithHand(play, at, square));
203
+ session.held = null;
204
+ change(there === undefined ? placeFromHand(now, at, square) : swapWithHand(now, at, square));
193
205
  return;
194
206
  }
195
207
  const from = held.square;
196
- held = null;
197
- change(from === square ? liftToHand(play, square) : moveOnTable(play, from, square));
208
+ session.held = null;
209
+ change(from === square ? liftToHand(now, square) : moveOnTable(now, from, square));
198
210
  };
199
211
  const tapHand = (at) => {
200
- if (play === null || finishedAt !== null)
212
+ if (session.play === null || session.finishedAt !== null)
201
213
  return;
202
- held = held?.from === "hand" && held.at === at ? null : { from: "hand", at };
214
+ const held = session.held;
215
+ session.held = held?.from === "hand" && held.at === at ? null : { from: "hand", at };
203
216
  render();
204
217
  };
205
218
  board.addEventListener("click", (event) => {
@@ -222,7 +235,23 @@ export function mountKumimoji(target, options = {}) {
222
235
  const cells = [...grid.children];
223
236
  const cols = Number(grid.dataset.cols);
224
237
  const at = cells.indexOf(cell);
225
- 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;
238
+ const step = event.key === "ArrowLeft"
239
+ ? at % cols > 0
240
+ ? -1
241
+ : 0
242
+ : event.key === "ArrowRight"
243
+ ? at % cols < cols - 1
244
+ ? 1
245
+ : 0
246
+ : event.key === "ArrowUp"
247
+ ? at - cols >= 0
248
+ ? -cols
249
+ : 0
250
+ : event.key === "ArrowDown"
251
+ ? at + cols < cells.length
252
+ ? cols
253
+ : 0
254
+ : null;
226
255
  if (step === null)
227
256
  return;
228
257
  event.preventDefault();
@@ -231,18 +260,18 @@ export function mountKumimoji(target, options = {}) {
231
260
  return;
232
261
  cell.tabIndex = -1;
233
262
  next.tabIndex = 0;
234
- onSquare = next.dataset.square ?? null;
263
+ session.onSquare = next.dataset.square ?? null;
235
264
  next.focus();
236
265
  });
237
266
  board.addEventListener("focusin", (event) => {
238
267
  const square = event.target.closest("[data-square]")?.getAttribute("data-square");
239
268
  if (square !== null && square !== undefined)
240
- onSquare = square;
269
+ session.onSquare = square;
241
270
  });
242
271
  hand.addEventListener("focusin", (event) => {
243
272
  const at = event.target.closest("[data-hand]")?.getAttribute("data-hand");
244
273
  if (at !== null && at !== undefined)
245
- onHand = Number(at);
274
+ session.onHand = Number(at);
246
275
  });
247
276
  hand.addEventListener("keydown", (event) => {
248
277
  const tile = event.target.closest("[data-hand]");
@@ -250,7 +279,15 @@ export function mountKumimoji(target, options = {}) {
250
279
  return;
251
280
  const tiles = [...hand.querySelectorAll("[data-hand]")];
252
281
  const at = tiles.indexOf(tile);
253
- const to = event.key === "ArrowLeft" ? at - 1 : event.key === "ArrowRight" ? at + 1 : event.key === "Home" ? 0 : event.key === "End" ? tiles.length - 1 : null;
282
+ const to = event.key === "ArrowLeft"
283
+ ? at - 1
284
+ : event.key === "ArrowRight"
285
+ ? at + 1
286
+ : event.key === "Home"
287
+ ? 0
288
+ : event.key === "End"
289
+ ? tiles.length - 1
290
+ : null;
254
291
  if (to === null)
255
292
  return;
256
293
  event.preventDefault();
@@ -259,234 +296,82 @@ export function mountKumimoji(target, options = {}) {
259
296
  return;
260
297
  tile.tabIndex = -1;
261
298
  next.tabIndex = 0;
262
- onHand = to;
299
+ session.onHand = to;
263
300
  next.focus();
264
301
  });
265
302
  root.addEventListener("keydown", (event) => {
266
- if (event.key !== "Escape" || held === null)
303
+ if (event.key !== "Escape" || session.held === null)
267
304
  return;
268
- held = null;
305
+ session.held = null;
269
306
  render();
270
307
  });
271
- function renderStatus() {
272
- root.dataset.state = play === null ? "dealing" : finishedAt !== null ? "finished" : "playing";
273
- if (play === null) {
274
- status.textContent = say.dealing;
275
- return;
276
- }
277
- const left = tilesLeft(play);
278
- root.dataset.left = String(left);
279
- const time = kumimojiClock((finishedAt ?? Date.now()) - started);
280
- status.textContent = finishedAt !== null ? kumimojiSay(say.finished, { time }) : kumimojiSay(left === 1 ? say.leftOne : say.left, { n: left, time });
281
- }
282
- function renderBoard(verdict) {
283
- const model = boardModel(play.tiles, play.tiles.size === 0 ? null : verdict, words.glyphOf);
284
- const grid = node("div", "km-grid");
285
- grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
286
- grid.setAttribute("role", "group");
287
- grid.setAttribute("aria-label", say.tableLabel);
288
- grid.setAttribute("aria-description", say.tableKeys);
289
- grid.dataset.cols = String(model.cols);
290
- const cells = [];
291
- for (const square of model.squares) {
292
- const cell = node("button", "km-square");
293
- cell.type = "button";
294
- cell.dataset.square = square.square;
295
- if (square.tile !== null) {
296
- cell.classList.add("km-tile");
297
- if (square.wild)
298
- cell.classList.add("km-wild");
299
- if (square.mark !== null)
300
- cell.classList.add(`km-${square.mark}`);
301
- cell.textContent = square.glyph;
302
- cell.dataset.tile = square.glyph;
303
- cell.setAttribute("aria-label", square.wild ? kumimojiSay(say.wildTile, { tile: square.glyph }) : square.glyph);
304
- }
305
- else {
306
- cell.setAttribute("aria-label", say.emptySquare);
307
- }
308
- if (held?.from === "table" && held.square === square.square)
309
- cell.classList.add("km-held");
310
- cell.tabIndex = -1;
311
- cells.push(cell);
312
- grid.append(cell);
313
- }
314
- // The one Tab lands on: where the keyboard was, else a tile, else the middle.
315
- const stop = cells.find((cell) => cell.dataset.square === onSquare) ?? cells.find((cell) => cell.classList.contains("km-tile")) ?? cells[Math.floor(cells.length / 2)];
316
- if (stop !== undefined) {
317
- stop.tabIndex = 0;
318
- onSquare = stop.dataset.square ?? null;
319
- }
320
- board.replaceChildren(grid);
321
- notes.textContent = verdict.notWords.length > 0 ? kumimojiSay(say.notWords, { words: verdict.notWords.join(", ") }) : verdict.apart.size > 0 ? say.apart : "";
322
- }
323
- function renderHand() {
324
- hand.replaceChildren();
325
- hand.setAttribute("aria-label", say.handLabel);
326
- play.hand.forEach((tile, at) => {
327
- const face = tileFace(tile);
328
- const made = node("button", "km-tile km-in-hand");
329
- made.type = "button";
330
- made.dataset.hand = String(at);
331
- made.textContent = face.blank ? WILD_MARK : words.glyphOf(tile);
332
- if (face.wild)
333
- made.classList.add("km-wild");
334
- if (held?.from === "hand" && held.at === at)
335
- made.classList.add("km-held");
336
- made.dataset.tile = face.blank ? "*" : words.glyphOf(tile);
337
- made.setAttribute("aria-label", face.blank ? say.wildBlank : face.wild ? kumimojiSay(say.wildTile, { tile: words.glyphOf(tile) }) : words.glyphOf(tile));
338
- made.tabIndex = at === Math.min(onHand, play.hand.length - 1) ? 0 : -1;
339
- hand.append(made);
340
- });
341
- if (play.hand.length === 0 && finishedAt === null)
342
- hand.append(node("span", "km-empty", say.noTiles));
343
- }
344
- function renderPicker() {
345
- picker.replaceChildren();
346
- if (held?.from !== "hand" || play === null)
347
- return;
348
- const at = held.at;
349
- const tile = play.hand[at];
350
- if (tile === undefined || !tileFace(tile).wild)
351
- return;
352
- picker.append(node("span", "km-picker-title", tile === "*" ? say.giveWild : say.changeWild));
353
- for (const sound of words.wildOptions) {
354
- const code = words.wildFor(sound);
355
- if (code === null)
356
- continue;
357
- picker.append(button(sound, () => {
358
- held = { from: "hand", at };
359
- change(assignHandTile(play, at, code));
360
- }));
361
- }
362
- }
363
- function renderControls(verdict) {
364
- controls.replaceChildren();
365
- if (finishedAt !== null) {
366
- controls.append(button(say.newGame, () => void handle.newGame(), true, "km-new"));
367
- return;
368
- }
369
- const now = play;
370
- const drawButton = button(say.draw, () => {
371
- held = null;
372
- change(draw(now));
373
- }, true, "km-draw");
374
- drawButton.disabled = !mayDraw(now, verdict);
375
- const tradeButton = button(say.trade, () => {
376
- if (held?.from !== "hand")
377
- return;
378
- const at = held.at;
379
- held = null;
380
- change(trade(now, at));
381
- }, false, "km-trade");
382
- tradeButton.disabled = held?.from !== "hand" || !mayTrade(now);
383
- controls.append(drawButton, tradeButton, button(say.sort, () => change(sortHand(now)), false, "km-sort"), button(say.liftAll, () => {
384
- held = null;
385
- change(liftAll(now));
386
- }, false, "km-lift"), button(say.newGame, () => void handle.newGame(), false, "km-new"));
387
- }
388
- function renderKeep() {
389
- keepTitle.textContent = say.keep;
390
- saveJson.textContent = say.saveJson;
391
- saveText.textContent = say.saveText;
392
- load.textContent = say.load;
393
- saveJson.disabled = play === null;
394
- saveText.disabled = play === null;
395
- keepNote.textContent = note;
396
- }
397
- /** Where the keyboard is, so that it can be put back after everything is drawn again: the zone, and which of it. */
398
- function keyboardAt() {
399
- const on = document.activeElement;
400
- if (on === null || !root.contains(on))
401
- return null;
402
- const mark = (zone, id) => (id === undefined ? null : { zone, id });
403
- if (board.contains(on))
404
- return mark("board", on.dataset.square);
405
- if (hand.contains(on))
406
- return mark("hand", on.dataset.hand);
407
- if (controls.contains(on))
408
- return mark("controls", on.dataset.testid);
409
- if (picker.contains(on))
410
- return mark("picker", String([...picker.querySelectorAll("button")].indexOf(on)));
411
- return null;
412
- }
413
- function keyboardBack(at) {
414
- if (at === null)
415
- return;
416
- const zone = { board, hand, controls, picker }[at.zone];
417
- 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)];
418
- // A button that has become disabled cannot hold the focus; the table keeps it rather than dropping it on the page.
419
- if (found !== undefined && found !== null && !found.disabled)
420
- found.focus({ preventScroll: true });
421
- }
422
- function render() {
423
- if (destroyed)
424
- return;
425
- const where = keyboardAt();
426
- root.lang = locale;
427
- renderStatus();
428
- renderKeep();
429
- if (play === null || words === null)
430
- return;
431
- const verdict = verdictOf(play);
432
- renderBoard(verdict);
433
- renderPicker();
434
- renderHand();
435
- renderControls(verdict);
436
- keyboardBack(where);
437
- }
308
+ const drawing = tableDrawing(session, { root, status, board, notes, hand, picker, controls, keepTitle, saveJson, saveText, load, keepNote }, { verdictOf, change, newGame: () => bridge.newGame() });
309
+ bridge.render = () => drawing.render();
310
+ bridge.renderStatus = () => drawing.renderStatus();
311
+ const tick = setInterval(() => {
312
+ if (session.play !== null && session.finishedAt === null)
313
+ bridge.renderStatus();
314
+ }, 1000);
438
315
  let initialReady = Promise.resolve();
439
316
  const handle = {
440
- get ready() { return initialReady; },
317
+ get ready() {
318
+ return initialReady;
319
+ },
441
320
  set: (changes) => {
442
- for (const name of Object.keys(options.theme ?? {}))
321
+ for (const name of Object.keys(current.theme ?? {}))
443
322
  if (changes.theme !== undefined && !(name in changes.theme))
444
323
  root.style.removeProperty(name);
445
- if (changes.theme !== undefined)
446
- for (const [name, value] of Object.entries(changes.theme))
447
- root.style.setProperty(name, value);
448
- options = { ...options, ...changes };
324
+ if (changes.theme !== undefined || "cloth" in changes)
325
+ wear("cloth" in changes ? changes.cloth : current.cloth, changes.theme ?? current.theme ?? {});
326
+ current = { ...current, ...changes };
449
327
  if (changes.locale !== undefined)
450
- locale = changes.locale;
328
+ session.locale = changes.locale;
451
329
  if (changes.strings !== undefined)
452
330
  own = changes.strings;
453
- say = kumimojiStrings(locale, own);
454
- if (options.keep === false)
331
+ session.say = kumimojiStrings(session.locale, own);
332
+ if (current.keep === false)
455
333
  keep.remove();
456
334
  else if (keep.parentNode !== root)
457
335
  root.append(keep);
458
- controls.hidden = options.controls === false;
336
+ controls.hidden = current.controls === false;
459
337
  render();
460
338
  },
461
- play: () => play,
339
+ play: () => session.play,
462
340
  saved: savedNow,
463
341
  setGame: async (saved) => {
464
342
  const mine = ++asked;
465
343
  const language = saved.deal.language;
466
- settings = { language, hand: saved.deal.size, level: saved.deal.level, gameLength: saved.deal.gameLength, diagonals: saved.deal.diagonals === true, seed: undefined };
344
+ settings = {
345
+ language,
346
+ hand: saved.deal.size,
347
+ level: saved.deal.level,
348
+ gameLength: saved.deal.gameLength,
349
+ diagonals: saved.deal.diagonals === true,
350
+ seed: undefined,
351
+ };
467
352
  const list = await loadTileWords(language);
468
- if (destroyed || mine !== asked)
353
+ if (session.destroyed || mine !== asked)
469
354
  return;
470
- words = list;
355
+ session.words = list;
471
356
  root.classList.toggle("km-japanese", language === "japanese");
472
357
  dealt = saved.deal;
473
358
  root.dataset.seed = String(saved.deal.seed);
474
- held = null;
475
- finishedAt = null;
476
- started = Date.now() - saved.elapsedMs;
359
+ session.held = null;
360
+ session.finishedAt = null;
361
+ session.started = Date.now() - saved.elapsedMs;
477
362
  // A game loaded already finished stays finished, at the time it took, and is not announced again.
478
- if (isFinished(saved.play, judgeWithWords(saved.play.tiles, words, { diagonals: settings.diagonals === true })))
479
- finishedAt = Date.now();
480
- play = saved.play;
481
- note = "";
482
- options.onChange?.(saved);
363
+ if (isFinished(saved.play, judgeWithWords(saved.play.tiles, list, { diagonals: settings.diagonals === true })))
364
+ session.finishedAt = Date.now();
365
+ session.play = saved.play;
366
+ session.note = "";
367
+ current.onChange?.(saved);
483
368
  render();
484
369
  },
485
370
  setLocale: (next, strings) => {
486
- locale = next;
371
+ session.locale = next;
487
372
  if (strings !== undefined)
488
373
  own = strings;
489
- say = kumimojiStrings(locale, own);
374
+ session.say = kumimojiStrings(session.locale, own);
490
375
  render();
491
376
  },
492
377
  newGame: async (changed = {}) => {
@@ -494,31 +379,43 @@ export function mountKumimoji(target, options = {}) {
494
379
  settings = { ...settings, ...changed, seed: undefined };
495
380
  const language = settings.language ?? "english";
496
381
  const size = settings.hand ?? KUMIMOJI_HANDS.quick;
497
- play = null;
498
- held = null;
499
- finishedAt = null;
500
- note = "";
382
+ session.play = null;
383
+ session.held = null;
384
+ session.finishedAt = null;
385
+ session.note = "";
501
386
  render();
502
387
  const list = await loadTileWords(language);
503
- if (destroyed || mine !== asked)
388
+ if (session.destroyed || mine !== asked)
504
389
  return;
505
- words = list;
390
+ session.words = list;
506
391
  // A seed deals one game: the next is dealt from a new one unless asked for by seed again.
507
392
  const seed = changed.seed ?? Math.floor(Math.random() * 2 ** 31);
508
393
  root.classList.toggle("km-japanese", language === "japanese");
509
394
  root.dataset.seed = String(seed);
510
- dealt = generateKumimoji(size, settings.level ?? "medium", seed, { language, gameLength: settings.gameLength, diagonals: settings.diagonals });
511
- started = Date.now();
395
+ dealt = generateKumimoji(size, settings.level ?? "medium", seed, {
396
+ language,
397
+ gameLength: settings.gameLength,
398
+ diagonals: settings.diagonals,
399
+ });
400
+ session.started = Date.now();
512
401
  change(deal(dealt.givens, size));
513
402
  },
514
403
  destroy: () => {
515
- destroyed = true;
404
+ session.destroyed = true;
516
405
  clearInterval(tick);
517
406
  root.remove();
518
407
  },
519
408
  };
520
- initialReady = options.saved === undefined ? handle.newGame(settings) : handle.setGame(options.saved);
521
- void initialReady.catch((error) => { if (destroyed)
522
- return; const problem = error instanceof Error ? error : new Error(String(error)); status.textContent = say.loadError; options.onError?.(problem); });
409
+ bridge.newGame = () => {
410
+ void handle.newGame();
411
+ };
412
+ initialReady = current.saved === undefined ? handle.newGame(settings) : handle.setGame(current.saved);
413
+ void initialReady.catch((error) => {
414
+ if (session.destroyed)
415
+ return;
416
+ const problem = error instanceof Error ? error : new Error(String(error));
417
+ status.textContent = session.say.loadError;
418
+ current.onError?.(problem);
419
+ });
523
420
  return handle;
524
421
  }
@@ -3,10 +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
6
+ * @example
7
7
  * ```ts
8
8
  * import { KUMIMOJI_STYLE } from "@johnmorrisdotca/kumimoji/ui";
9
9
  * console.log(KUMIMOJI_STYLE.includes(".km-root")); // true
10
10
  * ```
11
11
  */
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";
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-root, .km-root * { line-height: 1.35; }\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";