@johnmorrisdotca/kumimoji 1.0.1 → 1.1.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * EVERY WORD KUMIMOJI SHOWS A PERSON, in English and Japanese: the table's
3
+ * buttons and status lines, and the lines of a game written out as text
4
+ * (`kumimojiToText`). This is the language of the page, which is not the
5
+ * language of the tiles: an English page can play kana, and a Japanese page
6
+ * English letters.
7
+ *
8
+ * A page in another language passes its own table, whole or in part:
9
+ * `{ ...KUMIMOJI_STRINGS.en, draw: "Robar" }`. `{n}`, `{time}` and the other
10
+ * braces are filled in when a line is shown; a table must keep them.
11
+ * `docs/strings-ja.md` lists the two languages side by side and is made from
12
+ * this file.
13
+ */
14
+ const EN = {
15
+ dealing: "Dealing…",
16
+ leftOne: "1 tile left in the bag · {time}",
17
+ left: "{n} tiles left in the bag · {time}",
18
+ finished: "Finished in {time}. Every tile laid, every run a word.",
19
+ tableLabel: "The table",
20
+ handLabel: "Your hand",
21
+ emptySquare: "Empty square",
22
+ wildTile: "{tile}, wild",
23
+ wildBlank: "Wild, no letter yet",
24
+ notWords: "Not words: {words}",
25
+ apart: "Every tile must join the one crossword.",
26
+ noTiles: "No tiles in hand",
27
+ giveWild: "Give the wild a letter:",
28
+ changeWild: "Change the wild's letter:",
29
+ draw: "Draw",
30
+ trade: "Trade for three",
31
+ sort: "Sort",
32
+ liftAll: "Lift all",
33
+ newGame: "New game",
34
+ keep: "Save and load",
35
+ saveJson: "Save as JSON",
36
+ saveText: "Save as text",
37
+ load: "Load a game",
38
+ loaded: "Game loaded.",
39
+ loadBad: "That file is not a game of Kumimoji these rules can open.",
40
+ textTitle: "Kumimoji: {language}, a hand of {hand}, seed {seed}",
41
+ textEmpty: "(no tiles on the table)",
42
+ textHand: "Hand: {tiles}",
43
+ textHandEmpty: "Hand: empty",
44
+ textLeft: "In the bag: {n}",
45
+ textTime: "Time: {time}",
46
+ textFinished: "Finished: every tile laid, every run a word.",
47
+ english: "English",
48
+ japanese: "Japanese",
49
+ };
50
+ const JA = {
51
+ dealing: "配っています…",
52
+ leftOne: "袋の残り1枚 · {time}",
53
+ left: "袋の残り{n}枚 · {time}",
54
+ finished: "{time}で完成しました。すべてのタイルを置き、どの並びも単語になっています。",
55
+ tableLabel: "テーブル",
56
+ handLabel: "手札",
57
+ emptySquare: "空きマス",
58
+ wildTile: "{tile}(ワイルド)",
59
+ wildBlank: "ワイルド(文字は未定)",
60
+ notWords: "単語ではありません: {words}",
61
+ apart: "すべてのタイルを1つのクロスワードにつなげてください。",
62
+ noTiles: "手札にタイルはありません",
63
+ giveWild: "ワイルドの文字を選んでください:",
64
+ changeWild: "ワイルドの文字を変える:",
65
+ draw: "1枚引く",
66
+ trade: "1枚を3枚と交換",
67
+ sort: "並べ替え",
68
+ liftAll: "すべて手札に戻す",
69
+ newGame: "新しいゲーム",
70
+ keep: "保存と読み込み",
71
+ saveJson: "JSONで保存",
72
+ saveText: "テキストで保存",
73
+ load: "ゲームを読み込む",
74
+ loaded: "ゲームを読み込みました。",
75
+ loadBad: "このファイルは、開くことのできる組み文字のゲームではありません。",
76
+ textTitle: "組み文字: {language}、手札{hand}枚、シード {seed}",
77
+ textEmpty: "(テーブルにタイルはありません)",
78
+ textHand: "手札: {tiles}",
79
+ textHandEmpty: "手札: なし",
80
+ textLeft: "袋の残り: {n}枚",
81
+ textTime: "時間: {time}",
82
+ textFinished: "完成: すべてのタイルを置き、どの並びも単語になっています。",
83
+ english: "英語",
84
+ japanese: "日本語",
85
+ };
86
+ /** The table's words in each language it speaks: `KUMIMOJI_STRINGS.en`, `KUMIMOJI_STRINGS.ja`. */
87
+ export const KUMIMOJI_STRINGS = { en: EN, ja: JA };
88
+ /** A line from a table of strings with its braces filled in: `kumimojiSay(strings.left, { n: 12, time: "1:05" })`. A brace with no value is left as it is. */
89
+ export function kumimojiSay(line, values = {}) {
90
+ return line.replace(/\{(\w+)\}/g, (whole, name) => (name in values ? String(values[name]) : whole));
91
+ }
92
+ /** The table of strings for a locale with a page's own words laid over it. An unknown locale is English. */
93
+ export function kumimojiStrings(locale, own = {}) {
94
+ const base = typeof locale === "string" && locale.toLowerCase().startsWith("ja") ? JA : EN;
95
+ return { ...base, ...own };
96
+ }
@@ -73,6 +73,7 @@ export declare function fitView(area: Area, width: number, height: number, least
73
73
  export declare function overflows(area: Area, view: View, width: number, height: number): boolean;
74
74
  /** Zoomed by `factor` about the point (px, py) on the screen, which stays over the same spot of the table. */
75
75
  export declare function zoomView(view: View, factor: number, px: number, py: number): View;
76
+ /** The view moved by `dx` and `dy` screen pixels. */
76
77
  export declare function panView(view: View, dx: number, dy: number): View;
77
78
  /**
78
79
  * Kept within reach: some of the area always on the screen, so a pan can
package/dist/tableView.js CHANGED
@@ -91,6 +91,7 @@ export function zoomView(view, factor, px, py) {
91
91
  const scale = tile / view.tile;
92
92
  return { tile, x: px - (px - view.x) * scale, y: py - (py - view.y) * scale };
93
93
  }
94
+ /** The view moved by `dx` and `dy` screen pixels. */
94
95
  export function panView(view, dx, dy) {
95
96
  return { ...view, x: view.x + dx, y: view.y + dy };
96
97
  }
@@ -8,18 +8,22 @@
8
8
  * without fetching the dictionary, and `tileWords` reads the same codes.
9
9
  */
10
10
  export declare const KANA_TILE_START = 57344;
11
+ /** Where the codes of wild tiles given a kana begin: the kana's place in `BASE_KANA` above this. */
11
12
  export declare const KANA_WILD_START = 61440;
12
13
  /** The code of a base kana's tile, or of a wild given that kana. */
13
14
  export declare function kanaTileCode(kana: string, wild?: boolean): string | null;
15
+ /** What a tile shows: what `tileFace` answers. */
14
16
  export type TileFaceOf = {
15
17
  /** The letter or kana printed on it: 五 on a wild nobody has given one. */
16
18
  glyph: string;
17
19
  /** The other forms it plays as, small in its corner (`CORNER_FORMS`). */
18
20
  forms: string;
21
+ /** Whether it is a wild tile, given a letter or not. */
19
22
  wild: boolean;
20
23
  /** A wild with no letter yet. */
21
24
  blank: boolean;
22
25
  };
26
+ /** What a tile code shows, read without the word list: its letter or kana, its corner forms, and whether it is a wild. */
23
27
  export declare function tileFace(tile: string): TileFaceOf;
24
28
  /** What a screen reader says for a tile: its letter, or that it is a wild and what it stands for. */
25
29
  export declare function tileDescription(tile: string): string;
package/dist/tileFace.js CHANGED
@@ -9,6 +9,7 @@ import { BASE_KANA, CORNER_FORMS } from "./kana.js";
9
9
  * without fetching the dictionary, and `tileWords` reads the same codes.
10
10
  */
11
11
  export const KANA_TILE_START = 0xe000;
12
+ /** Where the codes of wild tiles given a kana begin: the kana's place in `BASE_KANA` above this. */
12
13
  export const KANA_WILD_START = 0xf000;
13
14
  const KANA = [...BASE_KANA];
14
15
  /** The code of a base kana's tile, or of a wild given that kana. */
@@ -16,6 +17,7 @@ export function kanaTileCode(kana, wild = false) {
16
17
  const at = KANA.indexOf(kana);
17
18
  return at === -1 ? null : String.fromCodePoint((wild ? KANA_WILD_START : KANA_TILE_START) + at);
18
19
  }
20
+ /** What a tile code shows, read without the word list: its letter or kana, its corner forms, and whether it is a wild. */
19
21
  export function tileFace(tile) {
20
22
  if (tile === "*")
21
23
  return { glyph: "五", forms: "", wild: true, blank: true };
@@ -45,6 +45,7 @@ export type TileWords = {
45
45
  };
46
46
  /** Front-coded words of one length read back (see `scripts/tile-words.mjs`): a shared-prefix character, then the rest. */
47
47
  export declare function unpackLength(packed: string, length: number): string[];
48
+ /** The English list read from its packed data: every word, the words by length, the set's mix, and the functions that turn tiles into words. */
48
49
  export declare function unpackTileWords(data: Readonly<Record<number, string>>): TileWords;
49
50
  /** Used by `tileWordsModule.ts` only: how to read a list where there is no browser. */
50
51
  export declare function readTileWordsWith(source: (language: KumimojiLanguage) => Promise<TileWords>): void;
@@ -65,6 +66,7 @@ export declare function tileWordsFrom(language: KumimojiLanguage, data: unknown)
65
66
  * server's own checks and the tests import.
66
67
  */
67
68
  export declare function loadTileWords(language?: KumimojiLanguage): Promise<TileWords>;
69
+ /** The list already loaded, for judging and dealing. Throws where `loadTileWords` has not yet fetched it: a check that cannot read the list must not answer. */
68
70
  export declare function tileWords(language?: KumimojiLanguage): TileWords;
69
71
  /** Whether the list has been fetched yet, for a page to say "loading" rather than throw. */
70
72
  export declare function tileWordsReady(language?: KumimojiLanguage): boolean;
package/dist/tileWords.js CHANGED
@@ -18,6 +18,7 @@ export function unpackLength(packed, length) {
18
18
  }
19
19
  return words;
20
20
  }
21
+ /** The English list read from its packed data: every word, the words by length, the set's mix, and the functions that turn tiles into words. */
21
22
  export function unpackTileWords(data) {
22
23
  const byLength = new Map();
23
24
  const allowed = new Set();
@@ -152,6 +153,7 @@ export async function loadTileWords(language = "english") {
152
153
  loaded.set(language, words);
153
154
  return words;
154
155
  }
156
+ /** The list already loaded, for judging and dealing. Throws where `loadTileWords` has not yet fetched it: a check that cannot read the list must not answer. */
155
157
  export function tileWords(language = "english") {
156
158
  const words = loaded.get(language);
157
159
  if (words === undefined)
@@ -1,16 +1,22 @@
1
1
  import { type GridVerdict, type Tiles } from "../grid.ts";
2
2
  /** How a tile on the table stands: part of a sound crossword, in a run that is not a word, or apart from the rest. */
3
3
  export type TileMark = "sound" | "misspelt" | "apart";
4
+ /** One square of the board drawn. */
4
5
  export type BoardSquare = {
6
+ /** Its key, `squareAt(row, col)`. */
5
7
  square: string;
6
8
  row: number;
7
9
  col: number;
8
10
  /** The tile standing there, or null for an empty square. */
9
11
  tile: string | null;
12
+ /** What is printed on the tile; "" for an empty square. */
10
13
  glyph: string;
14
+ /** Whether the tile is a wild. */
11
15
  wild: boolean;
16
+ /** How the tile stands; null for an empty square, or with no verdict. */
12
17
  mark: TileMark | null;
13
18
  };
19
+ /** The part of the table to draw: how many rows and columns, and every square, row by row. */
14
20
  export type BoardModel = {
15
21
  rows: number;
16
22
  cols: number;
@@ -1,10 +1,16 @@
1
+ import { type KumimojiSaved } from "../export.ts";
1
2
  import type { KumimojiDeal, KumimojiLanguage, KumimojiLength, KumimojiLevel } from "../kumimoji.types.ts";
2
3
  import { type TilePlay } from "../play.ts";
4
+ import { type KumimojiLocale, type KumimojiStrings } from "../strings.ts";
5
+ /** What `mountKumimoji` may be given. Everything is optional: with nothing, it is a short English game from a hand of seven, with some wild tiles. */
3
6
  export type KumimojiTableOptions = {
7
+ /** The language of the tiles: `"english"` or `"japanese"` kana. English by default. */
4
8
  language?: KumimojiLanguage;
5
9
  /** The opening hand: 3, 7 or 11 tiles. */
6
10
  hand?: number;
11
+ /** How many of the tiles are wild: `"easy"` the most, `"medium"` some, `"hard"` none. */
7
12
  level?: KumimojiLevel;
13
+ /** How much of the set the bag holds: `"short"`, `"medium"`, or `"full"`, all 144 tiles. */
8
14
  gameLength?: KumimojiLength;
9
15
  /** Every diagonal run of three or more must be a word too. */
10
16
  diagonals?: boolean;
@@ -16,10 +22,32 @@ export type KumimojiTableOptions = {
16
22
  play: TilePlay;
17
23
  elapsedMs: number;
18
24
  }) => void;
25
+ /** Told after every change to the game, with the game as it would be saved. */
26
+ onChange?: (saved: KumimojiSaved) => void;
27
+ /** The language of the table's words, which is not the language of the tiles: `"en"` or `"ja"`. By default the page's own (`<html lang>`), and English for any other. */
28
+ locale?: KumimojiLocale;
29
+ /** Words of your own, laid over the locale's: any of `KumimojiStrings`. */
30
+ strings?: Partial<KumimojiStrings>;
31
+ /** CSS variables set on the table itself, which win in light and dark alike: `{ "--km-felt": "#23405a" }`. */
32
+ theme?: Readonly<Record<string, string>>;
33
+ /** Whether the table offers saving and loading. True by default. */
34
+ keep?: boolean;
19
35
  };
36
+ /** The options that make a game, which a new game may change. */
37
+ type GameOptions = Pick<KumimojiTableOptions, "language" | "hand" | "level" | "gameLength" | "diagonals" | "seed">;
38
+ /** What `mountKumimoji` hands back: the game being played, and the ways to change it from outside. */
20
39
  export type KumimojiTableHandle = {
40
+ /** The game as it stands, or null while it is being dealt. */
21
41
  play: () => TilePlay | null;
22
- newGame: (options?: Omit<KumimojiTableOptions, "onFinish">) => Promise<void>;
42
+ /** The game as it would be saved (`kumimojiToJSON`), or null while it is being dealt. */
43
+ saved: () => KumimojiSaved | null;
44
+ /** A new game at the same table, with any options changed. Resolves when it is dealt. */
45
+ newGame: (options?: GameOptions) => Promise<void>;
46
+ /** Put a game on the table: one read back by `kumimojiFromJSON`. Its clock goes on from the time already spent. */
47
+ setGame: (saved: KumimojiSaved) => Promise<void>;
48
+ /** Change the language of the table's words, and with it any words of your own. */
49
+ setLocale: (locale: KumimojiLocale, strings?: Partial<KumimojiStrings>) => void;
50
+ /** Take the table off the page and stop its clock. */
23
51
  destroy: () => void;
24
52
  };
25
53
  /**
@@ -29,8 +57,10 @@ export type KumimojiTableHandle = {
29
57
  * table and then a square to move it, or tap it again to take it back. When
30
58
  * the hand is empty and every run is a word, Draw brings the next tile; a
31
59
  * tile you cannot use can be traded for three. Use every tile in the bag to
32
- * finish.
60
+ * finish. Under the buttons, the game saves as JSON or as text, and a saved
61
+ * game loads back.
33
62
  *
34
63
  * The word list is fetched the first time a game in its language is dealt.
35
64
  */
36
65
  export declare function mountKumimoji(target: HTMLElement, options?: KumimojiTableOptions): KumimojiTableHandle;
66
+ export {};
package/dist/ui/mount.js CHANGED
@@ -1,12 +1,13 @@
1
+ import { kumimojiClock, kumimojiFromJSON, kumimojiToJSON, kumimojiToText } from "../export.js";
1
2
  import { generateKumimoji } from "../generate.js";
2
3
  import { judgeWithWords } from "../judge.js";
3
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
6
  import { KUMIMOJI_HANDS } from "../tiles.constants.js";
5
7
  import { tileFace } from "../tileFace.js";
6
8
  import { loadTileWords } from "../tileWords.js";
7
9
  import { boardModel } from "./board.js";
8
10
  import { KUMIMOJI_STYLE } from "./style.js";
9
- /** What is picked up: a tile in the hand, or one on the table. */
10
11
  /** What a wild nobody has given a letter yet shows in the hand. */
11
12
  const WILD_MARK = "★";
12
13
  function node(name, className, text) {
@@ -17,15 +18,22 @@ function node(name, className, text) {
17
18
  made.textContent = text;
18
19
  return made;
19
20
  }
20
- function button(text, onClick, primary = false) {
21
+ function button(text, onClick, primary = false, testId) {
21
22
  const made = node("button", primary ? "km-button km-primary" : "km-button", text);
22
23
  made.type = "button";
24
+ if (testId !== undefined)
25
+ made.dataset.testid = testId;
23
26
  made.addEventListener("click", onClick);
24
27
  return made;
25
28
  }
26
- function clock(ms) {
27
- const seconds = Math.floor(ms / 1000);
28
- return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
29
+ /** Hand a string to the person as a file of that name. */
30
+ function save(name, type, text) {
31
+ const url = URL.createObjectURL(new Blob([text], { type }));
32
+ const link = node("a");
33
+ link.href = url;
34
+ link.download = name;
35
+ link.click();
36
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
29
37
  }
30
38
  /**
31
39
  * A GAME OF KUMIMOJI ALONE, IN PLAIN DOM: the bag dealt from a seed, your
@@ -34,12 +42,18 @@ function clock(ms) {
34
42
  * table and then a square to move it, or tap it again to take it back. When
35
43
  * the hand is empty and every run is a word, Draw brings the next tile; a
36
44
  * tile you cannot use can be traded for three. Use every tile in the bag to
37
- * finish.
45
+ * finish. Under the buttons, the game saves as JSON or as text, and a saved
46
+ * game loads back.
38
47
  *
39
48
  * The word list is fetched the first time a game in its language is dealt.
40
49
  */
41
50
  export function mountKumimoji(target, options = {}) {
42
- let settings = { ...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 = "";
43
57
  let words = null;
44
58
  let dealt = null;
45
59
  let play = null;
@@ -47,18 +61,80 @@ export function mountKumimoji(target, options = {}) {
47
61
  let started = 0;
48
62
  let finishedAt = null;
49
63
  let destroyed = false;
64
+ // Each deal and each game put on the table takes a number; one that is overtaken while its list loads gives way.
65
+ let asked = 0;
50
66
  const root = node("div", "km-root");
67
+ root.dataset.testid = "km-root";
68
+ root.dataset.state = "dealing";
69
+ for (const [name, value] of Object.entries(options.theme ?? {}))
70
+ root.style.setProperty(name, value);
51
71
  const style = node("style");
52
72
  style.textContent = KUMIMOJI_STYLE;
53
73
  const status = node("p", "km-status");
74
+ status.dataset.testid = "km-status";
54
75
  status.setAttribute("aria-live", "polite");
55
76
  const board = node("div", "km-board");
77
+ board.dataset.testid = "km-board";
56
78
  const notes = node("p", "km-notes");
79
+ notes.dataset.testid = "km-notes";
57
80
  const hand = node("div", "km-hand");
81
+ hand.dataset.testid = "km-hand";
82
+ hand.setAttribute("role", "group");
58
83
  const picker = node("div", "km-picker");
84
+ picker.dataset.testid = "km-picker";
59
85
  const controls = node("div", "km-controls");
86
+ controls.dataset.testid = "km-controls";
87
+ const keep = node("details", "km-keep");
88
+ keep.dataset.testid = "km-keep";
60
89
  root.append(style, status, board, notes, picker, hand, controls);
90
+ if (options.keep !== false)
91
+ root.append(keep);
61
92
  target.append(root);
93
+ const savedNow = () => (play === null || dealt === null ? null : { deal: dealt, play, elapsedMs: (finishedAt ?? Date.now()) - started });
94
+ // The saving and loading are made once, so the fold stays open or shut as the person left it; only their words change.
95
+ const keepTitle = node("summary", "km-keep-title");
96
+ const keepNote = node("p", "km-note");
97
+ keepNote.dataset.testid = "km-note";
98
+ keepNote.setAttribute("aria-live", "polite");
99
+ const file = node("input");
100
+ file.type = "file";
101
+ file.accept = ".json,application/json";
102
+ file.hidden = true;
103
+ file.dataset.testid = "km-file";
104
+ const read = async (text) => {
105
+ const loaded = await kumimojiFromJSON(text).catch(() => null);
106
+ if (destroyed)
107
+ return;
108
+ if (loaded === null) {
109
+ note = say.loadBad;
110
+ render();
111
+ return;
112
+ }
113
+ await handle.setGame(loaded);
114
+ note = say.loaded;
115
+ render();
116
+ };
117
+ file.addEventListener("change", () => {
118
+ const chosen = file.files?.[0];
119
+ file.value = "";
120
+ if (chosen === undefined)
121
+ return;
122
+ void chosen.text().then(read, () => read(""));
123
+ });
124
+ const saveJson = button("", () => {
125
+ const now = savedNow();
126
+ if (now !== null)
127
+ save(`kumimoji-${now.deal.seed}.json`, "application/json", kumimojiToJSON(now));
128
+ }, false, "km-save-json");
129
+ const saveText = button("", () => {
130
+ const now = savedNow();
131
+ if (now !== null)
132
+ save(`kumimoji-${now.deal.seed}.txt`, "text/plain", kumimojiToText(now, say));
133
+ }, false, "km-save-text");
134
+ const load = button("", () => file.click(), false, "km-load");
135
+ const keepButtons = node("div", "km-controls");
136
+ keepButtons.append(saveJson, saveText, load);
137
+ keep.append(keepTitle, keepButtons, keepNote, file);
62
138
  const tick = setInterval(() => {
63
139
  if (play !== null && finishedAt === null)
64
140
  renderStatus();
@@ -71,6 +147,10 @@ export function mountKumimoji(target, options = {}) {
71
147
  finishedAt = Date.now();
72
148
  options.onFinish?.({ deal: dealt, play: next, elapsedMs: finishedAt - started });
73
149
  }
150
+ note = "";
151
+ const now = savedNow();
152
+ if (now !== null)
153
+ options.onChange?.(now);
74
154
  render();
75
155
  };
76
156
  const tapSquare = (square) => {
@@ -116,19 +196,22 @@ export function mountKumimoji(target, options = {}) {
116
196
  tapHand(Number(at));
117
197
  });
118
198
  function renderStatus() {
199
+ root.dataset.state = play === null ? "dealing" : finishedAt !== null ? "finished" : "playing";
119
200
  if (play === null) {
120
- status.textContent = "Dealing…";
201
+ status.textContent = say.dealing;
121
202
  return;
122
203
  }
123
204
  const left = tilesLeft(play);
124
- const time = clock((finishedAt ?? Date.now()) - started);
125
- status.textContent = finishedAt !== null ? `Finished in ${time}. Every tile laid, every run a word.` : `${left} ${left === 1 ? "tile" : "tiles"} left in the bag · ${time}`;
205
+ root.dataset.left = String(left);
206
+ const time = kumimojiClock((finishedAt ?? Date.now()) - started);
207
+ status.textContent = finishedAt !== null ? kumimojiSay(say.finished, { time }) : kumimojiSay(left === 1 ? say.leftOne : say.left, { n: left, time });
126
208
  }
127
209
  function renderBoard(verdict) {
128
210
  const model = boardModel(play.tiles, play.tiles.size === 0 ? null : verdict, words.glyphOf);
129
211
  const grid = node("div", "km-grid");
130
212
  grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
131
- grid.setAttribute("role", "grid");
213
+ grid.setAttribute("role", "group");
214
+ grid.setAttribute("aria-label", say.tableLabel);
132
215
  for (const square of model.squares) {
133
216
  const cell = node("button", "km-square");
134
217
  cell.type = "button";
@@ -140,20 +223,22 @@ export function mountKumimoji(target, options = {}) {
140
223
  if (square.mark !== null)
141
224
  cell.classList.add(`km-${square.mark}`);
142
225
  cell.textContent = square.glyph;
143
- cell.setAttribute("aria-label", `${square.glyph}${square.wild ? ", wild" : ""}`);
226
+ cell.dataset.tile = square.glyph;
227
+ cell.setAttribute("aria-label", square.wild ? kumimojiSay(say.wildTile, { tile: square.glyph }) : square.glyph);
144
228
  }
145
229
  else {
146
- cell.setAttribute("aria-label", "Empty square");
230
+ cell.setAttribute("aria-label", say.emptySquare);
147
231
  }
148
232
  if (held?.from === "table" && held.square === square.square)
149
233
  cell.classList.add("km-held");
150
234
  grid.append(cell);
151
235
  }
152
236
  board.replaceChildren(grid);
153
- notes.textContent = verdict.notWords.length > 0 ? `Not words: ${verdict.notWords.join(", ")}` : verdict.apart.size > 0 ? "Every tile must join the one crossword." : "";
237
+ notes.textContent = verdict.notWords.length > 0 ? kumimojiSay(say.notWords, { words: verdict.notWords.join(", ") }) : verdict.apart.size > 0 ? say.apart : "";
154
238
  }
155
239
  function renderHand() {
156
240
  hand.replaceChildren();
241
+ hand.setAttribute("aria-label", say.handLabel);
157
242
  play.hand.forEach((tile, at) => {
158
243
  const face = tileFace(tile);
159
244
  const made = node("button", "km-tile km-in-hand");
@@ -164,11 +249,12 @@ export function mountKumimoji(target, options = {}) {
164
249
  made.classList.add("km-wild");
165
250
  if (held?.from === "hand" && held.at === at)
166
251
  made.classList.add("km-held");
167
- made.setAttribute("aria-label", face.blank ? "Wild, no letter yet" : `${words.glyphOf(tile)}${face.wild ? ", wild" : ""}`);
252
+ made.dataset.tile = face.blank ? "*" : words.glyphOf(tile);
253
+ made.setAttribute("aria-label", face.blank ? say.wildBlank : face.wild ? kumimojiSay(say.wildTile, { tile: words.glyphOf(tile) }) : words.glyphOf(tile));
168
254
  hand.append(made);
169
255
  });
170
256
  if (play.hand.length === 0 && finishedAt === null)
171
- hand.append(node("span", "km-empty", "No tiles in hand"));
257
+ hand.append(node("span", "km-empty", say.noTiles));
172
258
  }
173
259
  function renderPicker() {
174
260
  picker.replaceChildren();
@@ -178,7 +264,7 @@ export function mountKumimoji(target, options = {}) {
178
264
  const tile = play.hand[at];
179
265
  if (tile === undefined || !tileFace(tile).wild)
180
266
  return;
181
- picker.append(node("span", "km-picker-title", tile === "*" ? "Give the wild a letter:" : "Change the wild's letter:"));
267
+ picker.append(node("span", "km-picker-title", tile === "*" ? say.giveWild : say.changeWild));
182
268
  for (const sound of words.wildOptions) {
183
269
  const code = words.wildFor(sound);
184
270
  if (code === null)
@@ -192,32 +278,43 @@ export function mountKumimoji(target, options = {}) {
192
278
  function renderControls(verdict) {
193
279
  controls.replaceChildren();
194
280
  if (finishedAt !== null) {
195
- controls.append(button("New game", () => void handle.newGame(), true));
281
+ controls.append(button(say.newGame, () => void handle.newGame(), true, "km-new"));
196
282
  return;
197
283
  }
198
284
  const now = play;
199
- const drawButton = button("Draw", () => {
285
+ const drawButton = button(say.draw, () => {
200
286
  held = null;
201
287
  change(draw(now));
202
- }, true);
288
+ }, true, "km-draw");
203
289
  drawButton.disabled = !mayDraw(now, verdict);
204
- const tradeButton = button("Trade for three", () => {
290
+ const tradeButton = button(say.trade, () => {
205
291
  if (held?.from !== "hand")
206
292
  return;
207
293
  const at = held.at;
208
294
  held = null;
209
295
  change(trade(now, at));
210
- });
296
+ }, false, "km-trade");
211
297
  tradeButton.disabled = held?.from !== "hand" || !mayTrade(now);
212
- controls.append(drawButton, tradeButton, button("Sort", () => change(sortHand(now))), button("Lift all", () => {
298
+ controls.append(drawButton, tradeButton, button(say.sort, () => change(sortHand(now)), false, "km-sort"), button(say.liftAll, () => {
213
299
  held = null;
214
300
  change(liftAll(now));
215
- }), button("New game", () => void handle.newGame()));
301
+ }, false, "km-lift"), button(say.newGame, () => void handle.newGame(), false, "km-new"));
302
+ }
303
+ function renderKeep() {
304
+ keepTitle.textContent = say.keep;
305
+ saveJson.textContent = say.saveJson;
306
+ saveText.textContent = say.saveText;
307
+ load.textContent = say.load;
308
+ saveJson.disabled = play === null;
309
+ saveText.disabled = play === null;
310
+ keepNote.textContent = note;
216
311
  }
217
312
  function render() {
218
313
  if (destroyed)
219
314
  return;
315
+ root.lang = locale;
220
316
  renderStatus();
317
+ renderKeep();
221
318
  if (play === null || words === null)
222
319
  return;
223
320
  const verdict = verdictOf(play);
@@ -228,20 +325,54 @@ export function mountKumimoji(target, options = {}) {
228
325
  }
229
326
  const handle = {
230
327
  play: () => play,
328
+ saved: savedNow,
329
+ setGame: async (saved) => {
330
+ const mine = ++asked;
331
+ const language = saved.deal.language;
332
+ settings = { language, hand: saved.deal.size, level: saved.deal.level, gameLength: saved.deal.gameLength, diagonals: saved.deal.diagonals === true, seed: undefined };
333
+ const list = await loadTileWords(language);
334
+ if (destroyed || mine !== asked)
335
+ return;
336
+ words = list;
337
+ root.classList.toggle("km-japanese", language === "japanese");
338
+ dealt = saved.deal;
339
+ root.dataset.seed = String(saved.deal.seed);
340
+ held = null;
341
+ finishedAt = null;
342
+ started = Date.now() - saved.elapsedMs;
343
+ // A game loaded already finished stays finished, at the time it took, and is not announced again.
344
+ if (isFinished(saved.play, judgeWithWords(saved.play.tiles, words, { diagonals: settings.diagonals === true })))
345
+ finishedAt = Date.now();
346
+ play = saved.play;
347
+ note = "";
348
+ options.onChange?.(saved);
349
+ render();
350
+ },
351
+ setLocale: (next, strings) => {
352
+ locale = next;
353
+ if (strings !== undefined)
354
+ own = strings;
355
+ say = kumimojiStrings(locale, own);
356
+ render();
357
+ },
231
358
  newGame: async (changed = {}) => {
359
+ const mine = ++asked;
232
360
  settings = { ...settings, ...changed, seed: undefined };
233
361
  const language = settings.language ?? "english";
234
362
  const size = settings.hand ?? KUMIMOJI_HANDS.quick;
235
363
  play = null;
236
364
  held = null;
237
365
  finishedAt = null;
366
+ note = "";
238
367
  render();
239
- words = await loadTileWords(language);
240
- if (destroyed)
368
+ const list = await loadTileWords(language);
369
+ if (destroyed || mine !== asked)
241
370
  return;
371
+ words = list;
242
372
  // A seed deals one game: the next is dealt from a new one unless asked for by seed again.
243
373
  const seed = changed.seed ?? Math.floor(Math.random() * 2 ** 31);
244
374
  root.classList.toggle("km-japanese", language === "japanese");
375
+ root.dataset.seed = String(seed);
245
376
  dealt = generateKumimoji(size, settings.level ?? "medium", seed, { language, gameLength: settings.gameLength, diagonals: settings.diagonals });
246
377
  started = Date.now();
247
378
  change(deal(dealt.givens, size));
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The table's own styles, scoped to `.km-root`, every colour a CSS variable
3
- * so a page can wear it in its own colours. Light and dark follow the
4
- * reader's system.
2
+ * The table's own styles, scoped to `.km-root`, every colour and size a CSS
3
+ * variable so a page can wear it in its own colours (set them on the element
4
+ * or any ancestor, or pass them as `theme`). Light and dark follow the
5
+ * reader's system. Everything to be tapped is at least 44px.
5
6
  */
6
- export declare const KUMIMOJI_STYLE = "\n.km-root {\n --km-square: 36px; --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-apart: #9aa3a0; --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 display: grid; gap: 10px; color: var(--km-ink); font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; 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-status { margin: 0; min-height: 1.4em; font-variant-numeric: tabular-nums; }\n.km-board { background: var(--km-felt); border-radius: 12px; 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; }\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 20px/1 system-ui, sans-serif; text-transform: uppercase;\n box-shadow: 0 2px 0 rgba(0,0,0,.35); cursor: pointer; }\n.km-japanese .km-tile { font-size: 19px; 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-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; }\n.km-hand { display: flex; flex-wrap: wrap; gap: 6px; min-height: 40px; 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: 4px 8px; min-width: 32px; 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; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; }\n.km-button:disabled { opacity: .45; cursor: default; }\n.km-primary { background: var(--km-accent); color: var(--km-accent-ink); border-color: transparent; }\n";
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-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";