@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.
@@ -4,9 +4,14 @@
4
4
  // owned() the looks the player owns (the game's GET /api/me gives them) signedIn() whether a player is signed in
5
5
  // signIn() the game's own sign-in (the kit's sign-in path, or the game's): true when the player is signed in after it. The shop draws no sign-in.
6
6
  // worn/wear what the player wears; `onOwned(lookId)` when a paid order gave a look (wear it)
7
+ // onSignInCancel(lookId) the game's own Buy (`buy(lookId)`) asked for a sign-in and the player closed it: the game shows its own sheet again
7
8
  //
8
- // `key: false` draws no key: the game opens the sheet from its own key with `open()`, as the options panel allows. The sheet is a <dialog>: it rises from
9
- // the bottom on a phone held upright and stands in the middle on a wide screen; on a phone held sideways the pay sheet puts the QR code beside the words.
9
+ // `key: false` draws no key: the game opens the sheet from its own key with `open()`, as the options panel allows. `buy(lookId)` starts on the next frame:
10
+ // a game's own <dialog> that closed just before has had its `close` event by then (Chromium fires it at the frame), so its handler cannot take the focus.
11
+ // The sheet is a <dialog>: it rises from the bottom on a phone held upright and stands in the middle on a wide screen; on a phone held sideways the pay
12
+ // sheet puts the QR code beside the words. Phone first: text 16 px, every key 48 px or more each way.
13
+ //
14
+ // `words` changes any word of the two sheets (./words.ts: SHOP_WORDS are the defaults), for a game's own words or language.
10
15
 
11
16
  import { ApiError, createApi } from "../../admin/panel/api.js";
12
17
  import { h, releaseFocus } from "../../admin/panel/dom.js";
@@ -15,6 +20,7 @@ import { checkLooks, wearOf, type Look } from "../../looks/look.js";
15
20
  import type { PayView } from "../handler.js";
16
21
  import { looksView, slotsOf, type SlotWords } from "./looks.js";
17
22
  import { payPanel } from "./pay.js";
23
+ import { shopWords, type ShopWords } from "./words.js";
18
24
 
19
25
  export type ShopMountOptions = {
20
26
  /** The game's looks (looks.json as it was read). The server decides every price; these are for the list. */
@@ -26,6 +32,16 @@ export type ShopMountOptions = {
26
32
  signedIn(): boolean;
27
33
  /** The game's sign-in: true when a player is signed in after it. With none, Buy asks the player to sign in and stops. */
28
34
  signIn?: () => Promise<boolean>;
35
+ /**
36
+ * The game's own Buy (`buy(lookId)`) asked for a sign-in, and the player closed it without signing in: the game shows its own looks sheet again.
37
+ * The kit's list does not open then (the kit's sheet closes if it was open). A Buy in the kit's own list goes back to that list, as before.
38
+ */
39
+ onSignInCancel?: (lookId: string, look: Look) => void;
40
+ /**
41
+ * The server refused an order with 401: the player's session ended, though `signedIn()` said yes. The game marks the player signed out. The sheet's
42
+ * "Sign in" key then calls `signIn()` without asking `signedIn()` first, so a stale "yes" cannot post the same refused order again.
43
+ */
44
+ onSessionEnd?: () => void;
29
45
  /** Whether the player wears this look now. */
30
46
  worn(look: Look): boolean;
31
47
  /** Wear a look the player owns. */
@@ -40,8 +56,10 @@ export type ShopMountOptions = {
40
56
  picture?: (look: Look) => HTMLElement | null;
41
57
  /** Words for the tabs of the wear slots. */
42
58
  slots?: Record<string, SlotWords>;
43
- /** The sheet's title. Default "Looks". */
59
+ /** The sheet's title. Default: `words.title` ("Looks"). */
44
60
  title?: string;
61
+ /** The game's words for the two sheets, over the defaults (SHOP_WORDS): `{ buyTitle: (look) => look, sendExactly: "Pay exactly" }`. */
62
+ words?: Partial<ShopWords>;
45
63
  /** Draw the Looks key. Default true; false: the game calls `open()` from its own key. */
46
64
  key?: boolean;
47
65
  /** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
@@ -58,7 +76,10 @@ export type MountedShop = {
58
76
  /** Opens the sheet on the looks. */
59
77
  open(): void;
60
78
  close(): void;
61
- /** Opens the sheet on Buy for one look (the game's own "Buy" elsewhere). */
79
+ /**
80
+ * Opens the sheet on Buy for one look (the game's own "Buy" elsewhere), on the next frame: close the game's own <dialog> first. A sign-in closed
81
+ * without a sign-in calls `onSignInCancel` and opens nothing.
82
+ */
62
83
  buy(lookId: string): void;
63
84
  /** Draws the looks again (after the player signed in, or wore something). */
64
85
  refresh(): void;
@@ -75,12 +96,14 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
75
96
  let stopPay: (() => void) | null = null;
76
97
  let destroyed = false;
77
98
  const cleanup: (() => void)[] = [];
99
+ const words = shopWords(options.words);
100
+ const sheetTitle = options.title ?? words.title;
78
101
 
79
102
  const title = h("h2", { class: "n133s-title", tabindex: "-1" });
80
- const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": "Close" }, icon("close", 20));
103
+ const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
81
104
  const body = h("div", { class: "n133s-body" });
82
- const dialog = h("dialog", { class: "n133s-sheet", "aria-label": options.title ?? "Looks" }, h("header", { class: "n133s-head" }, title, closeKey), body);
83
- const key = options.key === false ? null : h("button", { class: "n133s-key", type: "button", "aria-haspopup": "dialog" }, icon("shirt", 20), h("span", { class: "n133s-key-word" }, options.title ?? "Looks"));
105
+ const dialog = h("dialog", { class: "n133s-sheet", "aria-label": sheetTitle }, h("header", { class: "n133s-head" }, title, closeKey), body);
106
+ const key = options.key === false ? null : h("button", { class: "n133s-key", type: "button", "aria-haspopup": "dialog" }, icon("shirt", 20), h("span", { class: "n133s-key-word" }, sheetTitle));
84
107
  const wrap = h("div", { class: "n133-shop" }, key, dialog);
85
108
  root.append(wrap);
86
109
 
@@ -147,7 +170,7 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
147
170
  function showLooks() {
148
171
  if (destroyed) return;
149
172
  show(
150
- options.title ?? "Looks",
173
+ sheetTitle,
151
174
  looksView({
152
175
  looks,
153
176
  owned: ownedNow(),
@@ -163,9 +186,10 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
163
186
  showLooks();
164
187
  }
165
188
  : undefined,
166
- buy: (l) => void buy(l),
189
+ buy: (l) => void buy(l, false),
167
190
  picture: options.picture,
168
191
  slots: options.slots,
192
+ words,
169
193
  tab,
170
194
  onTab: (s) => {
171
195
  tab = s;
@@ -177,23 +201,31 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
177
201
  }
178
202
 
179
203
  const back = (message: string, extra?: HTMLElement | null) =>
180
- h("div", { class: "n133s-message" }, h("p", { class: "n133s-status" }, message), extra ?? null, h("button", { class: "n133s-btn n133s-wide", type: "button", onclick: () => showLooks() }, "Back to looks"));
204
+ h("div", { class: "n133s-message" }, h("p", { class: "n133s-status" }, message), extra ?? null, h("button", { class: "n133s-btn n133s-wide", type: "button", onclick: () => showLooks() }, words.back));
181
205
 
182
- async function buy(look: Look) {
206
+ /**
207
+ * `game`: the game's own Buy started it (`buy(lookId)`), not the kit's list: a closed sign-in goes back to the game, not to the list.
208
+ * `ended`: the server said the session ended (401): sign in again whatever `signedIn()` says.
209
+ */
210
+ async function buy(look: Look, game: boolean, ended = false) {
183
211
  if (destroyed) return;
184
212
  tab = slotsOf(looks).length > 1 ? wearOf(look) : undefined;
185
- if (!options.signedIn()) {
186
- if (!options.signIn) return show(`Buy ${look.name}`, back("To buy a look, sign in with Nano first: your looks belong to your Nano address."));
213
+ if (ended || !options.signedIn()) {
214
+ if (!options.signIn) return show(words.buyTitle(look.name), back(words.signInFirst));
187
215
  let ok = false;
188
216
  try {
189
217
  ok = await options.signIn();
190
218
  } catch {
191
219
  ok = false;
192
220
  }
193
- if (!ok || !options.signedIn()) return showLooks();
221
+ if (!ok || !options.signedIn()) {
222
+ if (!game) return showLooks();
223
+ close();
224
+ return options.onSignInCancel?.(look.id, look);
225
+ }
194
226
  }
195
227
  const box = h("div", { class: "n133s-turnstile", hidden: true });
196
- show(`Buy ${look.name}`, h("div", {}, h("p", { class: "n133s-status" }, "Making your order…"), box));
228
+ show(words.buyTitle(look.name), h("div", {}, h("p", { class: "n133s-status" }, words.making), box));
197
229
  try {
198
230
  const turnstile = options.personCheck ? await options.personCheck(box) : null;
199
231
  const order = await api.post<PayView>("", { lookId: look.id, turnstile });
@@ -201,18 +233,21 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
201
233
  const pay = payPanel(order, {
202
234
  lookName: look.name,
203
235
  check: (id) => api.post<PayView>(`/${id}/check`),
204
- errorText: (e) => (e instanceof ApiError ? e.message : null),
236
+ errorText: (e) => (e instanceof ApiError ? words.refusal(e.message, e.status) : null),
237
+ words,
205
238
  onPaid: () => {
206
239
  bought.add(look.id);
207
240
  options.onOwned?.(look.id, look);
208
241
  },
209
242
  });
210
- show(`Buy ${look.name}`, pay.node);
243
+ show(words.buyTitle(look.name), pay.node);
211
244
  stopPay = pay.stop;
212
245
  } catch (e) {
213
- const message = e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : "The order did not start. Try again.";
214
- const again = e instanceof ApiError && e.status === 401 && options.signIn ? h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", onclick: () => void buy(look) }, "Sign in") : null;
215
- show(`Buy ${look.name}`, back(message, again));
246
+ const message = e instanceof ApiError ? words.refusal(e.message, e.status) : e instanceof Error && e.message ? words.refusal(e.message, 0) : words.orderFailed;
247
+ const gone = e instanceof ApiError && e.status === 401;
248
+ if (gone) options.onSessionEnd?.();
249
+ const again = gone && options.signIn ? h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", onclick: () => void buy(look, game, true) }, words.signIn) : null;
250
+ show(words.buyTitle(look.name), back(message, again));
216
251
  }
217
252
  }
218
253
 
@@ -221,7 +256,11 @@ export function mountShop(root: HTMLElement, options: ShopMountOptions): Mounted
221
256
  close,
222
257
  buy(lookId) {
223
258
  const look = looks.find((l) => l.id === lookId);
224
- if (look) void buy(look);
259
+ // On the next frame (rAF; a hidden page has none): a <dialog> of the game's that closed just before has had its `close` event by then.
260
+ if (!look) return;
261
+ const go = () => void buy(look, true);
262
+ if (typeof requestAnimationFrame === "function" && !globalThis.document?.hidden) requestAnimationFrame(go);
263
+ else setTimeout(go, 0);
225
264
  },
226
265
  refresh() {
227
266
  if (isOpen() && body.querySelector(".n133s-looks-view")) showLooks();
@@ -3,12 +3,13 @@
3
3
  //
4
4
  // TEST MODE: the server sends no address and no link, and this sheet shows a large "TEST MODE: DO NOT PAY" stamp where the QR code would be, with the
5
5
  // wallet key off. Never change that: a payment to an address that nobody holds the key for is lost for good. The sheet decides it from the ORDER (its
6
- // `test`, a missing `uri` or a missing `address`), never from a setting of the page.
6
+ // `test`, a missing `uri` or a missing `address`), never from a setting of the page. The stamp's words are not among the game's words (./words.ts).
7
7
 
8
- import { copy, h } from "../../admin/panel/dom.js";
8
+ import { h } from "../../admin/panel/dom.js";
9
9
  import { usdText } from "../../looks/look.js";
10
10
  import type { PayView } from "../handler.js";
11
11
  import { qrSvg } from "./qr.js";
12
+ import { priceLine, shopWords, type ShopWords } from "./words.js";
12
13
 
13
14
  export const PAY_TIMING = { pollMs: 5000, checkForMs: 2 * 60_000 } as const;
14
15
 
@@ -23,6 +24,8 @@ export type PayOptions = {
23
24
  errorText?(e: unknown): string | null;
24
25
  /** The time now in ms (a test gives its own). */
25
26
  now?: () => number;
27
+ /** The game's words (./words.ts): the keys it leaves out keep the defaults. */
28
+ words?: Partial<ShopWords>;
26
29
  };
27
30
 
28
31
  export type PayPanel = { node: HTMLElement; stop(): void };
@@ -37,44 +40,62 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
37
40
  let timer: ReturnType<typeof setTimeout> | undefined;
38
41
  let clockTimer: ReturnType<typeof setInterval> | undefined;
39
42
  let checkingUntil = 0;
43
+ const w = shopWords(options.words);
44
+ const test = isTestOrder(order);
40
45
 
41
- const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite" });
42
- const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, "Check again");
43
- const left = h("span", { class: "n133s-time-left" });
44
- const amount = h("div", { class: "n133s-amount" }, h("strong", {}, `${order.xno} XNO`), h("span", {}, ` = ${usdText(Number(order.usd))}`));
46
+ /** The order's state. In test mode nothing is checked, so it has nothing to say and takes no room. */
47
+ const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite", hidden: test });
48
+ const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, w.checkAgain);
49
+ /** The time left, live only: in test mode nothing is paid, so nothing waits for the end. */
50
+ const ends = h("p", { class: "n133s-fine n133s-time-left", hidden: test });
51
+ const amount = h(
52
+ "div",
53
+ { class: "n133s-amount" },
54
+ test ? null : h("span", { class: "n133s-exact" }, w.sendExactly),
55
+ h("strong", {}, w.amount(order.xno)),
56
+ h("span", {}, w.usd(usdText(Number(order.usd)))),
57
+ );
58
+ const copyKey = (value: string) => {
59
+ const key = h("button", { class: "n133s-btn n133s-small", type: "button" }, w.copy);
60
+ key.addEventListener("click", async () => {
61
+ try {
62
+ await navigator.clipboard.writeText(value);
63
+ key.textContent = w.copied;
64
+ } catch {
65
+ key.textContent = w.copyFailed;
66
+ }
67
+ setTimeout(() => (key.textContent = w.copy), 1500);
68
+ });
69
+ return key;
70
+ };
45
71
 
46
72
  let payArea: HTMLElement;
47
73
  /** The main key (the wallet link; off in test mode): it stands in a foot that stays in view, so a phone on its side never hides it. */
48
74
  let wallet: HTMLElement;
49
- const test = isTestOrder(order);
50
75
  if (test) {
51
76
  payArea = h(
52
77
  "div",
53
78
  { class: "n133s-test-pay" },
54
79
  h("div", { class: "n133s-qr n133s-qr-blocked", "aria-hidden": "true" }, h("div", { class: "n133s-qr-fake" })),
55
80
  h("div", { class: "n133s-stamp", role: "alert" }, h("span", {}, "TEST MODE:"), " ", h("span", {}, "DO NOT PAY")),
56
- h("p", { class: "n133s-fine" }, "This game is in test mode, so nothing can be paid here. To sell for real, the creator sets their own receive address and turns test mode off."),
81
+ h("p", { class: "n133s-fine" }, w.testNote),
57
82
  );
58
- wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, "Open wallet (off in test mode)");
83
+ wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, w.walletOff);
59
84
  } else {
60
- const copyAmount = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
61
- copyAmount.addEventListener("click", () => copy(order.xno, copyAmount));
62
- const copyAddress = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
63
- copyAddress.addEventListener("click", () => copy(order.address!, copyAddress));
64
85
  payArea = h(
65
86
  "div",
66
87
  { class: "n133s-live-pay" },
67
- h("div", { class: "n133s-qr" }, qrSvg(order.uri!, `QR code to pay ${order.xno} XNO`)),
88
+ h("div", { class: "n133s-qr" }, qrSvg(order.uri!, w.qrLabel(order.xno))),
68
89
  h(
69
90
  "details",
70
91
  { class: "n133s-by-hand" },
71
- h("summary", {}, "Enter it by hand"),
72
- h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "Amount"), h("code", {}, `${order.xno} XNO`), copyAmount),
73
- h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "To"), h("code", { class: "n133s-address" }, order.address!), copyAddress),
74
- h("p", { class: "n133s-fine" }, "Send exactly this amount, with every digit. Another amount does not match this order."),
92
+ h("summary", {}, w.byHand),
93
+ h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.amountLabel), h("code", {}, w.amount(order.xno)), copyKey(order.xno)),
94
+ h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.toLabel), h("code", { class: "n133s-address" }, order.address!), copyKey(order.address!)),
95
+ h("p", { class: "n133s-fine" }, w.byHandNote),
75
96
  ),
76
97
  );
77
- wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri! }, "Open wallet");
98
+ wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri! }, w.wallet);
78
99
  }
79
100
 
80
101
  const node = h(
@@ -83,17 +104,17 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
83
104
  h(
84
105
  "div",
85
106
  { class: "n133s-pay-main" },
86
- h("p", { class: "n133s-pay-what" }, `${options.lookName}: a look for your character. It changes how you look, not how you play.`),
107
+ h("p", { class: "n133s-pay-what" }, w.lookLine(options.lookName)),
87
108
  amount,
88
109
  payArea,
89
110
  ),
90
111
  h(
91
112
  "div",
92
113
  { class: "n133s-pay-side" },
93
- h("p", { class: "n133s-fine" }, "This order ends in ", left, "."),
114
+ ends,
94
115
  status,
95
116
  again,
96
- h("p", { class: "n133s-fine n133s-prices" }, `Price in XNO from ${order.prices.join(", ")}. Powered by CoinGecko.`),
117
+ h("p", { class: "n133s-fine n133s-prices" }, priceLine(w, order.prices)),
97
118
  h("div", { class: "n133s-foot" }, wallet),
98
119
  ),
99
120
  );
@@ -109,7 +130,7 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
109
130
  const ms = Math.max(0, order.expiresAt - now());
110
131
  const m = Math.floor(ms / 60_000);
111
132
  const s = Math.floor((ms % 60_000) / 1000);
112
- left.textContent = `${m}:${String(s).padStart(2, "0")}`;
133
+ ends.textContent = w.endsIn(`${m}:${String(s).padStart(2, "0")}`);
113
134
  };
114
135
  tick();
115
136
  clockTimer = setInterval(tick, 1000);
@@ -118,7 +139,7 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
118
139
  order = o;
119
140
  if (o.status === "paid") {
120
141
  stop();
121
- payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, "Paid. Thank you!"), h("p", {}, `${options.lookName} is yours now.`)));
142
+ payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, w.paid), h("p", {}, w.paidLine(options.lookName))));
122
143
  wallet.hidden = true;
123
144
  status.textContent = "";
124
145
  again.hidden = true;
@@ -127,13 +148,13 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
127
148
  }
128
149
  if (o.status === "paid_late") {
129
150
  stop();
130
- status.textContent = "This payment came after the order ended. The look was not given. The creator sees it and can send it back.";
151
+ status.textContent = w.paidLate;
131
152
  again.hidden = true;
132
153
  return true;
133
154
  }
134
155
  if (o.status === "expired") {
135
156
  stop();
136
- status.textContent = "This order ended. If you already paid, wait here: a payment sent in time still counts.";
157
+ status.textContent = w.expired;
137
158
  again.hidden = false;
138
159
  return false;
139
160
  }
@@ -145,13 +166,13 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
145
166
  try {
146
167
  const o = await options.check(order.id);
147
168
  if (show(o)) return;
148
- if (o.status === "open") status.textContent = test ? "Test mode: the game does not wait for a payment." : "Waiting for your payment…";
169
+ if (o.status === "open") status.textContent = test ? "" : w.waiting;
149
170
  } catch (e) {
150
- status.textContent = options.errorText?.(e) ?? "No connection. Trying again…";
171
+ status.textContent = options.errorText?.(e) ?? w.noConnection;
151
172
  }
152
173
  if (now() < checkingUntil) timer = setTimeout(poll, PAY_TIMING.pollMs);
153
174
  else if (!stopped) {
154
- status.textContent = "Still waiting. Already paid? Press Check again.";
175
+ status.textContent = w.stillWaiting;
155
176
  again.hidden = false;
156
177
  }
157
178
  }
@@ -164,8 +185,8 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
164
185
  void poll();
165
186
  }
166
187
  again.addEventListener("click", startChecking);
188
+ // Test mode makes no checks: nothing can be paid, so nothing is waited for.
167
189
  if (!test) startChecking();
168
- else status.textContent = "Test mode: the game does not wait for a payment.";
169
190
 
170
191
  return { node, stop };
171
192
  }
@@ -1,5 +1,5 @@
1
1
  /* The Looks key and the shop's sheet (mountShop in ./mount.ts). Every rule is under .n133-shop, so nothing leaks into the game's page.
2
- Phone first: every key is 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 it
2
+ Phone first: text is 16 px (the stamp and the amount bigger), and every key, tab, close key and fold is 48 px or more each way. On a phone held upright the sheet rises from the bottom; on a wide screen it
3
3
  stands in the middle; on a phone held sideways the pay sheet puts the QR code beside the words, and the wallet key stays in view.
4
4
  Light and dark: mountShop sets data-theme="light" | "dark" on .n133-shop (the game's word, the page's data-n133-theme, or the device's setting).
5
5
  To theme it, set the --n133-* custom properties on the element that holds the key (or a parent): --n133-ink, --n133-ink-soft, --n133-paper,
@@ -64,14 +64,14 @@
64
64
  display: inline-flex;
65
65
  align-items: center;
66
66
  gap: 8px;
67
- min-width: 44px;
68
- min-height: 44px;
69
- padding: 0 14px;
67
+ min-width: 48px;
68
+ min-height: 48px;
69
+ padding: 0 16px;
70
70
  border: 1px solid var(--line);
71
71
  border-radius: 999px;
72
72
  background: var(--paper);
73
73
  color: var(--ink);
74
- font: 650 15px/1 var(--font);
74
+ font: 650 16px/1 var(--font);
75
75
  cursor: pointer;
76
76
  touch-action: manipulation;
77
77
  }
@@ -143,8 +143,8 @@
143
143
  .n133-shop .n133s-close {
144
144
  display: grid;
145
145
  place-items: center;
146
- width: 44px;
147
- height: 44px;
146
+ width: 48px;
147
+ height: 48px;
148
148
  border: 0;
149
149
  border-radius: 12px;
150
150
  background: transparent;
@@ -172,7 +172,7 @@
172
172
 
173
173
  .n133-shop .n133s-fine {
174
174
  color: var(--ink-soft);
175
- font-size: 13px;
175
+ font-size: 16px;
176
176
  }
177
177
 
178
178
  /* ---- keys ---- */
@@ -182,6 +182,7 @@
182
182
  align-items: center;
183
183
  justify-content: center;
184
184
  gap: 8px;
185
+ min-width: 48px;
185
186
  min-height: 48px;
186
187
  padding: 0 18px;
187
188
  border: 2px solid var(--line);
@@ -227,9 +228,7 @@
227
228
  }
228
229
 
229
230
  .n133-shop .n133s-small {
230
- min-height: 44px;
231
- padding: 0 12px;
232
- font-size: 14px;
231
+ padding: 0 14px;
233
232
  }
234
233
 
235
234
  /* ---- the looks ---- */
@@ -245,13 +244,14 @@
245
244
 
246
245
  .n133-shop .n133s-tab {
247
246
  flex: none;
248
- min-height: 44px;
249
- padding: 0 14px;
247
+ min-width: 48px;
248
+ min-height: 48px;
249
+ padding: 0 16px;
250
250
  border: 1px solid var(--line);
251
251
  border-radius: 999px;
252
252
  background: var(--ground);
253
253
  color: var(--ink);
254
- font: 650 15px/1 var(--font);
254
+ font: 650 16px/1 var(--font);
255
255
  cursor: pointer;
256
256
  }
257
257
 
@@ -295,7 +295,7 @@
295
295
 
296
296
  .n133-shop .n133s-look-text small {
297
297
  color: var(--ink-soft);
298
- font-size: 14px;
298
+ font-size: 16px;
299
299
  }
300
300
 
301
301
  .n133-shop .n133s-swatch {
@@ -366,6 +366,11 @@
366
366
  font-weight: 700;
367
367
  }
368
368
 
369
+ /* The time left: its digits keep their width while it counts down. */
370
+ .n133-shop .n133s-time-left {
371
+ font-variant-numeric: tabular-nums;
372
+ }
373
+
369
374
  .n133-shop .n133s-qr {
370
375
  display: grid;
371
376
  place-items: center;
@@ -429,8 +434,8 @@
429
434
  }
430
435
 
431
436
  .n133-shop .n133s-by-hand summary {
432
- min-height: 44px;
433
- line-height: 44px;
437
+ min-height: 48px;
438
+ line-height: 48px;
434
439
  font-weight: 700;
435
440
  cursor: pointer;
436
441
  }
@@ -443,17 +448,16 @@
443
448
  }
444
449
 
445
450
  .n133-shop .n133s-label {
446
- width: 60px;
451
+ width: 64px;
447
452
  flex: none;
448
453
  color: var(--ink-soft);
449
- font-size: 14px;
450
454
  }
451
455
 
452
456
  .n133-shop .n133s-row code {
453
457
  flex: 1;
454
458
  min-width: 0;
455
459
  word-break: break-all;
456
- font-size: 13px;
460
+ font-size: 16px;
457
461
  }
458
462
 
459
463
  .n133-shop .n133s-paid {