@nano133/nanoterra 0.9.0 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3 (2026-11-01)
4
+
5
+ - **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
6
+ "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
7
+ new `onSessionEnd()` hook on `mountShop` lets the game mark the player signed out (the Echo Island worker found it).
8
+
9
+ ## 0.9.2 (2026-11-01)
10
+
11
+ - Not published on npm: its changes reached npm in 0.9.3.
12
+ - **`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
13
+ 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
14
+ 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
15
+ goes back to that list and does not call the hook.
16
+ - **`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
17
+ 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.
18
+ `shop.isOpen` is false until that frame; `open()` still opens at once. The README says to close the game's dialog first.
19
+ - **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,
20
+ 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
21
+ 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
22
+ 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
23
+ 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.
24
+
25
+ ## 0.9.1 (2026-11-01)
26
+
27
+ - Not published on npm: its changes reached npm in 0.9.3.
28
+ - **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
29
+ `/shop` waited on it; Wes prefers fewer words). `words` on `mountShop`, `looksView` and `payPanel` (`ShopWords`, defaults `SHOP_WORDS`,
30
+ `shopWords()` merges) and on `mountPlayerSignin` and `signInView` (`PlayerSigninWords`, defaults `PLAYER_SIGNIN_WORDS`, `PlayerSigninWordsOption`
31
+ for a game's partial words and faults, `playerSigninWords()` merges). Every visible word, every key and every name for a screen reader; a text
32
+ with a value is a function (`buyTitle(look)`, `endsIn(time)`, `prices(sources)`, `waitingFor(wallet)`). `refusal(text, status)` turns the
33
+ 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
34
+ NOT PAY", and "Powered by CoinGecko" while CoinGecko is a price feed (`priceLine` adds it back).
35
+ - **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:
36
+ "Send exactly 0.25 XNO", "Ends in 14:59.", "Waiting for payment…", "Price: Kraken, KuCoin. Powered by CoinGecko."; by hand: "Another amount
37
+ 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
38
+ 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
39
+ 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
40
+ 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).
41
+ - `test/panel-words.test.ts` counts the words a player sees (happy-dom) and proves a game's words reach the page.
42
+
3
43
  ## 0.9.0 (2026-11-01)
4
44
 
5
45
  - **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.
@@ -1158,13 +1158,27 @@ const player = await signin.open(); // the shop's "sign in first":
1158
1158
  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
1159
  - 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
1160
  - `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
1161
+ - 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
1162
+ ("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
1163
+ 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
1164
  `--n133-*` variable with a `--n133-dark-*` twin; `--n133-signin-z` (default 1200) for its z-index.
1164
1165
  - The page loads the purse's signing code (`@nano133/signin/purse`, 40 kB gzip) only when a passkey signs in.
1165
1166
  - 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
1167
  way: no secret and no address.
1167
1168
 
1169
+ **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
1170
+ 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
1171
+ `faults` changes the words of any passkey fault (`faultWords(code, rpId, words)` gives them). The wallets' names stay. `refusal(text, status)` turns the
1172
+ server's refusals (in English) into the game's words.
1173
+
1174
+ ```ts
1175
+ mountPlayerSignin(document.body, {
1176
+ name: "My Game",
1177
+ scheme: SCHEME,
1178
+ words: { lead: "No password.", waitingFor: (wallet) => `Waiting for ${wallet}…`, faults: { cancelled: { title: "Stopped", text: "No passkey was used." } } },
1179
+ });
1180
+ ```
1181
+
1168
1182
  ## The shop (`/shop`; since 0.9.0)
1169
1183
 
1170
1184
  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 +1319,46 @@ const shop = mountShop(document.getElementById("shop")!, {
1305
1319
  Wearing, or Buy. `picture(look)` gives the game's own picture instead of the swatch.
1306
1320
  - 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
1321
  `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.
1322
+ 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,
1323
+ and the creator sends it back. Ended: a payment sent in time still counts.
1324
+ - 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
1325
+ 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
1326
+ 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
1327
+ the wallet key stays in view. A game that wants smaller text sets it in its own stylesheet, after the kit's.
1312
1328
  - 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
1329
  `--n133-*` variable with a `--n133-dark-*` twin (and `--n133-sun` for Buy, `--n133-warn` for the stamp). The QR code stays dark on white.
1330
+ - **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
1331
+ sheet, or the sign-in sheet stays hidden behind it. The game starter's `src/client/main.ts` does this.
1332
+ - **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
1333
+ `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
1334
+ 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
1335
+ 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.
1336
+ - **`onSessionEnd()`** (since 0.9.3): the server refused an order with 401 (the player's session ended, though `signedIn()` said yes). The
1337
+ game marks the player signed out. The sheet's "Sign in" key then calls `signIn()` without asking `signedIn()` first, so a stale "yes" cannot
1338
+ post the refused order again.
1339
+ - **`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
1340
+ 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
1341
+ list goes back to that list, as before, and does not call it.
1342
+
1343
+ ```ts
1344
+ const shop = mountShop(el, { ...options, key: false, onSignInCancel: () => myLooksDialog.showModal() });
1345
+ myBuyKey.onclick = () => { myLooksDialog.close(); shop.buy(look.id); };
1346
+ ```
1347
+
1348
+ **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
1349
+ titles, the lines, every key, the states (waiting, paid, paid late, ended), the refusals (`refusal(text, status)`: the server's words are English) and
1350
+ 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
1351
+ that value:
1352
+
1353
+ ```ts
1354
+ mountShop(el, { ...options, words: { sendExactly: "Pay exactly", buyTitle: (look) => `Get ${look}` } });
1355
+ ```
1356
+
1357
+ 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`).
1358
+ 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
1359
+ (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
1360
+ **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
1361
+ the creator sends it back.
1314
1362
 
1315
1363
  ### The page's Content Security Policy
1316
1364
 
package/ai/SPEC.md CHANGED
@@ -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,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;
@@ -11,6 +11,8 @@
11
11
  //
12
12
  // The seed lives in local variables of one sign-in only: never in storage, a log, an error text or an address. The page remembers only the passkeys'
13
13
  // ids and the last way (./memo.ts).
14
+ //
15
+ // `words` changes any word of the sheet (./words.ts: PLAYER_SIGNIN_WORDS are the defaults), for a game's own words or language.
14
16
  import { firstUse, makePasskey, openPasskey, PasskeyError, passkeyFault, passkeySupport } from "@nano133/signin/passkey";
15
17
  import { ApiError, createApi } from "../../admin/panel/api.js";
16
18
  import { h, shortAddress } from "../../admin/panel/dom.js";
@@ -19,7 +21,7 @@ import { checkScheme } from "../scheme-check.js";
19
21
  import { previewHost } from "../host.js";
20
22
  import { WAYS } from "../types.js";
21
23
  import { readMemo, rememberLast, rememberMade, rememberOpened } from "./memo.js";
22
- import { faultWords } from "./words.js";
24
+ import { faultWords, playerSigninWords } from "./words.js";
23
25
  /** The wallets, with letters, not logos: no wallet's licence clearly allows its logo. */
24
26
  const WALLETS = {
25
27
  berry: { name: "Berry Wallet", mark: "B" },
@@ -59,6 +61,7 @@ export function signInView(options) {
59
61
  const passkeyOn = options.passkey ?? !previewHost(rpId);
60
62
  const scheme = passkeyOn ? checkScheme(options.scheme) : null;
61
63
  const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
64
+ const w = playerSigninWords(options.words);
62
65
  let stopped = false;
63
66
  let timer;
64
67
  let prompt = null;
@@ -77,7 +80,7 @@ export function signInView(options) {
77
80
  node.toggleAttribute("aria-busy", on);
78
81
  };
79
82
  const key = (text, onclick, kind = "plain") => h("button", { class: `n133s-btn n133s-${kind}`, type: "button", onclick }, text);
80
- const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, "Back"));
83
+ const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, w.back));
81
84
  const title = (text) => h("h3", { class: "n133s-step", tabindex: "-1" }, text);
82
85
  const focusFirst = () => (area.querySelector("h3, button:not([disabled])") ?? area).focus?.({ preventScroll: true });
83
86
  const setup = () => ({ rpId, rpName: options.name, scheme: scheme, userName: passkeyName(options.name), exclude: readMemo(rpId).ids });
@@ -94,7 +97,7 @@ export function signInView(options) {
94
97
  function done(player) {
95
98
  stopWaiting();
96
99
  current = null;
97
- area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, `Signed in as ${shortAddress(player.address)}`)));
100
+ area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, w.signedInAs(shortAddress(player.address)))));
98
101
  say("");
99
102
  options.onSignedIn?.(player);
100
103
  }
@@ -102,29 +105,29 @@ export function signInView(options) {
102
105
  function passkeyTile() {
103
106
  const off = support === "no";
104
107
  const last = readMemo(rpId).last === "passkey";
105
- return h("button", { class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() }, h("span", { class: "n133s-pk-mark" }, keyIcon()), h("span", { class: "n133s-pk-words" }, h("strong", {}, "Use a passkey"), h("small", {}, off ? "Not on this browser" : "Face ID, Touch ID or your device PIN")), last ? h("span", { class: "n133s-last" }, "Last used") : null);
108
+ return h("button", { class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() }, h("span", { class: "n133s-pk-mark" }, keyIcon()), h("span", { class: "n133s-pk-words" }, h("strong", {}, w.usePasskey), h("small", {}, off ? w.passkeyOff : w.passkeyHint)), last ? h("span", { class: "n133s-last" }, w.lastUsed) : null);
106
109
  }
107
110
  function walletTiles() {
108
111
  const last = readMemo(rpId).last;
109
- return h("div", { class: "n133s-tiles" }, ...ways.map((w) => h("button", { class: "n133s-tile", type: "button", onclick: () => choose(w) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[w].mark), h("span", { class: "n133s-name" }, WALLETS[w].name), last === w ? h("span", { class: "n133s-last" }, "Last used") : null)));
112
+ return h("div", { class: "n133s-tiles" }, ...ways.map((way) => h("button", { class: "n133s-tile", type: "button", onclick: () => choose(way) }, h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[way].mark), h("span", { class: "n133s-name" }, WALLETS[way].name), last === way ? h("span", { class: "n133s-last" }, w.lastUsed) : null)));
110
113
  }
111
114
  function showWays() {
112
115
  stopWaiting();
113
116
  busy(false);
114
117
  const pk = passkeyOn ? passkeyTile() : null;
115
118
  const first = pk && support !== "no";
116
- const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? "Or a wallet app" : "Choose your wallet"), walletTiles()] : [];
117
- fill(h("p", { class: "n133s-lead" }, "No password and no payment."), first ? pk : null, ...walletsPart, !first && pk ? h("p", { class: "n133s-or" }, "Or a passkey") : null, !first ? pk : null);
119
+ const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? w.orWallet : w.chooseWallet), walletTiles()] : [];
120
+ fill(h("p", { class: "n133s-lead" }, w.lead), first ? pk : null, ...walletsPart, !first && pk ? h("p", { class: "n133s-or" }, w.orPasskey) : null, !first ? pk : null);
118
121
  say("");
119
122
  }
120
123
  // ---- the passkey ----
121
124
  function showFault(e) {
122
125
  busy(false);
123
126
  const f = passkeyFault(e);
124
- const w = faultWords(f.code, rpId);
125
- if (w.next === "stay")
126
- return showPasskey(w.text);
127
- fill(backKey(), title(w.title), h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, w.text), ways.length ? h("p", { class: "n133s-or" }, "Sign in with a wallet app") : null, ways.length ? walletTiles() : null);
127
+ const fw = faultWords(f.code, rpId, w);
128
+ if (fw.next === "stay")
129
+ return showPasskey(fw.text);
130
+ fill(backKey(), title(fw.title), h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, fw.text), ways.length ? h("p", { class: "n133s-or" }, w.faultWallets) : null, ways.length ? walletTiles() : null);
128
131
  say("");
129
132
  focusFirst();
130
133
  }
@@ -140,15 +143,15 @@ export function signInView(options) {
140
143
  if (!hostFits(location.hostname, rpId))
141
144
  return showFault({ name: "SecurityError" });
142
145
  const known = readMemo(rpId).ids.length > 0;
143
- const use = key("Use my passkey", () => void usePasskey(), known ? "primary" : "plain");
144
- const make = key("Make a passkey", () => void makeNew(), known ? "plain" : "primary");
145
- area.replaceChildren(backKey(), title("Passkey"), h("p", { class: "n133s-text" }, "Face ID, Touch ID or your device PIN. The passkey stays on your device: the game never sees it."), h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use));
146
+ const use = key(w.useMine, () => void usePasskey(), known ? "primary" : "plain");
147
+ const make = key(w.makeOne, () => void makeNew(), known ? "plain" : "primary");
148
+ area.replaceChildren(backKey(), title(w.passkeyTitle), h("p", { class: "n133s-text" }, w.passkeyText), h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use));
146
149
  say(note ?? "");
147
150
  focusFirst();
148
151
  }
149
152
  async function usePasskey() {
150
153
  busy(true);
151
- say("Waiting for your passkey…");
154
+ say(w.waitingPasskey);
152
155
  prompt = new AbortController();
153
156
  try {
154
157
  const p = await openPasskey(setup(), prompt.signal);
@@ -163,7 +166,7 @@ export function signInView(options) {
163
166
  }
164
167
  async function makeNew() {
165
168
  busy(true);
166
- say("Making your passkey…");
169
+ say(w.makingPasskey);
167
170
  prompt = new AbortController();
168
171
  let made;
169
172
  try {
@@ -183,7 +186,7 @@ export function signInView(options) {
183
186
  /** The first use of a passkey just made. Where it gives nothing (a browser that allows one prompt per press, a closed sheet), one key finishes it. */
184
187
  async function firstOpen(made, pick) {
185
188
  busy(true);
186
- say("Opening your passkey…");
189
+ say(w.openingPasskey);
187
190
  prompt = new AbortController();
188
191
  try {
189
192
  const p = await firstUse(setup(), made, prompt.signal, pick);
@@ -198,7 +201,7 @@ export function signInView(options) {
198
201
  if (f.code !== "cancelled" && f.code !== "failed")
199
202
  return showFault(f);
200
203
  busy(false);
201
- area.replaceChildren(title("Your passkey is made"), h("p", { class: "n133s-text" }, "Press the key to open it."), h("div", { class: "n133s-keys" }, key("Open my passkey", () => void firstOpen(made, true), "primary")));
204
+ area.replaceChildren(title(w.madeTitle), h("p", { class: "n133s-text" }, w.madeText), h("div", { class: "n133s-keys" }, key(w.openMine, () => void firstOpen(made, true), "primary")));
202
205
  say("");
203
206
  focusFirst();
204
207
  }
@@ -210,25 +213,25 @@ export function signInView(options) {
210
213
  const code = h("p", { class: "n133s-code", translate: "no" });
211
214
  const codeBox = h("div", { class: "n133s-codebox" }, code);
212
215
  codeBox.hidden = true;
213
- const go = key("Later", () => resolve(), "quiet");
214
- const copyBtn = key("Copy the code", async () => {
216
+ const go = key(w.later, () => resolve(), "quiet");
217
+ const copyBtn = key(w.copyCode, async () => {
215
218
  try {
216
219
  await navigator.clipboard.writeText(seed);
217
- copyBtn.textContent = "Copied";
220
+ copyBtn.textContent = w.copied;
218
221
  }
219
222
  catch {
220
- copyBtn.textContent = "Copy failed: write it down";
223
+ copyBtn.textContent = w.copyFailed;
221
224
  }
222
225
  });
223
226
  codeBox.append(copyBtn);
224
- const show = key("Show my code", () => {
227
+ const show = key(w.showCode, () => {
225
228
  code.textContent = seed.match(/.{1,16}/g).join("\n");
226
229
  codeBox.hidden = false;
227
230
  show.remove();
228
- go.textContent = "Continue";
231
+ go.textContent = w.continue;
229
232
  go.className = "n133s-btn n133s-primary";
230
233
  }, "primary");
231
- area.replaceChildren(title("Keep a spare key"), h("p", { class: "n133s-text" }, "Your looks belong to this passkey. If you lose it, this code brings them back."), codeBox, h("p", { class: "n133s-fine" }, "Keep it secret: whoever has it can sign in as you. To use it, import it as a seed in a Nano wallet such as Nautilus, then sign in with that wallet."), h("div", { class: "n133s-keys" }, show, go));
234
+ area.replaceChildren(title(w.spareTitle), h("p", { class: "n133s-text" }, w.spareText), codeBox, h("p", { class: "n133s-fine" }, w.spareFine), h("div", { class: "n133s-keys" }, show, go));
232
235
  say("");
233
236
  focusFirst();
234
237
  });
@@ -240,7 +243,7 @@ export function signInView(options) {
240
243
  if (stopped)
241
244
  return;
242
245
  busy(true);
243
- say("Signing in…");
246
+ say(w.signingIn);
244
247
  try {
245
248
  const { signinBody } = await import("@nano133/signin/purse");
246
249
  const turnstile = await personToken();
@@ -251,7 +254,7 @@ export function signInView(options) {
251
254
  }
252
255
  catch (e) {
253
256
  if (!stopped)
254
- showPasskey(e instanceof ApiError ? e.message : "Sign-in did not work. Try again.");
257
+ showPasskey(e instanceof ApiError ? w.refusal(e.message, e.status) : w.signInFailed);
255
258
  }
256
259
  }
257
260
  // ---- a wallet app ----
@@ -261,7 +264,7 @@ export function signInView(options) {
261
264
  stopWaiting();
262
265
  current = null;
263
266
  busy(true);
264
- say("Starting…");
267
+ say(w.starting);
265
268
  try {
266
269
  const turnstile = await personToken();
267
270
  const s = await api.post(`${base}/start`, { way, ...(turnstile ? { turnstile } : {}) });
@@ -275,13 +278,13 @@ export function signInView(options) {
275
278
  catch (e) {
276
279
  busy(false);
277
280
  showWays();
278
- say(e instanceof Error ? e.message : "Sign-in did not start. Try again.");
281
+ say(e instanceof ApiError ? w.refusal(e.message, e.status) : e instanceof Error && e.message ? w.refusal(e.message, 0) : w.startFailed);
279
282
  }
280
283
  }
281
284
  function showWallet(s) {
282
285
  const name = WALLETS[s.way].name;
283
286
  area.replaceChildren(backKey(), title(name), ...walletSteps(s, name));
284
- say(`Waiting for ${name}…`);
287
+ say(w.waitingFor(name));
285
288
  focusFirst();
286
289
  }
287
290
  /** What the player does with the wallet: on a touch screen, one key; with a mouse, a QR code for the phone. */
@@ -289,36 +292,36 @@ export function signInView(options) {
289
292
  const open = (text, href, newTab = false) => h("a", { class: "n133s-btn n133s-primary", href, target: newTab ? "_blank" : undefined, rel: newTab ? "noopener" : undefined }, text);
290
293
  const qr = (text) => {
291
294
  if (!options.qr)
292
- return h("p", { class: "n133s-fine" }, "Open this page on your phone to sign in with ", name, ".");
295
+ return h("p", { class: "n133s-fine" }, w.openOnPhone(name));
293
296
  const c = options.qr(text);
294
- c.setAttribute("aria-label", `QR code to sign in with ${name}`);
297
+ c.setAttribute("aria-label", w.qrLabel(name));
295
298
  return h("div", { class: "n133s-qr" }, c);
296
299
  };
297
- const only = h("p", { class: "n133s-fine" }, `Only approve a sign-in that you started yourself, on ${location.host}.`);
298
- const comeBack = h("p", { class: "n133s-fine" }, `Approve the sign-in in ${s.way === "berry" ? "your wallet" : name}, then come back to this page.`);
300
+ const only = h("p", { class: "n133s-fine" }, w.onlyYours(location.host));
301
+ const comeBack = h("p", { class: "n133s-fine" }, w.comeBack(s.way === "berry" ? w.yourWallet : name));
299
302
  if (s.way === "nautilus") {
300
- const code = h("p", { class: "n133s-text" }, "Check that Nautilus shows the code ", h("strong", { class: "n133s-signcode" }, s.code ?? ""), ".");
301
- return touch() ? [code, open("Open Nautilus", s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with Nautilus on your phone, then approve the sign-in."), code, only];
303
+ const code = h("p", { class: "n133s-text" }, w.checkCode, h("strong", { class: "n133s-signcode" }, s.code ?? ""));
304
+ return touch() ? [code, open(w.openApp(name), s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), code, only];
302
305
  }
303
306
  if (s.way === "nanchat") {
304
- const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.link, target: "_blank", rel: "noopener" }, "open NanChat"), ".");
305
- return touch() ? [open("Open NanChat", s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with your phone's camera to open NanChat, then approve the sign-in."), only, here];
307
+ const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.link, target: "_blank", rel: "noopener" }, w.openLink(name)));
308
+ return touch() ? [open(w.openApp(name), s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanCamera(name)), only, here];
306
309
  }
307
- const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, "Another wallet");
308
- const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.walletLink ?? s.link }, "open a wallet"), ".");
309
- return touch() ? [open("Open Berry Wallet", s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, "Scan this with Berry Wallet on your phone, then approve the sign-in."), only, here];
310
+ const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, w.anotherWallet);
311
+ const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.walletLink ?? s.link }, w.openLink(w.aWallet)));
312
+ return touch() ? [open(w.openApp(name), s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), only, here];
310
313
  }
311
314
  /** The request ended without a sign-in: say why, and offer a new one. */
312
315
  function again(s, words) {
313
316
  current = null;
314
- area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key("Try again", () => void choose(s.way), "primary"), key("Choose another way", () => showWays())));
317
+ area.replaceChildren(backKey(), title(WALLETS[s.way].name), h("div", { class: "n133s-keys" }, key(w.tryAgain, () => void choose(s.way), "primary"), key(w.otherWay, () => showWays())));
315
318
  say(words);
316
319
  }
317
320
  async function poll(s) {
318
321
  if (stopped || current !== s)
319
322
  return;
320
323
  if (Date.now() > s.expiresAt + 5000)
321
- return again(s, "This sign-in expired.");
324
+ return again(s, w.expired);
322
325
  try {
323
326
  const r = await api.post(`${base}/check`, { id: s.id, way: s.way });
324
327
  if (stopped || current !== s)
@@ -326,13 +329,13 @@ export function signInView(options) {
326
329
  if (r.status === "signedin" && r.address)
327
330
  return done({ address: r.address, via: "wallet" });
328
331
  if (r.status === "elsewhere" || r.status === "used")
329
- return again(s, "This sign-in was used in another browser. Start again.");
332
+ return again(s, w.usedElsewhere);
330
333
  if (r.status === "expired")
331
- return again(s, "This sign-in expired.");
334
+ return again(s, w.expired);
332
335
  }
333
336
  catch (e) {
334
337
  if (e instanceof ApiError && e.status === 429)
335
- say("Too many checks. Waiting a moment…");
338
+ say(w.tooManyChecks);
336
339
  }
337
340
  timer = setTimeout(() => void poll(s), 2000);
338
341
  }
@@ -5,4 +5,99 @@ export type FaultWords = {
5
5
  text: string;
6
6
  next: "stay" | "wallets";
7
7
  };
8
- export declare function faultWords(code: PasskeyFault, rpId: string): FaultWords;
8
+ /** A fault's words: its title, and its text (a function when it names the game's domain, `rpId`). */
9
+ export type FaultText = {
10
+ title: string;
11
+ text: string | ((rpId: string) => string);
12
+ };
13
+ export type PlayerSigninWords = {
14
+ /** The sheet's title. The option `title` wins over it. */
15
+ title: string;
16
+ /** The close key's name for a screen reader. */
17
+ close: string;
18
+ /** The first line of the ways. */
19
+ lead: string;
20
+ usePasskey: string;
21
+ /** Under "Use a passkey". */
22
+ passkeyHint: string;
23
+ /** Under "Use a passkey" where the browser reports no passkey PRF. */
24
+ passkeyOff: string;
25
+ lastUsed: string;
26
+ /** Before the wallet tiles, after the passkey. */
27
+ orWallet: string;
28
+ /** Before the wallet tiles, with no passkey before them. */
29
+ chooseWallet: string;
30
+ /** Before the passkey, when it comes last. */
31
+ orPasskey: string;
32
+ back: string;
33
+ passkeyTitle: string;
34
+ passkeyText: string;
35
+ useMine: string;
36
+ makeOne: string;
37
+ waitingPasskey: string;
38
+ makingPasskey: string;
39
+ openingPasskey: string;
40
+ /** A passkey just made, whose first use gave nothing: one key opens it. */
41
+ madeTitle: string;
42
+ madeText: string;
43
+ openMine: string;
44
+ spareTitle: string;
45
+ spareText: string;
46
+ /** Under the code: keep it secret; how to use it. */
47
+ spareFine: string;
48
+ showCode: string;
49
+ copyCode: string;
50
+ copied: string;
51
+ copyFailed: string;
52
+ later: string;
53
+ continue: string;
54
+ signingIn: string;
55
+ signInFailed: string;
56
+ /** After a fault that offers the wallet apps. */
57
+ faultWallets: string;
58
+ faults: Record<PasskeyFault, FaultText>;
59
+ starting: string;
60
+ startFailed: string;
61
+ /** The words of a refusal: the server's own (`text`, in English) and its HTTP status (0: no answer). Default: the server's words. */
62
+ refusal: (text: string, status: number) => string;
63
+ /** The status while the page waits for a wallet ("Berry Wallet"). */
64
+ waitingFor: (wallet: string) => string;
65
+ /** After a sign-in, with the player's short address. */
66
+ signedInAs: (address: string) => string;
67
+ /** The key that opens a wallet app on a touch screen. */
68
+ openApp: (wallet: string) => string;
69
+ anotherWallet: string;
70
+ /** Before a link that opens the wallet on this computer. */
71
+ onComputer: string;
72
+ /** The link's words: open a wallet ("NanChat", or `aWallet`). */
73
+ openLink: (wallet: string) => string;
74
+ aWallet: string;
75
+ /** With no QR hook, on a screen with a mouse. */
76
+ openOnPhone: (wallet: string) => string;
77
+ qrLabel: (wallet: string) => string;
78
+ /** Only approve a sign-in the player started, on this host. */
79
+ onlyYours: (host: string) => string;
80
+ /** After the key that opens the app (`yourWallet` for Berry Wallet, which opens any wallet). */
81
+ comeBack: (wallet: string) => string;
82
+ yourWallet: string;
83
+ /** Under a QR code: scan it with this wallet. */
84
+ scanWith: (wallet: string) => string;
85
+ /** Under NanChat's QR code: the phone's camera opens it. */
86
+ scanCamera: (wallet: string) => string;
87
+ /** Before Nautilus's code (the code follows, bold). */
88
+ checkCode: string;
89
+ tryAgain: string;
90
+ otherWay: string;
91
+ expired: string;
92
+ usedElsewhere: string;
93
+ tooManyChecks: string;
94
+ };
95
+ /** `words` as a game gives it: any of the keys, and any of the faults. */
96
+ export type PlayerSigninWordsOption = Partial<Omit<PlayerSigninWords, "faults">> & {
97
+ faults?: Partial<Record<PasskeyFault, FaultText>>;
98
+ };
99
+ export declare const PLAYER_SIGNIN_WORDS: PlayerSigninWords;
100
+ /** The game's words over the defaults; its faults over the default faults. A key it leaves out, or gives as undefined, keeps its default. */
101
+ export declare function playerSigninWords(over?: PlayerSigninWordsOption): PlayerSigninWords;
102
+ /** A fault's title, text and next step, in the game's words (default: PLAYER_SIGNIN_WORDS). An unknown code gets the words of "failed". */
103
+ export declare function faultWords(code: PasskeyFault, rpId: string, words?: PlayerSigninWordsOption): FaultWords;