@nano133/nanoterra 0.1.0 → 0.3.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 (106) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +269 -12
  3. package/ai/SPEC.md +7 -2
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +24 -0
  33. package/dist/admin/panel/shelf.js +271 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/character/bases/bee.js +7 -5
  45. package/dist/character/bases/spec.d.ts +7 -0
  46. package/dist/character/create.d.ts +15 -0
  47. package/dist/character/create.js +69 -2
  48. package/dist/character/ctx.d.ts +12 -2
  49. package/dist/character/index.d.ts +2 -0
  50. package/dist/character/index.js +2 -0
  51. package/dist/character/make.js +2 -1
  52. package/dist/character/preview.d.ts +32 -0
  53. package/dist/character/preview.js +112 -0
  54. package/dist/character/sockets.d.ts +37 -0
  55. package/dist/character/sockets.js +66 -0
  56. package/dist/looks/change.d.ts +8 -4
  57. package/dist/looks/change.js +25 -3
  58. package/dist/looks/index.d.ts +3 -3
  59. package/dist/looks/index.js +2 -2
  60. package/dist/looks/json.d.ts +2 -2
  61. package/dist/looks/json.js +6 -4
  62. package/dist/looks/look.d.ts +29 -3
  63. package/dist/looks/look.js +60 -4
  64. package/dist/model/check.d.ts +59 -0
  65. package/dist/model/check.js +681 -0
  66. package/dist/model/index.d.ts +2 -0
  67. package/dist/model/index.js +5 -0
  68. package/dist/model/limits.d.ts +103 -0
  69. package/dist/model/limits.js +132 -0
  70. package/package.json +11 -3
  71. package/src/admin/adapters/d1.ts +94 -0
  72. package/src/admin/adapters/firestore.ts +78 -0
  73. package/src/admin/amount.ts +14 -0
  74. package/src/admin/conformance.ts +605 -0
  75. package/src/admin/guard.ts +86 -0
  76. package/src/admin/handler.ts +200 -0
  77. package/src/admin/http.ts +74 -0
  78. package/src/admin/index.ts +21 -0
  79. package/src/admin/memory.ts +59 -0
  80. package/src/admin/panel/api.ts +37 -0
  81. package/src/admin/panel/dom.ts +31 -0
  82. package/src/admin/panel/gameCard.ts +63 -0
  83. package/src/admin/panel/index.ts +18 -0
  84. package/src/admin/panel/mount.ts +227 -0
  85. package/src/admin/panel/shelf.ts +361 -0
  86. package/src/admin/panel/shelfData.ts +87 -0
  87. package/src/admin/panel/signins.ts +68 -0
  88. package/src/admin/panel/style.css +551 -0
  89. package/src/admin/store.ts +98 -0
  90. package/src/admin/summary.ts +103 -0
  91. package/src/admin/switches.ts +74 -0
  92. package/src/character/bases/bee.ts +7 -5
  93. package/src/character/bases/spec.ts +4 -0
  94. package/src/character/create.ts +73 -2
  95. package/src/character/ctx.ts +17 -2
  96. package/src/character/index.ts +2 -0
  97. package/src/character/make.ts +2 -1
  98. package/src/character/preview.ts +148 -0
  99. package/src/character/sockets.ts +81 -0
  100. package/src/looks/change.ts +25 -6
  101. package/src/looks/index.ts +3 -3
  102. package/src/looks/json.ts +6 -4
  103. package/src/looks/look.ts +66 -5
  104. package/src/model/check.ts +624 -0
  105. package/src/model/index.ts +5 -0
  106. package/src/model/limits.ts +193 -0
@@ -0,0 +1,87 @@
1
+ // The numbers and the words of the Shelf screen in /admin (shelf.ts draws it). Pure: no page, no network. Everything
2
+ // here comes from the game's own database (the admin summary) and from looks.json; nothing is sent anywhere.
3
+
4
+ import type { LooksDiff } from "../../looks/change.js";
5
+ import { ID_PATTERN, type Look } from "../../looks/look.js";
6
+
7
+ export type ShelfOrder = { lookId: string; status: string; usd: string };
8
+ export type ShelfPlayer = { looks: { id: string }[] };
9
+
10
+ /** What one look has done in this game. */
11
+ export type LookStats = {
12
+ /** Players who own it (a paid order gave it to them). */
13
+ owners: number;
14
+ /** Paid orders, and their dollars in cents. */
15
+ paid: number;
16
+ paidCents: number;
17
+ /** Orders that were paid after the order ran out (the money is to be sent back; the look was not given). */
18
+ late: number;
19
+ };
20
+
21
+ const none = (): LookStats => ({ owners: 0, paid: 0, paidCents: 0, late: 0 });
22
+
23
+ /** "0.25" is 25 cents. */
24
+ export const centsOf = (usd: string): number => Math.round(Number(usd) * 100) || 0;
25
+
26
+ /** The stats for each look id that appears in the orders or in a player's looks. */
27
+ export function shelfStats(orders: readonly ShelfOrder[], players: readonly ShelfPlayer[]): Map<string, LookStats> {
28
+ const stats = new Map<string, LookStats>();
29
+ const of = (id: string) => stats.get(id) ?? stats.set(id, none()).get(id)!;
30
+ for (const o of orders) {
31
+ if (o.status === "paid") {
32
+ const s = of(o.lookId);
33
+ s.paid++;
34
+ s.paidCents += centsOf(o.usd);
35
+ } else if (o.status === "paid_late") of(o.lookId).late++;
36
+ }
37
+ for (const p of players) for (const l of new Set(p.looks.map((x) => x.id))) of(l).owners++;
38
+ return stats;
39
+ }
40
+
41
+ /** Every look id that a player owns. */
42
+ export function ownedIds(players: readonly ShelfPlayer[]): Set<string> {
43
+ return new Set(players.flatMap((p) => p.looks.map((l) => l.id)));
44
+ }
45
+
46
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
47
+
48
+ /** "1 added, 2 changed, 0 removed" */
49
+ export function changeText(d: LooksDiff): string {
50
+ return `${d.added.length} added, ${d.changed.length} changed, ${d.removed.length} removed`;
51
+ }
52
+
53
+ /** What to say when a player's money is in a look: "3 sold, $0.75, 3 owners", or "Not sold yet". */
54
+ export function soldText(s: LookStats | undefined): string {
55
+ if (!s || (!s.paid && !s.owners)) return "Not sold yet";
56
+ return `${s.paid} sold, $${(s.paidCents / 100).toFixed(2)}, ${plural(s.owners, "owner")}`;
57
+ }
58
+
59
+ /**
60
+ * A message to give to an AI assistant that works in the game's repository: the whole new looks.json, what changed, and the rules.
61
+ * It holds no address, no order and no secret: only the looks, and the ids that players own.
62
+ */
63
+ export function aiPrompt(text: string, diff: LooksDiff, owned: readonly string[], before: readonly Look[]): string {
64
+ // Only a real id goes in, written as a JSON string: an id that comes from a database is data, and a message that a person pastes into an AI assistant must not carry a line
65
+ // that an attacker wrote there.
66
+ const quote = (ids: readonly string[]) => ids.filter((id) => ID_PATTERN.test(id)).map((id) => JSON.stringify(id)).join(", ");
67
+ const names = (ls: readonly Look[]) => quote(ls.map((l) => l.id));
68
+ const ownedOk = owned.filter((id) => ID_PATTERN.test(id));
69
+ const odd = owned.length - ownedOk.length;
70
+ const lines = [
71
+ "Replace looks.json at the top of this game with the text below, then run `npm run check` and push to GitHub. Do not change anything else.",
72
+ "",
73
+ `The change: ${changeText(diff)}.`,
74
+ diff.added.length ? `Added: ${names(diff.added)}.` : "",
75
+ diff.removed.length ? `Removed: ${names(diff.removed)}.` : "",
76
+ diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
77
+ "",
78
+ `Looks that players own and that must stay in the file with the same id: ${ownedOk.length ? quote(ownedOk) : "none yet"}${odd ? ` (and ${odd} more with an id that is not a valid look id: do not remove any look)` : ""}.`,
79
+ `Rules: keep one free look (priceUsd 0), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the slots of a look that someone owns (slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
80
+ "",
81
+ "looks.json:",
82
+ "```json",
83
+ text.trimEnd(),
84
+ "```",
85
+ ];
86
+ return lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n");
87
+ }
@@ -0,0 +1,68 @@
1
+ // The "Sign-ins" card of /admin: the last admin sign-ins (when, the rough country, a short browser name) and "Sign out everywhere". It shows only what the store gave for a
2
+ // sign-in: the three things of `AdminLogin`. The sign-out is two presses, so that one slip does not end every login.
3
+
4
+ import type { AdminSummary } from "../store.js";
5
+ import { ApiError, type Api } from "./api.js";
6
+ import { h } from "./dom.js";
7
+
8
+ /** What the card says about what is kept, unless the game says its own (its retention). */
9
+ export const DEFAULT_LOGINS_NOTE = "For each sign-in only the time, the rough country and a short browser name are kept: no address and no full browser text.";
10
+
11
+ /** "United States (US)", or "US" when the name is not known, or "unknown country". */
12
+ function countryText(code: string | null): string {
13
+ if (!code) return "unknown country";
14
+ try {
15
+ const name = new Intl.DisplayNames(["en"], { type: "region" }).of(code);
16
+ return name && name !== code ? `${name} (${code})` : code;
17
+ } catch {
18
+ return code;
19
+ }
20
+ }
21
+
22
+ /** The card, or null when the store lists no sign-ins and cannot sign out. `done` runs after a sign-out everywhere (the panel loads again and asks for a sign-in). */
23
+ export function signinsCard(s: AdminSummary, api: Api, done: () => void, note: string = DEFAULT_LOGINS_NOTE): HTMLElement | null {
24
+ if (!s.logins && !s.canSignOutEverywhere) return null;
25
+ const card = h("section", { class: "admin-card signins", "aria-labelledby": "signins-title" }, h("h2", { id: "signins-title" }, "Sign-ins"));
26
+ if (s.logins) {
27
+ card.append(h("p", { class: "fine" }, "The last sign-ins to this admin, newest first. If one is not yours, press Sign out everywhere."));
28
+ const list = h("ul", { class: "signin-list" });
29
+ for (const l of s.logins) list.append(h("li", {}, h("strong", {}, new Date(l.at).toLocaleString()), h("span", {}, ` · ${countryText(l.country)} · ${l.browser}`)));
30
+ if (!s.logins.length) list.append(h("li", { class: "fine" }, "No sign-in is listed yet."));
31
+ card.append(list, h("p", { class: "fine" }, note));
32
+ }
33
+ if (s.canSignOutEverywhere) {
34
+ const box = h("div", { class: "signout-box" });
35
+ const status = h("span", { class: "fine", role: "status" });
36
+ const first = h("button", { class: "btn", type: "button" }, "Sign out everywhere");
37
+ const confirm = h("div", { class: "signout-confirm" });
38
+ confirm.hidden = true;
39
+ const yes = h("button", { class: "btn btn-primary", type: "button" }, "Yes, sign out everywhere");
40
+ const cancel = h("button", { class: "btn", type: "button" }, "Cancel");
41
+ confirm.append(h("p", {}, "This ends every admin sign-in, in every browser, this one too. You will sign in again."), yes, " ", cancel);
42
+ first.addEventListener("click", () => {
43
+ first.hidden = true;
44
+ confirm.hidden = false;
45
+ status.textContent = "";
46
+ });
47
+ cancel.addEventListener("click", () => {
48
+ confirm.hidden = true;
49
+ first.hidden = false;
50
+ });
51
+ yes.addEventListener("click", async () => {
52
+ yes.disabled = true;
53
+ cancel.disabled = true;
54
+ status.textContent = "Signing out…";
55
+ try {
56
+ await api.post("/signout-all");
57
+ done();
58
+ } catch (e) {
59
+ status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
60
+ yes.disabled = false;
61
+ cancel.disabled = false;
62
+ }
63
+ });
64
+ box.append(first, confirm, status);
65
+ card.append(box);
66
+ }
67
+ return card;
68
+ }
@@ -0,0 +1,551 @@
1
+ /* The admin panel's styles (mountAdmin in ./mount.ts). Every rule is under .n133-admin, so nothing leaks into the page that holds the panel and the page's own
2
+ styles do not reach in by accident. Phone first: every control is at least 40 px high (the order tabs), the rest 44 px or more; one column, two from 640px.
3
+ To theme it, set the --n133-* custom properties on the element that holds the panel (or on any parent): --n133-ink, --n133-ink-soft, --n133-paper, --n133-line,
4
+ --n133-gem, --n133-gem-dark, --n133-sea, --n133-radius and --n133-font. */
5
+
6
+ .n133-admin {
7
+ --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
8
+ --gem: var(--n133-gem, #20c9a6);
9
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
10
+ --ink: var(--n133-ink, #16202e);
11
+ --ink-soft: var(--n133-ink-soft, #4a5568);
12
+ --line: var(--n133-line, #dbe4ee);
13
+ --paper: var(--n133-paper, #ffffff);
14
+ --radius: var(--n133-radius, 18px);
15
+ --sea: var(--n133-sea, #1f8fc4);
16
+ box-sizing: border-box;
17
+ max-width: 900px;
18
+ margin: 0 auto;
19
+ color: var(--ink);
20
+ font-family: var(--font);
21
+ -webkit-text-size-adjust: 100%;
22
+ }
23
+
24
+ .n133-admin *,
25
+ .n133-admin *::before,
26
+ .n133-admin *::after {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ .n133-admin [hidden] {
31
+ display: none !important;
32
+ }
33
+
34
+ .n133-admin .btn {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ gap: 8px;
39
+ min-height: 48px;
40
+ padding: 0 18px;
41
+ border: 2px solid var(--line);
42
+ border-radius: 999px;
43
+ background: var(--paper);
44
+ color: var(--ink);
45
+ font: 700 16px/1 var(--font);
46
+ text-decoration: none;
47
+ cursor: pointer;
48
+ touch-action: manipulation;
49
+ }
50
+
51
+ .n133-admin .btn:focus-visible {
52
+ outline: 3px solid var(--sea);
53
+ outline-offset: 2px;
54
+ }
55
+
56
+ .n133-admin .btn:disabled {
57
+ opacity: 0.55;
58
+ cursor: default;
59
+ }
60
+
61
+ .n133-admin .btn-primary {
62
+ border-color: var(--gem-dark);
63
+ background: var(--gem);
64
+ color: #062b23;
65
+ }
66
+
67
+ .n133-admin .btn-small {
68
+ min-height: 40px;
69
+ padding: 0 12px;
70
+ font-size: 14px;
71
+ }
72
+
73
+ .n133-admin .fine {
74
+ color: var(--ink-soft);
75
+ font-size: 13px;
76
+ }
77
+
78
+ .n133-admin .admin-card {
79
+ margin: 12px 0;
80
+ padding: 16px;
81
+ border: 1px solid var(--line);
82
+ border-radius: var(--radius);
83
+ background: #fff;
84
+ }
85
+
86
+ .n133-admin .admin-card h2 {
87
+ margin: 0 0 10px;
88
+ font-size: 19px;
89
+ }
90
+
91
+ .n133-admin .card-head {
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ align-items: center;
95
+ justify-content: space-between;
96
+ gap: 8px;
97
+ margin-bottom: 8px;
98
+ }
99
+
100
+ .n133-admin .card-head h2 {
101
+ margin: 0;
102
+ }
103
+
104
+ .n133-admin .warnings {
105
+ border-color: #f2b8c1;
106
+ background: #fff4f6;
107
+ }
108
+
109
+ .n133-admin .warnings ul {
110
+ margin: 0;
111
+ padding-left: 20px;
112
+ }
113
+
114
+ .n133-admin .totals {
115
+ display: grid;
116
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
117
+ gap: 12px;
118
+ }
119
+
120
+ .n133-admin .total {
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 4px;
124
+ padding: 16px;
125
+ border: 1px solid var(--line);
126
+ border-radius: var(--radius);
127
+ background: #fff;
128
+ }
129
+
130
+ .n133-admin .total span {
131
+ color: var(--ink-soft);
132
+ font-size: 14px;
133
+ }
134
+
135
+ .n133-admin .total strong {
136
+ font-size: 24px;
137
+ font-variant-numeric: tabular-nums;
138
+ }
139
+
140
+ .n133-admin .tabs {
141
+ display: flex;
142
+ gap: 6px;
143
+ overflow-x: auto;
144
+ padding-bottom: 6px;
145
+ }
146
+
147
+ .n133-admin .tab {
148
+ flex: none;
149
+ min-height: 40px;
150
+ padding: 0 14px;
151
+ border: 2px solid var(--line);
152
+ border-radius: 999px;
153
+ background: #fff;
154
+ font: 700 14px/1 var(--font);
155
+ cursor: pointer;
156
+ }
157
+
158
+ .n133-admin .tab-on {
159
+ border-color: var(--ink);
160
+ background: var(--ink);
161
+ color: #fff;
162
+ }
163
+
164
+ .n133-admin .order {
165
+ padding: 12px 0;
166
+ border-bottom: 1px solid var(--line);
167
+ }
168
+
169
+ .n133-admin .order-top {
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: space-between;
173
+ gap: 8px;
174
+ }
175
+
176
+ .n133-admin .order-amount {
177
+ margin: 4px 0;
178
+ font-variant-numeric: tabular-nums;
179
+ }
180
+
181
+ .n133-admin .badge {
182
+ padding: 4px 10px;
183
+ border-radius: 999px;
184
+ background: #eef1f4;
185
+ font-size: 13px;
186
+ font-weight: 700;
187
+ }
188
+
189
+ .n133-admin .badge-paid {
190
+ background: #dcf8ef;
191
+ color: #0b6d5a;
192
+ }
193
+
194
+ .n133-admin .badge-open {
195
+ background: #e3f1ff;
196
+ color: #134e86;
197
+ }
198
+
199
+ .n133-admin .badge-paid_late {
200
+ background: #ffe3e8;
201
+ color: #a1122a;
202
+ }
203
+
204
+ .n133-admin .late-note {
205
+ margin-top: 6px;
206
+ padding: 8px 10px;
207
+ border-radius: 10px;
208
+ background: #fff4f6;
209
+ font-size: 14px;
210
+ }
211
+
212
+ .n133-admin .late-note code,
213
+ .n133-admin .player code,
214
+ .n133-admin .admin-card code {
215
+ word-break: break-all;
216
+ font-size: 12px;
217
+ }
218
+
219
+ .n133-admin .hash {
220
+ word-break: break-all;
221
+ }
222
+
223
+ .n133-admin .player {
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 2px;
227
+ padding: 8px 0;
228
+ border-bottom: 1px solid var(--line);
229
+ }
230
+
231
+ /* The Shelf (src/client/shelf.ts): phone first, one column; two from 640px. */
232
+ .n133-admin .shelf-list {
233
+ display: grid;
234
+ grid-template-columns: 1fr;
235
+ gap: 10px;
236
+ }
237
+
238
+ @media (min-width: 640px) {
239
+ .n133-admin .shelf-list {
240
+ grid-template-columns: 1fr 1fr;
241
+ }
242
+ }
243
+
244
+ .n133-admin .shelf-card {
245
+ padding: 12px;
246
+ border: 1px solid var(--line);
247
+ border-radius: 14px;
248
+ background: #fbfdff;
249
+ }
250
+
251
+ .n133-admin .shelf-top {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 12px;
255
+ }
256
+
257
+ .n133-admin .shelf-pic {
258
+ display: grid;
259
+ flex: none;
260
+ width: 68px;
261
+ height: 76px;
262
+ place-items: center;
263
+ border-radius: 12px;
264
+ background: #e8f3fb;
265
+ }
266
+
267
+ .n133-admin .shelf-preview {
268
+ display: grid;
269
+ place-items: center;
270
+ }
271
+
272
+ .n133-admin .shelf-preview img {
273
+ display: block;
274
+ }
275
+
276
+ .n133-admin .shelf-text {
277
+ display: grid;
278
+ min-width: 0;
279
+ gap: 2px;
280
+ }
281
+
282
+ .n133-admin .shelf-text strong {
283
+ overflow-wrap: anywhere;
284
+ }
285
+
286
+ .n133-admin .shelf-price {
287
+ font-weight: 700;
288
+ font-variant-numeric: tabular-nums;
289
+ }
290
+
291
+ .n133-admin .shelf-field {
292
+ display: grid;
293
+ gap: 4px;
294
+ margin-top: 10px;
295
+ }
296
+
297
+ .n133-admin .shelf-field > span {
298
+ color: var(--ink-soft);
299
+ font-size: 13px;
300
+ font-weight: 700;
301
+ }
302
+
303
+ .n133-admin .shelf-input {
304
+ width: 100%;
305
+ min-height: 44px;
306
+ padding: 0 12px;
307
+ border: 2px solid var(--line);
308
+ border-radius: 12px;
309
+ background: #fff;
310
+ color: var(--ink);
311
+ font: 16px/1 var(--font);
312
+ }
313
+
314
+ .n133-admin .shelf-color {
315
+ width: 100%;
316
+ min-height: 44px;
317
+ padding: 2px;
318
+ border: 2px solid var(--line);
319
+ border-radius: 12px;
320
+ background: #fff;
321
+ }
322
+
323
+ .n133-admin .shelf-color:disabled {
324
+ opacity: 0.45;
325
+ }
326
+
327
+ .n133-admin .shelf-colors {
328
+ display: grid;
329
+ grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
330
+ gap: 8px;
331
+ }
332
+
333
+ .n133-admin .shelf-ladder {
334
+ display: flex;
335
+ flex-wrap: wrap;
336
+ gap: 6px;
337
+ }
338
+
339
+ .n133-admin .chip {
340
+ min-width: 44px;
341
+ min-height: 44px;
342
+ padding: 0 12px;
343
+ border: 2px solid var(--line);
344
+ border-radius: 999px;
345
+ background: #fff;
346
+ color: var(--ink);
347
+ font: 700 14px/1 var(--font);
348
+ cursor: pointer;
349
+ }
350
+
351
+ .n133-admin .shelf-actions,
352
+ .n133-admin .shelf-add {
353
+ display: flex;
354
+ flex-wrap: wrap;
355
+ gap: 8px;
356
+ margin-top: 12px;
357
+ }
358
+
359
+ .n133-admin .shelf-foot {
360
+ display: grid;
361
+ gap: 10px;
362
+ margin-top: 14px;
363
+ }
364
+
365
+ .n133-admin .shelf-changes ul,
366
+ .n133-admin .late-note ul {
367
+ margin: 4px 0 0;
368
+ padding-left: 20px;
369
+ }
370
+
371
+ .n133-admin .shelf-file pre {
372
+ max-height: 320px;
373
+ margin: 8px 0 0;
374
+ padding: 12px;
375
+ overflow: auto;
376
+ border-radius: 12px;
377
+ background: #0f172a;
378
+ color: #e6edf6;
379
+ font-size: 13px;
380
+ }
381
+
382
+ /* WebKit draws a select at its own height and ignores min-height: take over the drawing, so it is 44px (a finger) on an iPad too. */
383
+ .n133-admin select.shelf-input {
384
+ height: 44px;
385
+ padding-right: 32px;
386
+ background-image: linear-gradient(45deg, transparent 50%, var(--ink) 50%), linear-gradient(135deg, var(--ink) 50%, transparent 50%);
387
+ background-position: calc(100% - 18px) 19px, calc(100% - 13px) 19px;
388
+ background-repeat: no-repeat;
389
+ background-size: 5px 5px, 5px 5px;
390
+ -webkit-appearance: none;
391
+ appearance: none;
392
+ }
393
+
394
+ /* The "Game" card: two switches (src/client/gameSwitches.ts). Each is a button of at least 44 px, on a phone first. */
395
+ .n133-admin .game-card .switch-row {
396
+ display: grid;
397
+ gap: 6px;
398
+ padding: 14px 0;
399
+ border-top: 1px solid var(--line);
400
+ }
401
+
402
+ .n133-admin .game-card .switch-head {
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: space-between;
406
+ gap: 12px;
407
+ flex-wrap: wrap;
408
+ }
409
+
410
+ .n133-admin .game-card .switch-title {
411
+ font: 700 16px/1.3 var(--font);
412
+ flex: 1 1 14rem;
413
+ min-width: 0;
414
+ }
415
+
416
+ .n133-admin .game-card .switch-note {
417
+ margin: 0;
418
+ color: var(--ink-soft);
419
+ font-size: 15px;
420
+ line-height: 1.45;
421
+ max-width: 60ch;
422
+ }
423
+
424
+ .n133-admin .game-card .switch-review {
425
+ margin: 4px 0 0;
426
+ padding-top: 12px;
427
+ border-top: 1px solid var(--line);
428
+ }
429
+
430
+ .n133-admin .game-card .switch-review a {
431
+ display: inline-block;
432
+ padding: 10px 0;
433
+ color: var(--ink);
434
+ font-weight: 700;
435
+ }
436
+
437
+ .n133-admin .switch {
438
+ display: inline-flex;
439
+ align-items: center;
440
+ justify-content: space-between;
441
+ gap: 10px;
442
+ min-width: 96px;
443
+ min-height: 44px;
444
+ /* Off: the knob is on the left and the word on the right. On: the word on the left and the knob on the right (the usual switch). */
445
+ flex-direction: row-reverse;
446
+ padding: 0 14px 0 6px;
447
+ border: 2px solid var(--line);
448
+ border-radius: 999px;
449
+ background: var(--paper);
450
+ color: var(--ink);
451
+ font: 700 14px/1 var(--font);
452
+ cursor: pointer;
453
+ }
454
+
455
+ .n133-admin .switch .switch-knob {
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 50%;
459
+ background: var(--line);
460
+ }
461
+
462
+ .n133-admin .switch.switch-on {
463
+ border-color: var(--gem-dark);
464
+ background: var(--gem);
465
+ color: #052b23;
466
+ flex-direction: row;
467
+ padding: 0 6px 0 14px;
468
+ }
469
+
470
+ .n133-admin .switch.switch-on .switch-knob {
471
+ background: var(--paper);
472
+ }
473
+
474
+ .n133-admin .switch:focus-visible {
475
+ outline: 3px solid var(--sea);
476
+ outline-offset: 2px;
477
+ }
478
+
479
+ .n133-admin .switch:disabled {
480
+ opacity: 0.6;
481
+ cursor: progress;
482
+ }
483
+
484
+ /* The slots of a look in the Shelf's editor: a name and a value in a row, and Remove; on a narrow phone Remove goes under them. */
485
+ .n133-admin .shelf-slots-edit {
486
+ gap: 8px;
487
+ }
488
+
489
+ .n133-admin .shelf-slot-list {
490
+ display: grid;
491
+ gap: 8px;
492
+ }
493
+
494
+ .n133-admin .shelf-slot {
495
+ display: grid;
496
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) auto;
497
+ gap: 8px;
498
+ align-items: center;
499
+ }
500
+
501
+ @media (max-width: 480px) {
502
+ .n133-admin .shelf-slot {
503
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
504
+ }
505
+
506
+ .n133-admin .shelf-slot .btn {
507
+ grid-column: 1 / -1;
508
+ }
509
+ }
510
+
511
+ .n133-admin .shelf-slots-edit > .btn {
512
+ justify-self: start;
513
+ }
514
+
515
+ .n133-admin .shelf-slots {
516
+ overflow-wrap: anywhere;
517
+ }
518
+
519
+ /* The Sign-ins card: a list of the last sign-ins, and the two-step "Sign out everywhere". */
520
+ .n133-admin .signin-list {
521
+ display: grid;
522
+ gap: 8px;
523
+ margin: 8px 0;
524
+ padding: 0;
525
+ list-style: none;
526
+ }
527
+
528
+ .n133-admin .signin-list li {
529
+ padding: 8px 0;
530
+ border-bottom: 1px solid var(--line);
531
+ overflow-wrap: anywhere;
532
+ }
533
+
534
+ .n133-admin .signout-box {
535
+ display: grid;
536
+ gap: 8px;
537
+ justify-items: start;
538
+ margin-top: 8px;
539
+ }
540
+
541
+ .n133-admin .signout-confirm {
542
+ display: grid;
543
+ gap: 8px;
544
+ padding: 12px;
545
+ border-radius: 12px;
546
+ background: #fff4f6;
547
+ }
548
+
549
+ .n133-admin .signout-confirm p {
550
+ margin: 0;
551
+ }