@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 +14 -0
- package/README.md +32 -1
- package/dist/admin/panel/icons.d.ts +18 -0
- package/dist/admin/panel/icons.js +3 -0
- package/dist/admin/panel/index.d.ts +2 -0
- package/dist/admin/panel/index.js +1 -0
- package/dist/admin/panel/mount.d.ts +5 -0
- package/dist/admin/panel/mount.js +11 -5
- package/dist/admin/panel/theme.d.ts +40 -0
- package/dist/admin/panel/theme.js +142 -0
- package/dist/friends/handler.d.ts +2 -2
- package/dist/friends/handler.js +2 -2
- package/dist/friends/panel/mount.js +1 -1
- package/package.json +1 -1
- package/src/admin/panel/icons.ts +3 -0
- package/src/admin/panel/index.ts +2 -0
- package/src/admin/panel/mount.ts +15 -5
- package/src/admin/panel/style.css +222 -62
- package/src/admin/panel/theme.ts +179 -0
- package/src/friends/handler.ts +2 -2
- package/src/friends/panel/mount.ts +1 -1
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: "
|
|
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:
|
|
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;
|
package/dist/friends/handler.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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",
|
package/src/admin/panel/icons.ts
CHANGED
|
@@ -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
|
|
package/src/admin/panel/index.ts
CHANGED
|
@@ -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";
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -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
|
-
--
|
|
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
|
-
--
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
390
|
-
color:
|
|
463
|
+
background: var(--ok-bg);
|
|
464
|
+
color: var(--ok-ink);
|
|
391
465
|
}
|
|
392
466
|
|
|
393
467
|
.n133-admin .chip-warn {
|
|
394
|
-
background:
|
|
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
|
|
569
|
+
border: 1px solid var(--note-line);
|
|
410
570
|
border-radius: var(--radius);
|
|
411
|
-
background:
|
|
412
|
-
color:
|
|
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:
|
|
624
|
+
--accent: var(--info);
|
|
465
625
|
}
|
|
466
626
|
|
|
467
627
|
.n133-admin .kpi-late {
|
|
468
|
-
--accent:
|
|
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:
|
|
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(
|
|
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:
|
|
665
|
-
color:
|
|
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:
|
|
673
|
-
color:
|
|
832
|
+
background: var(--ok-bg);
|
|
833
|
+
color: var(--ok-ink);
|
|
674
834
|
}
|
|
675
835
|
|
|
676
836
|
.n133-admin .badge-open {
|
|
677
|
-
background:
|
|
678
|
-
color:
|
|
837
|
+
background: var(--info-bg);
|
|
838
|
+
color: var(--info-ink);
|
|
679
839
|
}
|
|
680
840
|
|
|
681
841
|
.n133-admin .badge-paid_late {
|
|
682
|
-
background:
|
|
683
|
-
color:
|
|
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:
|
|
691
|
-
color:
|
|
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:
|
|
698
|
-
color:
|
|
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:
|
|
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
|
|
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:
|
|
730
|
-
color:
|
|
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:
|
|
738
|
-
color:
|
|
897
|
+
background: var(--info-bg);
|
|
898
|
+
color: var(--info-ink);
|
|
739
899
|
}
|
|
740
900
|
|
|
741
901
|
.n133-admin .mark-changed {
|
|
742
|
-
background:
|
|
902
|
+
background: var(--warn-bg);
|
|
743
903
|
color: var(--warn);
|
|
744
904
|
}
|
|
745
905
|
|
|
746
906
|
.n133-admin .mark-bad {
|
|
747
|
-
background:
|
|
907
|
+
background: var(--bad-bg);
|
|
748
908
|
color: var(--bad);
|
|
749
909
|
}
|
|
750
910
|
|
|
751
911
|
.n133-admin .mark-ok {
|
|
752
|
-
background:
|
|
753
|
-
color:
|
|
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:
|
|
789
|
-
color:
|
|
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
|
|
1326
|
+
border: 1px solid var(--note-line);
|
|
1167
1327
|
border-radius: 14px;
|
|
1168
|
-
background:
|
|
1169
|
-
color:
|
|
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:
|
|
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(--
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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:
|
|
1383
|
-
color:
|
|
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(
|
|
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:
|
|
1678
|
-
color:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/src/friends/handler.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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" });
|