@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/door.ts ADDED
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The door page: what a stranger sees where nothing lets them in. No
3
+ // invitation in the fragment, no relation in this tab's harbor, and nobody
4
+ // at the ward's door; or a harbor's root with no world to list. It is
5
+ // Quo's page and not the estate's: it says what this is and that a world
6
+ // here is entered by a link someone sends you, and it asks for nothing,
7
+ // since there is nothing a stranger could type that would let them in. A
8
+ // world that wants a face of its own has a public being with a page and a
9
+ // look, or its own page on its own origin; this is the default and nothing
10
+ // more. It is the first page written in the grammar, `tree.ts`, painted
11
+ // with nobody behind it: no ask, no answer, no standing.
12
+ import { paint, nobody, type Node, type Painter } from './tree.ts';
13
+ import { escape } from './html.ts';
14
+ import { MARK } from './mark.ts';
15
+
16
+ export type Door = { world?: string; host: string };
17
+
18
+ export function doorTree(at: Door): Node {
19
+ return {
20
+ kind: 'stack',
21
+ as: 'hero',
22
+ of: [
23
+ { kind: 'image', src: MARK, alt: '' },
24
+ { kind: 'text', text: 'quo', role: 'title' },
25
+ { kind: 'text', text: at.world ? `${at.world} at ${at.host}` : at.host, role: 'label' },
26
+ { kind: 'text', text: at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.', role: 'lead' },
27
+ { kind: 'text', text: 'An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.' },
28
+ { kind: 'text', text: 'Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.', role: 'quiet' },
29
+ ],
30
+ };
31
+ }
32
+
33
+ export function door(at: Door): string {
34
+ return `<main data-door>${paint(doorTree(at), nobody)}</main>`;
35
+ }
36
+
37
+ // The other page with nobody behind it: what a human sees where this
38
+ // browser already holds the harbor in another tab. A device is one harbor
39
+ // and the lock is what says so, since two harbors over one seed would be
40
+ // two devices claiming to be the same one, each writing the other's wards.
41
+ // So the second tab is a page and not a device, and the page says which of
42
+ // the two it is rather than saying `booting` until she gives up. Nothing
43
+ // here offers to take the lock: the tab that holds it may be the one she
44
+ // is working in.
45
+ export function heldTree(at: Door): Node {
46
+ return {
47
+ kind: 'stack',
48
+ as: 'hero',
49
+ of: [
50
+ { kind: 'image', src: MARK, alt: '' },
51
+ { kind: 'text', text: 'Already open', role: 'title' },
52
+ { kind: 'text', text: at.world ? `${at.world} at ${at.host}` : at.host, role: 'label' },
53
+ { kind: 'text', text: 'This world is open in another tab of this browser, and one browser is one device here. That tab holds the device; this one is only a page.', role: 'lead' },
54
+ { kind: 'text', text: 'Go back to the tab that has it, or close that tab and reload this one. Nothing is lost either way: what this device is in every world is kept in the browser, not in a tab.' },
55
+ ],
56
+ };
57
+ }
58
+
59
+ export function held(at: Door): string {
60
+ return `<main data-door>${paint(heldTree(at), nobody)}</main>`;
61
+ }
62
+
63
+ // The policy every page Quo writes itself is served under, wherever it is
64
+ // served from. These pages run no script and want none, so nothing but
65
+ // their own form is allowed anywhere, an image is the mark and therefore
66
+ // data, and no referer carries what brought a human here off the page.
67
+ export const POLICY = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
68
+
69
+ // A painter for Quo's own pages. It is `nobody` with links painted, and
70
+ // that is sound where the other painter's caution is not: the vouching rule
71
+ // exists because a far being names a place and the side cannot know where
72
+ // she is sending a human. On these pages the dock wrote the address itself,
73
+ // out of what the daemon was configured with, so there is nobody to vouch
74
+ // for. No ask, no answer and no standing, as before.
75
+ export const ours: Painter = {
76
+ ...nobody,
77
+ link: (href, text) => `<a class="link" href="${escape(href)}">${escape(text)}</a>`,
78
+ };
79
+
80
+ // Where a redirect lands. A vendor a world sent a human to, a checkout or a
81
+ // consent screen, sends her back by opening a URL in her browser, and that
82
+ // URL is the envoy's own door: the same ask in the envoy always had, with an
83
+ // empty body and the code in the query. So a human ends a payment looking at
84
+ // whatever that ask answered, and what it answers is JSON.
85
+ //
86
+ // This is the page she gets instead. It says which of the two happened,
87
+ // because a human who has just paid needs to know, and it offers the world's
88
+ // own page, because a browser tab opened by a vendor has no history to go
89
+ // back through. What the envoy answered is never shown: she said `{ ok }` or
90
+ // an error object to a machine, and neither is a sentence.
91
+ export type Landed = { world?: string; host: string; at?: string; ok: boolean };
92
+
93
+ export function landedTree(l: Landed): Node {
94
+ const of: Node[] = [
95
+ { kind: 'image', src: MARK, alt: '' },
96
+ { kind: 'text', text: l.ok ? 'That is done' : 'That did not finish', role: 'title' },
97
+ { kind: 'text', text: l.world ? `${l.world} at ${l.host}` : l.host, role: 'label' },
98
+ {
99
+ kind: 'text',
100
+ text: l.ok
101
+ ? 'You came back from somewhere this world sent you, and it heard you. Nothing else is needed here.'
102
+ : 'You came back from somewhere this world sent you, and it could not finish. Nothing was taken from you here, and you can try again from the world itself.',
103
+ role: 'lead',
104
+ },
105
+ ];
106
+ if (l.at) of.push({ kind: 'link', href: l.at, text: l.world ? `back to ${l.world}` : 'back to this world' });
107
+ else of.push({ kind: 'text', text: 'You can close this page.', role: 'quiet' });
108
+ return { kind: 'stack', as: 'hero', of };
109
+ }
110
+
111
+ export function landed(l: Landed): string {
112
+ return `<main data-door>${paint(landedTree(l), ours)}</main>`;
113
+ }
package/human/guest.ts ADDED
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A guest at a world's door: the world's public being, whatever class she
3
+ // is, as a subject the screen can render. Her describe for a stranger is the
4
+ // guest page; a form on it is a knock on the public invitation; and an
5
+ // answer that is an invitation is the way in, which the tab takes by
6
+ // joining. Nothing here knows the desk, and nothing needs to: the forms are
7
+ // drawn from whatever asks she has, and what makes one of them the way in
8
+ // is that its answer is an invitation. She may call it anything.
9
+ //
10
+ // The proof way in is the one place a name is needed, since a proof arrives
11
+ // in a link's fragment rather than typed, so there is no form to answer and
12
+ // the shell must knock a named ask. That name is `device`, and it is a
13
+ // convention of the dock's like `hello`, `look` and `page`: a public being
14
+ // of any class takes proofs by writing an ask under that name, and one that
15
+ // writes none has no proof way in and loses nothing else.
16
+ import { isSilence, isWord, wordOf } from '@nervur-org/nervur';
17
+ import type { Answer, Blueprint, Invitation, JsonObject, Wanted } from '@nervur-org/nervur';
18
+ import type { Avatar } from '../beings/avatar.ts';
19
+ import type { Subject } from '../beings/side.ts';
20
+
21
+ export function guest(avatar: Avatar, ward: string): Subject {
22
+ const at: Invitation = { ward };
23
+ return {
24
+ tools: async () => {
25
+ const bp = await avatar.knock(at);
26
+ if (isSilence(bp)) return { error: 'silence' };
27
+ if (isWord(bp)) return { error: wordOf(bp) };
28
+ if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray((bp as Blueprint).asks)) return { error: 'nobody is home' };
29
+ return bp as Blueprint;
30
+ },
31
+ call: (name: string, args: JsonObject = {}, wanted?: Wanted): Promise<Answer> => avatar.knock(at, name, args, wanted),
32
+ ears: new Set(),
33
+ };
34
+ }
package/human/html.ts ADDED
@@ -0,0 +1,490 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A blueprint spoken as HTML, and a form heard back as arguments. Pure:
3
+ // strings in, strings out, no document, no element, no event. This is the
4
+ // whole vocabulary of the screen, so that a page can be proven with no
5
+ // browser at all. Nothing here knows an avatar; the side hands in what she
6
+ // answered and reads out what the human submitted.
7
+ //
8
+ // form(ask) the ask's input schema as fields, one per property
9
+ // values(ask, raw) the submitted strings back as arguments, typed by the schema
10
+ // view(value, schema?) an answer as a view, ordered by the output schema
11
+ // page(model) the whole page: title, notice, forms, views, pushes
12
+ import type { Answer, Ask, Blueprint, Json, JsonObject } from '@nervur-org/nervur';
13
+ import { SILENCE_TEXT, UNREACHED_TEXT, word, wordText, type Word } from '../beings/side.ts';
14
+ import { hint, hintFor, sanitise, groups as grouped, presentation, type Look, type Hint } from '../beings/look.ts';
15
+ import { paint, titled, type As, type Node, type Painter } from './tree.ts';
16
+ import { isInvitation } from '@nervur-org/nervur';
17
+ import { link } from '../beings/link.ts';
18
+
19
+ // One property of an input schema, as the form needs it.
20
+ export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
21
+ export type Schema = { properties?: Record<string, Property>; required?: string[] };
22
+
23
+ export const escape = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
24
+
25
+ const props = (schema: JsonObject | undefined): [string, Property][] => Object.entries(((schema as Schema | undefined)?.properties ?? {}));
26
+ const required = (schema: JsonObject | undefined): Set<string> => new Set(((schema as Schema | undefined)?.required ?? []));
27
+
28
+ // ---- what a side fills for itself
29
+ //
30
+ // An ask says what its arguments are, and one of them a side can answer
31
+ // better than the human can: the moment. A being may not read a clock, since
32
+ // a being who could would be a being who could not be moved, so she takes the
33
+ // moment as an argument and every side hands her its own. A screen is not a
34
+ // being; it runs on a device, and a device has a clock.
35
+ //
36
+ // So a property whose schema says `format: "moment"` is never drawn as a
37
+ // field and never typed by anybody. The screen fills it, and a read-only ask
38
+ // with nothing left to ask a human then runs on its own and shows a table
39
+ // where a form would have been. Nothing about this is presentation, which is
40
+ // why it is read off the input schema and not off a look: the ask says what
41
+ // the argument is, and each side decides for itself what to do with that.
42
+ export const MOMENT = 'moment';
43
+ export const mine = (ask: Ask, at: number = Date.now()): JsonObject => Object.fromEntries(props(ask.input).filter(([, p]) => p.format === MOMENT).map(([name]) => [name, at]));
44
+ // What is left for a human once the side has filled its own.
45
+ export const asks = (ask: Ask): string[] => [...required(ask.input)].filter((name) => !(name in mine(ask)));
46
+
47
+ // ---- a schema as a form
48
+
49
+ // A property is one field by its type. A string is a text input, and a
50
+ // format the browser knows, `password`, `date`, `email`, is the input's
51
+ // type. A number is a number input. A boolean is a checkbox. An enum is a
52
+ // select. An object or an array is a box for JSON, because a form has no
53
+ // better face for a value the schema left open. A description is the label.
54
+ export function field(name: string, p: Property, must: boolean): string {
55
+ const id = escape(name);
56
+ const label = `<label for="${id}">${id}${must ? ' <b>*</b>' : ''}</label>`;
57
+ const help = p.description ? `<p class="nv-hint">${escape(p.description)}</p>` : '';
58
+ const one = (control: string) => `<div class="nv-field">${label}${control}${help}</div>`;
59
+ const req = must ? ' required' : '';
60
+ if (Array.isArray(p.enum)) {
61
+ // An enum value is a scalar in every schema a being writes, but the
62
+ // schema comes from her and not from here: an object among the options
63
+ // is shown as what it is rather than as `[object Object]`.
64
+ const text = (v: Json) => (v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v));
65
+ const options = p.enum.map((v) => `<option value="${escape(text(v))}">${escape(text(v))}</option>`).join('');
66
+ return one(`<select id="${id}" name="${id}"${req}>${must ? '' : '<option value=""></option>'}${options}</select>`);
67
+ }
68
+ if (p.type === 'boolean') return `<div class="nv-field" data-inline><input type="checkbox" id="${id}" name="${id}" value="true">${label}${help}</div>`;
69
+ if (p.type === 'number' || p.type === 'integer') return one(`<input type="number" id="${id}" name="${id}"${p.type === 'integer' ? ' step="1"' : ' step="any"'}${req}>`);
70
+ if (p.type === 'string' || p.type === undefined) {
71
+ const kind = p.format === 'password' || p.format === 'date' || p.format === 'email' || p.format === 'time' ? p.format : 'text';
72
+ return one(`<input type="${kind}" id="${id}" name="${id}"${req}>`);
73
+ }
74
+ return one(`<textarea id="${id}" name="${id}" placeholder="JSON"${req}></textarea>`);
75
+ }
76
+
77
+ // The ask as a form: its name is the button, its description the legend's
78
+ // small print, and `data-ask` is how the surface says which ask was sent.
79
+ export function form(ask: Ask, h: Hint = {}): string {
80
+ const must = required(ask.input);
81
+ const own = mine(ask);
82
+ const fields = props(ask.input)
83
+ .filter(([name]) => !(name in own)) // the side fills it; nobody types a moment
84
+ .map(([name, p]) => field(name, p, must.has(name)))
85
+ .join('');
86
+ const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
87
+ const care = h.destructive ? ' data-confirm="true"' : '';
88
+ const said = ask.description ? `<p class="nv-hint">${escape(ask.description)}</p>` : '';
89
+ return `<form class="nv-stack" data-ask="${escape(ask.name)}"${care}><fieldset><legend>${label}</legend>${said}${fields}<div class="nv-row" data-align="center"><button class="nv-button"${h.destructive ? ' data-variant="danger"' : ''}>${label}</button></div></fieldset></form>`;
90
+ }
91
+
92
+ // ---- the form heard back
93
+
94
+ // What the surface hands back: every field by name, as the string the
95
+ // browser sends. A checkbox not ticked sends nothing.
96
+ export type Raw = Record<string, string>;
97
+
98
+ // The strings typed by the schema. An empty field that is not required is
99
+ // left out, so the being sees what the human said and nothing else; an
100
+ // empty required field goes as the empty string, and the being answers.
101
+ // A JSON box that does not parse is an error the side shows before asking.
102
+ export function values(ask: Ask, raw: Raw): { args: JsonObject } | { error: string } {
103
+ // The side's own first, so a form the human submits carries the moment it
104
+ // was submitted at rather than the one the page was drawn at.
105
+ const args: JsonObject = mine(ask);
106
+ const must = required(ask.input);
107
+ for (const [name, p] of props(ask.input)) {
108
+ if (name in args) continue;
109
+ const s = raw[name];
110
+ if (p.type === 'boolean') {
111
+ if (s === 'true') args[name] = true;
112
+ else if (must.has(name)) args[name] = false;
113
+ continue;
114
+ }
115
+ if (s === undefined || (s === '' && !must.has(name))) continue;
116
+ if (p.type === 'number' || p.type === 'integer') {
117
+ const n = Number(s);
118
+ if (s === '' || Number.isNaN(n)) return { error: `${name} is a number` };
119
+ args[name] = n;
120
+ } else if (p.type === 'string' || p.type === undefined || Array.isArray(p.enum)) {
121
+ args[name] = s;
122
+ } else {
123
+ try {
124
+ args[name] = JSON.parse(s) as Json;
125
+ } catch {
126
+ return { error: `${name} is not JSON` };
127
+ }
128
+ }
129
+ }
130
+ return { args };
131
+ }
132
+
133
+ // ---- an answer as a view
134
+
135
+ // A value has one face by its shape. A primitive is text. An object is a
136
+ // table of its keys, in the output schema's order when she declared one and
137
+ // her own order otherwise, each value viewed in turn. A list of objects is
138
+ // one table with a column per key seen; any other list is a list. Null is
139
+ // the word nothing, so that an answer of null is never a blank.
140
+ export function view(value: Json, schema?: JsonObject): string {
141
+ if (value === null) return '<i>nothing</i>';
142
+ if (typeof value !== 'object') return escape(String(value));
143
+ if (Array.isArray(value)) {
144
+ if (value.length && value.every((v) => v !== null && typeof v === 'object' && !Array.isArray(v))) {
145
+ const rows = value as JsonObject[];
146
+ const keys = [...new Set(rows.flatMap((r) => Object.keys(r)))];
147
+ const head = keys.map((k) => `<th>${escape(k)}</th>`).join('');
148
+ const body = rows.map((r) => `<tr>${keys.map((k) => `<td>${k in r ? view(r[k]) : ''}</td>`).join('')}</tr>`).join('');
149
+ return `<div class="nv-table-scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
150
+ }
151
+ return value.length ? `<ul class="nv-list" role="list">${value.map((v) => `<li class="nv-item">${view(v)}</li>`).join('')}</ul>` : '<p class="nv-empty">none</p>';
152
+ }
153
+ const declared = props(schema).map(([k]) => k);
154
+ const keys = [...declared.filter((k) => k in value), ...Object.keys(value).filter((k) => !declared.includes(k))];
155
+ if (!keys.length) return '<i>empty</i>';
156
+ return `<div class="nv-table-scroll"><table>${keys.map((k) => `<tr><th>${escape(k)}</th><td>${view(value[k], (props(schema).find(([n]) => n === k)?.[1] as JsonObject | undefined))}</td></tr>`).join('')}</table></div>`;
157
+ }
158
+
159
+ // The three words for "no object", and the object, each with a face. An
160
+ // error object is the answer she gave, marked; silence and unreached are
161
+ // what the side says about them, since they carry nothing themselves. The
162
+ // word is on the answer for a design and a test to read, and the tone is
163
+ // what a reader sees: hers in red, the wire's in amber.
164
+ export function face(w: Word, schema?: JsonObject): string {
165
+ if (w.word === 'object') return `<div class="nv-answer" data-word="object">${view(w.value, schema)}</div>`;
166
+ if (w.word === 'error') return `<div class="nv-answer" data-word="error" data-tone="danger">${view(w.value)}</div>`;
167
+ if (w.word === 'silence') return `<div class="nv-answer" data-word="silence" data-tone="warning">${escape(SILENCE_TEXT)}</div>`;
168
+ if (w.word === 'quo') return `<div class="nv-answer" data-word="quo" data-tone="warning">${escape(wordText(w.name))}</div>`;
169
+ return `<div class="nv-answer" data-word="unreached" data-tone="warning">${escape(UNREACHED_TEXT)}</div>`;
170
+ }
171
+
172
+ // ---- an answer of a named kind
173
+
174
+ // Rows of people: a name, a line under it, and a face when she has one, on
175
+ // one surface. A row that is not a person is shown by its own shape, so a
176
+ // page that said `people` of a list of strings still shows the strings.
177
+ const person = (v: Json): string => {
178
+ if (v === null || typeof v !== 'object' || Array.isArray(v) || typeof v.name !== 'string') return view(v);
179
+ const name = v.name;
180
+ const line = typeof v.line === 'string' ? `<p class="nv-t-quiet">${escape(v.line)}</p>` : '';
181
+ const pic = typeof v.picture === 'string' && /^data:image\//.test(v.picture) ? `<img alt="" src="${v.picture}">` : escape(name.trim().charAt(0).toUpperCase());
182
+ return `<div class="nv-person"><span class="nv-avatar">${pic}</span><div class="nv-stack"><p class="nv-t-body">${escape(name)}</p>${line}</div></div>`;
183
+ };
184
+ export function people(value: Json): string {
185
+ if (!Array.isArray(value)) return view(value);
186
+ if (!value.length) return '<p class="nv-empty">nobody yet</p>';
187
+ return `<div class="nv-list">${value.map((v) => `<div class="nv-item">${person(v)}</div>`).join('')}</div>`;
188
+ }
189
+
190
+ // What happened, in order: a moment and the words, down a line.
191
+ const happened = (v: Json): string => {
192
+ if (v === null || typeof v !== 'object' || Array.isArray(v) || typeof v.text !== 'string') return view(v);
193
+ const at = typeof v.at === 'string' || typeof v.at === 'number' ? `<p class="nv-t-label">${escape(String(v.at))}</p>` : '';
194
+ return `<div class="nv-stack">${at}<p class="nv-t-body">${escape(v.text)}</p></div>`;
195
+ };
196
+ export function feed(value: Json): string {
197
+ if (!Array.isArray(value)) return view(value);
198
+ if (!value.length) return '<p class="nv-empty">nothing yet</p>';
199
+ return `<div class="nv-feed">${value.map(happened).join('')}</div>`;
200
+ }
201
+
202
+ // ---- the page
203
+
204
+ // Everything the screen shows, as data. The side keeps one of these and
205
+ // hands it here after every change; the page is a function of it.
206
+ export type Model = {
207
+ blueprint: Blueprint | null; // her describe for this human, or nothing yet
208
+ look: Look; // her own look, from her `look` ask, or nothing
209
+ tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
210
+ pages: Record<string, Node | null>; // the page of each standing she carries, by id, from its carried `page` ask
211
+ focus?: string; // one standing she carries shown as the whole page, its id: the page at `web./<ward>/<being>`
212
+ at?: string; // the world's page address, so a section can say where its own page is, a focused page where back is, and an invitation answered what link it makes
213
+ notice: string; // one line about where the human stands: in, not in, an error before an ask
214
+ standing: Standing; // whether her relation is live, ended, away, or answering an error
215
+ answers: Record<string, Word>; // the last answer per ask, shown under its form
216
+ pushes: JsonObject[]; // every push from the world, newest last
217
+ };
218
+
219
+ // ---- where a human stands in a world
220
+ //
221
+ // A describe that does not come back says one of several things, and a
222
+ // screen that painted them alike told a human nothing she could act on. The
223
+ // far world removing her and the box being unreachable for a minute are the
224
+ // same line on the page, and one of them is over while the other is not.
225
+ //
226
+ // So the words are read apart. `removed` and `dropped` are the relation
227
+ // ended, and so is holding no standing at all: nothing this device does
228
+ // brings it back, and the only act left is leaving. `unreached`, `silence`,
229
+ // `late` and `absent` are the world being away: the standing is untouched,
230
+ // and the next ask may well answer. Anything else she said is her error and
231
+ // is shown as her words.
232
+ //
233
+ // A device never acts on this by itself. `ended` is one answer from one ask
234
+ // at one moment, and the far world is never told and never asked, so
235
+ // nothing can ever confirm it; a device that forgot a world on this alone
236
+ // would delete a ward holding a seed and every standing in it on the
237
+ // strength of one reply. It says so and offers the way out, and the human
238
+ // presses it or does not.
239
+ export type Standing = 'in' | 'ended' | 'away' | 'error';
240
+ const ENDED = new Set(['removed', 'dropped', 'not joined']);
241
+ const AWAY = new Set(['unreached', 'silence', 'late', 'absent']);
242
+ export function standing(error: string): Standing {
243
+ return ENDED.has(error) ? 'ended' : AWAY.has(error) ? 'away' : 'error';
244
+ }
245
+ // What a human reads for one, in her own terms rather than the ward's word.
246
+ export function stands(error: string): string {
247
+ switch (standing(error)) {
248
+ case 'ended':
249
+ return error === 'not joined' ? 'you are not in this world' : 'this world ended it: you are out, and nothing here reaches it';
250
+ case 'away':
251
+ return `this world cannot be reached right now, and nothing was delivered (${error})`;
252
+ default:
253
+ return `not in: ${error}`;
254
+ }
255
+ }
256
+
257
+ // What a human reads when a way in did not let her in. A world refuses for
258
+ // several different reasons and a shell that said one sentence for all of
259
+ // them sent her looking for the wrong thing. One of them has an answer and
260
+ // is the reason this exists: `id taken` is a device of that name already in
261
+ // this world, which is exactly what a human meets on a new device after
262
+ // losing the one that held it, since a presence is a seed and the seed went
263
+ // with the device. The standing it left is still there, and hers to take
264
+ // out from any device she still holds.
265
+ //
266
+ // The three words it knows are the dock's own beings', the desk's and the
267
+ // user being's, and knowing them is allowed because they ship in this
268
+ // package beside this file: what the screen may not do is invent a word a
269
+ // being never said, and it invents none. Any other public being falls
270
+ // through and is quoted in her own words, so a world that answers something
271
+ // of its own is passed on rather than guessed at. That is the whole rule,
272
+ // and it is why there is a default branch and not a table.
273
+ export function refused(answer: Answer): string {
274
+ const w = word(answer);
275
+ if (w.word === 'silence') return SILENCE_TEXT;
276
+ if (w.word === 'unreached') return UNREACHED_TEXT;
277
+ if (w.word === 'quo') return wordText(w.name);
278
+ const said = w.word === 'error' && typeof w.value.error === 'string' ? w.value.error : '';
279
+ switch (said) {
280
+ case 'id taken':
281
+ return 'a device of that name is already in this world. If it is one you have lost, take it out of the world from a device you still hold, or come back under another name.';
282
+ case 'no such user':
283
+ return 'this world knows nobody by that way in.';
284
+ case 'refused':
285
+ return 'that way in was refused: it may be spent, or it may never have been one.';
286
+ default:
287
+ return said ? `that way in was refused: ${said}` : 'that way in was refused.';
288
+ }
289
+ }
290
+
291
+ // The title is the one hint the notes may carry: a string named `name`.
292
+ // The rest of the notes is shown as a view and read as nothing else.
293
+ export function title(bp: Blueprint | null, l: Look = {}): string {
294
+ if (l.name) return l.name;
295
+ const n = bp?.notes;
296
+ return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
297
+ }
298
+
299
+ // ---- a look
300
+
301
+ // A look as the page paints it: the tokens become CSS variables on one
302
+ // section, and the name and the logo its heading. The shape of every token
303
+ // is the dock's, `beings/look.ts`; this only writes what survived it.
304
+ // Her tokens become the kit's slots on her section and nowhere else: her
305
+ // accent is the accent there, her ground the raised ground, her font the
306
+ // sans, her radius the large corner. The kit's derived slots follow from
307
+ // the accent in the stylesheet, so one colour of hers recolours her whole
308
+ // section and nothing outside it.
309
+ export function look(l: Look | undefined): { style: string; head: string } {
310
+ if (!l) return { style: '', head: '' };
311
+ const vars: string[] = [];
312
+ if (l.accent) vars.push(`--nv-color-accent:${l.accent}`, `--nv-color-accent-hover:${l.accent}`, `--nv-color-link:${l.accent}`);
313
+ if (l.background) vars.push(`--nv-color-bg-raised:${l.background}`);
314
+ if (l.foreground) vars.push(`--nv-color-fg:${l.foreground}`, `--nv-color-fg-body:${l.foreground}`);
315
+ if (l.font) vars.push(`--nv-font-sans:${l.font}`, `--nv-font-display:${l.font}`);
316
+ if (l.radius !== undefined) vars.push(`--nv-radius-lg:${l.radius}px`, `--nv-radius-xl:${l.radius}px`);
317
+ const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
318
+ const name = l.name ? escape(l.name) : '';
319
+ return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
320
+ }
321
+
322
+ // Her asks in the order her look asks for, the rest after in her own order.
323
+ export function ordered(all: Ask[], l: Look | undefined): Ask[] {
324
+ const want = l?.order ?? [];
325
+ return [...want.map((n) => all.find((a) => a.name === n)).filter((a): a is Ask => a !== undefined), ...all.filter((a) => !want.includes(a.name))];
326
+ }
327
+
328
+ export { hintFor };
329
+
330
+ export function page(m: Model): string {
331
+ const bp = m.blueprint;
332
+ const pres = presentation(bp);
333
+ // An answer that is an invitation is a way in, and the only thing to do
334
+ // with one is to open it: painted as the link it is, on this world's page
335
+ // address. A being does not know where her page lives and the side does,
336
+ // which is why the grammar carries no URL and this is not a node.
337
+ const shows = (w: Word, schema?: JsonObject): string => {
338
+ if (w.word !== 'object' || m.at === undefined || !isInvitation(w.value)) return face(w, schema);
339
+ const url = link(m.at, w.value);
340
+ return `<div class="nv-answer" data-word="object" data-way><a class="nv-key" href="${escape(url)}">${escape(url)}</a></div>`;
341
+ };
342
+ const one =(l: Look | undefined, prefix = '') => (a: Ask) => {
343
+ const w = m.answers[a.name];
344
+ const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
345
+ return `<section class="nv-stack">${form(a, h)}${w ? shows(w, a.output) : ''}</section>`;
346
+ };
347
+ // an answer shown as the page asks: as text, as cards, as a list, as
348
+ // people, as a feed, or by its shape
349
+ const answer = (name: string, as: As): string => {
350
+ const a = bp?.asks.find((x) => x.name === name);
351
+ const w = a ? m.answers[name] : undefined;
352
+ if (!a || !w) return '';
353
+ if (w.word === 'object' && as === 'text') return `<div class="nv-answer" data-word="object">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
354
+ if (w.word === 'object' && as === 'cards' && Array.isArray(w.value)) return `<div class="nv-cards">${w.value.map((v) => `<div class="nv-card">${view(v)}</div>`).join('')}</div>`;
355
+ if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="nv-list" role="list">${w.value.map((v) => `<li class="nv-item">${view(v)}</li>`).join('')}</ul>`;
356
+ if (w.word === 'object' && as === 'people') return people(w.value);
357
+ if (w.word === 'object' && as === 'feed') return feed(w.value);
358
+ return shows(w, a.output);
359
+ };
360
+ // ---- where a being may send a human
361
+ //
362
+ // A being names a place and the side decides whether it becomes an anchor.
363
+ // Two origins are vouched for and no third. The world's own, which is the
364
+ // origin serving this very page, so a being may move a human around inside
365
+ // the world she already chose. And the origins she declared in her look as
366
+ // the ones she stands in for, which is how an envoy speaking for a payment
367
+ // or a booking vendor sends a human to that vendor's own checkout.
368
+ //
369
+ // The declaration is what makes the second safe to allow. A look is asked
370
+ // once per digest and is the same for every answer she gives, so the
371
+ // places she stands in for are a standing claim rather than a destination
372
+ // that changes under one reply. That is what bounds the attack this rule
373
+ // is for: a vendor's response relayed through an envoy, carrying an
374
+ // address nobody here wrote. It does not bound a being who is hostile
375
+ // herself, and nothing on a page could; what bounds her is that a human
376
+ // chose to hold a standing at her.
377
+ //
378
+ // An address the side will not vouch for is painted as its own words. The
379
+ // human sees exactly where she was being sent, in full, and goes there by
380
+ // her own hand or not at all. Nothing is dropped and nothing is hidden,
381
+ // because a link silently deleted is a page that lies about what the being
382
+ // said.
383
+ const originOf = (s: string | undefined): string | null => {
384
+ if (s === undefined) return null;
385
+ try {
386
+ return new URL(s).origin;
387
+ } catch {
388
+ return null;
389
+ }
390
+ };
391
+ const here = originOf(m.at);
392
+ const linkedBy =
393
+ (l: Look | undefined) =>
394
+ (href: string, text: string): string => {
395
+ const origin = originOf(href);
396
+ if (origin === null) return escape(href);
397
+ const vouched = (here !== null && origin === here) || (l?.stands?.includes(origin) ?? false);
398
+ if (!vouched) return `<span class="nv-key link away" title="${escape(href)}">${escape(href)}</span>`;
399
+ // `noopener` and `noreferrer` because a page opened from here is a page
400
+ // the world wrote, and it is handed no window and no referer of ours.
401
+ // The origin is on the anchor whether or not a design shows it, so what
402
+ // a human is about to leave for is in the page and not only in a
403
+ // status bar she may never see.
404
+ const out = origin === here ? '' : ' out';
405
+ return `<a class="link${out}" href="${escape(href)}" rel="noopener noreferrer" data-origin="${escape(origin)}">${escape(text)}</a>`;
406
+ };
407
+
408
+ const groups = grouped(bp);
409
+ const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
410
+ // A standing's section. With her page, the far tree is painted inside it
411
+ // through a painter of her own: a name it uses is one of her asks, carried,
412
+ // and never one of the carrier's or another standing's, so a far page
413
+ // reaches nothing outside its section; her title is the page's, and the
414
+ // section keeps her look. Without one, her asks are forms under her name.
415
+ const section = (id: string, as?: 'door'): string => {
416
+ const g = groups[id];
417
+ const inGroup = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
418
+ if (!inGroup.length) return '';
419
+ const kept = sanitise(g.look);
420
+ const l = look(kept);
421
+ const prefix = `${id}-`;
422
+ const tree = m.pages[id];
423
+ // where her own page is, when the world's page is known and this is not it already
424
+ const own = m.at !== undefined && m.focus !== id ? `<p class="nv-t-quiet"><a class="open" href="${escape(`${m.at}/${encodeURIComponent(id)}`)}">open</a></p>` : '';
425
+ const whole = m.focus === id ? ' data-whole' : '';
426
+ // A door: her name and the way to her page, and nothing of hers here.
427
+ // Painted whole on her own page, as any standing is.
428
+ if (as === 'door' && m.focus !== id) {
429
+ const name = kept.name ?? id;
430
+ const count = inGroup.length;
431
+ return `<section class="nv-standing" data-standing="${escape(id)}" data-door${l.style}><div class="nv-person"><span class="nv-avatar">${escape(name.charAt(0).toUpperCase())}</span><div class="nv-stack"><p class="nv-t-body">${escape(name)}</p><p class="nv-t-quiet">${count} ${count === 1 ? 'thing' : 'things'} to do here, on its own page</p></div></div>${own}</section>`;
432
+ }
433
+ if (tree) {
434
+ const hers: Painter = {
435
+ form: (name) => {
436
+ const a = inGroup.find((x) => x.name === prefix + name);
437
+ return a ? one(kept, prefix)(a) : '';
438
+ },
439
+ answer: (name, as) => (inGroup.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
440
+ link: linkedBy(kept), // a carried standing vouches by her own look and never her carrier's
441
+ standing: () => '',
442
+ standings: () => '',
443
+ };
444
+ return `<section class="nv-standing" data-standing="${escape(id)}"${whole}${l.style}>${paint(tree, hers)}${own}</section>`;
445
+ }
446
+ const want = (kept.order ?? []).map((n) => prefix + n);
447
+ const inOrder = ordered(inGroup, { order: want });
448
+ return `<section class="nv-standing" data-standing="${escape(id)}"${whole}${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}${own}</section>`;
449
+ };
450
+ const far = Object.keys(groups).map((id) => section(id)).join('');
451
+ // Her page, when she answered one: painted from her tree, each name it
452
+ // uses looked up in her describe, so a page shows nothing her gate hid.
453
+ const painter: Painter = {
454
+ form: (name) => {
455
+ const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
456
+ return a ? one(m.look)(a) : '';
457
+ },
458
+ answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
459
+ link: linkedBy(m.look),
460
+ standing: section,
461
+ standings: () => far,
462
+ };
463
+ const mineAsks = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
464
+ // A being's page: one standing she carries as the whole page, and nothing of hers around it. A key her
465
+ // describe does not carry paints as nothing, the way a name on a page does: the address adds no right.
466
+ const focused = m.focus !== undefined;
467
+ const body = focused ? section(m.focus!) : m.tree ? paint(m.tree, painter) : `<div class="nv-stack">${mineAsks}${far}</div>`;
468
+ const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
469
+ // her notes are shown beside her forms when she has no page: a being with
470
+ // a page says everything she wants said, and a note is wiring
471
+ const notes = bp && !m.tree && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="nv-answer notes">${view(shown)}</aside>` : '';
472
+ const pushes = m.pushes.length ? `<section class="nv-stack pushes"><p class="nv-t-label">pushes</p><div class="nv-feed">${m.pushes.map((p) => `<div>${view(p)}</div>`).join('')}</div></section>` : '';
473
+ const styled = look(m.look);
474
+ // A name is said once. A page that opens with a title of its own is the
475
+ // name, and the bar keeps only where the human stands; a being painted
476
+ // as forms has no page to say it, so the bar says her name; a focused
477
+ // page says the way back and the standing's own tree says the rest.
478
+ const back = focused && m.at !== undefined ? `<a class="back" href="${escape(m.at)}">${escape(title(bp, m.look))}</a>` : '';
479
+ const said = focused ? (m.pages[m.focus!] ? titled(m.pages[m.focus!]) : false) : titled(m.tree);
480
+ const head = focused ? back : m.tree && said ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
481
+ // The notice carries where she stands, so a design and a reader can tell
482
+ // a relation that ended from a world that is merely away without parsing
483
+ // a sentence; the chip's tone is that word.
484
+ const tone = m.standing === 'ended' || m.standing === 'error' ? 'danger' : m.standing === 'away' ? 'warning' : 'success';
485
+ // `data-page` is the one hook that is not the design's: a class here would
486
+ // be a livery's name, and a livery is worn and swapped. The door marks
487
+ // itself `data-door` for the same reason, and between them anything
488
+ // outside can say which of the two it is looking at.
489
+ return `<header class="nv-bar"${styled.style}>${head}<p class="nv-chip notice" data-standing="${m.standing}" data-tone="${tone}">${escape(m.notice)}</p></header>${focused ? '' : notes}<main data-page${focused ? ' class="focus"' : ''}${styled.style}>${body}</main>${pushes}`;
490
+ }