@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/showcase.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BASE_KANA, CORNER_FORMS, tileKana } from "./kana.js";
2
2
  import { kanaTileCode } from "./tileFace.js";
3
- import { JAPANESE_TILE_MIX, KUMIMOJI_HANDS, TILE_MIX, kumimojiTileCount, kumimojiWildCount } from "./tiles.constants.js";
3
+ import { JAPANESE_TILE_MIX, KUMIMOJI_HANDS, kumimojiTileCount, kumimojiWildCount, TILE_MIX, } from "./tiles.constants.js";
4
4
  /** A language's set as a page shows it: every tile with its count, the total, and which are the rarest and the commonest.
5
5
  *
6
6
  * @param language - The English or Japanese tile language.
@@ -17,7 +17,12 @@ import { JAPANESE_TILE_MIX, KUMIMOJI_HANDS, TILE_MIX, kumimojiTileCount, kumimoj
17
17
  export function mixShown(language) {
18
18
  const tiles = language === "english"
19
19
  ? Object.entries(TILE_MIX).map(([letter, count]) => ({ code: letter, glyph: letter, forms: "", count }))
20
- : [...BASE_KANA].map((kana) => ({ code: kanaTileCode(kana), glyph: kana, forms: CORNER_FORMS[kana] ?? "", count: JAPANESE_TILE_MIX[kana] ?? 0 }));
20
+ : [...BASE_KANA].map((kana) => ({
21
+ code: kanaTileCode(kana),
22
+ glyph: kana,
23
+ forms: CORNER_FORMS[kana] ?? "",
24
+ count: JAPANESE_TILE_MIX[kana] ?? 0,
25
+ }));
21
26
  const counts = tiles.map((tile) => tile.count);
22
27
  const most = Math.max(...counts);
23
28
  const fewest = Math.min(...counts);
@@ -87,7 +92,11 @@ export function lengthRows(hand = KUMIMOJI_HANDS.classic) {
87
92
  length,
88
93
  tiles,
89
94
  doubleTiles: kumimojiTileCount(hand, length, undefined, true),
90
- wilds: { easy: kumimojiWildCount(hand, "easy", tiles), medium: kumimojiWildCount(hand, "medium", tiles), hard: kumimojiWildCount(hand, "hard", tiles) },
95
+ wilds: {
96
+ easy: kumimojiWildCount(hand, "easy", tiles),
97
+ medium: kumimojiWildCount(hand, "medium", tiles),
98
+ hard: kumimojiWildCount(hand, "hard", tiles),
99
+ },
91
100
  };
92
101
  });
93
102
  }
package/dist/tileWords.js CHANGED
@@ -51,7 +51,7 @@ export function unpackTileWords(data) {
51
51
  }
52
52
  const letters = [...Object.keys(TILE_MIX)];
53
53
  const assigned = new Map(letters.map((letter) => [letter.toUpperCase(), letter]));
54
- const soundOf = (tile) => (tile === "*" ? null : assigned.get(tile) ?? (/^[a-z]$/.test(tile) ? tile : null));
54
+ const soundOf = (tile) => tile === "*" ? null : (assigned.get(tile) ?? (/^[a-z]$/.test(tile) ? tile : null));
55
55
  const isWild = (tile) => tile === "*" || assigned.has(tile);
56
56
  return {
57
57
  language: "english",
@@ -94,8 +94,12 @@ function unpackJapaneseWords(data) {
94
94
  allowed.add([...word].map((tile) => tileFace(tile).glyph).join(""));
95
95
  }
96
96
  const mix = new Map(Object.entries(JAPANESE_TILE_MIX).map(([face, count]) => [kanaTileCode(face), count]));
97
- const isTile = (tile) => tile.length === 1 && tile.codePointAt(0) - KANA_TILE_START >= 0 && tile.codePointAt(0) - KANA_TILE_START < kana.length;
98
- const isAssignedWild = (tile) => tile.length === 1 && tile.codePointAt(0) - KANA_WILD_START >= 0 && tile.codePointAt(0) - KANA_WILD_START < kana.length;
97
+ const isTile = (tile) => tile.length === 1 &&
98
+ tile.codePointAt(0) - KANA_TILE_START >= 0 &&
99
+ tile.codePointAt(0) - KANA_TILE_START < kana.length;
100
+ const isAssignedWild = (tile) => tile.length === 1 &&
101
+ tile.codePointAt(0) - KANA_WILD_START >= 0 &&
102
+ tile.codePointAt(0) - KANA_WILD_START < kana.length;
99
103
  const isWild = (tile) => tile === "*" || isAssignedWild(tile);
100
104
  const soundOf = (tile) => (isTile(tile) || isAssignedWild(tile) ? tileFace(tile).glyph : null);
101
105
  const identity = (tile) => (isWild(tile) ? "*" : isTile(tile) ? tileFace(tile).glyph : null);
@@ -196,9 +200,10 @@ export async function loadTileWords(language = "english") {
196
200
  return already;
197
201
  let words;
198
202
  if (typeof window !== "undefined") {
199
- words = language === "english"
200
- ? unpackTileWords((await import("./words.en.data.js")).TILE_WORDS_EN)
201
- : unpackJapaneseWords((await import("./words.ja.data.js")).TILE_WORDS_JA);
203
+ words =
204
+ language === "english"
205
+ ? unpackTileWords((await import("./words.en.data.js")).TILE_WORDS_EN)
206
+ : unpackJapaneseWords((await import("./words.ja.data.js")).TILE_WORDS_JA);
202
207
  }
203
208
  else {
204
209
  if (fromModule === null)
@@ -1,5 +1,5 @@
1
- import { type TileWords } from "./tileWords.ts";
2
1
  import type { KumimojiLanguage } from "./kumimoji.types.ts";
2
+ import { type TileWords } from "./tileWords.ts";
3
3
  /** The list, read from its module: `loadTileWords` for a caller with no browser.
4
4
  *
5
5
  * @param language - The English or Japanese tile language.
@@ -6,7 +6,9 @@ import { loadTileWords, readTileWordsWith, tileWordsFrom } from "./tileWords.js"
6
6
  * page's components never import it, so no page's server function carries the
7
7
  * lists for the browser's sake (see `loadTileWords`).
8
8
  */
9
- readTileWordsWith(async (language) => tileWordsFrom(language, language === "english" ? (await import("./words.en.data.js")).TILE_WORDS_EN : (await import("./words.ja.data.js")).TILE_WORDS_JA));
9
+ readTileWordsWith(async (language) => tileWordsFrom(language, language === "english"
10
+ ? (await import("./words.en.data.js")).TILE_WORDS_EN
11
+ : (await import("./words.ja.data.js")).TILE_WORDS_JA));
10
12
  /** The list, read from its module: `loadTileWords` for a caller with no browser.
11
13
  *
12
14
  * @param language - The English or Japanese tile language.
@@ -21,8 +21,32 @@
21
21
  * ```
22
22
  */
23
23
  export const TILE_MIX = {
24
- a: 13, b: 3, c: 3, d: 6, e: 18, f: 3, g: 4, h: 3, i: 12, j: 2, k: 2, l: 5, m: 3,
25
- n: 8, o: 11, p: 3, q: 2, r: 9, s: 6, t: 9, u: 6, v: 3, w: 3, x: 2, y: 3, z: 2,
24
+ a: 13,
25
+ b: 3,
26
+ c: 3,
27
+ d: 6,
28
+ e: 18,
29
+ f: 3,
30
+ g: 4,
31
+ h: 3,
32
+ i: 12,
33
+ j: 2,
34
+ k: 2,
35
+ l: 5,
36
+ m: 3,
37
+ n: 8,
38
+ o: 11,
39
+ p: 3,
40
+ q: 2,
41
+ r: 9,
42
+ s: 6,
43
+ t: 9,
44
+ u: 6,
45
+ v: 3,
46
+ w: 3,
47
+ x: 2,
48
+ y: 3,
49
+ z: 2,
26
50
  };
27
51
  /**
28
52
  * THE JAPANESE SET: 144 hiragana tiles in 45 kinds (`kana.ts`), as the English
@@ -45,11 +69,51 @@ export const TILE_MIX = {
45
69
  * ```
46
70
  */
47
71
  export const JAPANESE_TILE_MIX = {
48
- あ: 2, い: 11, う: 12, え: 1, お: 2, か: 7, き: 6, く: 6, け: 3, こ: 4,
49
- さ: 3, し: 10, す: 2, せ: 3, そ: 2, た: 4, ち: 3, つ: 6, て: 2, と: 3,
50
- な: 1, に: 1, ぬ: 1, ね: 1, の: 1, は: 2, ひ: 2, ふ: 2, へ: 1, ほ: 1,
51
- ま: 2, み: 1, む: 1, め: 1, も: 1, や: 2, ゆ: 4, よ: 6, ら: 1, り: 3,
52
- る: 3, れ: 1, ろ: 1, わ: 1, ん: 11,
72
+ あ: 2,
73
+ い: 11,
74
+ う: 12,
75
+ え: 1,
76
+ お: 2,
77
+ か: 7,
78
+ き: 6,
79
+ く: 6,
80
+ け: 3,
81
+ こ: 4,
82
+ さ: 3,
83
+ し: 10,
84
+ す: 2,
85
+ せ: 3,
86
+ そ: 2,
87
+ た: 4,
88
+ ち: 3,
89
+ つ: 6,
90
+ て: 2,
91
+ と: 3,
92
+ な: 1,
93
+ に: 1,
94
+ ぬ: 1,
95
+ ね: 1,
96
+ の: 1,
97
+ は: 2,
98
+ ひ: 2,
99
+ ふ: 2,
100
+ へ: 1,
101
+ ほ: 1,
102
+ ま: 2,
103
+ み: 1,
104
+ む: 1,
105
+ め: 1,
106
+ も: 1,
107
+ や: 2,
108
+ ゆ: 4,
109
+ よ: 6,
110
+ ら: 1,
111
+ り: 3,
112
+ る: 3,
113
+ れ: 1,
114
+ ろ: 1,
115
+ わ: 1,
116
+ ん: 11,
53
117
  };
54
118
  /** The whole set, counted from the table.
55
119
  *
@@ -203,7 +267,7 @@ export const KUMIMOJI_TRADE = { give: 1, take: 3 };
203
267
  * console.log(KUMIMOJI_SCORE);
204
268
  * ```
205
269
  */
206
- export const KUMIMOJI_SCORE = { tile: 10, slowestMsATile: 30000 };
270
+ export const KUMIMOJI_SCORE = { tile: 10, slowestMsATile: 30_000 };
207
271
  /**
208
272
  * PASS AND PLAY: up to eight people round one device, each with a hand and a
209
273
  * table of their own from one shared bag (`party.ts`). John, 2026-09-28: "pass
package/dist/turn.js CHANGED
@@ -92,7 +92,12 @@ export function unturnPlace(row, col, turn) {
92
92
  export function turnArea(area, turn) {
93
93
  const a = turnPlace(area.top, area.left, turn);
94
94
  const b = turnPlace(area.top + area.rows - 1, area.left + area.cols - 1, turn);
95
- return { top: Math.min(a.row, b.row), left: Math.min(a.col, b.col), rows: Math.abs(a.row - b.row) + 1, cols: Math.abs(a.col - b.col) + 1 };
95
+ return {
96
+ top: Math.min(a.row, b.row),
97
+ left: Math.min(a.col, b.col),
98
+ rows: Math.abs(a.row - b.row) + 1,
99
+ cols: Math.abs(a.col - b.col) + 1,
100
+ };
96
101
  }
97
102
  /**
98
103
  * A view the player zoomed or panned, turned a quarter clockwise with the
package/dist/ui/board.js CHANGED
@@ -55,8 +55,22 @@ export function boardModel(tiles, verdict, glyphOf = (tile) => tileFace(tile).gl
55
55
  for (let col = left; col < left + cols; col += 1) {
56
56
  const square = squareAt(row, col);
57
57
  const tile = tiles.get(square) ?? null;
58
- const mark = tile === null || verdict === null ? null : verdict.misspelt.has(square) ? "misspelt" : verdict.apart.has(square) ? "apart" : "sound";
59
- squares.push({ square, row, col, tile, glyph: tile === null ? "" : glyphOf(tile), wild: tile !== null && tileFace(tile).wild, mark });
58
+ const mark = tile === null || verdict === null
59
+ ? null
60
+ : verdict.misspelt.has(square)
61
+ ? "misspelt"
62
+ : verdict.apart.has(square)
63
+ ? "apart"
64
+ : "sound";
65
+ squares.push({
66
+ square,
67
+ row,
68
+ col,
69
+ tile,
70
+ glyph: tile === null ? "" : glyphOf(tile),
71
+ wild: tile !== null && tileFace(tile).wild,
72
+ mark,
73
+ });
60
74
  }
61
75
  }
62
76
  return { rows, cols, squares };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * THE CLOTHS A TABLE MAY BE LAID IN: the same five the whole family offers, and itsutsu.com's boards:
3
+ * green, blue, red, black and wood. Each is the felt's colour, its deep edge and the ink written on it.
4
+ * Given to a table as its `--km-felt` and `--km-accent` variables; `theme` is laid over it.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { KUMIMOJI_CLOTHS } from "@johnmorrisdotca/kumimoji/ui";
9
+ * console.log(Object.keys(KUMIMOJI_CLOTHS));
10
+ * ```
11
+ */
12
+ export declare const KUMIMOJI_CLOTHS: {
13
+ readonly green: {
14
+ readonly felt: "#2f5d4a";
15
+ readonly deep: "#1f4135";
16
+ readonly ink: "#f3efe4";
17
+ };
18
+ readonly blue: {
19
+ readonly felt: "#2865a6";
20
+ readonly deep: "#1a4677";
21
+ readonly ink: "#f3efe4";
22
+ };
23
+ readonly red: {
24
+ readonly felt: "#a3342e";
25
+ readonly deep: "#7a231f";
26
+ readonly ink: "#f3efe4";
27
+ };
28
+ readonly black: {
29
+ readonly felt: "#2f3236";
30
+ readonly deep: "#1b1d20";
31
+ readonly ink: "#ece8dc";
32
+ };
33
+ readonly wood: {
34
+ readonly felt: "#e2ba7a";
35
+ readonly deep: "#c4954f";
36
+ readonly ink: "#2b1d0e";
37
+ };
38
+ };
39
+ /** A cloth's name.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import type { Cloth } from "@johnmorrisdotca/kumimoji/ui";
44
+ * const value: Cloth = "green";
45
+ * console.log(value);
46
+ * ```
47
+ */
48
+ export type Cloth = keyof typeof KUMIMOJI_CLOTHS;
49
+ /** Whether a text names a cloth.
50
+ *
51
+ * @param text - Text to parse, validate or format.
52
+ * @returns Whether a text names a cloth.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { isCloth } from "@johnmorrisdotca/kumimoji/ui";
57
+ * console.log(isCloth("green"), isCloth("unknown"));
58
+ * ```
59
+ */
60
+ export declare function isCloth(text: unknown): text is Cloth;
61
+ /** A cloth as CSS variables; nothing for a name that is not a cloth, so the defaults stay.
62
+ *
63
+ * @param cloth - A built-in cloth name; an unknown name leaves the defaults.
64
+ * @returns The CSS variables to set on the element, or an empty object.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * import { clothVars } from "@johnmorrisdotca/kumimoji/ui";
69
+ * console.log(clothVars("green"));
70
+ * ```
71
+ */
72
+ export declare function clothVars(cloth: string | undefined | null): Record<`--km-${string}`, string>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * THE CLOTHS A TABLE MAY BE LAID IN: the same five the whole family offers, and itsutsu.com's boards:
3
+ * green, blue, red, black and wood. Each is the felt's colour, its deep edge and the ink written on it.
4
+ * Given to a table as its `--km-felt` and `--km-accent` variables; `theme` is laid over it.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { KUMIMOJI_CLOTHS } from "@johnmorrisdotca/kumimoji/ui";
9
+ * console.log(Object.keys(KUMIMOJI_CLOTHS));
10
+ * ```
11
+ */
12
+ export const KUMIMOJI_CLOTHS = {
13
+ green: { felt: "#2f5d4a", deep: "#1f4135", ink: "#f3efe4" },
14
+ blue: { felt: "#2865a6", deep: "#1a4677", ink: "#f3efe4" },
15
+ red: { felt: "#a3342e", deep: "#7a231f", ink: "#f3efe4" },
16
+ black: { felt: "#2f3236", deep: "#1b1d20", ink: "#ece8dc" },
17
+ wood: { felt: "#e2ba7a", deep: "#c4954f", ink: "#2b1d0e" },
18
+ };
19
+ /** Whether a text names a cloth.
20
+ *
21
+ * @param text - Text to parse, validate or format.
22
+ * @returns Whether a text names a cloth.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { isCloth } from "@johnmorrisdotca/kumimoji/ui";
27
+ * console.log(isCloth("green"), isCloth("unknown"));
28
+ * ```
29
+ */
30
+ export function isCloth(text) {
31
+ return typeof text === "string" && Object.hasOwn(KUMIMOJI_CLOTHS, text);
32
+ }
33
+ /** A cloth as CSS variables; nothing for a name that is not a cloth, so the defaults stay.
34
+ *
35
+ * @param cloth - A built-in cloth name; an unknown name leaves the defaults.
36
+ * @returns The CSS variables to set on the element, or an empty object.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { clothVars } from "@johnmorrisdotca/kumimoji/ui";
41
+ * console.log(clothVars("green"));
42
+ * ```
43
+ */
44
+ export function clothVars(cloth) {
45
+ if (!isCloth(cloth))
46
+ return {};
47
+ const { felt, ink } = KUMIMOJI_CLOTHS[cloth];
48
+ return { "--km-felt": felt, "--km-accent": felt, "--km-accent-ink": ink };
49
+ }
@@ -0,0 +1,62 @@
1
+ import type { GridVerdict } from "../grid.ts";
2
+ import { type TilePlay } from "../play.ts";
3
+ import { type KumimojiLocale, type KumimojiStrings } from "../strings.ts";
4
+ import type { TileWords } from "../tileWords.ts";
5
+ /** What is picked up: a tile in the hand, or one on the table. */
6
+ export type Held = {
7
+ from: "hand";
8
+ at: number;
9
+ } | {
10
+ from: "table";
11
+ square: string;
12
+ } | null;
13
+ export declare function node<K extends keyof HTMLElementTagNameMap>(name: K, className?: string, text?: string): HTMLElementTagNameMap[K];
14
+ export declare function button(text: string, onClick: () => void, primary?: boolean, testId?: string): HTMLButtonElement;
15
+ /** The game as the drawing reads it, shared with the mount so a move and a redraw see the same table. */
16
+ export type TableSession = {
17
+ play: TilePlay | null;
18
+ finishedAt: number | null;
19
+ started: number;
20
+ words: TileWords | null;
21
+ held: Held;
22
+ onSquare: string | null;
23
+ onHand: number;
24
+ say: KumimojiStrings;
25
+ locale: KumimojiLocale;
26
+ note: string;
27
+ destroyed: boolean;
28
+ };
29
+ /** The elements the mount built once; a redraw fills them and never replaces them. */
30
+ export type TableParts = {
31
+ root: HTMLElement;
32
+ status: HTMLElement;
33
+ board: HTMLElement;
34
+ notes: HTMLElement;
35
+ hand: HTMLElement;
36
+ picker: HTMLElement;
37
+ controls: HTMLElement;
38
+ keepTitle: HTMLElement;
39
+ saveJson: HTMLButtonElement;
40
+ saveText: HTMLButtonElement;
41
+ load: HTMLButtonElement;
42
+ keepNote: HTMLElement;
43
+ };
44
+ /** What a button does to the game. The mount owns the rules; the drawing only asks. */
45
+ export type TableActions = {
46
+ verdictOf: (now: TilePlay) => GridVerdict;
47
+ change: (next: TilePlay) => void;
48
+ newGame: () => void;
49
+ };
50
+ /**
51
+ * Draw the table from the session and the elements the mount built: the status
52
+ * line, the board, the hand, the wild-tile picker, the play buttons and the
53
+ * save labels, then put the keyboard focus back on the control it was on.
54
+ * @param session - The game, the words and what is held, shared with the mount.
55
+ * @param parts - The elements the drawing fills.
56
+ * @param actions - How a button changes the game or starts another.
57
+ * @returns The redraw, and the status line alone for the clock.
58
+ */
59
+ export declare function tableDrawing(session: TableSession, parts: TableParts, actions: TableActions): {
60
+ render: () => void;
61
+ renderStatus: () => void;
62
+ };
@@ -0,0 +1,229 @@
1
+ import { kumimojiClock } from "../export.js";
2
+ import { assignHandTile, draw, liftAll, mayDraw, mayTrade, sortHand, tilesLeft, trade, } from "../play.js";
3
+ import { kumimojiSay } from "../strings.js";
4
+ import { tileFace } from "../tileFace.js";
5
+ import { boardModel } from "./board.js";
6
+ /** What a wild nobody has given a letter yet shows in the hand. */
7
+ const WILD_MARK = "★";
8
+ export function node(name, className, text) {
9
+ const made = document.createElement(name);
10
+ if (className !== undefined)
11
+ made.className = className;
12
+ if (text !== undefined)
13
+ made.textContent = text;
14
+ return made;
15
+ }
16
+ export function button(text, onClick, primary = false, testId) {
17
+ const made = node("button", primary ? "km-button km-primary" : "km-button", text);
18
+ made.type = "button";
19
+ if (testId !== undefined)
20
+ made.dataset.testid = testId;
21
+ made.addEventListener("click", onClick);
22
+ return made;
23
+ }
24
+ /**
25
+ * Draw the table from the session and the elements the mount built: the status
26
+ * line, the board, the hand, the wild-tile picker, the play buttons and the
27
+ * save labels, then put the keyboard focus back on the control it was on.
28
+ * @param session - The game, the words and what is held, shared with the mount.
29
+ * @param parts - The elements the drawing fills.
30
+ * @param actions - How a button changes the game or starts another.
31
+ * @returns The redraw, and the status line alone for the clock.
32
+ */
33
+ export function tableDrawing(session, parts, actions) {
34
+ function renderStatus() {
35
+ const now = session.play;
36
+ parts.root.dataset.state = now === null ? "dealing" : session.finishedAt !== null ? "finished" : "playing";
37
+ if (now === null) {
38
+ parts.status.textContent = session.say.dealing;
39
+ return;
40
+ }
41
+ const left = tilesLeft(now);
42
+ parts.root.dataset.left = String(left);
43
+ const time = kumimojiClock((session.finishedAt ?? Date.now()) - session.started);
44
+ parts.status.textContent =
45
+ session.finishedAt !== null
46
+ ? kumimojiSay(session.say.finished, { time })
47
+ : kumimojiSay(left === 1 ? session.say.leftOne : session.say.left, { n: left, time });
48
+ }
49
+ function renderBoard(verdict) {
50
+ const playing = session.play;
51
+ const words = session.words;
52
+ const model = boardModel(playing.tiles, playing.tiles.size === 0 ? null : verdict, words.glyphOf);
53
+ const grid = node("div", "km-grid");
54
+ grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
55
+ grid.setAttribute("role", "group");
56
+ grid.setAttribute("aria-label", session.say.tableLabel);
57
+ grid.setAttribute("aria-description", session.say.tableKeys);
58
+ grid.dataset.cols = String(model.cols);
59
+ const cells = [];
60
+ for (const square of model.squares) {
61
+ const cell = node("button", "km-square");
62
+ cell.type = "button";
63
+ cell.dataset.square = square.square;
64
+ if (square.tile !== null) {
65
+ cell.classList.add("km-tile");
66
+ if (square.wild)
67
+ cell.classList.add("km-wild");
68
+ if (square.mark !== null)
69
+ cell.classList.add(`km-${square.mark}`);
70
+ cell.textContent = square.glyph;
71
+ cell.dataset.tile = square.glyph;
72
+ cell.setAttribute("aria-label", square.wild ? kumimojiSay(session.say.wildTile, { tile: square.glyph }) : square.glyph);
73
+ }
74
+ else {
75
+ cell.setAttribute("aria-label", session.say.emptySquare);
76
+ }
77
+ if (session.held?.from === "table" && session.held.square === square.square)
78
+ cell.classList.add("km-held");
79
+ cell.tabIndex = -1;
80
+ cells.push(cell);
81
+ grid.append(cell);
82
+ }
83
+ // The one Tab lands on: where the keyboard was, else a tile, else the middle.
84
+ const stop = cells.find((cell) => cell.dataset.square === session.onSquare) ??
85
+ cells.find((cell) => cell.classList.contains("km-tile")) ??
86
+ cells[Math.floor(cells.length / 2)];
87
+ if (stop !== undefined) {
88
+ stop.tabIndex = 0;
89
+ session.onSquare = stop.dataset.square ?? null;
90
+ }
91
+ parts.board.replaceChildren(grid);
92
+ parts.notes.textContent =
93
+ verdict.notWords.length > 0
94
+ ? kumimojiSay(session.say.notWords, { words: verdict.notWords.join(", ") })
95
+ : verdict.apart.size > 0
96
+ ? session.say.apart
97
+ : "";
98
+ }
99
+ function renderHand() {
100
+ const playing = session.play;
101
+ const words = session.words;
102
+ parts.hand.replaceChildren();
103
+ parts.hand.setAttribute("aria-label", session.say.handLabel);
104
+ playing.hand.forEach((tile, at) => {
105
+ const face = tileFace(tile);
106
+ const made = node("button", "km-tile km-in-hand");
107
+ made.type = "button";
108
+ made.dataset.hand = String(at);
109
+ made.textContent = face.blank ? WILD_MARK : words.glyphOf(tile);
110
+ if (face.wild)
111
+ made.classList.add("km-wild");
112
+ if (session.held?.from === "hand" && session.held.at === at)
113
+ made.classList.add("km-held");
114
+ made.dataset.tile = face.blank ? "*" : words.glyphOf(tile);
115
+ made.setAttribute("aria-label", face.blank
116
+ ? session.say.wildBlank
117
+ : face.wild
118
+ ? kumimojiSay(session.say.wildTile, { tile: words.glyphOf(tile) })
119
+ : words.glyphOf(tile));
120
+ made.tabIndex = at === Math.min(session.onHand, playing.hand.length - 1) ? 0 : -1;
121
+ parts.hand.append(made);
122
+ });
123
+ if (playing.hand.length === 0 && session.finishedAt === null)
124
+ parts.hand.append(node("span", "km-empty", session.say.noTiles));
125
+ }
126
+ function renderPicker() {
127
+ parts.picker.replaceChildren();
128
+ if (session.held?.from !== "hand" || session.play === null)
129
+ return;
130
+ const at = session.held.at;
131
+ const tile = session.play.hand[at];
132
+ if (tile === undefined || !tileFace(tile).wild)
133
+ return;
134
+ parts.picker.append(node("span", "km-picker-title", tile === "*" ? session.say.giveWild : session.say.changeWild));
135
+ const words = session.words;
136
+ for (const sound of words.wildOptions) {
137
+ const code = words.wildFor(sound);
138
+ if (code === null)
139
+ continue;
140
+ parts.picker.append(button(sound, () => {
141
+ session.held = { from: "hand", at };
142
+ actions.change(assignHandTile(session.play, at, code));
143
+ }));
144
+ }
145
+ }
146
+ function renderControls(verdict) {
147
+ parts.controls.replaceChildren();
148
+ if (session.finishedAt !== null) {
149
+ parts.controls.append(button(session.say.newGame, () => actions.newGame(), true, "km-new"));
150
+ return;
151
+ }
152
+ const now = session.play;
153
+ const drawButton = button(session.say.draw, () => {
154
+ session.held = null;
155
+ actions.change(draw(now));
156
+ }, true, "km-draw");
157
+ drawButton.disabled = !mayDraw(now, verdict);
158
+ const tradeButton = button(session.say.trade, () => {
159
+ if (session.held?.from !== "hand")
160
+ return;
161
+ const at = session.held.at;
162
+ session.held = null;
163
+ actions.change(trade(now, at));
164
+ }, false, "km-trade");
165
+ tradeButton.disabled = session.held?.from !== "hand" || !mayTrade(now);
166
+ parts.controls.append(drawButton, tradeButton, button(session.say.sort, () => actions.change(sortHand(now)), false, "km-sort"), button(session.say.liftAll, () => {
167
+ session.held = null;
168
+ actions.change(liftAll(now));
169
+ }, false, "km-lift"), button(session.say.newGame, () => actions.newGame(), false, "km-new"));
170
+ }
171
+ function renderKeep() {
172
+ parts.keepTitle.textContent = session.say.keep;
173
+ parts.saveJson.textContent = session.say.saveJson;
174
+ parts.saveText.textContent = session.say.saveText;
175
+ parts.load.textContent = session.say.load;
176
+ parts.saveJson.disabled = session.play === null;
177
+ parts.saveText.disabled = session.play === null;
178
+ parts.keepNote.textContent = session.note;
179
+ }
180
+ /** Where the keyboard is, so that it can be put back after everything is drawn again: the zone, and which of it. */
181
+ function keyboardAt() {
182
+ const on = document.activeElement;
183
+ if (on === null || !parts.root.contains(on))
184
+ return null;
185
+ const mark = (zone, id) => id === undefined ? null : { zone, id };
186
+ if (parts.board.contains(on))
187
+ return mark("board", on.dataset.square);
188
+ if (parts.hand.contains(on))
189
+ return mark("hand", on.dataset.hand);
190
+ if (parts.controls.contains(on))
191
+ return mark("controls", on.dataset.testid);
192
+ if (parts.picker.contains(on))
193
+ return mark("picker", String([...parts.picker.querySelectorAll("button")].indexOf(on)));
194
+ return null;
195
+ }
196
+ function keyboardBack(at) {
197
+ if (at === null)
198
+ return;
199
+ const zone = { board: parts.board, hand: parts.hand, controls: parts.controls, picker: parts.picker }[at.zone];
200
+ const found = at.zone === "board"
201
+ ? zone.querySelector(`[data-square="${at.id}"]`)
202
+ : at.zone === "hand"
203
+ ? zone.querySelector(`[data-hand="${at.id}"]`)
204
+ : at.zone === "controls"
205
+ ? zone.querySelector(`[data-testid="${at.id}"]`)
206
+ : zone.querySelectorAll("button")[Number(at.id)];
207
+ // A button that has become disabled cannot hold the focus; the table keeps it rather than dropping it on the page.
208
+ if (found !== undefined && found !== null && !found.disabled)
209
+ found.focus({ preventScroll: true });
210
+ }
211
+ function render() {
212
+ if (session.destroyed)
213
+ return;
214
+ const where = keyboardAt();
215
+ parts.root.lang = session.locale;
216
+ renderStatus();
217
+ renderKeep();
218
+ const now = session.play;
219
+ if (now === null || session.words === null)
220
+ return;
221
+ const verdict = actions.verdictOf(now);
222
+ renderBoard(verdict);
223
+ renderPicker();
224
+ renderHand();
225
+ renderControls(verdict);
226
+ keyboardBack(where);
227
+ }
228
+ return { render, renderStatus };
229
+ }
@@ -2,6 +2,7 @@ import { type KumimojiSaved } from "../export.ts";
2
2
  import type { KumimojiDeal, KumimojiLanguage, KumimojiLength, KumimojiLevel } from "../kumimoji.types.ts";
3
3
  import { type TilePlay } from "../play.ts";
4
4
  import { type KumimojiLocale, type KumimojiStrings } from "../strings.ts";
5
+ import { type Cloth } from "./cloth.ts";
5
6
  /**
6
7
  * Optional initial game, presentation, readiness and callback choices.
7
8
  * With nothing, it is a short English game from a hand of seven, with some wild tiles.
@@ -39,6 +40,8 @@ export type KumimojiTableOptions = {
39
40
  strings?: Partial<KumimojiStrings>;
40
41
  /** CSS variables set on the table itself, which win in light and dark alike: `{ "--km-felt": "#23405a" }`. */
41
42
  theme?: Readonly<Record<string, string>>;
43
+ /** The cloth the table is laid in: "green", "blue", "red", "black" or "wood", the family's five. `theme` is laid over it. */
44
+ cloth?: Cloth;
42
45
  /** Whether the table offers saving and loading. True by default. */
43
46
  keep?: boolean;
44
47
  /** Show native play-action buttons; a host hiding them supplies its own actions. */
@@ -63,7 +66,7 @@ export type KumimojiTableHandle = {
63
66
  /** Settles once the initial deal or restored game is ready. */
64
67
  readonly ready: Promise<void>;
65
68
  /** Change presentation and controls without redealing. */
66
- set: (options: Pick<KumimojiTableOptions, "locale" | "strings" | "theme" | "keep" | "controls">) => void;
69
+ set: (options: Pick<KumimojiTableOptions, "locale" | "strings" | "cloth" | "theme" | "keep" | "controls">) => void;
67
70
  /** The game as it stands, or null while it is being dealt. */
68
71
  play: () => TilePlay | null;
69
72
  /** The game as it would be saved (`kumimojiToJSON`), or null while it is being dealt. */