packing-bingo 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # Packing Bingo (持ちものビンゴ)
2
2
 
3
- **A packing checklist for school trips that plays like bingo: stamp each item as it goes in the bag, line them up, and get combos, fanfares and fireworks. Drop it into a React site as one component, or open the single-file HTML page.**
3
+ **A React component that turns a school-trip packing checklist into a 4×4 bingo card: students stamp each item as it goes in the bag, complete lines, and get combos, fanfares and fireworks. One component, no runtime dependencies, safe to render on the server.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/packing-bingo?logo=npm)](https://www.npmjs.com/package/packing-bingo)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
- [![React 18 or later](https://img.shields.io/badge/React-%E2%89%A518-61DAFB?logo=react&logoColor=black)](#use-it-in-react)
8
- [![TypeScript types included](https://img.shields.io/badge/types-TypeScript-3178C6?logo=typescript&logoColor=white)](src/index.ts)
9
- [![Standalone single HTML file](https://img.shields.io/badge/standalone-single%20HTML%20file-E34F26?logo=html5&logoColor=white)](index.html)
7
+ [![React 18 or later](https://img.shields.io/badge/React-%E2%89%A518-61DAFB?logo=react&logoColor=black)](#at-a-glance)
8
+ [![TypeScript types included](https://img.shields.io/badge/types-TypeScript-3178C6?logo=typescript&logoColor=white)](#api)
9
+ [![Standalone single HTML file](https://img.shields.io/badge/standalone-single%20HTML%20file-E34F26?logo=html5&logoColor=white)](#standalone-page)
10
10
 
11
- [Use it in React](#use-it-in-react) · [Standalone page](#standalone-page) · [Customize](#customize) · [Good to know](#good-to-know) · [日本語](#日本語) · [License](#license)
11
+ [Quick start](#quick-start) · [Recipes](#recipes) · [API](#api) · [Integration notes](#integration-notes) · [Without React](#without-react) · [日本語](#日本語) · [Changelog](CHANGELOG.md)
12
12
 
13
13
  <p align="center">
14
14
  <a href="docs/images/bingo-desktop.png"><img src="docs/images/bingo-desktop.png" alt="A 4×4 bingo card of packing items. A diagonal line has just been completed: a large BINGO! banner, a +100 score popup and confetti are on screen, and the score panel shows 8/16 items, 2 lines and a 2-tap combo." width="560"></a>
@@ -24,29 +24,35 @@ _The second line completes on a two-tap combo: the line is drawn, the BINGO! ban
24
24
 
25
25
  _On a phone, left to right: one line done and a リーチ ("reach") badge on the square that would finish the next one; a three-tap combo; the Perfect! screen with the final score, fireworks and falling item emoji._
26
26
 
27
- ## Features
27
+ ## What it does
28
28
 
29
- Packing the night before a school trip is a chore. Packing Bingo turns the checklist into a 4×4 bingo card, so every item packed is a stamp, and every few items there's something to celebrate.
29
+ Packing the night before a school trip is a chore. Packing Bingo puts the 16 items on a bingo card, so every packed item is a stamp, and every few items there's something to celebrate.
30
30
 
31
- - **Packing becomes a game.** The 16 items sit on a 4×4 card. Any row, column or diagonal is a bingo, for 10 possible lines.
32
- - **There's always a next goal.** A square that would complete a line pulses and gets a リーチ ("reach") badge, and the footer names the item that would finish it.
33
- - **Every tap gives feedback.** Each stamp comes with a ripple and sparks. Taps less than 2.5 s apart chain into a combo, with a rising note on each tap and a draining combo gauge.
34
- - **The score rewards finishing.** An item is worth 10 points × the current combo the first time it's stamped. Completing n lines at once adds 100 × n² points, and the first full card adds a 1000-point bonus. Unchecking and re-checking adds nothing.
35
- - **Finishing gets a real finale.** Stamping all 16 plays a "ready" stamp slam, fireworks and falling item emoji, then counts up the score, max combo and lines.
36
- - **It fits into your site.** It's one React component with TypeScript types. All its CSS is scoped and prefixed, it lays itself out by the width of its own container rather than the screen, it renders safely on the server (Next.js App Router included), and you can theme it with CSS variables.
37
- - **Sound needs no audio files.** All effects are synthesized with the Web Audio API. The 🔊 toggle mutes them, and the choice is remembered in the browser.
38
- - **It's considerate by default.** Squares are real checkboxes, so the keyboard works. `prefers-reduced-motion` turns off particles, shaking and bouncing. Android phones vibrate on taps and bingos.
31
+ - **Every tap gets feedback.** A stamp, a ripple, sparks and a rising note. Taps less than 2.5 s apart chain into a combo.
32
+ - **There's always a next goal.** A square that would complete a line pulses with a リーチ ("reach") badge, and the status line names the item.
33
+ - **Lines and the full card are celebrated.** Any row, column or diagonal (10 lines in all) is a bingo, with a banner and confetti. Stamping all 16 plays a finale with fireworks and a score card.
34
+ - **Points reward finishing,** not tapping on and off. See [Scoring](#scoring).
35
+ - **It's considerate by default.** Squares are real checkboxes, so the keyboard works. `prefers-reduced-motion` is respected. The sound is synthesized (no audio files) and can be muted.
39
36
 
40
- ## Use it in React
37
+ ## At a glance
41
38
 
42
- You need React 18 or later.
39
+ | Area | Details |
40
+ | --- | --- |
41
+ | Install | `npm install packing-bingo`: ESM and CommonJS, TypeScript types included |
42
+ | Requires | React 18 or later and `react-dom`. No other dependencies, no CSS import, no provider |
43
+ | Size | About 13 kB minified and gzipped, CSS included (React not counted) |
44
+ | Server rendering | Ships with `"use client"`. On the server it renders an empty `<div>`, and the card is built after mount |
45
+ | Styling | Injects one `<style>` into `<head>`. Every class is prefixed `pkb-`. Themable with CSS variables, and it sizes itself to its container |
46
+ | Network | Loads two fonts from Google Fonts, unless you pass `fonts={false}` |
47
+ | Storage | localStorage, only for `storageKey` progress and the mute setting |
48
+ | Browsers | Chrome and Edge 105+, Safari 16+, Firefox 121+. Only tested in Chromium so far ([details](#browser-support-and-testing)) |
49
+
50
+ ## Quick start
43
51
 
44
52
  ```sh
45
- npm install packing-bingo # or: pnpm add packing-bingo / yarn add packing-bingo
53
+ npm install packing-bingo # or: pnpm add packing-bingo / yarn add packing-bingo
46
54
  ```
47
55
 
48
- Render the component wherever the checklist should go:
49
-
50
56
  ```tsx
51
57
  import { PackingBingo } from "packing-bingo";
52
58
 
@@ -55,9 +61,24 @@ export default function PackingPage() {
55
61
  }
56
62
  ```
57
63
 
58
- With `storageKey`, each student's progress is saved in their browser, so they can close the page and come back. The component ships with `"use client"`, so in the Next.js App Router you can use it straight from a page. It renders an empty `<div>` on the server and builds the card in the browser.
64
+ That's the whole setup. The card fills the width of its container, up to 720 px. With `storageKey`, each student's checks are saved in their browser, so they can close the page and come back.
65
+
66
+ **Next.js App Router:** you can use the component straight from a Server Component page, as above, including `items` and `messages={en}`. Functions you write can't cross from a Server Component to a Client Component, though, so callbacks like `onChange` or `onBingo` stop the build with "Event handlers cannot be passed to Client Component props". For those, wrap the card in your own client component:
67
+
68
+ ```tsx
69
+ // app/packing/PackingCard.tsx
70
+ "use client";
71
+
72
+ import { PackingBingo } from "packing-bingo";
73
+
74
+ export function PackingCard() {
75
+ return <PackingBingo storageKey="kyoto-trip-2026" onBingo={(e) => console.log(e)} />;
76
+ }
77
+ ```
78
+
79
+ ## Recipes
59
80
 
60
- ### Your own items and English text
81
+ ### Your own items and text
61
82
 
62
83
  ```tsx
63
84
  import { PackingBingo, en, type BingoItem } from "packing-bingo";
@@ -68,67 +89,261 @@ const items: BingoItem[] = [
68
89
  // … exactly 16 items, row by row from the top left
69
90
  ];
70
91
 
71
- <PackingBingo items={items} messages={en} title="Kyoto Trip" tag="Class 2-B" />;
92
+ <PackingBingo items={items} messages={en} title="Kyoto Trip" tag="Class 2-B" storageKey="kyoto-2b" />;
72
93
  ```
73
94
 
74
- `messages` takes any subset of the UI text, so `messages={{ reset: "Start over" }}` changes just that one label. Define `items` and `messages` outside the component, or memoize them, because changing them rebuilds the card.
95
+ - `items` needs exactly 16 entries. Any other count throws `[packing-bingo] "items" must have exactly 16 entries`.
96
+ - `messages` is merged over the Japanese defaults, so `messages={{ reset: "Start over" }}` changes just that label. `en` is a complete English set. See [Messages](#messages) for every key.
97
+ - On narrow cards, labels wrap after a `・`, so `常備薬・酔い止め` breaks as 常備薬・ / 酔い止め.
75
98
 
76
- ### Keeping the state yourself
99
+ ### Save progress on your server
77
100
 
78
- To store progress in your own backend or app state, make it controlled:
101
+ `storageKey` saves progress in one browser. To make progress follow a student to another device, keep the checks in your own state and pass them as `checked`, which makes the card controlled:
79
102
 
80
103
  ```tsx
81
- const [checked, setChecked] = useState<boolean[]>(Array(16).fill(false));
104
+ import { useEffect, useRef, useState } from "react";
105
+ import { PackingBingo } from "packing-bingo";
106
+
107
+ export function PackingCard({ studentId }: { studentId: string }) {
108
+ const [checked, setChecked] = useState<boolean[] | null>(null);
109
+ const saving = useRef(Promise.resolve());
110
+
111
+ useEffect(() => {
112
+ let current = true; // ignore a response that arrives after the student has changed
113
+ setChecked(null);
114
+ fetch(`/api/packing/${studentId}`)
115
+ .then((res) => res.json())
116
+ .then((data) => {
117
+ if (current) setChecked(data.checked ?? Array(16).fill(false));
118
+ });
119
+ return () => {
120
+ current = false;
121
+ };
122
+ }, [studentId]);
123
+
124
+ if (!checked) return null; // or a placeholder
125
+
126
+ const save = (next: boolean[]) => {
127
+ setChecked(next);
128
+ // Send one request at a time, so a slow earlier save can't overwrite a later one.
129
+ saving.current = saving.current
130
+ .then(() => fetch(`/api/packing/${studentId}`, { method: "PUT", body: JSON.stringify({ checked: next }) }))
131
+ .then(() => undefined, () => undefined);
132
+ };
133
+
134
+ return <PackingBingo checked={checked} onChange={save} />;
135
+ }
136
+ ```
137
+
138
+ Students tap faster than requests finish, so the example sends the saves one after another, and it drops a load that arrives after `studentId` has changed. Each save sends all 16 checks, so a later save always holds the latest state.
139
+
140
+ How a controlled card behaves:
82
141
 
142
+ - A tap doesn't change the card by itself. It calls `onChange` with the 16 booleans the user asked for, and the card follows when `checked` changes. If you don't update `checked`, the tap is undone.
143
+ - When `checked` changes by exactly one item, the card plays it like a tap: stamp, sound, and any bingo or finale. A bigger change, such as data arriving from your server, is applied quietly, with no fanfare and no bonus for lines completed at once.
144
+ - The reset button calls `onChange` with 16 `false` values.
145
+ - `storageKey` is ignored.
146
+
147
+ ### React to bingos
148
+
149
+ ```tsx
83
150
  <PackingBingo
84
- checked={checked}
85
- onChange={setChecked}
86
- onBingo={({ newLines, totalLines }) => console.log("bingo!", newLines, totalLines)}
87
- onPerfect={({ score }) => saveResult(score)}
88
- />;
151
+ storageKey="kyoto-trip-2026"
152
+ onBingo={({ newLines, totalLines, score }) => analytics.track("bingo", { totalLines, score })}
153
+ onPerfect={({ score, maxCombo }) => analytics.track("perfect", { score, maxCombo })}
154
+ />
155
+ ```
156
+
157
+ - `onBingo` fires when a tap completes one or more lines. On the tap that completes the whole card, `onPerfect` fires instead.
158
+ - `onChange` fires only for what the user does: a tap, the reset button, or `ref.reset()`. Changes you make through `checked` or `ref.setChecked()` don't trigger it.
159
+ - `onBingo` and `onPerfect` fire whenever a change plays like a tap, including a one-item change of `checked`. Quiet updates and the first render fire nothing.
160
+ - `onChange` runs first, then `onBingo` or `onPerfect`, after the card has updated. You can reset or unmount the card from inside any of them.
161
+
162
+ ### Control the card from outside
163
+
164
+ ```tsx
165
+ import { useRef } from "react";
166
+ import { PackingBingo, type PackingBingoHandle } from "packing-bingo";
167
+
168
+ function PackingPage() {
169
+ const card = useRef<PackingBingoHandle>(null);
170
+ return (
171
+ <>
172
+ <button onClick={() => card.current?.reset()}>Start over</button>
173
+ <PackingBingo ref={card} storageKey="kyoto-trip-2026" />
174
+ </>
175
+ );
176
+ }
177
+ ```
178
+
179
+ The handle has `reset()`, `setChecked()` and `getState()`. See [Ref handle](#ref-handle). With a controlled card, change `checked` instead of calling `setChecked()`.
180
+
181
+ ### Theme and layout
182
+
183
+ Set any of these CSS variables on the component or an ancestor:
184
+
185
+ | Variable | Used for | Default |
186
+ | --- | --- | --- |
187
+ | `--pkb-sun` | Board background | `#FFD54F` |
188
+ | `--pkb-ink` | Lines, borders and text | `#2A1F14` |
189
+ | `--pkb-paper` | Squares | `#FFFDF6` |
190
+ | `--pkb-red` | Stamps and accents | `#E8473B` |
191
+ | `--pkb-mint` | Completed lines | `#BDEBD2` |
192
+ | `--pkb-font-fallback` | Font used before or instead of the display fonts | `sans-serif` |
193
+
194
+ ```tsx
195
+ <PackingBingo style={{ "--pkb-sun": "#9AD0F5" } as React.CSSProperties} />
89
196
  ```
90
197
 
91
- Only a change of a single item plays like a tap. A bigger jump in `checked`, such as loading saved progress from your server, updates the card quietly, with no fanfare and no bonus points for lines completed all at once.
198
+ The card takes the full width of its container, up to 720 px, and centers itself. When its container is narrower than 560 px, it switches to a compact layout. This uses a container query, so the compact layout also kicks in inside a narrow sidebar on a wide screen. The height follows from the width. Because the server renders an empty `<div>`, the card appears after hydration; if layout shift matters, give its wrapper a `min-height`.
199
+
200
+ ## API
92
201
 
93
202
  ### Props
94
203
 
95
204
  | Prop | Type | Default | What it does |
96
205
  | --- | --- | --- | --- |
97
- | `items` | `BingoItem[]` (16) | Japanese school-trip list | Squares, row by row: `{ emoji, label }` |
98
- | `messages` | `Partial<Messages>` | `ja` | UI text; `en` is included |
99
- | `title`, `tag`, `subtitle` | `string` | from `messages` | Heading, the small tag above it, and the line below it |
100
- | `defaultChecked` | `boolean[]` (16) | all `false` | Initial state when uncontrolled |
101
- | `checked` | `boolean[]` (16) | – | Controlled state; update it in `onChange` |
102
- | `onChange` | `(checked) => void` | – | Called after each tap or reset (before `onBingo`/`onPerfect`) |
103
- | `onBingo` | `({ newLines, totalLines, score }) => void` | – | Called when a tap completes lines |
104
- | `onPerfect` | `({ score, maxCombo }) => void` | – | Called when all 16 are stamped |
105
- | `storageKey` | `string` | – | Save progress in localStorage (uncontrolled only) |
106
- | `sound` | `boolean` | `true` | Start with sound on or off, unless the user has already chosen |
206
+ | `items` | `readonly BingoItem[]` (exactly 16) | `DEFAULT_ITEMS` (Japanese school-trip list) | The squares, row by row: `{ emoji, label }` |
207
+ | `messages` | `Partial<Messages>` | `ja` | UI text, merged over the Japanese defaults. `en` is included |
208
+ | `title`, `tag`, `subtitle` | `string` | from `messages` | The heading, the small label above it, and the line below it |
209
+ | `storageKey` | `string` | none | Save the checks in localStorage under this key. Uncontrolled cards only |
210
+ | `defaultChecked` | `readonly boolean[]` (16) | all `false` | Initial checks of an uncontrolled card, used when nothing is saved under `storageKey` |
211
+ | `checked` | `readonly boolean[]` (16) | none | Makes the card controlled. See [Save progress on your server](#save-progress-on-your-server) |
212
+ | `onChange` | `(checked: boolean[]) => void` | none | The user tapped or reset. For a controlled card, this is the requested state |
213
+ | `onBingo` | `(event: BingoEvent) => void` | none | A tap completed one or more lines |
214
+ | `onPerfect` | `(event: PerfectEvent) => void` | none | A tap completed the card |
215
+ | `sound` | `boolean` | `true` | Initial sound setting, until the user picks one with the 🔊 button |
107
216
  | `fonts` | `boolean` | `true` | Load Dela Gothic One and M PLUS Rounded 1c from Google Fonts |
108
- | `demo` | `boolean` | `false` | Pre-fill six items and show demo buttons |
109
- | `className`, `style` | | – | Applied to the wrapper `<div>` |
217
+ | `demo` | `boolean` | `false` | Pre-fill six items (when nothing is saved) and show demo buttons |
218
+ | `className`, `style` | | none | Applied to the wrapper `<div>` |
219
+ | `ref` | `Ref<PackingBingoHandle>` | none | See [Ref handle](#ref-handle) |
110
220
 
111
- A `ref` gives you `reset()`, `setChecked(checked)` and `getState()`. `setChecked` follows the same rule as `checked`: one item changes like a tap, more change quietly. `getState()` returns the checks, completed lines, score, max combo and whether the card is complete.
221
+ Props are compared by value, so inline arrays and objects are fine. When the content of `items`, `title`, `tag`, `subtitle`, `storageKey`, `sound`, `fonts`, `demo` or the text in `messages` changes, the card is rebuilt. The checks are kept, unless `storageKey` changed, in which case the card loads that key's own progress. The score is then recomputed, as described in [Scoring](#scoring). Message functions can use your props or state: when `messages` changes, the status line is redrawn without a rebuild.
112
222
 
113
- ### Theming
223
+ ### Events
114
224
 
115
- Set any of these CSS variables on the component or an ancestor: `--pkb-sun` (board), `--pkb-ink` (lines and text), `--pkb-paper` (squares), `--pkb-red` (stamps and accents), `--pkb-mint` (completed lines), and `--pkb-font-fallback`.
225
+ ```ts
226
+ interface BingoEvent {
227
+ newLines: number[]; // lines completed by this tap, as indices into LINES
228
+ totalLines: number; // lines completed so far
229
+ score: number; // score after this tap
230
+ }
116
231
 
117
- ```tsx
118
- <PackingBingo style={{ "--pkb-sun": "#9AD0F5" } as React.CSSProperties} />
232
+ interface PerfectEvent {
233
+ score: number; // final score, including the 1000-point bonus the first time
234
+ maxCombo: number;
235
+ }
236
+ ```
237
+
238
+ ### Ref handle
239
+
240
+ ```ts
241
+ interface PackingBingoHandle {
242
+ /** Same as the reset button: clears the card and score, and calls onChange. A controlled card only calls onChange. */
243
+ reset(): void;
244
+ /** A one-item change plays like a tap; bigger changes apply quietly. Doesn't call onChange. */
245
+ setChecked(checked: readonly boolean[]): void;
246
+ /** null before mount and on the server. */
247
+ getState(): PackingBingoState | null;
248
+ }
249
+
250
+ interface PackingBingoState {
251
+ checked: boolean[]; // 16 booleans, row by row
252
+ lines: number[]; // completed lines, as indices into LINES
253
+ score: number;
254
+ maxCombo: number;
255
+ complete: boolean; // all 16 checked
256
+ }
119
257
  ```
120
258
 
121
- The card is at most 720 px wide and switches to its compact layout below 560 px of available width.
259
+ `LINES` is exported. Its entries 0–3 are the rows, 4–7 the columns, 8 the diagonal from the top left, and 9 the diagonal from the top right.
260
+
261
+ ### Scoring
262
+
263
+ - An item checked for the first time scores 10 × the current combo. A combo is a chain of taps less than 2.5 s apart.
264
+ - Completing n lines with one tap scores 100 × n². Each line scores only once.
265
+ - Completing the card for the first time adds 1000.
266
+ - Unchecking never takes points away, and checking an item again earns nothing.
267
+ - After a quiet update or a rebuild (loading saved progress, for example), the score is recomputed as 10 per checked item plus 100 per completed line, and the max combo starts again from 0.
268
+
269
+ ### Messages
270
+
271
+ `messages` accepts any subset of these keys. The full Japanese and English sets are `ja` and `en` in [`src/messages.ts`](src/messages.ts).
272
+
273
+ - **Text:** `title`, `tag`, `subtitle`, `packed`, `bingo`, `score`, `reset`, `soundOn`, `soundOff`, `stamp`, `reachBadge`, `reachPop`, `bannerSingle`, `bannerDouble`, `bannerTriple`, `statusDone`, `perfectTag`, `perfectStamp`, `perfectTitle`, `perfectBody`, `statScore`, `statCombo`, `statLines`, `close`, `demoLabel`, `demoBingo`, `demoPerfect`, `demoRestore`. In `perfectStamp` and `perfectBody`, `\n` starts a new line.
274
+ - **Functions:** `combo(n)`, `comboPop(n)`, `bannerNth(totalLines)`, `bannerMulti(newLines)`, `statusStart(left)`, `statusLines(lines, left)`, `statusReach(labels)`, `perfectBonus(points)`. Each returns a string.
275
+
276
+ ### Exports
277
+
278
+ - `packing-bingo`: `PackingBingo`, `createPackingBingo`, `LINES`, `DEFAULT_ITEMS`, `ja`, `en`, and the types `PackingBingoProps`, `PackingBingoHandle`, `PackingBingoState`, `BingoItem`, `BingoEvent`, `PerfectEvent`, `Messages`, `PackingBingoOptions`, `PackingBingoInstance`.
279
+ - `packing-bingo/core`: the same without the React component and its types (`PackingBingoProps`, `PackingBingoHandle`), so it doesn't import React.
280
+
281
+ ## Integration notes
282
+
283
+ ### What the card adds to your page
284
+
285
+ On first mount, the card appends `<style id="pkb-styles">` to `<head>`, shared by every card and left in place after unmount. Unless `fonts={false}`, it also appends `<link id="pkb-fonts">` for Google Fonts. All CSS is scoped under `.pkb`, and every class and keyframe name starts with `pkb-`, so the rest of your page is left alone.
286
+
287
+ Everything the card draws (the confetti canvas, the banner, the finale screen) stays inside the card's own box. Nothing is `position: fixed` or attached to `<body>`, and the card is its own stacking context, so a sticky site header stays on top of it.
288
+
289
+ ### Content Security Policy
290
+
291
+ The injected `<style>` and a few inline `style` attributes need `style-src 'unsafe-inline'`. With fonts on, also allow `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`.
292
+
293
+ ### Fonts
294
+
295
+ The card uses Dela Gothic One and M PLUS Rounded 1c. With `fonts={false}`, nothing is requested from Google. If your site already loads fonts with those family names (self-hosted, for example), the card uses them. Otherwise it falls back to `--pkb-font-fallback`.
296
+
297
+ ### Sound and vibration
298
+
299
+ Sound is synthesized with the Web Audio API. The audio context is only created after the user has interacted with the page, so the browser shows no autoplay warnings. The 🔊 button's choice is stored in localStorage as `packing-bingo-muted`, and every card created afterwards starts with it, including after a reload. Cards already on screen keep their own setting. Taps and bingos vibrate where the Vibration API exists, which in practice means Android browsers.
300
+
301
+ ### Saved data
302
+
303
+ With `storageKey`, the checks are stored under that key as a JSON array of 16 booleans. The score isn't stored. Saved progress takes priority over `defaultChecked`. If two cards on one page should keep separate progress, give each its own key. Blocked storage, as in some private browsing modes, is handled: the card just doesn't save.
122
304
 
123
- ### Without React
305
+ ### Accessibility
306
+
307
+ Each square is a native checkbox inside a `<label>`, so Tab and Space work and the item name is the accessible name. The emoji and the stamp are hidden from assistive technology. The status line is a polite live region. With `prefers-reduced-motion`, shaking, bouncing and particles are turned off. It hasn't been tested with a screen reader yet.
308
+
309
+ ### Browser support and testing
310
+
311
+ The card uses CSS container queries (Chrome and Edge 105+, Safari 16+, Firefox 110+) and `:has()` for the keyboard focus ring (Firefox 121+).
312
+
313
+ Tested so far:
314
+
315
+ - React 19 in StrictMode, with three cards on one page, controlled and uncontrolled
316
+ - Server rendering with `renderToString`, ESM and CommonJS
317
+ - A Next.js 16 App Router production build, using the card from a Server Component page and from a client component
318
+ - Installing from npm with npm and pnpm
319
+ - Taps scripted in Chromium at desktop and phone sizes, including resets and callbacks that land mid-celebration
320
+
321
+ These scripted browser tests aren't part of this repository yet. Safari, Firefox and real phones haven't been tried, and the sound hasn't been checked on a real device. Emoji come from each device's emoji font, so they look different on each system.
322
+
323
+ ## Without React
124
324
 
125
325
  The engine has no framework dependency:
126
326
 
127
327
  ```ts
128
328
  import { createPackingBingo } from "packing-bingo/core";
129
329
 
130
- const card = createPackingBingo(document.getElementById("bingo")!, { storageKey: "trip" });
131
- // later: card.destroy();
330
+ const card = createPackingBingo(document.getElementById("bingo")!, {
331
+ storageKey: "trip",
332
+ onBingo: (e) => console.log(e),
333
+ });
334
+ // card.reset(), card.getState(), card.setChecked(next, { animate: true }), card.destroy()
335
+ ```
336
+
337
+ Its options are the React props without `checked`, `className`, `style` and `ref` (type `PackingBingoOptions`). For a controlled card, pass `controlled: true` and update it with `setChecked()`. Without `{ animate: true }`, `setChecked()` always applies quietly.
338
+
339
+ Without a bundler, load the browser build from a CDN. It defines a global `PackingBingo`:
340
+
341
+ ```html
342
+ <div id="bingo"></div>
343
+ <script src="https://cdn.jsdelivr.net/npm/packing-bingo@0.1/dist/packing-bingo.global.js"></script>
344
+ <script>
345
+ PackingBingo.createPackingBingo(document.getElementById("bingo"), { storageKey: "trip" });
346
+ </script>
132
347
  ```
133
348
 
134
349
  ## Standalone page
@@ -143,12 +358,7 @@ open index.html # macOS; on other systems, open the file in a browser
143
358
 
144
359
  Add `?demo` to the URL to start part-way through and show the demo buttons: "▶ ビンゴを見る" plays a bingo, and "▶ コンボ → パーフェクト" plays a combo run to a full card. On GitHub Pages, use **Settings → Pages → Deploy from a branch → `main` / root**.
145
360
 
146
- ## Customize
147
-
148
- - **React:** use the `items`, `messages`, `title`, `tag` and `subtitle` props shown above.
149
- - **Standalone page:** the last `<script>` in `index.html` calls `PackingBingo.createPackingBingo(…)`. Add options there, for example `items: [{ emoji: "🛂", label: "Passport" }, …]`. To change the defaults for every build, edit `src/items.ts` or `src/messages.ts` and run `npm run build`.
150
-
151
- The card needs exactly 16 items, and it throws an error otherwise. On narrow screens, labels wrap after a `・`, so `常備薬・酔い止め` breaks as 常備薬・ / 酔い止め.
361
+ To change the page, edit the options in its last `<script>`, which calls `PackingBingo.createPackingBingo(…)`. To change the defaults for every build, edit `src/items.ts` or `src/messages.ts` and run `npm run build`.
152
362
 
153
363
  ## Development
154
364
 
@@ -160,19 +370,29 @@ npm run typecheck
160
370
 
161
371
  `index.html` is generated from `src/` by `scripts/build-standalone.mjs`, so don't edit it by hand.
162
372
 
163
- ## Good to know
373
+ ## 日本語
164
374
 
165
- - **Saving is per browser.** `storageKey` and the standalone page use localStorage, so progress doesn't follow a student to another device. For that, use controlled mode and store `checked` yourself.
166
- - **Emoji look different on each system**, because they come from the device's emoji font.
167
- - **Vibration works only where the Vibration API exists**, which in practice means Android browsers. iPhones don't vibrate.
168
- - **Testing so far:** the React component was tested with React 19 in StrictMode, three cards on one page, controlled and uncontrolled, and server rendering with `renderToString` (ESM and CommonJS). Resets and callbacks that land mid-celebration were checked with scripted browser tests, which aren't part of this repository yet. Taps were automated in Chromium at desktop and phone sizes. Safari, Firefox and real phones haven't been tried yet, and the sound hasn't been checked on a real device.
375
+ 修学旅行の持ち物チェックリストを4×4のビンゴにする、Reactのコンポーネントです。持ち物をかばんに入れるたびにマスをタップしてスタンプを押し、タテ・ヨコ・ナナメがそろうとビンゴになります。
169
376
 
170
- ## 日本語
377
+ ```sh
378
+ npm install packing-bingo
379
+ ```
380
+
381
+ ```tsx
382
+ import { PackingBingo } from "packing-bingo";
383
+
384
+ <PackingBingo storageKey="kyoto-trip-2026" />;
385
+ ```
171
386
 
172
- 修学旅行の持ち物チェックリストを、4×4のビンゴにしたものです。持ち物を入れるたびにマスをタップしてスタンプを押し、タテ・ヨコ・ナナメがそろうとビンゴになります。コンボ・スコア・効果音・花火で、前の晩の荷造りを少し楽しくします。
387
+ - React 18以上で動きます。ほかに依存するパッケージはなく、CSSの読み込みも要りません。
388
+ - `storageKey` を付けると、チェックがその人のブラウザ(localStorage)に保存されます。
389
+ - 持ち物は `items`(ちょうど16個)で、文言は `messages` で変えられます。英語の文言は `en` です。
390
+ - 自分のサーバーに保存したいときは、`checked` と `onChange` を使って親側で状態を持ちます([Save progress on your server](#save-progress-on-your-server))。
391
+ - Next.jsのApp Routerでは、Server Componentのページにそのまま置けます(`items` や `messages={en}` も渡せます)。ただし、`onChange` などのコールバックは渡せないので、使うときは `"use client"` を付けた自分のコンポーネントで包んでください。
392
+ - 配色はCSS変数(`--pkb-sun` など)で変えられます。Google Fontsを読み込みたくないときは `fonts={false}` にします。
393
+ - CSP(Content Security Policy)を設定している場合は、`style-src 'unsafe-inline'` が必要です。
173
394
 
174
- - **Reactのサイトで使う場合:** `npm install packing-bingo` でインストールし、`<PackingBingo storageKey="kyoto-trip-2026" />` と書くだけで表示できます。持ち物は `items`、画面の文言は `messages` で変更できます。Next.jsのApp Routerでもそのまま使えます。
175
- - **単体のページで使う場合:** `index.html` を開くだけで動き、進み具合はブラウザに保存されます。`?demo` を付けると、途中まで埋まった状態とデモ用のボタンが出ます。
395
+ 詳しい仕様は [API](#api) と [Integration notes](#integration-notes) にあります。
176
396
 
177
397
  ## License
178
398
 
@@ -198,7 +198,7 @@ function createSound(isMuted) {
198
198
  // src/styles.ts
199
199
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
200
200
  var CSS = `
201
- .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;width:100%}
201
+ .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;isolation:isolate;width:100%}
202
202
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
203
203
  .pkb-board{position:relative;overflow:hidden;max-width:720px;margin:0 auto;padding:22px 26px 20px;border-radius:24px;background-color:var(--pk-sun);background-image:radial-gradient(rgba(42,31,20,.12) 1.3px,transparent 1.6px);background-size:16px 16px;border:2.5px solid var(--pk-ink);color:var(--pk-ink);font-family:var(--pk-body);line-height:1.4;text-align:left}
204
204
  .pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
@@ -915,14 +915,15 @@ function createPackingBingo(container, options = {}) {
915
915
  if (!busy) request(demoStart(), null, { reset: true });
916
916
  });
917
917
  }
918
- let initial = options.defaultChecked ? normalize(options.defaultChecked) : null;
919
- if (!initial && storageKey) {
918
+ let initial = null;
919
+ if (storageKey) {
920
920
  try {
921
921
  const saved = JSON.parse(readStore(storageKey) ?? "null");
922
922
  if (Array.isArray(saved)) initial = normalize(saved);
923
923
  } catch {
924
924
  }
925
925
  }
926
+ if (!initial && options.defaultChecked) initial = normalize(options.defaultChecked);
926
927
  if (!initial && demo) initial = demoStart();
927
928
  fit();
928
929
  baseline(initial ?? normalize(void 0));
package/dist/core.cjs CHANGED
@@ -227,7 +227,7 @@ function createSound(isMuted) {
227
227
  // src/styles.ts
228
228
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
229
229
  var CSS = `
230
- .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;width:100%}
230
+ .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;isolation:isolate;width:100%}
231
231
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
232
232
  .pkb-board{position:relative;overflow:hidden;max-width:720px;margin:0 auto;padding:22px 26px 20px;border-radius:24px;background-color:var(--pk-sun);background-image:radial-gradient(rgba(42,31,20,.12) 1.3px,transparent 1.6px);background-size:16px 16px;border:2.5px solid var(--pk-ink);color:var(--pk-ink);font-family:var(--pk-body);line-height:1.4;text-align:left}
233
233
  .pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
@@ -944,14 +944,15 @@ function createPackingBingo(container, options = {}) {
944
944
  if (!busy) request(demoStart(), null, { reset: true });
945
945
  });
946
946
  }
947
- let initial = options.defaultChecked ? normalize(options.defaultChecked) : null;
948
- if (!initial && storageKey) {
947
+ let initial = null;
948
+ if (storageKey) {
949
949
  try {
950
950
  const saved = JSON.parse(readStore(storageKey) ?? "null");
951
951
  if (Array.isArray(saved)) initial = normalize(saved);
952
952
  } catch {
953
953
  }
954
954
  }
955
+ if (!initial && options.defaultChecked) initial = normalize(options.defaultChecked);
955
956
  if (!initial && demo) initial = demoStart();
956
957
  fit();
957
958
  baseline(initial ?? normalize(void 0));
package/dist/core.d.cts CHANGED
@@ -82,7 +82,7 @@ interface PackingBingoOptions {
82
82
  title?: string;
83
83
  tag?: string;
84
84
  subtitle?: string;
85
- /** Initial state: 16 booleans. */
85
+ /** Initial state (16 booleans), used when nothing is saved under `storageKey`. */
86
86
  defaultChecked?: readonly boolean[];
87
87
  /**
88
88
  * When true, taps don't change the card by themselves: they call `onChange` with the
package/dist/core.d.ts CHANGED
@@ -82,7 +82,7 @@ interface PackingBingoOptions {
82
82
  title?: string;
83
83
  tag?: string;
84
84
  subtitle?: string;
85
- /** Initial state: 16 booleans. */
85
+ /** Initial state (16 booleans), used when nothing is saved under `storageKey`. */
86
86
  defaultChecked?: readonly boolean[];
87
87
  /**
88
88
  * When true, taps don't change the card by themselves: they call `onChange` with the
package/dist/core.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  createPackingBingo,
6
6
  en,
7
7
  ja
8
- } from "./chunk-RJDKWHWG.js";
8
+ } from "./chunk-ZDBGKPFF.js";
9
9
  export {
10
10
  DEFAULT_ITEMS,
11
11
  LINES,
package/dist/index.cjs CHANGED
@@ -231,7 +231,7 @@ function createSound(isMuted) {
231
231
  // src/styles.ts
232
232
  var FONT_URL = "https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap";
233
233
  var CSS = `
234
- .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;width:100%}
234
+ .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;isolation:isolate;width:100%}
235
235
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
236
236
  .pkb-board{position:relative;overflow:hidden;max-width:720px;margin:0 auto;padding:22px 26px 20px;border-radius:24px;background-color:var(--pk-sun);background-image:radial-gradient(rgba(42,31,20,.12) 1.3px,transparent 1.6px);background-size:16px 16px;border:2.5px solid var(--pk-ink);color:var(--pk-ink);font-family:var(--pk-body);line-height:1.4;text-align:left}
237
237
  .pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
@@ -948,14 +948,15 @@ function createPackingBingo(container, options = {}) {
948
948
  if (!busy) request(demoStart(), null, { reset: true });
949
949
  });
950
950
  }
951
- let initial = options.defaultChecked ? normalize(options.defaultChecked) : null;
952
- if (!initial && storageKey) {
951
+ let initial = null;
952
+ if (storageKey) {
953
953
  try {
954
954
  const saved = JSON.parse(readStore(storageKey) ?? "null");
955
955
  if (Array.isArray(saved)) initial = normalize(saved);
956
956
  } catch {
957
957
  }
958
958
  }
959
+ if (!initial && options.defaultChecked) initial = normalize(options.defaultChecked);
959
960
  if (!initial && demo) initial = demoStart();
960
961
  fit();
961
962
  baseline(initial ?? normalize(void 0));
@@ -1012,6 +1013,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1012
1013
  messagesRef.current = messages;
1013
1014
  const controlled = checked !== void 0;
1014
1015
  const latest = (0, import_react.useRef)(checked ?? defaultChecked);
1016
+ const latestKey = (0, import_react.useRef)(config.storageKey);
1015
1017
  if (controlled) latest.current = checked;
1016
1018
  const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
1017
1019
  const key = JSON.stringify({ ...config, messageShape, controlled });
@@ -1027,7 +1029,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1027
1029
  ...config,
1028
1030
  messages: liveMessages,
1029
1031
  controlled,
1030
- defaultChecked: latest.current,
1032
+ defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
1031
1033
  onChange: (next) => {
1032
1034
  if (!controlled) latest.current = next;
1033
1035
  callbacks.current.onChange?.(next);
@@ -1038,6 +1040,7 @@ var PackingBingo = (0, import_react.forwardRef)(function PackingBingo2(props, re
1038
1040
  inst.current = instance;
1039
1041
  return () => {
1040
1042
  if (!controlled) latest.current = instance.getState().checked;
1043
+ latestKey.current = config.storageKey;
1041
1044
  instance.destroy();
1042
1045
  if (inst.current === instance) inst.current = null;
1043
1046
  };
package/dist/index.d.cts CHANGED
@@ -6,7 +6,7 @@ export { BingoEvent, BingoItem, DEFAULT_ITEMS, LINES, Messages, PackingBingoInst
6
6
  interface PackingBingoProps extends Omit<PackingBingoOptions, "controlled" | "defaultChecked"> {
7
7
  /** Controlled state (16 booleans). Update it from `onChange`. */
8
8
  checked?: readonly boolean[];
9
- /** Initial state for an uncontrolled card (16 booleans). */
9
+ /** Initial state for an uncontrolled card (16 booleans), used when nothing is saved under `storageKey`. */
10
10
  defaultChecked?: readonly boolean[];
11
11
  className?: string;
12
12
  style?: CSSProperties;
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { BingoEvent, BingoItem, DEFAULT_ITEMS, LINES, Messages, PackingBingoInst
6
6
  interface PackingBingoProps extends Omit<PackingBingoOptions, "controlled" | "defaultChecked"> {
7
7
  /** Controlled state (16 booleans). Update it from `onChange`. */
8
8
  checked?: readonly boolean[];
9
- /** Initial state for an uncontrolled card (16 booleans). */
9
+ /** Initial state for an uncontrolled card (16 booleans), used when nothing is saved under `storageKey`. */
10
10
  defaultChecked?: readonly boolean[];
11
11
  className?: string;
12
12
  style?: CSSProperties;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  createPackingBingo,
6
6
  en,
7
7
  ja
8
- } from "./chunk-RJDKWHWG.js";
8
+ } from "./chunk-ZDBGKPFF.js";
9
9
 
10
10
  // src/react.tsx
11
11
  import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
@@ -20,6 +20,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
20
20
  messagesRef.current = messages;
21
21
  const controlled = checked !== void 0;
22
22
  const latest = useRef(checked ?? defaultChecked);
23
+ const latestKey = useRef(config.storageKey);
23
24
  if (controlled) latest.current = checked;
24
25
  const messageShape = messages ? Object.fromEntries(Object.entries(messages).map(([k, v]) => [k, typeof v === "function" ? "\u0192" : v])) : null;
25
26
  const key = JSON.stringify({ ...config, messageShape, controlled });
@@ -35,7 +36,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
35
36
  ...config,
36
37
  messages: liveMessages,
37
38
  controlled,
38
- defaultChecked: latest.current,
39
+ defaultChecked: controlled || latestKey.current === config.storageKey ? latest.current : defaultChecked,
39
40
  onChange: (next) => {
40
41
  if (!controlled) latest.current = next;
41
42
  callbacks.current.onChange?.(next);
@@ -46,6 +47,7 @@ var PackingBingo = forwardRef(function PackingBingo2(props, ref) {
46
47
  inst.current = instance;
47
48
  return () => {
48
49
  if (!controlled) latest.current = instance.getState().checked;
50
+ latestKey.current = config.storageKey;
49
51
  instance.destroy();
50
52
  if (inst.current === instance) inst.current = null;
51
53
  };
@@ -3,7 +3,7 @@
3
3
  \u5FD8\u308C\u7269\u30BC\u30ED\u3067\u3001\u3044\u3063\u3066\u3089\u3063\u3057\u3083\u3044\uFF01`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",statCombo:"MAX COMBO",statLines:"BINGO",close:"\u3068\u3058\u308B",demoLabel:"\u30C7\u30E2\uFF1A",demoBingo:"\u25B6 \u30D3\u30F3\u30B4\u3092\u898B\u308B",demoPerfect:"\u25B6 \u30B3\u30F3\u30DC \u2192 \u30D1\u30FC\u30D5\u30A7\u30AF\u30C8",demoRestore:"\u6700\u521D\u306B\u623B\u3059"},Ge={title:"Packing Bingo",tag:"School trip",subtitle:"Tap to stamp. Rows, columns and diagonals all count!",packed:"PACKED",bingo:"BINGO",score:"SCORE",combo:r=>`${r} COMBO`,comboPop:r=>`${r} COMBO!`,reset:"Reset",soundOn:"\u{1F50A} Sound on",soundOff:"\u{1F507} Muted",stamp:"OK",reachBadge:"REACH",reachPop:"Reach!",bannerSingle:"BINGO!",bannerDouble:"DOUBLE BINGO!",bannerTriple:"TRIPLE BINGO!",bannerNth:r=>`Line ${r}!`,bannerMulti:r=>`${r} lines at once!`,statusStart:r=>`${r} to go. Finish a line first!`,statusLines:(r,n)=>`${r} bingo line${r===1?"":"s"}! ${n} to go`,statusReach:r=>`Reach! Pack ${r.slice(0,2).join(" or ")}${r.length>2?" (or more)":""} for a bingo`,statusDone:"\u{1F389} Perfect! Nothing left behind.",perfectTag:"\u{1F3C6} PERFECT",perfectStamp:`ALL
4
4
  SET`,perfectTitle:"Perfect!",perfectBody:`All 16 packed!
5
5
  Have a great trip!`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",statCombo:"MAX COMBO",statLines:"BINGO",close:"Close",demoLabel:"Demo:",demoBingo:"\u25B6 Show a bingo",demoPerfect:"\u25B6 Combo \u2192 perfect",demoRestore:"Restore"};var Ue=[0,2,4,7,9,12,14,16,19,21,24,26,28,31,33,36],U=r=>523.25*Math.pow(2,r/12);function _e(r){let n=null,k=null;function d(){if(r()||typeof window>"u")return null;let c=navigator.userActivation;if(!n&&c&&!c.hasBeenActive)return null;if(!n){let b=window.AudioContext??window.webkitAudioContext;if(!b)return null;n=new b;let y=n.createDynamicsCompressor();k=n.createGain(),k.gain.value=.6,k.connect(y).connect(n.destination)}return n.state==="suspended"&&n.resume(),n}function m(c,b=0,y=.15,E="triangle",M=.2,A=0){let p=d();if(!p||!k)return;let x=p.currentTime+b,S=p.createOscillator(),C=p.createGain();S.type=E,S.frequency.setValueAtTime(c,x),A&&S.frequency.exponentialRampToValueAtTime(A,x+y),C.gain.setValueAtTime(1e-4,x),C.gain.exponentialRampToValueAtTime(M,x+.01),C.gain.exponentialRampToValueAtTime(1e-4,x+y),S.connect(C).connect(k),S.start(x),S.stop(x+y+.05)}function $(c=0,b=.3,y=.25,E=1200){let M=d();if(!M||!k)return;let A=M.currentTime+c,p=Math.ceil(M.sampleRate*b),x=M.createBuffer(1,p,M.sampleRate),S=x.getChannelData(0);for(let f=0;f<p;f++)S[f]=(Math.random()*2-1)*Math.pow(1-f/p,2.5);let C=M.createBufferSource(),W=M.createBiquadFilter(),I=M.createGain();C.buffer=x,W.type="bandpass",W.frequency.value=E,W.Q.value=.7,I.gain.value=y,C.connect(W).connect(I).connect(k),C.start(A)}let R=c=>U(Ue[Math.min(Math.max(c,0),Ue.length-1)]);return{pop(c){let b=R(c);m(b,0,.16,"triangle",.22),m(b*2,0,.08,"sine",.06),m(150,0,.09,"sine",.25,60)},off:()=>m(392,0,.1,"sine",.1,262),reach(){m(988,0,.08,"square",.05),m(1319,.09,.14,"square",.05)},bingo(c){[0,4,7,12,16,19].forEach((b,y)=>m(U(b),y*.06,.22,"square",.06)),[12,16,19].forEach(b=>m(U(b),.38,.6,"triangle",.12)),c>1&&[24,28,31].forEach(b=>m(U(b),.55,.6,"triangle",.08)),$(0,.25,.12,3e3)},perfect(){[0,4,7,12,16,19,24].forEach((c,b)=>m(U(c-12),b*.07,.25,"square",.06)),[0,4,7,12].forEach(c=>m(U(c),.55,1.2,"triangle",.12)),[12,16,19].forEach(c=>m(U(c),.55,1.2,"sawtooth",.03)),m(U(-12),.55,1.2,"sine",.2)},boom(){$(0,.5,.3,500),m(90,0,.3,"sine",.25,40)},tick:()=>m(1760,0,.03,"square",.03),note:(c,b=0)=>m(R(c),b,.12,"square",.05),close(){n&&n.close().catch(()=>{}),n=null,k=null}}}var We="https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=M+PLUS+Rounded+1c:wght@500;800&display=swap",Ve=`
6
- .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;width:100%}
6
+ .pkb{--pk-sun:var(--pkb-sun,#FFD54F);--pk-ink:var(--pkb-ink,#2A1F14);--pk-paper:var(--pkb-paper,#FFFDF6);--pk-red:var(--pkb-red,#E8473B);--pk-mint:var(--pkb-mint,#BDEBD2);--pk-display:"Dela Gothic One",var(--pkb-font-fallback,sans-serif);--pk-body:"M PLUS Rounded 1c","Hiragino Maru Gothic ProN",var(--pkb-font-fallback,sans-serif);container-type:inline-size;isolation:isolate;width:100%}
7
7
  .pkb,.pkb *{box-sizing:border-box;margin:0;padding:0}
8
8
  .pkb-board{position:relative;overflow:hidden;max-width:720px;margin:0 auto;padding:22px 26px 20px;border-radius:24px;background-color:var(--pk-sun);background-image:radial-gradient(rgba(42,31,20,.12) 1.3px,transparent 1.6px);background-size:16px 16px;border:2.5px solid var(--pk-ink);color:var(--pk-ink);font-family:var(--pk-body);line-height:1.4;text-align:left}
9
9
  .pkb-board.pkb-shake{animation:pkb-shake .45s cubic-bezier(.36,.07,.19,.97)}
@@ -123,4 +123,4 @@ Have a great trip!`,perfectBonus:r=>`PERFECT BONUS +${r}`,statScore:"SCORE",stat
123
123
  <div class="pkb-medal">${g(n.perfectStamp)}</div><h3 class="pkb-ptitle">${g(n.perfectTitle)}</h3><p class="pkb-pbody">${g(n.perfectBody)}</p>
124
124
  <div class="pkb-stats"><div><small>${g(n.statScore)}</small><b data-r="sScore">0</b></div><div><small>${g(n.statCombo)}</small><b data-r="sCombo">\xD70</b></div><div><small>${g(n.statLines)}</small><b data-r="sBingo">0</b></div></div>
125
125
  <button type="button" class="pkb-close" data-r="close">${g(n.close)}</button></div></div>
126
- <canvas class="pkb-fx"></canvas></div>${m}</div>`}function vt(r,n={}){let k=n.items??Le;if(k.length!==16)throw new Error(`[packing-bingo] "items" must have exactly 16 entries (got ${k.length}).`);let d={...Se,...n.messages};n.title!=null&&(d.title=n.title),n.tag!=null&&(d.tag=n.tag),n.subtitle!=null&&(d.subtitle=n.subtitle);let m=!!n.controlled,$=m?null:n.storageKey??null,R=!!n.demo,c=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches,b=!1,y=new Set,E=(e,t)=>{let a=window.setTimeout(()=>{y.delete(a),b||e()},t);y.add(a)},M=e=>new Promise(t=>window.setTimeout(t,e)),A=0;ht(n.fonts!==!1),r.innerHTML=xt(k,d,R);let p=e=>{let t=r.querySelector(`[data-r="${e}"]`);if(!t)throw new Error(`[packing-bingo] internal: missing element "${e}"`);return t},x=r.querySelector(".pkb-board"),S=r.querySelector(".pkb-gw"),C=r.querySelector(".pkb-grid"),W=r.querySelector(".pkb-lines"),I=r.querySelector(".pkb-fx"),f=I.getContext("2d"),L=Array.from(C.children),re=L.map(e=>e.querySelector("input")),Be=p("kick"),be=p("snd"),N=p("perfect"),h=_(void 0),w=[],ie=new Set,de=new Set,se=!1,Me=0,z=0,te=0,B=0,X=0,Ce=0,me=!1,ne=new Set,J=new Set,ze=Qe(Xe),V=ze!=null?ze==="1":n.sound===!1,T=_e(()=>V),Te=()=>{be.textContent=V?d.soundOff:d.soundOn,be.setAttribute("aria-pressed",String(!V))};be.addEventListener("click",()=>{V=!V,Ze(Xe,V?"1":"0"),Te(),V||T.pop(4)}),Te();let D=[],K=0,ae=1,H=0,G=0,Pe=()=>{let e=x.getBoundingClientRect();ae=Math.min(2,window.devicePixelRatio||1),H=e.width,G=e.height,I.width=Math.round(H*ae),I.height=Math.round(G*ae)},fe=(e,t,a,o,s,i,l,v)=>({x:e,y:t,vx:a,vy:o,g:s,life:l,shape:v,w:(5+Math.random()*6)*i,h:(3+Math.random()*5)*i,r:Math.random()*6,vr:(Math.random()-.5)*.35,c:Ke[Math.random()*Ke.length|0]}),ke=()=>{!K&&f&&(K=requestAnimationFrame($e))};function Q(e,t,a,o={}){if(!c){for(let s=0;s<a;s++){let i=(o.angle??-Math.PI/2)+(Math.random()-.5)*(o.spread??Math.PI*2),l=(o.speed??10)*(.35+Math.random()*.85),v=Math.random()<(o.star??.15)?"star":Math.random()<.3?"round":"rect";D.push(fe(e,t,Math.cos(i)*l,Math.sin(i)*l,o.g??.28,o.size??1,(o.life??110)+Math.random()*40,v))}ke()}}function et(e){if(c)return;let t=performance.now()+e,a=()=>{for(let o=0;o<4;o++)D.push(fe(Math.random()*H,-12,(Math.random()-.5)*2,2+Math.random()*3,.045,1,420,Math.random()<.2?"star":"rect"));ke(),performance.now()<t&&E(a,45)};a()}function tt(e){if(c)return;let t=performance.now()+e,a=()=>{let o=fe(Math.random()*H,-30,(Math.random()-.5)*1.5,1.5+Math.random()*2,.05,1,500,"emoji");o.e=k[Math.random()*k.length|0].emoji,o.w=22+Math.random()*12,o.vr=(Math.random()-.5)*.06,D.push(o),ke(),performance.now()<t&&E(a,110)};a()}function nt(e,t){Q(e,t,80,{speed:7.5,g:.06,life:85,star:.4}),T.boom()}function at(e,t){e.beginPath();for(let a=0;a<10;a++){let o=a*Math.PI/5-Math.PI/2,s=a%2?t*.45:t;e.lineTo(Math.cos(o)*s,Math.sin(o)*s)}e.closePath(),e.fill()}function $e(){if(K=0,!(b||!f)){f.setTransform(ae,0,0,ae,0,0),f.clearRect(0,0,H,G),D=D.filter(e=>e.life>0&&e.y<G+40);for(let e of D)e.vx*=.985,e.vy=e.vy*.985+e.g,e.x+=e.vx,e.y+=e.vy,e.r+=e.vr,e.life--,f.save(),f.globalAlpha=Math.min(1,e.life/25),f.translate(e.x,e.y),f.rotate(e.r),f.fillStyle=e.c,e.shape==="round"?(f.beginPath(),f.arc(0,0,e.h/1.6,0,7),f.fill()):e.shape==="star"?at(f,e.w*.8):e.shape==="emoji"?(f.font=`${e.w}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`,f.textAlign="center",f.textBaseline="middle",f.fillText(e.e??"",0,0)):(f.scale(1,Math.cos(e.r*2.3)),f.fillRect(-e.w/2,-e.h/2,e.w,e.h)),f.restore();D.length&&(K=requestAnimationFrame($e))}}let pe=e=>{let t=x.getBoundingClientRect(),a=e.getBoundingClientRect();return{x:a.left-t.left+a.width/2,y:a.top-t.top+a.height/2}},P=(e,t)=>{e.classList.remove(t),e.offsetWidth,e.classList.add(t)},Ae=(e,t)=>{c||(L[e].classList.remove("pkb-squash"),L[e].style.setProperty("--pkb-d",t+"ms"),P(L[e],"pkb-hop"))};C.addEventListener("animationend",e=>{let t=e.target;t.classList.contains("pkb-tile")&&(e.animationName==="pkb-hop"||e.animationName==="pkb-squash")&&t.classList.remove(e.animationName)});let ot=e=>{let t=document.createElement("span");t.className="pkb-ring",e.appendChild(t),t.addEventListener("animationend",()=>t.remove())},ue=e=>{let t=navigator.userActivation;if(!(t&&!t.hasBeenActive))try{navigator.vibrate?.(e)}catch{}},le=(e,t,a,o,s=0,i)=>{let l=document.createElement("div");l.className="pkb-float pkb-"+o,l.textContent=e,l.style.left=t+"px",l.style.top=a+"px",s&&(l.style.animationDelay=s+"ms"),i&&(l.style.color=i),x.appendChild(l),l.addEventListener("animationend",()=>l.remove())},Oe=(e,t,a)=>Math.max(0,Math.min(1,(e-t)/a)),Fe=e=>1-Math.pow(1-e,3),ce=0;function ge(){cancelAnimationFrame(ce);let e=te,t=z,a=performance.now(),o=s=>{if(b)return;let i=Oe(s,a,450);te=Math.round(e+(t-e)*Fe(i)),p("sc").textContent=te.toLocaleString(),i<1&&(ce=requestAnimationFrame(o))};o(a),c||P(p("sp"),"pkb-bump")}let Re=(e,t,a,o="pt")=>{le(`+${e}`,t,a,o),ge()};function he(e,t,a,o,s,i=!1){let l=performance.now(),v=A,q=0,j=Y=>{if(b||v!==A)return;let ee=Oe(Y,l,o);e.textContent=s(Math.round(t+(a-t)*Fe(ee))),i&&ee<1&&Y-q>55&&(T.tick(),q=Y),ee<1?requestAnimationFrame(j):c||P(e,"pkb-bump")};j(l)}let qe=e=>{let t=L[e];return{x:t.offsetLeft+t.offsetWidth/2,y:t.offsetTop+t.offsetHeight/2}};function je(e=[]){W.setAttribute("viewBox",`0 0 ${S.clientWidth} ${S.clientHeight}`),W.innerHTML=w.map(t=>{let a=qe(F[t][0]),o=qe(F[t][3]),s=o.x-a.x,i=o.y-a.y,l=Math.hypot(s,i)||1,v=s/l*26,q=i/l*26,j=`x1="${a.x-v}" y1="${a.y-q}" x2="${o.x+v}" y2="${o.y+q}" pathLength="1"`;return`<g${e.includes(t)?' class="pkb-draw"':""}><line class="pkb-u" ${j}/><line class="pkb-o" ${j}/></g>`}).join("")}function rt(e,t){let a=pe(L[e]);if(!t){B=0,p("combo").classList.remove("pkb-on"),T.off();return}let o=performance.now();B=o-Ce<gt?B+1:1,Ce=o,X=Math.max(X,B),T.pop(B-1),c||(P(L[e],"pkb-squash"),ot(L[e])),Q(a.x,a.y,18,{speed:6,g:.2,life:30,size:.6,star:.5}),ne.has(e)||(ne.add(e),z+=10*B,Re(10*B,a.x,a.y-12)),B>=2&&(le(d.comboPop(B),a.x,a.y+22,"cmb",90,Ye[(B-2)%Ye.length]),p("cmbN").textContent=d.combo(B),P(p("combo"),"pkb-on")),ue(12)}p("cbar").addEventListener("animationend",()=>{p("combo").classList.remove("pkb-on"),B=0});function it(e){e.forEach(t=>{let a=pe(L[t]);le(d.reachPop,a.x,a.y-34,"rch",150)}),E(()=>T.reach(),150)}function st(e,t){let a=e.length,o=[...new Set(e.flatMap(i=>F[i]))],s=Math.min(70,300/o.length);o.forEach((i,l)=>E(()=>{Ae(i,0);let v=pe(L[i]);Q(v.x,v.y,14,{speed:5,g:.15,life:30,size:.7,star:.8}),T.note(l+3)},l*s)),E(()=>{p("bT").textContent=a>=3?d.bannerTriple:a===2?d.bannerDouble:d.bannerSingle,p("bS").textContent=a>1?d.bannerMulti(a):d.bannerNth(w.length),P(p("banner"),"pkb-show"),c||(P(p("flash"),"pkb-go"),P(x,"pkb-punch"));let i=pe(S);Q(i.x,i.y,a>1?180:110,{speed:a>1?16:13,star:.3}),T.bingo(a),ue(a>1?[40,60,40,60,90]:[40,60,40]),t&&E(()=>Re(t,i.x,i.y+105,"big"),350)},o.length*s+60)}function pt(e){let t=me?0:1e3;me=!0,z+=t,L.forEach((o,s)=>Ae(s,((s>>2)+(s&3))*60)),T.perfect(),c||P(p("flash"),"pkb-go"),p("sScore").textContent="0",p("sCombo").textContent="\xD70",p("sBingo").textContent="0",E(()=>{P(N,"pkb-show"),p("close").focus({preventScroll:!0}),E(()=>{c||P(x,"pkb-shake"),T.boom(),Q(0,G,90,{angle:-Math.PI/3,spread:.8,speed:22,g:.32,star:.3}),Q(H,G,90,{angle:-Math.PI*2/3,spread:.8,speed:22,g:.32,star:.3}),et(3500),ue([60,80,60,80,200]),t&&le(d.perfectBonus(t),H/2,G*.16,"big"),he(p("sScore"),0,z,1300,o=>o.toLocaleString(),!0),he(p("sCombo"),0,X,700,o=>"\xD7"+o),he(p("sBingo"),0,w.length,700,String),ge()},720),[1300,1650,2e3,2350,2700].forEach(o=>E(()=>nt(H*(.15+Math.random()*.7),G*(.12+Math.random()*.3)),o)),E(()=>tt(3e3),1500)},520);let a={score:z,maxCombo:X};e.push(()=>n.onPerfect?.(a))}function Ie(){A++,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(ce),te=z,p("sc").textContent=z.toLocaleString()}function lt(){D=[],cancelAnimationFrame(K),K=0,f?.clearRect(0,0,I.width,I.height),x.querySelectorAll(".pkb-float, .pkb-ring").forEach(e=>e.remove()),p("banner").classList.remove("pkb-show"),p("flash").classList.remove("pkb-go"),p("combo").classList.remove("pkb-on"),N.classList.remove("pkb-show")}function xe(e,t){let a=[],o=h,s=o.filter(Boolean).length;w=F.flatMap((u,O)=>u.every(we=>o[we])?[O]:[]);let i=w.filter(u=>!ie.has(u));[...ie].some(u=>!w.includes(u))&&(Ie(),s<16&&N.classList.remove("pkb-show"));let l=new Set(w.flatMap(u=>F[u])),v=new Set;for(let u of F){let O=u.filter(we=>!o[we]);O.length===1&&v.add(O[0])}L.forEach((u,O)=>{u.classList.toggle("pkb-on",o[O]),u.classList.toggle("pkb-line",l.has(O)),u.classList.toggle("pkb-reach",v.has(O))}),x.classList.toggle("pkb-all",s===16),Be.classList.toggle("pkb-gold",s===16),Be.textContent=s===16?d.perfectTag:d.tag,p("cnt").textContent=`${s}/16`,p("bng").textContent=String(w.length);let q=p("bp");w.length>Me&&(q.classList.remove("pkb-hot"),q.offsetWidth),q.classList.toggle("pkb-hot",w.length>0),Me=w.length,je(i),e!==null&&rt(e,o[e]);let j=i.filter(u=>!J.has(u));j.forEach(u=>J.add(u));let Y=100*j.length*j.length;if(z+=Y,t)Y&&ge();else if(s===16&&!se)pt(a);else if(i.length){st(i,Y);let u={newLines:i,totalLines:w.length,score:z};a.push(()=>n.onBingo?.(u))}else if(e!==null&&o[e]){let u=[...v].filter(O=>!de.has(O));u.length&&it(u)}ie=new Set(w),de=v,se=s===16;let ee=[...v].map(u=>k[u].label);return p("msg").textContent=s===16?d.statusDone:ee.length?d.statusReach(ee):w.length?d.statusLines(w.length,16-s):d.statusStart(16-s),a}let Ne=e=>{for(let t of e){if(b)return;t()}},De=()=>{$&&Ze($,JSON.stringify(h))};function He(e,t,a){h=e,re.forEach((s,i)=>s.checked=h[i]);let o=xe(t,a);return De(),o}function ve(e){Ie(),lt(),h=e,re.forEach((t,a)=>t.checked=h[a]),ne.clear(),J.clear(),h.forEach((t,a)=>t&&ne.add(a)),F.forEach((t,a)=>t.every(o=>h[o])&&J.add(a)),ie=new Set(J),de=new Set,se=h.every(Boolean),me=se,z=te=ne.size*10+J.size*100,B=0,X=0,p("sc").textContent=z.toLocaleString(),xe(null,!0),De()}function Z(e,t,a={}){if(m){re.forEach((s,i)=>s.checked=h[i]),n.onChange?.(e.slice());return}let o=[];a.reset?ve(e):o=He(e,t,!!a.quiet),n.onChange?.(h.slice()),Ne(o)}if(C.addEventListener("change",e=>{let t=re.indexOf(e.target);if(t<0)return;let a=h.slice();a[t]=!a[t],Z(a,t)}),p("reset").addEventListener("click",()=>Z(_(void 0),null,{reset:!0})),p("close").addEventListener("click",()=>N.classList.remove("pkb-show")),N.addEventListener("click",e=>{e.target===N&&N.classList.remove("pkb-show")}),R){let e=!1,t=()=>{h.every(Boolean)?Z(_(void 0),null,{reset:!0}):N.classList.remove("pkb-show")},a=async o=>{if(!e){e=!0;try{t();let s=A;for(let i of o){if(await M(i.delay),b||s!==A)return;let l=h.slice();l[i.i]=!0,Z(l,i.i,{quiet:i.quiet})}}finally{e=!1}}};p("dB").addEventListener("click",()=>{let o=()=>F.map(i=>i.filter(l=>!h[l])).filter(i=>i.length).sort((i,l)=>i.length-l.length)[0]??[],s=h.every(Boolean)?[...F[0]]:o();a(s.map(i=>({i,delay:320})))}),p("dP").addEventListener("click",()=>{let o=h.every(Boolean)?L.map((s,i)=>i):L.map((s,i)=>i).filter(s=>!h[s]);a(o.map((s,i)=>i<o.length-1?{i:s,delay:170,quiet:!0}:{i:s,delay:450}))}),p("dR").addEventListener("click",()=>{e||Z(Je(),null,{reset:!0})})}let oe=n.defaultChecked?_(n.defaultChecked):null;if(!oe&&$)try{let e=JSON.parse(Qe($)??"null");Array.isArray(e)&&(oe=_(e))}catch{}!oe&&R&&(oe=Je()),Pe(),ve(oe??_(void 0));let ye=typeof ResizeObserver=="function"?new ResizeObserver(()=>{Pe(),je()}):null;return ye?.observe(x),ye?.observe(S),{setChecked(e,t={}){if(b)return;let a=_(e),o=a.flatMap((s,i)=>s!==h[i]?[i]:[]);if(o.length){if(!t.animate||o.length!==1){ve(a);return}Ne(He(a,o[0],!1))}},reset(){b||Z(_(void 0),null,{reset:!0})},refresh(){b||xe(null,!0)},getState(){return{checked:h.slice(),lines:w.slice(),score:z,maxCombo:X,complete:h.every(Boolean)}},destroy(){b||(b=!0,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(K),cancelAnimationFrame(ce),ye?.disconnect(),T.close(),r.innerHTML="")}}}return kt(yt);})();
126
+ <canvas class="pkb-fx"></canvas></div>${m}</div>`}function vt(r,n={}){let k=n.items??Le;if(k.length!==16)throw new Error(`[packing-bingo] "items" must have exactly 16 entries (got ${k.length}).`);let d={...Se,...n.messages};n.title!=null&&(d.title=n.title),n.tag!=null&&(d.tag=n.tag),n.subtitle!=null&&(d.subtitle=n.subtitle);let m=!!n.controlled,$=m?null:n.storageKey??null,R=!!n.demo,c=typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches,b=!1,y=new Set,E=(e,t)=>{let a=window.setTimeout(()=>{y.delete(a),b||e()},t);y.add(a)},M=e=>new Promise(t=>window.setTimeout(t,e)),A=0;ht(n.fonts!==!1),r.innerHTML=xt(k,d,R);let p=e=>{let t=r.querySelector(`[data-r="${e}"]`);if(!t)throw new Error(`[packing-bingo] internal: missing element "${e}"`);return t},x=r.querySelector(".pkb-board"),S=r.querySelector(".pkb-gw"),C=r.querySelector(".pkb-grid"),W=r.querySelector(".pkb-lines"),I=r.querySelector(".pkb-fx"),f=I.getContext("2d"),L=Array.from(C.children),re=L.map(e=>e.querySelector("input")),Be=p("kick"),be=p("snd"),N=p("perfect"),h=_(void 0),w=[],ie=new Set,de=new Set,se=!1,Me=0,z=0,ne=0,B=0,X=0,Ce=0,me=!1,ae=new Set,J=new Set,ze=Qe(Xe),V=ze!=null?ze==="1":n.sound===!1,T=_e(()=>V),Te=()=>{be.textContent=V?d.soundOff:d.soundOn,be.setAttribute("aria-pressed",String(!V))};be.addEventListener("click",()=>{V=!V,Ze(Xe,V?"1":"0"),Te(),V||T.pop(4)}),Te();let D=[],K=0,oe=1,H=0,G=0,Pe=()=>{let e=x.getBoundingClientRect();oe=Math.min(2,window.devicePixelRatio||1),H=e.width,G=e.height,I.width=Math.round(H*oe),I.height=Math.round(G*oe)},fe=(e,t,a,o,s,i,l,v)=>({x:e,y:t,vx:a,vy:o,g:s,life:l,shape:v,w:(5+Math.random()*6)*i,h:(3+Math.random()*5)*i,r:Math.random()*6,vr:(Math.random()-.5)*.35,c:Ke[Math.random()*Ke.length|0]}),ke=()=>{!K&&f&&(K=requestAnimationFrame($e))};function Q(e,t,a,o={}){if(!c){for(let s=0;s<a;s++){let i=(o.angle??-Math.PI/2)+(Math.random()-.5)*(o.spread??Math.PI*2),l=(o.speed??10)*(.35+Math.random()*.85),v=Math.random()<(o.star??.15)?"star":Math.random()<.3?"round":"rect";D.push(fe(e,t,Math.cos(i)*l,Math.sin(i)*l,o.g??.28,o.size??1,(o.life??110)+Math.random()*40,v))}ke()}}function et(e){if(c)return;let t=performance.now()+e,a=()=>{for(let o=0;o<4;o++)D.push(fe(Math.random()*H,-12,(Math.random()-.5)*2,2+Math.random()*3,.045,1,420,Math.random()<.2?"star":"rect"));ke(),performance.now()<t&&E(a,45)};a()}function tt(e){if(c)return;let t=performance.now()+e,a=()=>{let o=fe(Math.random()*H,-30,(Math.random()-.5)*1.5,1.5+Math.random()*2,.05,1,500,"emoji");o.e=k[Math.random()*k.length|0].emoji,o.w=22+Math.random()*12,o.vr=(Math.random()-.5)*.06,D.push(o),ke(),performance.now()<t&&E(a,110)};a()}function nt(e,t){Q(e,t,80,{speed:7.5,g:.06,life:85,star:.4}),T.boom()}function at(e,t){e.beginPath();for(let a=0;a<10;a++){let o=a*Math.PI/5-Math.PI/2,s=a%2?t*.45:t;e.lineTo(Math.cos(o)*s,Math.sin(o)*s)}e.closePath(),e.fill()}function $e(){if(K=0,!(b||!f)){f.setTransform(oe,0,0,oe,0,0),f.clearRect(0,0,H,G),D=D.filter(e=>e.life>0&&e.y<G+40);for(let e of D)e.vx*=.985,e.vy=e.vy*.985+e.g,e.x+=e.vx,e.y+=e.vy,e.r+=e.vr,e.life--,f.save(),f.globalAlpha=Math.min(1,e.life/25),f.translate(e.x,e.y),f.rotate(e.r),f.fillStyle=e.c,e.shape==="round"?(f.beginPath(),f.arc(0,0,e.h/1.6,0,7),f.fill()):e.shape==="star"?at(f,e.w*.8):e.shape==="emoji"?(f.font=`${e.w}px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif`,f.textAlign="center",f.textBaseline="middle",f.fillText(e.e??"",0,0)):(f.scale(1,Math.cos(e.r*2.3)),f.fillRect(-e.w/2,-e.h/2,e.w,e.h)),f.restore();D.length&&(K=requestAnimationFrame($e))}}let pe=e=>{let t=x.getBoundingClientRect(),a=e.getBoundingClientRect();return{x:a.left-t.left+a.width/2,y:a.top-t.top+a.height/2}},P=(e,t)=>{e.classList.remove(t),e.offsetWidth,e.classList.add(t)},Ae=(e,t)=>{c||(L[e].classList.remove("pkb-squash"),L[e].style.setProperty("--pkb-d",t+"ms"),P(L[e],"pkb-hop"))};C.addEventListener("animationend",e=>{let t=e.target;t.classList.contains("pkb-tile")&&(e.animationName==="pkb-hop"||e.animationName==="pkb-squash")&&t.classList.remove(e.animationName)});let ot=e=>{let t=document.createElement("span");t.className="pkb-ring",e.appendChild(t),t.addEventListener("animationend",()=>t.remove())},ue=e=>{let t=navigator.userActivation;if(!(t&&!t.hasBeenActive))try{navigator.vibrate?.(e)}catch{}},le=(e,t,a,o,s=0,i)=>{let l=document.createElement("div");l.className="pkb-float pkb-"+o,l.textContent=e,l.style.left=t+"px",l.style.top=a+"px",s&&(l.style.animationDelay=s+"ms"),i&&(l.style.color=i),x.appendChild(l),l.addEventListener("animationend",()=>l.remove())},Oe=(e,t,a)=>Math.max(0,Math.min(1,(e-t)/a)),Fe=e=>1-Math.pow(1-e,3),ce=0;function ge(){cancelAnimationFrame(ce);let e=ne,t=z,a=performance.now(),o=s=>{if(b)return;let i=Oe(s,a,450);ne=Math.round(e+(t-e)*Fe(i)),p("sc").textContent=ne.toLocaleString(),i<1&&(ce=requestAnimationFrame(o))};o(a),c||P(p("sp"),"pkb-bump")}let Re=(e,t,a,o="pt")=>{le(`+${e}`,t,a,o),ge()};function he(e,t,a,o,s,i=!1){let l=performance.now(),v=A,q=0,j=Y=>{if(b||v!==A)return;let te=Oe(Y,l,o);e.textContent=s(Math.round(t+(a-t)*Fe(te))),i&&te<1&&Y-q>55&&(T.tick(),q=Y),te<1?requestAnimationFrame(j):c||P(e,"pkb-bump")};j(l)}let qe=e=>{let t=L[e];return{x:t.offsetLeft+t.offsetWidth/2,y:t.offsetTop+t.offsetHeight/2}};function je(e=[]){W.setAttribute("viewBox",`0 0 ${S.clientWidth} ${S.clientHeight}`),W.innerHTML=w.map(t=>{let a=qe(F[t][0]),o=qe(F[t][3]),s=o.x-a.x,i=o.y-a.y,l=Math.hypot(s,i)||1,v=s/l*26,q=i/l*26,j=`x1="${a.x-v}" y1="${a.y-q}" x2="${o.x+v}" y2="${o.y+q}" pathLength="1"`;return`<g${e.includes(t)?' class="pkb-draw"':""}><line class="pkb-u" ${j}/><line class="pkb-o" ${j}/></g>`}).join("")}function rt(e,t){let a=pe(L[e]);if(!t){B=0,p("combo").classList.remove("pkb-on"),T.off();return}let o=performance.now();B=o-Ce<gt?B+1:1,Ce=o,X=Math.max(X,B),T.pop(B-1),c||(P(L[e],"pkb-squash"),ot(L[e])),Q(a.x,a.y,18,{speed:6,g:.2,life:30,size:.6,star:.5}),ae.has(e)||(ae.add(e),z+=10*B,Re(10*B,a.x,a.y-12)),B>=2&&(le(d.comboPop(B),a.x,a.y+22,"cmb",90,Ye[(B-2)%Ye.length]),p("cmbN").textContent=d.combo(B),P(p("combo"),"pkb-on")),ue(12)}p("cbar").addEventListener("animationend",()=>{p("combo").classList.remove("pkb-on"),B=0});function it(e){e.forEach(t=>{let a=pe(L[t]);le(d.reachPop,a.x,a.y-34,"rch",150)}),E(()=>T.reach(),150)}function st(e,t){let a=e.length,o=[...new Set(e.flatMap(i=>F[i]))],s=Math.min(70,300/o.length);o.forEach((i,l)=>E(()=>{Ae(i,0);let v=pe(L[i]);Q(v.x,v.y,14,{speed:5,g:.15,life:30,size:.7,star:.8}),T.note(l+3)},l*s)),E(()=>{p("bT").textContent=a>=3?d.bannerTriple:a===2?d.bannerDouble:d.bannerSingle,p("bS").textContent=a>1?d.bannerMulti(a):d.bannerNth(w.length),P(p("banner"),"pkb-show"),c||(P(p("flash"),"pkb-go"),P(x,"pkb-punch"));let i=pe(S);Q(i.x,i.y,a>1?180:110,{speed:a>1?16:13,star:.3}),T.bingo(a),ue(a>1?[40,60,40,60,90]:[40,60,40]),t&&E(()=>Re(t,i.x,i.y+105,"big"),350)},o.length*s+60)}function pt(e){let t=me?0:1e3;me=!0,z+=t,L.forEach((o,s)=>Ae(s,((s>>2)+(s&3))*60)),T.perfect(),c||P(p("flash"),"pkb-go"),p("sScore").textContent="0",p("sCombo").textContent="\xD70",p("sBingo").textContent="0",E(()=>{P(N,"pkb-show"),p("close").focus({preventScroll:!0}),E(()=>{c||P(x,"pkb-shake"),T.boom(),Q(0,G,90,{angle:-Math.PI/3,spread:.8,speed:22,g:.32,star:.3}),Q(H,G,90,{angle:-Math.PI*2/3,spread:.8,speed:22,g:.32,star:.3}),et(3500),ue([60,80,60,80,200]),t&&le(d.perfectBonus(t),H/2,G*.16,"big"),he(p("sScore"),0,z,1300,o=>o.toLocaleString(),!0),he(p("sCombo"),0,X,700,o=>"\xD7"+o),he(p("sBingo"),0,w.length,700,String),ge()},720),[1300,1650,2e3,2350,2700].forEach(o=>E(()=>nt(H*(.15+Math.random()*.7),G*(.12+Math.random()*.3)),o)),E(()=>tt(3e3),1500)},520);let a={score:z,maxCombo:X};e.push(()=>n.onPerfect?.(a))}function Ie(){A++,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(ce),ne=z,p("sc").textContent=z.toLocaleString()}function lt(){D=[],cancelAnimationFrame(K),K=0,f?.clearRect(0,0,I.width,I.height),x.querySelectorAll(".pkb-float, .pkb-ring").forEach(e=>e.remove()),p("banner").classList.remove("pkb-show"),p("flash").classList.remove("pkb-go"),p("combo").classList.remove("pkb-on"),N.classList.remove("pkb-show")}function xe(e,t){let a=[],o=h,s=o.filter(Boolean).length;w=F.flatMap((u,O)=>u.every(we=>o[we])?[O]:[]);let i=w.filter(u=>!ie.has(u));[...ie].some(u=>!w.includes(u))&&(Ie(),s<16&&N.classList.remove("pkb-show"));let l=new Set(w.flatMap(u=>F[u])),v=new Set;for(let u of F){let O=u.filter(we=>!o[we]);O.length===1&&v.add(O[0])}L.forEach((u,O)=>{u.classList.toggle("pkb-on",o[O]),u.classList.toggle("pkb-line",l.has(O)),u.classList.toggle("pkb-reach",v.has(O))}),x.classList.toggle("pkb-all",s===16),Be.classList.toggle("pkb-gold",s===16),Be.textContent=s===16?d.perfectTag:d.tag,p("cnt").textContent=`${s}/16`,p("bng").textContent=String(w.length);let q=p("bp");w.length>Me&&(q.classList.remove("pkb-hot"),q.offsetWidth),q.classList.toggle("pkb-hot",w.length>0),Me=w.length,je(i),e!==null&&rt(e,o[e]);let j=i.filter(u=>!J.has(u));j.forEach(u=>J.add(u));let Y=100*j.length*j.length;if(z+=Y,t)Y&&ge();else if(s===16&&!se)pt(a);else if(i.length){st(i,Y);let u={newLines:i,totalLines:w.length,score:z};a.push(()=>n.onBingo?.(u))}else if(e!==null&&o[e]){let u=[...v].filter(O=>!de.has(O));u.length&&it(u)}ie=new Set(w),de=v,se=s===16;let te=[...v].map(u=>k[u].label);return p("msg").textContent=s===16?d.statusDone:te.length?d.statusReach(te):w.length?d.statusLines(w.length,16-s):d.statusStart(16-s),a}let Ne=e=>{for(let t of e){if(b)return;t()}},De=()=>{$&&Ze($,JSON.stringify(h))};function He(e,t,a){h=e,re.forEach((s,i)=>s.checked=h[i]);let o=xe(t,a);return De(),o}function ve(e){Ie(),lt(),h=e,re.forEach((t,a)=>t.checked=h[a]),ae.clear(),J.clear(),h.forEach((t,a)=>t&&ae.add(a)),F.forEach((t,a)=>t.every(o=>h[o])&&J.add(a)),ie=new Set(J),de=new Set,se=h.every(Boolean),me=se,z=ne=ae.size*10+J.size*100,B=0,X=0,p("sc").textContent=z.toLocaleString(),xe(null,!0),De()}function Z(e,t,a={}){if(m){re.forEach((s,i)=>s.checked=h[i]),n.onChange?.(e.slice());return}let o=[];a.reset?ve(e):o=He(e,t,!!a.quiet),n.onChange?.(h.slice()),Ne(o)}if(C.addEventListener("change",e=>{let t=re.indexOf(e.target);if(t<0)return;let a=h.slice();a[t]=!a[t],Z(a,t)}),p("reset").addEventListener("click",()=>Z(_(void 0),null,{reset:!0})),p("close").addEventListener("click",()=>N.classList.remove("pkb-show")),N.addEventListener("click",e=>{e.target===N&&N.classList.remove("pkb-show")}),R){let e=!1,t=()=>{h.every(Boolean)?Z(_(void 0),null,{reset:!0}):N.classList.remove("pkb-show")},a=async o=>{if(!e){e=!0;try{t();let s=A;for(let i of o){if(await M(i.delay),b||s!==A)return;let l=h.slice();l[i.i]=!0,Z(l,i.i,{quiet:i.quiet})}}finally{e=!1}}};p("dB").addEventListener("click",()=>{let o=()=>F.map(i=>i.filter(l=>!h[l])).filter(i=>i.length).sort((i,l)=>i.length-l.length)[0]??[],s=h.every(Boolean)?[...F[0]]:o();a(s.map(i=>({i,delay:320})))}),p("dP").addEventListener("click",()=>{let o=h.every(Boolean)?L.map((s,i)=>i):L.map((s,i)=>i).filter(s=>!h[s]);a(o.map((s,i)=>i<o.length-1?{i:s,delay:170,quiet:!0}:{i:s,delay:450}))}),p("dR").addEventListener("click",()=>{e||Z(Je(),null,{reset:!0})})}let ee=null;if($)try{let e=JSON.parse(Qe($)??"null");Array.isArray(e)&&(ee=_(e))}catch{}!ee&&n.defaultChecked&&(ee=_(n.defaultChecked)),!ee&&R&&(ee=Je()),Pe(),ve(ee??_(void 0));let ye=typeof ResizeObserver=="function"?new ResizeObserver(()=>{Pe(),je()}):null;return ye?.observe(x),ye?.observe(S),{setChecked(e,t={}){if(b)return;let a=_(e),o=a.flatMap((s,i)=>s!==h[i]?[i]:[]);if(o.length){if(!t.animate||o.length!==1){ve(a);return}Ne(He(a,o[0],!1))}},reset(){b||Z(_(void 0),null,{reset:!0})},refresh(){b||xe(null,!0)},getState(){return{checked:h.slice(),lines:w.slice(),score:z,maxCombo:X,complete:h.every(Boolean)}},destroy(){b||(b=!0,y.forEach(e=>clearTimeout(e)),y.clear(),cancelAnimationFrame(K),cancelAnimationFrame(ce),ye?.disconnect(),T.close(),r.innerHTML="")}}}return kt(yt);})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "packing-bingo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A school-trip packing checklist that plays like bingo, as a React component (or plain JS): combos, fanfares and fireworks.",
5
5
  "license": "MIT",
6
6
  "author": "Jin-hiroo",