@nano133/nanoterra 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-10-30)
4
+
5
+ - **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,
6
+ 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
7
+ 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
8
+ 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-*`
9
+ twin. `mountAdmin` returns `theme` and `destroy()` too. New exports: `createTheme`, `themeKey`, `resolveTheme`, `readThemeChoice`, `themeLabel`, `THEME_KEY`,
10
+ `THEME_CHOICES` and their types.
11
+
12
+ ## 0.6.1 (2026-10-30)
13
+
14
+ - **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
15
+ `FRIENDS_LIMITS.nameMax` and the panel's name field.
16
+
3
17
  ## 0.6.0 (2026-10-30)
4
18
 
5
19
  - **A friends list for the game's players** (`@nano133/nanoterra/friends`, `/friends/d1`, `/friends/firestore`, `/friends/conformance`, `/friends/panel` and
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):
@@ -682,7 +713,7 @@ import "@nano133/nanoterra/friends/style.css";
682
713
  const settings = await (await fetch("/api/settings")).json(); // your route: loadGameSettings(store, defaults) from "@nano133/nanoterra/admin"
683
714
  const friends = mountFriends(document.getElementById("friends")!, {
684
715
  mode: settings.friends, // "nanoterra" | "own" | "off"
685
- game: "echo-island", // your game's id on Nanoterra (its listing's id)
716
+ game: "my-game", // your game's id on Nanoterra (its listing's id: Echo Island's is "gem-island")
686
717
  where: "Lobby", // where the player is, in your words: friends see it
687
718
  onJoin: (target) => go(target), // optional: "nanoterra" gives a friend's https address, "own" an invite's place (and the friend's name)
688
719
  });
@@ -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
+ }
@@ -22,8 +22,8 @@ export declare const FRIENDS_LIMITS: {
22
22
  };
23
23
  /** The biggest body of a POST. */
24
24
  readonly bodyMax: 1024;
25
- /** The longest name a request may send. */
26
- readonly nameMax: 64;
25
+ /** The longest name a request may send: room for a whole Nano address (65 characters with "nano_"), which a directory may accept as a name. */
26
+ readonly nameMax: 80;
27
27
  };
28
28
  export declare function createFriendsHandler(options: FriendsHandlerOptions): {
29
29
  base: string;
@@ -19,8 +19,8 @@ export const FRIENDS_LIMITS = {
19
19
  calls: { limit: 30, windowMs: 60_000 },
20
20
  /** The biggest body of a POST. */
21
21
  bodyMax: 1024,
22
- /** The longest name a request may send. */
23
- nameMax: 64,
22
+ /** The longest name a request may send: room for a whole Nano address (65 characters with "nano_"), which a directory may accept as a name. */
23
+ nameMax: 80,
24
24
  };
25
25
  const sharedLimit = memoryLimiter();
26
26
  const OPS = new Set(["request", "accept", "decline", "remove", "block", "unblock", "invite", "pulse", "visibility"]);
@@ -173,7 +173,7 @@ function ownList(body, options, whereNow, setBadge, ask) {
173
173
  const api = createApi(options.base ?? "/friends", options.fetch);
174
174
  const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
175
175
  const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
176
- 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" });
176
+ const nameInput = h("input", { class: "n133f-input", type: "text", name: "friend", maxlength: 80, autocomplete: "off", placeholder: "A player's name", "aria-label": "A player's name" });
177
177
  const addKey = h("button", { class: "n133f-btn n133f-primary", type: "submit" }, "Add");
178
178
  const form = h("form", { class: "n133f-add" }, nameInput, addKey);
179
179
  const list = h("div", { class: "n133f-list" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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
 
@@ -4,32 +4,106 @@
4
4
  orders as a table from 760 px.
5
5
  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,
6
6
  --n133-gem, --n133-gem-dark, --n133-sea, --n133-radius and --n133-font; and, with the tabbed layout, --n133-ground (the wells inside a card), --n133-warn and
7
- --n133-bad (the amber and red of a warning and a problem) and --n133-shadow (the cards' shadow). */
7
+ --n133-bad (the amber and red of a warning and a problem) and --n133-shadow (the cards' shadow). Every other colour has a hook too (the list below, e.g.
8
+ --n133-ok-bg). Those set the LIGHT theme. The DARK theme reads the twins with "dark-" in the name: --n133-dark-ink, --n133-dark-paper, --n133-dark-shadow and so
9
+ on. The panel's theme key (theme.ts) sets data-theme="light" or "dark" on the panel. */
8
10
 
9
11
  .n133-admin {
10
12
  --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
11
- --gem: var(--n133-gem, #20c9a6);
12
- --gem-dark: var(--n133-gem-dark, #0e8a72);
13
+ --radius: var(--n133-radius, 18px);
13
14
  --ink: var(--n133-ink, #16202e);
14
15
  --ink-soft: var(--n133-ink-soft, #4a5568);
15
- --line: var(--n133-line, #dbe4ee);
16
16
  --paper: var(--n133-paper, #ffffff);
17
- --radius: var(--n133-radius, 18px);
18
- --sea: var(--n133-sea, #1f8fc4);
19
17
  --ground: var(--n133-ground, #f4f7fb);
18
+ --line: var(--n133-line, #dbe4ee);
19
+ --gem: var(--n133-gem, #20c9a6);
20
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
21
+ --on-gem: var(--n133-on-gem, #062b23);
22
+ --sea: var(--n133-sea, #1f8fc4);
20
23
  --warn: var(--n133-warn, #8a4b00);
21
24
  --bad: var(--n133-bad, #b4142f);
22
- --shadow: var(--n133-shadow, 0 1px 2px rgb(16 24 40 / 0.05), 0 6px 18px -6px rgb(16 24 40 / 0.1));
25
+ --ok-bg: var(--n133-ok-bg, #dcf6ec);
26
+ --ok-ink: var(--n133-ok-ink, #0b6a52);
27
+ --info: var(--n133-info, #1d5fa8);
28
+ --info-bg: var(--n133-info-bg, #e3f0ff);
29
+ --info-ink: var(--n133-info-ink, #134e86);
30
+ --warn-bg: var(--n133-warn-bg, #fff1d6);
31
+ --bad-bg: var(--n133-bad-bg, #ffe3e8);
32
+ --bad-ink: var(--n133-bad-ink, #9e1029);
33
+ --neutral-bg: var(--n133-neutral-bg, #eef1f4);
34
+ --neutral-ink: var(--n133-neutral-ink, #3f4a5a);
35
+ --note-bg: var(--n133-note-bg, #fff7e8);
36
+ --note-line: var(--n133-note-line, #f3c27a);
37
+ --note-ink: var(--n133-note-ink, #4a2d00);
38
+ --alarm-bg: var(--n133-alarm-bg, #fff1f3);
39
+ --alarm-ink: var(--n133-alarm-ink, #5c0716);
40
+ --quiet: var(--n133-quiet, #6b7686);
41
+ --violet: var(--n133-violet, #6d3fc0);
42
+ --changed: var(--n133-changed, #c47a00);
43
+ --stage: var(--n133-stage, #ffffff);
44
+ --tint-empty: var(--n133-tint-empty, #9fb3c8);
45
+ --swatch-empty: var(--n133-swatch-empty, #c9d3df);
46
+ --swatch-edge: var(--n133-swatch-edge, rgb(0 0 0 / 0.18));
47
+ --code-bg: var(--n133-code-bg, #0f172a);
48
+ --code-ink: var(--n133-code-ink, #e6edf6);
49
+ /* The colour of every shadow (r g b), and the shadow of a card. */
50
+ --shade: 16 24 40;
51
+ --shadow: var(--n133-shadow, 0 1px 2px rgb(var(--shade) / 0.05), 0 6px 18px -6px rgb(var(--shade) / 0.1));
52
+ --gem-hover: color-mix(in srgb, var(--gem) 85%, #ffffff);
23
53
  --gap: 12px;
24
54
  container: n133 / inline-size;
25
55
  box-sizing: border-box;
26
56
  max-width: 1200px;
27
57
  margin: 0 auto;
28
58
  color: var(--ink);
59
+ color-scheme: light;
29
60
  font: 15px/1.45 var(--font);
30
61
  -webkit-text-size-adjust: 100%;
31
62
  }
32
63
 
64
+ /* Dark: mountAdmin sets data-theme="dark" on the panel (the owner's choice, or the device's setting). Each colour has a --n133-dark-* twin; the shadows go
65
+ black and faint, so the cards stand on their borders. */
66
+ .n133-admin[data-theme="dark"] {
67
+ --ink: var(--n133-dark-ink, #e7edf5);
68
+ --ink-soft: var(--n133-dark-ink-soft, #a9b5c5);
69
+ --paper: var(--n133-dark-paper, #171f2b);
70
+ --ground: var(--n133-dark-ground, #0f151e);
71
+ --line: var(--n133-dark-line, #303c4e);
72
+ --gem: var(--n133-dark-gem, #20c9a6);
73
+ --gem-dark: var(--n133-dark-gem-dark, #3fd8b6);
74
+ --on-gem: var(--n133-dark-on-gem, #062b23);
75
+ --sea: var(--n133-dark-sea, #62bcec);
76
+ --warn: var(--n133-dark-warn, #f2b45c);
77
+ --bad: var(--n133-dark-bad, #ff8293);
78
+ --ok-bg: var(--n133-dark-ok-bg, #14392f);
79
+ --ok-ink: var(--n133-dark-ok-ink, #7fe4c4);
80
+ --info: var(--n133-dark-info, #6eaaf0);
81
+ --info-bg: var(--n133-dark-info-bg, #172f4c);
82
+ --info-ink: var(--n133-dark-info-ink, #a3ccf6);
83
+ --warn-bg: var(--n133-dark-warn-bg, #3a2a12);
84
+ --bad-bg: var(--n133-dark-bad-bg, #45171f);
85
+ --bad-ink: var(--n133-dark-bad-ink, #ffa7b4);
86
+ --neutral-bg: var(--n133-dark-neutral-bg, #263040);
87
+ --neutral-ink: var(--n133-dark-neutral-ink, #c8d1dc);
88
+ --note-bg: var(--n133-dark-note-bg, #2a2113);
89
+ --note-line: var(--n133-dark-note-line, #7a5a22);
90
+ --note-ink: var(--n133-dark-note-ink, #f6dfb8);
91
+ --alarm-bg: var(--n133-dark-alarm-bg, #3a161d);
92
+ --alarm-ink: var(--n133-dark-alarm-ink, #ffcad2);
93
+ --quiet: var(--n133-dark-quiet, #98a5b7);
94
+ --violet: var(--n133-dark-violet, #bba3f6);
95
+ --changed: var(--n133-dark-changed, #e3a440);
96
+ --stage: var(--n133-dark-stage, #2a3546);
97
+ --tint-empty: var(--n133-dark-tint-empty, #6f86a0);
98
+ --swatch-empty: var(--n133-dark-swatch-empty, #66748a);
99
+ --swatch-edge: var(--n133-dark-swatch-edge, rgb(255 255 255 / 0.4));
100
+ --code-bg: var(--n133-dark-code-bg, #0a0f17);
101
+ --code-ink: var(--n133-dark-code-ink, #e6edf6);
102
+ --shade: 0 0 0;
103
+ --shadow: var(--n133-dark-shadow, 0 1px 2px rgb(0 0 0 / 0.3));
104
+ color-scheme: dark;
105
+ }
106
+
33
107
  .n133-admin *,
34
108
  .n133-admin *::before,
35
109
  .n133-admin *::after {
@@ -93,7 +167,7 @@
93
167
  @media (hover: hover) {
94
168
  .n133-admin .btn:not(:disabled):hover {
95
169
  border-color: color-mix(in srgb, var(--ink) 30%, var(--line));
96
- box-shadow: 0 1px 3px rgb(16 24 40 / 0.1);
170
+ box-shadow: 0 1px 3px rgb(var(--shade) / 0.1);
97
171
  }
98
172
  }
99
173
 
@@ -105,13 +179,13 @@
105
179
  .n133-admin .btn-primary {
106
180
  border-color: var(--gem-dark);
107
181
  background: var(--gem);
108
- color: #062b23;
182
+ color: var(--on-gem);
109
183
  }
110
184
 
111
185
  @media (hover: hover) {
112
186
  .n133-admin .btn-primary:not(:disabled):hover {
113
187
  border-color: var(--gem-dark);
114
- background: color-mix(in srgb, var(--gem) 85%, #ffffff);
188
+ background: var(--gem-hover);
115
189
  }
116
190
  }
117
191
 
@@ -386,30 +460,116 @@
386
460
  }
387
461
 
388
462
  .n133-admin .chip-ok {
389
- background: #dcf6ec;
390
- color: #0b6a52;
463
+ background: var(--ok-bg);
464
+ color: var(--ok-ink);
391
465
  }
392
466
 
393
467
  .n133-admin .chip-warn {
394
- background: #fff1d6;
468
+ background: var(--warn-bg);
395
469
  color: var(--warn);
396
470
  }
397
471
 
398
472
  .n133-admin .topbar-keys {
399
473
  display: flex;
474
+ align-items: center;
475
+ gap: 6px;
400
476
  margin-left: auto;
401
477
  }
402
478
 
479
+ /* ---- The theme key (theme.ts): a 44 px round key that opens a menu of Device, Light and Dark ---- */
480
+
481
+ .n133-admin .theme-key {
482
+ position: relative;
483
+ }
484
+
485
+ .n133-admin .gate {
486
+ position: relative;
487
+ }
488
+
489
+ .n133-admin .gate > .theme-key {
490
+ position: absolute;
491
+ top: 8px;
492
+ right: 8px;
493
+ }
494
+
495
+ .n133-admin .theme-summary {
496
+ width: 44px;
497
+ padding: 0;
498
+ color: var(--ink-soft);
499
+ list-style: none;
500
+ }
501
+
502
+ .n133-admin .theme-summary::-webkit-details-marker {
503
+ display: none;
504
+ }
505
+
506
+ .n133-admin .theme-key[open] > .theme-summary {
507
+ border-color: var(--line);
508
+ background: var(--ground);
509
+ color: var(--ink);
510
+ }
511
+
512
+ .n133-admin .theme-menu {
513
+ position: absolute;
514
+ top: calc(100% + 6px);
515
+ right: 0;
516
+ z-index: 20;
517
+ display: grid;
518
+ gap: 2px;
519
+ min-width: 156px;
520
+ padding: 6px;
521
+ border: 1px solid var(--line);
522
+ border-radius: 14px;
523
+ background: var(--paper);
524
+ box-shadow: 0 10px 30px -10px rgb(var(--shade) / 0.35);
525
+ text-align: left;
526
+ }
527
+
528
+ .n133-admin .theme-choice {
529
+ display: flex;
530
+ align-items: center;
531
+ gap: 10px;
532
+ min-height: 44px;
533
+ padding: 0 12px;
534
+ border: 0;
535
+ border-radius: 10px;
536
+ background: transparent;
537
+ color: var(--ink);
538
+ font: 650 15px/1 var(--font);
539
+ cursor: pointer;
540
+ touch-action: manipulation;
541
+ }
542
+
543
+ @media (hover: hover) {
544
+ .n133-admin .theme-choice:hover {
545
+ background: var(--ground);
546
+ }
547
+ }
548
+
549
+ .n133-admin .theme-choice[aria-pressed="true"] {
550
+ background: color-mix(in srgb, var(--gem) 16%, var(--paper));
551
+ color: var(--ink);
552
+ }
553
+
554
+ .n133-admin .theme-choice[aria-pressed="true"] .icon {
555
+ color: var(--gem-dark);
556
+ }
557
+
558
+ .n133-admin .theme-choice:focus-visible {
559
+ outline: 3px solid var(--sea);
560
+ outline-offset: -3px;
561
+ }
562
+
403
563
  /* ---- What is still off: one banner ---- */
404
564
 
405
565
  .n133-admin .banner {
406
566
  display: flex;
407
567
  gap: 12px;
408
568
  padding: 14px 16px;
409
- border: 1px solid #f3c27a;
569
+ border: 1px solid var(--note-line);
410
570
  border-radius: var(--radius);
411
- background: #fff7e8;
412
- color: #4a2d00;
571
+ background: var(--note-bg);
572
+ color: var(--note-ink);
413
573
  }
414
574
 
415
575
  .n133-admin .banner h2 {
@@ -461,11 +621,11 @@
461
621
  }
462
622
 
463
623
  .n133-admin .kpi-open {
464
- --accent: #1d5fa8;
624
+ --accent: var(--info);
465
625
  }
466
626
 
467
627
  .n133-admin .kpi-late {
468
- --accent: #6b7686;
628
+ --accent: var(--quiet);
469
629
  }
470
630
 
471
631
  .n133-admin .kpi-late.is-alert {
@@ -475,7 +635,7 @@
475
635
  }
476
636
 
477
637
  .n133-admin .kpi-players {
478
- --accent: #6d3fc0;
638
+ --accent: var(--violet);
479
639
  }
480
640
 
481
641
  .n133-admin .kpi-top {
@@ -627,7 +787,7 @@
627
787
  .n133-admin .seg-btn.is-on {
628
788
  background: var(--paper);
629
789
  color: var(--ink);
630
- box-shadow: 0 1px 3px rgb(16 24 40 / 0.14);
790
+ box-shadow: 0 1px 3px rgb(var(--shade) / 0.14);
631
791
  }
632
792
 
633
793
  .n133-admin .seg-btn.is-alert:not(.is-on) {
@@ -661,51 +821,51 @@
661
821
  align-items: center;
662
822
  padding: 3px 10px;
663
823
  border-radius: 999px;
664
- background: #eef1f4;
665
- color: #3f4a5a;
824
+ background: var(--neutral-bg);
825
+ color: var(--neutral-ink);
666
826
  font-size: 13px;
667
827
  font-weight: 700;
668
828
  white-space: nowrap;
669
829
  }
670
830
 
671
831
  .n133-admin .badge-paid {
672
- background: #dcf6ec;
673
- color: #0b6a52;
832
+ background: var(--ok-bg);
833
+ color: var(--ok-ink);
674
834
  }
675
835
 
676
836
  .n133-admin .badge-open {
677
- background: #e3f0ff;
678
- color: #134e86;
837
+ background: var(--info-bg);
838
+ color: var(--info-ink);
679
839
  }
680
840
 
681
841
  .n133-admin .badge-paid_late {
682
- background: #ffe3e8;
683
- color: #9e1029;
842
+ background: var(--bad-bg);
843
+ color: var(--bad-ink);
684
844
  }
685
845
 
686
846
  .n133-admin .pill {
687
847
  flex: none;
688
848
  padding: 3px 10px;
689
849
  border-radius: 999px;
690
- background: #eef1f4;
691
- color: #3f4a5a;
850
+ background: var(--neutral-bg);
851
+ color: var(--neutral-ink);
692
852
  font-size: 13px;
693
853
  font-weight: 700;
694
854
  }
695
855
 
696
856
  .n133-admin .pill-on {
697
- background: #dcf6ec;
698
- color: #0b6a52;
857
+ background: var(--ok-bg);
858
+ color: var(--ok-ink);
699
859
  }
700
860
 
701
861
  .n133-admin .look-dot,
702
862
  .n133-admin .swatch {
703
- --swatch: #c9d3df;
863
+ --swatch: var(--swatch-empty);
704
864
  display: inline-block;
705
865
  flex: none;
706
866
  width: 12px;
707
867
  height: 12px;
708
- border: 1px solid rgb(0 0 0 / 0.18);
868
+ border: 1px solid var(--swatch-edge);
709
869
  border-radius: 50%;
710
870
  background: var(--swatch);
711
871
  }
@@ -726,31 +886,31 @@
726
886
  gap: 4px;
727
887
  padding: 2px 8px;
728
888
  border-radius: 999px;
729
- background: #eef1f4;
730
- color: #3f4a5a;
889
+ background: var(--neutral-bg);
890
+ color: var(--neutral-ink);
731
891
  font-size: 12px;
732
892
  font-weight: 700;
733
893
  white-space: nowrap;
734
894
  }
735
895
 
736
896
  .n133-admin .mark-new {
737
- background: #e3f0ff;
738
- color: #134e86;
897
+ background: var(--info-bg);
898
+ color: var(--info-ink);
739
899
  }
740
900
 
741
901
  .n133-admin .mark-changed {
742
- background: #fff1d6;
902
+ background: var(--warn-bg);
743
903
  color: var(--warn);
744
904
  }
745
905
 
746
906
  .n133-admin .mark-bad {
747
- background: #ffe3e8;
907
+ background: var(--bad-bg);
748
908
  color: var(--bad);
749
909
  }
750
910
 
751
911
  .n133-admin .mark-ok {
752
- background: #dcf6ec;
753
- color: #0b6a52;
912
+ background: var(--ok-bg);
913
+ color: var(--ok-ink);
754
914
  }
755
915
 
756
916
  .n133-admin .share {
@@ -785,8 +945,8 @@
785
945
  gap: 6px 8px;
786
946
  padding: 10px 12px;
787
947
  border-radius: 12px;
788
- background: #fff1f3;
789
- color: #5c0716;
948
+ background: var(--alarm-bg);
949
+ color: var(--alarm-ink);
790
950
  font-size: 14px;
791
951
  }
792
952
 
@@ -1163,10 +1323,10 @@
1163
1323
  align-items: center;
1164
1324
  gap: 8px 10px;
1165
1325
  padding: 10px 12px;
1166
- border: 1px solid #f3c27a;
1326
+ border: 1px solid var(--note-line);
1167
1327
  border-radius: 14px;
1168
- background: #fff7e8;
1169
- color: #4a2d00;
1328
+ background: var(--note-bg);
1329
+ color: var(--note-ink);
1170
1330
  font-size: 14px;
1171
1331
  }
1172
1332
 
@@ -1201,12 +1361,12 @@
1201
1361
 
1202
1362
  /* The stage: the picture on a soft tint of the look's own body colour. */
1203
1363
  .n133-admin .shelf-stage {
1204
- --tint: #9fb3c8;
1364
+ --tint: var(--tint-empty);
1205
1365
  position: relative;
1206
1366
  display: grid;
1207
1367
  place-items: center;
1208
1368
  min-height: 128px;
1209
- background: radial-gradient(circle at 50% 70%, color-mix(in srgb, var(--tint) 30%, var(--paper)), color-mix(in srgb, var(--tint) 10%, var(--paper)) 70%);
1369
+ background: radial-gradient(circle at 50% 70%, color-mix(in srgb, var(--tint) 30%, var(--stage)), color-mix(in srgb, var(--tint) 10%, var(--stage)) 70%);
1210
1370
  }
1211
1371
 
1212
1372
  .n133-admin .shelf-pic {
@@ -1244,7 +1404,7 @@
1244
1404
  padding: 4px 10px;
1245
1405
  border-radius: 999px;
1246
1406
  background: var(--paper);
1247
- box-shadow: 0 1px 3px rgb(16 24 40 / 0.15);
1407
+ box-shadow: 0 1px 3px rgb(var(--shade) / 0.15);
1248
1408
  font-size: 13px;
1249
1409
  font-weight: 800;
1250
1410
  font-variant-numeric: tabular-nums;
@@ -1315,11 +1475,11 @@
1315
1475
  }
1316
1476
 
1317
1477
  .n133-admin .shelf-card.is-new {
1318
- border-color: color-mix(in srgb, #1d5fa8 45%, var(--line));
1478
+ border-color: color-mix(in srgb, var(--info) 45%, var(--line));
1319
1479
  }
1320
1480
 
1321
1481
  .n133-admin .shelf-card.is-changed {
1322
- border-color: color-mix(in srgb, #c47a00 50%, var(--line));
1482
+ border-color: color-mix(in srgb, var(--changed) 50%, var(--line));
1323
1483
  }
1324
1484
 
1325
1485
  .n133-admin .shelf-card.has-problem {
@@ -1331,7 +1491,7 @@
1331
1491
  gap: 0;
1332
1492
  border-width: 2px;
1333
1493
  border-color: var(--ink);
1334
- box-shadow: 0 10px 30px -10px rgb(16 24 40 / 0.25);
1494
+ box-shadow: 0 10px 30px -10px rgb(var(--shade) / 0.25);
1335
1495
  }
1336
1496
 
1337
1497
  .n133-admin .shelf-editing:focus {
@@ -1379,8 +1539,8 @@
1379
1539
  margin: 12px 14px 0;
1380
1540
  padding: 10px 12px 10px 30px;
1381
1541
  border-radius: 12px;
1382
- background: #fff1f3;
1383
- color: #5c0716;
1542
+ background: var(--alarm-bg);
1543
+ color: var(--alarm-ink);
1384
1544
  font-size: 14px;
1385
1545
  }
1386
1546
 
@@ -1616,7 +1776,7 @@
1616
1776
  border-top: 1px solid var(--line);
1617
1777
  border-bottom: 1px solid var(--line);
1618
1778
  background: color-mix(in srgb, var(--paper) 96%, transparent);
1619
- box-shadow: 0 -8px 24px -12px rgb(16 24 40 / 0.25);
1779
+ box-shadow: 0 -8px 24px -12px rgb(var(--shade) / 0.25);
1620
1780
  -webkit-backdrop-filter: blur(8px);
1621
1781
  backdrop-filter: blur(8px);
1622
1782
  }
@@ -1674,8 +1834,8 @@
1674
1834
  padding: 12px;
1675
1835
  overflow: auto;
1676
1836
  border-radius: 12px;
1677
- background: #0f172a;
1678
- color: #e6edf6;
1837
+ background: var(--code-bg);
1838
+ color: var(--code-ink);
1679
1839
  font-size: 12px;
1680
1840
  }
1681
1841
 
@@ -1753,7 +1913,7 @@
1753
1913
  height: 30px;
1754
1914
  border-radius: 50%;
1755
1915
  background: var(--paper);
1756
- box-shadow: 0 1px 3px rgb(16 24 40 / 0.25);
1916
+ box-shadow: 0 1px 3px rgb(var(--shade) / 0.25);
1757
1917
  }
1758
1918
 
1759
1919
  .n133-admin .switch.switch-on {
@@ -1761,7 +1921,7 @@
1761
1921
  padding: 0 6px 0 14px;
1762
1922
  border-color: var(--gem-dark);
1763
1923
  background: var(--gem);
1764
- color: #052b23;
1924
+ color: var(--on-gem);
1765
1925
  }
1766
1926
 
1767
1927
  .n133-admin .switch:disabled {
@@ -1801,7 +1961,7 @@
1801
1961
  gap: 8px;
1802
1962
  padding: 12px;
1803
1963
  border-radius: 12px;
1804
- background: #fff1f3;
1964
+ background: var(--alarm-bg);
1805
1965
  }
1806
1966
 
1807
1967
  .n133-admin .signout-confirm p {
@@ -2044,7 +2204,7 @@
2044
2204
 
2045
2205
  .n133-admin .tabbar-tab.is-on .tab-count {
2046
2206
  background: var(--gem);
2047
- color: #062b23;
2207
+ color: var(--on-gem);
2048
2208
  }
2049
2209
  }
2050
2210
 
@@ -0,0 +1,179 @@
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
+
8
+ import { h } from "./dom.js";
9
+ import { icon, type IconName } from "./icons.js";
10
+
11
+ export type ThemeChoice = "device" | "light" | "dark";
12
+ export type Theme = "light" | "dark";
13
+
14
+ /** The localStorage key of the choice. */
15
+ export const THEME_KEY = "n133-admin-theme";
16
+ export const THEME_CHOICES: readonly ThemeChoice[] = ["device", "light", "dark"];
17
+ const QUERY = "(prefers-color-scheme: dark)";
18
+
19
+ /** The theme a choice gives: Device follows the device's setting. */
20
+ export const resolveTheme = (choice: ThemeChoice, deviceDark: boolean): Theme => (choice === "device" ? (deviceDark ? "dark" : "light") : choice);
21
+
22
+ const isChoice = (v: unknown): v is ThemeChoice => typeof v === "string" && (THEME_CHOICES as readonly string[]).includes(v);
23
+
24
+ type Store = Pick<Storage, "getItem" | "setItem">;
25
+
26
+ /** The page's localStorage, or null where reading it throws (some private windows, a blocked site). */
27
+ function pageStorage(): Store | null {
28
+ try {
29
+ return globalThis.localStorage ?? null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** The kept choice; Device when there is none, it is not a choice, or the storage cannot be read. */
36
+ export function readThemeChoice(storage: Store | null = pageStorage()): ThemeChoice {
37
+ try {
38
+ const v = storage?.getItem(THEME_KEY);
39
+ return isChoice(v) ? v : "device";
40
+ } catch {
41
+ return "device";
42
+ }
43
+ }
44
+
45
+ export type ThemeOptions = {
46
+ /** Where the choice is kept (default: the page's localStorage). null keeps it only for the life of the page. */
47
+ storage?: Store | null;
48
+ /** The device's setting (default: window.matchMedia). */
49
+ matchMedia?: ((query: string) => Pick<MediaQueryList, "matches" | "addEventListener" | "removeEventListener">) | null;
50
+ /** The page's root, which gets data-n133-theme (default: document.documentElement). null sets nothing outside the panel. */
51
+ host?: HTMLElement | null;
52
+ };
53
+
54
+ export type ThemeControl = {
55
+ /** What the owner chose. */
56
+ readonly choice: ThemeChoice;
57
+ /** The theme drawn now. */
58
+ readonly resolved: Theme;
59
+ /** Picks a theme, keeps it in this browser and draws it. */
60
+ set(choice: ThemeChoice): void;
61
+ /** Hears every change of the choice or the drawn theme (the device's setting too). Returns the call that stops it. */
62
+ onChange(listen: (resolved: Theme, choice: ThemeChoice) => void): () => void;
63
+ /** Stops following the device and takes data-n133-theme off the page. */
64
+ destroy(): void;
65
+ };
66
+
67
+ /** Reads the kept choice and draws it on `root` at once (data-theme), and on the page's <html> (data-n133-theme). */
68
+ export function createTheme(root: HTMLElement, options: ThemeOptions = {}): ThemeControl {
69
+ const storage = options.storage === undefined ? pageStorage() : options.storage;
70
+ const host = options.host === undefined ? (globalThis.document?.documentElement ?? null) : options.host;
71
+ let media: ReturnType<NonNullable<ThemeOptions["matchMedia"]>> | null = null;
72
+ try {
73
+ const mm = options.matchMedia === undefined ? (typeof globalThis.matchMedia === "function" ? globalThis.matchMedia.bind(globalThis) : null) : options.matchMedia;
74
+ media = mm ? mm(QUERY) : null;
75
+ } catch {
76
+ media = null;
77
+ }
78
+ let choice = readThemeChoice(storage);
79
+ let resolved: Theme = "light";
80
+ const listeners = new Set<(resolved: Theme, choice: ThemeChoice) => void>();
81
+
82
+ function draw() {
83
+ resolved = resolveTheme(choice, media?.matches === true);
84
+ root.setAttribute("data-theme", resolved);
85
+ host?.setAttribute("data-n133-theme", resolved);
86
+ for (const l of listeners) l(resolved, choice);
87
+ }
88
+ const onDevice = () => {
89
+ if (choice === "device") draw();
90
+ };
91
+ media?.addEventListener?.("change", onDevice);
92
+ draw();
93
+
94
+ return {
95
+ get choice() {
96
+ return choice;
97
+ },
98
+ get resolved() {
99
+ return resolved;
100
+ },
101
+ set(next) {
102
+ if (!isChoice(next)) return;
103
+ choice = next;
104
+ try {
105
+ storage?.setItem(THEME_KEY, next);
106
+ } catch {
107
+ // A full or refused storage: the choice holds for this page only.
108
+ }
109
+ draw();
110
+ },
111
+ onChange(listen) {
112
+ listeners.add(listen);
113
+ return () => listeners.delete(listen);
114
+ },
115
+ destroy() {
116
+ media?.removeEventListener?.("change", onDevice);
117
+ listeners.clear();
118
+ host?.removeAttribute("data-n133-theme");
119
+ },
120
+ };
121
+ }
122
+
123
+ const WORDS: Record<ThemeChoice, string> = { device: "Device", light: "Light", dark: "Dark" };
124
+ const ICONS: Record<ThemeChoice, IconName> = { device: "device", light: "sun", dark: "moon" };
125
+
126
+ /** "Theme: Dark", or "Theme: Device (dark now)". */
127
+ export const themeLabel = (choice: ThemeChoice, resolved: Theme): string => (choice === "device" ? `Theme: Device (${resolved} now)` : `Theme: ${WORDS[choice]}`);
128
+
129
+ /**
130
+ * 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
131
+ * 44 px row with its icon and word; the chosen one is pressed (aria-pressed). A choice, Escape or a press outside closes it.
132
+ */
133
+ export function themeKey(theme: ThemeControl): HTMLElement {
134
+ const summary = h("summary", { class: "btn btn-quiet theme-summary" });
135
+ const rows = THEME_CHOICES.map((c) =>
136
+ h(
137
+ "button",
138
+ {
139
+ class: "theme-choice",
140
+ type: "button",
141
+ "data-choice": c,
142
+ onclick: () => {
143
+ theme.set(c);
144
+ box.open = false;
145
+ summary.focus();
146
+ },
147
+ },
148
+ icon(ICONS[c], 18),
149
+ WORDS[c],
150
+ ),
151
+ );
152
+ const menu = h("div", { class: "theme-menu", role: "group", "aria-label": "Theme" }, ...rows);
153
+ const box = h("details", { class: "theme-key" }, summary, menu);
154
+
155
+ const outside = (e: Event) => {
156
+ if (!box.contains(e.target as Node)) box.open = false;
157
+ };
158
+ box.addEventListener("toggle", () => {
159
+ if (box.open) document.addEventListener("pointerdown", outside, true);
160
+ else document.removeEventListener("pointerdown", outside, true);
161
+ });
162
+ box.addEventListener("keydown", (e) => {
163
+ if ((e as KeyboardEvent).key === "Escape" && box.open) {
164
+ box.open = false;
165
+ summary.focus();
166
+ }
167
+ });
168
+
169
+ const show = (resolved: Theme, choice: ThemeChoice) => {
170
+ const label = themeLabel(choice, resolved);
171
+ summary.replaceChildren(icon(ICONS[choice], 20));
172
+ summary.title = label;
173
+ summary.setAttribute("aria-label", label);
174
+ for (const r of rows) r.setAttribute("aria-pressed", String(r.dataset.choice === choice));
175
+ };
176
+ show(theme.resolved, theme.choice);
177
+ theme.onChange(show);
178
+ return box;
179
+ }
@@ -38,8 +38,8 @@ export const FRIENDS_LIMITS = {
38
38
  calls: { limit: 30, windowMs: 60_000 },
39
39
  /** The biggest body of a POST. */
40
40
  bodyMax: 1024,
41
- /** The longest name a request may send. */
42
- nameMax: 64,
41
+ /** The longest name a request may send: room for a whole Nano address (65 characters with "nano_"), which a directory may accept as a name. */
42
+ nameMax: 80,
43
43
  } as const;
44
44
 
45
45
  const sharedLimit = memoryLimiter();
@@ -202,7 +202,7 @@ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () =
202
202
  const api: Api = createApi(options.base ?? "/friends", options.fetch);
203
203
  const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
204
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" });
205
+ const nameInput = h("input", { class: "n133f-input", type: "text", name: "friend", maxlength: 80, autocomplete: "off", placeholder: "A player's name", "aria-label": "A player's name" });
206
206
  const addKey = h("button", { class: "n133f-btn n133f-primary", type: "submit" }, "Add");
207
207
  const form = h("form", { class: "n133f-add" }, nameInput, addKey);
208
208
  const list = h("div", { class: "n133f-list" });