@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
@@ -1,7 +1,7 @@
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
3
 
4
- import { isRawText, xnoText } from "./amount.js";
4
+ import { isRawText, usdDecimals, xnoText } from "./amount.js";
5
5
  import type { AdminOrder, Money, StoreSummary } from "./store.js";
6
6
 
7
7
  /** An order as an adapter reads it: the amount is the raw amount (digits), the times are ms. */
@@ -21,21 +21,21 @@ export type OrderRow = {
21
21
 
22
22
  export const ORDERS_SHOWN = 500;
23
23
 
24
- /** 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. */
25
- const toCents = (usd: string): number => {
24
+ /** 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. */
25
+ const toSteps = (usd: string): number => {
26
26
  if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd)) return 0;
27
- return Math.round(Number(usd) * 100);
27
+ return Math.round(Number(usd) * 10_000);
28
28
  };
29
29
 
30
- /** 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). */
30
+ /** 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). */
31
31
  export function moneyOf(rows: readonly { amountRaw: string; usd: string }[]): Money {
32
32
  let raw = 0n;
33
- let cents = 0;
33
+ let steps = 0;
34
34
  for (const r of rows) {
35
35
  if (isRawText(r.amountRaw)) raw += BigInt(r.amountRaw);
36
- cents += toCents(r.usd);
36
+ steps += toSteps(r.usd);
37
37
  }
38
- return { count: rows.length, xno: xnoText(raw.toString()), usd: (cents / 100).toFixed(2) };
38
+ return { count: rows.length, xno: xnoText(raw.toString()), usd: usdDecimals(steps / 10_000) };
39
39
  }
40
40
 
41
41
  /** Where an order counts: an open order past its expiry counts as expired. */
@@ -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,5 +1,7 @@
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
+
4
+ import { usdText } from "./look.js";
3
5
 
4
6
  export type Rung = {
5
7
  name: "dye" | "charm" | "piece" | "statement" | "set" | "outfit";
@@ -26,10 +28,9 @@ export function rungOf(usd: number): Rung | undefined {
26
28
  return PRICE_LADDER.find((r) => Math.abs(rungUsd(r) - usd) < 1e-9);
27
29
  }
28
30
 
29
- /** A price as a page writes it: 1¢, 25¢, $1, $1.10. */
31
+ /** A price as a page writes it: 0.5¢, 1¢, 25¢, $1, $1.10, $1.005. */
30
32
  export function priceText(usd: number): string {
31
- const cents = Math.round(usd * 100);
32
- if (cents < 100) return `${cents}¢`;
33
- const dollars = cents / 100;
34
- return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`;
33
+ const steps = Math.round(usd * 10_000);
34
+ if (steps < 10_000) return `${steps / 100}¢`;
35
+ return steps % 10_000 === 0 ? `$${steps / 10_000}` : usdText(steps / 10_000);
35
36
  }
package/src/looks/look.ts CHANGED
@@ -17,7 +17,7 @@ export type Look = {
17
17
  id: string;
18
18
  /** What the player reads, 1 to 40 characters. */
19
19
  name: string;
20
- /** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
20
+ /** The price in US dollars with at most 4 decimals, for example 0.25 or 0.005. 0 means free for everyone. */
21
21
  priceUsd: number;
22
22
  /**
23
23
  * 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
@@ -73,6 +73,14 @@ export function wholeCents(usd: number): boolean {
73
73
  return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(2)) === usd;
74
74
  }
75
75
 
76
+ /** 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. */
77
+ export const PRICE_STEP_USD = 0.0001;
78
+
79
+ /** 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. */
80
+ export function priceStepOk(usd: number): boolean {
81
+ return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(4)) === usd;
82
+ }
83
+
76
84
  /** 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. */
77
85
  export function lookProblems(raw: unknown): Problem[] {
78
86
  const out: Problem[] = [];
@@ -88,7 +96,7 @@ export function lookProblems(raw: unknown): Problem[] {
88
96
  else seen.add(v.id);
89
97
  if (typeof v.name !== "string" || !v.name.trim() || v.name.length > NAME_MAX) add("name", '"name" must be 1 to 40 characters');
90
98
  if (typeof v.priceUsd !== "number" || !Number.isFinite(v.priceUsd) || v.priceUsd < 0 || v.priceUsd > PRICE_MAX_USD) add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
91
- else if (!wholeCents(v.priceUsd)) add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
99
+ else if (!priceStepOk(v.priceUsd)) add("priceUsd", '"priceUsd" can have at most 4 decimals (0.0001 is the smallest price above 0)');
92
100
  const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
93
101
  if (!wearOk) 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")`);
94
102
  const outfit = v.wear === undefined || v.wear === OUTFIT;
@@ -160,7 +168,8 @@ export function checkLooks(rawInput: unknown): Look[] {
160
168
  }
161
169
 
162
170
  /** "$0.25" */
163
- export const usdText = (usd: number): string => `$${usd.toFixed(2)}`;
171
+ /** "$0.25", "$1.00", "$0.005"; and the same without "$", as an order keeps it ("0.005"). The money code's own helpers. */
172
+ export { usdDecimals, usdText } from "../admin/amount.js";
164
173
 
165
174
  /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
166
175
  export const wearOf = (l: Pick<Look, "wear">): string => l.wear ?? OUTFIT;
@@ -1,12 +1,13 @@
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
 
4
5
  export { mountPlayerSignin } from "./mount.js";
5
6
  export type { MountedPlayerSignin, PlayerSigninMountOptions } from "./mount.js";
6
7
  export { hostFits, passkeyName, signInView } from "./view.js";
7
8
  export { PASSKEY_PREVIEW_NOTE, previewHost } from "../host.js";
8
9
  export type { PlayerSigninView, PlayerSigninViewOptions, SignedIn } from "./view.js";
9
- export { faultWords } from "./words.js";
10
- export type { FaultWords } from "./words.js";
10
+ export { PLAYER_SIGNIN_WORDS, faultWords, playerSigninWords } from "./words.js";
11
+ export type { FaultText, FaultWords, PlayerSigninWords, PlayerSigninWordsOption } from "./words.js";
11
12
  export { readMemo } from "./memo.js";
12
13
  export type { SigninMemo } from "./memo.js";
@@ -1,14 +1,15 @@
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
 
6
6
  import { h, releaseFocus } from "../../admin/panel/dom.js";
7
7
  import { icon } from "../../admin/panel/icons.js";
8
8
  import { signInView, type PlayerSigninViewOptions, type SignedIn } from "./view.js";
9
+ import { playerSigninWords } from "./words.js";
9
10
 
10
11
  export type PlayerSigninMountOptions = PlayerSigninViewOptions & {
11
- /** The sheet's title. Default "Sign in". */
12
+ /** The sheet's title. Default: `words.title` ("Sign in"). */
12
13
  title?: string;
13
14
  /** How long "Signed in as …" shows before the sheet closes, in ms. Default 900. */
14
15
  closeAfterMs?: number;
@@ -36,6 +37,7 @@ export function mountPlayerSignin(root: HTMLElement, options: PlayerSigninMountO
36
37
  let pending: { promise: Promise<SignedIn | null>; resolve: (p: SignedIn | null) => void } | null = null;
37
38
  let before: Element | null = null;
38
39
  let closing: ReturnType<typeof setTimeout> | undefined;
40
+ const words = playerSigninWords(options.words);
39
41
 
40
42
  const settle = (p: SignedIn | null) => {
41
43
  const was = pending;
@@ -52,8 +54,8 @@ export function mountPlayerSignin(root: HTMLElement, options: PlayerSigninMountO
52
54
  },
53
55
  });
54
56
 
55
- const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ?? "Sign in");
56
- const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": "Close" }, icon("close", 20));
57
+ const titleEl = h("h2", { class: "n133s-title", id: `n133s-title-${n}` }, options.title ?? words.title);
58
+ const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
57
59
  const scrim = h("div", { class: "n133s-scrim" });
58
60
  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));
59
61
  const wrap = h("div", { class: "n133-signin n133s-sheet", "data-open": "false" }, scrim, panel);
@@ -1,6 +1,6 @@
1
1
  /* The player's sign-in sheet (mountPlayerSignin in ./mount.ts) and its content (signInView in ./view.ts). Every rule is under .n133-signin, so nothing
2
2
  leaks into the game's page.
3
- Phone first: every key is 44 px or more and its words 16 px. On a phone held upright the sheet comes from the bottom; on a wide or sideways screen it
3
+ Phone first: text is 16 px, and every key, tile, link, close key and back key is 48 px or more each way. On a phone held upright the sheet comes from the bottom; on a wide or sideways screen it
4
4
  is a card in the middle. A scrim covers the game while it is open.
5
5
  Light and dark: the sheet follows data-n133-theme="light" | "dark" on the page's <html> when the page sets it, else the device's prefers-color-scheme.
6
6
  To theme it, set the --n133-* custom properties on the element that holds the sheet (or a parent): --n133-ink, --n133-ink-soft, --n133-paper,
@@ -129,8 +129,8 @@
129
129
  .n133-signin .n133s-close {
130
130
  display: inline-grid;
131
131
  place-items: center;
132
- width: 44px;
133
- height: 44px;
132
+ width: 48px;
133
+ height: 48px;
134
134
  border: 0;
135
135
  border-radius: 50%;
136
136
  background: transparent;
@@ -170,12 +170,12 @@
170
170
 
171
171
  .n133-signin .n133s-fine {
172
172
  margin: 0;
173
- font-size: 14px;
173
+ font-size: 16px;
174
174
  }
175
175
 
176
176
  .n133-signin .n133s-or {
177
177
  margin: 6px 0 -4px;
178
- font: 650 14px/1.3 var(--font);
178
+ font: 650 16px/1.3 var(--font);
179
179
  }
180
180
 
181
181
  .n133-signin .n133s-step {
@@ -251,7 +251,7 @@
251
251
  }
252
252
 
253
253
  .n133-signin .n133s-pk-words small {
254
- font-size: 14px;
254
+ font-size: 16px;
255
255
  opacity: 0.92;
256
256
  }
257
257
 
@@ -292,26 +292,26 @@
292
292
  border-radius: 12px;
293
293
  background: var(--ground);
294
294
  color: var(--ink);
295
- font: 800 15px/1 var(--font);
295
+ font: 800 16px/1 var(--font);
296
296
  }
297
297
 
298
298
  .n133-signin .n133s-name {
299
- font: 650 15px/1.2 var(--font);
299
+ font: 650 16px/1.2 var(--font);
300
300
  text-align: center;
301
301
  }
302
302
 
303
303
  .n133-signin .n133s-last {
304
- position: absolute;
305
- top: 6px;
306
- right: 8px;
307
- padding: 1px 7px;
304
+ padding: 0 8px;
308
305
  border-radius: 999px;
309
306
  background: var(--ground);
310
307
  color: var(--ink-soft);
311
- font: 700 11px/18px var(--font);
308
+ font: 700 16px/24px var(--font);
309
+ white-space: nowrap;
312
310
  }
313
311
 
312
+ /* "Last used": in the flow, so it never covers a word at 16 px: under the name in a tile, at the right end of the passkey's row. */
314
313
  .n133-signin .n133s-pk .n133s-last {
314
+ margin-left: auto;
315
315
  background: rgb(255 255 255 / 0.22);
316
316
  color: inherit;
317
317
  }
@@ -376,14 +376,15 @@
376
376
  align-self: flex-start;
377
377
  align-items: center;
378
378
  gap: 6px;
379
- min-height: 44px;
379
+ min-width: 48px;
380
+ min-height: 48px;
380
381
  margin: 0 0 -4px -10px;
381
- padding: 0 10px;
382
+ padding: 0 12px 0 10px;
382
383
  border: 0;
383
384
  border-radius: 10px;
384
385
  background: transparent;
385
386
  color: var(--ink-soft);
386
- font: 650 15px/1 var(--font);
387
+ font: 650 16px/1 var(--font);
387
388
  cursor: pointer;
388
389
  }
389
390
 
@@ -393,6 +394,10 @@
393
394
  }
394
395
 
395
396
  .n133-signin a:not(.n133s-btn) {
397
+ display: inline-flex;
398
+ align-items: center;
399
+ min-width: 48px;
400
+ min-height: 48px;
396
401
  color: var(--gem-dark);
397
402
  font-weight: 650;
398
403
  text-underline-offset: 2px;
@@ -443,7 +448,7 @@
443
448
  min-height: 1.4em;
444
449
  margin: 12px 0 0;
445
450
  color: var(--ink-soft);
446
- font-size: 15px;
451
+ font-size: 16px;
447
452
  }
448
453
 
449
454
  .n133-signin .n133s-status:empty {
@@ -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
 
15
17
  import { firstUse, makePasskey, openPasskey, PasskeyError, passkeyFault, passkeySupport, type NewPasskey, type PasskeyScheme, type PasskeySetup } from "@nano133/signin/passkey";
16
18
  import { ApiError, createApi } from "../../admin/panel/api.js";
@@ -20,7 +22,7 @@ import { checkScheme } from "../scheme-check.js";
20
22
  import { previewHost } from "../host.js";
21
23
  import { WAYS, type Way } from "../types.js";
22
24
  import { readMemo, rememberLast, rememberMade, rememberOpened } from "./memo.js";
23
- import { faultWords } from "./words.js";
25
+ import { faultWords, playerSigninWords, type PlayerSigninWordsOption } from "./words.js";
24
26
 
25
27
  export type SignedIn = { address: string; via: "wallet" | "passkey" };
26
28
 
@@ -50,6 +52,8 @@ export type PlayerSigninViewOptions = {
50
52
  fetch?: typeof fetch;
51
53
  /** A touch screen (one key that opens the app) or not (a QR code). Default: `(pointer: coarse)`. */
52
54
  touch?: boolean;
55
+ /** The game's words for the sheet, over the defaults (PLAYER_SIGNIN_WORDS): `{ lead: "No password.", faults: { cancelled: { ... } } }`. */
56
+ words?: PlayerSigninWordsOption;
53
57
  };
54
58
 
55
59
  export type PlayerSigninView = {
@@ -102,6 +106,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
102
106
  const passkeyOn = options.passkey ?? !previewHost(rpId);
103
107
  const scheme = passkeyOn ? checkScheme(options.scheme as PasskeyScheme) : null;
104
108
  const touch = () => options.touch ?? (typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
109
+ const w = playerSigninWords(options.words);
105
110
 
106
111
  let stopped = false;
107
112
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -122,7 +127,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
122
127
  node.toggleAttribute("aria-busy", on);
123
128
  };
124
129
  const key = (text: string, onclick: () => void, kind: "primary" | "plain" | "quiet" = "plain") => h("button", { class: `n133s-btn n133s-${kind}`, type: "button", onclick }, text);
125
- const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, "Back"));
130
+ const backKey = () => h("button", { class: "n133s-back", type: "button", onclick: () => showWays() }, icon("back", 18), h("span", {}, w.back));
126
131
  const title = (text: string) => h("h3", { class: "n133s-step", tabindex: "-1" }, text);
127
132
  const focusFirst = () => (area.querySelector<HTMLElement>("h3, button:not([disabled])") ?? area).focus?.({ preventScroll: true });
128
133
  const setup = (): PasskeySetup => ({ rpId, rpName: options.name, scheme: scheme!, userName: passkeyName(options.name), exclude: readMemo(rpId).ids });
@@ -140,7 +145,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
140
145
  function done(player: SignedIn) {
141
146
  stopWaiting();
142
147
  current = null;
143
- area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, `Signed in as ${shortAddress(player.address)}`)));
148
+ area.replaceChildren(h("p", { class: "n133s-done" }, icon("check", 22), h("span", {}, w.signedInAs(shortAddress(player.address)))));
144
149
  say("");
145
150
  options.onSignedIn?.(player);
146
151
  }
@@ -154,8 +159,8 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
154
159
  "button",
155
160
  { class: `n133s-pk${off ? " is-off" : ""}`, type: "button", onclick: () => showPasskey() },
156
161
  h("span", { class: "n133s-pk-mark" }, keyIcon()),
157
- 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")),
158
- last ? h("span", { class: "n133s-last" }, "Last used") : null,
162
+ h("span", { class: "n133s-pk-words" }, h("strong", {}, w.usePasskey), h("small", {}, off ? w.passkeyOff : w.passkeyHint)),
163
+ last ? h("span", { class: "n133s-last" }, w.lastUsed) : null,
159
164
  );
160
165
  }
161
166
 
@@ -164,13 +169,13 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
164
169
  return h(
165
170
  "div",
166
171
  { class: "n133s-tiles" },
167
- ...ways.map((w) =>
172
+ ...ways.map((way) =>
168
173
  h(
169
174
  "button",
170
- { class: "n133s-tile", type: "button", onclick: () => choose(w) },
171
- h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[w].mark),
172
- h("span", { class: "n133s-name" }, WALLETS[w].name),
173
- last === w ? h("span", { class: "n133s-last" }, "Last used") : null,
175
+ { class: "n133s-tile", type: "button", onclick: () => choose(way) },
176
+ h("span", { class: "n133s-mark", "aria-hidden": "true" }, WALLETS[way].mark),
177
+ h("span", { class: "n133s-name" }, WALLETS[way].name),
178
+ last === way ? h("span", { class: "n133s-last" }, w.lastUsed) : null,
174
179
  ),
175
180
  ),
176
181
  );
@@ -181,8 +186,8 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
181
186
  busy(false);
182
187
  const pk = passkeyOn ? passkeyTile() : null;
183
188
  const first = pk && support !== "no";
184
- const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? "Or a wallet app" : "Choose your wallet"), walletTiles()] : [];
185
- 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);
189
+ const walletsPart = ways.length ? [h("p", { class: "n133s-or" }, pk ? w.orWallet : w.chooseWallet), walletTiles()] : [];
190
+ 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);
186
191
  say("");
187
192
  }
188
193
 
@@ -191,13 +196,13 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
191
196
  function showFault(e: unknown): void {
192
197
  busy(false);
193
198
  const f = passkeyFault(e);
194
- const w = faultWords(f.code, rpId);
195
- if (w.next === "stay") return showPasskey(w.text);
199
+ const fw = faultWords(f.code, rpId, w);
200
+ if (fw.next === "stay") return showPasskey(fw.text);
196
201
  fill(
197
202
  backKey(),
198
- title(w.title),
199
- h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, w.text),
200
- ways.length ? h("p", { class: "n133s-or" }, "Sign in with a wallet app") : null,
203
+ title(fw.title),
204
+ h("p", { class: "n133s-text n133s-fault", "data-fault": f.code }, fw.text),
205
+ ways.length ? h("p", { class: "n133s-or" }, w.faultWallets) : null,
201
206
  ways.length ? walletTiles() : null,
202
207
  );
203
208
  say("");
@@ -212,12 +217,12 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
212
217
  if (support === "no") return showFault(new PasskeyError("no-prf"));
213
218
  if (!hostFits(location.hostname, rpId)) return showFault({ name: "SecurityError" });
214
219
  const known = readMemo(rpId).ids.length > 0;
215
- const use = key("Use my passkey", () => void usePasskey(), known ? "primary" : "plain");
216
- const make = key("Make a passkey", () => void makeNew(), known ? "plain" : "primary");
220
+ const use = key(w.useMine, () => void usePasskey(), known ? "primary" : "plain");
221
+ const make = key(w.makeOne, () => void makeNew(), known ? "plain" : "primary");
217
222
  area.replaceChildren(
218
223
  backKey(),
219
- title("Passkey"),
220
- h("p", { class: "n133s-text" }, "Face ID, Touch ID or your device PIN. The passkey stays on your device: the game never sees it."),
224
+ title(w.passkeyTitle),
225
+ h("p", { class: "n133s-text" }, w.passkeyText),
221
226
  h("div", { class: "n133s-keys" }, known ? use : make, known ? make : use),
222
227
  );
223
228
  say(note ?? "");
@@ -226,7 +231,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
226
231
 
227
232
  async function usePasskey() {
228
233
  busy(true);
229
- say("Waiting for your passkey…");
234
+ say(w.waitingPasskey);
230
235
  prompt = new AbortController();
231
236
  try {
232
237
  const p = await openPasskey(setup(), prompt.signal);
@@ -239,7 +244,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
239
244
 
240
245
  async function makeNew() {
241
246
  busy(true);
242
- say("Making your passkey…");
247
+ say(w.makingPasskey);
243
248
  prompt = new AbortController();
244
249
  let made: NewPasskey;
245
250
  try {
@@ -257,7 +262,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
257
262
  /** 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. */
258
263
  async function firstOpen(made: NewPasskey, pick: boolean) {
259
264
  busy(true);
260
- say("Opening your passkey…");
265
+ say(w.openingPasskey);
261
266
  prompt = new AbortController();
262
267
  try {
263
268
  const p = await firstUse(setup(), made, prompt.signal, pick);
@@ -268,7 +273,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
268
273
  const f = passkeyFault(e);
269
274
  if (f.code !== "cancelled" && f.code !== "failed") return showFault(f);
270
275
  busy(false);
271
- 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")));
276
+ 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")));
272
277
  say("");
273
278
  focusFirst();
274
279
  }
@@ -281,28 +286,28 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
281
286
  const code = h("p", { class: "n133s-code", translate: "no" });
282
287
  const codeBox = h("div", { class: "n133s-codebox" }, code);
283
288
  codeBox.hidden = true;
284
- const go = key("Later", () => resolve(), "quiet");
285
- const copyBtn = key("Copy the code", async () => {
289
+ const go = key(w.later, () => resolve(), "quiet");
290
+ const copyBtn = key(w.copyCode, async () => {
286
291
  try {
287
292
  await navigator.clipboard.writeText(seed);
288
- copyBtn.textContent = "Copied";
293
+ copyBtn.textContent = w.copied;
289
294
  } catch {
290
- copyBtn.textContent = "Copy failed: write it down";
295
+ copyBtn.textContent = w.copyFailed;
291
296
  }
292
297
  });
293
298
  codeBox.append(copyBtn);
294
- const show = key("Show my code", () => {
299
+ const show = key(w.showCode, () => {
295
300
  code.textContent = seed.match(/.{1,16}/g)!.join("\n");
296
301
  codeBox.hidden = false;
297
302
  show.remove();
298
- go.textContent = "Continue";
303
+ go.textContent = w.continue;
299
304
  go.className = "n133s-btn n133s-primary";
300
305
  }, "primary");
301
306
  area.replaceChildren(
302
- title("Keep a spare key"),
303
- h("p", { class: "n133s-text" }, "Your looks belong to this passkey. If you lose it, this code brings them back."),
307
+ title(w.spareTitle),
308
+ h("p", { class: "n133s-text" }, w.spareText),
304
309
  codeBox,
305
- 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."),
310
+ h("p", { class: "n133s-fine" }, w.spareFine),
306
311
  h("div", { class: "n133s-keys" }, show, go),
307
312
  );
308
313
  say("");
@@ -315,7 +320,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
315
320
  if (fresh && options.spareKey !== false) await spareKey(seed);
316
321
  if (stopped) return;
317
322
  busy(true);
318
- say("Signing in…");
323
+ say(w.signingIn);
319
324
  try {
320
325
  const { signinBody } = await import("@nano133/signin/purse");
321
326
  const turnstile = await personToken();
@@ -323,7 +328,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
323
328
  rememberOpened(rpId, credentialId);
324
329
  if (!stopped) done({ address: r.address, via: "passkey" });
325
330
  } catch (e) {
326
- if (!stopped) showPasskey(e instanceof ApiError ? e.message : "Sign-in did not work. Try again.");
331
+ if (!stopped) showPasskey(e instanceof ApiError ? w.refusal(e.message, e.status) : w.signInFailed);
327
332
  }
328
333
  }
329
334
 
@@ -334,7 +339,7 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
334
339
  stopWaiting();
335
340
  current = null;
336
341
  busy(true);
337
- say("Starting…");
342
+ say(w.starting);
338
343
  try {
339
344
  const turnstile = await personToken();
340
345
  const s = await api.post<Start>(`${base}/start`, { way, ...(turnstile ? { turnstile } : {}) });
@@ -346,14 +351,14 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
346
351
  } catch (e) {
347
352
  busy(false);
348
353
  showWays();
349
- say(e instanceof Error ? e.message : "Sign-in did not start. Try again.");
354
+ say(e instanceof ApiError ? w.refusal(e.message, e.status) : e instanceof Error && e.message ? w.refusal(e.message, 0) : w.startFailed);
350
355
  }
351
356
  }
352
357
 
353
358
  function showWallet(s: Start) {
354
359
  const name = WALLETS[s.way].name;
355
360
  area.replaceChildren(backKey(), title(name), ...walletSteps(s, name));
356
- say(`Waiting for ${name}…`);
361
+ say(w.waitingFor(name));
357
362
  focusFirst();
358
363
  }
359
364
 
@@ -361,44 +366,44 @@ export function signInView(options: PlayerSigninViewOptions): PlayerSigninView {
361
366
  function walletSteps(s: Start, name: string): Node[] {
362
367
  const open = (text: string, href: string, newTab = false) => h("a", { class: "n133s-btn n133s-primary", href, target: newTab ? "_blank" : undefined, rel: newTab ? "noopener" : undefined }, text);
363
368
  const qr = (text: string) => {
364
- if (!options.qr) return h("p", { class: "n133s-fine" }, "Open this page on your phone to sign in with ", name, ".");
369
+ if (!options.qr) return h("p", { class: "n133s-fine" }, w.openOnPhone(name));
365
370
  const c = options.qr(text);
366
- c.setAttribute("aria-label", `QR code to sign in with ${name}`);
371
+ c.setAttribute("aria-label", w.qrLabel(name));
367
372
  return h("div", { class: "n133s-qr" }, c);
368
373
  };
369
- const only = h("p", { class: "n133s-fine" }, `Only approve a sign-in that you started yourself, on ${location.host}.`);
370
- const comeBack = h("p", { class: "n133s-fine" }, `Approve the sign-in in ${s.way === "berry" ? "your wallet" : name}, then come back to this page.`);
374
+ const only = h("p", { class: "n133s-fine" }, w.onlyYours(location.host));
375
+ const comeBack = h("p", { class: "n133s-fine" }, w.comeBack(s.way === "berry" ? w.yourWallet : name));
371
376
  if (s.way === "nautilus") {
372
- const code = h("p", { class: "n133s-text" }, "Check that Nautilus shows the code ", h("strong", { class: "n133s-signcode" }, s.code ?? ""), ".");
373
- 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];
377
+ const code = h("p", { class: "n133s-text" }, w.checkCode, h("strong", { class: "n133s-signcode" }, s.code ?? ""));
378
+ return touch() ? [code, open(w.openApp(name), s.link), comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), code, only];
374
379
  }
375
380
  if (s.way === "nanchat") {
376
- const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.link, target: "_blank", rel: "noopener" }, "open NanChat"), ".");
377
- 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];
381
+ const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.link, target: "_blank", rel: "noopener" }, w.openLink(name)));
382
+ return touch() ? [open(w.openApp(name), s.link, true), comeBack, only] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanCamera(name)), only, here];
378
383
  }
379
- const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, "Another wallet");
380
- const here = h("p", { class: "n133s-fine" }, "On this computer: ", h("a", { href: s.walletLink ?? s.link }, "open a wallet"), ".");
381
- 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];
384
+ const other = h("a", { class: "n133s-btn n133s-plain", href: s.walletLink ?? s.link }, w.anotherWallet);
385
+ const here = h("p", { class: "n133s-fine" }, w.onComputer, h("a", { href: s.walletLink ?? s.link }, w.openLink(w.aWallet)));
386
+ return touch() ? [open(w.openApp(name), s.link), other, comeBack] : [qr(s.link), h("p", { class: "n133s-text" }, w.scanWith(name)), only, here];
382
387
  }
383
388
 
384
389
  /** The request ended without a sign-in: say why, and offer a new one. */
385
390
  function again(s: Start, words: string) {
386
391
  current = null;
387
- 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())));
392
+ 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())));
388
393
  say(words);
389
394
  }
390
395
 
391
396
  async function poll(s: Start) {
392
397
  if (stopped || current !== s) return;
393
- if (Date.now() > s.expiresAt + 5000) return again(s, "This sign-in expired.");
398
+ if (Date.now() > s.expiresAt + 5000) return again(s, w.expired);
394
399
  try {
395
400
  const r = await api.post<{ status: string; address?: string }>(`${base}/check`, { id: s.id, way: s.way });
396
401
  if (stopped || current !== s) return;
397
402
  if (r.status === "signedin" && r.address) return done({ address: r.address, via: "wallet" });
398
- if (r.status === "elsewhere" || r.status === "used") return again(s, "This sign-in was used in another browser. Start again.");
399
- if (r.status === "expired") return again(s, "This sign-in expired.");
403
+ if (r.status === "elsewhere" || r.status === "used") return again(s, w.usedElsewhere);
404
+ if (r.status === "expired") return again(s, w.expired);
400
405
  } catch (e) {
401
- if (e instanceof ApiError && e.status === 429) say("Too many checks. Waiting a moment…");
406
+ if (e instanceof ApiError && e.status === 429) say(w.tooManyChecks);
402
407
  }
403
408
  timer = setTimeout(() => void poll(s), 2000);
404
409
  }