@johnmorrisdotca/jirai 0.4.1 → 0.4.2

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,31 @@ All notable changes to this project are written here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-10-06
10
+
11
+ Nothing that was exported has changed. The README is the family's one layout, in full.
12
+
13
+ ### Added
14
+
15
+ - The README has a picture of the demo on a desk and on a phone, in light and dark, taken from the demo by `pnpm screenshots:readme` (the pictures are in `docs/images/` and are not in the package), a picture of each rule set and outline (square, orthogonal, hexagonal, wraparound, a heart, a star), of the explained hint and of the slate board with flowers on a phone, an Examples section of twelve examples that run, examples for React, Vue, Svelte and Angular, tables of the entry points, the calls to learn first and every theme property, and an Accessibility section.
16
+ - `pnpm test:readme` type-checks and runs every TypeScript and JavaScript example in the README against the built package, as a job of its own in CI; `src/readme.test.js` holds the README to the family's standard (sections in order, languages on code fences, pictures with alt text and a caption, no marketing words, version pins) in `pnpm check`; `pnpm test:package` fails if a picture or anything under `docs/` is in the packed package.
17
+
18
+ ### Fixed
19
+
20
+ - The README told readers to import `DEFAULT_SETTINGS` from `@johnmorrisdotca/jirai/play`, which does not export it (it is in the main entry), and showed `opening="clear"` on `<jirai-board>`, which does not read an `opening` attribute (the tag reads `width`, `height`, `mines`, `seed`, `grid`, `shape`, `no-guess`, `material`, `pieces` and `lang`). Both examples are corrected, and every TypeScript example is now type-checked and run.
21
+
22
+ ### Changed
23
+
24
+ - `pnpm pictures` is `pnpm screenshots:readme`, and takes WebP pictures in light and dark under `docs/images/`; `docs/desktop.jpg` and `docs/phone.jpg` are gone, and the presentation check looks for the hero pictures there instead.
25
+ - Repository only: the package and everything it exports are unchanged. `CONTRIBUTING.md` is the family's one text with a section of its own for Jirai, held to the master in johnmorrisdotca/.github by `src/family.test.js`; `ci.yml` and `pages.yml` are the family's one text (`pnpm check`, the demo, and the package on Linux, macOS and Windows), and any jobs of the package's own after them.
26
+ - The demo's page titles read `Jirai · pitch`, like the rest of the family's.
27
+ - The demo's own stylesheet is `demo/jirai.css`, named for the package like the family's.
28
+ - The demo is built into `site/`, where the Pages workflow and every other package look for it.
29
+
30
+ ### Fixed
31
+
32
+ - The API reference page wraps a long entry path instead of running about 2 px wider than a 360 px screen. Nothing the package exports has changed.
33
+
9
34
  ## [0.4.1] - 2026-10-05
10
35
 
11
36
  Nothing that was exported has changed.
package/README.md CHANGED
@@ -13,10 +13,24 @@ Minesweeper across four-neighbour, eight-neighbour, hexagonal and wraparound gri
13
13
 
14
14
  <p align="center"><a href="https://johnmorrisdotca.github.io/jirai/"><strong>Play Jirai →</strong></a> · <a href="https://johnmorrisdotca.github.io/jirai/api.html">API reference</a></p>
15
15
 
16
- <p align="center">
17
- <img src="docs/desktop.jpg" alt="Jirai on a desktop: the Minesweeper board and its shape, grid, size, mine-count and material controls in the shared family demo style" width="680">
18
- <img src="docs/phone.jpg" alt="Jirai on a phone: a square minefield with touch controls and clear number clues" width="220">
19
- </p>
16
+ <table align="center">
17
+ <tr>
18
+ <td align="center" valign="top">
19
+ <picture>
20
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-desk-dark.webp">
21
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-desk-light.webp" alt="The demo on a desk, in English: the page header with the language chooser, the API reference link, five cloth patches and the Help switch, the Your field choices (board, shape, level, width, height, mines, no-guess, first opening, material, markers and seed), and the Hexagonal field on green felt: a nine by nine board of hexagons on a wooden tray with the opened cells showing blue and green number clues, the counters Mines left 10, Time 0:00 and Moves 1, and the Start over, Open, Hint and Just the board buttons" width="600">
22
+ </picture>
23
+ <br><em>The demo on a desk: a hexagonal field, opened at its middle.</em>
24
+ </td>
25
+ <td align="center" valign="top">
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-phone-dark.webp">
28
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hero-phone-light.webp" alt="The demo on a phone, in Japanese: an orthogonal nine by nine field with opened cells and number clues, the status line 数字を手がかりに、安全なマスをすべて開けましょう, the buttons やり直す, 開く, ヒント and 盤だけ, and the start of the rules under it" width="190">
29
+ </picture>
30
+ <br><em>On a phone, in Japanese, in the device's light or dark.</em>
31
+ </td>
32
+ </tr>
33
+ </table>
20
34
 
21
35
  Jirai is a Minesweeper rules engine and player for TypeScript and JavaScript. The core is plain functions; drawing, browser controls, a custom element and an optional React wrapper are separate imports. The package has no runtime dependencies and needs Node 22 or a modern browser.
22
36
 
@@ -39,7 +53,7 @@ For a dedicated four-neighbour variant, its entry fixes the topology for you:
39
53
  ```ts
40
54
  import { makeOrthogonalBoard, newOrthogonalGame } from "@johnmorrisdotca/jirai/orthogonal";
41
55
 
42
- const settings = { width: 9, height: 9, mines: 10, noGuess: true, opening: "clear", seed: 42 };
56
+ const settings = { width: 9, height: 9, mines: 10, noGuess: true, opening: "clear" as const, seed: 42 };
43
57
  const game = newOrthogonalGame(settings);
44
58
  const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing neighbours
45
59
  ```
@@ -62,12 +76,96 @@ const board = makeOrthogonalBoard(settings, 40); // counts only edge-sharing nei
62
76
  - **Accessible controls:** keyboard navigation, pointer and touch, long press to mark, English and Japanese strings, and board labels read by assistive technology.
63
77
  - **Materials and markers:** ivory, wood or slate; flags, stones or flowers. Host CSS can replace the palette.
64
78
 
79
+ ### What's in it
80
+
81
+ Each picture is the real player, drawn by the package and taken from [the demo](https://johnmorrisdotca.github.io/jirai/) with `pnpm screenshots:readme`, in light and dark. Every field is the same seed, opened at the same cell, so the pictures are the same each run.
82
+
83
+ <table>
84
+ <tr>
85
+ <td align="center" valign="top" width="50%">
86
+ <picture>
87
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/square-desk-dark.webp">
88
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/square-desk-light.webp" alt="A nine by nine square field on a wooden tray on a desk, opened at its middle cell, with blue, green and red number clues, the counters Mines left 10, Time 0:00 and Moves 1, and the Start over, Open, Hint and Just the board buttons" width="360">
89
+ </picture>
90
+ <br><em><strong>Square.</strong> A number counts the eight cells around it.</em>
91
+ </td>
92
+ <td align="center" valign="top" width="50%">
93
+ <picture>
94
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/orthogonal-desk-dark.webp">
95
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/orthogonal-desk-light.webp" alt="A nine by nine orthogonal field on a desk, opened at its middle cell: the opened patch is larger and the clues are fewer, the title Orthogonal field and the counters Mines left 10" width="360">
96
+ </picture>
97
+ <br><em><strong>Orthogonal.</strong> A number counts only the four cells that share an edge.</em>
98
+ </td>
99
+ </tr>
100
+ <tr>
101
+ <td align="center" valign="top" width="50%">
102
+ <picture>
103
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hex-desk-dark.webp">
104
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hex-desk-light.webp" alt="A nine by nine hexagonal field on a desk: rows of hexagons offset along a slanting parallelogram, opened at its middle with blue and green clues, titled Hexagonal field" width="360">
105
+ </picture>
106
+ <br><em><strong>Hexagonal.</strong> A number counts the six cells around a hexagon.</em>
107
+ </td>
108
+ <td align="center" valign="top" width="50%">
109
+ <picture>
110
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/wraparound-desk-dark.webp">
111
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/wraparound-desk-light.webp" alt="A nine by nine wraparound field on a desk, opened at its middle, with clues along its edges that count cells on the opposite edge, titled Wraparound field" width="360">
112
+ </picture>
113
+ <br><em><strong>Wraparound.</strong> Opposite edges join, so a clue on an edge counts across it.</em>
114
+ </td>
115
+ </tr>
116
+ <tr>
117
+ <td align="center" valign="top" width="50%">
118
+ <picture>
119
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/heart-desk-dark.webp">
120
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/heart-desk-light.webp" alt="A heart-shaped sixteen by sixteen field on a desk, made of square cells with the cut-out corners missing, opened in its middle, with many number clues and the counters Mines left 27" width="360">
121
+ </picture>
122
+ <br><em><strong>A heart.</strong> Outlines cut cells out of the field: a heart, a star or a hexagon, with the level's share of mines.</em>
123
+ </td>
124
+ <td align="center" valign="top" width="50%">
125
+ <picture>
126
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/star-desk-dark.webp">
127
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/star-desk-light.webp" alt="A star-shaped sixteen by sixteen orthogonal field on a desk, a five-pointed star of square cells with its points cut out of the rectangle, opened in its middle, with the counters Mines left 14" width="360">
128
+ </picture>
129
+ <br><em><strong>A star.</strong> Shaped boards work on every rule; here the four-neighbour rule.</em>
130
+ </td>
131
+ </tr>
132
+ <tr>
133
+ <td align="center" valign="top" width="50%">
134
+ <picture>
135
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hint-desk-dark.webp">
136
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/hint-desk-light.webp" alt="A nine by nine square field on a desk after the Hint button: one cell is outlined in pale green, and the status line says This cell is safe. The neighbouring count settles it." width="360">
137
+ </picture>
138
+ <br><em><strong>The explained hint.</strong> The cell is outlined and the line says why it is certain.</em>
139
+ </td>
140
+ <td align="center" valign="top" width="50%">
141
+ <picture>
142
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/slate-flowers-phone-dark.webp">
143
+ <img src="https://raw.githubusercontent.com/johnmorrisdotca/jirai/main/docs/images/slate-flowers-phone-light.webp" alt="A nine by nine field on a phone in the slate material with flowers as markers: dark grey cells with white numbers, three flowers marking covered cells, the counters Mines left 8, Time 0:00 and Moves 3, the status line, the buttons Start over, Open, Hint and Just the board, and the line of help under them" width="240">
144
+ </picture>
145
+ <br><em><strong>On a phone.</strong> Slate with flowers; a tap opens, and a long press or the Open button marks.</em>
146
+ </td>
147
+ </tr>
148
+ </table>
149
+
65
150
  ## Use it in your project
66
151
 
152
+ ### Install
153
+
154
+ ```sh
155
+ npm install @johnmorrisdotca/jirai
156
+ pnpm add @johnmorrisdotca/jirai
157
+ yarn add @johnmorrisdotca/jirai
158
+ ```
159
+
160
+ It is ES modules only, with its types included, and needs Node 22 or later outside a browser. A page with no bundler loads the tag from a CDN (`@0` is the major version).
161
+
162
+ ### A player from a script
163
+
67
164
  Mount a player into any element. The first reveal asks the module worker to deal the board, so serve the package over HTTP and allow same-origin module workers in your content security policy.
68
165
 
69
- ```ts
70
- import { DEFAULT_SETTINGS, mountJirai } from "@johnmorrisdotca/jirai/play";
166
+ ```ts no-run
167
+ import { DEFAULT_SETTINGS } from "@johnmorrisdotca/jirai";
168
+ import { mountJirai } from "@johnmorrisdotca/jirai/play";
71
169
 
72
170
  const board = mountJirai(document.querySelector<HTMLElement>("#game")!, {
73
171
  settings: { ...DEFAULT_SETTINGS, grid: "orthogonal", seed: 7 },
@@ -85,6 +183,8 @@ board.destroy();
85
183
 
86
184
  The handle also provides `game()`, `progress()`, `play(cell, mark?)`, `hint()`, and `load(settings, progress?)`. Set `controls: false` when your page supplies its own controls and status. Change options with `set`; call `destroy` when the host is removed.
87
185
 
186
+ ### The tag
187
+
88
188
  Use the custom element without a mount call:
89
189
 
90
190
  ```html
@@ -92,12 +192,313 @@ Use the custom element without a mount call:
92
192
  import "@johnmorrisdotca/jirai/element/define";
93
193
  </script>
94
194
  <jirai-board width="9" height="9" mines="10" seed="42"
95
- grid="orthogonal" opening="clear" no-guess="true"
195
+ grid="orthogonal" no-guess="true"
96
196
  material="wood" pieces="stones" lang="ja"></jirai-board>
97
197
  ```
98
198
 
199
+ The tag reads `width`, `height`, `mines`, `seed`, `grid`, `shape`, `no-guess`, `material`, `pieces` and `lang`, and mounts again when one changes; the first opening is the default (`clear`), and `jirai-error` is the event it fires when a field cannot be dealt.
200
+
201
+ ### The React component
202
+
99
203
  The optional React entry exports `JiraiBoard` from `@johnmorrisdotca/jirai/react`; React is an optional peer dependency. Its options are read when mounted. Use a new React `key` to start with a different settings object.
100
204
 
205
+ ### In a framework
206
+
207
+ Two ways: the `<jirai-board>` tag, which every framework can carry, and `JiraiBoard`, the React component. The player deals a board in a module worker, so the page must be served over HTTP.
208
+
209
+ #### React
210
+
211
+ ```jsx
212
+ import { JiraiBoard } from "@johnmorrisdotca/jirai/react";
213
+
214
+ export function Game({ seed }) {
215
+ // The options are read when it mounts: a new key starts it with different settings.
216
+ return <JiraiBoard key={seed} settings={{ grid: "orthogonal", width: 9, height: 9, mines: 10, seed }} material="wood" onFinish={(game) => console.log(game.status)} />;
217
+ }
218
+ ```
219
+
220
+ #### Vue
221
+
222
+ Vue needs to be told that `jirai-board` is a custom element, a compiler option.
223
+
224
+ ```vue
225
+ <script setup>
226
+ import "@johnmorrisdotca/jirai/element/define";
227
+ defineProps({ seed: Number });
228
+ </script>
229
+
230
+ <template>
231
+ <jirai-board width="9" height="9" mines="10" :seed="seed" grid="hex" material="slate" pieces="flowers"></jirai-board>
232
+ </template>
233
+ ```
234
+
235
+ ```js no-check
236
+ // vite.config.js
237
+ import vue from "@vitejs/plugin-vue";
238
+
239
+ export default { plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "jirai-board" } } })] };
240
+ ```
241
+
242
+ #### Svelte
243
+
244
+ ```svelte
245
+ <script>
246
+ import "@johnmorrisdotca/jirai/element/define";
247
+ export let seed = 42;
248
+ </script>
249
+
250
+ <jirai-board width="16" height="16" mines="40" {seed} grid="square" lang="ja"></jirai-board>
251
+ ```
252
+
253
+ #### Angular
254
+
255
+ ```ts no-check
256
+ import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
257
+ import "@johnmorrisdotca/jirai/element/define";
258
+
259
+ @Component({
260
+ selector: "app-game",
261
+ standalone: true,
262
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
263
+ template: `<jirai-board width="9" height="9" mines="10" seed="42" grid="orthogonal"></jirai-board>`,
264
+ })
265
+ export class GameComponent {}
266
+ ```
267
+
268
+ ## Examples
269
+
270
+ Each example is a whole recipe: copy it and it works. The ones in TypeScript are run in CI against the built package (`pnpm test:readme`), so none of them is a guess, and the output shown is what they print.
271
+
272
+ ### A game on a page with no script of your own
273
+
274
+ Save this as a file, serve it over HTTP (the player deals in a module worker, which a `file:` page cannot load) and open it: a hexagonal field, dealt from a seed that is proved to need no guess.
275
+
276
+ ```html
277
+ <!doctype html>
278
+ <meta charset="utf-8">
279
+ <title>Jirai</title>
280
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/jirai@0/dist/element-define.js"></script>
281
+ <jirai-board width="12" height="12" mines="22" seed="2026" grid="hex" material="wood" pieces="flowers"></jirai-board>
282
+ ```
283
+
284
+ ### The first move deals the board
285
+
286
+ `newGame` makes a ready game with no mines in it. The first reveal deals the seeded field, with that cell and, by default, its neighbours safe, so nobody loses on the first move.
287
+
288
+ ```ts
289
+ import { DEFAULT_SETTINGS, newGame, play } from "@johnmorrisdotca/jirai";
290
+
291
+ let game = newGame({ ...DEFAULT_SETTINGS, width: 9, height: 9, mines: 10, seed: 42 });
292
+ console.log(game.status, game.board); // ready, nothing dealt yet
293
+ game = play(game, { kind: "reveal", cell: 40 });
294
+ console.log(game.status, "dealt around cell", game.board!.first, "with", game.board!.mines.filter(Boolean).length, "mines");
295
+ console.log(play(game, { kind: "reveal", cell: 999 }) === game); // a move that cannot be made returns the same game
296
+ ```
297
+
298
+ ```text
299
+ ready null
300
+ playing dealt around cell 40 with 10 mines
301
+ true
302
+ ```
303
+
304
+ ### A hint that proves its answer
305
+
306
+ `hintFor` reads only what is opened, never the hidden mines, and says which cells are certain, and from which clue. Flags are marks and never evidence.
307
+
308
+ ```ts
309
+ import { DEFAULT_SETTINGS, hintFor, newGame, play, visibleGame } from "@johnmorrisdotca/jirai";
310
+
311
+ let game = newGame({ ...DEFAULT_SETTINGS, seed: 42 });
312
+ game = play(game, { kind: "reveal", cell: 40 });
313
+ const hint = hintFor(visibleGame(game))!;
314
+ console.log(hint);
315
+ ```
316
+
317
+ ```text
318
+ {
319
+ safe: [ 5 ],
320
+ mines: [],
321
+ reason: 'count',
322
+ sources: [ 6 ],
323
+ contradiction: false
324
+ }
325
+ ```
326
+
327
+ ### Solve a field with nothing but hints
328
+
329
+ A no-guess field can be finished by deduction alone, so a bot needs no luck: ask for a hint, open what is safe, flag what is a mine, and repeat. A medium field and a hex star at the hard level are both cleared.
330
+
331
+ ```ts
332
+ import { DEFAULT_SETTINGS, gameProgress, hintFor, levelSettings, newGame, play, visibleGame } from "@johnmorrisdotca/jirai";
333
+
334
+ for (const [label, extra] of [["square", {}], ["hex star", { grid: "hex", shape: "star" }]] as const) {
335
+ const level = label === "square" ? "medium" : "hard";
336
+ const settings = { ...DEFAULT_SETTINGS, ...extra, ...levelSettings(level, extra), seed: 3, noGuess: true };
337
+ let game = play(newGame(settings), { kind: "reveal", cell: Math.floor(settings.height / 2) * settings.width + Math.floor(settings.width / 2) });
338
+ let hints = 0;
339
+ while (game.status === "playing") {
340
+ const hint = hintFor(visibleGame(game));
341
+ if (hint === null || (hint.safe.length === 0 && hint.mines.length === 0)) break;
342
+ hints += 1;
343
+ for (const cell of hint.safe) game = play(game, { kind: "reveal", cell });
344
+ for (const cell of hint.mines) if (game.marks[cell] !== "flag") game = play(game, { kind: "mark", cell });
345
+ }
346
+ console.log(label, game.status, `after ${hints} hints and ${game.moves.length} moves; saved in ${gameProgress(game).length} characters`);
347
+ }
348
+ ```
349
+
350
+ ```text
351
+ square won after 51 hints and 77 moves; saved in 2334 characters
352
+ hex star won after 34 hints and 50 moves; saved in 1601 characters
353
+ ```
354
+
355
+ ### Levels, outlines and huge fields
356
+
357
+ Four levels step up in size and mine share, on every rule and outline; a shaped board keeps the level's share of mines over the cells it has left. `hugeSettings` is the same on a field of four times the area.
358
+
359
+ ```ts
360
+ import { hugeSettings, levelSettings } from "@johnmorrisdotca/jirai";
361
+
362
+ console.log(levelSettings("hard"));
363
+ console.log(levelSettings("extra-hard", { grid: "hex", shape: "star" }));
364
+ console.log(hugeSettings("hard"));
365
+ ```
366
+
367
+ ```text
368
+ { width: 30, height: 16, mines: 99 }
369
+ { width: 40, height: 24, mines: 85 }
370
+ { width: 32, height: 32, mines: 211 }
371
+ ```
372
+
373
+ ### Deal a field and check it
374
+
375
+ `makeBoard` deals at a first cell; with `noGuess` it returns only a board its solver can finish. `measureBoard` grades it with the same deductions the hints use, so levels can be put in order.
376
+
377
+ ```ts
378
+ import { DEFAULT_SETTINGS, isSolvable, makeBoard, measureBoard } from "@johnmorrisdotca/jirai";
379
+
380
+ const board = makeBoard({ ...DEFAULT_SETTINGS, noGuess: true, seed: 7 }, 40);
381
+ console.log(isSolvable(board), board.mines.filter(Boolean).length, "mines; dealt on attempt", board.attempt);
382
+ const grade = measureBoard(board);
383
+ console.log(`score ${grade.score}, depth ${grade.depth}, solvable ${grade.solvable}`);
384
+ ```
385
+
386
+ ```text
387
+ true 10 mines; dealt on attempt 0
388
+ score 27.2, depth 10, solvable true
389
+ ```
390
+
391
+ ### Who touches whom
392
+
393
+ The rule sets differ only in which cells a number counts. `neighbours` says, for any cell, which cells those are.
394
+
395
+ ```ts
396
+ import { DEFAULT_SETTINGS, neighbours } from "@johnmorrisdotca/jirai";
397
+
398
+ for (const grid of ["square", "orthogonal", "hex", "wrap"] as const) {
399
+ console.log(grid.padEnd(10), "cell 40:", neighbours({ ...DEFAULT_SETTINGS, grid }, 40).length, "neighbours; cell 0:", neighbours({ ...DEFAULT_SETTINGS, grid }, 0).length);
400
+ }
401
+ ```
402
+
403
+ ```text
404
+ square cell 40: 8 neighbours; cell 0: 3 neighbours
405
+ orthogonal cell 40: 4 neighbours; cell 0: 2 neighbours
406
+ hex cell 40: 6 neighbours; cell 0: 2 neighbours
407
+ wrap cell 40: 8 neighbours; cell 0: 8 neighbours
408
+ ```
409
+
410
+ ### Keep a game and read it back
411
+
412
+ A saved game is the settings and the moves, not an answer anybody could edit. Reading it replays every move under the rules, so a record that is not a game is `null`.
413
+
414
+ ```ts
415
+ import { DEFAULT_SETTINGS, gameFromProgress, gameProgress, newGame, play } from "@johnmorrisdotca/jirai";
416
+
417
+ let game = newGame({ ...DEFAULT_SETTINGS, seed: 42 });
418
+ game = play(game, { kind: "reveal", cell: 40 });
419
+ const saved = gameProgress(game);
420
+ console.log(saved);
421
+ console.log(gameFromProgress(saved)?.status, gameFromProgress("not a game"), gameFromProgress(saved.slice(0, 60)));
422
+ ```
423
+
424
+ ```text
425
+ {"version":1,"settings":{"width":9,"height":9,"mines":10,"grid":"square","noGuess":true,"opening":"clear","seed":42},"moves":[{"kind":"reveal","cell":40}],"helped":false}
426
+ playing null null
427
+ ```
428
+
429
+ ### Today's field
430
+
431
+ `dailySeed` is the same seed for everyone on a date (UTC), per rule set, so a site can offer a daily field with nothing stored.
432
+
433
+ ```ts
434
+ import { dailySeed } from "@johnmorrisdotca/jirai";
435
+
436
+ console.log(dailySeed("2026-10-01"), dailySeed("2026-10-01", "hex"), dailySeed("2026-10-01") === dailySeed("2026-10-01"));
437
+ ```
438
+
439
+ ```text
440
+ 1293497838 968579362 true
441
+ ```
442
+
443
+ ### Draw it yourself
444
+
445
+ `boardModel` is the board as row-major labelled cells with no DOM, for a page that draws its own. Each cell says where it is, what it reads aloud and what state it is in.
446
+
447
+ ```ts
448
+ import { DEFAULT_SETTINGS, newGame, play } from "@johnmorrisdotca/jirai";
449
+ import { boardModel } from "@johnmorrisdotca/jirai/draw";
450
+
451
+ const game = play(newGame({ ...DEFAULT_SETTINGS, seed: 42 }), { kind: "reveal", cell: 40 });
452
+ const model = boardModel(game, {});
453
+ console.log(model.width, "by", model.height, "=", model.cells.length, "cells");
454
+ console.log(model.cells[40]);
455
+ ```
456
+
457
+ ```text
458
+ 9 by 9 = 81 cells
459
+ {
460
+ cell: 40,
461
+ x: 4,
462
+ y: 4,
463
+ width: 1,
464
+ height: 1,
465
+ label: '5, 5: empty',
466
+ text: '',
467
+ kind: 'open',
468
+ hint: false
469
+ }
470
+ ```
471
+
472
+ ### A player you steer from code
473
+
474
+ ```ts no-run
475
+ import { DEFAULT_SETTINGS } from "@johnmorrisdotca/jirai";
476
+ import { mountJirai } from "@johnmorrisdotca/jirai/play";
477
+
478
+ const board = mountJirai(document.querySelector<HTMLElement>("#game")!, {
479
+ settings: { ...DEFAULT_SETTINGS, grid: "wrap", seed: 7 },
480
+ material: "slate",
481
+ pieces: "flowers",
482
+ language: "ja",
483
+ onChange: () => localStorage.setItem("jirai", board.progress()), // keep the game as it is played
484
+ onFinish: (game) => console.log(game.status, game.helped), // won or lost, and whether a hint was used
485
+ });
486
+ const kept = localStorage.getItem("jirai");
487
+ if (kept) board.load({ ...DEFAULT_SETTINGS, grid: "wrap", seed: 7 }, kept); // play a kept game back
488
+ ```
489
+
490
+ ### A look of your own
491
+
492
+ The player is themed by custom properties, and `material` and `pieces` change how the cells and markers look, never the rules.
493
+
494
+ ```css
495
+ jirai-board .jr-root[data-material] { /* as specific as the material's own rule, so that it wins */
496
+ --jr-cover: #cfd8dc;
497
+ --jr-open: #eceff1;
498
+ --jr-ink: #263238;
499
+ }
500
+ ```
501
+
101
502
  ## Rules and settings
102
503
 
103
504
  | Setting | Values and limits |
@@ -163,12 +564,47 @@ The full engine entry is `@johnmorrisdotca/jirai`; four-neighbour helpers are in
163
564
 
164
565
  Every export of every entry point is in the [API reference](https://johnmorrisdotca.github.io/jirai/api.html), made from the source when the demo is built, and the [API guide](docs/API.md) lists the entries and public calls in prose.
165
566
 
567
+ ### Entry points
568
+
569
+ | Import | What it holds |
570
+ | --- | --- |
571
+ | `@johnmorrisdotca/jirai` | The rules engine, grid and shape utilities, levels, the dealer and solver, hints and saved progress |
572
+ | `@johnmorrisdotca/jirai/orthogonal` | The explicit four-neighbour game, board, hint and version-2 save helpers |
573
+ | `@johnmorrisdotca/jirai/draw` | `boardModel`, `JIRAI_STYLE` and the drawing model types |
574
+ | `@johnmorrisdotca/jirai/play` | `mountJirai` and the player's types and options |
575
+ | `@johnmorrisdotca/jirai/element` | `JiraiElement` and `defineJirai`, which registers nothing until called |
576
+ | `@johnmorrisdotca/jirai/element/define` | Defines `<jirai-board>` by being imported |
577
+ | `@johnmorrisdotca/jirai/react` | The optional `JiraiBoard` component; React is an optional peer |
578
+
579
+ ### The calls to learn first
580
+
581
+ | Call | What it does |
582
+ | --- | --- |
583
+ | `newGame(settings)` and `play(game, move)` | A ready game, and the game after a move; a move that cannot be made returns the same game |
584
+ | `visibleGame(game)` and `hintFor(visible)` | What a player can see, and the cells that are certain from it |
585
+ | `makeBoard(settings, first, options)` and `isSolvable(board)` | A seeded field dealt at a first cell, and whether deduction can finish it |
586
+ | `levelSettings(level, options)` and `hugeSettings(level, options)` | The size and mines of a level, on any rule and outline |
587
+ | `gameProgress(game)` and `gameFromProgress(text)` | A game as text, and back; `null` for a record that is not a game |
588
+ | `boardModel(game, options)` | The board as labelled cells, for a page that draws its own |
589
+ | `mountJirai(element, options)` | The player |
590
+
166
591
  ## Theming
167
592
 
168
593
  `boardModel(game, options)` returns row-major labelled cells without touching the DOM. The draw entry exports `JIRAI_STYLE` and the board model. The player uses the same theme variables as its SVG and HTML controls; set `material`, `pieces`, and `language` on mount or override the CSS custom properties in your host.
169
594
 
170
595
  Materials are `ivory`, `wood` and `slate`. Marker sets are `flags`, `stones` and `flowers`. They change appearance only; the engine still stores covered, flag, question or open states.
171
596
 
597
+ | Property | What it colours | Ivory | Wood | Slate |
598
+ | --- | --- | --- | --- | --- |
599
+ | `--jr-ground` | the tray round the board | `#a98954` | `#ae804a` | `#252e32` |
600
+ | `--jr-cover` | a covered cell | `#fbf8f1` | `#e0bb7e` | `#455359` |
601
+ | `--jr-open` | an opened cell | `#efe8d8` | `#c69d63` | `#303c41` |
602
+ | `--jr-line` | the lines between cells | `#cfc6b2` | `#936e40` | `#62747a` |
603
+ | `--jr-ink` | the digits and marks | `#1f2320` | `#352c20` | `#f3f0e5` |
604
+ | `--jr-accent` | the cell a hint outlines | `#2f7a4f` | `#2f7a4f` | `#b7d298` |
605
+
606
+ They are set on `.jr-root`, and the material sets them again with `.jr-root[data-material=…]`, so a host rule must be as specific (`.jr-root[data-material]`) to win. The buttons take `--jr-ui-ink`, `--jr-ui-line` and `--jr-ui-surface`, which follow the page's `--ink`, `--rule` and `--surface` when it has them, as the family's demos do.
607
+
172
608
  ## Limits
173
609
 
174
610
  The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at least 9×9; wraparound is rectangular.
@@ -192,6 +628,16 @@ The board is capped at 60 cells per side and 2,400 cells overall. Shapes need at
192
628
 
193
629
  For synchronous server use, consider running a deal in a worker; a browser deals extra-hard in the board's own worker without a pause.
194
630
 
631
+ ## Accessibility
632
+
633
+ - **Every cell is a button with a name.** A screen reader hears the cell's place and state ("5, 5: empty", "3, 2: flag", the number a clue shows), and the board is a labelled group, with `aria-busy` while a field is being dealt. `boardModel` gives the same label for a board you draw yourself.
634
+ - **The status line is spoken.** What to do next, what a hint proved and how the game ended is a `role="status"` line, so a change is announced without moving focus. A hint says which cell is certain and why, so it teaches the deduction as well as giving the answer.
635
+ - **The keyboard plays the whole game.** The board has one tab stop and the arrow keys move between cells; Enter opens, F or Space marks, and an open number whose flags match clears its neighbours. The buttons (Start over, Open or Mark, Hint, Just the board) are native buttons.
636
+ - **Touch and pointer.** Tap opens, a long press or right-click marks, and a Mark button switches a touch screen between opening and marking (`aria-pressed`), so nothing needs a long press. The buttons are at least 44 pixels high.
637
+ - **A number is not told by colour alone.** The clue is a digit, in a colour per value for those who see it; flags, stones and flowers are different shapes, not different colours, and every cell state has its name in the label.
638
+ - **Reduced motion.** The only transition, a cell's background, is on only for `prefers-reduced-motion: no-preference`.
639
+ - **Not yet.** The colour pairs of the three materials have not been measured against WCAG contrast ratios. A very wide field (30 and 40 columns) scrolls sideways inside its box on a narrow screen. The Japanese has not been read by a native reader (see [Languages](#languages)).
640
+
195
641
  ## Browser support
196
642
 
197
643
  The browser player uses ES modules, SVG, custom elements, dialogs and module workers. Serve built files over HTTP; `file:` pages cannot load its worker. The engine and drawing functions do not need DOM globals. Development and tests require Node 22 or later.
@@ -245,10 +691,14 @@ src/
245
691
  ground) and 雷 (*rai*, thunder): a mine is thunder buried in the ground. Every number on the board is a clue to
246
692
  where it lies. ([Wiktionary: 地雷](https://en.wiktionary.org/wiki/地雷).)
247
693
 
248
- ## Where it comes from
694
+ ## Where it comes from, and where it is used
249
695
 
250
696
  Minesweeper's rules are common property. Everything here, the rules, the dealer, the solver, the hints, the pictures and the words, is written for the package, and no third-party puzzle boards or artwork are included.
251
697
 
698
+ ### Used by
699
+
700
+ Using Jirai in something? Open an *Add my project* issue and we will add you.
701
+
252
702
  ### The family
253
703
 
254
704
  <!-- family:start (made by scripts/family-readme.mjs from scripts/family-template.mjs; change those, not this) -->
@@ -290,10 +740,12 @@ pnpm install --frozen-lockfile
290
740
  pnpm check # lint, types, tests and the presentation checks
291
741
  pnpm test:package # build and import the actual npm tarball
292
742
  pnpm test:demo # browser flows against the built page
293
- pnpm site # build the standalone page into docs/
743
+ pnpm site # build the standalone page into site/
744
+ pnpm test:readme # run every example in this README against the built package
745
+ pnpm screenshots:readme # take the README's pictures from the built demo, in light and dark
294
746
  ```
295
747
 
296
- The standalone game is `docs/index.html`. The preview binds to `127.0.0.1:6713`; see [CONTRIBUTING.md](CONTRIBUTING.md) before changing the engine or player.
748
+ The standalone game is `site/index.html`. The preview binds to `127.0.0.1:6713`; see [CONTRIBUTING.md](CONTRIBUTING.md) before changing the engine or player.
297
749
 
298
750
  ## Contributing
299
751
 
@@ -301,7 +753,7 @@ Bug reports and pull requests are welcome in the [issues](https://github.com/joh
301
753
 
302
754
  ## Changes
303
755
 
304
- Every release is written up in [CHANGELOG.md](./CHANGELOG.md).
756
+ Every release is written up in [CHANGELOG.md](./CHANGELOG.md). The latest release, 0.4.2, adds no code: it is this README in full, with pictures of every rule set and outline, examples that are run on every change, examples for React, Vue, Svelte and Angular, and an Accessibility section.
305
757
 
306
758
  ## Licence
307
759
 
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export * from "./deduce.ts";
8
8
  export * from "./keep.ts";
9
9
  export { seededRandom } from "./random.ts";
10
10
  /** Package version, kept in step with the release metadata. */
11
- export declare const VERSION = "0.4.1";
11
+ export declare const VERSION = "0.4.2";
12
12
  export { SHAPES, activeCell, activeCells } from "./shape.ts";
13
13
  export * from "./orthogonal.ts";
14
14
  export { measureBoard } from "./measure.ts";
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export * from "./deduce.js";
8
8
  export * from "./keep.js";
9
9
  export { seededRandom } from "./random.js";
10
10
  /** Package version, kept in step with the release metadata. */
11
- export const VERSION = "0.4.1";
11
+ export const VERSION = "0.4.2";
12
12
  export { SHAPES, activeCell, activeCells } from "./shape.js";
13
13
  export * from "./orthogonal.js";
14
14
  export { measureBoard } from "./measure.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/jirai",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Minesweeper on square, orthogonal, hexagonal and wraparound boards: four levels up to extra-hard, seeded games, a safe opening, verified no-guess boards, explained hints and a playable board for any page. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -113,7 +113,8 @@
113
113
  "test:package": "pnpm build && node scripts/check-package.mjs",
114
114
  "prepublishOnly": "pnpm check && pnpm test:package",
115
115
  "check:presentation": "node scripts/check-presentation.mjs",
116
- "pictures": "pnpm site && node scripts/readme-pictures.mjs"
116
+ "screenshots:readme": "pnpm site && node scripts/readme-pictures.mjs",
117
+ "test:readme": "pnpm build && node scripts/check-readme-examples.mjs"
117
118
  },
118
119
  "peerDependencies": {
119
120
  "react": ">=18"
@@ -23,7 +23,11 @@ const FILES = {
23
23
  "scripts/release-notes.mjs": "efab0fb78ad05973a8885624c0d2ce3b458b55799c11eabaa5176603ce8cd1e9",
24
24
  "scripts/community/SECURITY.md": "ff6f650be7789396233671d2558439736efe7f96a1d1d39115dd5cf94d29275c",
25
25
  "scripts/community/CODE_OF_CONDUCT.md": "34da1f56f004ce8f7f95d4449b64d2ecb5827dd9f0d4eea1da351a20713ebe70",
26
+ "scripts/community/CONTRIBUTING.md": "3225201be4f66531c27e849f0bb219f8c7274e5815b726a93565bccadbc6a43c",
26
27
  };
28
+ // The Pages workflow is one text in every package. A package that also builds a documentation site adds the one step that
29
+ // builds it, and that line is left out before the text is compared.
30
+ const PAGES_SHA256 = "f45c33dd0403551588b8b00882cfd0c1d953d2095e558ffab0f7451b8cdb6ef6";
27
31
 
28
32
  describe("the family template", () => {
29
33
  it("is the one file, byte for byte, in every package", () => {
@@ -51,13 +55,41 @@ describe("the family template", () => {
51
55
  });
52
56
 
53
57
  describe("the files every package shares", () => {
54
- it("are copied unchanged: the README writer, the release notes, and the family's SECURITY.md and CODE_OF_CONDUCT.md", () => {
58
+ it("are copied unchanged: the README writer, the release notes, and the family's SECURITY.md, CODE_OF_CONDUCT.md and CONTRIBUTING.md", () => {
55
59
  for (const [path, hash] of Object.entries(FILES)) expect(sha(path), path).toBe(hash);
56
60
  });
57
61
 
58
62
  it("SECURITY.md and CODE_OF_CONDUCT.md are the master text of github.com/johnmorrisdotca/.github, which scripts/community keeps a copy of", () => {
59
63
  for (const file of ["SECURITY.md", "CODE_OF_CONDUCT.md"]) expect(read(file), file).toBe(read(`scripts/community/${file}`));
60
64
  });
65
+
66
+ it("CONTRIBUTING.md is the master text of github.com/johnmorrisdotca/.github, which scripts/community keeps a copy of, and then what is particular to this package", () => {
67
+ const master = read("scripts/community/CONTRIBUTING.md");
68
+ const own = read("CONTRIBUTING.md");
69
+ const name = FAMILY.find((one) => one.id === id).name;
70
+ expect(own.startsWith(`${master}\n## Particular to ${name}\n`), "CONTRIBUTING.md is the master text, a blank line and '## Particular to <Name>'").toBe(true);
71
+ expect(own.split("\n## Particular to ").length - 1).toBe(1);
72
+ });
73
+ });
74
+
75
+ describe("the workflows", () => {
76
+ const ci = read(".github/workflows/ci.yml");
77
+
78
+ it("ci.yml has the family's three jobs, check, demo and package, and runs `pnpm check` rather than its parts, with jobs of the package's own after them", () => {
79
+ expect(ci).toMatch(/^name: CI$/m);
80
+ const jobs = [...ci.slice(ci.indexOf("\njobs:\n")).matchAll(/^ {2}([a-z][a-z-]*):$/gm)].map((match) => match[1]);
81
+ expect(jobs.slice(0, 3)).toEqual(["check", "demo", "package"]);
82
+ expect(ci).toContain(" - run: pnpm check\n");
83
+ expect(ci).not.toMatch(/- run: pnpm (lint|typecheck|test)$/m);
84
+ expect(ci).toContain("os: [ubuntu-latest, macos-latest, windows-latest]");
85
+ expect(ci).toContain("run: pnpm test:package");
86
+ });
87
+
88
+ it("pages.yml is the same text in every package, named Pages, apart from one step that builds a documentation site", () => {
89
+ const pages = read(".github/workflows/pages.yml").replace("\n - run: pnpm docs:site\n", "\n");
90
+ expect(pages).toMatch(/^name: Pages$/m);
91
+ expect(createHash("sha256").update(pages).digest("hex")).toBe(PAGES_SHA256);
92
+ });
61
93
  });
62
94
 
63
95
  describe("the README's family", () => {
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export * from "./deduce.ts";
8
8
  export * from "./keep.ts";
9
9
  export { seededRandom } from "./random.ts";
10
10
  /** Package version, kept in step with the release metadata. */
11
- export const VERSION = "0.4.1";
11
+ export const VERSION = "0.4.2";
12
12
 
13
13
  export { SHAPES, activeCell, activeCells } from "./shape.ts";
14
14
 
@@ -1,34 +1,70 @@
1
- // readme.test.js: the README has the sections every package of the family has, in the family's order, each with something in it.
2
- import { readFileSync } from "node:fs";
1
+ // The README, held to the family's README standard (johnmorrisdotca/.github, README-STANDARD.md): its sections, its examples'
2
+ // languages, its pictures and their files, its tables and its tone. The rules are in scripts/readme-lint.mjs, the same file in
3
+ // every package. A fault names the line and what to do.
3
4
  import { describe, expect, it } from "vitest";
4
5
 
5
- const readme = readFileSync("README.md", "utf8").replace(/\r\n/g, "\n");
6
- const HOUSE = [
7
- "In 30 seconds", "Who it is for", "Features", "Use it in your project", "API", "Theming", "Limits", "Browser support",
8
- "Languages", "Roadmap", "Architecture", "The name", "Where it comes from", "Development", "Contributing", "Changes", "Licence",
9
- ];
10
-
11
- describe("the README's shape", () => {
12
- it("has the family's sections, in the family's order, each with something in it", () => {
13
- const headings = [...readme.matchAll(/^## (.+)$/gm)].map((match) => match[1]);
14
- let from = 0;
15
- for (const heading of HOUSE) {
16
- const at = headings.indexOf(heading, from);
17
- expect(at, `README.md has no "## ${heading}" after the section before it`).toBeGreaterThanOrEqual(from);
18
- from = at + 1;
19
- const start = readme.indexOf(`\n## ${heading}\n`) + 1;
20
- const next = readme.indexOf("\n## ", start + 4);
21
- const body = readme.slice(start + heading.length + 4, next < 0 ? undefined : next).trim();
22
- expect(body.length, `"## ${heading}" is empty`).toBeGreaterThan(20);
23
- }
24
- });
25
-
26
- it("puts the family under \"Where it comes from\", one level down", () => {
27
- const from = readme.indexOf("\n## Where it comes from\n");
28
- const family = readme.indexOf("\n### The family\n");
29
- const next = readme.indexOf("\n## Development\n");
30
- expect(from).toBeGreaterThan(0);
31
- expect(family).toBeGreaterThan(from);
32
- expect(family).toBeLessThan(next);
6
+ import { lintReadme, readInputs, REQUIRED_SECTIONS } from "../scripts/readme-lint.mjs";
7
+
8
+ describe("the README keeps the family's standard", () => {
9
+ it("has no fault", () => {
10
+ expect(lintReadme(readInputs("."))).toEqual([]);
11
+ });
12
+
13
+ it("lists the sections in the order the standard gives", () => {
14
+ expect(REQUIRED_SECTIONS.map(([name]) => name)).toEqual([
15
+ "In 30 seconds", "Who it is for", "Features", "Use it in your project", "Examples", "API", "Theming", "Limits", "Accessibility",
16
+ "Browser support", "Languages", "Roadmap", "Architecture", "The name", "Where it comes from, and where it is used", "Development",
17
+ "Contributing", "Changes", "Licence",
18
+ ]);
19
+ });
20
+ });
21
+
22
+ describe("the standard's checks catch what they are for", () => {
23
+ const pkg = { name: "@johnmorrisdotca/sample", version: "2.1.0", files: ["dist", "README.md"] };
24
+ const raw = "https://raw.githubusercontent.com/johnmorrisdotca/sample/main/docs/images/";
25
+ const ok = (name) => ({ file: name, bytes: 1000 });
26
+ const faultsFor = (readme, pictures = []) => lintReadme({ readme, pkg, pictures, minSubjects: 0 });
27
+
28
+ it("refuses a fenced block with no language, and a language that is not listed", () => {
29
+ expect(faultsFor("# T\n\n```\nx\n```\n\n```pascal\nx\n```\n").join("\n")).toMatch(/no language[\s\S]*pascal/);
30
+ });
31
+
32
+ it("refuses a picture that is not this repository's docs/images, has no alt text, or has no dark twin", () => {
33
+ const readme = `# T\n\n<picture><img src="docs/a.webp" alt="x" width="1"></picture>\n\n<picture><source media="(prefers-color-scheme: dark)" srcset="${raw}z-desk-dark.webp"><img src="${raw}z-desk-light.webp" alt="" width="1"></picture>\n`;
34
+ const faults = faultsFor(readme, [ok("z-desk-light.webp")]).join("\n");
35
+ expect(faults).toMatch(/not https:\/\/raw\.githubusercontent\.com\/johnmorrisdotca\/sample\/main\/docs\/images\//);
36
+ expect(faults).toMatch(/no alt text/);
37
+ expect(faults).toMatch(/has no dark twin/);
38
+ expect(faults).toMatch(/docs\/images\/z-desk-dark\.webp does not exist/);
39
+ });
40
+
41
+ it("refuses a picture over its budget, an unused file and a badly named one", () => {
42
+ const faults = faultsFor("# T\n", [{ file: "big-desk-light.webp", bytes: 300 * 1024 }, { file: "big-desk-dark.webp", bytes: 1000 }, { file: "photo.jpg", bytes: 10 }]).join("\n");
43
+ expect(faults).toMatch(/big-desk-light\.webp is 300 KB; the budget for it is 200 KB/);
44
+ expect(faults).toMatch(/big-desk-dark\.webp is not used/);
45
+ expect(faults).toMatch(/photo\.jpg is not named/);
46
+ });
47
+
48
+ it("refuses marketing words and exclamation marks in prose but not in code", () => {
49
+ const faults = faultsFor("# T\n\nA powerful, seamless library. It works!\n\n```ts\nconst powerful = !x; // magic!\n```\n").join("\n");
50
+ expect(faults).toMatch(/"powerful"/);
51
+ expect(faults).toMatch(/"seamless"/);
52
+ expect(faults).toMatch(/exclamation mark/);
53
+ expect(faults).not.toMatch(/"magic"/);
54
+ });
55
+
56
+ it("refuses a table row of the wrong width, a skipped heading level and a stale version pin", () => {
57
+ const faults = faultsFor("# T\n\n| a | b |\n| - | - |\n| 1 |\n\n## A\n\n#### Skips\n\nUse `@johnmorrisdotca/sample@1` from the CDN.\n").join("\n");
58
+ expect(faults).toMatch(/1 cells where its header has 2/);
59
+ expect(faults).toMatch(/skips a heading level/);
60
+ expect(faults).toMatch(/@johnmorrisdotca\/sample@1; the package is at 2\.1\.0, so the pin is @2/);
61
+ });
62
+
63
+ it("refuses a README longer than npm will show", () => {
64
+ expect(faultsFor(`# T\n\n${"word ".repeat(13_000)}\n`).join("\n")).toMatch(/npm shows only the first 65,536/);
65
+ });
66
+
67
+ it("refuses a package whose files ship the pictures", () => {
68
+ expect(lintReadme({ readme: "# T\n", pkg: { ...pkg, files: ["dist", "docs"] }, pictures: [], minSubjects: 0 }).join("\n")).toMatch(/"files" lists docs/);
33
69
  });
34
70
  });