@johnmorrisdotca/kumimoji 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -6,6 +6,38 @@ All notable changes to this project are written down here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.0] - 2026-10-01
10
+
11
+ Nothing that was exported has changed; the rules, the word lists, every deal and every saved game are exactly as they were.
12
+
13
+ ### Added
14
+
15
+ - **`<kumimoji-table>`**, a game alone as a tag, with no framework: `@johnmorrisdotca/kumimoji/element/define` defines it (or `…/element` holds the class alone), and `language`, `hand`, `level`, `length`, `diagonals`, `seed`, `lang` and `keep` are attributes, read again when they change. It fires `kumimoji-change` after every change to the game and `kumimoji-finish` when it is won. A table the rules do not offer draws nothing.
16
+ - **A bag a day**: `kumimojiDailySeed(date)` is the UTC date as a number (2026-10-01 is `20261001`), the same seed for everybody and the same number as Tane's `dailySeed`; `kumimojiDay(date)` writes the day. The tag takes `seed="daily"`, and the demo has Today's game.
17
+ - **Played from a keyboard.** Tab lands on one square of the table and one tile of the hand; the arrow keys move between squares, and Left, Right, Home and End between the hand's tiles; Enter or Space picks up and lays; Escape lets go of a tile. The keyboard stays where it was after each move, on the squares, the hand and the buttons under the table. The table's notes are a polite live region. One new word in both languages (`tableKeys`) describes the table for a screen reader.
18
+ - **Demo:** a table in a tag, Today's game, and Copy link, which copies an address that deals the same game (its seed, tiles, hand, wilds, length and diagonals).
19
+ - **README:** an Accessibility section, "The element", "The day's seed", and the list of all sixteen packages of the family.
20
+ - A *word list* issue template, a pull request template, SECURITY.md and CODE_OF_CONDUCT.md as the family's shared text (held equal by a test), and the family's house rules in CONTRIBUTING.md.
21
+
22
+ ### Changed
23
+
24
+ - **Node 22 or later** (`engines`), where it said 20, which is out of support and was never tested. The package's `sideEffects` also names the file that defines the tag.
25
+ - **A Help switch in the demo.** Beside the language chooser in the family header, shared by every demo. Off (the default) the page is as it was; on, each option row (the tiles, the hand, the wilds) says in one plain line what it does, in English or Japanese, and every button in it has the same words as its hover text. Kept on the device.
26
+ - The README's pictures are taken again, with the Help switch in the header.
27
+
28
+ ## [1.1.1] - 2026-10-01
29
+
30
+ Nothing that was exported has changed.
31
+
32
+ ### Added
33
+
34
+ - **An API reference page**, `api.html` on the demo site: every export of every entry point, with its signature and its doc comment, made from the source when the site is built so it cannot fall behind the code. The README and the demo's header link to it, and a test holds it to the source.
35
+ - **An Architecture section in the README**: how the source is split and what each file is for, held to the real files by a test.
36
+
37
+ ### Changed
38
+
39
+ - The family's footer lists Jarajara.
40
+
9
41
  ## [1.1.0] - 2026-09-30
10
42
 
11
43
  Nothing that was exported has changed, a seed deals the bag it always dealt,
@@ -94,7 +126,8 @@ The first release.
94
126
  `@johnmorrisdotca/kumimoji/react`.
95
127
  - A static demo for GitHub Pages.
96
128
 
97
- [Unreleased]: https://github.com/johnmorrisdotca/kumimoji/compare/v1.1.0...HEAD
129
+ [Unreleased]: https://github.com/johnmorrisdotca/kumimoji/compare/v1.1.1...HEAD
130
+ [1.1.1]: https://github.com/johnmorrisdotca/kumimoji/compare/v1.1.0...v1.1.1
98
131
  [1.1.0]: https://github.com/johnmorrisdotca/kumimoji/releases/tag/v1.1.0
99
132
  [1.0.1]: https://github.com/johnmorrisdotca/kumimoji/releases/tag/v1.0.1
100
133
  [1.0.0]: https://github.com/johnmorrisdotca/kumimoji/releases/tag/v1.0.0
package/CONTRIBUTING.md CHANGED
@@ -21,6 +21,7 @@ pnpm test:table # the demo in real browsers: builds it, then taps it
21
21
  pnpm test:package # npm pack, install the tarball, import and require every entry, read both lists
22
22
  pnpm test:frameworks # the README's examples built in React, Vue, Svelte, Angular and a plain page
23
23
  pnpm site # builds the demo into ./site
24
+ pnpm pictures # re-takes the README's two pictures from the built demo
24
25
  pnpm dlx serve site # or any static server
25
26
  ```
26
27
 
@@ -68,6 +69,16 @@ pnpm dlx serve site # or any static server
68
69
  - One change per pull request, with a line in `CHANGELOG.md` under
69
70
  *Unreleased*.
70
71
 
72
+ ## House rules, shared by every package of the family
73
+
74
+ - Open an issue first for anything bigger than a typo, so that we can agree on the shape before you spend time on it.
75
+ - No runtime dependencies. Every function that plays or checks a game is pure: it returns new values and never changes what it was given.
76
+ - Tests sit beside the code they test. A rule you change has a test that would have caught it.
77
+ - Words a player reads come in English and Japanese. If you cannot write the Japanese, say so in the pull request and someone will.
78
+ - Option values and names are kebab case.
79
+ - Art and sound are CC0 or public domain only, checked at the source, and credited in the README. No GPL or LGPL code.
80
+ - Needs Node 22 or later. A change a user would notice gets a line in `CHANGELOG.md`.
81
+
71
82
  ## Releasing
72
83
 
73
84
  Maintainers bump the version in `package.json` and `src/version.ts` and move
package/README.md CHANGED
@@ -12,11 +12,11 @@ In English and in Japanese kana. The rules as pure, seeded TypeScript, two word
12
12
  <img alt="TypeScript" src="https://img.shields.io/badge/types-TypeScript-3178c6">
13
13
  </p>
14
14
 
15
- <p align="center"><a href="https://johnmorrisdotca.github.io/kumimoji/"><strong>Play a game →</strong></a></p>
15
+ <p align="center"><a href="https://johnmorrisdotca.github.io/kumimoji/"><strong>Play a game →</strong></a> · <a href="https://johnmorrisdotca.github.io/kumimoji/api.html">API reference</a></p>
16
16
 
17
17
  <p align="center">
18
- <img src="docs/desktop.jpg" alt="An English game half way: a crossword on the green table, a hand of tiles under it, and the buttons to draw, trade and sort" width="720">
19
- <img src="docs/phone.jpg" alt="A game in Japanese kana on a phone in dark mode" width="220">
18
+ <img src="docs/desktop.jpg" alt="An English game half way, under the demo's header with its language chooser, five cloth patches and Help switch: the set-up choices, a crossword of SYNDIC and the words that cross it on the green table, a tile in hand under it, and the buttons to draw, trade and sort" width="720">
19
+ <img src="docs/phone.jpg" alt="A game in Japanese kana on a phone in dark mode, in Japanese: a crossword half built on the green table, with a tile in hand and the buttons to draw, trade and sort" width="220">
20
20
  </p>
21
21
 
22
22
  A word game engine for the race to build a crossword. Every player lays their
@@ -92,9 +92,11 @@ Or with nothing to install, [play a game in the demo](https://johnmorrisdotca.gi
92
92
 
93
93
  ## Use it in your project
94
94
 
95
- Kumimoji is four things, each usable without the others: **the rules**, plain
95
+ Kumimoji is five things, each usable without the others: **the rules**, plain
96
96
  functions over plain values; **the word lists**, loaded when first needed;
97
- **a table** you mount into any element; and **React components**.
97
+ **a table** you mount into any element; **the same table as a tag**,
98
+ `<kumimoji-table>`, for a page with no script of its own; and **React
99
+ components**.
98
100
 
99
101
  ### 1. The API alone
100
102
 
@@ -134,7 +136,22 @@ encodeGrid(tiles); // → "cat/2o/2w"
134
136
  Without a bundler, import from the files as they are published:
135
137
  `./node_modules/@johnmorrisdotca/kumimoji/dist/ui.js`, or a copy of `dist/`.
136
138
 
137
- ### 3. React
139
+ ### 3. As a tag
140
+
141
+ ```html
142
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/kumimoji@1/dist/element-define.js"></script>
143
+
144
+ <kumimoji-table language="english" hand="7" seed="2026"></kumimoji-table>
145
+ <kumimoji-table language="japanese" level="easy" seed="daily"></kumimoji-table>
146
+ ```
147
+
148
+ The first is a game of English tiles dealt from a seed; the second is the
149
+ day's bag in kana. A table in a tag needs no framework and no bundler, and a
150
+ page with a framework can use it the same way. Every attribute is listed under
151
+ [The element](#the-element); the table fires `kumimoji-change` after each
152
+ change to the game and `kumimoji-finish` when it is won.
153
+
154
+ ### 4. React
138
155
 
139
156
  ```jsx
140
157
  import { useState } from "react";
@@ -157,7 +174,7 @@ draws an empty box and nothing needs a provider. In Next.js, use it from a
157
174
  client component (`"use client"`). `KumimojiBoard` is the grid alone, for a
158
175
  table of your own: see [The React components](#the-react-components).
159
176
 
160
- ### 4. Vue
177
+ ### 5. Vue
161
178
 
162
179
  ```vue
163
180
  <script setup>
@@ -179,7 +196,7 @@ onBeforeUnmount(() => table?.destroy());
179
196
  </template>
180
197
  ```
181
198
 
182
- ### 5. Svelte
199
+ ### 6. Svelte
183
200
 
184
201
  ```svelte
185
202
  <script>
@@ -198,7 +215,7 @@ onBeforeUnmount(() => table?.destroy());
198
215
  <p id="left">{left}</p>
199
216
  ```
200
217
 
201
- ### 6. Angular
218
+ ### 7. Angular
202
219
 
203
220
  ```typescript
204
221
  import { Component, ElementRef, OnDestroy, afterNextRender, provideZonelessChangeDetection, signal, viewChild } from "@angular/core";
@@ -226,7 +243,7 @@ class App implements OnDestroy {
226
243
  bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] });
227
244
  ```
228
245
 
229
- Each of the five is taken from this page as it is written, built from the
246
+ Each of the six is taken from this page as it is written, built from the
230
247
  packed tarball in a project of its own, and played by taps in Chromium and
231
248
  WebKit, by `scripts/check-frameworks.mjs`, before a release names it. In each
232
249
  the bundler splits the word lists into files of their own, which the browser
@@ -240,16 +257,77 @@ fetches when a game is first dealt.
240
257
  text; what is read back is dealt again from its seed and checked against
241
258
  its bag, never trusted.
242
259
  - **No dependencies**, ES modules, a `default` export condition so that
243
- `require()` loads it too (Node 22 and later).
260
+ `require()` loads it too (Node 22 and later), and a `sideEffects` list that
261
+ names only the files that define the tag and register the word lists.
244
262
  - **Sizes.** The rules, the computer player and the table of players are
245
263
  about 44 kB minified (16 kB gzipped); the table for a page is 34 kB (13 kB).
246
264
  The English list is 606 kB (277 kB gzipped) and the Japanese 718 kB
247
265
  (396 kB), each a file of its own that is fetched only when a game in that
248
266
  language is first dealt.
249
267
  - **Where it runs.** Current Chrome, Edge, Firefox and Safari, on a desk or a
250
- phone. The rules have no DOM in them and run in Node 20 and later, Deno,
268
+ phone. The rules have no DOM in them and run in Node 22 and later, Deno,
251
269
  Bun and web workers.
252
270
 
271
+ ## Architecture
272
+
273
+ The rules are plain functions over plain data with no DOM: a game is a value,
274
+ every move returns the next one, and the same seed deals the same bag in every
275
+ browser. Word lists are loaded only when a game opens, and the table is its own
276
+ entry (`/ui` for plain DOM, `/element` for a tag, `/react` for React), so a page that wants only the
277
+ rules carries none of it. A computer player is pure too, so its turn replays
278
+ exactly after a reload.
279
+
280
+ ```text
281
+ src/
282
+ ├── check.ts whether a finished grid completes a game: the one check a server also runs
283
+ ├── computer.types.ts what a computer did in one step of its turn
284
+ ├── computerPlay.ts the word a computer player lays: from its own hand, with no randomness
285
+ ├── computerTurn.ts a computer's whole turn, planned as steps the page can show one at a time
286
+ ├── daily.ts one seed a day, the same for everybody
287
+ ├── element-define.ts the "/element/define" entry: defines <kumimoji-table> on the page by being imported
288
+ ├── element.ts the "/element" entry: the <kumimoji-table> element, a game alone in a tag
289
+ ├── export.ts a game written out as JSON or plain text, and read back
290
+ ├── generate.ts making a game from a seed: a crossword laid first, then its tiles become the bag
291
+ ├── grid.ts the grid of tiles on a table with no edges, and how it is written as a string
292
+ ├── help.ts arranging the hand into a word, for a player who asks for help
293
+ ├── index.ts the main entry: the rules, the generator, the computer player and the table's pure helpers
294
+ ├── judge.ts judging a grid against its language's word list, run by run
295
+ ├── kana.ts Japanese tiles: the 45 base kana, and how every other kana is played as one of them
296
+ ├── kumimoji.types.ts the game's types: how much of the set a bag holds, and the rest
297
+ ├── party.ts pass and play: two to eight people round one device, one bag, a hand and a table each
298
+ ├── party.types.ts what a pass-and-play game was set up as
299
+ ├── partyKept.ts a pass-and-play game kept in the browser as one string of JSON
300
+ ├── partySeats.ts people and computers joining and leaving a pass-and-play game between turns
301
+ ├── partyTurns.ts how a pass-and-play turn ends, and how the game does, and who won
302
+ ├── placement.ts where a word can cross a crossword without spoiling it
303
+ ├── play.ts a game being played: the bag, the hand and the table, and every move on them
304
+ ├── random.ts a seeded random, so one seed deals one game in every browser
305
+ ├── react.tsx the "/react" entry: a grid to draw in React
306
+ ├── showcase.ts what the rules page shows of the game, worked out from the game's own tables
307
+ ├── strings.ts every word Kumimoji shows a person, in English and Japanese
308
+ ├── tableView.ts how the table is looked at: which squares are shown, and how big a tile is drawn
309
+ ├── tileFace.ts what a tile code shows, without loading a word list
310
+ ├── tileFamily.ts which tile of the set a tile is, without loading a word list
311
+ ├── tileWords.ts the word list, loaded once when a game opens
312
+ ├── tileWordsModule.ts the same lists where there is no browser, imported only by a server or a test
313
+ ├── tiles.constants.ts every number the game is played by: tile mixes, hand sizes, lengths of a game
314
+ ├── turn.ts turning the table while every tile stays upright
315
+ ├── ui.ts the "/ui" entry: Kumimoji played in the browser, in plain DOM
316
+ ├── version.ts the version of this package, as package.json has it
317
+ ├── wilds.ts a wild tile with no letter read as whatever makes the grid words
318
+ ├── words.en.data.ts the English word list
319
+ ├── words.ja.data.ts the Japanese word list and tile mix
320
+ ├── words.ts the "/words" entry: the word lists, for a place with no browser, such as a server or a test
321
+ └── ui/ the table that draws and plays a game
322
+ ├── board.ts how a tile on the table is drawn: sound, in a run that is not a word, or apart
323
+ ├── mount.ts the table itself: mounting it on a page, the options it takes, and playing it from a keyboard
324
+ └── style.ts the table's own styles, every colour and size a CSS variable so a page can restyle it
325
+ ```
326
+
327
+ Tests sit beside the code they test (`*.test.ts`). `scripts/` builds the demo
328
+ and checks the package as npm packs it, `demo/` is the page published on
329
+ GitHub Pages, and `table/` taps it in real browsers.
330
+
253
331
  ## The name
254
332
 
255
333
  *Kumimoji* (組み文字) is made of *kumi* (組み, from 組む, to put together or
@@ -278,25 +356,27 @@ issue and we will add you.
278
356
 
279
357
  ### The family
280
358
 
281
- Kumimoji has siblings, each made for the same site, each MIT, each at
359
+ Kumimoji is one of sixteen packages, each made for the same site, each MIT, each at
282
360
  [github.com/johnmorrisdotca](https://github.com/johnmorrisdotca):
283
361
 
284
- - [Korokoro](https://github.com/johnmorrisdotca/korokoro) (コロコロ, the sound
285
- of something small rolling along): a dice roller and a dice notation
286
- parser, with the exact odds of every roll.
287
- - [Kyuubu](https://github.com/johnmorrisdotca/kyuubu) (キューブ, how Japanese
288
- says "cube"): a turning cube for the browser, 2×2 to 7×7, drawn in CSS 3D.
289
- - [Hitotsu](https://github.com/johnmorrisdotca/hitotsu) (一つ, "one"): a
290
- colour-card game, named for the call a player makes with one card left.
291
- - [Toranpu](https://github.com/johnmorrisdotca/toranpu) (トランプ, the everyday
292
- Japanese word for a deck of playing cards): ten card games, complete, with a
293
- computer for every seat.
294
- - [Tane](https://github.com/johnmorrisdotca/tane) (種, a seed, the kind you
295
- plant): seeded random numbers and daily seeds.
296
- - [Narabe](https://github.com/johnmorrisdotca/narabe) (並べ, "line them up"):
297
- a rules engine for forty-eight board games, from five in a row to Go.
298
- - [Tenka](https://github.com/johnmorrisdotca/tenka) (天下, "all under
299
- heaven"): world conquest for two to six, on a map of the real world.
362
+ - [Korokoro](https://github.com/johnmorrisdotca/korokoro) (コロコロ, the sound of something small rolling): dice, with notation, exact odds and games.
363
+ - [Kyuubu](https://github.com/johnmorrisdotca/kyuubu) (キューブ, how Japanese says "cube"): a turning cube for the browser, 2×2 to 7×7.
364
+ - [Hitotsu](https://github.com/johnmorrisdotca/hitotsu) (一つ, "one"): a colour-card game, named for the call a player makes with one card left.
365
+ - [Toranpu](https://github.com/johnmorrisdotca/toranpu) (トランプ, the everyday Japanese word for a deck of playing cards): card games as pure rules.
366
+ - [Tane](https://github.com/johnmorrisdotca/tane) (種, a seed, the kind you plant): seeded random numbers and daily seeds.
367
+ - [Narabe](https://github.com/johnmorrisdotca/narabe) (並べ, "line them up"): a rules engine for gomoku, Reversi, Go, checkers and many more.
368
+ - [Tenka](https://github.com/johnmorrisdotca/tenka) (天下, "under heaven"): a world-conquest game for two to six.
369
+ - [Kumimoji](https://github.com/johnmorrisdotca/kumimoji) (組み文字, "letters put together"): a crossword tile race in English and Japanese.
370
+ - [Tsunagi](https://github.com/johnmorrisdotca/tsunagi) (繋ぎ, "joining"): a line-joining puzzle.
371
+ - [Jarajara](https://github.com/johnmorrisdotca/jarajara) (ジャラジャラ, the rattle of mahjong tiles being shuffled): mahjong tiles and a matching solitaire.
372
+ - [Suido](https://github.com/johnmorrisdotca/suido) (水道, "waterworks"): a pipe puzzle.
373
+ - [Domino](https://github.com/johnmorrisdotca/domino) (ドミノ, the Japanese word for dominoes): dominoes and Mexican Train.
374
+ - [Kotoba](https://github.com/johnmorrisdotca/kotoba) (言葉, "words"): word lists and word-game rules.
375
+ - [Sugoroku](https://github.com/johnmorrisdotca/sugoroku) (双六, backgammon's Japanese name): backgammon and its variants.
376
+ - [Kazu](https://github.com/johnmorrisdotca/kazu) (数, "number"): grid number puzzles, Sudoku and five more.
377
+ - [Meikyuu](https://github.com/johnmorrisdotca/meikyuu) (迷宮, "labyrinth"): mazes to draw a line through.
378
+
379
+ **This package is Kumimoji.** The demos of all sixteen share one header and footer, so each links the rest.
300
380
 
301
381
  ## Features
302
382
 
@@ -320,6 +400,12 @@ Kumimoji has siblings, each made for the same site, each MIT, each at
320
400
  - **Help**, if chosen: the words the hand's own tiles spell.
321
401
  - **A table to play on.** `mountKumimoji` plays a whole game alone in plain
322
402
  DOM, saves it and loads it back; `KumimojiBoard` draws a grid in React.
403
+ - **A tag.** `<kumimoji-table>` is the same game in one element, with no
404
+ framework.
405
+ - **One bag a day.** `kumimojiDailySeed(new Date())` is the same seed for
406
+ everybody, worldwide.
407
+ - **Played from a keyboard.** Arrow keys between squares and tiles, Enter or
408
+ Space to pick up and lay; see [Accessibility](#accessibility).
323
409
  - **The table's words in English and Japanese**, and any other language by a
324
410
  table of your own. **Themeable**: every colour is a CSS variable.
325
411
 
@@ -428,6 +514,26 @@ The tiles are the 45 base hiragana. A word is spelt with the base of each of
428
514
  its kana, so ことば is played, and listed, as ことは: a tile shows the other
429
515
  forms it plays as in its corner (`CORNER_FORMS`, `formsOfTile`).
430
516
 
517
+ ### The day's seed
518
+
519
+ | Export | What it does |
520
+ | --- | --- |
521
+ | `kumimojiDay(date)` | The day a moment falls on, in UTC, written `YYYY-MM-DD` |
522
+ | `kumimojiDailySeed(date)` | That day's seed: the date as a number, so 2026-10-01 is `20261001` |
523
+
524
+ ```ts
525
+ import { kumimojiDailySeed } from "@johnmorrisdotca/kumimoji";
526
+
527
+ kumimojiDailySeed(new Date("2026-10-01T12:00:00Z")); // → 20261001
528
+ kumimojiDailySeed(new Date("2026-10-01T23:59:59Z")); // → 20261001
529
+ ```
530
+
531
+ The day is the UTC date, so it changes at one moment for the whole world, and
532
+ everybody who deals from it with the same settings (language, hand, level and
533
+ length) gets the same bag in the same order. It is the same number as Tane's
534
+ `dailySeed`, so a page that uses both agrees; Kumimoji does not need Tane. The
535
+ race against the clock is each player's own.
536
+
431
537
  ### A table of players
432
538
 
433
539
  ```ts
@@ -495,7 +601,7 @@ The JSON, as `kumimojiToJSON` writes it:
495
601
  {
496
602
  "format": 1,
497
603
  "game": "kumimoji",
498
- "generator": "kumimoji 1.1.0",
604
+ "generator": "kumimoji 1.2.0",
499
605
  "size": 3,
500
606
  "level": "hard",
501
607
  "seed": 44,
@@ -557,6 +663,11 @@ What that means for a project that uses this package:
557
663
  This is a description, not legal advice; the licences themselves are what
558
664
  hold.
559
665
 
666
+ A real word the table calls "not a word", or a word it accepts that it should
667
+ not: open a [*word list*](https://github.com/johnmorrisdotca/kumimoji/issues/new?template=word-list.md)
668
+ issue. The lists are not edited by hand, so a word may have to be changed at
669
+ its source.
670
+
560
671
  ### Loading them
561
672
 
562
673
  In a browser, `loadTileWords(language)` fetches the list as its own module
@@ -586,6 +697,8 @@ turn tiles into words: `glyphOf`, `soundOf`, `wordOf`, `wildFor`, `isWild`,
586
697
 
587
698
  ## API
588
699
 
700
+ The [API reference](https://johnmorrisdotca.github.io/kumimoji/api.html) lists every export of every entry point with its signature and its doc comment. It is made from the source by `pnpm site`, so it cannot fall behind the code.
701
+
589
702
  Every function, type and constant has a doc comment, so an editor shows this
590
703
  as you type. The entries:
591
704
 
@@ -594,6 +707,8 @@ as you type. The entries:
594
707
  | `@johnmorrisdotca/kumimoji` | Dealing, playing, judging, the table of players, the computer player, keeping and export, and the table's words |
595
708
  | `@johnmorrisdotca/kumimoji/words` | Reads both word lists from this package's files, for a server, a test or a script: `loadTileWordsFromModule` |
596
709
  | `@johnmorrisdotca/kumimoji/ui` | `mountKumimoji`, a whole game alone in plain DOM, and `boardModel` |
710
+ | `@johnmorrisdotca/kumimoji/element` | `KumimojiTable`, the `<kumimoji-table>` element's class, to extend or to define under another name |
711
+ | `@johnmorrisdotca/kumimoji/element/define` | Defines `<kumimoji-table>` on the page by being imported; exports nothing |
597
712
  | `@johnmorrisdotca/kumimoji/react` | `KumimojiBoard` and `KumimojiTable` |
598
713
 
599
714
  ### Dealing and judging
@@ -712,6 +827,32 @@ entry has `boardModel(tiles, verdict, glyphOf?)`, which works out the squares
712
827
  to draw and how each tile stands, with `BOARD_MARGIN` and `BOARD_LEAST`, and
713
828
  `KUMIMOJI_STYLE`, the table's stylesheet as a string.
714
829
 
830
+ ### The element
831
+
832
+ `<kumimoji-table>` is `mountKumimoji` as a tag: `@johnmorrisdotca/kumimoji/element/define`
833
+ defines it, and `@johnmorrisdotca/kumimoji/element` holds the class alone. Each
834
+ attribute is read again when it changes, and a change to any but `lang` deals a
835
+ new game. A table the rules do not offer (a hand of 5, say) draws nothing.
836
+
837
+ | Attribute | Default | What it does |
838
+ | --- | --- | --- |
839
+ | `language` | `english` | The tiles: `english` or `japanese` kana |
840
+ | `hand` | `7` | The opening hand: 3, 7 or 11 |
841
+ | `level` | `medium` | Wild tiles: `easy` the most, `medium` some, `hard` none |
842
+ | `length` | `short` | `short`, `medium` or `full`, all 144 tiles |
843
+ | `diagonals` | off | `on` to read every diagonal run of three or more too |
844
+ | `seed` | a new one each game | A whole number to deal from, or `daily` for the day's seed |
845
+ | `lang` | the page's | The language of the table's words: `en` or `ja` |
846
+ | `keep` | on | `off` leaves saving and loading out |
847
+
848
+ It fires `kumimoji-change` after every change to the game, with the game as it
849
+ would be saved as `event.detail.saved`, and `kumimoji-finish` when the last
850
+ tile is laid and every run is a word, with the deal, the play and the time it
851
+ took as `event.detail`; both bubble. It has `saved` (the game as it would be
852
+ saved), `table` (the handle `mountKumimoji` returns), `newGame(options?)` and
853
+ `setGame(saved)`. To style it, set the table's variables on `kumimoji-table
854
+ .km-root`: see [Theming](#theming).
855
+
715
856
  ### The React components
716
857
 
717
858
  ```jsx
@@ -749,10 +890,31 @@ export function Board() {
749
890
  else for its `<div>`. Options are read when it mounts; give it a new `key` to
750
891
  start over with different ones.
751
892
 
893
+ ## Accessibility
894
+
895
+ - **A keyboard plays it.** Every square and every tile in the hand is a button
896
+ with a name. Tab lands on one square of the table and on one tile of the
897
+ hand, the arrow keys move between squares (and Left, Right, Home and End
898
+ between the hand's tiles), Enter or Space picks a tile up and lays it, and
899
+ Escape lets go of a tile you have picked up. The keyboard stays where it was
900
+ after each move, including on the buttons under the table, and the table's
901
+ description says so.
902
+ - **What is happening is said.** The line above the table, with the tiles left
903
+ and the time, and the line saying which runs are not words, are polite live
904
+ regions, and so is the note after saving or loading.
905
+ - **Targets are big enough.** Every button is at least 44 pixels each way,
906
+ which a test holds at a phone's width.
907
+ - **Not by colour alone.** A run that is not a word is ringed and listed in
908
+ words; a tile apart from the crossword is faded and said so.
909
+ - **Motion.** There is none to reduce: the table has no animation.
910
+ - **Not yet.** Letters cannot be typed to lay a tile, and a screen reader hears
911
+ the table as a grid of squares, not as the words they make.
912
+
752
913
  ## Theming
753
914
 
754
- Every colour is a CSS variable on `.km-root`. Set them in your stylesheet, on
755
- the table's element or any ancestor, or pass them as `theme`, which sets them
915
+ Every colour is a CSS variable on `.km-root`. Set them in your stylesheet on
916
+ `.km-root` with a selector more specific than the table's own, such as
917
+ `#table .km-root`, or pass them as `theme`, which sets them
756
918
  on the table itself and so wins in light and dark alike.
757
919
 
758
920
  | Variable | What it sets | Light | Dark |
@@ -827,7 +989,7 @@ that language, whose licence lets its words be shipped.
827
989
  Any current browser: Chrome, Edge, Firefox and Safari, on a desk or a phone.
828
990
  It needs ES2020 and dynamic `import()`. The table is tested in Chromium and in
829
991
  WebKit, Safari's engine, at phone size with touch and on a desktop. The rules
830
- run in Node 20 and later, Deno, Bun and web workers, and load by `import` and
992
+ run in Node 22 and later, Deno, Bun and web workers, and load by `import` and
831
993
  by `require()`.
832
994
 
833
995
  ## The command line
@@ -848,12 +1010,12 @@ words.allowed.has("snowy"); // → true
848
1010
  ## Roadmap
849
1011
 
850
1012
  - More languages for the tiles, each from a real dictionary of that language.
851
- - Playing the plain-DOM table from a keyboard, as the React one on Itsutsu
852
- can be.
1013
+ - Typing a letter to lay that tile, in the plain-DOM table, which is
1014
+ otherwise played from a keyboard.
853
1015
  - A table of players in plain DOM, beside the game alone.
854
1016
  - A computer player at three strengths.
855
1017
  - Word definitions for a finished crossword.
856
- - A Vue wrapper and a web component, beside the React one.
1018
+ - A Vue wrapper, beside the React one and the tag.
857
1019
 
858
1020
  Left out on purpose: play between devices, which needs a server (a table is
859
1021
  kept as text made to be sent, and Itsutsu sends it; the sending is yours); a
@@ -871,9 +1033,10 @@ pnpm install
871
1033
  pnpm check # lint, types and tests
872
1034
  pnpm test:table # the demo in real browsers, by taps
873
1035
  pnpm site # build the demo into ./site, then serve it
1036
+ pnpm pictures # take the README's two pictures from the built demo
874
1037
  ```
875
1038
 
876
- Please follow the [code of conduct](./CODE_OF_CONDUCT.md).
1039
+ Please follow the [code of conduct](./CODE_OF_CONDUCT.md). A way to make a check or a deal run for long, or markup that gets out of the drawing, is for the [security policy](./SECURITY.md), not a public issue.
877
1040
 
878
1041
  ## Changes
879
1042
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ONE SEED A DAY, THE SAME FOR EVERYBODY. A day is a calendar date in UTC and
3
+ * its seed is that date as a number: 2026-10-01 is 20261001, which
4
+ * `generateKumimoji` accepts. It is the same number Tane's `dailySeed` gives,
5
+ * so a page that uses both agrees; nothing here needs Tane.
6
+ *
7
+ * Everybody who deals from the day's seed with the same settings (language,
8
+ * hand, level, length) gets the same bag, in the same order. The race against
9
+ * the clock is then theirs.
10
+ */
11
+ /** The day a moment falls on, in UTC, written `YYYY-MM-DD`. Throws a `RangeError` for an invalid `Date`. */
12
+ export declare function kumimojiDay(at: Date): string;
13
+ /** Today's seed, the date as a number: 2026-10-01 is 20261001. The day is UTC, so it is one seed worldwide. */
14
+ export declare function kumimojiDailySeed(at: Date): number;
package/dist/daily.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ONE SEED A DAY, THE SAME FOR EVERYBODY. A day is a calendar date in UTC and
3
+ * its seed is that date as a number: 2026-10-01 is 20261001, which
4
+ * `generateKumimoji` accepts. It is the same number Tane's `dailySeed` gives,
5
+ * so a page that uses both agrees; nothing here needs Tane.
6
+ *
7
+ * Everybody who deals from the day's seed with the same settings (language,
8
+ * hand, level, length) gets the same bag, in the same order. The race against
9
+ * the clock is then theirs.
10
+ */
11
+ const pad = (value, width) => String(value).padStart(width, "0");
12
+ /** The day a moment falls on, in UTC, written `YYYY-MM-DD`. Throws a `RangeError` for an invalid `Date`. */
13
+ export function kumimojiDay(at) {
14
+ if (Number.isNaN(at.getTime()))
15
+ throw new RangeError("kumimoji: an invalid Date has no day");
16
+ return `${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}`;
17
+ }
18
+ /** Today's seed, the date as a number: 2026-10-01 is 20261001. The day is UTC, so it is one seed worldwide. */
19
+ export function kumimojiDailySeed(at) {
20
+ const [year, month, day] = kumimojiDay(at).split("-").map(Number);
21
+ return year * 10000 + month * 100 + day;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Defines the `<kumimoji-table>` element on the page. Import it for its effect:
3
+ *
4
+ * ```html
5
+ * <script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/kumimoji@1/dist/element-define.js"></script>
6
+ * <kumimoji-table language="english" hand="7"></kumimoji-table>
7
+ * ```
8
+ *
9
+ * A tag already defined is left as it is, and on a server, where there is no page, nothing happens.
10
+ */
11
+ import { KumimojiTable } from "./element.js";
12
+ if (typeof customElements !== "undefined" && customElements.get("kumimoji-table") === undefined)
13
+ customElements.define("kumimoji-table", KumimojiTable);
@@ -0,0 +1,45 @@
1
+ import { type KumimojiTableHandle } from "./ui/mount.ts";
2
+ import type { KumimojiSaved } from "./export.ts";
3
+ declare const ElementBase: typeof HTMLElement;
4
+ /**
5
+ * THE `<kumimoji-table>` ELEMENT: a game of Kumimoji alone in a tag, with no
6
+ * framework. `@johnmorrisdotca/kumimoji/element/define` defines it; this entry
7
+ * holds the class alone, to extend or to define under another name. Safe to
8
+ * import on a server, where there is no page: the class then extends nothing.
9
+ *
10
+ * ```html
11
+ * <kumimoji-table language="english" hand="7" seed="2026"></kumimoji-table>
12
+ * <kumimoji-table language="japanese" level="easy" seed="daily"></kumimoji-table>
13
+ * ```
14
+ *
15
+ * Attributes (each is read again when it changes; a change to any but `lang`
16
+ * deals a new game):
17
+ * - `language`: the tiles, `english` (default) or `japanese` kana.
18
+ * - `hand`: the opening hand, 3, 7 (default) or 11.
19
+ * - `level`: how many tiles are wild, `easy` (most), `medium` (default) or `hard` (none).
20
+ * - `length`: how much of the set the bag holds, `short` (default), `medium` or `full`.
21
+ * - `diagonals`: `on` to read every diagonal run of three or more too.
22
+ * - `seed`: a whole number to deal from, or `daily` for the day's seed (`kumimojiDailySeed`); a new one if left out.
23
+ * - `lang`: the language of the table's words, `en` or `ja`, or the page's. `keep`: `off` to leave saving and loading out.
24
+ *
25
+ * It fires `kumimoji-change` after every change to the game, with the game as it would
26
+ * be saved as `event.detail.saved`, and `kumimoji-finish` when the last tile is laid and every run
27
+ * is a word, with `event.detail` the deal, the play and the time it took. It has the methods
28
+ * `newGame()` and `setGame()`. A table the rules do not offer (a hand of 5, say) draws nothing.
29
+ */
30
+ export declare class KumimojiTable extends ElementBase {
31
+ #private;
32
+ static observedAttributes: string[];
33
+ connectedCallback(): void;
34
+ disconnectedCallback(): void;
35
+ attributeChangedCallback(): void;
36
+ /** The mounted table's handle (`mountKumimoji`), or null while the attributes name no table the rules offer. */
37
+ get table(): KumimojiTableHandle | null;
38
+ /** The game as it would be saved, or null while there is none (or it is still being dealt). */
39
+ get saved(): KumimojiSaved | null;
40
+ /** A new game at the same table: see `KumimojiTableHandle.newGame`. */
41
+ newGame(options?: Parameters<KumimojiTableHandle["newGame"]>[0]): Promise<void>;
42
+ /** Put a game on the table: see `KumimojiTableHandle.setGame`. */
43
+ setGame(saved: KumimojiSaved): Promise<void>;
44
+ }
45
+ export {};
@@ -0,0 +1,130 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
7
+ if (kind === "m") throw new TypeError("Private method is not writable");
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
+ };
12
+ var _KumimojiTable_instances, _KumimojiTable_table, _KumimojiTable_key, _KumimojiTable_queued, _KumimojiTable_seed, _KumimojiTable_refresh;
13
+ import { kumimojiDailySeed } from "./daily.js";
14
+ import { KUMIMOJI_HANDS } from "./tiles.constants.js";
15
+ import { mountKumimoji } from "./ui/mount.js";
16
+ const ElementBase = typeof HTMLElement === "undefined" ? class {
17
+ } : HTMLElement;
18
+ const oneOf = (value, allowed) => (allowed.includes(value) ? value : undefined);
19
+ const isOn = (value) => value !== null && ["true", "on", "1", "yes"].includes(value.toLowerCase());
20
+ const HANDS = Object.values(KUMIMOJI_HANDS);
21
+ /**
22
+ * THE `<kumimoji-table>` ELEMENT: a game of Kumimoji alone in a tag, with no
23
+ * framework. `@johnmorrisdotca/kumimoji/element/define` defines it; this entry
24
+ * holds the class alone, to extend or to define under another name. Safe to
25
+ * import on a server, where there is no page: the class then extends nothing.
26
+ *
27
+ * ```html
28
+ * <kumimoji-table language="english" hand="7" seed="2026"></kumimoji-table>
29
+ * <kumimoji-table language="japanese" level="easy" seed="daily"></kumimoji-table>
30
+ * ```
31
+ *
32
+ * Attributes (each is read again when it changes; a change to any but `lang`
33
+ * deals a new game):
34
+ * - `language`: the tiles, `english` (default) or `japanese` kana.
35
+ * - `hand`: the opening hand, 3, 7 (default) or 11.
36
+ * - `level`: how many tiles are wild, `easy` (most), `medium` (default) or `hard` (none).
37
+ * - `length`: how much of the set the bag holds, `short` (default), `medium` or `full`.
38
+ * - `diagonals`: `on` to read every diagonal run of three or more too.
39
+ * - `seed`: a whole number to deal from, or `daily` for the day's seed (`kumimojiDailySeed`); a new one if left out.
40
+ * - `lang`: the language of the table's words, `en` or `ja`, or the page's. `keep`: `off` to leave saving and loading out.
41
+ *
42
+ * It fires `kumimoji-change` after every change to the game, with the game as it would
43
+ * be saved as `event.detail.saved`, and `kumimoji-finish` when the last tile is laid and every run
44
+ * is a word, with `event.detail` the deal, the play and the time it took. It has the methods
45
+ * `newGame()` and `setGame()`. A table the rules do not offer (a hand of 5, say) draws nothing.
46
+ */
47
+ export class KumimojiTable extends ElementBase {
48
+ constructor() {
49
+ super(...arguments);
50
+ _KumimojiTable_instances.add(this);
51
+ _KumimojiTable_table.set(this, null);
52
+ _KumimojiTable_key.set(this, "");
53
+ _KumimojiTable_queued.set(this, false);
54
+ _KumimojiTable_seed.set(this, null);
55
+ }
56
+ connectedCallback() {
57
+ __classPrivateFieldGet(this, _KumimojiTable_instances, "m", _KumimojiTable_refresh).call(this);
58
+ }
59
+ disconnectedCallback() {
60
+ __classPrivateFieldGet(this, _KumimojiTable_table, "f")?.destroy();
61
+ __classPrivateFieldSet(this, _KumimojiTable_table, null, "f");
62
+ __classPrivateFieldSet(this, _KumimojiTable_key, "", "f");
63
+ }
64
+ attributeChangedCallback() {
65
+ if (!this.isConnected || __classPrivateFieldGet(this, _KumimojiTable_queued, "f"))
66
+ return;
67
+ __classPrivateFieldSet(this, _KumimojiTable_queued, true, "f");
68
+ queueMicrotask(() => {
69
+ __classPrivateFieldSet(this, _KumimojiTable_queued, false, "f");
70
+ __classPrivateFieldGet(this, _KumimojiTable_instances, "m", _KumimojiTable_refresh).call(this);
71
+ });
72
+ }
73
+ /** The mounted table's handle (`mountKumimoji`), or null while the attributes name no table the rules offer. */
74
+ get table() {
75
+ return __classPrivateFieldGet(this, _KumimojiTable_table, "f");
76
+ }
77
+ /** The game as it would be saved, or null while there is none (or it is still being dealt). */
78
+ get saved() {
79
+ return __classPrivateFieldGet(this, _KumimojiTable_table, "f")?.saved() ?? null;
80
+ }
81
+ /** A new game at the same table: see `KumimojiTableHandle.newGame`. */
82
+ newGame(options) {
83
+ return __classPrivateFieldGet(this, _KumimojiTable_table, "f")?.newGame(options) ?? Promise.resolve();
84
+ }
85
+ /** Put a game on the table: see `KumimojiTableHandle.setGame`. */
86
+ setGame(saved) {
87
+ return __classPrivateFieldGet(this, _KumimojiTable_table, "f")?.setGame(saved) ?? Promise.resolve();
88
+ }
89
+ }
90
+ _KumimojiTable_table = new WeakMap(), _KumimojiTable_key = new WeakMap(), _KumimojiTable_queued = new WeakMap(), _KumimojiTable_seed = new WeakMap(), _KumimojiTable_instances = new WeakSet(), _KumimojiTable_refresh = function _KumimojiTable_refresh() {
91
+ const language = oneOf(this.getAttribute("language"), ["english", "japanese"]);
92
+ const handAttribute = this.getAttribute("hand");
93
+ const hand = handAttribute === null ? undefined : Number(handAttribute);
94
+ const level = oneOf(this.getAttribute("level"), ["easy", "medium", "hard"]);
95
+ const gameLength = oneOf(this.getAttribute("length"), ["short", "medium", "full"]);
96
+ const diagonals = isOn(this.getAttribute("diagonals"));
97
+ const seedAttribute = this.getAttribute("seed");
98
+ const asked = Number(seedAttribute);
99
+ const seed = seedAttribute === "daily"
100
+ ? kumimojiDailySeed(new Date())
101
+ : seedAttribute !== null && Number.isInteger(asked) && asked >= 0 && asked <= 0xffffffff
102
+ ? asked
103
+ : (__classPrivateFieldSet(this, _KumimojiTable_seed, __classPrivateFieldGet(this, _KumimojiTable_seed, "f") ?? Math.floor(Math.random() * 2 ** 31), "f"));
104
+ const lang = this.getAttribute("lang");
105
+ const locale = lang === "en" || lang === "ja" ? lang : undefined;
106
+ const keep = !["false", "off", "0", "no"].includes((this.getAttribute("keep") ?? "").toLowerCase());
107
+ const key = JSON.stringify([language, hand, level, gameLength, diagonals, seed, keep]);
108
+ if (key === __classPrivateFieldGet(this, _KumimojiTable_key, "f") && __classPrivateFieldGet(this, _KumimojiTable_table, "f") !== null) {
109
+ __classPrivateFieldGet(this, _KumimojiTable_table, "f").setLocale(locale ?? (document.documentElement.lang.toLowerCase().startsWith("ja") ? "ja" : "en"));
110
+ return;
111
+ }
112
+ __classPrivateFieldGet(this, _KumimojiTable_table, "f")?.destroy();
113
+ __classPrivateFieldSet(this, _KumimojiTable_table, null, "f");
114
+ __classPrivateFieldSet(this, _KumimojiTable_key, key, "f");
115
+ if (hand !== undefined && !HANDS.includes(hand))
116
+ return;
117
+ __classPrivateFieldSet(this, _KumimojiTable_table, mountKumimoji(this, {
118
+ language,
119
+ hand,
120
+ level,
121
+ gameLength,
122
+ diagonals,
123
+ seed,
124
+ locale,
125
+ keep,
126
+ onChange: (saved) => this.dispatchEvent(new CustomEvent("kumimoji-change", { detail: { saved }, bubbles: true })),
127
+ onFinish: (result) => this.dispatchEvent(new CustomEvent("kumimoji-finish", { detail: result, bubbles: true })),
128
+ }), "f");
129
+ };
130
+ KumimojiTable.observedAttributes = ["language", "hand", "level", "length", "diagonals", "seed", "lang", "keep"];
package/dist/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export * from "./computer.types.ts";
42
42
  export * from "./computerPlay.ts";
43
43
  export * from "./computerTurn.ts";
44
44
  export * from "./random.ts";
45
+ export * from "./daily.ts";
45
46
  export * from "./export.ts";
46
47
  export * from "./strings.ts";
47
48
  export { KUMIMOJI_VERSION } from "./version.ts";
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ export * from "./computer.types.js";
42
42
  export * from "./computerPlay.js";
43
43
  export * from "./computerTurn.js";
44
44
  export * from "./random.js";
45
+ export * from "./daily.js";
45
46
  export * from "./export.js";
46
47
  export * from "./strings.js";
47
48
  export { KUMIMOJI_VERSION } from "./version.js";
package/dist/strings.d.ts CHANGED
@@ -19,6 +19,7 @@ declare const EN: {
19
19
  left: string;
20
20
  finished: string;
21
21
  tableLabel: string;
22
+ tableKeys: string;
22
23
  handLabel: string;
23
24
  emptySquare: string;
24
25
  wildTile: string;
package/dist/strings.js CHANGED
@@ -17,6 +17,7 @@ const EN = {
17
17
  left: "{n} tiles left in the bag · {time}",
18
18
  finished: "Finished in {time}. Every tile laid, every run a word.",
19
19
  tableLabel: "The table",
20
+ tableKeys: "Arrow keys move between squares, Enter or Space taps one, and Escape lets go of a tile you have picked up.",
20
21
  handLabel: "Your hand",
21
22
  emptySquare: "Empty square",
22
23
  wildTile: "{tile}, wild",
@@ -53,6 +54,7 @@ const JA = {
53
54
  left: "袋の残り{n}枚 · {time}",
54
55
  finished: "{time}で完成しました。すべてのタイルを置き、どの並びも単語になっています。",
55
56
  tableLabel: "テーブル",
57
+ tableKeys: "矢印キーでマスを移動し、Enterキーかスペースキーでタップします。Escapeキーで、選んだタイルの選択を解除します。",
56
58
  handLabel: "手札",
57
59
  emptySquare: "空きマス",
58
60
  wildTile: "{tile}(ワイルド)",
package/dist/ui/mount.js CHANGED
@@ -58,6 +58,9 @@ export function mountKumimoji(target, options = {}) {
58
58
  let dealt = null;
59
59
  let play = null;
60
60
  let held = null;
61
+ // Where a keyboard is: the square and the hand tile that Tab lands on, and the one the arrow keys move from.
62
+ let onSquare = null;
63
+ let onHand = 0;
61
64
  let started = 0;
62
65
  let finishedAt = null;
63
66
  let destroyed = false;
@@ -77,6 +80,7 @@ export function mountKumimoji(target, options = {}) {
77
80
  board.dataset.testid = "km-board";
78
81
  const notes = node("p", "km-notes");
79
82
  notes.dataset.testid = "km-notes";
83
+ notes.setAttribute("aria-live", "polite");
80
84
  const hand = node("div", "km-hand");
81
85
  hand.dataset.testid = "km-hand";
82
86
  hand.setAttribute("role", "group");
@@ -195,6 +199,62 @@ export function mountKumimoji(target, options = {}) {
195
199
  if (at !== null && at !== undefined)
196
200
  tapHand(Number(at));
197
201
  });
202
+ // Keyboard play. The squares and the hand's tiles are buttons, so Enter and Space tap them; each is one stop for
203
+ // Tab, and the arrow keys move between squares (by the grid) and between the hand's tiles. Escape puts a held tile down.
204
+ board.addEventListener("keydown", (event) => {
205
+ const cell = event.target.closest("[data-square]");
206
+ const grid = board.querySelector(".km-grid");
207
+ if (cell === null || grid === null || event.altKey || event.ctrlKey || event.metaKey)
208
+ return;
209
+ const cells = [...grid.children];
210
+ const cols = Number(grid.dataset.cols);
211
+ const at = cells.indexOf(cell);
212
+ const step = event.key === "ArrowLeft" ? (at % cols > 0 ? -1 : 0) : event.key === "ArrowRight" ? (at % cols < cols - 1 ? 1 : 0) : event.key === "ArrowUp" ? (at - cols >= 0 ? -cols : 0) : event.key === "ArrowDown" ? (at + cols < cells.length ? cols : 0) : null;
213
+ if (step === null)
214
+ return;
215
+ event.preventDefault();
216
+ const next = cells[at + step];
217
+ if (step === 0 || next === undefined)
218
+ return;
219
+ cell.tabIndex = -1;
220
+ next.tabIndex = 0;
221
+ onSquare = next.dataset.square ?? null;
222
+ next.focus();
223
+ });
224
+ board.addEventListener("focusin", (event) => {
225
+ const square = event.target.closest("[data-square]")?.getAttribute("data-square");
226
+ if (square !== null && square !== undefined)
227
+ onSquare = square;
228
+ });
229
+ hand.addEventListener("focusin", (event) => {
230
+ const at = event.target.closest("[data-hand]")?.getAttribute("data-hand");
231
+ if (at !== null && at !== undefined)
232
+ onHand = Number(at);
233
+ });
234
+ hand.addEventListener("keydown", (event) => {
235
+ const tile = event.target.closest("[data-hand]");
236
+ if (tile === null || event.altKey || event.ctrlKey || event.metaKey)
237
+ return;
238
+ const tiles = [...hand.querySelectorAll("[data-hand]")];
239
+ const at = tiles.indexOf(tile);
240
+ const to = event.key === "ArrowLeft" ? at - 1 : event.key === "ArrowRight" ? at + 1 : event.key === "Home" ? 0 : event.key === "End" ? tiles.length - 1 : null;
241
+ if (to === null)
242
+ return;
243
+ event.preventDefault();
244
+ const next = tiles[to];
245
+ if (next === undefined)
246
+ return;
247
+ tile.tabIndex = -1;
248
+ next.tabIndex = 0;
249
+ onHand = to;
250
+ next.focus();
251
+ });
252
+ root.addEventListener("keydown", (event) => {
253
+ if (event.key !== "Escape" || held === null)
254
+ return;
255
+ held = null;
256
+ render();
257
+ });
198
258
  function renderStatus() {
199
259
  root.dataset.state = play === null ? "dealing" : finishedAt !== null ? "finished" : "playing";
200
260
  if (play === null) {
@@ -212,6 +272,9 @@ export function mountKumimoji(target, options = {}) {
212
272
  grid.style.gridTemplateColumns = `repeat(${model.cols}, var(--km-square))`;
213
273
  grid.setAttribute("role", "group");
214
274
  grid.setAttribute("aria-label", say.tableLabel);
275
+ grid.setAttribute("aria-description", say.tableKeys);
276
+ grid.dataset.cols = String(model.cols);
277
+ const cells = [];
215
278
  for (const square of model.squares) {
216
279
  const cell = node("button", "km-square");
217
280
  cell.type = "button";
@@ -231,8 +294,16 @@ export function mountKumimoji(target, options = {}) {
231
294
  }
232
295
  if (held?.from === "table" && held.square === square.square)
233
296
  cell.classList.add("km-held");
297
+ cell.tabIndex = -1;
298
+ cells.push(cell);
234
299
  grid.append(cell);
235
300
  }
301
+ // The one Tab lands on: where the keyboard was, else a tile, else the middle.
302
+ const stop = cells.find((cell) => cell.dataset.square === onSquare) ?? cells.find((cell) => cell.classList.contains("km-tile")) ?? cells[Math.floor(cells.length / 2)];
303
+ if (stop !== undefined) {
304
+ stop.tabIndex = 0;
305
+ onSquare = stop.dataset.square ?? null;
306
+ }
236
307
  board.replaceChildren(grid);
237
308
  notes.textContent = verdict.notWords.length > 0 ? kumimojiSay(say.notWords, { words: verdict.notWords.join(", ") }) : verdict.apart.size > 0 ? say.apart : "";
238
309
  }
@@ -251,6 +322,7 @@ export function mountKumimoji(target, options = {}) {
251
322
  made.classList.add("km-held");
252
323
  made.dataset.tile = face.blank ? "*" : words.glyphOf(tile);
253
324
  made.setAttribute("aria-label", face.blank ? say.wildBlank : face.wild ? kumimojiSay(say.wildTile, { tile: words.glyphOf(tile) }) : words.glyphOf(tile));
325
+ made.tabIndex = at === Math.min(onHand, play.hand.length - 1) ? 0 : -1;
254
326
  hand.append(made);
255
327
  });
256
328
  if (play.hand.length === 0 && finishedAt === null)
@@ -309,9 +381,35 @@ export function mountKumimoji(target, options = {}) {
309
381
  saveText.disabled = play === null;
310
382
  keepNote.textContent = note;
311
383
  }
384
+ /** Where the keyboard is, so that it can be put back after everything is drawn again: the zone, and which of it. */
385
+ function keyboardAt() {
386
+ const on = document.activeElement;
387
+ if (on === null || !root.contains(on))
388
+ return null;
389
+ const mark = (zone, id) => (id === undefined ? null : { zone, id });
390
+ if (board.contains(on))
391
+ return mark("board", on.dataset.square);
392
+ if (hand.contains(on))
393
+ return mark("hand", on.dataset.hand);
394
+ if (controls.contains(on))
395
+ return mark("controls", on.dataset.testid);
396
+ if (picker.contains(on))
397
+ return mark("picker", String([...picker.querySelectorAll("button")].indexOf(on)));
398
+ return null;
399
+ }
400
+ function keyboardBack(at) {
401
+ if (at === null)
402
+ return;
403
+ const zone = { board, hand, controls, picker }[at.zone];
404
+ const found = at.zone === "board" ? zone.querySelector(`[data-square="${at.id}"]`) : at.zone === "hand" ? zone.querySelector(`[data-hand="${at.id}"]`) : at.zone === "controls" ? zone.querySelector(`[data-testid="${at.id}"]`) : zone.querySelectorAll("button")[Number(at.id)];
405
+ // A button that has become disabled cannot hold the focus; the table keeps it rather than dropping it on the page.
406
+ if (found !== undefined && found !== null && !found.disabled)
407
+ found.focus({ preventScroll: true });
408
+ }
312
409
  function render() {
313
410
  if (destroyed)
314
411
  return;
412
+ const where = keyboardAt();
315
413
  root.lang = locale;
316
414
  renderStatus();
317
415
  renderKeep();
@@ -322,6 +420,7 @@ export function mountKumimoji(target, options = {}) {
322
420
  renderPicker();
323
421
  renderHand();
324
422
  renderControls(verdict);
423
+ keyboardBack(where);
325
424
  }
326
425
  const handle = {
327
426
  play: () => play,
@@ -4,4 +4,4 @@
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
6
  */
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";
7
+ export declare const KUMIMOJI_STYLE = "\n.km-root {\n --km-square: 44px; --km-felt: #2f5d4a; --km-grid-line: rgba(255,255,255,.12); --km-tile: #f3e6c8; --km-tile-ink: #2a2118;\n --km-wild: #f6d27a; --km-wrong: #d9534f; --km-held: #ffcf3f; --km-ink: #1f2320; --km-panel: #f7f3ea;\n --km-line: rgba(20,20,20,.35); --km-accent: #2f5d4a; --km-accent-ink: #fff;\n --km-radius: 12px; --km-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n display: grid; gap: 10px; min-width: 0; color: var(--km-ink); font-family: var(--km-font); font-size: 15px;\n}\n@media (prefers-color-scheme: dark) {\n .km-root { --km-felt: #1e3a2f; --km-ink: #ece8dc; --km-panel: #1d201e; --km-line: rgba(255,255,255,.25); --km-accent: #6fb08f; --km-accent-ink: #10150f; }\n}\n.km-root > * { min-width: 0; }\n.km-status { margin: 0; min-height: 1.4em; font-variant-numeric: tabular-nums; }\n.km-board { background: var(--km-felt); border-radius: var(--km-radius); padding: 10px; overflow: auto; max-height: 70vh; }\n.km-grid { display: grid; gap: 2px; width: max-content; margin: 0 auto; }\n.km-square { width: var(--km-square); height: var(--km-square); border: 0; border-radius: 5px; background: var(--km-grid-line); padding: 0; cursor: pointer; touch-action: manipulation; }\n.km-tile { display: grid; place-items: center; width: var(--km-square); height: var(--km-square); border-radius: 6px; border: 0; padding: 0;\n background: var(--km-tile); color: var(--km-tile-ink); font: 700 calc(var(--km-square) * .55)/1 var(--km-font); text-transform: uppercase;\n box-shadow: 0 2px 0 rgba(0,0,0,.35); cursor: pointer; touch-action: manipulation; }\n.km-japanese .km-tile { font-size: calc(var(--km-square) * .52); text-transform: none; }\n.km-wild { background: var(--km-wild); }\n.km-misspelt { box-shadow: 0 0 0 3px var(--km-wrong) inset, 0 2px 0 rgba(0,0,0,.35); }\n.km-apart { opacity: .6; }\n.km-held { outline: 3px solid var(--km-held); outline-offset: 1px; }\n.km-square:focus-visible, .km-in-hand:focus-visible, .km-button:focus-visible, .km-keep-title:focus-visible { outline: 3px solid var(--km-held); outline-offset: 2px; }\n.km-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; overflow-wrap: anywhere; }\n.km-hand { display: flex; flex-wrap: wrap; gap: 6px; min-height: calc(var(--km-square) + 16px); align-items: center; padding: 8px; border-radius: 10px; background: var(--km-panel); }\n.km-empty { opacity: .6; font-size: 14px; }\n.km-picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }\n.km-picker:empty { display: none; }\n.km-picker-title { width: 100%; font-size: 14px; }\n.km-picker .km-button { padding: 0 8px; text-transform: uppercase; }\n.km-japanese .km-picker .km-button { text-transform: none; }\n.km-controls { display: flex; flex-wrap: wrap; gap: 8px; }\n.km-button { font: inherit; min-height: 44px; min-width: 44px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--km-line); background: var(--km-panel); color: var(--km-ink); cursor: pointer; touch-action: manipulation; }\n.km-button:disabled { opacity: .45; cursor: default; }\n.km-primary { background: var(--km-accent); color: var(--km-accent-ink); border-color: transparent; }\n.km-keep { border-radius: 10px; background: var(--km-panel); padding: 0 10px; }\n.km-keep[open] { padding-bottom: 10px; }\n.km-keep-title { min-height: 44px; display: flex; align-items: center; font-weight: 600; cursor: pointer; }\n.km-note { margin: 8px 0 0; min-height: 1.4em; font-size: 13px; }\n";
package/dist/ui/style.js CHANGED
@@ -28,7 +28,7 @@ export const KUMIMOJI_STYLE = `
28
28
  .km-misspelt { box-shadow: 0 0 0 3px var(--km-wrong) inset, 0 2px 0 rgba(0,0,0,.35); }
29
29
  .km-apart { opacity: .6; }
30
30
  .km-held { outline: 3px solid var(--km-held); outline-offset: 1px; }
31
- .km-square:focus-visible, .km-button:focus-visible, .km-keep-title:focus-visible { outline: 3px solid var(--km-held); outline-offset: 2px; }
31
+ .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; }
32
32
  .km-notes { margin: 0; min-height: 1.4em; font-size: 14px; opacity: .8; overflow-wrap: anywhere; }
33
33
  .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); }
34
34
  .km-empty { opacity: .6; font-size: 14px; }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The version of this package, as `package.json` has it. A test holds the two together. */
2
- export declare const KUMIMOJI_VERSION = "1.1.0";
2
+ export declare const KUMIMOJI_VERSION = "1.2.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The version of this package, as `package.json` has it. A test holds the two together. */
2
- export const KUMIMOJI_VERSION = "1.1.0";
2
+ export const KUMIMOJI_VERSION = "1.2.0";
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kumimoji",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Crossword tile race: a word game engine where each player builds a crossword from a bag of letter tiles, in English or Japanese kana, alone against the clock or at a table of two to eight. Seeded deals, word checking, wild tiles, a computer player, and a playable table for React, Vue, Svelte, Angular or plain HTML. Zero dependencies.",
5
5
  "keywords": [
6
6
  "word-game",
7
+ "custom-element",
8
+ "web-component",
7
9
  "word-puzzle",
8
10
  "crossword",
9
11
  "crossword-game",
@@ -102,6 +104,14 @@
102
104
  "./react": {
103
105
  "types": "./dist/react.d.ts",
104
106
  "default": "./dist/react.js"
107
+ },
108
+ "./element": {
109
+ "types": "./dist/element.d.ts",
110
+ "default": "./dist/element.js"
111
+ },
112
+ "./element/define": {
113
+ "types": "./dist/element-define.d.ts",
114
+ "default": "./dist/element-define.js"
105
115
  }
106
116
  },
107
117
  "files": [
@@ -114,10 +124,11 @@
114
124
  ],
115
125
  "sideEffects": [
116
126
  "./dist/words.js",
117
- "./dist/tileWordsModule.js"
127
+ "./dist/tileWordsModule.js",
128
+ "./dist/element-define.js"
118
129
  ],
119
130
  "engines": {
120
- "node": ">=20"
131
+ "node": ">=22"
121
132
  },
122
133
  "scripts": {
123
134
  "build": "tsc -p tsconfig.json",
@@ -129,6 +140,7 @@
129
140
  "test:frameworks": "pnpm build && node scripts/check-frameworks.mjs",
130
141
  "check": "pnpm lint && pnpm typecheck && pnpm test",
131
142
  "site": "pnpm build && node scripts/site.mjs",
143
+ "pictures": "pnpm site && node scripts/readme-pictures.mjs",
132
144
  "docs:make": "node scripts/docs.mjs",
133
145
  "prepublishOnly": "pnpm check && pnpm build"
134
146
  },