@quo-systems/dock 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/beings/carry.ts CHANGED
@@ -58,16 +58,20 @@ export class Carrier extends Being {
58
58
  if (!st) continue;
59
59
  if (!rec.blueprint || rec.seen !== rec.digest) await st.ask(); // her ward writes the record
60
60
  if (!rec.blueprint) continue;
61
+ // her look is kept here once per digest and never carried as an ask;
62
+ // her page follows every call, so it is carried as one and named in
63
+ // the notes, where every side finds it and none takes it for a form
61
64
  const names: string[] = [];
65
+ const group: { asks: string[]; look?: Look; page?: string } = { asks: names };
62
66
  for (const a of rec.blueprint.asks) {
63
- if (a.name === 'look' || a.name === 'page') continue; // presentation is hers, never carried as an ask
67
+ if (a.name === 'look') continue;
64
68
  const name = `${id}-${a.name}`;
65
69
  if (own.has(name)) continue;
66
70
  carried[name] = { id, ask: a.name };
67
- names.push(name);
68
71
  bp.asks.push({ ...a, name });
72
+ if (a.name === 'page') group.page = name;
73
+ else names.push(name);
69
74
  }
70
- const group: { asks: string[]; look?: Look } = { asks: names };
71
75
  if (rec.blueprint.asks.some((a) => a.name === 'look')) {
72
76
  const kept = this.looks[id];
73
77
  if (!kept || kept.digest !== rec.digest) {
package/beings/desk.ts CHANGED
@@ -25,8 +25,12 @@ export class Desk extends Being {
25
25
  device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
26
26
  };
27
27
 
28
+ // A stranger learns the proof kinds and nothing else. Who lives on this
29
+ // world is not the caller's to know: the verifier names the user, so
30
+ // nobody at the door ever has to, and a world with a thousand of them
31
+ // would otherwise hand the whole list to anyone who knocked.
28
32
  hello(_args: JsonObject, asker: Asker) {
29
- return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers), users: Object.keys(this.cells.standings).filter((k) => k.startsWith('user:')).map((k) => k.slice(5)) };
33
+ return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers) };
30
34
  }
31
35
 
32
36
  async device(args: JsonObject) {
package/beings/look.ts CHANGED
@@ -75,15 +75,24 @@ export function sanitise(v: Json | undefined): Look {
75
75
  // The one hint a side reads first: whether this ask is in the look at all.
76
76
  export const hint = (l: Look | undefined, name: string): Hint => l?.asks?.[name] ?? {};
77
77
 
78
- // A carrier's notes say which asks are a standing's, and how she looks:
79
- // `standings: { id: { asks: [name], look? } }`. Every side reads them here.
80
- export type Group = { asks?: string[]; look?: Json };
78
+ // A carrier's notes say which asks are a standing's, how she looks, and
79
+ // which carried ask is her page: `standings: { id: { asks: [name], look?,
80
+ // page? } }`. Every side reads them here.
81
+ export type Group = { asks?: string[]; look?: Json; page?: string };
81
82
  export function groups(bp: Blueprint | null): Record<string, Group> {
82
83
  const n = bp?.notes;
83
84
  const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
84
85
  return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? (g as Record<string, Group>) : {};
85
86
  }
86
87
 
88
+ // The asks that are presentation and never a form nor a tool: her look, her
89
+ // page, and the page of every standing she carries, named in her notes.
90
+ export function presentation(bp: Blueprint | null): Set<string> {
91
+ const p = new Set(['look', 'page']);
92
+ for (const g of Object.values(groups(bp))) if (typeof g.page === 'string') p.add(g.page);
93
+ return p;
94
+ }
95
+
87
96
  // The hint for one ask, hers or a standing's: a carried ask is looked up
88
97
  // under its bare name in that standing's look.
89
98
  export function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint {
@@ -278,14 +278,15 @@ Carrying is a base, `beings/carry.ts`, and nothing in it knows what a user
278
278
  is: a house being could carry its rooms, a company its suppliers. A subclass
279
279
  says who may see what she carries, and the user being says a device with the
280
280
  `reach` note, written when the human ticked the box at the exchange. The
281
- notes of a carrier's describe say which asks are whose,
282
- `standings: { id: { asks, look? } }`, so a screen draws one section per
283
- standing and a model side keeps the flat list it needs; the mapping back
284
- from a carried name to the standing is in her cells and never parsed from
285
- the name. Her own asks come first and are never shadowed. She asks a
286
- standing's describe again when its digest moved and its `look` once per
287
- digest, so a describe of hers may cost one ask per stale standing, the price
288
- of a page that is never more than one ask behind.
281
+ notes of a carrier's describe say which asks are whose, how each standing
282
+ looks and which carried ask is her page, `standings: { id: { asks, look?,
283
+ page? } }`, so a screen draws one section per standing, paints her page in
284
+ it when she has one, and a model side keeps the flat list it needs with no
285
+ page in it; the mapping back from a carried name to the standing is in her
286
+ cells and never parsed from the name. Her own asks come first and are never
287
+ shadowed. She asks a standing's describe again when its digest moved and its
288
+ `look` once per digest, so a describe of hers may cost one ask per stale
289
+ standing, the price of a page that is never more than one ask behind.
289
290
 
290
291
  ## The look
291
292
 
@@ -313,11 +314,13 @@ A look is not capability: it is never in a blueprint or a digest, Quo never
313
314
  sees it, and a being without one renders and lists exactly as before. The
314
315
  `look` ask itself is presentation and is neither a form nor a tool, and so
315
316
  is `page`, the one other presentation ask, which a screen alone reads and
316
- `packages/dock/human/quo-human.md` defines; a carrier carries neither. A
317
- carrier keeps each standing's look beside her asks in the notes, so acme's
318
- section on the human's page is painted as acme asked, inside that section
319
- and nowhere else. No token can carry a URL, a stylesheet or code, so a far
320
- being cannot paint over the page or reach a server through it. A being who
317
+ `packages/dock/human/quo-human.md` defines. A carrier keeps each standing's
318
+ look beside her asks in the notes, since a look moves with the digest, and
319
+ carries her page as an ask named there, since a page moves with every call;
320
+ so acme's section on the human's page is painted as acme asked, her look
321
+ and her page, inside that section and nowhere else. No token can carry a
322
+ URL, a stylesheet or code, so a far being cannot paint over the page or
323
+ reach a server through it. A being who
321
324
  marks an ask read-only and then writes has lied to her own page, and nothing
322
325
  enforces it, the way nothing enforces that her empty ask is safe to repeat.
323
326
 
@@ -56,18 +56,24 @@ export class Carrier extends Being {
56
56
  await st.ask(); // her ward writes the record
57
57
  if (!rec.blueprint)
58
58
  continue;
59
+ // her look is kept here once per digest and never carried as an ask;
60
+ // her page follows every call, so it is carried as one and named in
61
+ // the notes, where every side finds it and none takes it for a form
59
62
  const names = [];
63
+ const group = { asks: names };
60
64
  for (const a of rec.blueprint.asks) {
61
- if (a.name === 'look' || a.name === 'page')
62
- continue; // presentation is hers, never carried as an ask
65
+ if (a.name === 'look')
66
+ continue;
63
67
  const name = `${id}-${a.name}`;
64
68
  if (own.has(name))
65
69
  continue;
66
70
  carried[name] = { id, ask: a.name };
67
- names.push(name);
68
71
  bp.asks.push({ ...a, name });
72
+ if (a.name === 'page')
73
+ group.page = name;
74
+ else
75
+ names.push(name);
69
76
  }
70
- const group = { asks: names };
71
77
  if (rec.blueprint.asks.some((a) => a.name === 'look')) {
72
78
  const kept = this.looks[id];
73
79
  if (!kept || kept.digest !== rec.digest) {
@@ -37,7 +37,6 @@ export declare class Desk extends Being {
37
37
  desk: boolean;
38
38
  asker: string | null;
39
39
  kinds: string[];
40
- users: string[];
41
40
  };
42
41
  device(args: JsonObject): Promise<Json>;
43
42
  }
@@ -17,8 +17,12 @@ export class Desk extends Being {
17
17
  hello: { description: 'how to get in', input: { type: 'object' } },
18
18
  device: { description: 'trade a proof for a device invitation', input: { type: 'object', properties: { proof: { type: 'object' } }, required: ['proof'] } },
19
19
  };
20
+ // A stranger learns the proof kinds and nothing else. Who lives on this
21
+ // world is not the caller's to know: the verifier names the user, so
22
+ // nobody at the door ever has to, and a world with a thousand of them
23
+ // would otherwise hand the whole list to anyone who knocked.
20
24
  hello(_args, asker) {
21
- return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers), users: Object.keys(this.cells.standings).filter((k) => k.startsWith('user:')).map((k) => k.slice(5)) };
25
+ return { desk: true, asker: asker.id ?? null, kinds: Object.keys(Desk.verifiers) };
22
26
  }
23
27
  async device(args) {
24
28
  const proof = args.proof;
@@ -22,6 +22,8 @@ export declare const hint: (l: Look | undefined, name: string) => Hint;
22
22
  export type Group = {
23
23
  asks?: string[];
24
24
  look?: Json;
25
+ page?: string;
25
26
  };
26
27
  export declare function groups(bp: Blueprint | null): Record<string, Group>;
28
+ export declare function presentation(bp: Blueprint | null): Set<string>;
27
29
  export declare function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint;
@@ -58,6 +58,15 @@ export function groups(bp) {
58
58
  const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
59
59
  return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? g : {};
60
60
  }
61
+ // The asks that are presentation and never a form nor a tool: her look, her
62
+ // page, and the page of every standing she carries, named in her notes.
63
+ export function presentation(bp) {
64
+ const p = new Set(['look', 'page']);
65
+ for (const g of Object.values(groups(bp)))
66
+ if (typeof g.page === 'string')
67
+ p.add(g.page);
68
+ return p;
69
+ }
61
70
  // The hint for one ask, hers or a standing's: a carried ask is looked up
62
71
  // under its bare name in that standing's look.
63
72
  export function hintFor(bp, mine, name) {
@@ -1,13 +1,13 @@
1
1
  import type { BeingClass } from '@quo-systems/quo';
2
- import { Harbor, type Dialer } from '@quo-systems/quo/harbor';
3
- import { Idb } from './idb.ts';
2
+ import { Harbor, type Dialer, type Store } from '@quo-systems/quo/harbor';
4
3
  export declare const BUILT_IN: Record<string, BeingClass>;
5
4
  export declare class BrowserHarbor extends Harbor {
6
5
  #private;
7
- readonly db: Idb;
6
+ readonly name: string;
8
7
  readonly dialers: Dialer[];
9
- constructor(name?: string, classes?: Record<string, BeingClass>);
8
+ constructor(store?: string | Store, classes?: Record<string, BeingClass>);
10
9
  boot(): Promise<void>;
11
10
  dial(url: string): Dialer;
11
+ wake(): void;
12
12
  close(): Promise<void>;
13
13
  }
@@ -3,18 +3,21 @@ import { Harbor, dial } from '@quo-systems/quo/harbor';
3
3
  import { Idb } from './idb.js';
4
4
  export const BUILT_IN = { User, Desk, Avatar };
5
5
  export class BrowserHarbor extends Harbor {
6
- db;
6
+ name;
7
7
  dialers = [];
8
+ #locked;
8
9
  #release;
9
- constructor(name = 'quo', classes = {}) {
10
- const db = new Idb(name);
11
- super(db, async () => ({ ...BUILT_IN, ...classes }));
12
- this.db = db;
10
+ constructor(store = 'quo', classes = {}) {
11
+ const s = typeof store === 'string' ? new Idb(store) : store;
12
+ super(s, async () => ({ ...BUILT_IN, ...classes }));
13
+ this.name = typeof store === 'string' ? store : 'quo';
14
+ this.#locked = typeof store === 'string';
13
15
  }
14
16
  // Take the lock and boot every ward kept here. A lock already held is
15
17
  // another tab's harbor over this seed, and this one refuses to boot.
16
18
  async boot() {
17
- await this.#lease();
19
+ if (this.#locked)
20
+ await this.#lease();
18
21
  await super.boot();
19
22
  }
20
23
  // Hold one socket to a world's quo. route.
@@ -23,10 +26,16 @@ export class BrowserHarbor extends Harbor {
23
26
  this.dialers.push(d);
24
27
  return d;
25
28
  }
29
+ // The device is back: a phone out of a pocket, an app to the foreground.
30
+ // Every socket is told, and one that died silently is dialed again.
31
+ wake() {
32
+ for (const d of this.dialers)
33
+ d.wake();
34
+ }
26
35
  async close() {
27
36
  for (const d of this.dialers.splice(0))
28
37
  d.close();
29
- this.db.close();
38
+ this.store.close?.();
30
39
  this.#release?.();
31
40
  this.#release = undefined;
32
41
  }
@@ -35,7 +44,7 @@ export class BrowserHarbor extends Harbor {
35
44
  if (!locks)
36
45
  return; // no web locks on this terrain: nothing vouches, as in the memory harbor
37
46
  const granted = await new Promise((ok) => {
38
- void locks.request(`quo:${this.db.name}`, { ifAvailable: true }, (lock) => {
47
+ void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
39
48
  if (!lock) {
40
49
  ok(false);
41
50
  return;
@@ -45,6 +54,6 @@ export class BrowserHarbor extends Harbor {
45
54
  });
46
55
  });
47
56
  if (!granted)
48
- throw new Error(`harbor ${this.db.name} is held by another tab`);
57
+ throw new Error(`harbor ${this.name} is held by another tab`);
49
58
  }
50
59
  }
@@ -1,4 +1,7 @@
1
+ import type { BeingClass } from '@quo-systems/quo';
1
2
  import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
3
+ import { BrowserHarbor } from './browser.ts';
4
+ export declare function nativeHarbor(name?: string, classes?: Record<string, BeingClass>): Promise<BrowserHarbor>;
2
5
  export declare class Native implements Store {
3
6
  #private;
4
7
  readonly harbor: string;
@@ -12,11 +12,27 @@
12
12
  // with no folder is a reinstall, and the stale key is deleted; a folder
13
13
  // with no key is a restore to another device, and the unreadable files are
14
14
  // deleted. Either way the harbor starts fresh and there is never a twin.
15
+ //
16
+ // The harbor on a phone is the browser harbor over this store: the app
17
+ // process is its lease, and the App plugin's foreground is its wake.
15
18
  import { Capacitor } from '@capacitor/core';
19
+ import { App } from '@capacitor/app';
16
20
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
17
21
  import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
18
22
  import { arithmetic } from '@quo-systems/quo/ward';
23
+ import { BrowserHarbor } from './browser.js';
19
24
  import { sealKey, seal, open } from './seal.js';
25
+ // The app's one harbor, booted, told `wake` every time the app comes to
26
+ // the foreground, since a phone asleep loses its sockets silently.
27
+ export async function nativeHarbor(name = 'quo', classes = {}) {
28
+ const h = new BrowserHarbor(await Native.open(name), classes);
29
+ await h.boot();
30
+ await App.addListener('appStateChange', ({ isActive }) => {
31
+ if (isActive)
32
+ h.wake();
33
+ });
34
+ return h;
35
+ }
20
36
  const { hex, unhex } = arithmetic;
21
37
  // Where the files live: the folder iCloud does not copy on iOS, the app's
22
38
  // own files on Android, whose manifest says no backup.
@@ -25,6 +25,7 @@ import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
25
25
  import { existsSync } from 'node:fs';
26
26
  import { join, resolve, isAbsolute } from 'node:path';
27
27
  import { pathToFileURL } from 'node:url';
28
+ import { register } from 'node:module';
28
29
  import process from 'node:process';
29
30
  import { User, Desk, Avatar } from '../beings/index.js';
30
31
  import { setup } from '../beings/setup.js';
@@ -35,12 +36,24 @@ export const BUILT_IN = { User, Desk, Avatar };
35
36
  // a second harbor in the same pid would pass that check while still being a
36
37
  // second ward with one pk.
37
38
  const HELD = new Set();
39
+ // A class file in the harbor folder resolves its packages from where the
40
+ // dock is installed, `resolve.ts`: the folder has no node_modules and is not
41
+ // meant to. Registered once per process, before the first class loads.
42
+ let hooked = false;
43
+ function hook() {
44
+ if (hooked)
45
+ return;
46
+ hooked = true;
47
+ const self = import.meta.url;
48
+ register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
49
+ }
38
50
  // The class source is a module. Every export that is a class is a class the
39
51
  // harbor holds, under its export name. The harbor never sees a body: it
40
52
  // constructs when a ward names a class, and never chooses one.
41
53
  async function loadClasses(path) {
42
54
  if (!existsSync(path))
43
55
  return {};
56
+ hook();
44
57
  // Keyed by the file's own time, so a source that changed on disk is read
45
58
  // again and a harbor boots on what it pins, not on what it once loaded.
46
59
  const { mtimeMs } = await stat(path);
@@ -0,0 +1,16 @@
1
+ type Context = {
2
+ parentURL?: string;
3
+ conditions: string[];
4
+ importAttributes: Record<string, string>;
5
+ };
6
+ type Resolved = {
7
+ url: string;
8
+ format?: string | null;
9
+ shortCircuit?: boolean;
10
+ };
11
+ type Next = (specifier: string, context: Context) => Promise<Resolved>;
12
+ export declare function initialize(data: {
13
+ parent: string;
14
+ }): void;
15
+ export declare function resolve(specifier: string, context: Context, next: Next): Promise<Resolved>;
16
+ export {};
@@ -0,0 +1,17 @@
1
+ let parent = '';
2
+ export function initialize(data) {
3
+ parent = data.parent;
4
+ }
5
+ const bare = (s) => !s.startsWith('.') && !s.startsWith('/') && !/^[a-z]+:/i.test(s);
6
+ export async function resolve(specifier, context, next) {
7
+ try {
8
+ return await next(specifier, context);
9
+ }
10
+ catch (e) {
11
+ const missing = e.code === 'ERR_MODULE_NOT_FOUND';
12
+ const from = context.parentURL ?? '';
13
+ if (missing && parent && bare(specifier) && from.startsWith('file:') && !from.includes('/node_modules/'))
14
+ return next(specifier, { ...context, parentURL: parent });
15
+ throw e;
16
+ }
17
+ }
@@ -28,6 +28,7 @@ export type Model = {
28
28
  blueprint: Blueprint | null;
29
29
  look: Look;
30
30
  tree: Node | null;
31
+ pages: Record<string, Node | null>;
31
32
  notice: string;
32
33
  answers: Record<string, Word>;
33
34
  pushes: JsonObject[];
@@ -1,5 +1,5 @@
1
1
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
2
- import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
2
+ import { hint, hintFor, sanitise, groups as grouped, presentation } from '../beings/look.js';
3
3
  import { paint } from './tree.js';
4
4
  export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
5
5
  const props = (schema) => Object.entries((schema?.properties ?? {}));
@@ -154,17 +154,35 @@ export function ordered(asks, l) {
154
154
  return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
155
155
  }
156
156
  export { hintFor };
157
- // The asks that are presentation and never a form: her look and her page.
158
- const PRESENTATION = new Set(['look', 'page']);
159
157
  export function page(m) {
160
158
  const bp = m.blueprint;
159
+ const pres = presentation(bp);
161
160
  const one = (l, prefix = '') => (a) => {
162
161
  const w = m.answers[a.name];
163
162
  const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
164
163
  return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
165
164
  };
165
+ // an answer shown as the page asks: as text, as cards, as a list, or by its shape
166
+ const answer = (name, as) => {
167
+ const a = bp?.asks.find((x) => x.name === name);
168
+ const w = a ? m.answers[name] : undefined;
169
+ if (!a || !w)
170
+ return '';
171
+ if (w.word === 'object' && as === 'text')
172
+ return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
173
+ if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
174
+ return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
175
+ if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
176
+ return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
177
+ return face(w, a.output);
178
+ };
166
179
  const groups = grouped(bp);
167
180
  const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
181
+ // A standing's section. With her page, the far tree is painted inside it
182
+ // through a painter of her own: a name it uses is one of her asks, carried,
183
+ // and never one of the carrier's or another standing's, so a far page
184
+ // reaches nothing outside its section; her title is the page's, and the
185
+ // section keeps her look. Without one, her asks are forms under her name.
168
186
  const section = (id) => {
169
187
  const g = groups[id];
170
188
  const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
@@ -173,6 +191,19 @@ export function page(m) {
173
191
  const kept = sanitise(g.look);
174
192
  const l = look(kept);
175
193
  const prefix = `${id}-`;
194
+ const tree = m.pages[id];
195
+ if (tree) {
196
+ const hers = {
197
+ form: (name) => {
198
+ const a = asks.find((x) => x.name === prefix + name);
199
+ return a ? one(kept, prefix)(a) : '';
200
+ },
201
+ answer: (name, as) => (asks.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
202
+ standing: () => '',
203
+ standings: () => '',
204
+ };
205
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div></section>`;
206
+ }
176
207
  const want = (kept.order ?? []).map((n) => prefix + n);
177
208
  const inOrder = ordered(asks, { order: want });
178
209
  return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
@@ -182,26 +213,14 @@ export function page(m) {
182
213
  // uses looked up in her describe, so a page shows nothing her gate hid.
183
214
  const painter = {
184
215
  form: (name) => {
185
- const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !PRESENTATION.has(x.name));
216
+ const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
186
217
  return a ? one(m.look)(a) : '';
187
218
  },
188
- answer: (name, as) => {
189
- const a = bp?.asks.find((x) => x.name === name);
190
- const w = a ? m.answers[name] : undefined;
191
- if (!a || !w)
192
- return '';
193
- if (w.word === 'object' && as === 'text')
194
- return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
195
- if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
196
- return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
197
- if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
198
- return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
199
- return face(w, a.output);
200
- },
219
+ answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
201
220
  standing: section,
202
221
  standings: () => far,
203
222
  };
204
- const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !PRESENTATION.has(a.name)), m.look).map(one(m.look)).join('') : '';
223
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
205
224
  const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
206
225
  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;
207
226
  const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
@@ -11,12 +11,12 @@ import { word } from '../beings/side.js';
11
11
  import { isInvitation } from '../beings/link.js';
12
12
  import { page, values, hintFor } from './html.js';
13
13
  import { sanitiseTree, answersIn } from './tree.js';
14
- import { sanitise } from '../beings/look.js';
14
+ import { groups, sanitise } from '../beings/look.js';
15
15
  import { isSilence, isWord } from '@quo-systems/quo';
16
16
  export async function screenSide(avatar, surface, options = {}) {
17
17
  const after = options.after ?? (async () => { });
18
18
  const notice = options.notice ?? '';
19
- const model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
19
+ const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
20
20
  let seen = null;
21
21
  const show = () => surface.show(page(model));
22
22
  // Her describe, again: the page follows the digest.
@@ -47,14 +47,20 @@ export async function screenSide(avatar, surface, options = {}) {
47
47
  // her page, after every ask: a tree of values from what she holds now, or nothing and her asks
48
48
  // are forms. A cell moved is a page moved, and her describe need not have; so the answers the
49
49
  // page shows are run again with it, and the page never stands more than one ask behind her
50
- const shows = new Set(model.tree ? answersIn(model.tree) : []);
51
- if (bp.asks.some((a) => a.name === 'page')) {
52
- const t = await avatar.call('page');
53
- model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
54
- }
55
- else
56
- model.tree = null;
57
- const wanted = new Set(model.tree ? answersIn(model.tree) : []);
50
+ const trees = () => [model.tree ? answersIn(model.tree) : [], ...Object.entries(model.pages).map(([id, t]) => (t ? answersIn(t).map((n) => `${id}-${n}`) : []))].flat();
51
+ const shows = new Set(trees());
52
+ const tree = async (name) => {
53
+ const t = await avatar.call(name);
54
+ return isSilence(t) || isWord(t) ? null : sanitiseTree(t);
55
+ };
56
+ model.tree = bp.asks.some((a) => a.name === 'page') ? await tree('page') : null;
57
+ // and the page of every standing she carries, the same way, painted in that standing's section;
58
+ // a name a carried page uses is one of that standing's asks, carried, and nothing else
59
+ model.pages = {};
60
+ for (const [id, g] of Object.entries(groups(bp)))
61
+ if (typeof g.page === 'string' && bp.asks.some((a) => a.name === g.page))
62
+ model.pages[id] = await tree(g.page);
63
+ const wanted = new Set(trees());
58
64
  for (const n of shows)
59
65
  if (wanted.has(n))
60
66
  delete model.answers[n];
package/dist/human/web.js CHANGED
@@ -98,7 +98,9 @@ const CSS = [
98
98
  ':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
99
99
  '@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
100
100
  'html{background:var(--paper);color:var(--ink)}',
101
- 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
101
+ // one column that is a phone first and a desk after: the same page, more air
102
+ 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:1rem auto;padding:0 1rem}@media(min-width:40rem){body{margin:2rem auto}}',
103
+ 'input:not([type=checkbox]),select,textarea{width:100%;max-width:24rem;box-sizing:border-box}',
102
104
  'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
103
105
  'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
104
106
  'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
@@ -112,6 +114,10 @@ const CSS = [
112
114
  '.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
113
115
  '.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
114
116
  '.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
117
+ // a row with a picture is a line about someone or something: the picture small and the words beside it, centred
118
+ '.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
119
+ // a far page inside a standing's section: her title is a section's, not the page's
120
+ 'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
115
121
  // the door page: one column, generous air, the mark, a word, a sentence
116
122
  'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
117
123
  'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
@@ -1,13 +1,15 @@
1
1
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
2
+ import { presentation } from '../beings/look.js';
2
3
  export const TURNS = 10;
3
4
  // Her describe, spoken as a tools array. Name, description and input are
4
5
  // verbatim, with the one narrowing an endpoint has asked for: an ask that
5
6
  // declares no properties is sent with an empty `properties`, because
6
- // LM Studio refuses a parameters schema without one. `look` and `page` are
7
- // presentation, for a screen, and are not functions.
7
+ // LM Studio refuses a parameters schema without one. Her look, her page and
8
+ // every carried page are presentation, for a screen, and are not functions.
8
9
  export function tools(bp) {
10
+ const shown = presentation(bp);
9
11
  return bp.asks
10
- .filter((a) => a.name !== 'look' && a.name !== 'page')
12
+ .filter((a) => !shown.has(a.name))
11
13
  .map((a) => {
12
14
  const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
13
15
  if (a.description !== undefined)
@@ -11,7 +11,7 @@
11
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
12
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
14
- import { hintFor, sanitise } from '../beings/look.js';
14
+ import { hintFor, presentation, sanitise } from '../beings/look.js';
15
15
  import { isSilence, isWord, digest } from '@quo-systems/quo';
16
16
  export const NAME = 'quo';
17
17
  export const VERSION = '0.0.0';
@@ -20,10 +20,12 @@ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her des
20
20
  // Her describe, spoken as tools, the empty ask first. Name, description and
21
21
  // input are verbatim; an output schema crosses when she declared one. Her
22
22
  // look, when she has one, is the hints: a title, and the annotations a host
23
- // reads. The `look` ask itself is presentation, and is not a tool.
23
+ // reads. Her look, her page and every carried page are presentation, for a
24
+ // screen, and are not tools.
24
25
  export function tools(bp, look = {}) {
26
+ const shown = presentation(bp);
25
27
  const asks = bp.asks
26
- .filter((a) => a.name !== 'look' && a.name !== 'page') // presentation, for a screen, and not a tool
28
+ .filter((a) => !shown.has(a.name))
27
29
  .map((a) => {
28
30
  const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
29
31
  if (a.description !== undefined)
package/harbor/browser.ts CHANGED
@@ -1,33 +1,38 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The browser harbor: the core over IndexedDB, in a tab. A dialer and never
3
- // a listener: it holds one socket per world it is connected to, and every
4
- // ask to a pk it does not hold goes down a socket to the listener, which is
5
- // the rendezvous. The lease is a web lock on the database name, held for
6
- // the life of the tab: a second tab on one origin meets the lock and is a
7
- // screen, not a harbor. The classes are the estate's beings, handed in
8
- // with the bundle, plus whatever a page hands in beside them.
2
+ // The browser harbor: the core over a store, in a webview. A dialer and
3
+ // never a listener: it holds one socket per world it is connected to, and
4
+ // every ask to a pk it does not hold goes down a socket to the listener,
5
+ // which is the rendezvous. In a tab the store is IndexedDB under a name,
6
+ // and the lease is a web lock on that name, held for the life of the tab:
7
+ // a second tab on one origin meets the lock and is a screen, not a harbor.
8
+ // In an app the store is handed in, and the process that holds it is the
9
+ // lease: an app has one harbor and nothing else can open its files. The
10
+ // classes are the estate's beings, handed in with the bundle, plus whatever
11
+ // a page hands in beside them.
9
12
  import type { BeingClass } from '@quo-systems/quo';
10
13
  import { User, Desk, Avatar } from '../beings/index.ts';
11
- import { Harbor, dial, type Dialer } from '@quo-systems/quo/harbor';
14
+ import { Harbor, dial, type Dialer, type Store } from '@quo-systems/quo/harbor';
12
15
  import { Idb } from './idb.ts';
13
16
 
14
17
  export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
15
18
 
16
19
  export class BrowserHarbor extends Harbor {
17
- readonly db: Idb;
20
+ readonly name: string;
18
21
  readonly dialers: Dialer[] = [];
22
+ readonly #locked: boolean;
19
23
  #release: (() => void) | undefined;
20
24
 
21
- constructor(name = 'quo', classes: Record<string, BeingClass> = {}) {
22
- const db = new Idb(name);
23
- super(db, async () => ({ ...BUILT_IN, ...classes }));
24
- this.db = db;
25
+ constructor(store: string | Store = 'quo', classes: Record<string, BeingClass> = {}) {
26
+ const s = typeof store === 'string' ? new Idb(store) : store;
27
+ super(s, async () => ({ ...BUILT_IN, ...classes }));
28
+ this.name = typeof store === 'string' ? store : 'quo';
29
+ this.#locked = typeof store === 'string';
25
30
  }
26
31
 
27
32
  // Take the lock and boot every ward kept here. A lock already held is
28
33
  // another tab's harbor over this seed, and this one refuses to boot.
29
34
  override async boot(): Promise<void> {
30
- await this.#lease();
35
+ if (this.#locked) await this.#lease();
31
36
  await super.boot();
32
37
  }
33
38
 
@@ -38,9 +43,15 @@ export class BrowserHarbor extends Harbor {
38
43
  return d;
39
44
  }
40
45
 
46
+ // The device is back: a phone out of a pocket, an app to the foreground.
47
+ // Every socket is told, and one that died silently is dialed again.
48
+ wake(): void {
49
+ for (const d of this.dialers) d.wake();
50
+ }
51
+
41
52
  async close(): Promise<void> {
42
53
  for (const d of this.dialers.splice(0)) d.close();
43
- this.db.close();
54
+ (this.store as { close?(): void }).close?.();
44
55
  this.#release?.();
45
56
  this.#release = undefined;
46
57
  }
@@ -49,7 +60,7 @@ export class BrowserHarbor extends Harbor {
49
60
  const locks = (globalThis.navigator as Navigator | undefined)?.locks;
50
61
  if (!locks) return; // no web locks on this terrain: nothing vouches, as in the memory harbor
51
62
  const granted = await new Promise<boolean>((ok) => {
52
- void locks.request(`quo:${this.db.name}`, { ifAvailable: true }, (lock) => {
63
+ void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
53
64
  if (!lock) {
54
65
  ok(false);
55
66
  return;
@@ -58,6 +69,6 @@ export class BrowserHarbor extends Harbor {
58
69
  return new Promise<void>((release) => (this.#release = release)); // held until close
59
70
  });
60
71
  });
61
- if (!granted) throw new Error(`harbor ${this.db.name} is held by another tab`);
72
+ if (!granted) throw new Error(`harbor ${this.name} is held by another tab`);
62
73
  }
63
74
  }
@@ -12,13 +12,30 @@
12
12
  // with no folder is a reinstall, and the stale key is deleted; a folder
13
13
  // with no key is a restore to another device, and the unreadable files are
14
14
  // deleted. Either way the harbor starts fresh and there is never a twin.
15
+ //
16
+ // The harbor on a phone is the browser harbor over this store: the app
17
+ // process is its lease, and the App plugin's foreground is its wake.
15
18
  import { Capacitor } from '@capacitor/core';
19
+ import { App } from '@capacitor/app';
16
20
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
17
21
  import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
22
+ import type { BeingClass } from '@quo-systems/quo';
18
23
  import { arithmetic } from '@quo-systems/quo/ward';
19
24
  import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
25
+ import { BrowserHarbor } from './browser.ts';
20
26
  import { sealKey, seal, open } from './seal.ts';
21
27
 
28
+ // The app's one harbor, booted, told `wake` every time the app comes to
29
+ // the foreground, since a phone asleep loses its sockets silently.
30
+ export async function nativeHarbor(name = 'quo', classes: Record<string, BeingClass> = {}): Promise<BrowserHarbor> {
31
+ const h = new BrowserHarbor(await Native.open(name), classes);
32
+ await h.boot();
33
+ await App.addListener('appStateChange', ({ isActive }) => {
34
+ if (isActive) h.wake();
35
+ });
36
+ return h;
37
+ }
38
+
22
39
  const { hex, unhex } = arithmetic;
23
40
  type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
24
41
 
package/harbor/disk.ts CHANGED
@@ -17,6 +17,7 @@ import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join, resolve, isAbsolute } from 'node:path';
19
19
  import { pathToFileURL } from 'node:url';
20
+ import { register } from 'node:module';
20
21
  import process from 'node:process';
21
22
  import type { BeingClass } from '@quo-systems/quo';
22
23
  import { User, Desk, Avatar } from '../beings/index.ts';
@@ -33,11 +34,23 @@ export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
33
34
  // second ward with one pk.
34
35
  const HELD = new Set<string>();
35
36
 
37
+ // A class file in the harbor folder resolves its packages from where the
38
+ // dock is installed, `resolve.ts`: the folder has no node_modules and is not
39
+ // meant to. Registered once per process, before the first class loads.
40
+ let hooked = false;
41
+ function hook(): void {
42
+ if (hooked) return;
43
+ hooked = true;
44
+ const self = import.meta.url;
45
+ register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
46
+ }
47
+
36
48
  // The class source is a module. Every export that is a class is a class the
37
49
  // harbor holds, under its export name. The harbor never sees a body: it
38
50
  // constructs when a ward names a class, and never chooses one.
39
51
  async function loadClasses(path: string): Promise<Record<string, BeingClass>> {
40
52
  if (!existsSync(path)) return {};
53
+ hook();
41
54
  // Keyed by the file's own time, so a source that changed on disk is read
42
55
  // again and a harbor boots on what it pins, not on what it once loaded.
43
56
  const { mtimeMs } = await stat(path);
@@ -339,6 +339,15 @@ core plus files, the loader and the pid, and holds the `ws` listener's end of
339
339
  every socket dialed to it; the browser harbor, `browser.ts`, is the core plus
340
340
  IndexedDB, the built-in beings, the lock and one dialer per world.
341
341
 
342
+ The harbor folder is the device's and not a package: it has no node_modules
343
+ and is not meant to, and a class file in it imports the library by name like
344
+ any code. So the disk harbor resolves a class file's packages from where the
345
+ dock itself is installed, `resolve.ts`, a module resolution hook registered
346
+ once before the first class loads: a bare name that does not resolve from a
347
+ file outside any node_modules is resolved again from the dock's own place.
348
+ A class sees the packages the daemon sees, the library and the dock's own,
349
+ and one copy of each, so a `Being` in a class file is the daemon's `Being`.
350
+
342
351
  A tab's store keeps values through JSON, as a file does, because the ward
343
352
  hands its partition out through a guard that structured clone refuses.
344
353
  The daemon's `/quo` and `/health` answer any origin, with the preflight a
@@ -417,15 +426,27 @@ key is a restore to another device, and the unreadable files are deleted;
417
426
  either way the harbor starts fresh and there is never a twin. The app that
418
427
  holds the store is `packages/app/`, which the dock does not know.
419
428
 
429
+ The harbor on a phone is the browser harbor, `browser.ts`, over that
430
+ store: the browser harbor takes a name, which is an IndexedDB database
431
+ under a web lock, or a store handed in, whose lease is the process that
432
+ holds it, since an app has one harbor and nothing else can open its
433
+ files. `nativeHarbor` in `capacitor.ts` is that harbor booted and armed:
434
+ the App plugin's foreground event tells it `wake`, which tells every
435
+ dialer, because a phone asleep loses its sockets silently and the wake is
436
+ what dials them back.
437
+
420
438
  The proof is `packages/dock/test/terrain/ios.test.ts`, behind
421
- `npm run check:terrain`: the store suite, untouched, and the custody rule,
422
- run inside the real app in the iOS Simulator against a real Keychain and a
423
- real folder. The app is synced with the test's origin as its page, built
424
- with xcodebuild, installed fresh and launched with simctl; the page loads
425
- the bundled exercise, `native.ts`, runs it and posts the list back. Two
426
- things the plugins taught, held in the store: the secure store keeps JSON,
427
- so a value is read with the call that parses; and mkdir refuses a folder
428
- that exists, recursive or not.
439
+ `npm run check:terrain`, inside the real app in the iOS Simulator: the
440
+ store suite, untouched, and the custody rule against a real Keychain and
441
+ a real folder; the whole conformance suite over two harbors on native
442
+ stores, both dialing a daemon on the Mac's loopback through the tab's own
443
+ probe; and the wake, the app sent behind another and brought back, its
444
+ dialer told and its socket held again. The app is synced with the test's
445
+ origin as its page, built with xcodebuild, installed fresh and launched
446
+ with simctl; the page loads the bundled exercise, `native.ts`, runs it and
447
+ posts the list back. Two things the plugins taught, held in the store:
448
+ the secure store keeps JSON, so a value is read with the call that
449
+ parses; and mkdir refuses a folder that exists, recursive or not.
429
450
 
430
451
  ## The link
431
452
 
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // How a class file the harbor folder holds finds its packages. The folder
3
+ // is the device's, not a package: it has no node_modules, and a class in it
4
+ // that imports `@quo-systems/quo` would otherwise fail to resolve from where
5
+ // it sits. This is a module resolution hook, registered once by the disk
6
+ // harbor: a bare specifier that does not resolve from a file outside any
7
+ // node_modules is resolved again from where the dock itself is installed,
8
+ // so a class file sees exactly the packages the daemon sees, the library
9
+ // and the dock's own, and one copy of each.
10
+ type Context = { parentURL?: string; conditions: string[]; importAttributes: Record<string, string> };
11
+ type Resolved = { url: string; format?: string | null; shortCircuit?: boolean };
12
+ type Next = (specifier: string, context: Context) => Promise<Resolved>;
13
+
14
+ let parent = '';
15
+
16
+ export function initialize(data: { parent: string }): void {
17
+ parent = data.parent;
18
+ }
19
+
20
+ const bare = (s: string) => !s.startsWith('.') && !s.startsWith('/') && !/^[a-z]+:/i.test(s);
21
+
22
+ export async function resolve(specifier: string, context: Context, next: Next): Promise<Resolved> {
23
+ try {
24
+ return await next(specifier, context);
25
+ } catch (e) {
26
+ const missing = (e as { code?: string }).code === 'ERR_MODULE_NOT_FOUND';
27
+ const from = context.parentURL ?? '';
28
+ if (missing && parent && bare(specifier) && from.startsWith('file:') && !from.includes('/node_modules/')) return next(specifier, { ...context, parentURL: parent });
29
+ throw e;
30
+ }
31
+ }
package/human/html.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  // page(model) the whole page: title, notice, forms, views, pushes
12
12
  import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
13
13
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText, type Word } from '../beings/side.ts';
14
- import { hint, hintFor, sanitise, groups as grouped, type Look, type Hint } from '../beings/look.ts';
15
- import { paint, type Node, type Painter } from './tree.ts';
14
+ import { hint, hintFor, sanitise, groups as grouped, presentation, type Look, type Hint } from '../beings/look.ts';
15
+ import { paint, type As, type Node, type Painter } from './tree.ts';
16
16
 
17
17
  // One property of an input schema, as the form needs it.
18
18
  export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
@@ -142,6 +142,7 @@ export type Model = {
142
142
  blueprint: Blueprint | null; // her describe for this human, or nothing yet
143
143
  look: Look; // her own look, from her `look` ask, or nothing
144
144
  tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
145
+ pages: Record<string, Node | null>; // the page of each standing she carries, by id, from its carried `page` ask
145
146
  notice: string; // one line about where the human stands: in, not in, an error before an ask
146
147
  answers: Record<string, Word>; // the last answer per ask, shown under its form
147
148
  pushes: JsonObject[]; // every push from the world, newest last
@@ -181,18 +182,31 @@ export function ordered(asks: Ask[], l: Look | undefined): Ask[] {
181
182
 
182
183
  export { hintFor };
183
184
 
184
- // The asks that are presentation and never a form: her look and her page.
185
- const PRESENTATION = new Set(['look', 'page']);
186
-
187
185
  export function page(m: Model): string {
188
186
  const bp = m.blueprint;
189
- const one = (l: Look | undefined, prefix = '') => (a: Ask) => {
187
+ const pres = presentation(bp);
188
+ const one =(l: Look | undefined, prefix = '') => (a: Ask) => {
190
189
  const w = m.answers[a.name];
191
190
  const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
192
191
  return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
193
192
  };
193
+ // an answer shown as the page asks: as text, as cards, as a list, or by its shape
194
+ const answer = (name: string, as: As): string => {
195
+ const a = bp?.asks.find((x) => x.name === name);
196
+ const w = a ? m.answers[name] : undefined;
197
+ if (!a || !w) return '';
198
+ if (w.word === 'object' && as === 'text') return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
199
+ if (w.word === 'object' && as === 'cards' && Array.isArray(w.value)) return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
200
+ if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
201
+ return face(w, a.output);
202
+ };
194
203
  const groups = grouped(bp);
195
204
  const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
205
+ // A standing's section. With her page, the far tree is painted inside it
206
+ // through a painter of her own: a name it uses is one of her asks, carried,
207
+ // and never one of the carrier's or another standing's, so a far page
208
+ // reaches nothing outside its section; her title is the page's, and the
209
+ // section keeps her look. Without one, her asks are forms under her name.
196
210
  const section = (id: string): string => {
197
211
  const g = groups[id];
198
212
  const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
@@ -200,6 +214,19 @@ export function page(m: Model): string {
200
214
  const kept = sanitise(g!.look);
201
215
  const l = look(kept);
202
216
  const prefix = `${id}-`;
217
+ const tree = m.pages[id];
218
+ if (tree) {
219
+ const hers: Painter = {
220
+ form: (name) => {
221
+ const a = asks.find((x) => x.name === prefix + name);
222
+ return a ? one(kept, prefix)(a) : '';
223
+ },
224
+ answer: (name, as) => (asks.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
225
+ standing: () => '',
226
+ standings: () => '',
227
+ };
228
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div></section>`;
229
+ }
203
230
  const want = (kept.order ?? []).map((n) => prefix + n);
204
231
  const inOrder = ordered(asks, { order: want });
205
232
  return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
@@ -209,22 +236,14 @@ export function page(m: Model): string {
209
236
  // uses looked up in her describe, so a page shows nothing her gate hid.
210
237
  const painter: Painter = {
211
238
  form: (name) => {
212
- const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !PRESENTATION.has(x.name));
239
+ const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
213
240
  return a ? one(m.look)(a) : '';
214
241
  },
215
- answer: (name, as) => {
216
- const a = bp?.asks.find((x) => x.name === name);
217
- const w = a ? m.answers[name] : undefined;
218
- if (!a || !w) return '';
219
- if (w.word === 'object' && as === 'text') return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
220
- if (w.word === 'object' && as === 'cards' && Array.isArray(w.value)) return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
221
- if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
222
- return face(w, a.output);
223
- },
242
+ answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
224
243
  standing: section,
225
244
  standings: () => far,
226
245
  };
227
- const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !PRESENTATION.has(a.name)), m.look).map(one(m.look)).join('') : '';
246
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
228
247
  const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
229
248
  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;
230
249
  const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
@@ -55,9 +55,9 @@ running there, from her cells, and painted by the same painter.
55
55
  - **What opens with the page.** The answers a tree shows are run on open
56
56
  when the ask needs nothing typed, beside the read-only asks her look
57
57
  marks, so the page opens with what it shows.
58
- - **The page is presentation.** `page` is asked once per digest, like
59
- `look`; it is never a form, never a tool, and a carrier never carries it.
60
- The user being answers one: her name as the title, who this device is as
58
+ - **The page is presentation.** `page` is asked after every call, and it is
59
+ never a form and never a tool, hers or a carried one. The user being
60
+ answers one: her name as the title, who this device is as
61
61
  an answer, her standings as sections, and the two things a human does
62
62
  here by hand, waking another device and calling her by a name, as forms.
63
63
  A being does not know her own key, so her name is given: by the desk's
@@ -99,9 +99,20 @@ classes, and the ward runs them.
99
99
  same: her page is never more than one ask behind her.
100
100
  - **One section each.** Under the far being's page, each being of the world
101
101
  has her own section and her own side; the shell's own heading is dropped
102
- wherever a page carries its title. Slots that bind her page to a
103
- standing she holds, and the shadow root that isolates a far page, are
104
- the next part of the same design.
102
+ wherever a page carries its title.
103
+ - **A carried page is painted in her section.** The third source of the one
104
+ grammar, and no new grammar: a standing the user being carries answers
105
+ `page` like anyone, the carrier carries it as `<id>-page` and names it in
106
+ her notes, and the side asks it after every call like her own. The
107
+ section that was her forms under her name becomes her page under her
108
+ look, painted through a painter of her own: a form or an answer it names
109
+ is one of her asks by its bare name, carried, and a name that is not hers,
110
+ or a `standing` inside a page, paints as nothing. So a far page reaches
111
+ nothing outside its section, by construction and not by a shadow root:
112
+ the grammar carries no selector, no script and no stylesheet, the one
113
+ stylesheet is the shell's, and a root of her own would keep the shell's
114
+ stylesheet out of her section for no gain. The section is the root a page
115
+ owns.
105
116
 
106
117
  ## How a screen is made
107
118
 
package/human/screen.ts CHANGED
@@ -13,7 +13,7 @@ import { isInvitation } from '../beings/link.ts';
13
13
  import type { Invitation } from '@quo-systems/quo';
14
14
  import { page, values, hintFor, type Model, type Raw } from './html.ts';
15
15
  import { sanitiseTree, answersIn } from './tree.ts';
16
- import { sanitise } from '../beings/look.ts';
16
+ import { groups, sanitise } from '../beings/look.ts';
17
17
  import { isSilence, isWord } from '@quo-systems/quo';
18
18
  import type { Json } from '@quo-systems/quo';
19
19
 
@@ -36,7 +36,7 @@ export type Options = { after?: () => Promise<void>; notice?: string; admit?: (i
36
36
  export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
37
37
  const after = options.after ?? (async () => {});
38
38
  const notice = options.notice ?? '';
39
- const model: Model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
39
+ const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
40
40
  let seen: string | null = null;
41
41
  const show = () => surface.show(page(model));
42
42
 
@@ -63,12 +63,18 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
63
63
  // her page, after every ask: a tree of values from what she holds now, or nothing and her asks
64
64
  // are forms. A cell moved is a page moved, and her describe need not have; so the answers the
65
65
  // page shows are run again with it, and the page never stands more than one ask behind her
66
- const shows = new Set(model.tree ? answersIn(model.tree) : []);
67
- if ((bp as Blueprint).asks.some((a) => a.name === 'page')) {
68
- const t = await avatar.call('page');
69
- model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
70
- } else model.tree = null;
71
- const wanted = new Set(model.tree ? answersIn(model.tree) : []);
66
+ const trees = () => [model.tree ? answersIn(model.tree) : [], ...Object.entries(model.pages).map(([id, t]) => (t ? answersIn(t).map((n) => `${id}-${n}`) : []))].flat();
67
+ const shows = new Set(trees());
68
+ const tree = async (name: string) => {
69
+ const t = await avatar.call(name);
70
+ return isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
71
+ };
72
+ model.tree = (bp as Blueprint).asks.some((a) => a.name === 'page') ? await tree('page') : null;
73
+ // and the page of every standing she carries, the same way, painted in that standing's section;
74
+ // a name a carried page uses is one of that standing's asks, carried, and nothing else
75
+ model.pages = {};
76
+ for (const [id, g] of Object.entries(groups(bp as Blueprint))) if (typeof g.page === 'string' && (bp as Blueprint).asks.some((a) => a.name === g.page)) model.pages[id] = await tree(g.page);
77
+ const wanted = new Set(trees());
72
78
  for (const n of shows) if (wanted.has(n)) delete model.answers[n];
73
79
  // a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
74
80
  // shows the answer of after every ask, so the page opens and stays with what it shows
package/human/web.ts CHANGED
@@ -125,7 +125,9 @@ const CSS = [
125
125
  ':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
126
126
  '@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
127
127
  'html{background:var(--paper);color:var(--ink)}',
128
- 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
128
+ // one column that is a phone first and a desk after: the same page, more air
129
+ 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:1rem auto;padding:0 1rem}@media(min-width:40rem){body{margin:2rem auto}}',
130
+ 'input:not([type=checkbox]),select,textarea{width:100%;max-width:24rem;box-sizing:border-box}',
129
131
  'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
130
132
  'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
131
133
  'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
@@ -139,6 +141,10 @@ const CSS = [
139
141
  '.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
140
142
  '.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
141
143
  '.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
144
+ // a row with a picture is a line about someone or something: the picture small and the words beside it, centred
145
+ '.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
146
+ // a far page inside a standing's section: her title is a section's, not the page's
147
+ 'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
142
148
  // the door page: one column, generous air, the mark, a word, a sentence
143
149
  'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
144
150
  'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
package/mcp/runner.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import type { Blueprint, JsonObject } from '@quo-systems/quo';
14
14
  import type { Avatar } from '../beings/avatar.ts';
15
15
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving } from '../beings/side.ts';
16
+ import { presentation } from '../beings/look.ts';
16
17
 
17
18
  // Where the model is. `key` is a bearer for endpoints that want one, read
18
19
  // from the device and never from cells. `turns` is the ceiling of model
@@ -32,11 +33,12 @@ type Completion = { choices: { message: { role: 'assistant'; content: string | n
32
33
  // Her describe, spoken as a tools array. Name, description and input are
33
34
  // verbatim, with the one narrowing an endpoint has asked for: an ask that
34
35
  // declares no properties is sent with an empty `properties`, because
35
- // LM Studio refuses a parameters schema without one. `look` and `page` are
36
- // presentation, for a screen, and are not functions.
36
+ // LM Studio refuses a parameters schema without one. Her look, her page and
37
+ // every carried page are presentation, for a screen, and are not functions.
37
38
  export function tools(bp: Blueprint): Tool[] {
39
+ const shown = presentation(bp);
38
40
  return bp.asks
39
- .filter((a) => a.name !== 'look' && a.name !== 'page')
41
+ .filter((a) => !shown.has(a.name))
40
42
  .map((a) => {
41
43
  const t: Tool = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
42
44
  if (a.description !== undefined) t.function.description = a.description;
package/mcp/server.ts CHANGED
@@ -13,7 +13,7 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
13
13
  import { ListToolsRequestSchema, CallToolRequestSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js';
14
14
  import type { Blueprint, JsonObject } from '@quo-systems/quo';
15
15
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT, type Serving, type Subject } from '../beings/side.ts';
16
- import { hintFor, sanitise, type Look } from '../beings/look.ts';
16
+ import { hintFor, presentation, sanitise, type Look } from '../beings/look.ts';
17
17
  import { isSilence, isWord, digest } from '@quo-systems/quo';
18
18
  import type { Json } from '@quo-systems/quo';
19
19
 
@@ -28,10 +28,12 @@ export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: h
28
28
  // Her describe, spoken as tools, the empty ask first. Name, description and
29
29
  // input are verbatim; an output schema crosses when she declared one. Her
30
30
  // look, when she has one, is the hints: a title, and the annotations a host
31
- // reads. The `look` ask itself is presentation, and is not a tool.
31
+ // reads. Her look, her page and every carried page are presentation, for a
32
+ // screen, and are not tools.
32
33
  export function tools(bp: Blueprint, look: Look = {}): Tool[] {
34
+ const shown = presentation(bp);
33
35
  const asks = bp.asks
34
- .filter((a) => a.name !== 'look' && a.name !== 'page') // presentation, for a screen, and not a tool
36
+ .filter((a) => !shown.has(a.name))
35
37
  .map((a) => {
36
38
  const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
37
39
  if (a.description !== undefined) t.description = a.description;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/dock",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
5
5
  "keywords": [
6
6
  "quo",
@@ -72,14 +72,15 @@
72
72
  "build": "rm -rf dist && tsc -p tsconfig.build.json && cp harbor/edge/platform.d.ts dist/harbor/edge/ && cp -R cli/estate dist/cli/",
73
73
  "test": "node --test \"test/*.test.ts\"",
74
74
  "check:terrain": "node --test \"test/terrain/*.test.ts\"",
75
- "prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
75
+ "prepublishOnly": "test \"$QUO_GATED\" = 1 || { echo 'publish from the root, gated once: npm run release' >&2; exit 1; }"
76
76
  },
77
77
  "dependencies": {
78
78
  "@aparajita/capacitor-secure-storage": "^8.0.0",
79
+ "@capacitor/app": "^8.1.1",
79
80
  "@capacitor/core": "^8.5.1",
80
81
  "@capacitor/filesystem": "^8.1.3",
81
82
  "@modelcontextprotocol/sdk": "^1.30.0",
82
- "@quo-systems/quo": "^0.2.4",
83
+ "@quo-systems/quo": "^0.2.6",
83
84
  "esbuild": "^0.28.2",
84
85
  "ws": "^8.21.3"
85
86
  },