@nervur-org/dock 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/GETTING_STARTED.md +128 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +37 -0
  5. package/api/quo-api.md +196 -0
  6. package/api/route.ts +227 -0
  7. package/beings/GLOSSARY.md +118 -0
  8. package/beings/WORLDS.md +567 -0
  9. package/beings/avatar.ts +101 -0
  10. package/beings/carry.ts +159 -0
  11. package/beings/clock.ts +552 -0
  12. package/beings/courier.ts +107 -0
  13. package/beings/desk.ts +128 -0
  14. package/beings/doorbell.ts +129 -0
  15. package/beings/envoy.ts +218 -0
  16. package/beings/index.ts +16 -0
  17. package/beings/join.ts +51 -0
  18. package/beings/link.ts +104 -0
  19. package/beings/look.ts +139 -0
  20. package/beings/porter.ts +43 -0
  21. package/beings/presence.ts +39 -0
  22. package/beings/quo-app-bells.md +280 -0
  23. package/beings/quo-dock.md +1942 -0
  24. package/beings/setup.ts +45 -0
  25. package/beings/side.ts +60 -0
  26. package/beings/user.ts +459 -0
  27. package/beings/way.ts +65 -0
  28. package/cli/bundles.ts +20 -0
  29. package/cli/client.ts +73 -0
  30. package/cli/daemon.ts +581 -0
  31. package/cli/estate/Caddyfile +33 -0
  32. package/cli/estate/quo.service +35 -0
  33. package/cli/estate/systems.quo.plist +44 -0
  34. package/cli/estate.ts +48 -0
  35. package/cli/folder.ts +27 -0
  36. package/cli/oauth.ts +32 -0
  37. package/cli/pilot.ts +38 -0
  38. package/cli/quo-dock.md +1942 -0
  39. package/cli/quo.ts +203 -0
  40. package/cli/stand.ts +160 -0
  41. package/dist/api/route.d.ts +21 -0
  42. package/dist/api/route.js +193 -0
  43. package/dist/beings/avatar.d.ts +32 -0
  44. package/dist/beings/avatar.js +100 -0
  45. package/dist/beings/carry.d.ts +12 -0
  46. package/dist/beings/carry.js +174 -0
  47. package/dist/beings/clock.d.ts +161 -0
  48. package/dist/beings/clock.js +528 -0
  49. package/dist/beings/courier.d.ts +50 -0
  50. package/dist/beings/courier.js +96 -0
  51. package/dist/beings/desk.d.ts +53 -0
  52. package/dist/beings/desk.js +135 -0
  53. package/dist/beings/doorbell.d.ts +78 -0
  54. package/dist/beings/doorbell.js +133 -0
  55. package/dist/beings/envoy.d.ts +54 -0
  56. package/dist/beings/envoy.js +211 -0
  57. package/dist/beings/index.d.ts +14 -0
  58. package/dist/beings/index.js +16 -0
  59. package/dist/beings/join.d.ts +15 -0
  60. package/dist/beings/join.js +42 -0
  61. package/dist/beings/link.d.ts +14 -0
  62. package/dist/beings/link.js +91 -0
  63. package/dist/beings/look.d.ts +30 -0
  64. package/dist/beings/look.js +104 -0
  65. package/dist/beings/porter.d.ts +14 -0
  66. package/dist/beings/porter.js +41 -0
  67. package/dist/beings/presence.d.ts +8 -0
  68. package/dist/beings/presence.js +21 -0
  69. package/dist/beings/setup.d.ts +3 -0
  70. package/dist/beings/setup.js +39 -0
  71. package/dist/beings/side.d.ts +32 -0
  72. package/dist/beings/side.js +37 -0
  73. package/dist/beings/user.d.ts +315 -0
  74. package/dist/beings/user.js +462 -0
  75. package/dist/beings/way.d.ts +19 -0
  76. package/dist/beings/way.js +28 -0
  77. package/dist/cli/bundles.d.ts +5 -0
  78. package/dist/cli/bundles.js +17 -0
  79. package/dist/cli/client.d.ts +15 -0
  80. package/dist/cli/client.js +58 -0
  81. package/dist/cli/daemon.d.ts +50 -0
  82. package/dist/cli/daemon.js +584 -0
  83. package/dist/cli/estate/Caddyfile +33 -0
  84. package/dist/cli/estate/quo.service +35 -0
  85. package/dist/cli/estate/systems.quo.plist +44 -0
  86. package/dist/cli/estate.d.ts +5 -0
  87. package/dist/cli/estate.js +50 -0
  88. package/dist/cli/folder.d.ts +2 -0
  89. package/dist/cli/folder.js +25 -0
  90. package/dist/cli/oauth.d.ts +2 -0
  91. package/dist/cli/oauth.js +32 -0
  92. package/dist/cli/pilot.d.ts +5 -0
  93. package/dist/cli/pilot.js +22 -0
  94. package/dist/cli/quo.d.ts +2 -0
  95. package/dist/cli/quo.js +201 -0
  96. package/dist/cli/stand.d.ts +37 -0
  97. package/dist/cli/stand.js +110 -0
  98. package/dist/dock/agents.d.ts +102 -0
  99. package/dist/dock/agents.js +105 -0
  100. package/dist/dock/apns.d.ts +27 -0
  101. package/dist/dock/apns.js +98 -0
  102. package/dist/dock/console.d.ts +6 -0
  103. package/dist/dock/console.js +20 -0
  104. package/dist/dock/dialers.d.ts +56 -0
  105. package/dist/dock/dialers.js +75 -0
  106. package/dist/dock/faculty.d.ts +24 -0
  107. package/dist/dock/faculty.js +142 -0
  108. package/dist/dock/fcm.d.ts +13 -0
  109. package/dist/dock/fcm.js +77 -0
  110. package/dist/dock/grants.d.ts +41 -0
  111. package/dist/dock/grants.js +99 -0
  112. package/dist/dock/http2.d.ts +4 -0
  113. package/dist/dock/http2.js +68 -0
  114. package/dist/dock/index.d.ts +41 -0
  115. package/dist/dock/index.js +113 -0
  116. package/dist/dock/local.d.ts +13 -0
  117. package/dist/dock/local.js +44 -0
  118. package/dist/dock/location.d.ts +19 -0
  119. package/dist/dock/location.js +11 -0
  120. package/dist/dock/notifications.d.ts +38 -0
  121. package/dist/dock/notifications.js +60 -0
  122. package/dist/dock/push.d.ts +79 -0
  123. package/dist/dock/push.js +132 -0
  124. package/dist/dock/service.d.ts +4 -0
  125. package/dist/dock/service.js +107 -0
  126. package/dist/dock/timer.d.ts +52 -0
  127. package/dist/dock/timer.js +149 -0
  128. package/dist/dock/webpush.d.ts +12 -0
  129. package/dist/dock/webpush.js +144 -0
  130. package/dist/harbor/browser.d.ts +15 -0
  131. package/dist/harbor/browser.js +82 -0
  132. package/dist/harbor/capacitor.d.ts +28 -0
  133. package/dist/harbor/capacitor.js +218 -0
  134. package/dist/harbor/disk.d.ts +27 -0
  135. package/dist/harbor/disk.js +249 -0
  136. package/dist/harbor/edge/alarm.d.ts +10 -0
  137. package/dist/harbor/edge/alarm.js +38 -0
  138. package/dist/harbor/edge/edge.d.ts +54 -0
  139. package/dist/harbor/edge/edge.js +370 -0
  140. package/dist/harbor/edge/exercise.d.ts +7 -0
  141. package/dist/harbor/edge/exercise.js +232 -0
  142. package/dist/harbor/edge/given.d.ts +26 -0
  143. package/dist/harbor/edge/given.js +42 -0
  144. package/dist/harbor/edge/platform.d.ts +39 -0
  145. package/dist/harbor/edge/storage.d.ts +30 -0
  146. package/dist/harbor/edge/storage.js +180 -0
  147. package/dist/harbor/edge/worker.d.ts +17 -0
  148. package/dist/harbor/edge/worker.js +67 -0
  149. package/dist/harbor/files.d.ts +15 -0
  150. package/dist/harbor/files.js +220 -0
  151. package/dist/harbor/http.d.ts +19 -0
  152. package/dist/harbor/http.js +177 -0
  153. package/dist/harbor/idb.d.ts +15 -0
  154. package/dist/harbor/idb.js +0 -0
  155. package/dist/harbor/quo.d.ts +10 -0
  156. package/dist/harbor/quo.js +42 -0
  157. package/dist/harbor/resolve.d.ts +16 -0
  158. package/dist/harbor/resolve.js +17 -0
  159. package/dist/harbor/seal.d.ts +3 -0
  160. package/dist/harbor/seal.js +25 -0
  161. package/dist/harbor/sealed.d.ts +35 -0
  162. package/dist/harbor/sealed.js +162 -0
  163. package/dist/harbor/tauri.d.ts +16 -0
  164. package/dist/harbor/tauri.js +59 -0
  165. package/dist/harbor/ward.d.ts +9 -0
  166. package/dist/harbor/ward.js +15 -0
  167. package/dist/human/app.d.ts +32 -0
  168. package/dist/human/app.js +527 -0
  169. package/dist/human/apps.d.ts +10 -0
  170. package/dist/human/apps.js +29 -0
  171. package/dist/human/dom.d.ts +2 -0
  172. package/dist/human/dom.js +15 -0
  173. package/dist/human/door.d.ts +19 -0
  174. package/dist/human/door.js +92 -0
  175. package/dist/human/guest.d.ts +3 -0
  176. package/dist/human/guest.js +33 -0
  177. package/dist/human/html.d.ts +55 -0
  178. package/dist/human/html.js +451 -0
  179. package/dist/human/local.d.ts +7 -0
  180. package/dist/human/local.js +10 -0
  181. package/dist/human/mark.d.ts +8 -0
  182. package/dist/human/mark.js +47 -0
  183. package/dist/human/move.d.ts +17 -0
  184. package/dist/human/move.js +90 -0
  185. package/dist/human/screen.d.ts +19 -0
  186. package/dist/human/screen.js +119 -0
  187. package/dist/human/style.d.ts +2 -0
  188. package/dist/human/style.js +60 -0
  189. package/dist/human/tab.bundle.txt +4775 -0
  190. package/dist/human/tab.d.ts +15 -0
  191. package/dist/human/tab.js +409 -0
  192. package/dist/human/tree.d.ts +50 -0
  193. package/dist/human/tree.js +157 -0
  194. package/dist/human/wake.d.ts +34 -0
  195. package/dist/human/wake.js +179 -0
  196. package/dist/human/web.d.ts +33 -0
  197. package/dist/human/web.js +184 -0
  198. package/dist/human/worker.bundle.txt +3868 -0
  199. package/dist/human/worker.d.ts +1 -0
  200. package/dist/human/worker.js +81 -0
  201. package/dist/human/worlds.d.ts +24 -0
  202. package/dist/human/worlds.js +99 -0
  203. package/dist/mcp/agent.d.ts +10 -0
  204. package/dist/mcp/agent.js +78 -0
  205. package/dist/mcp/allow.d.ts +20 -0
  206. package/dist/mcp/allow.js +88 -0
  207. package/dist/mcp/http.d.ts +27 -0
  208. package/dist/mcp/http.js +92 -0
  209. package/dist/mcp/oauth.d.ts +91 -0
  210. package/dist/mcp/oauth.js +241 -0
  211. package/dist/mcp/route.d.ts +15 -0
  212. package/dist/mcp/route.js +42 -0
  213. package/dist/mcp/runner.d.ts +45 -0
  214. package/dist/mcp/runner.js +99 -0
  215. package/dist/mcp/server.d.ts +11 -0
  216. package/dist/mcp/server.js +102 -0
  217. package/dock/agents.ts +121 -0
  218. package/dock/apns.ts +138 -0
  219. package/dock/console.ts +23 -0
  220. package/dock/dialers.ts +85 -0
  221. package/dock/faculty.ts +148 -0
  222. package/dock/fcm.ts +103 -0
  223. package/dock/grants.ts +111 -0
  224. package/dock/http2.ts +70 -0
  225. package/dock/index.ts +133 -0
  226. package/dock/local.ts +48 -0
  227. package/dock/location.ts +22 -0
  228. package/dock/notifications.ts +67 -0
  229. package/dock/push.ts +161 -0
  230. package/dock/quo-app-bells.md +280 -0
  231. package/dock/quo-dock.md +1942 -0
  232. package/dock/service.ts +100 -0
  233. package/dock/timer.ts +174 -0
  234. package/dock/webpush.ts +182 -0
  235. package/harbor/browser.ts +97 -0
  236. package/harbor/capacitor.ts +223 -0
  237. package/harbor/disk.ts +237 -0
  238. package/harbor/edge/alarm.ts +48 -0
  239. package/harbor/edge/edge.ts +407 -0
  240. package/harbor/edge/exercise.ts +241 -0
  241. package/harbor/edge/given.ts +106 -0
  242. package/harbor/edge/platform.d.ts +39 -0
  243. package/harbor/edge/storage.ts +179 -0
  244. package/harbor/edge/text.d.ts +15 -0
  245. package/harbor/edge/worker.ts +70 -0
  246. package/harbor/edge/wrangler.toml +30 -0
  247. package/harbor/files.ts +221 -0
  248. package/harbor/http.ts +188 -0
  249. package/harbor/idb.ts +0 -0
  250. package/harbor/quo-harbor.md +850 -0
  251. package/harbor/quo.ts +44 -0
  252. package/harbor/resolve.ts +31 -0
  253. package/harbor/seal.ts +26 -0
  254. package/harbor/sealed.ts +183 -0
  255. package/harbor/tauri.ts +79 -0
  256. package/harbor/ward.ts +28 -0
  257. package/human/app.ts +580 -0
  258. package/human/apps.ts +47 -0
  259. package/human/dom.ts +21 -0
  260. package/human/door.ts +113 -0
  261. package/human/guest.ts +34 -0
  262. package/human/html.ts +490 -0
  263. package/human/local.ts +30 -0
  264. package/human/mark.ts +53 -0
  265. package/human/move.ts +122 -0
  266. package/human/quo-human.md +1042 -0
  267. package/human/screen.ts +136 -0
  268. package/human/style.ts +63 -0
  269. package/human/tab.bundle.txt +4775 -0
  270. package/human/tab.ts +446 -0
  271. package/human/tree.ts +217 -0
  272. package/human/wake.ts +191 -0
  273. package/human/web.ts +259 -0
  274. package/human/worker.bundle.txt +3868 -0
  275. package/human/worker.ts +124 -0
  276. package/human/worlds.ts +127 -0
  277. package/mcp/agent.ts +94 -0
  278. package/mcp/allow.ts +131 -0
  279. package/mcp/http.ts +92 -0
  280. package/mcp/oauth.ts +270 -0
  281. package/mcp/quo-mcp.md +343 -0
  282. package/mcp/route.ts +45 -0
  283. package/mcp/runner.ts +136 -0
  284. package/mcp/server.ts +101 -0
  285. package/package.json +121 -0
package/human/tab.ts ADDED
@@ -0,0 +1,446 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The tab: a page on a world's web. that is a device of its own. The tab
3
+ // boots a browser harbor on a seed minted into IndexedDB the first time,
4
+ // and is a browser of worlds and of the relations in them, `worlds.ts`: one
5
+ // local ward per far world, one avatar per relation, a switcher over both.
6
+ // A switch is a switch of avatar and nothing more.
7
+ //
8
+ // Three ways in and there is no fourth, the same act each time, a knock
9
+ // that ends in a standing, and each one a fresh avatar:
10
+ //
11
+ // a link the page with the invitation in its fragment, `#quo=...`,
12
+ // read and stripped before anything else runs, then joined.
13
+ // The invitation names its ward, so it is joined in that
14
+ // world's local ward whatever page it was opened on, and a
15
+ // link opened while already in is one more relation.
16
+ // a proof the page with a proof in its fragment, `#proof=<kind>.<token>`,
17
+ // read and stripped the same way. A proof names no ward, so it
18
+ // is for the world whose page it was opened on: the tab knocks
19
+ // at that world's public being with it, and what she trades it
20
+ // for is an invitation, joined like any other. This is the way
21
+ // in a world sent out itself, by mail or by message.
22
+ // a guest the world's public being, whatever class she is, rendered
23
+ // as a page by the same screen; a form on it whose answer is
24
+ // an invitation is the way in.
25
+ //
26
+ // Nothing is typed to get in, ever: no password, no account. A ward with
27
+ // nobody at its door shows the door page, `door.ts`, and a link is the only
28
+ // way. From then on there is no cookie and no token anywhere; the seed and
29
+ // the standings are in the tab's store, and a reload asks nothing. The screen
30
+ // is the human side in `screen.ts` over the DOM surface in `dom.ts`. This
31
+ // file is the shell: the harbor, the config, the switcher and the ways in,
32
+ // and it hands one avatar at a time to the side.
33
+ import type { Invitation, JsonObject } from '@nervur-org/nervur';
34
+ import { BrowserHarbor } from '../harbor/browser.ts';
35
+ import { USER } from '../beings/avatar.ts';
36
+ import { isInvitation } from '@nervur-org/nervur';
37
+ import { parse, parseProof, strip } from '../beings/link.ts';
38
+ import { giveWorld, type Giving } from './move.ts';
39
+ import { drained, subscribe } from './wake.ts';
40
+ import { screenSide } from './screen.ts';
41
+ import { refused } from './html.ts';
42
+ import { domSurface } from './dom.ts';
43
+ import { guest } from './guest.ts';
44
+ import { door, held } from './door.ts';
45
+ import { world, relations, fresh, localName, type Relation } from './worlds.ts';
46
+ import { local } from './local.ts';
47
+ import type { BeingClass } from '@nervur-org/nervur';
48
+
49
+ // What the page is told by the daemon that served it: the world's routes,
50
+ // every ward on that harbor by name, its pk and whether a public being is
51
+ // at its door, which of them this page is, if it is one's, and whether the
52
+ // world serves code for the tab.
53
+ // `bell` is the world's doorbell key, the public half of its VAPID pair,
54
+ // which a page subscribes to a push service with. A world with no doorbell
55
+ // sends none, and a device there is reachable only while it holds a line.
56
+ export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; being?: string; beings?: boolean; bell?: string };
57
+
58
+ // What the tab remembers between pages, beside the harbor: the worlds it
59
+ // has joined, by pk, where each lives and what it is called; which relation
60
+ // is on screen in each; and what each relation is called. A tab with no
61
+ // storage still works, and forgets.
62
+ type Worlds = Record<string, { url: string; name: string }>;
63
+ const kept = <T>(key: string, fallback: T): T => {
64
+ try {
65
+ return (JSON.parse(localStorage.getItem(key) ?? 'null') as T | null) ?? fallback;
66
+ } catch {
67
+ return fallback;
68
+ }
69
+ };
70
+ const keep = (key: string, value: unknown) => {
71
+ try {
72
+ localStorage.setItem(key, JSON.stringify(value));
73
+ } catch {
74
+ /* forgets */
75
+ }
76
+ };
77
+ const WORLDS = 'quo-worlds';
78
+ const worlds = (): Worlds => kept(WORLDS, {});
79
+ const remember = (pk: string, url: string, name: string) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
80
+ const AT = (pk: string) => `quo-at:${pk}`;
81
+ const NAMES = (pk: string) => `quo-names:${pk}`;
82
+
83
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
84
+ const e = document.createElement(tag);
85
+ if (text) e.textContent = text;
86
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
87
+ return e;
88
+ };
89
+
90
+ export async function start(cfg: Config, root: HTMLElement = document.body): Promise<void> {
91
+ // The link, first and once: the invitation, or the proof a world sent
92
+ // out, leaves the address bar before any other code sees it.
93
+ const linked = parse(location.hash);
94
+ const proved = parseProof(location.hash);
95
+ if (linked || proved) history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
96
+
97
+ const nav = el('nav', '', { class: 'worlds nv-strip' });
98
+ const who = el('nav', '', { class: 'relations nv-row' });
99
+ const status = el('p', 'booting', { class: 'state nv-t-quiet' });
100
+ const screen = el('div');
101
+ root.append(nav, who, status, screen);
102
+ const say = (s: string) => (status.textContent = s);
103
+
104
+ // The harbor in the tab: one database per origin, one ward per world.
105
+ // The world's code first, when its origin serves any: the classes for
106
+ // the tab, handed to the harbor beside the built-in ones before any ward
107
+ // boots, since a being kept from the last visit is booted by class name.
108
+ // A world that serves none has no beings in the tab, and the page is the
109
+ // far being's alone.
110
+ const harbor = new BrowserHarbor('quo');
111
+ const classes = cfg.beings
112
+ ? await import(`${cfg.web}/beings.js`).then((mod: Record<string, unknown>) => {
113
+ const out: Record<string, BeingClass> = {};
114
+ for (const [name, v] of Object.entries(mod)) if (typeof v === 'function' && 'prototype' in v) out[name] = v as BeingClass;
115
+ return out;
116
+ })
117
+ : {};
118
+ Object.assign(harbor.classes, classes);
119
+ // A device is one harbor, and this browser may already be holding it in
120
+ // another tab. That tab is the device; this one is a page, and it says
121
+ // so here rather than sitting on the word `booting` with the refusal in a
122
+ // console nobody has open.
123
+ try {
124
+ await harbor.boot();
125
+ } catch (e) {
126
+ status.remove();
127
+ screen.innerHTML = held({ world: cfg.ward, host: new URL(cfg.web).host });
128
+ throw e; // the terrain still hears it: a page that says so is not a page that succeeded
129
+ }
130
+ harbor.dial(cfg.quo);
131
+
132
+ // The doorbell. A world with a bell key is a world that can wake this
133
+ // device when it is closed, and the ear is the service worker on this
134
+ // origin; a device already subscribed says so again silently, and one
135
+ // that has never been asked is offered a button rather than a prompt on
136
+ // arrival. The ring arrives at the worker: with this page open the worker
137
+ // hands it a port, and the page wakes its dialers, drains and answers
138
+ // what it found, since the harbor is held here and nowhere else.
139
+ const pks = Object.values(cfg.wards).map((w) => w.pk);
140
+ let bell = cfg.bell ? await subscribe(cfg.web, cfg.bell) : null;
141
+ globalThis.navigator?.serviceWorker?.addEventListener('message', (e: MessageEvent) => {
142
+ if ((e.data as { quo?: string } | null)?.quo !== 'wake') return;
143
+ const port = e.ports[0];
144
+ void (async () => {
145
+ harbor.wake();
146
+ port?.postMessage({ missed: await drained(harbor, pks) });
147
+ })();
148
+ });
149
+
150
+ // The world this page is about: the link's, since an invitation names
151
+ // its ward, or the page's own. A link for a world not on this harbor is
152
+ // a link to the wrong page, and says so.
153
+ const nameOf = (pk: string) => Object.keys(cfg.wards).find((n) => cfg.wards[n].pk === pk);
154
+ const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
155
+ const wardName = pk ? nameOf(pk) : undefined;
156
+ const pageOf = (name: string) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
157
+ // The same page, whole. What leaves this tab, a remembered world or a link
158
+ // for another device, carries the address and not the path.
159
+ const addressOf = (name: string) => location.origin + pageOf(name);
160
+
161
+ const switcher = () => {
162
+ nav.replaceChildren();
163
+ const known = worlds();
164
+ for (const [k, w] of Object.entries(known)) {
165
+ const a = el('a', w.name || k.slice(0, 8), { href: w.url, class: 'nv-chip' });
166
+ if (k === pk) a.setAttribute('aria-current', 'page');
167
+ nav.append(a);
168
+ }
169
+ // a world this device is not in yet is named by its own page, not by the strip
170
+ if (!nav.childElementCount) nav.remove();
171
+ else if (!nav.isConnected) root.insertBefore(nav, root.firstChild);
172
+ };
173
+ switcher();
174
+
175
+ if (!pk || !wardName) {
176
+ say(linked ? 'this link is for a world that does not live here' : 'choose a world');
177
+ return;
178
+ }
179
+ if (linked && location.pathname !== pageOf(wardName)) history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
180
+ const ward = await world(harbor, pk);
181
+ const at = { name: wardName, pk, public: cfg.wards[wardName].public };
182
+
183
+ // The relations in this world, and which one is on screen.
184
+ let side: { close(): Promise<void> } | null = null;
185
+ let current: Relation | null = null;
186
+ const names = () => kept<Record<string, string>>(NAMES(pk), {});
187
+ const people = () => {
188
+ who.replaceChildren();
189
+ const rels = relations(ward);
190
+ const called = names();
191
+ for (const r of rels) {
192
+ const b = el('button', called[r.key] ?? r.key, { type: 'button', class: 'nv-chip' });
193
+ if (r.key === current?.key) b.setAttribute('aria-current', 'true');
194
+ b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
195
+ who.append(b);
196
+ }
197
+ if (rels.length) {
198
+ const more = el('button', 'another way in', { type: 'button', class: 'another nv-chip' });
199
+ more.onclick = () => void atDoor();
200
+ who.append(more);
201
+ }
202
+ moving.hidden = rels.length === 0; // there is nothing to move out of a world this device is not in
203
+ };
204
+
205
+ // The world's beings in the tab, for one relation: one of each class,
206
+ // booted under her key the first time and found there after, since their
207
+ // cells are in the ward and come back with it. Each is a subject of her
208
+ // own with her own side in her own section, so a note she keeps and a
209
+ // form she shows are hers, painted by the same painter as the far page.
210
+ const locals: { close(): Promise<void> }[] = [];
211
+ const boot = async (rel: Relation) => {
212
+ for (const name of Object.keys(classes)) {
213
+ const key = `${rel.key}-${name}`;
214
+ // Booted the first time and found after. Boot refuses a key that has a
215
+ // row, so asking is how a caller learns she is already there, and a
216
+ // refusal is the same nothing-to-do as finding her.
217
+ if (!ward.being(key)) {
218
+ const out = (await ward.ask('boot', { key, class: name })) as { error?: string };
219
+ if (out.error) continue;
220
+ await ward.save();
221
+ }
222
+ if (!ward.being(key)) continue;
223
+ const section = el('section', '', { class: 'local', 'data-being': key });
224
+ screen.append(section);
225
+ // The ask pointer saves before it returns, so the side has nothing to do after one.
226
+ locals.push(await screenSide(local(ward, key), domSurface(section), { at: addressOf(at.name) }));
227
+ }
228
+ };
229
+
230
+ // In, as one relation: her page, then the world's beings for her. Every
231
+ // call rotates her keys and a same-ward ask never crosses the harbor, so
232
+ // the side saves after each.
233
+ const inside = async (rel: Relation, notice: string, called: string) => {
234
+ await side?.close();
235
+ for (const l of locals.splice(0)) await l.close();
236
+ status.remove();
237
+ root.querySelector('form.password')?.remove();
238
+ screen.replaceChildren();
239
+ current = rel;
240
+ keep(AT(pk), rel.key);
241
+ const mine = el('div', '', { class: 'far' });
242
+ screen.append(mine);
243
+ // the page's address: the world's page, or one being she carries as the whole page
244
+ const s = await screenSide(rel.avatar, domSurface(mine), {
245
+ after: () => ward.save(),
246
+ notice,
247
+ at: addressOf(at.name),
248
+ // A world that ended the relation is over, and the one act left is
249
+ // leaving. The tab says so and puts the way out where she is looking;
250
+ // it drops nothing, since one answer at one moment is never enough to
251
+ // delete a ward and the far world can never be asked to confirm it.
252
+ ended: async () => {
253
+ leave.textContent = 'leave this world';
254
+ leave.classList.add('only');
255
+ moving.scrollIntoView({ block: 'nearest' });
256
+ },
257
+ ...(cfg.being ? { focus: cfg.being } : {}),
258
+ });
259
+ side = s;
260
+ const bp = s.model.blueprint;
261
+ const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
262
+ remember(pk, addressOf(at.name), s.model.look.name || notesName || at.name);
263
+ if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
264
+ // How this world wakes this device, said again whenever a relation comes
265
+ // on screen: a push service may hand a browser a new endpoint at any
266
+ // time, and a stale one rings an empty house.
267
+ if (bell) await rel.avatar.call('bell', { token: bell }).then(() => ward.save());
268
+ // The device is back, so it drains: whatever was pushed while this tab
269
+ // was closed is on her under this relation, and it lands on the avatar
270
+ // where a push would have landed. A tab with nothing waiting spends one
271
+ // ask and shows the same page.
272
+ await drained(harbor, [pk]);
273
+ switcher();
274
+ people();
275
+ if (!cfg.being) await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
276
+ };
277
+
278
+ // The way in, from any of the three: a fresh avatar joins, the ward is
279
+ // saved, and she is the one on screen.
280
+ const admit = async (inv: Invitation, notice: string, called: string) => {
281
+ say('knocking');
282
+ const rel = await fresh(ward);
283
+ const got = await rel.avatar.join(inv);
284
+ await ward.save(); // the knock went through her own door, which the harbor never sees
285
+ if (!('asks' in got)) {
286
+ say(`not in: ${got.error}`);
287
+ return false;
288
+ }
289
+ await inside(rel, notice, called);
290
+ return true;
291
+ };
292
+
293
+ // At the door. With a public being there, her describe as a page, and a
294
+ // form whose answer is an invitation lets the guest in. With nobody
295
+ // there, the door page: a link is the only way in, and nothing to type.
296
+ const atDoor = async () => {
297
+ await side?.close();
298
+ for (const l of locals.splice(0)) await l.close();
299
+ side = null;
300
+ current = null;
301
+ screen.replaceChildren();
302
+ if (!status.isConnected) root.insertBefore(status, screen);
303
+ people();
304
+ if (!at.public) {
305
+ status.remove();
306
+ screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
307
+ return;
308
+ }
309
+ say(`at the door of ${at.name}`);
310
+ const rel = await fresh(ward);
311
+ const gate = guest(rel.avatar, pk);
312
+ const s = await screenSide(gate, domSurface(screen), {
313
+ after: () => ward.save(),
314
+ notice: `a guest at ${at.name}: what she shows strangers`,
315
+ at: addressOf(at.name),
316
+ admit: async (inv) => {
317
+ await s.close();
318
+ await admit(inv, `in, as a guest of ${at.name}`, 'guest');
319
+ },
320
+ });
321
+ side = s;
322
+ status.remove(); // her page is up and says where the human stands; the status was for the wait
323
+ };
324
+
325
+ // Moving this world to another device. What the tab holds of a world is
326
+ // one local ward, and every standing won here is in it, so the move is
327
+ // that ward and nothing else: the world is never asked and never told.
328
+ // The other device shows an invitation, the human carries it here, and
329
+ // this tab is out of the world the moment the other one has it.
330
+ // Leaving. What this tab holds of a world is one local ward, and every
331
+ // standing won here is in it, so leaving is that ward dropped whole, seed
332
+ // and all, and the world forgotten from the list. The far world is never
333
+ // asked and never told, as WORLDS has it of a lost phone: it sees one
334
+ // device gone and nothing else. Nothing here is recoverable afterwards, so
335
+ // it is one press and never a consequence of an answer: a world that says
336
+ // the relation ended offers this and does not take it.
337
+ const forget = async () => {
338
+ await side?.close();
339
+ for (const l of locals.splice(0)) await l.close();
340
+ side = null;
341
+ keep(
342
+ WORLDS,
343
+ Object.fromEntries(Object.entries(worlds()).filter(([k]) => k !== pk)),
344
+ );
345
+ localStorage.removeItem(AT(pk));
346
+ localStorage.removeItem(NAMES(pk));
347
+ };
348
+ const leave = el('button', 'leave this world', { type: 'button', class: 'leave nv-button', 'data-variant': 'danger' });
349
+ leave.onclick = () =>
350
+ void (async () => {
351
+ await forget();
352
+ await harbor.drop(localName(pk));
353
+ location.reload(); // at the door of a world this device is out of
354
+ })();
355
+
356
+ const moving = el('div', '', { class: 'move' });
357
+ const offer = el('p', '', { class: 'invitation' });
358
+ const said = el('p', '', { class: 'quiet' });
359
+ const give = el('button', 'move this world to another device', { type: 'button', class: 'nv-button', 'data-variant': 'quiet' });
360
+ // Being woken is asked for and never sprung: a page that asks a browser
361
+ // for notifications the moment it opens is a page every browser punishes,
362
+ // and a human who has not said yet is offered a button instead.
363
+ const doorbell = el('button', 'let this world wake me', { type: 'button', class: 'doorbell nv-button', 'data-variant': 'quiet' });
364
+ doorbell.hidden = !cfg.bell || bell !== null;
365
+ doorbell.onclick = () =>
366
+ void (async () => {
367
+ bell = cfg.bell ? await subscribe(cfg.web, cfg.bell, true) : null;
368
+ doorbell.hidden = bell !== null;
369
+ if (bell && current) await current.avatar.call('bell', { token: bell }).then(() => ward.save());
370
+ })();
371
+ moving.append(give, leave, offer, said, doorbell);
372
+ let giving: Giving | null = null;
373
+ const stopGiving = async () => {
374
+ const g = giving;
375
+ giving = null;
376
+ offer.textContent = '';
377
+ give.textContent = 'move this world to another device';
378
+ await g?.close();
379
+ };
380
+ give.onclick = () =>
381
+ void (async () => {
382
+ if (giving) return stopGiving();
383
+ said.textContent = '';
384
+ giving = await giveWorld(harbor, { pk, at: addressOf(at.name), name: at.name }, cfg.quo);
385
+ offer.textContent = giving.offer;
386
+ give.textContent = 'stop offering it';
387
+ void giving.moved.then(async () => {
388
+ // it is on the other device now, and this one is out of the world:
389
+ // the standings went with the ward and nothing here is in it
390
+ await forget(); // the ward went with the move, so nothing is dropped here
391
+ location.reload(); // the page reloads at the door of a world this device is out of
392
+ });
393
+ })().catch((e: unknown) => (said.textContent = String(e)));
394
+ root.append(moving);
395
+
396
+ // A proof: the world's own way in, spent at her door. A proof names no
397
+ // ward, so it is for the world this page is, and what she trades it for
398
+ // is an invitation, joined like any other.
399
+ //
400
+ // `device` is the ask, and that name is a convention of the dock's rather
401
+ // than the desk's alone: a proof arrives in a fragment and not as a form,
402
+ // so there is nothing to draw and a name is the only way to hand it over.
403
+ // A public being of any class takes proofs by writing that ask; one that
404
+ // writes none answers this as an unknown ask, which is a world with no
405
+ // proof way in and is refused like any other refusal. What a proof is worth is the
406
+ // way in's word and never the desk's: the desk routes it by kind, and the
407
+ // being who verifies it says who it is for, how far she may go and
408
+ // whether it may be spent again. A world with nobody at her door trades
409
+ // nothing, and a refusal of any of those kinds leaves the human at the
410
+ // door with nothing taken and one line saying so.
411
+ if (proved && at.public) {
412
+ say('at the door');
413
+ const rel = await fresh(ward);
414
+ const got = await rel.avatar.knock({ ward: pk }, 'device', { proof: { kind: proved.kind, token: proved.token } });
415
+ await ward.save();
416
+ if (isInvitation(got) && (await admit(got, 'in, by the link', 'link'))) return;
417
+ // What came back and not a guess at it. A world refuses a way in for
418
+ // several different reasons and only one of them is that it was spent;
419
+ // `id taken` in particular is a device of that name already in this
420
+ // world, which is what a human meets when she has lost the device that
421
+ // held it, and telling her it was spent sends her looking for the wrong
422
+ // thing.
423
+ say(refused(got));
424
+ }
425
+
426
+ // A link: one more relation, whoever else is in. Then whoever was on
427
+ // screen last, or the door.
428
+ if (linked && (await admit(linked, 'in, by the link', 'link'))) return;
429
+ const rels = relations(ward);
430
+ const last = kept<string | null>(AT(pk), null);
431
+ const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
432
+ if (back) {
433
+ const had = await back.avatar.tools();
434
+ await ward.save(); // her keys rotated on that ask, whatever it answered
435
+ if ('asks' in had) return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
436
+ say(`not in (${had.error})`);
437
+ }
438
+ await atDoor();
439
+ }
440
+
441
+ // The page hands the config in a JSON script, which a content security
442
+ // policy allows where an inline script is not.
443
+ const config = document.getElementById('quo');
444
+ if (config?.textContent) void start(JSON.parse(config.textContent) as Config);
445
+
446
+ export { USER };
package/human/tree.ts ADDED
@@ -0,0 +1,217 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The page grammar: a page as a tree of values, and its painter. One
3
+ // grammar, two sources: a far being answers `page` with a tree, values
4
+ // only, the way she answers `look`; a being running in the tab computes one
5
+ // from her cells. The screen paints either with the painter below, and a
6
+ // being that answers nothing gets the forms `html.ts` paints on its own.
7
+ //
8
+ // The vocabulary is closed, and a node that fails its shape is dropped, not
9
+ // fixed: nothing here can carry a selector, a stylesheet or a script, and an
10
+ // image is a data URI, so a far being can design her page and can never
11
+ // reach out of it. What a node names, an ask or a standing, must exist in
12
+ // her describe for this asker, or it paints as nothing: a page adds no
13
+ // right.
14
+ //
15
+ // One node names a place, and it is the one the side holds rather than the
16
+ // grammar. A `link` carries an address, because a human who must buy
17
+ // something, sign something or come back from a vendor has to be able to go
18
+ // there, and a being who could not say so would be a being every world
19
+ // works around. So the shape here admits only an absolute `http` or `https`
20
+ // address, and where it may point is not decided here: the painter is the
21
+ // side's, and the side is what knows which places this human already
22
+ // reaches. A painter that will not vouch for an address paints the address
23
+ // as words, so the human sees where she was being sent and goes there by
24
+ // her own hand or not at all.
25
+ //
26
+ // stack children one under another { kind: 'stack', of: [...], as?: list | grid | hero | feed }
27
+ // row children side by side, wrapping { kind: 'row', of: [...], as?: person }
28
+ // text words with a role { kind: 'text', text, role?: title | lead | body | quiet | label }
29
+ // image a picture as a data URI, never a URL { kind: 'image', src, alt? }
30
+ // link somewhere to go, held by the side { kind: 'link', href, text? }
31
+ // form one of her asks as a form { kind: 'form', ask }
32
+ // answer one of her asks' last answer, shown { kind: 'answer', ask, as?: auto | text | table | cards | list | people | feed }
33
+ // standing one standing she carries, a section { kind: 'standing', id, as?: door }
34
+ // standings every standing she carries { kind: 'standings' }
35
+ //
36
+ // A container may say what kind of thing it holds, and never how it looks:
37
+ // `list` is several of one thing as rows on one surface, `grid` is cards,
38
+ // `hero` is one thing on a lot of air, `feed` is what happened in order,
39
+ // and a row that is a `person` is a face and the words beside it. The
40
+ // painter holds every opinion about what each kind is on a screen, so
41
+ // every world's people, requests and contacts are one shape everywhere, and
42
+ // a being never carries a layout. An answer says its kind the same way:
43
+ // `people` are rows of { name, line?, picture? }, and `feed` is rows of
44
+ // { at, text }; a value that is not that shape is shown by its own shape.
45
+ import type { Json } from '@nervur-org/nervur';
46
+ import { escape } from './html.ts';
47
+
48
+ export type Role = 'title' | 'lead' | 'body' | 'quiet' | 'label';
49
+ export type As = 'auto' | 'text' | 'table' | 'cards' | 'list' | 'people' | 'feed';
50
+ export type Layout = 'list' | 'grid' | 'hero' | 'feed';
51
+ export type Node =
52
+ | { kind: 'stack'; of: Node[]; as?: Layout }
53
+ | { kind: 'row'; of: Node[]; as?: 'person' }
54
+ | { kind: 'text'; text: string; role?: Role }
55
+ | { kind: 'image'; src: string; alt?: string }
56
+ | { kind: 'link'; href: string; text?: string }
57
+ | { kind: 'form'; ask: string }
58
+ | { kind: 'answer'; ask: string; as?: As }
59
+ | { kind: 'standing'; id: string; as?: 'door' }
60
+ | { kind: 'standings' };
61
+
62
+ const ROLES = new Set<Role>(['title', 'lead', 'body', 'quiet', 'label']);
63
+ const AS = new Set<As>(['auto', 'text', 'table', 'cards', 'list', 'people', 'feed']);
64
+ const LAYOUTS = new Set<Layout>(['list', 'grid', 'hero', 'feed']);
65
+ const TEXT = 4000; // characters a text node may carry
66
+ const DEPTH = 12; // how deep a tree may nest
67
+ const WIDE = 200; // children a container may have
68
+ // A name in the grammar is a name in her describe, spelled as she spells
69
+ // it: an ask of her own, a standing's id, or a carried ask, `<id>-<name>`.
70
+ // A standing's id is the dock's to choose and carries a colon where the
71
+ // dock's own convention puts one, a way back to a device being `to:<id>`,
72
+ // so a shape that refused a colon would drop the section a device's own
73
+ // faculties are painted in. It stays a name and never a path: no slash, no
74
+ // space, and it is looked up in her describe before anything is painted.
75
+ const NAME = /^[\w.:-]{1,80}$/;
76
+ const HREF = 2000; // characters an address may be
77
+
78
+ // A value held to the grammar, or null. Children that fail are dropped and
79
+ // the container kept; a container with nothing left is kept empty, since an
80
+ // empty region is a design and not a fault.
81
+ export function sanitiseTree(v: Json, depth = 0): Node | null {
82
+ if (depth > DEPTH || v === null || typeof v !== 'object' || Array.isArray(v)) return null;
83
+ const o = v as Record<string, Json>;
84
+ switch (o.kind) {
85
+ case 'stack':
86
+ case 'row': {
87
+ const of = Array.isArray(o.of) ? o.of.slice(0, WIDE).map((c) => sanitiseTree(c, depth + 1)).filter((c): c is Node => c !== null) : [];
88
+ if (o.kind === 'row') return typeof o.as === 'string' && o.as === 'person' ? { kind: 'row', of, as: 'person' } : { kind: 'row', of };
89
+ return typeof o.as === 'string' && LAYOUTS.has(o.as as Layout) ? { kind: 'stack', of, as: o.as as Layout } : { kind: 'stack', of };
90
+ }
91
+ case 'text': {
92
+ if (typeof o.text !== 'string') return null;
93
+ const n: Node = { kind: 'text', text: o.text.slice(0, TEXT) };
94
+ if (typeof o.role === 'string' && ROLES.has(o.role as Role)) n.role = o.role as Role;
95
+ return n;
96
+ }
97
+ case 'image': {
98
+ if (typeof o.src !== 'string' || !/^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i.test(o.src)) return null;
99
+ const n: Node = { kind: 'image', src: o.src };
100
+ if (typeof o.alt === 'string') n.alt = o.alt.slice(0, 200);
101
+ return n;
102
+ }
103
+ case 'link': {
104
+ // The shape only: an absolute address on the web, parsed rather than
105
+ // matched, so nothing that merely looks like one gets through. Where
106
+ // it may point is the painter's, not this file's.
107
+ if (typeof o.href !== 'string' || o.href.length > HREF) return null;
108
+ let url: URL;
109
+ try {
110
+ url = new URL(o.href);
111
+ } catch {
112
+ return null;
113
+ }
114
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
115
+ const n: Node = { kind: 'link', href: url.href };
116
+ if (typeof o.text === 'string' && o.text.trim()) n.text = o.text.slice(0, 200);
117
+ return n;
118
+ }
119
+ case 'form':
120
+ return typeof o.ask === 'string' && NAME.test(o.ask) ? { kind: 'form', ask: o.ask } : null;
121
+ case 'answer': {
122
+ if (typeof o.ask !== 'string' || !NAME.test(o.ask)) return null;
123
+ const n: Node = { kind: 'answer', ask: o.ask };
124
+ if (typeof o.as === 'string' && AS.has(o.as as As)) n.as = o.as as As;
125
+ return n;
126
+ }
127
+ case 'standing':
128
+ if (typeof o.id !== 'string' || !NAME.test(o.id)) return null;
129
+ return o.as === 'door' ? { kind: 'standing', id: o.id, as: 'door' } : { kind: 'standing', id: o.id };
130
+ case 'standings':
131
+ return { kind: 'standings' };
132
+ default:
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // The names a tree asks the answers of: what a side runs on her behalf so
138
+ // the page opens with what it shows, when the ask needs nothing typed.
139
+ export function answersIn(n: Node, out: string[] = []): string[] {
140
+ if (n.kind === 'answer') out.push(n.ask);
141
+ if (n.kind === 'stack' || n.kind === 'row') for (const c of n.of) answersIn(c, out);
142
+ return out;
143
+ }
144
+
145
+ // What the painter needs from whoever holds the describe and the answers:
146
+ // each returns HTML for a name it knows, and the empty string for one it
147
+ // does not, so a page can never show what the gate did not.
148
+ export type Painter = {
149
+ // An address the being named, and the words she wants on it. The painter
150
+ // vouches or it does not: an anchor where this human already reaches, and
151
+ // the address as words where she does not, so nothing is hidden either
152
+ // way.
153
+ link(href: string, text: string): string;
154
+ form(ask: string): string;
155
+ answer(ask: string, as: As): string;
156
+ // A standing as her section, or as a door: named, with the way to her
157
+ // own page, and nothing of hers on this one.
158
+ standing(id: string, as?: 'door'): string;
159
+ standings(): string;
160
+ };
161
+
162
+ const ROLE_TAG: Record<Role, string> = { title: 'h1', lead: 'p', body: 'p', quiet: 'p', label: 'p' };
163
+
164
+ // Whether a tree opens with a title of its own, at its top or one level
165
+ // in: then the page's bar says no name, since the page says it once.
166
+ export function titled(n: Node | null, depth = 0): boolean {
167
+ if (!n || depth > 2) return false;
168
+ if (n.kind === 'text') return n.role === 'title';
169
+ if (n.kind === 'stack' || n.kind === 'row') return n.of.some((c) => titled(c, depth + 1));
170
+ return false;
171
+ }
172
+
173
+ // Each kind on the kit's pieces, and the painter holds the opinion: a list
174
+ // wraps each child as a row, a grid as a card, a person puts her picture in
175
+ // the round place a face has.
176
+ export function paint(n: Node, p: Painter): string {
177
+ switch (n.kind) {
178
+ case 'stack': {
179
+ const inner = (wrap: string) => n.of.map((c) => `<div class="${wrap}">${paint(c, p)}</div>`).join('');
180
+ if (n.as === 'list') return `<div class="nv-list">${inner('nv-item')}</div>`;
181
+ if (n.as === 'grid') return `<div class="nv-cards">${inner('nv-card')}</div>`;
182
+ if (n.as === 'hero') return `<div class="nv-hero">${n.of.map((c) => paint(c, p)).join('')}</div>`;
183
+ if (n.as === 'feed') return `<div class="nv-feed">${n.of.map((c) => paint(c, p)).join('')}</div>`;
184
+ return `<div class="nv-stack">${n.of.map((c) => paint(c, p)).join('')}</div>`;
185
+ }
186
+ case 'row': {
187
+ if (n.as === 'person') {
188
+ const face = n.of.find((c) => c.kind === 'image');
189
+ const rest = n.of.filter((c) => c !== face).map((c) => paint(c, p)).join('');
190
+ const words = n.of.find((c) => c.kind === 'text');
191
+ const initial = words && words.kind === 'text' ? escape(words.text.trim().charAt(0).toUpperCase()) : '';
192
+ return `<div class="nv-person"><span class="nv-avatar">${face ? paint(face, p) : initial}</span><div class="nv-stack">${rest}</div></div>`;
193
+ }
194
+ return `<div class="nv-row" data-align="center">${n.of.map((c) => paint(c, p)).join('')}</div>`;
195
+ }
196
+ case 'text': {
197
+ const role = n.role ?? 'body';
198
+ return `<${ROLE_TAG[role]} class="nv-t-${role}">${escape(n.text)}</${ROLE_TAG[role]}>`;
199
+ }
200
+ case 'image':
201
+ return `<img class="nv-picture" alt="${escape(n.alt ?? '')}" src="${n.src}">`;
202
+ case 'link':
203
+ return p.link(n.href, n.text ?? n.href);
204
+ case 'form':
205
+ return p.form(n.ask);
206
+ case 'answer':
207
+ return p.answer(n.ask, n.as ?? 'auto');
208
+ case 'standing':
209
+ return p.standing(n.id, n.as);
210
+ case 'standings':
211
+ return p.standings();
212
+ }
213
+ }
214
+
215
+ // A painter with nothing behind it: for a page that names no ask, the door
216
+ // page and any page painted before anyone is in.
217
+ export const nobody: Painter = { link: () => '', form: () => '', answer: () => '', standing: () => '', standings: () => '' };