@nano133/nanoterra 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +4 -1
- package/dist/friends/panel/mount.d.ts +3 -3
- package/dist/friends/panel/mount.js +46 -12
- package/package.json +1 -1
- package/src/friends/panel/mount.ts +47 -13
- package/src/friends/panel/style.css +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 (2026-10-31)
|
|
4
|
+
|
|
5
|
+
- **Friends: the panel asks in the panel, never in a browser dialog.** Remove, Block and Join (both modes) showed `window.confirm`; now the question and a key for
|
|
6
|
+
each answer show at the top of the panel, which opens for it. Escape or closing the panel answers no. The `confirm` option stays, and may return a promise.
|
|
7
|
+
- **Friends, "own": no Invite key without `onJoin`.** A game with no way to join a friend gave an invite that led nowhere.
|
|
8
|
+
|
|
3
9
|
## 0.7.0 (2026-10-30)
|
|
4
10
|
|
|
5
11
|
- **The admin panel has a light and a dark theme** (`/admin/panel`, `/admin/style.css`). A theme key in the top bar (and on the signed-out screen) opens Device,
|
package/README.md
CHANGED
|
@@ -812,10 +812,13 @@ for (const c of friendsStoreConformance(async () => ({ store: newEmptyStore(), d
|
|
|
812
812
|
```
|
|
813
813
|
|
|
814
814
|
**The panel in `"own"` mode** reads the view at once, every 20 s while open and every 60 s while closed (for the badge), pulses every 45 s while it has a `where`, and
|
|
815
|
-
sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where
|
|
815
|
+
sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where` and Join an invite, both only when you
|
|
816
816
|
give `onJoin`, Remove, Block), Sent (Cancel) and Blocked (Unblock), an add-by-name field and the "show friends when I am online" choice. The game's own friends have
|
|
817
817
|
no live connection: only Nanoterra's do.
|
|
818
818
|
|
|
819
|
+
**Questions.** Remove, Block and (in both modes) Join ask the player first, in the panel itself: the question and a key for each answer; Escape or closing the
|
|
820
|
+
panel answers no. The kit never opens a browser dialog. To ask in your game's own dialog, pass `confirm(question)`, which returns a boolean or a promise of one.
|
|
821
|
+
|
|
819
822
|
## Moving from the three old packages
|
|
820
823
|
|
|
821
824
|
| Before | Now |
|
|
@@ -12,15 +12,15 @@ export type FriendsMountOptions = {
|
|
|
12
12
|
/**
|
|
13
13
|
* 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
|
|
14
14
|
* `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
|
|
15
|
-
* move the player, and `friend` names them (with no `onJoin`, there is no Join key).
|
|
15
|
+
* move the player, and `friend` names them (with no `onJoin`, there is no Join key and no Invite key: an invite would lead nowhere).
|
|
16
16
|
*/
|
|
17
17
|
onJoin?: (target: string, friend?: {
|
|
18
18
|
name: string;
|
|
19
19
|
}) => void;
|
|
20
20
|
/** The `fetch` to use (default: the page's). */
|
|
21
21
|
fetch?: typeof fetch;
|
|
22
|
-
/** Asks the player a yes or no question (default:
|
|
23
|
-
confirm?: (question: string) => boolean
|
|
22
|
+
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
23
|
+
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
24
24
|
};
|
|
25
25
|
export type MountedFriends = {
|
|
26
26
|
/** Sets where the player is (friends see it). Null: no place. */
|
|
@@ -22,7 +22,6 @@ export function mountFriends(root, options) {
|
|
|
22
22
|
return NOTHING;
|
|
23
23
|
const n = ++mounts;
|
|
24
24
|
const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
|
|
25
|
-
const ask = options.confirm ?? ((q) => window.confirm(q));
|
|
26
25
|
let where = options.where === undefined ? null : whereText(options.where);
|
|
27
26
|
let isOpen = false;
|
|
28
27
|
let destroyed = false;
|
|
@@ -34,7 +33,12 @@ export function mountFriends(root, options) {
|
|
|
34
33
|
const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
|
|
35
34
|
const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
|
|
36
35
|
const body = h("div", { class: "n133f-body" });
|
|
37
|
-
const
|
|
36
|
+
const question = h("p", { class: "n133f-ask-q", id: `n133f-ask-${n}` });
|
|
37
|
+
const yesKey = h("button", { class: "n133f-btn n133f-primary", type: "button" });
|
|
38
|
+
const noKey = h("button", { class: "n133f-btn", type: "button" }, "Cancel");
|
|
39
|
+
const askBox = h("div", { class: "n133f-ask", role: "alertdialog", "aria-labelledby": `n133f-ask-${n}` }, question, h("div", { class: "n133f-ask-keys" }, yesKey, noKey));
|
|
40
|
+
askBox.hidden = true;
|
|
41
|
+
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), askBox, body);
|
|
38
42
|
const wrap = h("div", { class: "n133-friends" }, key, panel);
|
|
39
43
|
root.append(wrap);
|
|
40
44
|
/** The requests (the thing to do) when there are any, else the friends online (another colour). The key's name says both. */
|
|
@@ -60,6 +64,7 @@ export function mountFriends(root, options) {
|
|
|
60
64
|
const close = () => {
|
|
61
65
|
if (destroyed || !isOpen)
|
|
62
66
|
return;
|
|
67
|
+
answer?.(false);
|
|
63
68
|
isOpen = false;
|
|
64
69
|
panel.dataset.open = "false";
|
|
65
70
|
key.setAttribute("aria-expanded", "false");
|
|
@@ -68,8 +73,34 @@ export function mountFriends(root, options) {
|
|
|
68
73
|
};
|
|
69
74
|
key.addEventListener("click", () => (isOpen ? close() : open()));
|
|
70
75
|
closeKey.addEventListener("click", close);
|
|
76
|
+
/** The question in the panel: one at a time (a new one answers the old with no), and closing the panel answers no. */
|
|
77
|
+
let answer = null;
|
|
78
|
+
const askInPanel = (q, yesWord) => new Promise((resolve) => {
|
|
79
|
+
answer?.(false);
|
|
80
|
+
question.textContent = q;
|
|
81
|
+
yesKey.textContent = yesWord;
|
|
82
|
+
askBox.hidden = false;
|
|
83
|
+
answer = (yes) => {
|
|
84
|
+
answer = null;
|
|
85
|
+
askBox.hidden = true;
|
|
86
|
+
resolve(yes);
|
|
87
|
+
if (isOpen && !destroyed)
|
|
88
|
+
closeKey.focus();
|
|
89
|
+
};
|
|
90
|
+
open();
|
|
91
|
+
yesKey.focus();
|
|
92
|
+
});
|
|
93
|
+
yesKey.addEventListener("click", () => answer?.(true));
|
|
94
|
+
noKey.addEventListener("click", () => answer?.(false));
|
|
95
|
+
const ask = (q, yesWord) => (options.confirm ? Promise.resolve(options.confirm(q)) : askInPanel(q, yesWord));
|
|
96
|
+
cleanup.push(() => answer?.(false));
|
|
71
97
|
const onKey = (e) => {
|
|
72
|
-
if (e.key
|
|
98
|
+
if (e.key !== "Escape" || !isOpen)
|
|
99
|
+
return;
|
|
100
|
+
// Escape answers an open question first, then closes the panel.
|
|
101
|
+
if (answer)
|
|
102
|
+
answer(false);
|
|
103
|
+
else
|
|
73
104
|
close();
|
|
74
105
|
};
|
|
75
106
|
document.addEventListener("keydown", onKey);
|
|
@@ -132,12 +163,15 @@ export function mountFriends(root, options) {
|
|
|
132
163
|
else if (m.type === "badge")
|
|
133
164
|
setBadge(m);
|
|
134
165
|
else if (m.type === "join") {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
const url = m.url;
|
|
167
|
+
void ask("Leave this game to join your friend?", "Join").then((yes) => {
|
|
168
|
+
if (!yes || destroyed)
|
|
169
|
+
return;
|
|
170
|
+
if (options.onJoin)
|
|
171
|
+
options.onJoin(url);
|
|
172
|
+
else
|
|
173
|
+
window.location.assign(url);
|
|
174
|
+
});
|
|
141
175
|
}
|
|
142
176
|
// "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
|
|
143
177
|
};
|
|
@@ -228,10 +262,10 @@ function ownList(body, options, whereNow, setBadge, ask) {
|
|
|
228
262
|
if (p.invite && options.onJoin)
|
|
229
263
|
keys.push(btn("Join", () => options.onJoin(p.invite.where, { name: p.name }), "n133f-primary"));
|
|
230
264
|
const here = whereNow();
|
|
231
|
-
if (here)
|
|
265
|
+
if (here && options.onJoin)
|
|
232
266
|
keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
|
|
233
|
-
keys.push(btn("Remove", () => ask(`Remove ${p.name} from your friends
|
|
234
|
-
keys.push(btn("Block", () => ask(`Block ${p.name}? They will not see you or be able to ask you again
|
|
267
|
+
keys.push(btn("Remove", () => void ask(`Remove ${p.name} from your friends?`, "Remove").then((yes) => (yes ? act({ op: "remove", edge: p.edge }) : undefined))));
|
|
268
|
+
keys.push(btn("Block", () => void ask(`Block ${p.name}? They will not see you or be able to ask you again.`, "Block").then((yes) => (yes ? act({ op: "block", edge: p.edge }) : undefined)), "n133f-quiet"));
|
|
235
269
|
}
|
|
236
270
|
if (p.state === "outgoing")
|
|
237
271
|
keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin and the friends list.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,13 +32,13 @@ export type FriendsMountOptions = {
|
|
|
32
32
|
/**
|
|
33
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
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).
|
|
35
|
+
* move the player, and `friend` names them (with no `onJoin`, there is no Join key and no Invite key: an invite would lead nowhere).
|
|
36
36
|
*/
|
|
37
37
|
onJoin?: (target: string, friend?: { name: string }) => void;
|
|
38
38
|
/** The `fetch` to use (default: the page's). */
|
|
39
39
|
fetch?: typeof fetch;
|
|
40
|
-
/** Asks the player a yes or no question (default:
|
|
41
|
-
confirm?: (question: string) => boolean
|
|
40
|
+
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
41
|
+
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
export type MountedFriends = {
|
|
@@ -60,7 +60,6 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
60
60
|
if (options.mode !== "nanoterra" && options.mode !== "own") return NOTHING;
|
|
61
61
|
const n = ++mounts;
|
|
62
62
|
const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
|
|
63
|
-
const ask = options.confirm ?? ((q: string) => window.confirm(q));
|
|
64
63
|
let where = options.where === undefined ? null : whereText(options.where);
|
|
65
64
|
let isOpen = false;
|
|
66
65
|
let destroyed = false;
|
|
@@ -73,7 +72,12 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
73
72
|
const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
|
|
74
73
|
const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
|
|
75
74
|
const body = h("div", { class: "n133f-body" });
|
|
76
|
-
const
|
|
75
|
+
const question = h("p", { class: "n133f-ask-q", id: `n133f-ask-${n}` });
|
|
76
|
+
const yesKey = h("button", { class: "n133f-btn n133f-primary", type: "button" });
|
|
77
|
+
const noKey = h("button", { class: "n133f-btn", type: "button" }, "Cancel");
|
|
78
|
+
const askBox = h("div", { class: "n133f-ask", role: "alertdialog", "aria-labelledby": `n133f-ask-${n}` }, question, h("div", { class: "n133f-ask-keys" }, yesKey, noKey));
|
|
79
|
+
askBox.hidden = true;
|
|
80
|
+
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), askBox, body);
|
|
77
81
|
const wrap = h("div", { class: "n133-friends" }, key, panel);
|
|
78
82
|
root.append(wrap);
|
|
79
83
|
|
|
@@ -99,6 +103,7 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
99
103
|
};
|
|
100
104
|
const close = () => {
|
|
101
105
|
if (destroyed || !isOpen) return;
|
|
106
|
+
answer?.(false);
|
|
102
107
|
isOpen = false;
|
|
103
108
|
panel.dataset.open = "false";
|
|
104
109
|
key.setAttribute("aria-expanded", "false");
|
|
@@ -107,8 +112,34 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
107
112
|
};
|
|
108
113
|
key.addEventListener("click", () => (isOpen ? close() : open()));
|
|
109
114
|
closeKey.addEventListener("click", close);
|
|
115
|
+
|
|
116
|
+
/** The question in the panel: one at a time (a new one answers the old with no), and closing the panel answers no. */
|
|
117
|
+
let answer: ((yes: boolean) => void) | null = null;
|
|
118
|
+
const askInPanel = (q: string, yesWord: string) =>
|
|
119
|
+
new Promise<boolean>((resolve) => {
|
|
120
|
+
answer?.(false);
|
|
121
|
+
question.textContent = q;
|
|
122
|
+
yesKey.textContent = yesWord;
|
|
123
|
+
askBox.hidden = false;
|
|
124
|
+
answer = (yes) => {
|
|
125
|
+
answer = null;
|
|
126
|
+
askBox.hidden = true;
|
|
127
|
+
resolve(yes);
|
|
128
|
+
if (isOpen && !destroyed) closeKey.focus();
|
|
129
|
+
};
|
|
130
|
+
open();
|
|
131
|
+
yesKey.focus();
|
|
132
|
+
});
|
|
133
|
+
yesKey.addEventListener("click", () => answer?.(true));
|
|
134
|
+
noKey.addEventListener("click", () => answer?.(false));
|
|
135
|
+
const ask = (q: string, yesWord: string): Promise<boolean> => (options.confirm ? Promise.resolve(options.confirm(q)) : askInPanel(q, yesWord));
|
|
136
|
+
cleanup.push(() => answer?.(false));
|
|
137
|
+
|
|
110
138
|
const onKey = (e: KeyboardEvent) => {
|
|
111
|
-
if (e.key
|
|
139
|
+
if (e.key !== "Escape" || !isOpen) return;
|
|
140
|
+
// Escape answers an open question first, then closes the panel.
|
|
141
|
+
if (answer) answer(false);
|
|
142
|
+
else close();
|
|
112
143
|
};
|
|
113
144
|
document.addEventListener("keydown", onKey);
|
|
114
145
|
cleanup.push(() => document.removeEventListener("keydown", onKey));
|
|
@@ -165,9 +196,12 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
165
196
|
frame.style.height = `${room > 0 ? Math.min(m.height, room) : m.height}px`;
|
|
166
197
|
} else if (m.type === "badge") setBadge(m);
|
|
167
198
|
else if (m.type === "join") {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
199
|
+
const url = m.url;
|
|
200
|
+
void ask("Leave this game to join your friend?", "Join").then((yes) => {
|
|
201
|
+
if (!yes || destroyed) return;
|
|
202
|
+
if (options.onJoin) options.onJoin(url);
|
|
203
|
+
else window.location.assign(url);
|
|
204
|
+
});
|
|
171
205
|
}
|
|
172
206
|
// "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
|
|
173
207
|
};
|
|
@@ -198,7 +232,7 @@ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): M
|
|
|
198
232
|
}
|
|
199
233
|
|
|
200
234
|
/** 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) {
|
|
235
|
+
function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string, yesWord: string) => Promise<boolean>) {
|
|
202
236
|
const api: Api = createApi(options.base ?? "/friends", options.fetch);
|
|
203
237
|
const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
204
238
|
const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
|
|
@@ -253,9 +287,9 @@ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () =
|
|
|
253
287
|
if (p.state === "friend") {
|
|
254
288
|
if (p.invite && options.onJoin) keys.push(btn("Join", () => options.onJoin!(p.invite!.where, { name: p.name }), "n133f-primary"));
|
|
255
289
|
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
|
|
258
|
-
keys.push(btn("Block", () => ask(`Block ${p.name}? They will not see you or be able to ask you again
|
|
290
|
+
if (here && options.onJoin) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
|
|
291
|
+
keys.push(btn("Remove", () => void ask(`Remove ${p.name} from your friends?`, "Remove").then((yes) => (yes ? act({ op: "remove", edge: p.edge }) : undefined))));
|
|
292
|
+
keys.push(btn("Block", () => void ask(`Block ${p.name}? They will not see you or be able to ask you again.`, "Block").then((yes) => (yes ? act({ op: "block", edge: p.edge }) : undefined)), "n133f-quiet"));
|
|
259
293
|
}
|
|
260
294
|
if (p.state === "outgoing") keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
|
|
261
295
|
if (p.state === "blocked") keys.push(btn("Unblock", () => void act({ op: "unblock", edge: p.edge })));
|
|
@@ -202,6 +202,32 @@
|
|
|
202
202
|
outline-offset: 2px;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/* The panel's own question (Remove, Block, Join): never a browser dialog. */
|
|
206
|
+
.n133-friends .n133f-ask {
|
|
207
|
+
flex: 0 0 auto;
|
|
208
|
+
margin: 12px 16px 0;
|
|
209
|
+
padding: 12px;
|
|
210
|
+
border: 1px solid var(--gem-dark);
|
|
211
|
+
border-radius: 14px;
|
|
212
|
+
background: var(--paper);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.n133-friends .n133f-ask[hidden] {
|
|
216
|
+
display: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.n133-friends .n133f-ask-q {
|
|
220
|
+
margin: 0 0 10px;
|
|
221
|
+
font-weight: 650;
|
|
222
|
+
overflow-wrap: anywhere;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.n133-friends .n133f-ask-keys {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-wrap: wrap;
|
|
228
|
+
gap: 8px;
|
|
229
|
+
}
|
|
230
|
+
|
|
205
231
|
.n133-friends .n133f-status:empty {
|
|
206
232
|
display: none;
|
|
207
233
|
}
|