@nano133/nanoterra 0.5.0 → 0.6.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 (70) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +133 -8
  3. package/ai/SPEC.md +25 -3
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +13 -5
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +3 -3
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +2 -2
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.d.ts +1 -1
  16. package/dist/admin/panel/gameCard.js +56 -6
  17. package/dist/admin/panel/mount.js +1 -1
  18. package/dist/admin/panel/overview.d.ts +1 -1
  19. package/dist/admin/panel/overview.js +4 -2
  20. package/dist/admin/store.d.ts +2 -1
  21. package/dist/admin/switches.d.ts +23 -1
  22. package/dist/admin/switches.js +26 -5
  23. package/dist/friends/adapters/d1.d.ts +4 -0
  24. package/dist/friends/adapters/d1.js +131 -0
  25. package/dist/friends/adapters/firestore.d.ts +49 -0
  26. package/dist/friends/adapters/firestore.js +163 -0
  27. package/dist/friends/conformance.d.ts +11 -0
  28. package/dist/friends/conformance.js +229 -0
  29. package/dist/friends/handler.d.ts +31 -0
  30. package/dist/friends/handler.js +139 -0
  31. package/dist/friends/index.d.ts +8 -0
  32. package/dist/friends/index.js +12 -0
  33. package/dist/friends/memory.d.ts +9 -0
  34. package/dist/friends/memory.js +79 -0
  35. package/dist/friends/panel/index.d.ts +4 -0
  36. package/dist/friends/panel/index.js +10 -0
  37. package/dist/friends/panel/mount.d.ts +38 -0
  38. package/dist/friends/panel/mount.js +321 -0
  39. package/dist/friends/panel/window.d.ts +52 -0
  40. package/dist/friends/panel/window.js +72 -0
  41. package/dist/friends/rules.d.ts +92 -0
  42. package/dist/friends/rules.js +180 -0
  43. package/dist/friends/types.d.ts +58 -0
  44. package/dist/friends/types.js +24 -0
  45. package/package.json +11 -4
  46. package/src/admin/adapters/d1.ts +5 -4
  47. package/src/admin/adapters/firestore.ts +6 -5
  48. package/src/admin/conformance.ts +15 -6
  49. package/src/admin/handler.ts +3 -3
  50. package/src/admin/index.ts +3 -3
  51. package/src/admin/memory.ts +2 -1
  52. package/src/admin/panel/gameCard.ts +52 -6
  53. package/src/admin/panel/mount.ts +1 -1
  54. package/src/admin/panel/overview.ts +4 -3
  55. package/src/admin/panel/style.css +10 -1
  56. package/src/admin/store.ts +2 -2
  57. package/src/admin/switches.ts +35 -9
  58. package/src/friends/adapters/d1.sql +33 -0
  59. package/src/friends/adapters/d1.ts +146 -0
  60. package/src/friends/adapters/firestore.ts +197 -0
  61. package/src/friends/conformance.ts +260 -0
  62. package/src/friends/handler.ts +144 -0
  63. package/src/friends/index.ts +17 -0
  64. package/src/friends/memory.ts +83 -0
  65. package/src/friends/panel/index.ts +13 -0
  66. package/src/friends/panel/mount.ts +346 -0
  67. package/src/friends/panel/style.css +303 -0
  68. package/src/friends/panel/window.ts +79 -0
  69. package/src/friends/rules.ts +196 -0
  70. package/src/friends/types.ts +57 -0
@@ -0,0 +1,346 @@
1
+ // The Friends key and panel of a game: `mountFriends(element, options)`. Plain DOM, no framework, no Three.js. The mode is the admin's friends
2
+ // setting (GET {admin}/game gives it as `friends`; a game with no admin passes its own word):
3
+ //
4
+ // "nanoterra" the key opens the nano133 window (./window.ts, contract v1). The window is made at once and kept while the panel is closed: it holds
5
+ // the player's live connection, which tells their friends "Playing <game>" and keeps the badge true. Closed, it is hidden (not shown,
6
+ // off the screen, out of the tab order), never removed or `display: none`. `destroy()` removes it. The badge shows the requests
7
+ // (the thing to do) when there are any, else the friends online, in another colour.
8
+ // "own" the key opens the game's own list, from the handler of "@nano133/nanoterra/friends" at `base`: the view every 20 s while open (every
9
+ // 60 s while closed, for the badge), a pulse every 45 s with this tab's lease, and a "leave" pulse when the page goes away.
10
+ // "off" nothing is drawn.
11
+ //
12
+ // The panel is phone first: a sheet from the bottom on a phone held upright, from the side on a wide or sideways screen; every key is 44 px.
13
+
14
+ import { ApiError, createApi, type Api } from "../../admin/panel/api.js";
15
+ import { h } from "../../admin/panel/dom.js";
16
+ import { icon } from "../../admin/panel/icons.js";
17
+ import type { FriendRow, FriendView } from "../types.js";
18
+ import { readWindowMessage, toWindow, whereText, windowUrl, type ToWindow } from "./window.js";
19
+
20
+ export type FriendsMode = "nanoterra" | "own" | "off";
21
+
22
+ export type FriendsMountOptions = {
23
+ mode: FriendsMode;
24
+ /** The game's id on Nanoterra (its listing's id): the window shows the player as "Playing" it. */
25
+ game: string;
26
+ /** nano133's origin. Default "https://nano133.com". */
27
+ origin?: string;
28
+ /** The handler's path for the game's own friends. Default "/friends". */
29
+ base?: string;
30
+ /** Where the player is now, in the game's own words ("Level 2", a room): friends see it. Plain text, at most 64 characters. Change it with `setWhere`. */
31
+ where?: string | null;
32
+ /**
33
+ * The player chose to join a friend. "nanoterra": `target` is the friend's https address (the kit has asked the player already) and there is no
34
+ * `friend` (the window sends no name); with no `onJoin` the page goes there. "own": `target` is the `where` of the friend's invite, for the game to
35
+ * move the player, and `friend` names them (with no `onJoin`, there is no Join key).
36
+ */
37
+ onJoin?: (target: string, friend?: { name: string }) => void;
38
+ /** The `fetch` to use (default: the page's). */
39
+ fetch?: typeof fetch;
40
+ /** Asks the player a yes or no question (default: `window.confirm`). */
41
+ confirm?: (question: string) => boolean;
42
+ };
43
+
44
+ export type MountedFriends = {
45
+ /** Sets where the player is (friends see it). Null: no place. */
46
+ setWhere(where: string | null): void;
47
+ open(): void;
48
+ close(): void;
49
+ /** Removes the key, the panel and the window, and stops every timer. */
50
+ destroy(): void;
51
+ };
52
+
53
+ export const FRIENDS_TIMING = { viewOpenMs: 20_000, viewClosedMs: 60_000, pulseMs: 45_000 } as const;
54
+
55
+ const NOTHING: MountedFriends = { setWhere() {}, open() {}, close() {}, destroy() {} };
56
+
57
+ let mounts = 0;
58
+
59
+ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): MountedFriends {
60
+ if (options.mode !== "nanoterra" && options.mode !== "own") return NOTHING;
61
+ const n = ++mounts;
62
+ const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
63
+ const ask = options.confirm ?? ((q: string) => window.confirm(q));
64
+ let where = options.where === undefined ? null : whereText(options.where);
65
+ let isOpen = false;
66
+ let destroyed = false;
67
+ const cleanup: (() => void)[] = [];
68
+
69
+ const badge = h("span", { class: "n133f-badge", "aria-hidden": "true" });
70
+ badge.hidden = true;
71
+ const keyWords = h("span", { class: "n133f-key-word" }, "Friends");
72
+ const key = h("button", { class: "n133f-key", type: "button", "aria-expanded": "false", "aria-controls": `n133f-panel-${n}` }, icon("players", 20), keyWords, badge);
73
+ const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
74
+ const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
75
+ const body = h("div", { class: "n133f-body" });
76
+ const panel = h("section", { class: `n133f-panel n133f-${options.mode}`, id: `n133f-panel-${n}`, role: "dialog", "aria-labelledby": `n133f-title-${n}`, "data-open": "false" }, h("header", { class: "n133f-head" }, title, closeKey), body);
77
+ const wrap = h("div", { class: "n133-friends" }, key, panel);
78
+ root.append(wrap);
79
+
80
+ /** The requests (the thing to do) when there are any, else the friends online (another colour). The key's name says both. */
81
+ const setBadge = ({ requests, online }: { requests: number; online: number }) => {
82
+ const shown = requests > 0 ? requests : online;
83
+ badge.hidden = shown <= 0;
84
+ badge.classList.toggle("is-online", requests <= 0 && online > 0);
85
+ badge.textContent = shown > 9 ? "9+" : String(shown);
86
+ const parts = [requests > 0 ? `${requests} ${requests === 1 ? "request" : "requests"}` : "", online > 0 ? `${online} online` : ""].filter(Boolean);
87
+ key.setAttribute("aria-label", parts.length ? `Friends, ${parts.join(", ")}` : "Friends");
88
+ };
89
+
90
+ let onOpen = () => {};
91
+ let onClose = () => {};
92
+ const open = () => {
93
+ if (destroyed || isOpen) return;
94
+ isOpen = true;
95
+ panel.dataset.open = "true";
96
+ key.setAttribute("aria-expanded", "true");
97
+ onOpen();
98
+ closeKey.focus();
99
+ };
100
+ const close = () => {
101
+ if (destroyed || !isOpen) return;
102
+ isOpen = false;
103
+ panel.dataset.open = "false";
104
+ key.setAttribute("aria-expanded", "false");
105
+ onClose();
106
+ key.focus();
107
+ };
108
+ key.addEventListener("click", () => (isOpen ? close() : open()));
109
+ closeKey.addEventListener("click", close);
110
+ const onKey = (e: KeyboardEvent) => {
111
+ if (e.key === "Escape" && isOpen) close();
112
+ };
113
+ document.addEventListener("keydown", onKey);
114
+ cleanup.push(() => document.removeEventListener("keydown", onKey));
115
+
116
+ let setWhereHere = (_w: string | null) => {};
117
+
118
+ if (options.mode === "nanoterra") {
119
+ // The window, made at once (no lazy loading) and kept while the panel is closed.
120
+ const frame = h("iframe", {
121
+ class: "n133f-window",
122
+ src: windowUrl(origin, options.game),
123
+ title: "Friends",
124
+ referrerpolicy: "origin",
125
+ allow: "storage-access",
126
+ sandbox: "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation",
127
+ });
128
+ body.append(frame);
129
+ const hideFrame = (hidden: boolean) => {
130
+ if (hidden) {
131
+ frame.setAttribute("aria-hidden", "true");
132
+ frame.tabIndex = -1;
133
+ } else {
134
+ frame.removeAttribute("aria-hidden");
135
+ frame.removeAttribute("tabindex");
136
+ }
137
+ };
138
+ hideFrame(true);
139
+ let ready = false;
140
+ const send = (msg: ToWindow) => {
141
+ if (ready) frame.contentWindow?.postMessage(msg, origin);
142
+ };
143
+ onOpen = () => {
144
+ hideFrame(false);
145
+ send(toWindow({ type: "open" }));
146
+ };
147
+ onClose = () => {
148
+ hideFrame(true);
149
+ send(toWindow({ type: "closed" }));
150
+ };
151
+ setWhereHere = (w) => send(toWindow({ type: "where", where: w }));
152
+ const onMessage = (e: MessageEvent) => {
153
+ // Only the window itself, from nano133's origin.
154
+ if (e.origin !== origin || e.source !== frame.contentWindow) return;
155
+ const m = readWindowMessage(e.data);
156
+ if (!m) return;
157
+ if (m.type === "ready") {
158
+ // Right after ready: where the player is, then whether the panel is open.
159
+ ready = true;
160
+ send(toWindow({ type: "where", where }));
161
+ send(toWindow({ type: isOpen ? "open" : "closed" }));
162
+ } else if (m.type === "size") {
163
+ // The height its content needs, at most the panel's own room.
164
+ const room = body.clientHeight;
165
+ frame.style.height = `${room > 0 ? Math.min(m.height, room) : m.height}px`;
166
+ } else if (m.type === "badge") setBadge(m);
167
+ else if (m.type === "join") {
168
+ if (!ask("Leave this game to join your friend?")) return;
169
+ if (options.onJoin) options.onJoin(m.url);
170
+ else window.location.assign(m.url);
171
+ }
172
+ // "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
173
+ };
174
+ window.addEventListener("message", onMessage);
175
+ cleanup.push(() => window.removeEventListener("message", onMessage));
176
+ } else {
177
+ const own = ownList(body, options, () => where, setBadge, ask);
178
+ onOpen = own.opened;
179
+ onClose = own.closed;
180
+ setWhereHere = own.moved;
181
+ cleanup.push(own.stop);
182
+ }
183
+
184
+ return {
185
+ setWhere(w) {
186
+ where = whereText(w);
187
+ setWhereHere(where);
188
+ },
189
+ open,
190
+ close,
191
+ destroy() {
192
+ if (destroyed) return;
193
+ destroyed = true;
194
+ for (const f of cleanup) f();
195
+ wrap.remove();
196
+ },
197
+ };
198
+ }
199
+
200
+ /** The game's own list in `body`. */
201
+ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string) => boolean) {
202
+ const api: Api = createApi(options.base ?? "/friends", options.fetch);
203
+ const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
204
+ const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
205
+ const nameInput = h("input", { class: "n133f-input", type: "text", name: "friend", maxlength: 64, autocomplete: "off", placeholder: "A player's name", "aria-label": "A player's name" });
206
+ const addKey = h("button", { class: "n133f-btn n133f-primary", type: "submit" }, "Add");
207
+ const form = h("form", { class: "n133f-add" }, nameInput, addKey);
208
+ const list = h("div", { class: "n133f-list" });
209
+ const show = h("input", { type: "checkbox", class: "n133f-check" });
210
+ const showRow = h("label", { class: "n133f-show" }, show, h("span", {}, "Show friends when I am online"));
211
+ body.append(form, status, list, showRow);
212
+
213
+ let view: FriendView | null = null;
214
+ let isOpen = false;
215
+ let stopped = false;
216
+ let timer: ReturnType<typeof setTimeout> | null = null;
217
+
218
+ const fail = (e: unknown) => {
219
+ status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Try again.";
220
+ };
221
+ const draw = () => {
222
+ if (!view) return;
223
+ show.checked = view.showOnline;
224
+ const requests = view.people.filter((p) => p.state === "incoming" || (p.state === "friend" && p.invite)).length;
225
+ setBadge({ requests, online: view.people.filter((p) => p.state === "friend" && p.online).length });
226
+ const groups: [string, FriendRow[]][] = [
227
+ ["Requests", view.people.filter((p) => p.state === "incoming")],
228
+ ["Friends", view.people.filter((p) => p.state === "friend")],
229
+ ["Sent", view.people.filter((p) => p.state === "outgoing")],
230
+ ["Blocked", view.people.filter((p) => p.state === "blocked")],
231
+ ];
232
+ const parts: HTMLElement[] = [];
233
+ for (const [label, rows] of groups) {
234
+ if (!rows.length) continue;
235
+ parts.push(h("h3", { class: "n133f-group" }, `${label} (${rows.length})`), h("ul", { class: "n133f-rows" }, ...rows.map(rowEl)));
236
+ }
237
+ if (!parts.length) parts.push(h("p", { class: "n133f-empty" }, "No friends yet. Add a player by their name."));
238
+ list.replaceChildren(...parts);
239
+ };
240
+ const act = async (body: Record<string, unknown>, done?: string) => {
241
+ try {
242
+ view = await api.post<FriendView>("", body);
243
+ status.textContent = done ?? "";
244
+ draw();
245
+ } catch (e) {
246
+ fail(e);
247
+ }
248
+ };
249
+ const btn = (words: string, onclick: () => void, kind = "") => h("button", { class: `n133f-btn ${kind}`.trim(), type: "button", onclick }, words);
250
+ function rowEl(p: FriendRow): HTMLElement {
251
+ const keys: HTMLElement[] = [];
252
+ if (p.state === "incoming") keys.push(btn("Accept", () => void act({ op: "accept", edge: p.edge }, `${p.name} is your friend now.`), "n133f-primary"), btn("Decline", () => void act({ op: "decline", edge: p.edge })));
253
+ if (p.state === "friend") {
254
+ if (p.invite && options.onJoin) keys.push(btn("Join", () => options.onJoin!(p.invite!.where, { name: p.name }), "n133f-primary"));
255
+ const here = whereNow();
256
+ if (here) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
257
+ keys.push(btn("Remove", () => ask(`Remove ${p.name} from your friends?`) && void act({ op: "remove", edge: p.edge })));
258
+ keys.push(btn("Block", () => ask(`Block ${p.name}? They will not see you or be able to ask you again.`) && void act({ op: "block", edge: p.edge }), "n133f-quiet"));
259
+ }
260
+ if (p.state === "outgoing") keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
261
+ if (p.state === "blocked") keys.push(btn("Unblock", () => void act({ op: "unblock", edge: p.edge })));
262
+ const line = p.state === "friend" ? (p.online ? p.where ?? "Online" : "Offline") : p.state === "incoming" ? "Wants to be friends" : p.state === "outgoing" ? "Asked" : "Blocked";
263
+ const invite = p.state === "friend" && p.invite ? h("p", { class: "n133f-invite" }, `Invites you: ${p.invite.where}`) : null;
264
+ return h(
265
+ "li",
266
+ { class: `n133f-row n133f-${p.state}${p.online ? " is-online" : ""}` },
267
+ h("span", { class: "n133f-dot", "aria-hidden": "true" }),
268
+ h("div", { class: "n133f-who" }, h("strong", {}, p.name), h("span", { class: "n133f-line" }, p.online ? `Online · ${line}` : line), invite),
269
+ h("div", { class: "n133f-keys" }, ...keys),
270
+ );
271
+ }
272
+
273
+ const read = async () => {
274
+ try {
275
+ view = await api.get<FriendView>("");
276
+ draw();
277
+ } catch (e) {
278
+ if (isOpen) fail(e);
279
+ }
280
+ };
281
+ const pulse = async (place: string | null) => {
282
+ try {
283
+ await api.post("", { op: "pulse", where: place, lease });
284
+ } catch {
285
+ // A missed pulse only makes the player look offline for a while.
286
+ }
287
+ };
288
+ /** The next read of the view. One timer at a time: a new one replaces the old. */
289
+ const loop = () => {
290
+ if (timer) clearTimeout(timer);
291
+ if (stopped) return;
292
+ timer = setTimeout(
293
+ () => {
294
+ void read().finally(loop);
295
+ },
296
+ isOpen ? FRIENDS_TIMING.viewOpenMs : FRIENDS_TIMING.viewClosedMs,
297
+ );
298
+ };
299
+ const beat = setInterval(() => {
300
+ const here = whereNow();
301
+ if (here) void pulse(here);
302
+ }, FRIENDS_TIMING.pulseMs);
303
+ const leave = () => {
304
+ // The page goes away: this tab's place is cleared (an old tab cannot clear a newer one: the lease).
305
+ void (options.fetch ?? fetch)(options.base ?? "/friends", { method: "POST", credentials: "same-origin", keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "pulse", where: null, lease }) }).catch(() => undefined);
306
+ };
307
+ window.addEventListener("pagehide", leave);
308
+
309
+ form.addEventListener("submit", (e) => {
310
+ e.preventDefault();
311
+ const name = nameInput.value.trim();
312
+ if (!name) return;
313
+ void act({ op: "request", name }, `Asked ${name}.`).then(() => {
314
+ if (!status.textContent?.startsWith("Asked")) return;
315
+ nameInput.value = "";
316
+ });
317
+ });
318
+ show.addEventListener("change", () => void act({ op: "visibility", showOnline: show.checked }));
319
+
320
+ void read();
321
+ if (whereNow()) void pulse(whereNow());
322
+ loop();
323
+
324
+ return {
325
+ opened() {
326
+ isOpen = true;
327
+ status.textContent = "";
328
+ void read();
329
+ loop();
330
+ },
331
+ closed() {
332
+ isOpen = false;
333
+ },
334
+ moved(place: string | null) {
335
+ void pulse(place);
336
+ draw();
337
+ },
338
+ stop() {
339
+ stopped = true;
340
+ if (timer) clearTimeout(timer);
341
+ clearInterval(beat);
342
+ window.removeEventListener("pagehide", leave);
343
+ leave();
344
+ },
345
+ };
346
+ }
@@ -0,0 +1,303 @@
1
+ /* The Friends key and panel (mountFriends in ./mount.ts). Every rule is under .n133-friends, so nothing leaks into the game's page.
2
+ Phone first: every key is 44 px or more. On a phone held upright the panel is a sheet from the bottom; on a wide or sideways screen it is a
3
+ sheet on the right. In "nanoterra" mode the nano133 window stays in the page while the panel is closed (it holds the live connection): the
4
+ closed panel is hidden and moved off the screen, never display: none.
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,
6
+ --n133-line, --n133-gem, --n133-gem-dark, --n133-radius, --n133-font and --n133-friends-z (the panel's z-index, default 1000). */
7
+
8
+ .n133-friends {
9
+ --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
10
+ --gem: var(--n133-gem, #20c9a6);
11
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
12
+ --ink: var(--n133-ink, #16202e);
13
+ --ink-soft: var(--n133-ink-soft, #4a5568);
14
+ --line: var(--n133-line, #dbe4ee);
15
+ --paper: var(--n133-paper, #ffffff);
16
+ --radius: var(--n133-radius, 18px);
17
+ display: inline-block;
18
+ color: var(--ink);
19
+ font: 15px/1.4 var(--font);
20
+ }
21
+
22
+ .n133-friends *,
23
+ .n133-friends *::before,
24
+ .n133-friends *::after {
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ .n133-friends .n133f-key {
29
+ position: relative;
30
+ display: inline-flex;
31
+ align-items: center;
32
+ gap: 8px;
33
+ min-width: 44px;
34
+ min-height: 44px;
35
+ padding: 0 14px;
36
+ border: 1px solid var(--line);
37
+ border-radius: 999px;
38
+ background: var(--paper);
39
+ color: var(--ink);
40
+ font: 650 15px/1 var(--font);
41
+ cursor: pointer;
42
+ touch-action: manipulation;
43
+ }
44
+
45
+ .n133-friends .n133f-badge {
46
+ min-width: 20px;
47
+ height: 20px;
48
+ padding: 0 6px;
49
+ border-radius: 10px;
50
+ background: #d92d4b;
51
+ color: #fff;
52
+ font: 700 12px/20px var(--font);
53
+ text-align: center;
54
+ }
55
+
56
+ /* No requests: the badge counts the friends online, in the gem colour. */
57
+ .n133-friends .n133f-badge.is-online {
58
+ background: var(--gem-dark);
59
+ }
60
+
61
+ .n133-friends .n133f-panel {
62
+ position: fixed;
63
+ z-index: var(--n133-friends-z, 1000);
64
+ right: 0;
65
+ bottom: 0;
66
+ left: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ height: min(80vh, 640px);
70
+ height: min(80dvh, 640px);
71
+ border: 1px solid var(--line);
72
+ border-radius: var(--radius) var(--radius) 0 0;
73
+ background: var(--paper);
74
+ box-shadow: 0 -8px 30px rgb(16 24 40 / 0.18);
75
+ padding-bottom: env(safe-area-inset-bottom);
76
+ }
77
+
78
+ /* Closed: not shown, off the screen and out of the tab order, but still in the page (the window keeps its connection). */
79
+ .n133-friends .n133f-panel[data-open="false"] {
80
+ visibility: hidden;
81
+ pointer-events: none;
82
+ transform: translateY(110%);
83
+ }
84
+
85
+ @media (min-width: 640px), (orientation: landscape) and (max-height: 520px) {
86
+ .n133-friends .n133f-panel {
87
+ top: 0;
88
+ left: auto;
89
+ width: min(380px, 100vw);
90
+ height: auto;
91
+ border-radius: var(--radius) 0 0 var(--radius);
92
+ box-shadow: -8px 0 30px rgb(16 24 40 / 0.18);
93
+ padding-bottom: 0;
94
+ padding-right: env(safe-area-inset-right);
95
+ }
96
+
97
+ .n133-friends .n133f-panel[data-open="false"] {
98
+ transform: translateX(110%);
99
+ }
100
+ }
101
+
102
+ @media (prefers-reduced-motion: no-preference) {
103
+ .n133-friends .n133f-panel {
104
+ transition: transform 180ms ease-out, visibility 0s linear 180ms;
105
+ }
106
+
107
+ .n133-friends .n133f-panel[data-open="true"] {
108
+ transition: transform 180ms ease-out, visibility 0s;
109
+ }
110
+ }
111
+
112
+ .n133-friends .n133f-head {
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: space-between;
116
+ gap: 8px;
117
+ padding: 8px 8px 8px 18px;
118
+ border-bottom: 1px solid var(--line);
119
+ }
120
+
121
+ .n133-friends .n133f-title {
122
+ margin: 0;
123
+ font: 750 18px/1.2 var(--font);
124
+ }
125
+
126
+ .n133-friends .n133f-close {
127
+ display: grid;
128
+ place-items: center;
129
+ width: 44px;
130
+ height: 44px;
131
+ border: 0;
132
+ border-radius: 12px;
133
+ background: transparent;
134
+ color: var(--ink);
135
+ cursor: pointer;
136
+ }
137
+
138
+ .n133-friends .n133f-body {
139
+ flex: 1 1 auto;
140
+ min-height: 0;
141
+ overflow: auto;
142
+ overscroll-behavior: contain;
143
+ }
144
+
145
+ .n133-friends .n133f-nanoterra .n133f-body {
146
+ overflow: hidden;
147
+ }
148
+
149
+ .n133-friends .n133f-window {
150
+ display: block;
151
+ width: 100%;
152
+ height: 100%;
153
+ border: 0;
154
+ }
155
+
156
+ .n133-friends .n133f-own .n133f-body {
157
+ padding: 12px 16px 16px;
158
+ }
159
+
160
+ .n133-friends .n133f-add {
161
+ display: flex;
162
+ gap: 8px;
163
+ }
164
+
165
+ .n133-friends .n133f-input {
166
+ flex: 1 1 auto;
167
+ min-width: 0;
168
+ min-height: 44px;
169
+ padding: 0 12px;
170
+ border: 1px solid var(--line);
171
+ border-radius: 12px;
172
+ background: var(--paper);
173
+ color: var(--ink);
174
+ font: 16px/1 var(--font);
175
+ }
176
+
177
+ .n133-friends .n133f-btn {
178
+ min-height: 44px;
179
+ padding: 0 14px;
180
+ border: 1px solid var(--line);
181
+ border-radius: 12px;
182
+ background: var(--paper);
183
+ color: var(--ink);
184
+ font: 650 14px/1 var(--font);
185
+ white-space: nowrap;
186
+ cursor: pointer;
187
+ touch-action: manipulation;
188
+ }
189
+
190
+ .n133-friends .n133f-primary {
191
+ border-color: var(--gem-dark);
192
+ background: var(--gem-dark);
193
+ color: #fff;
194
+ }
195
+
196
+ .n133-friends .n133f-quiet {
197
+ color: var(--ink-soft);
198
+ }
199
+
200
+ .n133-friends :is(.n133f-key, .n133f-close, .n133f-btn, .n133f-input, .n133f-check):focus-visible {
201
+ outline: 3px solid var(--gem);
202
+ outline-offset: 2px;
203
+ }
204
+
205
+ .n133-friends .n133f-status:empty {
206
+ display: none;
207
+ }
208
+
209
+ .n133-friends .n133f-status {
210
+ margin: 8px 0 0;
211
+ color: var(--ink-soft);
212
+ font-size: 14px;
213
+ }
214
+
215
+ .n133-friends .n133f-group {
216
+ margin: 16px 0 6px;
217
+ color: var(--ink-soft);
218
+ font: 700 13px/1.2 var(--font);
219
+ text-transform: uppercase;
220
+ letter-spacing: 0.04em;
221
+ }
222
+
223
+ .n133-friends .n133f-rows {
224
+ display: grid;
225
+ gap: 8px;
226
+ margin: 0;
227
+ padding: 0;
228
+ list-style: none;
229
+ }
230
+
231
+ .n133-friends .n133f-row {
232
+ display: grid;
233
+ grid-template-columns: 12px minmax(0, 1fr);
234
+ gap: 4px 10px;
235
+ align-items: start;
236
+ padding: 10px 12px;
237
+ border: 1px solid var(--line);
238
+ border-radius: 14px;
239
+ }
240
+
241
+ .n133-friends .n133f-dot {
242
+ width: 10px;
243
+ height: 10px;
244
+ margin-top: 6px;
245
+ border-radius: 50%;
246
+ background: var(--line);
247
+ }
248
+
249
+ .n133-friends .n133f-row.is-online .n133f-dot {
250
+ background: var(--gem);
251
+ }
252
+
253
+ .n133-friends .n133f-who {
254
+ display: grid;
255
+ min-width: 0;
256
+ }
257
+
258
+ .n133-friends .n133f-who strong {
259
+ overflow-wrap: anywhere;
260
+ }
261
+
262
+ .n133-friends .n133f-line,
263
+ .n133-friends .n133f-invite {
264
+ margin: 0;
265
+ color: var(--ink-soft);
266
+ font-size: 14px;
267
+ overflow-wrap: anywhere;
268
+ }
269
+
270
+ .n133-friends .n133f-invite {
271
+ color: var(--gem-dark);
272
+ font-weight: 650;
273
+ }
274
+
275
+ .n133-friends .n133f-keys {
276
+ display: flex;
277
+ flex-wrap: wrap;
278
+ grid-column: 2;
279
+ gap: 6px;
280
+ }
281
+
282
+ .n133-friends .n133f-keys:empty {
283
+ display: none;
284
+ }
285
+
286
+ .n133-friends .n133f-empty {
287
+ margin: 16px 0;
288
+ color: var(--ink-soft);
289
+ }
290
+
291
+ .n133-friends .n133f-show {
292
+ display: flex;
293
+ align-items: center;
294
+ gap: 10px;
295
+ min-height: 44px;
296
+ margin-top: 16px;
297
+ cursor: pointer;
298
+ }
299
+
300
+ .n133-friends .n133f-check {
301
+ width: 22px;
302
+ height: 22px;
303
+ }