@johnmorrisdotca/kumimoji 1.1.1 → 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,25 @@ 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
+
9
28
  ## [1.1.1] - 2026-10-01
10
29
 
11
30
  Nothing that was exported has changed.
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
@@ -15,8 +15,8 @@ In English and in Japanese kana. The rules as pure, seeded TypeScript, two word
15
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,14 +257,15 @@ 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
 
253
271
  ## Architecture
@@ -255,7 +273,7 @@ fetches when a game is first dealt.
255
273
  The rules are plain functions over plain data with no DOM: a game is a value,
256
274
  every move returns the next one, and the same seed deals the same bag in every
257
275
  browser. Word lists are loaded only when a game opens, and the table is its own
258
- entry (`/ui` for plain DOM, `/react` for React), so a page that wants only the
276
+ entry (`/ui` for plain DOM, `/element` for a tag, `/react` for React), so a page that wants only the
259
277
  rules carries none of it. A computer player is pure too, so its turn replays
260
278
  exactly after a reload.
261
279
 
@@ -265,6 +283,9 @@ src/
265
283
  ├── computer.types.ts what a computer did in one step of its turn
266
284
  ├── computerPlay.ts the word a computer player lays: from its own hand, with no randomness
267
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
268
289
  ├── export.ts a game written out as JSON or plain text, and read back
269
290
  ├── generate.ts making a game from a seed: a crossword laid first, then its tiles become the bag
270
291
  ├── grid.ts the grid of tiles on a table with no edges, and how it is written as a string
@@ -299,7 +320,7 @@ src/
299
320
  ├── words.ts the "/words" entry: the word lists, for a place with no browser, such as a server or a test
300
321
  └── ui/ the table that draws and plays a game
301
322
  ├── board.ts how a tile on the table is drawn: sound, in a run that is not a word, or apart
302
- ├── mount.ts the table itself: mounting it on a page, and the options it takes
323
+ ├── mount.ts the table itself: mounting it on a page, the options it takes, and playing it from a keyboard
303
324
  └── style.ts the table's own styles, every colour and size a CSS variable so a page can restyle it
304
325
  ```
305
326
 
@@ -335,25 +356,27 @@ issue and we will add you.
335
356
 
336
357
  ### The family
337
358
 
338
- 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
339
360
  [github.com/johnmorrisdotca](https://github.com/johnmorrisdotca):
340
361
 
341
- - [Korokoro](https://github.com/johnmorrisdotca/korokoro) (コロコロ, the sound
342
- of something small rolling along): a dice roller and a dice notation
343
- parser, with the exact odds of every roll.
344
- - [Kyuubu](https://github.com/johnmorrisdotca/kyuubu) (キューブ, how Japanese
345
- says "cube"): a turning cube for the browser, 2×2 to 7×7, drawn in CSS 3D.
346
- - [Hitotsu](https://github.com/johnmorrisdotca/hitotsu) (一つ, "one"): a
347
- colour-card game, named for the call a player makes with one card left.
348
- - [Toranpu](https://github.com/johnmorrisdotca/toranpu) (トランプ, the everyday
349
- Japanese word for a deck of playing cards): ten card games, complete, with a
350
- computer for every seat.
351
- - [Tane](https://github.com/johnmorrisdotca/tane) (種, a seed, the kind you
352
- plant): seeded random numbers and daily seeds.
353
- - [Narabe](https://github.com/johnmorrisdotca/narabe) (並べ, "line them up"):
354
- a rules engine for forty-eight board games, from five in a row to Go.
355
- - [Tenka](https://github.com/johnmorrisdotca/tenka) (天下, "all under
356
- 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.
357
380
 
358
381
  ## Features
359
382
 
@@ -377,6 +400,12 @@ Kumimoji has siblings, each made for the same site, each MIT, each at
377
400
  - **Help**, if chosen: the words the hand's own tiles spell.
378
401
  - **A table to play on.** `mountKumimoji` plays a whole game alone in plain
379
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).
380
409
  - **The table's words in English and Japanese**, and any other language by a
381
410
  table of your own. **Themeable**: every colour is a CSS variable.
382
411
 
@@ -485,6 +514,26 @@ The tiles are the 45 base hiragana. A word is spelt with the base of each of
485
514
  its kana, so ことば is played, and listed, as ことは: a tile shows the other
486
515
  forms it plays as in its corner (`CORNER_FORMS`, `formsOfTile`).
487
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
+
488
537
  ### A table of players
489
538
 
490
539
  ```ts
@@ -552,7 +601,7 @@ The JSON, as `kumimojiToJSON` writes it:
552
601
  {
553
602
  "format": 1,
554
603
  "game": "kumimoji",
555
- "generator": "kumimoji 1.1.1",
604
+ "generator": "kumimoji 1.2.0",
556
605
  "size": 3,
557
606
  "level": "hard",
558
607
  "seed": 44,
@@ -614,6 +663,11 @@ What that means for a project that uses this package:
614
663
  This is a description, not legal advice; the licences themselves are what
615
664
  hold.
616
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
+
617
671
  ### Loading them
618
672
 
619
673
  In a browser, `loadTileWords(language)` fetches the list as its own module
@@ -653,6 +707,8 @@ as you type. The entries:
653
707
  | `@johnmorrisdotca/kumimoji` | Dealing, playing, judging, the table of players, the computer player, keeping and export, and the table's words |
654
708
  | `@johnmorrisdotca/kumimoji/words` | Reads both word lists from this package's files, for a server, a test or a script: `loadTileWordsFromModule` |
655
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 |
656
712
  | `@johnmorrisdotca/kumimoji/react` | `KumimojiBoard` and `KumimojiTable` |
657
713
 
658
714
  ### Dealing and judging
@@ -771,6 +827,32 @@ entry has `boardModel(tiles, verdict, glyphOf?)`, which works out the squares
771
827
  to draw and how each tile stands, with `BOARD_MARGIN` and `BOARD_LEAST`, and
772
828
  `KUMIMOJI_STYLE`, the table's stylesheet as a string.
773
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
+
774
856
  ### The React components
775
857
 
776
858
  ```jsx
@@ -808,10 +890,31 @@ export function Board() {
808
890
  else for its `<div>`. Options are read when it mounts; give it a new `key` to
809
891
  start over with different ones.
810
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
+
811
913
  ## Theming
812
914
 
813
- Every colour is a CSS variable on `.km-root`. Set them in your stylesheet, on
814
- 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
815
918
  on the table itself and so wins in light and dark alike.
816
919
 
817
920
  | Variable | What it sets | Light | Dark |
@@ -886,7 +989,7 @@ that language, whose licence lets its words be shipped.
886
989
  Any current browser: Chrome, Edge, Firefox and Safari, on a desk or a phone.
887
990
  It needs ES2020 and dynamic `import()`. The table is tested in Chromium and in
888
991
  WebKit, Safari's engine, at phone size with touch and on a desktop. The rules
889
- 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
890
993
  by `require()`.
891
994
 
892
995
  ## The command line
@@ -907,12 +1010,12 @@ words.allowed.has("snowy"); // → true
907
1010
  ## Roadmap
908
1011
 
909
1012
  - More languages for the tiles, each from a real dictionary of that language.
910
- - Playing the plain-DOM table from a keyboard, as the React one on Itsutsu
911
- can be.
1013
+ - Typing a letter to lay that tile, in the plain-DOM table, which is
1014
+ otherwise played from a keyboard.
912
1015
  - A table of players in plain DOM, beside the game alone.
913
1016
  - A computer player at three strengths.
914
1017
  - Word definitions for a finished crossword.
915
- - A Vue wrapper and a web component, beside the React one.
1018
+ - A Vue wrapper, beside the React one and the tag.
916
1019
 
917
1020
  Left out on purpose: play between devices, which needs a server (a table is
918
1021
  kept as text made to be sent, and Itsutsu sends it; the sending is yours); a
@@ -930,9 +1033,10 @@ pnpm install
930
1033
  pnpm check # lint, types and tests
931
1034
  pnpm test:table # the demo in real browsers, by taps
932
1035
  pnpm site # build the demo into ./site, then serve it
1036
+ pnpm pictures # take the README's two pictures from the built demo
933
1037
  ```
934
1038
 
935
- 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.
936
1040
 
937
1041
  ## Changes
938
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.1";
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.1";
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.1",
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
  },