clavarium 0.1.5 → 0.2.0-next.1

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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.5",
3
+ "version": "0.2.0-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.1.5",
9
+ "version": "0.2.0-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
13
13
  "@xterm/xterm": "^6.0.0",
14
- "clavarium-parley": "0.9.0",
14
+ "clavarium-parley": "0.10.0-next.2",
15
15
  "mermaid": "11.17.2",
16
16
  "node-pty": "^1.1.0",
17
17
  "pdfjs-dist": "6.4.299",
@@ -686,9 +686,9 @@
686
686
  }
687
687
  },
688
688
  "node_modules/clavarium-parley": {
689
- "version": "0.9.0",
690
- "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.9.0.tgz",
691
- "integrity": "sha512-9JjM2GVlAq/a3n3G47QG/MSze8RjwiDH4aMqGQhJ92T4W/uDlRjCJiRwVyuPWx7PVAId/+r8sHNIPDAz7K0tzQ==",
689
+ "version": "0.10.0-next.2",
690
+ "resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.2.tgz",
691
+ "integrity": "sha512-/6pAEMEKgug1FerqBKzqoMcWmnR6eYWtfiGt1Oa1gz9vWFuupxXlTeUFurukgfqLiEVOU6U+g1qJUff8PtTLBQ==",
692
692
  "license": "MIT",
693
693
  "bin": {
694
694
  "parley": "bin/parley.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.1.5",
3
+ "version": "0.2.0-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@xterm/addon-fit": "^0.11.0",
45
45
  "@xterm/xterm": "^6.0.0",
46
- "clavarium-parley": "0.9.0",
46
+ "clavarium-parley": "0.10.0-next.2",
47
47
  "mermaid": "11.17.2",
48
48
  "node-pty": "^1.1.0",
49
49
  "pdfjs-dist": "6.4.299",
package/server/api.mjs CHANGED
@@ -424,6 +424,10 @@ export async function handleApi(request, response, conversations, headers, resou
424
424
  if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
425
425
  const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
426
426
  if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
427
+ const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
428
+ if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
429
+ const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
430
+ if (hiding) return send(200, conversations.setHidden(decodeURIComponent(hiding[1]), body.on)), true;
427
431
  const marking = /^\/api\/conversations\/([^/]+)\/voice-mark$/.exec(pathname);
428
432
  if (marking) return send(200, conversations.setVoiceMark(decodeURIComponent(marking[1]), body.on)), true;
429
433
  const resuming = /^\/api\/conversations\/([^/]+)\/resume$/.exec(pathname);
@@ -6,7 +6,7 @@
6
6
  * - Sleep is a button, never a guess: telling a long job from an idle tool
7
7
  * cannot be done without reading the tool, and this server does not read.
8
8
  */
9
- import { isConversationName } from './names.mjs';
9
+ import { isConversationName, identifierFrom, titleProblem } from './names.mjs';
10
10
  import { attachCommand, SOCKET } from './tmux.mjs';
11
11
  import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
12
12
  import { resumeOrFresh } from './tools.mjs';
@@ -189,7 +189,8 @@ export class Conversations {
189
189
 
190
190
  /* Whether a new machine may be made for this request: what `create`
191
191
  checks, and the server's room (capacity.mjs). Asked before `create`. */
192
- async admit({ name, tool, memoryMb } = {}) {
192
+ async admit({ name, title, tool, memoryMb } = {}) {
193
+ if (!name && typeof title === 'string' && !titleProblem(title)) ({ name } = this.#naming({ title }));
193
194
  const chosen = this.#tools.find((t) => t.id === tool);
194
195
  /* What `create` refuses anyway is said by `create`. */
195
196
  if (!chosen || !isConversationName(name) || this.#store.get(name)) return;
@@ -201,7 +202,17 @@ export class Conversations {
201
202
  /* `workOnDisk`: the tool works on its machine's own disk instead of in a
202
203
  folder kept by the server. Faster for a large code project; but what
203
204
  it makes is then not seen from the page, and goes with the machine. */
204
- create({ name, tool, memoryMb, workOnDisk }) {
205
+ /* A conversation is made from the name a person reads (`title`), its
206
+ identifier drawn from it, or from an identifier alone (the advanced
207
+ options, a blueprint). The identifier never changes afterwards. */
208
+ #naming({ name, title }) {
209
+ if (title !== undefined) { const problem = titleProblem(title); if (problem) throw new Refusal(400, problem); }
210
+ const id = name || (title !== undefined ? identifierFrom(title, (x) => Boolean(this.#store.get(x))) : name);
211
+ return { name: id, title: title !== undefined && title !== id ? title : undefined };
212
+ }
213
+
214
+ create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
215
+ const { name, title } = this.#naming({ name: given, title: read });
205
216
  if (!isConversationName(name)) throw new Refusal(400, 'A name is 1 to 32 lower-case letters, digits or hyphens; it starts with a letter and does not end with a hyphen.');
206
217
  const chosen = this.#tools.find((t) => t.id === tool);
207
218
  if (!chosen) throw new Refusal(400, 'Unknown tool.');
@@ -211,7 +222,7 @@ export class Conversations {
211
222
  }
212
223
  if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
213
224
  const now = new Date().toISOString();
214
- const conversation = { name, tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
225
+ const conversation = { name, ...(title ? { title } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
215
226
  this.#store.put(conversation);
216
227
  this.built = this.#build(name, chosen, memory);
217
228
  return conversation;
@@ -450,6 +461,28 @@ export class Conversations {
450
461
  return this.#store.get(name);
451
462
  }
452
463
 
464
+ /* THE NAME A PERSON READS, changed at any time; its identifier stays.
465
+ Kept only when it differs from the identifier: a conversation made
466
+ before free names is read by its identifier, and nothing is rewritten. */
467
+ setTitle(name, title) {
468
+ const c = this.#store.get(name);
469
+ if (!c) throw new Refusal(404, 'No such conversation.');
470
+ const problem = titleProblem(title);
471
+ if (problem) throw new Refusal(400, problem);
472
+ this.#store.patch(name, { title: title === name ? undefined : title });
473
+ return this.#store.get(name);
474
+ }
475
+
476
+ /* Hidden from the list, like a hidden file: it runs, keeps its rights,
477
+ and is still in Access; the list says how many are hidden. */
478
+ setHidden(name, on) {
479
+ const c = this.#store.get(name);
480
+ if (!c) throw new Refusal(404, 'No such conversation.');
481
+ if (typeof on !== 'boolean') throw new Refusal(400, 'Say true (hidden from the list) or false (shown).');
482
+ this.#store.patch(name, { hidden: on ? true : undefined });
483
+ return this.#store.get(name);
484
+ }
485
+
453
486
  /* What is dictated into its terminal starts with a microphone sign, once
454
487
  per message (true, the default for a tool that is an AI), or not. */
455
488
  setVoiceMark(name, on) {
package/server/names.mjs CHANGED
@@ -8,3 +8,34 @@ const NAME = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
8
8
  export function isConversationName(value) {
9
9
  return typeof value === 'string' && value.length <= NAME_MAX && NAME.test(value);
10
10
  }
11
+
12
+ /* THE NAME A PERSON READS is free: spaces, capitals, accents, emoji. It never
13
+ reaches a command line (the identifier above does), and the page shows it
14
+ as text only. Refused: what cannot be seen or reorders what is seen
15
+ (control characters, line breaks, zero-width and bidirectional marks), and
16
+ spaces at either end, which would make two names look alike. */
17
+ export const TITLE_MAX = 60;
18
+ const UNSEEN_RANGES = [[0x0, 0x1f], [0x7f, 0x9f], [0xad, 0xad], [0x61c, 0x61c], [0x180e, 0x180e], [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x206f], [0xfeff, 0xfeff], [0xfff9, 0xfffb]];
19
+ const UNSEEN = new RegExp(`[${UNSEEN_RANGES.map(([a, b]) => `\\u{${a.toString(16)}}-\\u{${b.toString(16)}}`).join('')}]`, 'u');
20
+
21
+ /* The reason it is refused, or '' when it may be a name. */
22
+ export function titleProblem(value) {
23
+ if (typeof value !== 'string' || value === '') return 'Give a name.';
24
+ if (UNSEEN.test(value)) return 'A name has no line breaks and no invisible or direction characters.';
25
+ if (value.trim() !== value) return 'A name does not start or end with a space.';
26
+ if ([...value].length > TITLE_MAX) return `A name is ${TITLE_MAX} characters at most.`;
27
+ return '';
28
+ }
29
+
30
+ /* The identifier drawn from a name: "Location voiture" -> location-voiture;
31
+ one taken -> location-voiture-2. `taken(id)` says whether it is. */
32
+ export function identifierFrom(title, taken = () => false) {
33
+ let base = String(title ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '');
35
+ if (!base) base = 'conversation';
36
+ for (let n = 1; ; n += 1) {
37
+ const tail = n === 1 ? '' : `-${n}`;
38
+ const id = `${base.slice(0, NAME_MAX - tail.length).replace(/-+$/, '')}${tail}`;
39
+ if (isConversationName(id) && !taken(id)) return id;
40
+ }
41
+ }
@@ -89,7 +89,7 @@ export class Toolbox {
89
89
  const sha256 = createHash('sha256').update(bytes).digest('hex');
90
90
  const there = existsSync(`${dir}/${file}`) ? createHash('sha256').update(readFileSync(`${dir}/${file}`)).digest('hex') : '';
91
91
  if (there !== sha256) { writeFileSync(`${dir}/.${file}.new`, bytes); renameSync(`${dir}/.${file}.new`, `${dir}/${file}`); }
92
- const version = /-(\d+\.\d+\.\d+)\.tgz$/.exec(file)?.[1] ?? '';
92
+ const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\.tgz$/.exec(file)?.[1] ?? '';
93
93
  tools.push({ name: source.name, version, file, sha256, usage: source.usage(`${TOOLS_PATH}/${file}`) });
94
94
  }
95
95
  const kept = new Set([...tools.map((t) => t.file), 'index.json', ...(this.#notes ? [NOTES] : [])]);
package/web/app.js CHANGED
@@ -650,8 +650,8 @@
650
650
  function enter(keepSheets = false) {
651
651
  if (welcoming) return;
652
652
  const c = conversations.find((x) => x.name === current);
653
- $('name').textContent = current || 'No conversation';
654
- document.title = current ? `${current} - Clavarium` : 'Clavarium';
653
+ $('name').textContent = (c ? shownName(c) : current) || 'No conversation';
654
+ document.title = current ? `${c ? shownName(c) : current} - Clavarium` : 'Clavarium';
655
655
  $('tool').hidden = !c; $('tool').textContent = c?.toolLabel ?? '';
656
656
  paintActions();
657
657
  $('copy-open').hidden = !current || (home && (!c || c.phase !== 'ready'));
@@ -731,10 +731,20 @@
731
731
  if (!r.ok) throw Object.assign(new Error(j.error || 'The request failed.'), { kind: j.kind, retry: j.retry === true });
732
732
  return j;
733
733
  };
734
+ /* The name a person reads; a conversation made before free names is read by its identifier. */
735
+ const shownName = (c) => (c && typeof c.title === 'string' && c.title ? c.title : c?.name ?? '');
736
+ /* It needs its owner: it asks or waits, its answerer asks, it failed, its machine is unwell. Said even when it is hidden. */
737
+ const needsYou = (c) => Boolean((c.talk && (c.talk.waiting > 0 || c.talk.state === 'waiting')) || c.answerer?.needs || c.phase === 'failed'
738
+ || (c.phase === 'ready' && !c.asleep && ['warn', 'bad'].includes(c.health?.level)));
739
+ /* Hidden conversations are shown on a click on "N hidden", for this page only. */
740
+ let showHidden = false;
734
741
  function paint() {
735
742
  const items = $('items');
736
743
  items.replaceChildren();
744
+ const hidden = conversations.filter((c) => c.hidden === true);
737
745
  for (const c of conversations) {
746
+ /* The one open stays in sight, hidden or not. */
747
+ if (c.hidden === true && !showHidden && c.name !== current) continue;
738
748
  const b = document.createElement('button');
739
749
  b.type = 'button'; b.className = 'item'; b.dataset.phase = c.phase;
740
750
  /* Ready, but its tool not there yet: it still reads as under way. */
@@ -742,7 +752,9 @@
742
752
  if (c.name === current) b.setAttribute('aria-current', 'true');
743
753
  const title = document.createElement('b');
744
754
  const dot = document.createElement('span'); dot.className = 'dot';
745
- title.append(dot, c.name);
755
+ title.append(dot, shownName(c));
756
+ if (c.title) b.title = c.name;
757
+ if (c.hidden === true) b.dataset.hidden = 'true';
746
758
  const sub = document.createElement('small');
747
759
  /* Under way: the step the panel shows, in its words (the tool named once). */
748
760
  const under = c.progress && ['building', 'restarting', 'ready'].includes(c.phase) ? stepNow(c.progress, c.toolLabel) : '';
@@ -761,6 +773,14 @@
761
773
  b.addEventListener('click', () => select(c.name));
762
774
  items.append(b);
763
775
  }
776
+ const toggle = $('hidden-toggle');
777
+ toggle.hidden = DEVICE || hidden.length === 0;
778
+ toggle.textContent = showHidden ? `Hide ${hidden.length} hidden` : `${hidden.length} hidden`;
779
+ toggle.setAttribute('aria-expanded', String(showHidden));
780
+ const calling = hidden.some(needsYou);
781
+ toggle.dataset.needs = String(calling);
782
+ toggle.title = calling ? 'A hidden conversation needs you' : '';
783
+ if (calling && !showHidden) { const dot = document.createElement('span'); dot.className = 'needs-dot'; dot.setAttribute('aria-label', 'one needs you'); toggle.append(' ', dot); }
764
784
  $('empty').hidden = conversations.length > 0;
765
785
  paintDeviceCount();
766
786
  }
@@ -832,16 +852,21 @@
832
852
  $('create').hidden = false;
833
853
  focusField($('create-name'));
834
854
  }
855
+ $('hidden-toggle').addEventListener('click', () => { showHidden = !showHidden; paint(); });
835
856
  $('new').addEventListener('click', () => { void openCreate(); });
836
857
  $('create-cancel').addEventListener('click', () => { $('create').hidden = true; });
858
+ /* The identifier drawn from the name, as the server draws it (names.mjs), shown before it is made; the server decides. */
859
+ const identifierOf = (title) => String(title).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 32).replace(/-+$/, '') || 'conversation';
860
+ $('create-name').addEventListener('input', () => { $('create-id').placeholder = identifierOf($('create-name').value.trim()); });
837
861
  $('create').addEventListener('submit', async (event) => {
838
862
  event.preventDefault();
839
- const name = $('create-name').value.trim();
863
+ const title = $('create-name').value.trim();
864
+ const id = $('create-id').value.trim();
840
865
  try {
841
- await post(`${API}/conversations`, { name, tool: chosenTool, workOnDisk: $('create-disk').checked });
842
- $('create').hidden = true; $('create-name').value = ''; $('create-disk').checked = false;
866
+ const made = await post(`${API}/conversations`, { title, ...(id ? { name: id } : {}), tool: chosenTool, workOnDisk: $('create-disk').checked });
867
+ $('create').hidden = true; $('create-name').value = ''; $('create-id').value = ''; $('create-disk').checked = false;
843
868
  await refresh();
844
- select(name);
869
+ select(made.name);
845
870
  } catch (error) { $('create-error').textContent = error.message; $('create-error').hidden = false; }
846
871
  });
847
872
 
@@ -1380,7 +1405,7 @@
1380
1405
  const facts = $('general-facts'); facts.replaceChildren();
1381
1406
  const fact = (term, value) => { const dt = document.createElement('dt'); dt.textContent = term; const dd = document.createElement('dd'); dd.textContent = value; facts.append(dt, dd); };
1382
1407
  if (here) {
1383
- fact('Name', here.name);
1408
+ fact('Identifier', here.name);
1384
1409
  fact('Tool', here.toolLabel ?? here.tool);
1385
1410
  if (here.createdAt) fact('Created', new Date(here.createdAt).toLocaleString());
1386
1411
  fact('Machine', `${sizeWords(here.memoryMb)} of memory, ${here.diskGb ? `${here.diskGb} GB` : 'an unknown size of'} disk`);
@@ -1740,6 +1765,9 @@
1740
1765
  if (DEVICE) { $('talk-box').hidden = true; $('voice-mark-box').hidden = true; return; }
1741
1766
  const here = conversations.find((x) => x.name === current);
1742
1767
  if (document.activeElement !== $('chat-name')) $('chat-name').value = here?.chatName ?? '';
1768
+ if (document.activeElement !== $('title-name')) $('title-name').value = shownName(here);
1769
+ $('title-box').hidden = $('hide-box').hidden = DEVICE || !here;
1770
+ for (const b of $('hide-on').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.hidden === true)));
1743
1771
  $('voice-mark-box').hidden = !here?.ai;
1744
1772
  for (const b of $('voice-mark').children) b.setAttribute('aria-pressed', String((b.dataset.on === 'true') === (here?.voiceMark === true)));
1745
1773
  await readTalk();
@@ -1758,6 +1786,19 @@
1758
1786
  catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
1759
1787
  });
1760
1788
  }
1789
+ $('title-form').addEventListener('submit', async (event) => {
1790
+ event.preventDefault();
1791
+ $('panel-error').hidden = true;
1792
+ try { await post(`${API}/conversations/${encodeURIComponent(current)}/title`, { title: $('title-name').value.trim() }); $('title-name').blur(); await refresh(); enter(true); }
1793
+ catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
1794
+ });
1795
+ for (const b of $('hide-on').children) {
1796
+ b.addEventListener('click', async () => {
1797
+ $('panel-error').hidden = true;
1798
+ try { await post(`${API}/conversations/${encodeURIComponent(current)}/hidden`, { on: b.dataset.on === 'true' }); await refresh(); for (const x of $('hide-on').children) x.setAttribute('aria-pressed', String(x === b)); }
1799
+ catch (error) { $('panel-error').textContent = error.message; $('panel-error').hidden = false; }
1800
+ });
1801
+ }
1761
1802
  $('chat-name-form').addEventListener('submit', async (event) => {
1762
1803
  event.preventDefault();
1763
1804
  $('panel-error').hidden = true;
@@ -2092,7 +2133,7 @@
2092
2133
  }
2093
2134
  for (const c of ready.filter((x) => x.name !== accessFor)) {
2094
2135
  const line = document.createElement('div'); line.className = 'given-row'; line.dataset.conversation = c.name;
2095
- const who = document.createElement('span'); who.textContent = c.name;
2136
+ const who = document.createElement('span'); who.textContent = shownName(c);
2096
2137
  const options = kind === 'mailbox' ? [['none', 'Off'], ['read', 'Read']] : [['none', 'Off'], ['read', 'Read'], ['write', 'Write']];
2097
2138
  line.append(who, modes(options, resource.attachments?.[c.name] ?? 'none', async (access) => { await attachTo(c.name, resource.name, access, 'assets-error'); await paintAssets(); }, `What ${c.name} may do with ${resource.name}`));
2098
2139
  inside.append(line);
@@ -2247,7 +2288,7 @@
2247
2288
  const inside = document.createElement('div'); inside.className = 'asset-inside';
2248
2289
  for (const c of ready.filter((x) => x.name !== accessFor)) {
2249
2290
  const line = document.createElement('div'); line.className = 'given-row'; line.dataset.conversation = c.name;
2250
- const who = document.createElement('span'); who.textContent = c.name;
2291
+ const who = document.createElement('span'); who.textContent = shownName(c);
2251
2292
  line.append(who, modes([['off', 'Off'], ['use', 'Use']], secret.conversations.includes(c.name) ? 'use' : 'off', async (value) => { await handTo(c.name, secret.name, value === 'use', 'assets-error'); await paintAssets(); }, `Whether the tool of ${c.name} is handed ${secret.name}`));
2252
2293
  inside.append(line);
2253
2294
  }
package/web/index.html CHANGED
@@ -15,7 +15,12 @@
15
15
  <div class="top"><b>Conversations</b><button type="button" id="new" class="primary">+ New</button></div>
16
16
  <form id="create" hidden autocomplete="off">
17
17
  <label for="create-name">Name</label>
18
- <input id="create-name" name="name" required maxlength="32" pattern="[a-z]([a-z0-9-]{0,30}[a-z0-9])?" title="Lower-case letters, digits and hyphens; starts with a letter, does not end with a hyphen" placeholder="website-redesign" spellcheck="false" autocapitalize="none">
18
+ <input id="create-name" name="name" required maxlength="60" placeholder="Website redesign" spellcheck="false">
19
+ <details class="advanced"><summary>Advanced options</summary>
20
+ <label for="create-id">Identifier</label>
21
+ <input id="create-id" maxlength="32" pattern="[a-z]([a-z0-9\-]{0,30}[a-z0-9])?" title="Lower-case letters, digits and hyphens; starts with a letter, does not end with a hyphen" placeholder="website-redesign" spellcheck="false" autocapitalize="none">
22
+ <p class="hint">Used for its machine, its folders, its address and what is given to it. Drawn from the name when empty; it cannot be changed later. The name can.</p>
23
+ </details>
19
24
  <span class="legend">What runs in it</span>
20
25
  <div id="create-tools" class="choices" role="radiogroup" aria-label="What runs in it"></div>
21
26
  <label class="check"><input type="checkbox" id="create-disk"> Work on the machine's own disk</label>
@@ -25,6 +30,8 @@
25
30
  <span class="hint">a new machine takes a few minutes</span></div>
26
31
  </form>
27
32
  <div id="items"></div>
33
+ <!-- Hidden conversations still run; this shows them, and says when one needs you. -->
34
+ <button type="button" id="hidden-toggle" class="inline" hidden aria-expanded="false"></button>
28
35
  <p id="empty" class="hint" hidden>No conversation yet. A conversation is a machine of its own with one tool running in it.</p>
29
36
  <!-- Shown only when this server is short of disk or memory: opens Settings, This server. -->
30
37
  <button type="button" id="server-gauge" hidden></button>
@@ -104,6 +111,13 @@
104
111
  <summary><span class="chapter-title">General</span><span class="chapter-about">What it is, and how you talk with it.</span></summary>
105
112
  <div class="chapter-body">
106
113
  <dl id="general-facts" class="facts"></dl>
114
+ <div id="title-box" hidden>
115
+ <form id="title-form" class="row" autocomplete="off"><label for="title-name">Name</label><input id="title-name" maxlength="60" spellcheck="false"><button type="submit">Save</button></form>
116
+ </div>
117
+ <div id="hide-box" hidden>
118
+ <div class="row"><span>In the list</span><div class="levels" id="hide-on" role="group" aria-label="Hidden from the list"><button type="button" data-on="false">Shown</button><button type="button" data-on="true">Hidden</button></div></div>
119
+ <p class="hint">Hidden, it runs as before, keeps what it was given and stays in Access; "hidden" at the bottom of the list shows it.</p>
120
+ </div>
107
121
  <!-- WHAT ITS TOOL IS TOLD ABOUT WHERE IT IS: the exact text, made by the
108
122
  server from these settings; the owner's own lines; told again on a click. -->
109
123
  <div id="guide-box">
package/web/style.css CHANGED
@@ -1010,3 +1010,10 @@ button.inline:hover { color: var(--brass); }
1010
1010
  #server-gauge [data-level="bad"] .gauge-bar > span { background: var(--danger); }
1011
1011
  #server-gauge .gauge-n { color: var(--ink2); font-variant-numeric: tabular-nums; white-space: nowrap; }
1012
1012
  #server-gauge [data-level="ok"] .gauge-word { color: var(--ink2); }
1013
+ /* Hidden conversations: a quiet link at the bottom of the list; a dot when one of them needs you. */
1014
+ #hidden-toggle { display: block; margin: .5rem .75rem; color: var(--ink2); font-size: .8rem; }
1015
+ #hidden-toggle[hidden] { display: none; }
1016
+ #hidden-toggle .needs-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--brass); vertical-align: middle; }
1017
+ .item[data-hidden="true"] { opacity: .7; }
1018
+ #create .advanced { margin: .25rem 0; }
1019
+ #create .advanced summary { cursor: pointer; color: var(--ink2); font-size: .8rem; }