@nano133/nanoterra 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +62 -12
  3. package/ai/SPEC.md +7 -1
  4. package/dist/admin/amount.d.ts +4 -0
  5. package/dist/admin/amount.js +4 -0
  6. package/dist/admin/panel/dom.d.ts +1 -1
  7. package/dist/admin/panel/dom.js +3 -2
  8. package/dist/admin/panel/shelf.js +1 -1
  9. package/dist/admin/panel/shelfData.d.ts +1 -1
  10. package/dist/admin/panel/shelfData.js +6 -6
  11. package/dist/admin/summary.d.ts +1 -1
  12. package/dist/admin/summary.js +8 -8
  13. package/dist/looks/index.d.ts +1 -1
  14. package/dist/looks/index.js +1 -1
  15. package/dist/looks/ladder.d.ts +1 -1
  16. package/dist/looks/ladder.js +7 -7
  17. package/dist/looks/look.d.ts +7 -2
  18. package/dist/looks/look.js +10 -3
  19. package/dist/player-signin/panel/index.d.ts +2 -2
  20. package/dist/player-signin/panel/index.js +3 -2
  21. package/dist/player-signin/panel/mount.d.ts +1 -1
  22. package/dist/player-signin/panel/mount.js +5 -3
  23. package/dist/player-signin/panel/view.d.ts +3 -0
  24. package/dist/player-signin/panel/view.js +49 -46
  25. package/dist/player-signin/panel/words.d.ts +96 -1
  26. package/dist/player-signin/panel/words.js +109 -24
  27. package/dist/shop/handler.js +2 -2
  28. package/dist/shop/panel/index.d.ts +2 -0
  29. package/dist/shop/panel/index.js +3 -0
  30. package/dist/shop/panel/looks.d.ts +3 -0
  31. package/dist/shop/panel/looks.js +11 -11
  32. package/dist/shop/panel/mount.d.ts +18 -2
  33. package/dist/shop/panel/mount.js +48 -21
  34. package/dist/shop/panel/pay.d.ts +3 -0
  35. package/dist/shop/panel/pay.js +38 -25
  36. package/dist/shop/panel/words.d.ts +89 -0
  37. package/dist/shop/panel/words.js +79 -0
  38. package/package.json +1 -1
  39. package/src/admin/amount.ts +6 -0
  40. package/src/admin/panel/dom.ts +4 -2
  41. package/src/admin/panel/shelf.ts +1 -1
  42. package/src/admin/panel/shelfData.ts +6 -6
  43. package/src/admin/summary.ts +8 -8
  44. package/src/looks/index.ts +1 -1
  45. package/src/looks/ladder.ts +7 -6
  46. package/src/looks/look.ts +12 -3
  47. package/src/player-signin/panel/index.ts +4 -3
  48. package/src/player-signin/panel/mount.ts +6 -4
  49. package/src/player-signin/panel/style.css +22 -17
  50. package/src/player-signin/panel/view.ts +60 -55
  51. package/src/player-signin/panel/words.ts +202 -24
  52. package/src/shop/handler.ts +2 -2
  53. package/src/shop/panel/index.ts +4 -0
  54. package/src/shop/panel/looks.ts +14 -14
  55. package/src/shop/panel/mount.ts +60 -21
  56. package/src/shop/panel/pay.ts +51 -30
  57. package/src/shop/panel/style.css +24 -20
  58. package/src/shop/panel/words.ts +172 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-11-01)
4
+
5
+ - **No minimum price: a fraction of a cent works.** A look's `priceUsd` may have up to 4 decimals (`0.005` is half a cent, `0.0001` the smallest price
6
+ above 0); before, the looks check refused anything under a whole cent. New in `/looks`: `priceStepOk(usd)` (the new rule), `PRICE_STEP_USD`
7
+ (`0.0001`) and `usdDecimals(usd)`; `wholeCents` stays as it was. The shop already rounded the Nano amount up to a step of 0.0001 XNO (at least one
8
+ step), and the matcher compares exact amounts, so a half-cent order asks for 0.005 XNO at $1 and only that amount pays it.
9
+ - `usdText` writes 2 to 4 decimals (`"$0.25"`, `"$1.00"`, `"$0.005"`), so the Buy key and the pay sheet no longer show a sub-cent price as a rounded
10
+ cent; `priceText` writes `0.5¢`. An order keeps its dollars as `usdDecimals` (`"0.25"` and `"1.00"` as before, `"0.005"`), not `toFixed(2)`.
11
+ - The admin counts sales in steps of $0.0001, not cents (the Shelf, Earned, the best sellers), its price box takes 4 decimals, and the message for an
12
+ AI assistant says "at most 4 decimals".
13
+
14
+ ## 0.9.3 (2026-11-01)
15
+
16
+ - **A session that ended at Buy.** A 401 from the server at Buy (the session ended, though the game's `signedIn()` still said yes) showed a
17
+ "Sign in" key that asked `signedIn()` again, got yes, and posted the same refused order again. The key now calls `signIn()` without asking, and the
18
+ new `onSessionEnd()` hook on `mountShop` lets the game mark the player signed out (the Echo Island worker found it).
19
+
20
+ ## 0.9.2 (2026-11-01)
21
+
22
+ - Not published on npm: its changes reached npm in 0.9.3.
23
+ - **`onSignInCancel(lookId, look)` on `mountShop`** (the Echo Island worker found the gap while it moved Echo's shop to `/shop`). A game with its own
24
+ looks sheet calls `shop.buy(lookId)`; when the player closes the sign-in without signing in, the kit now opens nothing (and closes its sheet if it
25
+ was open) and calls the hook, so the game shows its own sheet again. Before, the kit's own looks list opened. A Buy in the kit's own list still
26
+ goes back to that list and does not call the hook.
27
+ - **`buy(lookId)` starts on the next frame.** A game's own `<dialog>` that closed just before has had its `close` event by then (Chromium fires it at
28
+ the frame), so the game's close handler can no longer take the focus and the Escape key from the kit's sheet. `dialog.close(); shop.buy(id)` is safe.
29
+ `shop.isOpen` is false until that frame; `open()` still opens at once. The README says to close the game's dialog first.
30
+ - **Phone-first sizes by default** in the shop's sheets and the sign-in sheet: every text 16 px or more (was 11 to 15 px: the tabs, the Looks key,
31
+ each look's line, the notes, the price-source line, the time left, the address and labels by hand, the copy keys, the wallet tiles' names, "Or a
32
+ wallet app", the back key, the status, "Last used"), and every key, tab, close key, back key, fold summary, copy key, wallet tile and link 48 px or
33
+ more each way (was 44 px; the inline "open a wallet" link was 17 px tall). "Last used" sits in the flow now (under a wallet's name, at the end of
34
+ the passkey's row) so it never covers a word. A game that wants smaller text or keys sets them in its own stylesheet, after the kit's.
35
+
36
+ ## 0.9.1 (2026-11-01)
37
+
38
+ - Not published on npm: its changes reached npm in 0.9.3.
39
+ - **The words of the shop's sheets and of the player's sign-in sheet are the game's to set** (the coordinator asked for it: Echo Island's move to
40
+ `/shop` waited on it; Wes prefers fewer words). `words` on `mountShop`, `looksView` and `payPanel` (`ShopWords`, defaults `SHOP_WORDS`,
41
+ `shopWords()` merges) and on `mountPlayerSignin` and `signInView` (`PlayerSigninWords`, defaults `PLAYER_SIGNIN_WORDS`, `PlayerSigninWordsOption`
42
+ for a game's partial words and faults, `playerSigninWords()` merges). Every visible word, every key and every name for a screen reader; a text
43
+ with a value is a function (`buyTitle(look)`, `endsIn(time)`, `prices(sources)`, `waitingFor(wallet)`). `refusal(text, status)` turns the
44
+ server's refusals into the game's words. `faultWords(code, rpId, words?)` takes the words. Not the game's to change: the stamp "TEST MODE: DO
45
+ NOT PAY", and "Powered by CoinGecko" while CoinGecko is a price feed (`priceLine` adds it back).
46
+ - **Shorter default words.** The pay sheet: 30 words in TEST MODE (was about 85) and 29 live (was about 47), no sentence over 10 words. Live:
47
+ "Send exactly 0.25 XNO", "Ends in 14:59.", "Waiting for payment…", "Price: Kraken, KuCoin. Powered by CoinGecko."; by hand: "Another amount
48
+ does not match." TEST MODE: the stamp, "Nothing can be paid here.", the wallet key "Wallet off: test mode", no timer and no status (nothing
49
+ waits for a payment). Paid late: "Paid after the order ended, so no look. The creator sends it back." The looks sheet: "Looks never change
50
+ play. Pay in Nano, straight to the creator." and "Sign in first to buy a look."; "Back" (was "Back to looks"). The sign-in sheet: the lines
51
+ over 10 words split or cut, with the same meaning (the spare key's note, the password manager's and the "two answers" faults, the QR lines).
52
+ - `test/panel-words.test.ts` counts the words a player sees (happy-dom) and proves a game's words reach the page.
53
+
3
54
  ## 0.9.0 (2026-11-01)
4
55
 
5
56
  - **The player's sign-in** (`@nano133/nanoterra/player-signin`, `/player-signin/conformance`, `/player-signin/d1` with `/player-signin/d1.sql`,
package/README.md CHANGED
@@ -91,7 +91,7 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
91
91
  | `@nano133/nanoterra/shop` | the shop's rules and routes, the payment scan, a memory store, the CSP lists (server code; since 0.9.0) | no | 9.1 kB (all), 7.1 kB (`createShopHandler` alone) |
92
92
  | `@nano133/nanoterra/shop/d1`, `/shop/firestore` | the shop's store on D1 (the starter's tables, `/shop/d1.sql`) or Firestore: `createD1Shop`, `createFirestoreShop` (since 0.9.0) | no | 2.4 kB, 2.2 kB |
93
93
  | `@nano133/nanoterra/shop/worker` | the shop for a Worker with D1, from the starter's settings: `createShop` (since 0.9.0) | no | 10.0 kB (with the D1 store and Turnstile) |
94
- | `@nano133/nanoterra/shop/panel` | the Looks key, the looks sheet and the pay sheet with its own QR code: `mountShop`, and its stylesheet `@nano133/nanoterra/shop/style.css` (since 0.9.0) | no | 9.3 kB + 2.5 kB of CSS (gzip; 8.5 kB minified) |
94
+ | `@nano133/nanoterra/shop/panel` | the Looks key, the looks sheet and the pay sheet with its own QR code: `mountShop`, and its stylesheet `@nano133/nanoterra/shop/style.css` (since 0.9.0) | no | 10.0 kB + 2.5 kB of CSS (gzip; 8.5 kB minified) |
95
95
  | `@nano133/nanoterra/shop/conformance` | the shop's money rules as test cases, for a store a game writes itself (since 0.9.0) | no | n/a |
96
96
 
97
97
  Measured 2026-10-26 (the friends paths 2026-10-30, the options, the emotes, the player sign-in and the shop paths 2026-11-01): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
@@ -404,7 +404,7 @@ const text = looksJson(newLooks); // the new looks.json: one look on each line
404
404
  type Look = {
405
405
  id: string; // 1 to 40 lowercase letters, digits or dashes: "mint-cap"
406
406
  name: string; // 1 to 40 characters
407
- priceUsd: number; // US dollars, at most 2 decimals; 0 is the free look
407
+ priceUsd: number; // US dollars, at most 4 decimals (0.005 is half a cent; since 0.10.0); 0 is the free look
408
408
  wear?: string; // the wear slot it fills: none (an outfit), or your game's own slot ("shovel"): see "Wear slots" below
409
409
  change?: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // an outfit's colours ("#rrggbb"); an item of another slot has none
410
410
  slots?: Record<string, string>; // what YOUR game defines for itself: see "Slots" below. Optional.
@@ -478,8 +478,8 @@ The rules (the same as the starter's `AGENTS.md`):
478
478
  | `diffLooks(before, after)` | `{ added, removed, changed }` by id. A new order is not a change. |
479
479
  | `looksJson(looks)` | The text of `looks.json`, one look on each line, as the starter writes it. A change of one look is a change of one line. |
480
480
  | `slugId(name, taken)` | A free id for a name: `"Mint Cap"` gives `mint-cap`, then `mint-cap-2`. |
481
- | `PRICE_LADDER`, `rungOf(usd)`, `priceText(usd)` | The six steps nano133.com uses, from 1¢ to $1, and a price as a page writes it (`25¢`, `$1.10`). |
482
- | `usdText(usd)`, `wholeCents(usd)`, `HATS` | `"$0.25"`; whether a price has no fraction of a cent; the hats. |
481
+ | `PRICE_LADDER`, `rungOf(usd)`, `priceText(usd)` | The six steps nano133.com uses, from 1¢ to $1, and a price as a page writes it (`0.5¢`, `25¢`, `$1.10`). |
482
+ | `usdText(usd)`, `usdDecimals(usd)`, `priceStepOk(usd)`, `PRICE_STEP_USD`, `wholeCents(usd)`, `HATS` | `"$0.25"`, `"$0.005"` (2 to 4 decimals); the same without `$`, as an order keeps it; whether a price has at most 4 decimals (the looks rule since 0.10.0); `0.0001`, the smallest price above 0; whether a price has no fraction of a cent; the hats. |
483
483
  | `sameSlots(a, b)`, `cleanSlots(slots)`, `slotValueOk(v)`, `SLOTS_MAX` | Whether two looks have the same slots; a clean copy (none when empty); whether a value is a word or a colour; 8. |
484
484
 
485
485
  | Step | Price | For |
@@ -491,8 +491,10 @@ The rules (the same as the starter's `AGENTS.md`):
491
491
  | set | 50¢ | a few pieces that go together |
492
492
  | outfit | $1 | a whole look |
493
493
 
494
- The ladder is a suggestion. Any price with at most 2 decimals, up to $1000, is good. `wholeCents` accepts every price in whole cents (the starter's old check,
495
- `Math.round(x * 100) === x * 100`, refused $0.07, $0.29, $0.57 and $1.10 because of floating point).
494
+ The ladder is a suggestion. Any price with at most 4 decimals, up to $1000, is good: there is no minimum, so a fraction of a cent works ($0.005, $0.0001;
495
+ since 0.10.0, before it whole cents). The shop rounds the Nano amount up to a step of 0.0001 XNO and asks for at least one step, so a creator never gets
496
+ less than the price. `priceStepOk` and `wholeCents` compare a price with its own decimal text (the starter's old check, `Math.round(x * 100) === x * 100`,
497
+ refused $0.07, $0.29, $0.57 and $1.10 because of floating point).
496
498
 
497
499
  ## The admin (`/admin`; since 0.2.0)
498
500
 
@@ -1158,13 +1160,27 @@ const player = await signin.open(); // the shop's "sign in first":
1158
1160
  game's passkey, another passkey than the one just made, two answers for one passkey, the wrong host. None names a seed or a key; none says to delete a passkey.
1159
1161
  - A wallet: on a touch screen one key that opens the app; with a mouse its QR code. The page asks every 2 s, at most 5 minutes.
1160
1162
  - `signInView(options)` is the same content for a game with a sheet of its own (`{ node, stop, reset }`).
1161
- - Phone first: every key 44 px or more, the sheet from the bottom on a phone held upright, a card in the middle on a wide or sideways screen; Escape, the close
1162
- key and the scrim close it; no browser dialog. Light and dark like the options panel (`data-n133-theme` on `<html>`, else the device), every colour a
1163
+ - Phone first (since 0.9.3 by default): every text 16 px or more, and every key, wallet tile, link, the back key and the close key 48 px or more each way
1164
+ ("Last used" sits in the flow, under a wallet's name or at the end of the passkey's row). The sheet comes from the bottom on a phone held upright, and is
1165
+ a card in the middle on a wide or sideways screen; Escape, the close key and the scrim close it; no browser dialog. Light and dark like the options panel (`data-n133-theme` on `<html>`, else the device), every colour a
1163
1166
  `--n133-*` variable with a `--n133-dark-*` twin; `--n133-signin-z` (default 1200) for its z-index.
1164
1167
  - The page loads the purse's signing code (`@nano133/signin/purse`, 40 kB gzip) only when a passkey signs in.
1165
1168
  - The browser remembers, in `localStorage["n133.player-signin.v1:<rpId>"]`, the ids of its passkeys, which were made here and never opened, and the last
1166
1169
  way: no secret and no address.
1167
1170
 
1171
+ **Its words** (since 0.9.3). `words` on `mountPlayerSignin` and `signInView` changes any word of the sheet, for the game's own words or language: the keys
1172
+ of `PlayerSigninWords` (the defaults are `PLAYER_SIGNIN_WORDS`), any of them, over the defaults. A text with a value in it is a function of that value, and
1173
+ `faults` changes the words of any passkey fault (`faultWords(code, rpId, words)` gives them). The wallets' names stay. `refusal(text, status)` turns the
1174
+ server's refusals (in English) into the game's words.
1175
+
1176
+ ```ts
1177
+ mountPlayerSignin(document.body, {
1178
+ name: "My Game",
1179
+ scheme: SCHEME,
1180
+ words: { lead: "No password.", waitingFor: (wallet) => `Waiting for ${wallet}…`, faults: { cancelled: { title: "Stopped", text: "No passkey was used." } } },
1181
+ });
1182
+ ```
1183
+
1168
1184
  ## The shop (`/shop`; since 0.9.0)
1169
1185
 
1170
1186
  A shop that sells **looks** for Nano (XNO), the way the [game starter](https://github.com/nano133-com/nano-game-starter) sells them. A game
@@ -1305,12 +1321,46 @@ const shop = mountShop(document.getElementById("shop")!, {
1305
1321
  Wearing, or Buy. `picture(look)` gives the game's own picture instead of the swatch.
1306
1322
  - Buy: the game's sign-in (`signIn`) when nobody is signed in, then the person check, then the pay sheet: the amount, the QR code of the exact
1307
1323
  `nano:` link (drawn by the package as SVG: no QR library, no outside service), "Open wallet", the address and the amount by hand, the time
1308
- left, and a check every 5 seconds for 2 minutes ("Check again" after). Paid: "Paid. Thank you!" and `onOwned`. Paid late or expired: the
1309
- starter's words.
1310
- - Phone first: every key 44 px or more (the buy and pay keys 48 px). On a phone held upright the sheet rises from the bottom; on a wide screen
1311
- it stands in the middle; on a phone held sideways the QR code stands alone on the left and the wallet key stays in view.
1324
+ left (live only), and a check every 5 seconds for 2 minutes ("Check again" after). Paid: "Paid. Thank you!" and `onOwned`. Paid late: no look,
1325
+ and the creator sends it back. Ended: a payment sent in time still counts.
1326
+ - Phone first (since 0.9.3 by default): every text 16 px or more (the price-source line, the time left, "Enter by hand"'s address and labels too), and
1327
+ every key, tab, the Looks key, the close key, the fold "Enter by hand" and its copy keys 48 px or more each way. On a phone held upright the sheet
1328
+ rises from the bottom; on a wide screen it stands in the middle; on a phone held sideways the QR code (or the stamp) stands alone on the left and
1329
+ the wallet key stays in view. A game that wants smaller text sets it in its own stylesheet, after the kit's.
1312
1330
  - Light and dark: `theme: "light" | "dark" | "device"` (the default: the page's `data-n133-theme`, else the device's setting). Every colour is a
1313
1331
  `--n133-*` variable with a `--n133-dark-*` twin (and `--n133-sun` for Buy, `--n133-warn` for the stamp). The QR code stays dark on white.
1332
+ - **A shop that is a modal `<dialog>` of the game's own** (its own sheet around `open()`) closes it in its `signIn` hook before it opens the sign-in
1333
+ sheet, or the sign-in sheet stays hidden behind it. The game starter's `src/client/main.ts` does this.
1334
+ - **Buy from a game's own `<dialog>`** (its own looks sheet calls `shop.buy(lookId)`): close the game's dialog first, and let it finish closing (its
1335
+ `close` event) before the kit's sheet opens, or the game's close handler takes the focus and the Escape key from the kit's sheet. Since 0.9.2 the
1336
+ kit does this itself: `buy(lookId)` starts on the next frame (Chromium fires a dialog's `close` event at the frame, after a `setTimeout(0)`; a
1337
+ hidden page has no frames and starts at once), so `dialog.close(); shop.buy(id)` is safe. `shop.isOpen` is false until that frame. `open()` still opens at once.
1338
+ - **`onSessionEnd()`** (since 0.9.3): the server refused an order with 401 (the player's session ended, though `signedIn()` said yes). The
1339
+ game marks the player signed out. The sheet's "Sign in" key then calls `signIn()` without asking `signedIn()` first, so a stale "yes" cannot
1340
+ post the refused order again.
1341
+ - **`onSignInCancel(lookId, look)`** (since 0.9.3): when the game's own Buy (`buy(lookId)`) asks for a sign-in and the player closes it without signing
1342
+ in, the kit opens nothing (it closes its sheet if it was open) and calls this hook: the game shows its own looks sheet again. A Buy in the kit's own
1343
+ list goes back to that list, as before, and does not call it.
1344
+
1345
+ ```ts
1346
+ const shop = mountShop(el, { ...options, key: false, onSignInCancel: () => myLooksDialog.showModal() });
1347
+ myBuyKey.onclick = () => { myLooksDialog.close(); shop.buy(look.id); };
1348
+ ```
1349
+
1350
+ **Its words** (since 0.9.3). `words` on `mountShop` (and on `looksView` and `payPanel`) changes any word of the looks sheet and the pay sheet: the
1351
+ titles, the lines, every key, the states (waiting, paid, paid late, ended), the refusals (`refusal(text, status)`: the server's words are English) and
1352
+ the names for a screen reader. Give the keys of `ShopWords` you want, over the defaults (`SHOP_WORDS`); a text with a value in it is a function of
1353
+ that value:
1354
+
1355
+ ```ts
1356
+ mountShop(el, { ...options, words: { sendExactly: "Pay exactly", buyTitle: (look) => `Get ${look}` } });
1357
+ ```
1358
+
1359
+ The defaults are short: the pay sheet shows at most 30 words (TEST MODE and live) and no sentence has more than 10 (`test/panel-words.test.ts`).
1360
+ Two things are not the game's words: the stamp **"TEST MODE: DO NOT PAY"**, and **"Powered by CoinGecko"** while CoinGecko is one of the price feeds
1361
+ (its API terms; `priceLine` adds it when the game's `prices` leaves it out). Keep the meaning of the safety lines in any language: live, send the
1362
+ **exact** amount, and the order ends at the timer; in test mode nothing can be paid and the wallet key is off; a late payment gives no look and
1363
+ the creator sends it back.
1314
1364
 
1315
1365
  ### The page's Content Security Policy
1316
1366
 
package/ai/SPEC.md CHANGED
@@ -106,7 +106,7 @@ export const LOOKS = checkLooks(rawLooksJson); // throws with the first mistake
106
106
  ```
107
107
 
108
108
  - Before a change goes live: `[...lookProblems(next), ...removalProblems(current, next, ownedLookIds)]`; show `diffLooks(current, next)`; write the file with `looksJson(next)`.
109
- - One free look (`priceUsd: 0`) at least. Never change or remove the id of a look that someone owns. Prices in US dollars with at most 2 decimals; the server reads the price from the file.
109
+ - One free look (`priceUsd: 0`) at least. Never change or remove the id of a look that someone owns. Prices in US dollars with at most 4 decimals (no minimum: 0.005 is half a cent); the server reads the price from the file.
110
110
  - A look only changes how the character looks. No random look, no loot box, no look that changes a score.
111
111
  - `slots` (optional, at most 8: a name and a word or a `#rrggbb` colour) is where the game keeps its own parts of a look. Read `look.slots` from `checkLooks`. Never change the slots of a look that someone owns: add a new look.
112
112
  - `wear` (optional) is the wear slot a look fills: none for an outfit (the character's colours and hat, `change`), or the game's own slot (`"shovel"`, `"charm"`) for an item, which has no `change`. A player wears one look in each slot. Keep one free outfit. Never change the wear slot of a look that someone owns. Read outfits with `isOutfit(look)`.
@@ -216,6 +216,7 @@ npm install @nano133/nanoterra @nano133/signin
216
216
  6. **The sheet.** `mountPlayerSignin(document.body, { name, scheme: SCHEME, qr, personCheck, onSignedIn })` and the stylesheet; the account key and the
217
217
  shop's "sign in first" call `open()`. On `*.workers.dev` the sheet offers no passkey (it would be lost at the move to the game's own
218
218
  domain); give `mountAdmin` `playerPasskeys: true` so the admin says so. Do not write your own passkey or wallet code, and never store, log or send the seed.
219
+ 7. **The words.** For the game's own words or language, give the sheet `words` (keys of `PlayerSigninWords`, over `PLAYER_SIGNIN_WORDS`); never edit the kit's files.
219
220
 
220
221
  **Check:** on the game's real https address, with a phone (iOS 18 or a recent Android): Make a passkey, see "Keep a spare key", sign in, reload: still
221
222
  signed in; sign out, "Use my passkey": the same address. A desktop browser with no PRF shows the passkey last and its plain words. A wallet sign-in works only
@@ -241,6 +242,11 @@ import "@nano133/nanoterra/shop/style.css";
241
242
  code, no address and the wallet key off. Never show a pay screen to an address nobody holds the key for without that stamp.
242
243
  - Add the cron (`*/10 * * * *`) that calls `shop.scan()`, and give the admin `check: (at) => shop.scan(at)`.
243
244
  - Mount the panel with `onOwned(lookId)` (the game adds the look and wears it); `key: false` and `open()` when the game has its own Looks key.
245
+ A game with its own looks sheet calls `buy(lookId)` after it closes that sheet, and gives `onSignInCancel` to show the sheet again when the player
246
+ closes the sign-in without signing in.
247
+ - The game's own words or language: `words` on `mountShop` (keys of `ShopWords`, over `SHOP_WORDS`). Keep each safety line's meaning; the stamp
248
+ "TEST MODE: DO NOT PAY" and "Powered by CoinGecko" are not the game's to change. A shop in a modal `<dialog>` of the game's own closes it in `signIn`
249
+ before the sign-in sheet opens, or the sheet stays hidden behind it.
244
250
  - Merge `SHOP_CSP` into the game's Content Security Policy with `mergeCsp`; never replace the game's policy.
245
251
  - "Delete my data" calls `store.forget(player, now)`; with `waitUntil`, ask the player to try again then.
246
252
  - Tests: the shop's money tests are the package's. The game's tests read the game's own looks file and test only the game's own routes and wiring.
@@ -3,3 +3,7 @@ export declare const RAW_PER_XNO: bigint;
3
3
  export declare function xnoText(raw: string): string;
4
4
  /** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
5
5
  export declare const isRawText: (s: unknown) => s is string;
6
+ /** Dollars as plain decimals, as an order keeps them: at least 2 decimals and at most 4, "0.25", "1.00", "0.005", "0.0125". */
7
+ export declare const usdDecimals: (usd: number) => string;
8
+ /** "$0.25", "$1.00", "$0.005". */
9
+ export declare const usdText: (usd: number) => string;
@@ -9,3 +9,7 @@ export function xnoText(raw) {
9
9
  }
10
10
  /** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
11
11
  export const isRawText = (s) => typeof s === "string" && /^[0-9]{1,39}$/.test(s);
12
+ /** Dollars as plain decimals, as an order keeps them: at least 2 decimals and at most 4, "0.25", "1.00", "0.005", "0.0125". */
13
+ export const usdDecimals = (usd) => usd.toFixed(4).replace(/(\.\d\d\d*?)0+$/, "$1");
14
+ /** "$0.25", "$1.00", "$0.005". */
15
+ export const usdText = (usd) => `$${usdDecimals(usd)}`;
@@ -15,6 +15,6 @@ export declare function copyKey(value: string, label: string, icon?: Element): H
15
15
  export declare function ago(ms: number, now?: number): string;
16
16
  /** "3 orders", "1 order" */
17
17
  export declare const plural: (n: number, word: string) => string;
18
- /** "$1.25" from cents. */
18
+ /** "$1.25" from cents, "$0.005" from half a cent. */
19
19
  export declare const dollars: (cents: number) => string;
20
20
  export {};
@@ -1,4 +1,5 @@
1
1
  // A tiny helper to build page elements without innerHTML (safe for any text, and fine under a strict CSP). The panel runs in a browser.
2
+ import { usdText } from "../amount.js";
2
3
  export function h(tag, attrs = {}, ...children) {
3
4
  const el = document.createElement(tag);
4
5
  for (const [k, v] of Object.entries(attrs)) {
@@ -68,5 +69,5 @@ export function ago(ms, now = Date.now()) {
68
69
  }
69
70
  /** "3 orders", "1 order" */
70
71
  export const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
71
- /** "$1.25" from cents. */
72
- export const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
72
+ /** "$1.25" from cents, "$0.005" from half a cent. */
73
+ export const dollars = (cents) => usdText(cents / 100);
@@ -388,7 +388,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
388
388
  l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
389
389
  sync();
390
390
  } });
391
- const priceInput = h("input", { class: "input input-num", type: "number", step: 0.01, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e) => {
391
+ const priceInput = h("input", { class: "input input-num", type: "number", step: 0.0001, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e) => {
392
392
  const v = e.target.value;
393
393
  l.priceUsd = v === "" ? Number.NaN : Number(v);
394
394
  sync();
@@ -20,7 +20,7 @@ export type LookStats = {
20
20
  /** Orders that were paid after the order ran out (the money is to be sent back; the look was not given). */
21
21
  late: number;
22
22
  };
23
- /** "0.25" is 25 cents. */
23
+ /** "0.25" is 25 cents; "0.005" is half a cent (cents keep 2 decimals: a price has at most 4). */
24
24
  export declare const centsOf: (usd: string) => number;
25
25
  /** The stats for each look id that appears in the orders or in a player's looks. */
26
26
  export declare function shelfStats(orders: readonly ShelfOrder[], players: readonly ShelfPlayer[]): Map<string, LookStats>;
@@ -1,9 +1,9 @@
1
1
  // The numbers and the words of the Shelf screen in /admin (shelf.ts draws it). Pure: no page, no network. Everything
2
2
  // here comes from the game's own database (the admin summary) and from looks.json; nothing is sent anywhere.
3
- import { ID_PATTERN } from "../../looks/look.js";
3
+ import { ID_PATTERN, usdText } from "../../looks/look.js";
4
4
  const none = () => ({ owners: 0, paid: 0, paidCents: 0, late: 0 });
5
- /** "0.25" is 25 cents. */
6
- export const centsOf = (usd) => Math.round(Number(usd) * 100) || 0;
5
+ /** "0.25" is 25 cents; "0.005" is half a cent (cents keep 2 decimals: a price has at most 4). */
6
+ export const centsOf = (usd) => Math.round(Number(usd) * 10_000) / 100 || 0;
7
7
  /** The stats for each look id that appears in the orders or in a player's looks. */
8
8
  export function shelfStats(orders, players) {
9
9
  const stats = new Map();
@@ -12,7 +12,7 @@ export function shelfStats(orders, players) {
12
12
  if (o.status === "paid") {
13
13
  const s = of(o.lookId);
14
14
  s.paid++;
15
- s.paidCents += centsOf(o.usd);
15
+ s.paidCents = Math.round((s.paidCents + centsOf(o.usd)) * 100) / 100;
16
16
  }
17
17
  else if (o.status === "paid_late")
18
18
  of(o.lookId).late++;
@@ -35,7 +35,7 @@ export function changeText(d) {
35
35
  export function soldText(s) {
36
36
  if (!s || (!s.paid && !s.owners))
37
37
  return "Not sold yet";
38
- return `${s.paid} sold, $${(s.paidCents / 100).toFixed(2)}, ${plural(s.owners, "owner")}`;
38
+ return `${s.paid} sold, ${usdText(s.paidCents / 100)}, ${plural(s.owners, "owner")}`;
39
39
  }
40
40
  /**
41
41
  * A message to give to an AI assistant that works in the game's repository: the whole new looks.json, what changed, and the rules.
@@ -57,7 +57,7 @@ export function aiPrompt(text, diff, owned, before) {
57
57
  diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
58
58
  "",
59
59
  `Looks that players own and that must stay in the file with the same id: ${ownedOk.length ? quote(ownedOk) : "none yet"}${odd ? ` (and ${odd} more with an id that is not a valid look id: do not remove any look)` : ""}.`,
60
- `Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
60
+ `Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 4 decimals (0.005 is half a cent), never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
61
61
  "",
62
62
  "looks.json:",
63
63
  "```json",
@@ -14,7 +14,7 @@ export type OrderRow = {
14
14
  hash: string | null;
15
15
  };
16
16
  export declare const ORDERS_SHOWN = 500;
17
- /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
17
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in whole $0.0001 steps, so 0.1 + 0.2 is 0.30). */
18
18
  export declare function moneyOf(rows: readonly {
19
19
  amountRaw: string;
20
20
  usd: string;
@@ -1,23 +1,23 @@
1
1
  // The numbers of the summary from the rows of the orders, the same for every adapter: the counts by status, the totals and the shape of an order.
2
2
  // An adapter reads its rows; this file turns them into a `StoreSummary`.
3
- import { isRawText, xnoText } from "./amount.js";
3
+ import { isRawText, usdDecimals, xnoText } from "./amount.js";
4
4
  export const ORDERS_SHOWN = 500;
5
- /** Cents from a dollar text like "0.25". Anything that is not plain dollars (a sign, an exponent, hex) counts as 0: a bad row must not change a total. */
6
- const toCents = (usd) => {
5
+ /** Steps of $0.0001 from a dollar text like "0.25" or "0.005". Anything that is not plain dollars (a sign, an exponent, hex) counts as 0: a bad row must not change a total. */
6
+ const toSteps = (usd) => {
7
7
  if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd))
8
8
  return 0;
9
- return Math.round(Number(usd) * 100);
9
+ return Math.round(Number(usd) * 10_000);
10
10
  };
11
- /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
11
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in whole $0.0001 steps, so 0.1 + 0.2 is 0.30). */
12
12
  export function moneyOf(rows) {
13
13
  let raw = 0n;
14
- let cents = 0;
14
+ let steps = 0;
15
15
  for (const r of rows) {
16
16
  if (isRawText(r.amountRaw))
17
17
  raw += BigInt(r.amountRaw);
18
- cents += toCents(r.usd);
18
+ steps += toSteps(r.usd);
19
19
  }
20
- return { count: rows.length, xno: xnoText(raw.toString()), usd: (cents / 100).toFixed(2) };
20
+ return { count: rows.length, xno: xnoText(raw.toString()), usd: usdDecimals(steps / 10_000) };
21
21
  }
22
22
  /** Where an order counts: an open order past its expiry counts as expired. */
23
23
  export const statusOf = (status, expiresAt, now) => (status === "open" && expiresAt < now ? "expired" : status);
@@ -1,4 +1,4 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, PRICE_STEP_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, priceStepOk, lookProblems, checkLooks, cleanSlots, slotValueOk, usdDecimals, usdText, wearOf, isOutfit } from "./look.js";
2
2
  export type { Change, Hat, Look, Problem, Slots } from "./look.js";
3
3
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
4
4
  export type { Rung } from "./ladder.js";
@@ -1,4 +1,4 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, PRICE_STEP_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, priceStepOk, lookProblems, checkLooks, cleanSlots, slotValueOk, usdDecimals, usdText, wearOf, isOutfit } from "./look.js";
2
2
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
3
3
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
4
4
  export { looksJson, slugId } from "./json.js";
@@ -10,5 +10,5 @@ export declare const PRICE_LADDER: readonly Rung[];
10
10
  export declare const rungUsd: (r: Rung) => number;
11
11
  /** The rung that has exactly this price, or undefined. */
12
12
  export declare function rungOf(usd: number): Rung | undefined;
13
- /** A price as a page writes it: 1¢, 25¢, $1, $1.10. */
13
+ /** A price as a page writes it: 0.5¢, 1¢, 25¢, $1, $1.10, $1.005. */
14
14
  export declare function priceText(usd: number): string;
@@ -1,5 +1,6 @@
1
1
  // The price ladder: the six steps from a cent to a dollar that nano133.com uses for its own cosmetics and shows on /build/cosmetics.
2
- // It is a suggestion. A look can have any price with at most 2 decimals, up to $1000.
2
+ // It is a suggestion. A look can have any price with at most 4 decimals ($0.0001 steps), up to $1000.
3
+ import { usdText } from "./look.js";
3
4
  export const PRICE_LADDER = [
4
5
  { name: "dye", cents: 1, for: "a colour" },
5
6
  { name: "charm", cents: 2, for: "a small mark or a badge" },
@@ -14,11 +15,10 @@ export const rungUsd = (r) => r.cents / 100;
14
15
  export function rungOf(usd) {
15
16
  return PRICE_LADDER.find((r) => Math.abs(rungUsd(r) - usd) < 1e-9);
16
17
  }
17
- /** A price as a page writes it: 1¢, 25¢, $1, $1.10. */
18
+ /** A price as a page writes it: 0.5¢, 1¢, 25¢, $1, $1.10, $1.005. */
18
19
  export function priceText(usd) {
19
- const cents = Math.round(usd * 100);
20
- if (cents < 100)
21
- return `${cents}¢`;
22
- const dollars = cents / 100;
23
- return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`;
20
+ const steps = Math.round(usd * 10_000);
21
+ if (steps < 10_000)
22
+ return `${steps / 100}¢`;
23
+ return steps % 10_000 === 0 ? `$${steps / 10_000}` : usdText(steps / 10_000);
24
24
  }
@@ -12,7 +12,7 @@ export type Look = {
12
12
  id: string;
13
13
  /** What the player reads, 1 to 40 characters. */
14
14
  name: string;
15
- /** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
15
+ /** The price in US dollars with at most 4 decimals, for example 0.25 or 0.005. 0 means free for everyone. */
16
16
  priceUsd: number;
17
17
  /**
18
18
  * The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
@@ -61,6 +61,10 @@ export type Problem = {
61
61
  * and 1e-9 would pass): a price is whole cents when it is exactly the number that its own two-decimal text names.
62
62
  */
63
63
  export declare function wholeCents(usd: number): boolean;
64
+ /** The smallest step of a price: $0.0001, a hundredth of a cent. A price above 0 is at least this, and the shop asks for at least one 0.0001 XNO step. */
65
+ export declare const PRICE_STEP_USD = 0.0001;
66
+ /** Whether a price has at most 4 decimals (whole steps of $0.0001), by the same rule as `wholeCents`: it is exactly the number its own four-decimal text names. */
67
+ export declare function priceStepOk(usd: number): boolean;
64
68
  /** Every mistake in a list of looks (looks.json as it was read), in the order of the list. An empty array means the list is good. */
65
69
  export declare function lookProblems(raw: unknown): Problem[];
66
70
  /** Whether a slot's value is good: a word or a colour. */
@@ -73,7 +77,8 @@ export declare function cleanSlots(slots: Slots | undefined): Slots | undefined;
73
77
  /** The looks, or an Error that says the first mistake in plain words. The same words as the starter's `checkLooks`. */
74
78
  export declare function checkLooks(rawInput: unknown): Look[];
75
79
  /** "$0.25" */
76
- export declare const usdText: (usd: number) => string;
80
+ /** "$0.25", "$1.00", "$0.005"; and the same without "$", as an order keeps it ("0.005"). The money code's own helpers. */
81
+ export { usdDecimals, usdText } from "../admin/amount.js";
77
82
  /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
78
83
  export declare const wearOf: (l: Pick<Look, "wear">) => string;
79
84
  /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
@@ -27,6 +27,12 @@ export const PRICE_MAX_USD = 1000;
27
27
  export function wholeCents(usd) {
28
28
  return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(2)) === usd;
29
29
  }
30
+ /** The smallest step of a price: $0.0001, a hundredth of a cent. A price above 0 is at least this, and the shop asks for at least one 0.0001 XNO step. */
31
+ export const PRICE_STEP_USD = 0.0001;
32
+ /** Whether a price has at most 4 decimals (whole steps of $0.0001), by the same rule as `wholeCents`: it is exactly the number its own four-decimal text names. */
33
+ export function priceStepOk(usd) {
34
+ return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(4)) === usd;
35
+ }
30
36
  /** Every mistake in a list of looks (looks.json as it was read), in the order of the list. An empty array means the list is good. */
31
37
  export function lookProblems(raw) {
32
38
  const out = [];
@@ -48,8 +54,8 @@ export function lookProblems(raw) {
48
54
  add("name", '"name" must be 1 to 40 characters');
49
55
  if (typeof v.priceUsd !== "number" || !Number.isFinite(v.priceUsd) || v.priceUsd < 0 || v.priceUsd > PRICE_MAX_USD)
50
56
  add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
51
- else if (!wholeCents(v.priceUsd))
52
- add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
57
+ else if (!priceStepOk(v.priceUsd))
58
+ add("priceUsd", '"priceUsd" can have at most 4 decimals (0.0001 is the smallest price above 0)');
53
59
  const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
54
60
  if (!wearOk)
55
61
  add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
@@ -131,7 +137,8 @@ export function checkLooks(rawInput) {
131
137
  });
132
138
  }
133
139
  /** "$0.25" */
134
- export const usdText = (usd) => `$${usd.toFixed(2)}`;
140
+ /** "$0.25", "$1.00", "$0.005"; and the same without "$", as an order keeps it ("0.005"). The money code's own helpers. */
141
+ export { usdDecimals, usdText } from "../admin/amount.js";
135
142
  /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
136
143
  export const wearOf = (l) => l.wear ?? OUTFIT;
137
144
  /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
@@ -3,7 +3,7 @@ export type { MountedPlayerSignin, PlayerSigninMountOptions } from "./mount.js";
3
3
  export { hostFits, passkeyName, signInView } from "./view.js";
4
4
  export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
5
5
  export type { PlayerSigninView, PlayerSigninViewOptions, SignedIn } from "./view.js";
6
- export { faultWords } from "./words.js";
7
- export type { FaultWords } from "./words.js";
6
+ export { PLAYER_SIGNIN_WORDS, faultWords, playerSigninWords } from "./words.js";
7
+ export type { FaultText, FaultWords, PlayerSigninWords, PlayerSigninWordsOption } from "./words.js";
8
8
  export { readMemo } from "./memo.js";
9
9
  export type { SigninMemo } from "./memo.js";
@@ -1,7 +1,8 @@
1
1
  // "@nano133/nanoterra/player-signin/panel": the player's sign-in sheet in the browser. `mountPlayerSignin` is the sheet (call `open()`);
2
- // `signInView` is its content, for a game that has a sheet of its own. Add "@nano133/nanoterra/player-signin/style.css" to the page.
2
+ // `signInView` is its content, for a game that has a sheet of its own. Add "@nano133/nanoterra/player-signin/style.css" to the page. Every word of
3
+ // the sheet can be the game's own: `words` (PlayerSigninWords; PLAYER_SIGNIN_WORDS are the defaults).
3
4
  export { mountPlayerSignin } from "./mount.js";
4
5
  export { hostFits, passkeyName, signInView } from "./view.js";
5
6
  export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
6
- export { faultWords } from "./words.js";
7
+ export { PLAYER_SIGNIN_WORDS, faultWords, playerSigninWords } from "./words.js";
7
8
  export { readMemo } from "./memo.js";
@@ -1,6 +1,6 @@
1
1
  import { type PlayerSigninViewOptions, type SignedIn } from "./view.js";
2
2
  export type PlayerSigninMountOptions = PlayerSigninViewOptions & {
3
- /** The sheet's title. Default "Sign in". */
3
+ /** The sheet's title. Default: `words.title` ("Sign in"). */
4
4
  title?: string;
5
5
  /** How long "Signed in as …" shows before the sheet closes, in ms. Default 900. */
6
6
  closeAfterMs?: number;
@@ -1,10 +1,11 @@
1
1
  // The sign-in sheet of a game: `mountPlayerSignin(element, options)`. It draws no key of its own: the game's account key, the shop's "sign in
2
2
  // first" and anything else call `open()`, which resolves with the player once they are signed in, or null when the sheet closes. Plain DOM; a
3
3
  // modal sheet with a scrim: from the bottom on a phone held upright, in the middle on a wide or sideways screen. Escape, the close key and a press
4
- // on the scrim close it. Every key is 44 px or more.
4
+ // on the scrim close it. Phone first: text 16 px, every key 48 px or more each way.
5
5
  import { h, releaseFocus } from "../../admin/panel/dom.js";
6
6
  import { icon } from "../../admin/panel/icons.js";
7
7
  import { signInView } from "./view.js";
8
+ import { playerSigninWords } from "./words.js";
8
9
  let mounts = 0;
9
10
  export function mountPlayerSignin(root, options) {
10
11
  const n = ++mounts;
@@ -12,6 +13,7 @@ export function mountPlayerSignin(root, options) {
12
13
  let pending = null;
13
14
  let before = null;
14
15
  let closing;
16
+ const words = playerSigninWords(options.words);
15
17
  const settle = (p) => {
16
18
  const was = pending;
17
19
  pending = null;
@@ -25,8 +27,8 @@ export function mountPlayerSignin(root, options) {
25
27
  closing = setTimeout(close, Math.max(0, options.closeAfterMs ?? 900));
26
28
  },
27
29
  });
28
- const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ?? "Sign in");
29
- const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": "Close" }, icon("close", 20));
30
+ const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ?? words.title);
31
+ const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
30
32
  const scrim = h("div", { class: "n133s-scrim" });
31
33
  const panel = h("section", { class: "n133s-panel", role: "dialog", "aria-modal": "true", "aria-labelledby": `n133s-title-${n}` }, h("header", { class: "n133s-head" }, titleEl, closeKey), h("div", { class: "n133s-body" }, view.node));
32
34
  const wrap = h("div", { class: "n133-signin n133s-sheet", "data-open": "false" }, scrim, panel);
@@ -1,5 +1,6 @@
1
1
  import { type PasskeyScheme } from "@nano133/signin/passkey";
2
2
  import { type Way } from "../types.js";
3
+ import { type PlayerSigninWordsOption } from "./words.js";
3
4
  export type SignedIn = {
4
5
  address: string;
5
6
  via: "wallet" | "passkey";
@@ -30,6 +31,8 @@ export type PlayerSigninViewOptions = {
30
31
  fetch?: typeof fetch;
31
32
  /** A touch screen (one key that opens the app) or not (a QR code). Default: `(pointer: coarse)`. */
32
33
  touch?: boolean;
34
+ /** The game's words for the sheet, over the defaults (PLAYER_SIGNIN_WORDS): `{ lead: "No password.", faults: { cancelled: { ... } } }`. */
35
+ words?: PlayerSigninWordsOption;
33
36
  };
34
37
  export type PlayerSigninView = {
35
38
  node: HTMLElement;