@nano133/nanoterra 0.6.1 → 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 CHANGED
@@ -1,5 +1,20 @@
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
+
9
+ ## 0.7.0 (2026-10-30)
10
+
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,
12
+ Light and Dark; Device is the default and follows `prefers-color-scheme` live. The choice is kept in this browser (`localStorage`, `n133-admin-theme`); a refused
13
+ storage reads Device. The panel gets `data-theme` and `color-scheme`; the page's `<html>` gets `data-n133-theme` while the panel is mounted, so the host page can
14
+ follow (README, "Light and dark"). Every colour of the stylesheet is a variable now: `--n133-*` sets the light value as before, and each has a `--n133-dark-*`
15
+ twin. `mountAdmin` returns `theme` and `destroy()` too. New exports: `createTheme`, `themeKey`, `resolveTheme`, `readThemeChoice`, `themeLabel`, `THEME_KEY`,
16
+ `THEME_CHOICES` and their types.
17
+
3
18
  ## 0.6.1 (2026-10-30)
4
19
 
5
20
  - **Friends: a name may be 80 characters** (it was 64), so a whole Nano address (65 with `nano_`) reaches the game's `PlayerDirectory`: the handler's
package/README.md CHANGED
@@ -665,6 +665,37 @@ width (a container query), not the window's: on a phone two cards a row and the
665
665
 
666
666
  The wallet sign-in stays yours (`signIn`), because only your game can start a request in the ADMIN scope with its own cookie. The panel never signs anyone in.
667
667
 
668
+ **Light and dark** (unreleased). The top bar has a theme key (44 px, a half circle, a sun or a moon; its name says the choice: "Theme: Dark", "Theme: Device (dark
669
+ now)"). A press opens a menu of three: **Device** (the default: it follows the device's setting, `prefers-color-scheme`, and a live change of it), **Light** and **Dark**.
670
+ The signed-out screen has the same key. The choice is kept in this browser only, in `localStorage` under `n133-admin-theme`: no server call and no admin setting. A
671
+ browser that refuses storage (some private windows) reads Device, and a choice there holds for the page.
672
+
673
+ The panel gets `data-theme="light"` or `"dark"` on its root element and sets `color-scheme` to match (form controls, scroll bars, the colour picker). Every colour of the
674
+ stylesheet is a variable with a hook: `--n133-<name>` sets the light value, as before, and its twin `--n133-dark-<name>` sets the dark value. The names: `ink`,
675
+ `ink-soft`, `paper`, `ground`, `line`, `gem`, `gem-dark` (the green of text and marks), `on-gem` (text on a green fill), `sea` (links and the focus ring), `warn`, `bad`,
676
+ `ok-bg`, `ok-ink`, `info`, `info-bg`, `info-ink`, `warn-bg`, `bad-bg`, `bad-ink`, `neutral-bg`, `neutral-ink` (the badges and chips), `note-bg`, `note-line`, `note-ink`
677
+ (the "Check these" banner and the draft note), `alarm-bg`, `alarm-ink` (a late payment, a card's problems, Sign out everywhere), `quiet`, `violet`, `changed` (the accents
678
+ of the numbers and of a changed look), `stage` (the backdrop of a look's picture), `tint-empty`, `swatch-empty`, `swatch-edge`, `code-bg`, `code-ink`, and `shadow`
679
+ (`--n133-dark-shadow`). The dark defaults keep body text at 4.5:1 or more on the dark paper (most pairs are 7:1 or more).
680
+
681
+ **The page around the panel.** While the panel is mounted, `mountAdmin` also sets `data-n133-theme="light" | "dark"` on the page's `<html>`, so your page can follow it
682
+ with CSS alone. `destroy()` (on what `mountAdmin` returns) stops following the device and takes the attribute off. `theme` on the same object reads and sets the choice
683
+ (`theme.choice`, `theme.resolved`, `theme.set("dark")`, `theme.onChange(fn)`). The starter's `src/client/admin.css`:
684
+
685
+ ```css
686
+ html[data-n133-theme="dark"]:has(.admin-page) {
687
+ --ink: #e7edf5; --ink-soft: #a9b5c5; --paper: #171f2b; --line: #303c4e; --gem-dark: #3fd8b6; --sea: #62bcec;
688
+ color-scheme: dark;
689
+ }
690
+ html[data-n133-theme="dark"]:has(.admin-page),
691
+ html[data-n133-theme="dark"] body.admin-page {
692
+ background: #0f151e;
693
+ }
694
+ ```
695
+
696
+ The pictures: a look's flat picture and the character previews (`createLookPreview` draws with a clear background) stand on the `stage` colour, which is a mid grey
697
+ blue in dark, so a dark outfit and the outlines still show. The panel draws no QR code.
698
+
668
699
  ## The friends list (`/friends`; unreleased)
669
700
 
670
701
  A Friends key for the game, and a list behind it. The admin's **Game** card chooses which list (`friends` in the settings; Nanoterra by default):
@@ -781,10 +812,13 @@ for (const c of friendsStoreConformance(async () => ({ store: newEmptyStore(), d
781
812
  ```
782
813
 
783
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
784
- sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where`, Join an invite when you
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
785
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
786
817
  no live connection: only Nanoterra's do.
787
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
+
788
822
  ## Moving from the three old packages
789
823
 
790
824
  | Before | Now |
@@ -111,6 +111,24 @@ declare const SHAPES: {
111
111
  trash: [string, {
112
112
  d: string;
113
113
  }][];
114
+ sun: ([string, {
115
+ cx: number;
116
+ cy: number;
117
+ r: number;
118
+ }] | [string, {
119
+ d: string;
120
+ }])[];
121
+ moon: [string, {
122
+ d: string;
123
+ }][];
124
+ device: ([string, {
125
+ cx: number;
126
+ cy: number;
127
+ r: number;
128
+ }] | [string, {
129
+ d: string;
130
+ fill: string;
131
+ }])[];
114
132
  sparkle: [string, {
115
133
  d: string;
116
134
  }][];
@@ -24,6 +24,9 @@ const SHAPES = {
24
24
  close: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
25
25
  dup: [["rect", { x: 8, y: 8, width: 13, height: 13, rx: 2.5 }], ["path", { d: "M14.5 11.5v6M11.5 14.5h6M16 8V5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16H8" }]],
26
26
  trash: [["path", { d: "M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13" }]],
27
+ sun: [["circle", { cx: 12, cy: 12, r: 4 }], ["path", { d: "M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6" }]],
28
+ moon: [["path", { d: "M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" }]],
29
+ device: [["circle", { cx: 12, cy: 12, r: 8.5 }], ["path", { d: "M12 3.5a8.5 8.5 0 0 1 0 17Z", fill: "currentColor" }]],
27
30
  sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
28
31
  };
29
32
  /** An icon of `size` CSS pixels (default 20). */
@@ -13,6 +13,8 @@ export type { BestSeller, OverviewView } from "./overview.js";
13
13
  export { playerMatches, playersView } from "./players.js";
14
14
  export { TABS, tabBar } from "./tabs.js";
15
15
  export type { TabBar, TabId } from "./tabs.js";
16
+ export { THEME_CHOICES, THEME_KEY, createTheme, readThemeChoice, resolveTheme, themeKey, themeLabel } from "./theme.js";
17
+ export type { Theme, ThemeChoice, ThemeControl, ThemeOptions } from "./theme.js";
16
18
  export { icon } from "./icons.js";
17
19
  export type { IconName } from "./icons.js";
18
20
  export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
@@ -14,5 +14,6 @@ export { orderMatches, ordersView } from "./orders.js";
14
14
  export { bestSellers, overviewView } from "./overview.js";
15
15
  export { playerMatches, playersView } from "./players.js";
16
16
  export { TABS, tabBar } from "./tabs.js";
17
+ export { THEME_CHOICES, THEME_KEY, createTheme, readThemeChoice, resolveTheme, themeKey, themeLabel } from "./theme.js";
17
18
  export { icon } from "./icons.js";
18
19
  export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
@@ -1,4 +1,5 @@
1
1
  import type { Look } from "../../looks/look.js";
2
+ import { type ThemeControl } from "./theme.js";
2
3
  export type MountOptions = {
3
4
  /** The game's looks now (its looks.json). The Shelf starts from them. */
4
5
  looks: readonly Look[];
@@ -26,5 +27,9 @@ export type MountedAdmin = {
26
27
  reload(): Promise<void>;
27
28
  /** Resolves when the first read has been drawn (a test waits for it). */
28
29
  ready: Promise<void>;
30
+ /** The light or dark theme (the owner's choice in the top bar, kept in this browser). */
31
+ theme: Pick<ThemeControl, "choice" | "resolved" | "set" | "onChange">;
32
+ /** Stops following the device's theme and takes data-n133-theme off the page's <html>. */
33
+ destroy(): void;
29
34
  };
30
35
  export declare function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdmin;
@@ -1,7 +1,7 @@
1
1
  // The admin panel: orders, owned looks, totals and the two game switches, from the game's own database. Only the game's owner can read them (the
2
2
  // handler checks it on every call); this page only shows them. `mountAdmin(element, options)` draws the panel into `element`.
3
3
  //
4
- // The page: a top bar (who is signed in, Live or Test mode, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
4
+ // The page: a top bar (who is signed in, Live or Test mode, the light/dark theme key, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
5
5
  // each opens its list), then five tabs: Overview, Cosmetics (the Shelf), Orders, Players and Settings (the Game card, the sign-ins, the facts). Every tab is
6
6
  // made once; a new read of the summary fills them again and keeps the chosen tab, the Shelf's draft, a search and the Game card.
7
7
  import { ApiError, createApi } from "./api.js";
@@ -14,6 +14,7 @@ import { playersView } from "./players.js";
14
14
  import { shelfView } from "./shelf.js";
15
15
  import { signinsCard } from "./signins.js";
16
16
  import { tabBar } from "./tabs.js";
17
+ import { createTheme, themeKey } from "./theme.js";
17
18
  const when = (ms) => (ms ? new Date(ms).toLocaleString() : "");
18
19
  export function mountAdmin(root, options) {
19
20
  const api = createApi(options.api ?? "/api/admin", options.fetch);
@@ -22,6 +23,11 @@ export function mountAdmin(root, options) {
22
23
  data.hidden = true;
23
24
  root.classList.add("n133-admin");
24
25
  root.replaceChildren(gate, data);
26
+ // Before the first read, so the gate is drawn in the chosen theme too.
27
+ const theme = createTheme(root);
28
+ /** The two theme keys (the gate's and the top bar's), made once: each one listens to the theme for the life of the page. */
29
+ const gateKey = themeKey(theme);
30
+ const barKey = themeKey(theme);
25
31
  /** The Shelf keeps its draft while the page redraws (a tab, a payment check): one view for the life of the page. */
26
32
  let shelf = null;
27
33
  /** The "Game" card keeps its own state for the life of the page (a payment check redraws the rest, not this). */
@@ -51,7 +57,7 @@ export function mountAdmin(root, options) {
51
57
  data.replaceChildren();
52
58
  dash = null;
53
59
  gate.hidden = false;
54
- gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b) => b !== null));
60
+ gate.replaceChildren(gateKey, h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b) => b !== null));
55
61
  }
56
62
  async function load() {
57
63
  try {
@@ -64,7 +70,7 @@ export function mountAdmin(root, options) {
64
70
  if (!(e instanceof ApiError)) {
65
71
  // The data already drawn (if any) stays: a failed new read does not take it away.
66
72
  gate.hidden = false;
67
- gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon("alert", 28)), h("p", {}, "The game's server did not answer. Try again."), h("button", { class: "btn", type: "button", onclick: () => void load() }, "Try again"));
73
+ gate.replaceChildren(gateKey, h("div", { class: "gate-icon", "aria-hidden": "true" }, icon("alert", 28)), h("p", {}, "The game's server did not answer. Try again."), h("button", { class: "btn", type: "button", onclick: () => void load() }, "Try again"));
68
74
  return;
69
75
  }
70
76
  if (e.status === 403 && e.body.signedInAs) {
@@ -127,7 +133,7 @@ export function mountAdmin(root, options) {
127
133
  tabs.panels.players.append(players.el);
128
134
  const who = h("div", { class: "who" });
129
135
  const chips = h("div", { class: "chips" });
130
- const top = h("header", { class: "topbar" }, h("div", { class: "who-mark", "aria-hidden": "true" }, icon("lock", 18)), who, chips, h("div", { class: "topbar-keys" }, signOutButton()));
136
+ const top = h("header", { class: "topbar" }, h("div", { class: "who-mark", "aria-hidden": "true" }, icon("lock", 18)), who, chips, h("div", { class: "topbar-keys" }, barKey, signOutButton()));
131
137
  const banner = h("div", { class: "banner warnings", role: "alert" });
132
138
  const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
133
139
  const settings = h("div", { class: "settings-grid" });
@@ -177,5 +183,5 @@ export function mountAdmin(root, options) {
177
183
  dash ??= build();
178
184
  dash.update(s);
179
185
  }
180
- return { reload: () => load(), ready: load() };
186
+ return { reload: () => load(), ready: load(), theme, destroy: () => theme.destroy() };
181
187
  }
@@ -0,0 +1,40 @@
1
+ export type ThemeChoice = "device" | "light" | "dark";
2
+ export type Theme = "light" | "dark";
3
+ /** The localStorage key of the choice. */
4
+ export declare const THEME_KEY = "n133-admin-theme";
5
+ export declare const THEME_CHOICES: readonly ThemeChoice[];
6
+ /** The theme a choice gives: Device follows the device's setting. */
7
+ export declare const resolveTheme: (choice: ThemeChoice, deviceDark: boolean) => Theme;
8
+ type Store = Pick<Storage, "getItem" | "setItem">;
9
+ /** The kept choice; Device when there is none, it is not a choice, or the storage cannot be read. */
10
+ export declare function readThemeChoice(storage?: Store | null): ThemeChoice;
11
+ export type ThemeOptions = {
12
+ /** Where the choice is kept (default: the page's localStorage). null keeps it only for the life of the page. */
13
+ storage?: Store | null;
14
+ /** The device's setting (default: window.matchMedia). */
15
+ matchMedia?: ((query: string) => Pick<MediaQueryList, "matches" | "addEventListener" | "removeEventListener">) | null;
16
+ /** The page's root, which gets data-n133-theme (default: document.documentElement). null sets nothing outside the panel. */
17
+ host?: HTMLElement | null;
18
+ };
19
+ export type ThemeControl = {
20
+ /** What the owner chose. */
21
+ readonly choice: ThemeChoice;
22
+ /** The theme drawn now. */
23
+ readonly resolved: Theme;
24
+ /** Picks a theme, keeps it in this browser and draws it. */
25
+ set(choice: ThemeChoice): void;
26
+ /** Hears every change of the choice or the drawn theme (the device's setting too). Returns the call that stops it. */
27
+ onChange(listen: (resolved: Theme, choice: ThemeChoice) => void): () => void;
28
+ /** Stops following the device and takes data-n133-theme off the page. */
29
+ destroy(): void;
30
+ };
31
+ /** Reads the kept choice and draws it on `root` at once (data-theme), and on the page's <html> (data-n133-theme). */
32
+ export declare function createTheme(root: HTMLElement, options?: ThemeOptions): ThemeControl;
33
+ /** "Theme: Dark", or "Theme: Device (dark now)". */
34
+ export declare const themeLabel: (choice: ThemeChoice, resolved: Theme) => string;
35
+ /**
36
+ * The theme key: a 44 px round key with the choice's icon (a sun, a moon, a half circle for Device). A press opens a small menu of the three choices, each a
37
+ * 44 px row with its icon and word; the chosen one is pressed (aria-pressed). A choice, Escape or a press outside closes it.
38
+ */
39
+ export declare function themeKey(theme: ThemeControl): HTMLElement;
40
+ export {};
@@ -0,0 +1,142 @@
1
+ // The panel's light and dark themes. The owner picks Light, Dark or Device (the device's own setting, `prefers-color-scheme`, followed live); Device is the
2
+ // default. The choice is kept in this browser only (localStorage, `n133-admin-theme`): no server call, no admin setting. A private window, or a browser that
3
+ // refuses storage, still works: it reads Device and keeps a choice for the life of the page.
4
+ //
5
+ // The resolved theme is set as data-theme="light" | "dark" on the panel's root (style.css reads it), and as data-n133-theme on the page's <html> while the panel
6
+ // is mounted, so the page around the panel can follow it (README, "Light and dark").
7
+ import { h } from "./dom.js";
8
+ import { icon } from "./icons.js";
9
+ /** The localStorage key of the choice. */
10
+ export const THEME_KEY = "n133-admin-theme";
11
+ export const THEME_CHOICES = ["device", "light", "dark"];
12
+ const QUERY = "(prefers-color-scheme: dark)";
13
+ /** The theme a choice gives: Device follows the device's setting. */
14
+ export const resolveTheme = (choice, deviceDark) => (choice === "device" ? (deviceDark ? "dark" : "light") : choice);
15
+ const isChoice = (v) => typeof v === "string" && THEME_CHOICES.includes(v);
16
+ /** The page's localStorage, or null where reading it throws (some private windows, a blocked site). */
17
+ function pageStorage() {
18
+ try {
19
+ return globalThis.localStorage ?? null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** The kept choice; Device when there is none, it is not a choice, or the storage cannot be read. */
26
+ export function readThemeChoice(storage = pageStorage()) {
27
+ try {
28
+ const v = storage?.getItem(THEME_KEY);
29
+ return isChoice(v) ? v : "device";
30
+ }
31
+ catch {
32
+ return "device";
33
+ }
34
+ }
35
+ /** Reads the kept choice and draws it on `root` at once (data-theme), and on the page's <html> (data-n133-theme). */
36
+ export function createTheme(root, options = {}) {
37
+ const storage = options.storage === undefined ? pageStorage() : options.storage;
38
+ const host = options.host === undefined ? (globalThis.document?.documentElement ?? null) : options.host;
39
+ let media = null;
40
+ try {
41
+ const mm = options.matchMedia === undefined ? (typeof globalThis.matchMedia === "function" ? globalThis.matchMedia.bind(globalThis) : null) : options.matchMedia;
42
+ media = mm ? mm(QUERY) : null;
43
+ }
44
+ catch {
45
+ media = null;
46
+ }
47
+ let choice = readThemeChoice(storage);
48
+ let resolved = "light";
49
+ const listeners = new Set();
50
+ function draw() {
51
+ resolved = resolveTheme(choice, media?.matches === true);
52
+ root.setAttribute("data-theme", resolved);
53
+ host?.setAttribute("data-n133-theme", resolved);
54
+ for (const l of listeners)
55
+ l(resolved, choice);
56
+ }
57
+ const onDevice = () => {
58
+ if (choice === "device")
59
+ draw();
60
+ };
61
+ media?.addEventListener?.("change", onDevice);
62
+ draw();
63
+ return {
64
+ get choice() {
65
+ return choice;
66
+ },
67
+ get resolved() {
68
+ return resolved;
69
+ },
70
+ set(next) {
71
+ if (!isChoice(next))
72
+ return;
73
+ choice = next;
74
+ try {
75
+ storage?.setItem(THEME_KEY, next);
76
+ }
77
+ catch {
78
+ // A full or refused storage: the choice holds for this page only.
79
+ }
80
+ draw();
81
+ },
82
+ onChange(listen) {
83
+ listeners.add(listen);
84
+ return () => listeners.delete(listen);
85
+ },
86
+ destroy() {
87
+ media?.removeEventListener?.("change", onDevice);
88
+ listeners.clear();
89
+ host?.removeAttribute("data-n133-theme");
90
+ },
91
+ };
92
+ }
93
+ const WORDS = { device: "Device", light: "Light", dark: "Dark" };
94
+ const ICONS = { device: "device", light: "sun", dark: "moon" };
95
+ /** "Theme: Dark", or "Theme: Device (dark now)". */
96
+ export const themeLabel = (choice, resolved) => (choice === "device" ? `Theme: Device (${resolved} now)` : `Theme: ${WORDS[choice]}`);
97
+ /**
98
+ * The theme key: a 44 px round key with the choice's icon (a sun, a moon, a half circle for Device). A press opens a small menu of the three choices, each a
99
+ * 44 px row with its icon and word; the chosen one is pressed (aria-pressed). A choice, Escape or a press outside closes it.
100
+ */
101
+ export function themeKey(theme) {
102
+ const summary = h("summary", { class: "btn btn-quiet theme-summary" });
103
+ const rows = THEME_CHOICES.map((c) => h("button", {
104
+ class: "theme-choice",
105
+ type: "button",
106
+ "data-choice": c,
107
+ onclick: () => {
108
+ theme.set(c);
109
+ box.open = false;
110
+ summary.focus();
111
+ },
112
+ }, icon(ICONS[c], 18), WORDS[c]));
113
+ const menu = h("div", { class: "theme-menu", role: "group", "aria-label": "Theme" }, ...rows);
114
+ const box = h("details", { class: "theme-key" }, summary, menu);
115
+ const outside = (e) => {
116
+ if (!box.contains(e.target))
117
+ box.open = false;
118
+ };
119
+ box.addEventListener("toggle", () => {
120
+ if (box.open)
121
+ document.addEventListener("pointerdown", outside, true);
122
+ else
123
+ document.removeEventListener("pointerdown", outside, true);
124
+ });
125
+ box.addEventListener("keydown", (e) => {
126
+ if (e.key === "Escape" && box.open) {
127
+ box.open = false;
128
+ summary.focus();
129
+ }
130
+ });
131
+ const show = (resolved, choice) => {
132
+ const label = themeLabel(choice, resolved);
133
+ summary.replaceChildren(icon(ICONS[choice], 20));
134
+ summary.title = label;
135
+ summary.setAttribute("aria-label", label);
136
+ for (const r of rows)
137
+ r.setAttribute("aria-pressed", String(r.dataset.choice === choice));
138
+ };
139
+ show(theme.resolved, theme.choice);
140
+ theme.onChange(show);
141
+ return box;
142
+ }
@@ -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: `window.confirm`). */
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 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);
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 === "Escape" && isOpen)
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
- if (!ask("Leave this game to join your friend?"))
136
- return;
137
- if (options.onJoin)
138
- options.onJoin(m.url);
139
- else
140
- window.location.assign(m.url);
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?`) && void act({ op: "remove", edge: p.edge })));
234
- 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"));
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.6.1",
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",
@@ -28,6 +28,9 @@ const SHAPES = {
28
28
  close: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
29
29
  dup: [["rect", { x: 8, y: 8, width: 13, height: 13, rx: 2.5 }], ["path", { d: "M14.5 11.5v6M11.5 14.5h6M16 8V5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16H8" }]],
30
30
  trash: [["path", { d: "M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13" }]],
31
+ sun: [["circle", { cx: 12, cy: 12, r: 4 }], ["path", { d: "M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6" }]],
32
+ moon: [["path", { d: "M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" }]],
33
+ device: [["circle", { cx: 12, cy: 12, r: 8.5 }], ["path", { d: "M12 3.5a8.5 8.5 0 0 1 0 17Z", fill: "currentColor" }]],
31
34
  sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
32
35
  } satisfies Record<string, Shape[]>;
33
36
 
@@ -21,6 +21,8 @@ export type { BestSeller, OverviewView } from "./overview.js";
21
21
  export { playerMatches, playersView } from "./players.js";
22
22
  export { TABS, tabBar } from "./tabs.js";
23
23
  export type { TabBar, TabId } from "./tabs.js";
24
+ export { THEME_CHOICES, THEME_KEY, createTheme, readThemeChoice, resolveTheme, themeKey, themeLabel } from "./theme.js";
25
+ export type { Theme, ThemeChoice, ThemeControl, ThemeOptions } from "./theme.js";
24
26
  export { icon } from "./icons.js";
25
27
  export type { IconName } from "./icons.js";
26
28
  export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
@@ -1,7 +1,7 @@
1
1
  // The admin panel: orders, owned looks, totals and the two game switches, from the game's own database. Only the game's owner can read them (the
2
2
  // handler checks it on every call); this page only shows them. `mountAdmin(element, options)` draws the panel into `element`.
3
3
  //
4
- // The page: a top bar (who is signed in, Live or Test mode, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
4
+ // The page: a top bar (who is signed in, Live or Test mode, the light/dark theme key, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
5
5
  // each opens its list), then five tabs: Overview, Cosmetics (the Shelf), Orders, Players and Settings (the Game card, the sign-ins, the facts). Every tab is
6
6
  // made once; a new read of the summary fills them again and keeps the chosen tab, the Shelf's draft, a search and the Game card.
7
7
 
@@ -18,6 +18,7 @@ import { playersView } from "./players.js";
18
18
  import { shelfView } from "./shelf.js";
19
19
  import { signinsCard } from "./signins.js";
20
20
  import { tabBar, type TabId } from "./tabs.js";
21
+ import { createTheme, themeKey, type ThemeControl } from "./theme.js";
21
22
 
22
23
  export type MountOptions = {
23
24
  /** The game's looks now (its looks.json). The Shelf starts from them. */
@@ -47,6 +48,10 @@ export type MountedAdmin = {
47
48
  reload(): Promise<void>;
48
49
  /** Resolves when the first read has been drawn (a test waits for it). */
49
50
  ready: Promise<void>;
51
+ /** The light or dark theme (the owner's choice in the top bar, kept in this browser). */
52
+ theme: Pick<ThemeControl, "choice" | "resolved" | "set" | "onChange">;
53
+ /** Stops following the device's theme and takes data-n133-theme off the page's <html>. */
54
+ destroy(): void;
50
55
  };
51
56
 
52
57
  const when = (ms: number | null) => (ms ? new Date(ms).toLocaleString() : "");
@@ -61,6 +66,11 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
61
66
  data.hidden = true;
62
67
  root.classList.add("n133-admin");
63
68
  root.replaceChildren(gate, data);
69
+ // Before the first read, so the gate is drawn in the chosen theme too.
70
+ const theme = createTheme(root);
71
+ /** The two theme keys (the gate's and the top bar's), made once: each one listens to the theme for the life of the page. */
72
+ const gateKey = themeKey(theme);
73
+ const barKey = themeKey(theme);
64
74
 
65
75
  /** The Shelf keeps its draft while the page redraws (a tab, a payment check): one view for the life of the page. */
66
76
  let shelf: ReturnType<typeof shelfView> | null = null;
@@ -91,7 +101,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
91
101
  data.replaceChildren();
92
102
  dash = null;
93
103
  gate.hidden = false;
94
- gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b): b is Node => b !== null));
104
+ gate.replaceChildren(gateKey, h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b): b is Node => b !== null));
95
105
  }
96
106
 
97
107
  async function load(): Promise<void> {
@@ -104,7 +114,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
104
114
  if (!(e instanceof ApiError)) {
105
115
  // The data already drawn (if any) stays: a failed new read does not take it away.
106
116
  gate.hidden = false;
107
- gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon("alert", 28)), h("p", {}, "The game's server did not answer. Try again."), h("button", { class: "btn", type: "button", onclick: () => void load() }, "Try again"));
117
+ gate.replaceChildren(gateKey, h("div", { class: "gate-icon", "aria-hidden": "true" }, icon("alert", 28)), h("p", {}, "The game's server did not answer. Try again."), h("button", { class: "btn", type: "button", onclick: () => void load() }, "Try again"));
108
118
  return;
109
119
  }
110
120
  if (e.status === 403 && e.body.signedInAs) {
@@ -174,7 +184,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
174
184
 
175
185
  const who = h("div", { class: "who" });
176
186
  const chips = h("div", { class: "chips" });
177
- const top = h("header", { class: "topbar" }, h("div", { class: "who-mark", "aria-hidden": "true" }, icon("lock", 18)), who, chips, h("div", { class: "topbar-keys" }, signOutButton()));
187
+ const top = h("header", { class: "topbar" }, h("div", { class: "who-mark", "aria-hidden": "true" }, icon("lock", 18)), who, chips, h("div", { class: "topbar-keys" }, barKey, signOutButton()));
178
188
  const banner = h("div", { class: "banner warnings", role: "alert" });
179
189
  const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
180
190
  const settings = h("div", { class: "settings-grid" });
@@ -241,6 +251,6 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
241
251
  dash.update(s);
242
252
  }
243
253
 
244
- return { reload: () => load(), ready: load() };
254
+ return { reload: () => load(), ready: load(), theme, destroy: () => theme.destroy() };
245
255
  }
246
256