clavarium 0.2.366-next.1 → 0.2.367-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,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.366-next.1",
3
+ "version": "0.2.367-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.366-next.1",
9
+ "version": "0.2.367-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.366-next.1",
3
+ "version": "0.2.367-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.",
package/server/api.mjs CHANGED
@@ -94,6 +94,7 @@
94
94
  * GET /api/rooms the rooms (#268): [{id, name, host, members: [{id, kind, name, ref, host?}], unread, last, thinking: [names]}]
95
95
  * POST /api/rooms {name, conversations: [names], boxes: [ids], host?}: a new room
96
96
  * POST /api/rooms/<id> {name?, host?, add?: {conversations, boxes}, remove?: [member ids]}
97
+ * quiet?: true/false (muted in the list: no dot, at the bottom)
97
98
  * POST /api/rooms/<id>/remove the room and its thread
98
99
  * POST /api/rooms/<id>/give {asset, rights, shared?}: the same right given to each AI of it, its own
99
100
  * conversation's -> {asset, rights, given: [{conversation, ok, kept?, error?}]}
package/server/rooms.mjs CHANGED
@@ -100,7 +100,7 @@ export class Rooms {
100
100
  in its row of the list and on its bar, « Hugo réfléchit… » in its thread. */
101
101
  #thinkingOf(id, members) { return [...(this.#thinking.get(id) ?? new Map()).entries()].filter(([, v]) => Date.now() - v.at < THINKING_MS).map(([mid]) => members.find((m) => m.id === mid)?.name).filter(Boolean); }
102
102
  #doingOf(id, members) { return [...(this.#thinking.get(id) ?? new Map()).entries()].filter(([, v]) => v.text && Date.now() - v.at < THINKING_MS).map(([mid, v]) => ({ name: members.find((m) => m.id === mid)?.name, text: v.text })).filter((x) => x.name); }
103
- list() { return this.#all().map((r) => { const members = this.#shown(r); return { id: r.id, name: r.name, host: r.host, members, at: r.at, unread: this.#unread(r), last: this.#last(r), thinking: this.#thinkingOf(r.id, members) }; }); }
103
+ list() { return this.#all().map((r) => { const members = this.#shown(r); return { id: r.id, name: r.name, host: r.host, members, at: r.at, unread: this.#unread(r), last: this.#last(r), thinking: this.#thinkingOf(r.id, members), ...(r.quiet ? { quiet: true } : {}) }; }); }
104
104
  /* ITS AIs, as conversations (06-11): the names of the conversations of its AI members that are there now --
105
105
  never a box, never the person, never one taken out of the room. What « give to all its AIs » gives to. */
106
106
  ais(id) { return this.#shown(this.#find(id)).filter((m) => m.kind === 'ai').map((m) => m.ref); }
@@ -139,10 +139,11 @@ export class Rooms {
139
139
  if (new Set(names).size !== names.length) throw new Refusal(409, 'Two members of a room cannot have the same name: rename one first.');
140
140
  if (room.members.length > MEMBERS_MAX) throw new Refusal(409, `${MEMBERS_MAX} members at most.`);
141
141
  }
142
- /* Its name, its host, members added or taken away. */
143
- change(id, { name, host, add = {}, remove = [] } = {}) {
142
+ /* Its name, its host, members added or taken away; `quiet` (07-11, the tidy-up): muted in the list, or not. */
143
+ change(id, { name, host, add = {}, remove = [], quiet } = {}) {
144
144
  const list = this.#all(); const room = list.find((r) => r.id === id);
145
145
  if (!room) throw new Refusal(404, 'No such room.');
146
+ if (quiet !== undefined) { if (quiet === true) room.quiet = true; else delete room.quiet; }
146
147
  if (name !== undefined) {
147
148
  const n = nameOf(name); if (!n) throw new Refusal(400, 'Give the room a name.');
148
149
  if (list.some((r) => r.id !== id && r.name.toLowerCase() === n.toLowerCase())) throw new Refusal(409, 'A room has that name already.');
@@ -33,7 +33,7 @@ export const SETTINGS_WORDS_FR = {
33
33
  'set-dict-backup': 'Secours en service', 'set-dict-expired': 'Clé expirée', 'set-dict-with-backup': 'avec secours', 'set-dict-server': 'réglée sur le serveur',
34
34
  'set-attached': '{n} joints', 'set-secret': '1 secret', 'set-secrets': '{n} secrets', 'set-offline': 'hors ligne',
35
35
  'set-device': '1 appareil', 'set-devices': '{n} appareils', 'set-no-device': 'Aucun appareil', 'set-chat': 'Chat',
36
- 'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': 'Endormie', 'set-not-looked': 'Pas encore regardée',
36
+ 'set-wakeups-on': '{on} sur {n} actifs', 'set-asleep': 'En veille', 'set-not-looked': 'Pas encore regardée',
37
37
  'set-running-well': 'En marche · rien d\'anormal', 'set-conv-advanced': 'Archiver · remettre à neuf · supprimer',
38
38
  /* New asset, one box (05-11, step 3): one verb per gesture -- Créer, Attacher, Terminé. */
39
39
  'set-na-new': 'Nouvel asset', 'set-na-attach': 'Attacher', 'set-na-there': 'Déjà là', 'set-na-or-new': 'ou un nouveau',
@@ -109,11 +109,15 @@ export const SETTINGS_WORDS_FR = {
109
109
  'set-list-not-filed': 'Sans groupe', 'set-list-asks': 'vous demande', 'set-list-waits': 'vous attend',
110
110
  'set-list-asks-for': 'le répondeur vous demande', 'set-list-answered': 'a répondu à votre place',
111
111
  'set-list-wakeup': '1 réveil', 'set-list-wakeups': '{n} réveils', 'set-list-hidden': 'cachée',
112
- 'set-list-asleep': 'endormie, se réveille à l\'ouverture', 'set-list-starting': 'démarre', 'set-list-restarting': 'redémarre',
112
+ 'set-list-asleep': 'en veille', 'set-list-starting': 'démarre', 'set-list-restarting': 'redémarre',
113
113
  'set-list-moving': 'déplace son travail', 'set-list-failed': 'en échec', 'set-list-you': 'Vous : {text}',
114
114
  'set-list-fewer': 'En montrer moins', 'set-list-more': 'En montrer {n} de plus',
115
115
  'set-list-hide-hidden': 'Masquer les {n} cachées', 'set-list-n-hidden': '{n} cachées',
116
116
  'set-list-tip': 'Rangez-les en groupes : Réglages, Groupe.',
117
+ 'set-idle-h': 'rien depuis {n} h', 'set-idle-d': 'rien depuis {n} j', 'set-idle-w': 'rien depuis {n} sem.', 'set-idle-mo': 'rien depuis {n} mois',
118
+ 'set-tidy-open': 'Faire le ménage ({n})', 'set-tidy-title': 'Mettre en veille ?', 'set-tidy-hint': 'Rien depuis 12 h ou plus.',
119
+ 'set-tidy-go': 'Mettre en veille ({n})', 'set-close': 'Fermer',
120
+ 'set-asleep-page': 'En veille. Elle reprend quand vous la réveillez.', 'set-wake-it': 'Réveiller',
117
121
  'set-as-k-git': 'Dépôts git', 'set-as-k-folder': 'Dossiers', 'set-as-k-mailbox': 'Boîtes mail', 'set-as-k-agenda': 'Agendas',
118
122
  'set-as-k-contacts': 'Contacts', 'set-as-k-secret': 'Secrets', 'set-as-k-list': 'Classeurs', 'set-as-k-acces': 'Accès',
119
123
  'set-as-kind-folder': 'dossier', 'set-as-kind-git': 'dépôt', 'set-as-kind-mailbox': 'boîte mail',
@@ -134,12 +138,14 @@ export const SETTINGS_WORDS_FR = {
134
138
  'set-files-read-only': 'Lecture seule : rien ne peut y être changé.',
135
139
  'set-this-conv': 'Cette conversation · {name}', 'set-ask-first': 'Demander avant d\'agir',
136
140
  'set-online': 'En ligne', 'set-offline-btn': 'Hors ligne', 'set-sleep': 'Endormir',
137
- 'set-room-name': 'Nom', 'set-room-host': 'Accueille le salon', 'set-room-host-what': 'répond, ou passe le message',
138
- 'set-room-host-pick': 'Choisissez ci-dessous : « Accueille »', 'set-room-welcomes': 'Accueille', 'set-room-ais': 'Ses IA',
139
- 'set-room-in': '{n} dans le salon', 'set-room-find': 'Chercher une conversation', 'set-room-boxes': 'Boîtiers vocaux',
141
+ 'set-room-name': 'Nom', 'set-room-welcomes': 'Accueille', 'set-room-ais': 'Ses IA',
142
+ 'set-room-find': 'Chercher une conversation', 'set-room-boxes': 'Boîtiers vocaux',
140
143
  'set-room-remove': 'Supprimer le salon', 'set-room-remove-what': 'Le fil part · les IA et boîtiers restent',
141
144
  'set-room-remove-dots': 'Supprimer…', 'set-room-remove-yes': 'Supprimer {name}', 'set-room-keep': 'Garder',
142
- 'set-room-cancel': 'Annuler', 'set-room-save': 'Enregistrer', 'set-room-create': 'Créer', 'set-room-new': 'Nouveau salon',
145
+ 'set-room-create': 'Créer', 'set-room-new': 'Nouveau salon',
146
+ /* A room's settings as every settings page (07-11): each change saved at once, said a moment in the bar. */
147
+ 'set-room-saved': 'Enregistré', 'set-room-settings-of': '{name} · Réglages', 'set-room-of': '{n} sur {t}',
148
+ 'set-room-host-how': 'Touchez une autre IA du salon pour la déplacer',
143
149
  'set-room-people': 'Personnes', 'set-room-ais-of': 'IA', 'set-room-boxes-of': 'Boîtiers', 'set-room-host-mark': 'accueille',
144
150
  'set-room-n-person': '1 personne', 'set-room-n-people': '{n} personnes', 'set-room-n-ai': '1 IA', 'set-room-n-ais': '{n} IA',
145
151
  'set-room-n-box': '1 boîtier', 'set-room-n-boxes': '{n} boîtiers',
package/web/app.js CHANGED
@@ -433,15 +433,16 @@
433
433
  ? KIT.TopBar({ variant: 'main', title: $('name'), actions: [...old.children].filter((n) => n.id !== 'name') })
434
434
  : KIT.TopBar({ variant: 'main', fold: $('fold'), back: { element: $('back'), label: 'Conversations' }, state: $('state'), title: $('name'), actions: [$('views')] }));
435
435
  }
436
- /* A room (the device page has none): its dot, its name, under it who speaks now and who is in it; its settings
437
- in the same group of views a conversation's settings sit in; its way back, "‹", as a conversation's (kit: 'main'). */
436
+ /* A room (the device page has none): its dot, its name, under it who speaks now (only then); on its right who is
437
+ in it (07-11: small stacked avatars, a click opens the list) then its settings, in the same group of views a
438
+ conversation's settings sit in; one line, as every bar; its way back, "‹", as a conversation's (kit: 'main'). */
438
439
  if (document.getElementById('salon')) {
439
440
  const top = $('salon').querySelector(':scope > .panel-top');
440
441
  const t = $('salon-title'); const name = document.createElement('span'); name.id = 'salon-name'; name.textContent = t.textContent; t.replaceChildren(name);
441
442
  const live = document.createElement('small'); live.id = 'salon-live'; live.className = 'salon-live'; live.hidden = true;
442
443
  const views = document.createElement('div'); views.className = 'views'; views.setAttribute('role', 'group'); views.setAttribute('aria-label', 'Room'); views.append($('salon-settings'));
443
444
  const strip = $('salon').querySelector('.salon-bar');
444
- topBar(top, { variant: 'main', state: { id: 'salon-state' }, sub: [live, $('salon-who')], actions: [views] });
445
+ topBar(top, { variant: 'main', state: { id: 'salon-state' }, sub: [live], actions: [$('salon-who'), views] });
445
446
  strip?.remove();
446
447
  }
447
448
  for (const top of [...document.querySelectorAll('#places .sheet > .panel-top')]) topBar(top, top.parentElement.id === 'salon-new' ? { state: { id: 'salon-new-state' } } : {});
@@ -1068,7 +1069,7 @@
1068
1069
  /* Given its chat and not its terminal: the chat is the whole page, and no terminal is ever opened. */
1069
1070
  if (noTerminal()) { chatAlone(); return; }
1070
1071
  /* Asleep because somebody asked: it stays asleep until somebody asks. */
1071
- if (c && c.asleep === true && !socket) { say('asleep', 'waiting'); show(`Asleep. The tool is frozen as it was.${c.sizeAtWake ? ` When it wakes, its machine is restarted with its new size (${laterWords(c.sizeAtWake)}), and the tool takes up its session.` : ''}`, 'Wake it', connect); mascot('asleep'); return; }
1072
+ if (c && c.asleep === true && !socket) { say('asleep', 'waiting'); show(`${uiSaid('set-asleep-page', 'Asleep. It wakes only when you wake it.')}${c.sizeAtWake ? ` When it wakes, its machine is restarted with its new size (${laterWords(c.sizeAtWake)}), and the tool takes up its session.` : ''}`, uiSaid('set-wake-it', 'Wake it'), connect); mascot('asleep'); return; }
1072
1073
  if (!socket) { terminal.reset(); connect(); }
1073
1074
  void watchSignIn();
1074
1075
  }
@@ -1271,7 +1272,7 @@
1271
1272
  /* Under way: the step the panel shows, in its words (the tool named once). */
1272
1273
  const under = c.progress && ['building', 'restarting', 'ready'].includes(c.phase) ? stepNow(c.progress, c.toolLabel) : '';
1273
1274
  /* What it is doing, when it says something: its tool is not named on every row. */
1274
- const now = under || (holds(c) && c.phase !== 'building' ? (c.progress.kind === 'build' ? uiSaid('set-list-starting', 'starting') : uiSaid('set-list-restarting', 'restarting')) : c.phase === 'ready' ? (c.asleep ? uiSaid('set-list-asleep', 'asleep, wakes when opened') : '') : c.phase === 'building' ? (c.step ?? 'building') : c.phase === 'moving' ? uiSaid('set-list-moving', 'moving its work') : c.phase === 'restarting' ? uiSaid('set-list-restarting', 'restarting') : uiSaid('set-list-failed', 'failed'));
1275
+ const now = under || (holds(c) && c.phase !== 'building' ? (c.progress.kind === 'build' ? uiSaid('set-list-starting', 'starting') : uiSaid('set-list-restarting', 'restarting')) : c.phase === 'ready' ? (c.asleep ? uiSaid('set-list-asleep', 'asleep') : '') : c.phase === 'building' ? (c.step ?? 'building') : c.phase === 'moving' ? uiSaid('set-list-moving', 'moving its work') : c.phase === 'restarting' ? uiSaid('set-list-restarting', 'restarting') : uiSaid('set-list-failed', 'failed'));
1275
1276
  /* The line of its tool only when it says something: under way, asleep, failed -- not "Claude Code" again and again. */
1276
1277
  const plain = c.phase === 'ready' && !c.asleep && !under && !holds(c);
1277
1278
  const chips = [];
@@ -1288,9 +1289,15 @@
1288
1289
  for (const b of boxesNow()) if (b.conversation === c.name) chips.push({ text: b.name, icon: 'box', wide: true });
1289
1290
  /* Its last message. The agent is the usual speaker, so its name is left off (just the words); only
1290
1291
  the person's own last word is marked "You:", which tells you it is your move. */
1291
- const last = told && c.talk?.lastText ? (c.talk.lastFrom === 'person' ? uiSaid('set-list-you', 'You: {text}', { text: c.talk.lastText }) : c.talk.lastText) : '';
1292
+ let last = told && c.talk?.lastText ? (c.talk.lastFrom === 'person' ? uiSaid('set-list-you', 'You: {text}', { text: c.talk.lastText }) : c.talk.lastText) : '';
1292
1293
  const state = rollup(c);
1293
- return { title: shownName(c), state, stateLabel: STATE_WORDS[state], avatar: { name: shownName(c) }, when: told ? whenWords(activityOf(c)) : '', last, now: plain ? '' : now, who: agentWho(c), chips };
1294
+ /* IDLE 12 H OR MORE (07-11): « rien depuis 14 h », in grey, in place of its last words; never on one that
1295
+ waits for you. Asleep: after « asleep ». */
1296
+ const idle = c.phase === 'ready' && !under && !holds(c) ? KIT.idleFor(activityOf(c), { waits: needsYou(c) }) : 0;
1297
+ let said = plain ? '' : now;
1298
+ if (idle && c.asleep) said = `${said} \u00b7 ${KIT.idleWords(idle)}`;
1299
+ else if (idle && !said) { said = KIT.idleWords(idle); last = ''; }
1300
+ return { title: shownName(c), state, stateLabel: STATE_WORDS[state], avatar: { name: shownName(c) }, when: told ? whenWords(activityOf(c)) : '', last, now: said, idle: Boolean(idle && !c.asleep && said), who: agentWho(c), chips };
1294
1301
  }
1295
1302
  /* `copy`: a row of Needs you or Recent, beside its place in its group: it is not moved by hand.
1296
1303
  `told`: it says what was said last, and when (a copy always; the one list when there is no group). */
@@ -1336,9 +1343,18 @@
1336
1343
  try { roomsOnly = localStorage.getItem('clavarium-list-show') === 'rooms'; } catch { /* a private window */ }
1337
1344
  /* A ROOM, drawn as a conversation (kit.js ConvCard): its sign for an initial, its last words or who speaks,
1338
1345
  when; its point: new words waiting (the colour of what waits for you), else at work while one speaks, else fine. */
1346
+ /* A ROOM MUTED by a tidy-up (07-11): no point, at the bottom of the rooms -- until new words come, or it is opened. */
1347
+ const roomMuted = (r) => r.quiet === true && !(r.unread > 0) && thinkingOf(r).length === 0;
1348
+ const roomAt = (r) => Date.parse(r.last?.at ?? r.at ?? '') || 0;
1339
1349
  function roomModel(r) {
1340
1350
  const who = thinkingOf(r);
1341
1351
  const sign = document.createElement('span'); sign.className = 'room-sign';
1352
+ const muted = roomMuted(r);
1353
+ const idle = KIT.idleFor(roomAt(r), { waits: r.unread > 0 || who.length > 0 });
1354
+ if (muted || idle) {
1355
+ const words = [muted ? uiSaid('set-list-asleep', 'asleep') : '', idle ? KIT.idleWords(idle) : ''].filter(Boolean).join(' \u00b7 ');
1356
+ return { title: r.name, state: muted ? 'sleep' : 'ok', stateLabel: muted ? STATE_WORDS.sleep : 'Room', avatar: { name: r.name, glyph: sign }, last: '', when: r.last ? whenWords(Date.parse(r.last.at)) : '', now: words, idle: !muted, chips: [] };
1357
+ }
1342
1358
  const state = r.unread > 0 ? 'wait' : who.length ? 'work' : 'ok';
1343
1359
  const chips = [];
1344
1360
  if (r.unread > 0) chips.push({ text: r.unread > 99 ? '99+' : String(r.unread), tone: 'count', title: `${r.unread} new` });
@@ -1356,9 +1372,14 @@
1356
1372
  const b = KIT.ConvCard(roomModel(r), { size: 'row', cls: 'item room-item' }); b.dataset.room = r.id;
1357
1373
  /* One of its AIs speaking now: said instead of its last message (a conversation at work: its dot, gold). */
1358
1374
  if (thinkingOf(r).length) b.dataset.live = 'true';
1375
+ if (roomMuted(r)) b.dataset.asleep = 'true';
1359
1376
  if (r.last) b.dataset.when = 'true';
1360
1377
  if (r.id === roomOpenNow()) b.setAttribute('aria-current', 'true');
1361
- b.addEventListener('click', () => { void openSalon(r.id, true, { step: true }); });
1378
+ b.addEventListener('click', () => {
1379
+ /* Opened: a muted room is a room again (it has no machine to wake). */
1380
+ if (r.quiet) { r.quiet = false; void post(`${API}/rooms/${r.id}`, { quiet: false }).catch(() => {}); }
1381
+ void openSalon(r.id, true, { step: true });
1382
+ });
1362
1383
  return b;
1363
1384
  }
1364
1385
  /* What was just unfolded comes in gently, once (the next paints show it as it is). */
@@ -1413,6 +1434,66 @@
1413
1434
  paint();
1414
1435
  });
1415
1436
  }
1437
+ /* WHAT A TIDY-UP OFFERS: the conversations and rooms idle 12 h or more, neither waiting for you nor asleep
1438
+ already, nor hidden, nor under way; the longest idle first (kit.js tidyList). */
1439
+ const tidyNow = () => KIT.tidyList([
1440
+ ...conversations.filter((c) => c.phase === 'ready' && !holds(c)).map((c) => ({ kind: 'conversation', id: c.name, name: shownName(c), at: activityOf(c), waits: needsYou(c), asleep: c.asleep === true, hidden: c.hidden === true })),
1441
+ ...roomsNow().map((r) => ({ kind: 'room', id: r.id, name: r.name, at: roomAt(r), waits: r.unread > 0 || thinkingOf(r).length > 0, asleep: r.quiet === true })),
1442
+ ]);
1443
+ /* « Mettre en veille ? » (07-11): one question, the list all ticked (untick to keep one), one button. A
1444
+ conversation is put to sleep (its machine too: it wakes only when you open it and touch « Wake it »);
1445
+ a room is muted in the list. No undo: a machine asleep takes a while to come back. */
1446
+ function openTidy() {
1447
+ document.getElementById('tidy')?.remove();
1448
+ const list = tidyNow(); if (!list.length) return;
1449
+ const wrap = document.createElement('div'); wrap.className = 'lk-sheet-wrap tidy-wrap'; wrap.id = 'tidy';
1450
+ const box = document.createElement('div'); box.className = 'lk-sheet tidy'; box.setAttribute('role', 'dialog'); box.setAttribute('aria-modal', 'true'); box.setAttribute('aria-labelledby', 'tidy-title');
1451
+ const top = document.createElement('div'); top.className = 'lk-sheet-top';
1452
+ const title = document.createElement('b'); title.id = 'tidy-title'; title.className = 'lk-tx'; title.textContent = uiSaid('set-tidy-title', 'Put to sleep?');
1453
+ const x = document.createElement('button'); x.type = 'button'; x.className = 'icon-btn'; x.textContent = '\u00d7'; x.setAttribute('aria-label', uiSaid('set-close', 'Close'));
1454
+ top.append(title, x);
1455
+ const hint = document.createElement('p'); hint.className = 'hint'; hint.textContent = uiSaid('set-tidy-hint', 'Nothing for 12 h or more.');
1456
+ const rows = document.createElement('div'); rows.className = 'tidy-list';
1457
+ const go = document.createElement('button'); go.type = 'button'; go.className = 'primary tidy-go';
1458
+ const said = document.createElement('p'); said.className = 'hint tidy-said'; said.hidden = true;
1459
+ const boxes = list.map((t) => {
1460
+ const row = document.createElement('label'); row.className = 'tidy-row'; row.dataset.kind = t.kind; row.dataset.id = t.id;
1461
+ const tick = document.createElement('input'); tick.type = 'checkbox'; tick.checked = true;
1462
+ const name = document.createElement('span'); name.className = 'tidy-name';
1463
+ name.append(Object.assign(document.createElement('b'), { textContent: t.name }), document.createTextNode(` \u2014 ${KIT.idleWords(t.idle)}`));
1464
+ row.append(tick, name); rows.append(row);
1465
+ tick.addEventListener('change', count);
1466
+ return { t, tick };
1467
+ });
1468
+ function count() { const n = boxes.filter((b) => b.tick.checked).length; go.disabled = n === 0; go.textContent = uiSaid('set-tidy-go', 'Put to sleep ({n})', { n }); }
1469
+ count();
1470
+ const close = () => { wrap.remove(); document.removeEventListener('keydown', esc, true); };
1471
+ const esc = (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); shut(); } };
1472
+ const shut = () => { if (!nav.close('tidy')) close(); };
1473
+ x.addEventListener('click', shut);
1474
+ wrap.addEventListener('click', (event) => { if (event.target === wrap) shut(); });
1475
+ document.addEventListener('keydown', esc, true);
1476
+ go.addEventListener('click', async () => {
1477
+ const chosen = boxes.filter((b) => b.tick.checked).map((b) => b.t);
1478
+ go.disabled = true; for (const b of boxes) b.tick.disabled = true;
1479
+ if (chosen.some((t) => t.kind === 'conversation' && t.id === current)) disconnect();
1480
+ const done = await Promise.allSettled(chosen.map((t) => (t.kind === 'room'
1481
+ ? post(`${API}/rooms/${t.id}`, { quiet: true })
1482
+ : post(`${API}/conversations/${encodeURIComponent(t.id)}/sleep`))));
1483
+ const failed = done.map((r, i) => (r.status === 'rejected' ? `${chosen[i].name}: ${r.reason?.message ?? ''}` : '')).filter(Boolean);
1484
+ await refresh().catch(() => {});
1485
+ try { await loadRooms(); } catch { /* read again later */ }
1486
+ if (chosen.some((t) => t.kind === 'conversation' && t.id === current)) enter();
1487
+ paint();
1488
+ if (failed.length) { said.hidden = false; said.textContent = failed.join(' \u00b7 '); count(); for (const b of boxes) b.tick.disabled = false; return; }
1489
+ shut();
1490
+ });
1491
+ box.append(top, hint, rows, go, said);
1492
+ wrap.append(box);
1493
+ document.body.append(wrap);
1494
+ nav.push('tidy', () => close());
1495
+ go.focus();
1496
+ }
1416
1497
  function paint() {
1417
1498
  if (listHeld) { paintLater = true; return; }
1418
1499
  /* The groups, folded as a whole or not (a search shows them all). */
@@ -1464,7 +1545,7 @@
1464
1545
  if (openOne && !inSight.includes(openOne)) inSight.push(openOne);
1465
1546
  const few = inSight.length;
1466
1547
  /* The rooms used in the last day, among them by when (03-11: « dans le récent, indifféremment »). */
1467
- const recentRooms = roomsNow().filter((r) => r.last && Date.parse(r.last.at) >= dayAgo);
1548
+ const recentRooms = roomsNow().filter((r) => r.last && Date.parse(r.last.at) >= dayAgo && !roomMuted(r));
1468
1549
  const rowsOfRecent = (list) => [...list.map((c) => ({ at: activityOf(c), el: () => itemButton(c, true) })), ...recentRooms.map((r) => ({ at: Date.parse(r.last.at), el: () => roomItem(r) }))].sort((a, b) => b.at - a.at).map((x) => x.el());
1469
1550
  if ((recent.length || recentRooms.length) && groups.length && section(RECENT, uiSaid('set-list-recent', 'Recent'), recent)) {
1470
1551
  top.append(...unfolding(RECENT, rowsOfRecent(showAllRecent ? recent : recent.filter((c) => inSight.includes(c)))));
@@ -1503,7 +1584,7 @@
1503
1584
  }
1504
1585
  if (groupsOpen && !onlyRooms) unfolding(GROUPS, [...items.children]);
1505
1586
  /* ROOMS (03-11): their own list, after the groups of conversations; a search finds them by name. */
1506
- const roomsShown = roomsNow().filter((r) => !query || r.name.toLowerCase().includes(String(query).toLowerCase()));
1587
+ const roomsShown = roomsNow().filter((r) => !query || r.name.toLowerCase().includes(String(query).toLowerCase())).sort((a, b) => Number(roomMuted(a)) - Number(roomMuted(b)));
1507
1588
  if (!DEVICE && roomsShown.length) {
1508
1589
  const b = document.createElement('button'); b.type = 'button'; b.className = 'list-head list-fold'; b.dataset.section = 'rooms';
1509
1590
  const open = onlyRooms || !folded.has(ROOMS); b.setAttribute('aria-expanded', String(open));
@@ -1513,6 +1594,16 @@
1513
1594
  items.append(b);
1514
1595
  if (open) items.append(...roomsShown.map((r) => roomItem(r)));
1515
1596
  }
1597
+ /* THE OPEN ONE, MARKED ONCE (07-11): at its first place in the list (À vous, Récent, then its group). */
1598
+ for (const extra of [...top.querySelectorAll('[aria-current="true"]'), ...items.querySelectorAll('[aria-current="true"]')].slice(1)) extra.removeAttribute('aria-current');
1599
+ /* FAIRE LE MÉNAGE (07-11): at the bottom, only when something has been idle 12 h or more. */
1600
+ const tidy = !DEVICE && !query ? tidyNow() : [];
1601
+ if (tidy.length) {
1602
+ const b = document.createElement('button'); b.type = 'button'; b.id = 'tidy-open'; b.className = 'tidy-open';
1603
+ b.textContent = uiSaid('set-tidy-open', 'Tidy up ({n})', { n: tidy.length });
1604
+ b.addEventListener('click', () => openTidy());
1605
+ items.append(b);
1606
+ }
1516
1607
  /* The folders there are, offered where one is chosen. */
1517
1608
  $('group-names').replaceChildren(...groupsInOrder().map((g) => { const o = document.createElement('option'); o.value = g; return o; }));
1518
1609
  const hits = [...items.querySelectorAll('.item')];
@@ -2390,7 +2481,7 @@
2390
2481
  /* All folded when the app is opened: what the person opens stays open while this tab lives (a reload
2391
2482
  included), never from one day to the next -- a page that opens half-unfolded says nothing. What asks
2392
2483
  for them is said on the folded chapter instead (markAttention). */
2393
- const OPEN_AT_FIRST = { 's-features': true };
2484
+ const OPEN_AT_FIRST = { 's-features': true, 'room-ais': true };
2394
2485
  let chapters = {};
2395
2486
  try { chapters = JSON.parse(sessionStorage.getItem('clavarium-chapters') ?? '{}') ?? {}; } catch { /* a private window */ }
2396
2487
  try { localStorage.removeItem('clavarium-chapters'); } catch { /* as above */ }
@@ -5412,11 +5503,11 @@
5412
5503
  if (line) { line.hidden = who.length === 0; line.textContent = who.length ? speaking(who) : ''; }
5413
5504
  }
5414
5505
  let salonAsked = 0;
5415
- /* A room's bar: its name, its state, its members (folded). */
5506
+ /* A room's bar: its name, its state, its members (up to 3 avatars; a click shows the list under the bar). */
5416
5507
  function salonHead(r) {
5417
5508
  $('salon-name').textContent = r.name;
5418
5509
  salonLive(thinkingOf(r));
5419
- $('salon-who').replaceChildren(memberChips(r.members), el('span', 'hint', `${countOf(r.members)} \u25BE`));
5510
+ KIT.MemberStack(r.members, { max: 3, button: $('salon-who'), label: countOf(r.members) });
5420
5511
  $('salon-who').setAttribute('aria-expanded', 'false'); $('salon-members').hidden = true;
5421
5512
  const rows = ['person', 'ai', 'box'].map((k) => { const of = r.members.filter((m) => m.kind === k); if (!of.length) return null; const row = el('div', 'salon-members-row'); row.append(el('span', 'hint', k === 'person' ? uiSaid('set-room-people', 'People') : k === 'ai' ? uiSaid('set-room-ais-of', 'AIs') : uiSaid('set-room-boxes-of', 'Boxes')), ...of.map((m) => { const x = el('span', 'salon-member'); x.append(memberChips([m], 1), document.createTextNode(m.name)); if (m.host) x.append(el('span', 'salon-host', uiSaid('set-room-host-mark', 'host'))); return x; })); return row; }).filter(Boolean);
5422
5513
  $('salon-members').replaceChildren(...rows);
@@ -5503,37 +5594,92 @@
5503
5594
  }
5504
5595
  $('salon-close').addEventListener('click', () => leavePlace());
5505
5596
  $('salon-who').addEventListener('click', () => { const open = $('salon-members').hidden; $('salon-members').hidden = !open; $('salon-who').setAttribute('aria-expanded', String(open)); });
5506
- /* A NEW ROOM, or a room set up again (03-11): the one that welcomes in a box of its own at the top; below, every
5507
- conversation on one row -- its name, the name its assistant has in its chat, its tool -- with a switch (in the
5508
- room or not) and « Welcomes »; a search; a row dragged into the box above welcomes too. Boxes: the same rows,
5509
- with a switch only. `editing`: the room, or null for a new one. */
5597
+ /* A NEW ROOM, or a room's settings (07-11, charte-reglages: as every settings page): folding chapters -- General
5598
+ (its name), Its AIs (every conversation on a row, a switch: in the room or not; the one that welcomes wears
5599
+ « Welcomes », a tap on another of the room moves it), Their assets, Chat & voice (its boxes), Advanced (remove).
5600
+ A room's settings save each change at once (roomPatch), « ✓ Saved » in the bar; a new room keeps its choices
5601
+ here until « Create ». `salonEditing`: the room, or null for a new one. */
5510
5602
  let salonIn = new Set(); let salonHost = ''; let salonBoxes = new Set(); let salonEditing = null; let salonBoxList = [];
5511
- /* Every conversation as a member: the kit's card, its size `member`, its gestures (Welcomes, in the room) at its end. */
5603
+ /* What a room has, read again from it (after each change saved: what the server kept). */
5604
+ const salonTake = (r) => {
5605
+ salonIn = new Set(r ? r.members.filter((m) => m.kind === 'ai').map((m) => m.ref) : []);
5606
+ salonHost = r?.members.find((m) => m.kind === 'ai' && m.host)?.ref ?? '';
5607
+ salonBoxes = new Set(r ? r.members.filter((m) => m.kind === 'box').map((m) => m.ref) : []);
5608
+ };
5609
+ let salonSavedTimer;
5610
+ /* ONE CHANGE, SAVED AT ONCE (a room's settings): its POST, then the room as the server keeps it; « ✓ Saved » a
5611
+ moment, as Settings say it; refused, said under the bar and drawn back as it was. */
5612
+ async function salonSave(change) {
5613
+ const r = salonEditing; if (!r) return;
5614
+ const patch = KIT.roomPatch(r, change);
5615
+ if (!patch) { salonTake(r); paintSalonMembers(); return; }
5616
+ $('salon-new-error').hidden = true;
5617
+ try {
5618
+ const now = await post(`${API}/rooms/${r.id}`, patch);
5619
+ if (salonEditing !== r) return;
5620
+ salonEditing = now && Array.isArray(now.members) ? now : r;
5621
+ const said = $('salon-new-status'); said.textContent = `✓ ${uiSaid('set-room-saved', 'Saved')}`;
5622
+ clearTimeout(salonSavedTimer); salonSavedTimer = setTimeout(() => { said.textContent = ''; }, 2500);
5623
+ if ('name' in patch) { $('salon-new-title').textContent = uiSaid('set-room-settings-of', '{name} · Settings', { name: salonEditing.name }); }
5624
+ void loadRooms().then(() => paint());
5625
+ if ('add' in patch || 'remove' in patch) void paintSalonAssets();
5626
+ } catch (error) { if (salonEditing !== r) return; $('salon-new-error').textContent = error.message; $('salon-new-error').hidden = false; }
5627
+ salonTake(salonEditing); paintSalonMembers();
5628
+ if ('name' in change) $('salon-new-name').value = salonEditing.name;
5629
+ }
5630
+ /* Every conversation as a member: the kit's card, its size `member`, its switch at its end; the one that welcomes,
5631
+ its pill « Welcomes » (the only place it is said). */
5512
5632
  function paintSalonMembers() {
5513
- const hostC = conversations.find((c) => c.name === salonHost);
5514
- const box = $('salon-new-host'); box.replaceChildren();
5515
- if (hostC) box.append(KIT.ConvCard({ ...convModel(hostC), now: uiSaid('set-room-host-what', 'answers, or hands the message on') }, { size: 'member', cls: 'salon-host-what' }));
5516
- else box.append(el('small', 'hint', uiSaid('set-room-host-pick', 'Pick one below: « Welcomes »')));
5633
+ const editing = salonEditing;
5517
5634
  const q = $('salon-new-search').value.trim().toLowerCase();
5518
5635
  const rows = conversations.filter((c) => !q || `${shownName(c)} ${c.chatName ?? ''}`.toLowerCase().includes(q)).sort((a, b) => Number(salonIn.has(b.name)) - Number(salonIn.has(a.name)));
5636
+ const makeHost = (c) => { if (c.name === salonHost || !salonIn.has(c.name)) return; if (editing) { void salonSave({ host: c.name }); return; } salonHost = c.name; paintSalonMembers(); };
5519
5637
  $('salon-new-ais').replaceChildren(...rows.map((c) => {
5520
- const welcome = el('button', 'salon-welcome-btn', uiSaid('set-room-welcomes', 'Welcomes')); welcome.type = 'button'; welcome.setAttribute('aria-pressed', String(c.name === salonHost)); welcome.setAttribute('aria-label', `${shownName(c)} welcomes the room`);
5521
- welcome.addEventListener('click', () => { salonIn.add(c.name); salonHost = c.name; paintSalonMembers(); });
5522
- const sw = KIT.Switch({ on: salonIn.has(c.name), label: `${shownName(c)} in the room`, onChange: (on) => { if (!on) { salonIn.delete(c.name); if (salonHost === c.name) salonHost = [...salonIn][0] ?? ''; } else { salonIn.add(c.name); if (!salonHost) salonHost = c.name; } paintSalonMembers(); } });
5523
- const row = KIT.ConvCard(convModel(c), { size: 'member', cls: 'salon-member-row', actions: [welcome, sw] });
5524
- row.setAttribute('role', 'listitem'); row.draggable = true; row.dataset.name = c.name;
5638
+ const isIn = salonIn.has(c.name); const isHost = c.name === salonHost;
5639
+ const actions = [];
5640
+ if (isHost) { const pill = el('span', 'salon-welcome-pill', uiSaid('set-room-welcomes', 'Welcomes')); pill.setAttribute('aria-label', `${shownName(c)} welcomes the room`); actions.push(pill); }
5641
+ actions.push(KIT.Switch({ on: isIn, label: `${shownName(c)} in the room`, onChange: (on) => {
5642
+ if (editing) { void salonSave({ ai: c.name, on }); return; }
5643
+ if (!on) { salonIn.delete(c.name); if (salonHost === c.name) salonHost = [...salonIn][0] ?? ''; } else { salonIn.add(c.name); if (!salonHost) salonHost = c.name; }
5644
+ paintSalonMembers();
5645
+ } }));
5646
+ const row = KIT.ConvCard(convModel(c), { size: 'member', cls: 'salon-member-row', actions });
5647
+ row.setAttribute('role', 'listitem'); row.dataset.name = c.name;
5648
+ if (isHost) row.dataset.host = 'true';
5649
+ /* Another AI of the room: a tap (Enter) on its row, and it welcomes. */
5650
+ if (isIn && !isHost) {
5651
+ row.tabIndex = 0; row.dataset.pick = 'true'; row.title = uiSaid('set-room-host-how', 'Tap another AI of the room to move it');
5652
+ row.addEventListener('click', (e) => { if (!e.target.closest?.('button')) makeHost(c); });
5653
+ row.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === row) { e.preventDefault(); makeHost(c); } });
5654
+ }
5525
5655
  return row;
5526
5656
  }));
5527
- $('salon-new-count').textContent = `\u00b7 ${uiSaid('set-room-in', '{n} in the room', { n: salonIn.size })}`;
5657
+ const hostC = conversations.find((c) => c.name === salonHost);
5658
+ $('salon-new-count').textContent = [uiSaid('set-room-of', '{n} of {t}', { n: salonIn.size, t: conversations.length }), hostC ? shownName(hostC) : ''].filter(Boolean).join(' · ');
5659
+ $('salon-sum-general').textContent = editing?.name ?? '';
5660
+ const nb = salonBoxes.size;
5661
+ $('salon-sum-voice').textContent = nb ? uiSaid(nb > 1 ? 'set-room-n-boxes' : 'set-room-n-box', nb > 1 ? '{n} boxes' : '1 box', { n: nb }) : uiSaid('set-none', 'None');
5528
5662
  $('salon-new-boxes').replaceChildren(...salonBoxList.map((b) => {
5529
5663
  const row = el('div', 'salon-member-row'); row.setAttribute('role', 'listitem');
5530
5664
  const what = el('span', 'salon-member-what'); what.append(el('b', '', b.name), el('small', '', b.place || uiSaid('set-na-k-box', 'Voice box')));
5531
- const sw = KIT.Switch({ on: salonBoxes.has(b.id), label: `${b.name} in the room`, onChange: (on) => { if (on) salonBoxes.add(b.id); else salonBoxes.delete(b.id); paintSalonMembers(); } });
5665
+ const sw = KIT.Switch({ on: salonBoxes.has(b.id), label: `${b.name} in the room`, onChange: (on) => {
5666
+ if (editing) { void salonSave({ box: b.id, on }); return; }
5667
+ if (on) salonBoxes.add(b.id); else salonBoxes.delete(b.id); paintSalonMembers();
5668
+ } });
5532
5669
  row.append(what, sw);
5533
5670
  return row;
5534
5671
  }));
5535
5672
  }
5536
5673
  $('salon-new-search').addEventListener('input', paintSalonMembers);
5674
+ /* Its name: saved at Enter, or on leaving the field (a room's settings); Escape gives it back. */
5675
+ $('salon-new-name').addEventListener('keydown', (e) => {
5676
+ if (!salonEditing) return;
5677
+ if (e.key === 'Enter') { e.preventDefault(); $('salon-new-name').blur(); }
5678
+ if (e.key === 'Escape') { e.preventDefault(); $('salon-new-name').value = salonEditing.name; $('salon-new-name').blur(); }
5679
+ });
5680
+ $('salon-new-name').addEventListener('change', () => { if (salonEditing) void salonSave({ name: $('salon-new-name').value }); });
5681
+ /* « + … » on a chapter's title: its gesture, never the chapter folded by it -- opened instead. */
5682
+ for (const id of ['salon-assets-all', 'salon-new-box-add']) $(id).addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const ch = $(id).closest('details'); if (ch) ch.open = true; }, true);
5537
5683
  /* EACH AI'S ASSETS (06-11), in a room's settings: what each of its AIs has, as its own conversation has it --
5538
5684
  there is no room asset. « + Give… » opens that conversation's own give; « Give to all its AIs… » gives one
5539
5685
  asset at one right to each (POST /rooms/<id>/give): ordinary rights of each conversation, taken back one by one. */
@@ -5551,6 +5697,8 @@
5551
5697
  /* What its own Assets tab lists, row for row (06-11 showed a part: no calendar, contacts, binder or access). */
5552
5698
  const assetsOf = (name) => givenItems(name, { all, secrets: keys, boxes, tether, lists }).map((a) => ({ key: a.key, kind: a.kind, name: a.name, live: a.live, right: a.right }))
5553
5699
  .sort((a, b) => ASSET_KINDS.findIndex((k) => k[0] === a.kind) - ASSET_KINDS.findIndex((k) => k[0] === b.kind));
5700
+ /* Folded, its chapter says how many each has (« Kitchen helper 9 · Homework 1 »). */
5701
+ $('salon-sum-assets').textContent = ais.map((m) => `${shownName(conversations.find((x) => x.name === m.ref)) || m.name} ${assetsOf(m.ref).length}`).join(' \u00b7 ');
5554
5702
  $('salon-assets').replaceChildren(...ais.map((m) => {
5555
5703
  const c = conversations.find((x) => x.name === m.ref);
5556
5704
  const give = el('button', 'salon-give-btn', uiSaid('set-room-give', '+ Give\u2026')); give.type = 'button';
@@ -5597,7 +5745,7 @@
5597
5745
  }
5598
5746
  $('salon-assets-all').addEventListener('click', () => { salonGive = salonGive ? null : { asset: '', level: '', said: [] }; void paintSalonAssets(); });
5599
5747
  /* A ROOM'S "+ Voice box": the New asset box, its kind a voice box, speaking in this room; once it answers, it is
5600
- among the room's boxes, switched on (kept with Save). */
5748
+ among the room's boxes, switched on (saved at once). */
5601
5749
  function paintSalonBoxNew() {
5602
5750
  const holder = $('salon-box-newasset');
5603
5751
  if (holder.contains($('box-add'))) $('assets-forms').append($('box-add'));
@@ -5614,71 +5762,56 @@
5614
5762
  void getJson('/boxes', null).then((list) => {
5615
5763
  if (!Array.isArray(list)) return;
5616
5764
  salonBoxList = list; boxesKnown = list;
5617
- if (list.some((b) => b.id === boxFlow?.made?.id)) salonBoxes.add(boxFlow.made.id);
5618
- paintSalonMembers();
5765
+ if (list.some((b) => b.id === boxFlow?.made?.id)) void salonSave({ box: boxFlow.made.id, on: true });
5766
+ else paintSalonMembers();
5619
5767
  });
5620
5768
  } });
5621
5769
  paintSalonBoxNew();
5622
5770
  focusFirst($('salon-box-newasset'));
5623
5771
  });
5624
- let salonDragged = '';
5625
- $('salon-new-ais').addEventListener('dragstart', (e) => { salonDragged = e.target.closest?.('.salon-member-row')?.dataset.name ?? ''; });
5626
- $('salon-new-host').addEventListener('dragover', (e) => { e.preventDefault(); $('salon-new-host').classList.add('over'); });
5627
- $('salon-new-host').addEventListener('dragleave', () => $('salon-new-host').classList.remove('over'));
5628
- $('salon-new-host').addEventListener('drop', (e) => { e.preventDefault(); $('salon-new-host').classList.remove('over'); if (salonDragged) { salonIn.add(salonDragged); salonHost = salonDragged; paintSalonMembers(); } });
5629
5772
  async function openSalonNew(push = true, editing = null) {
5630
5773
  if (DEVICE) return;
5631
5774
  salonEditing = editing;
5632
5775
  enterPlace('salon-new', push);
5633
- $('salon-new-title').textContent = editing ? editing.name : uiSaid('set-room-new', 'New room');
5776
+ /* « <name> · Settings », as a conversation's; a room to be made: « New room ». */
5777
+ $('salon-new-title').textContent = editing ? uiSaid('set-room-settings-of', '{name} · Settings', { name: editing.name }) : uiSaid('set-room-new', 'New room');
5634
5778
  /* Its settings say its state as the room's bar does; a room to be made has none yet. */
5635
5779
  const dot = $('salon-new-state'); if (dot) { dot.hidden = !editing; dot.dataset.state = thinkingOf(editing).length ? 'work' : 'ok'; }
5636
- $('salon-new-create').textContent = editing ? uiSaid('set-room-save', 'Save') : uiSaid('set-room-create', 'Create');
5637
- $('salon-new-remove-card').hidden = $('salon-new-advanced-head').hidden = !editing;
5638
- $('salon-new-remove').hidden = false; $('salon-new-remove-card').querySelector('.confirm')?.remove();
5780
+ /* Saved at once: no button at its end; a new room, « Create » (nothing to save before it is there). */
5781
+ $('salon-new-actions').hidden = Boolean(editing);
5782
+ $('salon-new-status').textContent = ''; clearTimeout(salonSavedTimer);
5783
+ $('salon-new-remove-card').hidden = !editing; salonRemoveAsk?.close(); salonRemoveAsk = null;
5639
5784
  $('salon-new-error').hidden = true; $('salon-new-name').value = editing?.name ?? ''; $('salon-new-search').value = '';
5640
- salonIn = new Set(editing ? editing.members.filter((m) => m.kind === 'ai').map((m) => m.ref) : []);
5641
- salonHost = editing?.members.find((m) => m.host)?.ref ?? '';
5642
- salonBoxes = new Set(editing ? editing.members.filter((m) => m.kind === 'box').map((m) => m.ref) : []);
5785
+ salonTake(editing);
5786
+ /* A new room: its name and its AIs open, the rest as the person left it. */
5787
+ if (!editing) for (const id of ['salon-ch-general', 'salon-ch-ais']) $(id).open = true;
5643
5788
  try { const x = await fetch(`${API}/boxes`); salonBoxList = x.ok ? await x.json() : []; } catch { salonBoxList = []; }
5644
5789
  if (Array.isArray(salonBoxList)) boxesKnown = salonBoxList; else salonBoxList = [];
5645
5790
  /* A room that is there already adds a voice box of its own: "+ Voice box". */
5646
- $('salon-new-boxes-card').hidden = $('salon-new-voice-head').hidden = salonBoxList.length === 0 && !editing;
5791
+ $('salon-new-boxes-card').hidden = salonBoxList.length === 0 && !editing;
5647
5792
  $('salon-new-box-add').hidden = !editing; closeSalonBoxNew();
5648
5793
  paintSalonMembers();
5649
5794
  salonGive = null; void paintSalonAssets();
5650
- $('salon-new-name').focus();
5795
+ if (!editing) $('salon-new-name').focus();
5651
5796
  }
5652
5797
  $('salon-settings').addEventListener('click', async () => { await loadRooms(); const r = rooms.find((x) => x.id === salonId); if (r) await openSalonNew(true, r); });
5653
- $('salon-new-remove').addEventListener('click', () => {
5798
+ /* Remove the room: confirmed in place (what goes, what stays), as every settings page does. */
5799
+ let salonRemoveAsk = null;
5800
+ const salonRemove = $('salon-new-remove');
5801
+ salonRemove.addEventListener('click', () => {
5654
5802
  const r = salonEditing; if (!r) return;
5655
- const keep = el('button', '', uiSaid('set-room-keep', 'Keep')); keep.type = 'button';
5656
- const sure = el('button', 'danger', uiSaid('set-room-remove-yes', 'Remove {name}', { name: r.name })); sure.type = 'button';
5657
- const ask = el('span', 'callout bad confirm'); ask.setAttribute('role', 'alertdialog'); ask.append(keep, sure);
5658
- keep.addEventListener('click', () => { ask.remove(); $('salon-new-remove').hidden = false; });
5659
- sure.addEventListener('click', async () => { try { await post(`${API}/rooms/${r.id}/remove`, {}); salonEditing = null; await loadRooms(); paint(); openDeck(false); } catch (error) { $('salon-new-error').textContent = error.message; $('salon-new-error').hidden = false; } });
5660
- $('salon-new-remove').hidden = true; $('salon-new-remove').after(ask); sure.focus();
5803
+ salonRemoveAsk = KIT.InlineConfirm(salonRemove, { question: uiSaid('set-room-remove-what', 'The thread goes · AIs and boxes stay'), yes: uiSaid('set-room-remove-yes', 'Remove {name}', { name: r.name }), keep: uiSaid('set-room-keep', 'Keep'),
5804
+ onYes: async () => { try { await post(`${API}/rooms/${r.id}/remove`, {}); salonEditing = null; await loadRooms(); paint(); openDeck(false); } catch (error) { $('salon-new-error').textContent = error.message; $('salon-new-error').hidden = false; } } });
5661
5805
  });
5662
5806
  /* Back from a room's settings: to the room; from a new one: where it was opened from. */
5663
5807
  const salonNewBack = () => { const r = salonEditing; salonEditing = null; if (r) void openSalon(r.id, false); else leavePlace(); };
5664
5808
  $('salon-new-close').addEventListener('click', salonNewBack);
5665
- $('salon-new-cancel').addEventListener('click', salonNewBack);
5809
+ /* A NEW ROOM: made at « Create », with what was chosen above (its settings, once made, save at once). */
5666
5810
  $('salon-new-create').addEventListener('click', async () => {
5811
+ if (salonEditing) return;
5667
5812
  const picked = [salonHost, ...[...salonIn].filter((c) => c !== salonHost)].filter(Boolean);
5668
- const boxes = [...salonBoxes];
5669
5813
  try {
5670
- /* Set up again: what is switched on now -- members added, those switched off taken out, the one that welcomes. */
5671
- if (salonEditing) {
5672
- const r = salonEditing;
5673
- const ais = r.members.filter((m) => m.kind === 'ai'); const had = r.members.filter((m) => m.kind === 'box');
5674
- const add = { conversations: picked.filter((c) => !ais.some((m) => m.ref === c)), boxes: boxes.filter((b) => !had.some((m) => m.ref === b)) };
5675
- const remove = [...ais.filter((m) => !salonIn.has(m.ref)), ...had.filter((m) => !salonBoxes.has(m.ref))].map((m) => m.id);
5676
- let now = await post(`${API}/rooms/${r.id}`, { name: $('salon-new-name').value, add, remove });
5677
- const host = now.members.find((m) => m.kind === 'ai' && m.ref === salonHost);
5678
- if (host && !host.host) now = await post(`${API}/rooms/${r.id}`, { host: host.id });
5679
- salonEditing = null; await openSalon(r.id, false); return;
5680
- }
5681
- const made = await post(`${API}/rooms`, { name: $('salon-new-name').value, conversations: picked, boxes, host: salonHost });
5814
+ const made = await post(`${API}/rooms`, { name: $('salon-new-name').value, conversations: picked, boxes: [...salonBoxes], host: salonHost });
5682
5815
  await openSalon(made.id, false);
5683
5816
  } catch (error) { $('salon-new-error').textContent = error.message; $('salon-new-error').hidden = false; }
5684
5817
  });
package/web/index.html CHANGED
@@ -715,44 +715,49 @@
715
715
  <div id="salon-thread" class="salon-thread"></div>
716
716
  <p id="salon-thinking" class="salon-thinking" hidden></p>
717
717
  </div>
718
+ <!-- A ROOM'S SETTINGS (07-11, charte-reglages): the chapters of every settings page, folding; a row = its name and
719
+ a short line on the left, one control on the right; each change saved at once (a new room: « Create » at the end). -->
718
720
  <div id="salon-new" class="sheet as-place" hidden>
719
- <div class="panel-top"><b id="salon-new-title">New room</b><button type="button" id="salon-new-close" class="as-link place-back">&lsaquo; Back</button></div>
721
+ <div class="panel-top"><b id="salon-new-title">New room</b><span class="hint salon-saved" id="salon-new-status" role="status"></span><button type="button" id="salon-new-close" class="as-link place-back">&lsaquo; Back</button></div>
720
722
  <div class="nc-body">
721
- <!-- The same names as every settings page (General, Access, Chat & voice, Advanced): a room has no machine. -->
722
- <h3 class="room-group" data-said="set-tab-general">General</h3>
723
- <div class="nc-card">
724
- <div class="set-row"><div class="set-what"><label for="salon-new-name" data-said="set-room-name">Name</label></div><div class="set-control"><input id="salon-new-name" maxlength="60" placeholder="Home" spellcheck="false" autocomplete="off"></div></div>
725
- </div>
726
- <h3 class="room-group" data-said="set-tab-access">Access</h3>
727
- <div class="nc-card">
728
- <span class="sub-head" data-said="set-room-host">Welcomes the room</span>
729
- <div id="salon-new-host" class="salon-host-box" aria-live="polite"></div>
730
- </div>
731
- <div class="nc-card">
732
- <span class="sub-head"><span data-said="set-room-ais">Its AIs</span> <span id="salon-new-count" class="hint"></span></span>
733
- <small class="tip" hidden>The switch puts it in the room or takes it out; « Welcomes » makes it the one that answers first. On a computer, a row can also be dragged into the box above.</small>
734
- <div class="salon-members-tools"><input id="salon-new-search" type="search" placeholder="Find a conversation" data-said-placeholder="set-room-find" aria-label="Find a conversation" autocomplete="off"></div>
735
- <div id="salon-new-ais" class="salon-member-list" role="list" aria-label="AIs"></div>
736
- </div>
737
- <!-- Each AI's assets (06-11): its own, as its conversation has them; no room asset. -->
738
- <div class="nc-card" id="salon-assets-card" hidden>
739
- <div class="row"><span class="sub-head" data-said="set-room-assets">Their assets</span><span class="sp"></span><button type="button" id="salon-assets-all" data-said="set-room-give-all">Give to all its AIs…</button></div>
740
- <div id="salon-assets-give" class="salon-give" hidden></div>
741
- <div id="salon-assets" class="salon-member-list salon-assets-list" role="list" aria-label="Their assets"></div>
742
- </div>
743
- <h3 class="room-group" id="salon-new-voice-head" data-said="set-tab-chat">Chat &amp; voice</h3>
744
- <div class="nc-card" id="salon-new-boxes-card">
745
- <div class="row"><span class="sub-head" data-said="set-room-boxes">Voice boxes</span><span class="sp"></span><button type="button" id="salon-new-box-add" data-said="set-na-plus-box">+ Voice box</button></div>
746
- <div id="salon-box-newasset" hidden></div>
747
- <div id="salon-new-boxes" class="salon-member-list" role="list" aria-label="Voice boxes"></div>
748
- </div>
749
- <h3 class="room-group" id="salon-new-advanced-head" data-said="set-tab-advanced" hidden>Advanced</h3>
750
- <div class="nc-card" id="salon-new-remove-card" hidden>
751
- <div class="set-row"><div class="set-what"><label data-said="set-room-remove">Remove the room</label><small data-said="set-room-remove-what">The thread goes · AIs and boxes stay</small></div><div class="set-control"><button type="button" id="salon-new-remove" class="danger" data-said="set-room-remove-dots">Remove…</button></div></div>
752
- </div>
753
723
  <span id="salon-new-error" class="err" role="alert" hidden></span>
724
+ <details class="chapter" id="salon-ch-general" data-chapter="room-general">
725
+ <summary><span class="chapter-title" data-said="set-tab-general">General</span><span class="chapter-about" id="salon-sum-general"></span></summary>
726
+ <div class="chapter-body">
727
+ <div class="set-row"><div class="set-what"><label for="salon-new-name" data-said="set-room-name">Name</label></div><div class="set-control"><input id="salon-new-name" maxlength="60" placeholder="Home" spellcheck="false" autocomplete="off" enterkeyhint="done"></div></div>
728
+ </div>
729
+ </details>
730
+ <details class="chapter" id="salon-ch-ais" data-chapter="room-ais">
731
+ <summary><span class="chapter-title" data-said="set-room-ais">Its AIs</span><span class="chapter-about" id="salon-new-count"></span></summary>
732
+ <div class="chapter-body">
733
+ <div class="set-row salon-members-tools"><div class="set-what"><small data-said="set-room-host-how">Tap another AI of the room to move it</small></div><div class="set-control"><input id="salon-new-search" type="search" placeholder="Find a conversation" data-said-placeholder="set-room-find" aria-label="Find a conversation" autocomplete="off"></div></div>
734
+ <div id="salon-new-ais" class="salon-member-list" role="list" aria-label="AIs"></div>
735
+ </div>
736
+ </details>
737
+ <!-- Each AI's assets (06-11): its own, as its conversation has them; no room asset. -->
738
+ <details class="chapter" id="salon-assets-card" data-chapter="room-assets" hidden>
739
+ <summary><span class="chapter-title" data-said="set-room-assets">Their assets</span><span class="chapter-about" id="salon-sum-assets"></span><button type="button" id="salon-assets-all" class="chapter-add" data-said="set-room-give-all">Give to all its AIs…</button></summary>
740
+ <div class="chapter-body">
741
+ <div id="salon-assets-give" class="salon-give" hidden></div>
742
+ <div id="salon-assets" class="salon-member-list salon-assets-list" role="list" aria-label="Their assets"></div>
743
+ </div>
744
+ </details>
745
+ <details class="chapter" id="salon-new-boxes-card" data-chapter="room-voice">
746
+ <summary><span class="chapter-title" data-said="set-tab-chat">Chat &amp; voice</span><span class="chapter-about" id="salon-sum-voice"></span><button type="button" id="salon-new-box-add" class="chapter-add" data-said="set-na-plus-box">+ Voice box</button></summary>
747
+ <div class="chapter-body">
748
+ <div id="salon-box-newasset" hidden></div>
749
+ <div id="salon-new-boxes" class="salon-member-list" role="list" aria-label="Voice boxes"></div>
750
+ </div>
751
+ </details>
752
+ <details class="chapter" id="salon-new-remove-card" data-chapter="room-advanced" hidden>
753
+ <summary><span class="chapter-title" data-said="set-tab-advanced">Advanced</span><span class="chapter-about"></span></summary>
754
+ <div class="chapter-body">
755
+ <div class="set-row"><div class="set-what"><span data-said="set-room-remove">Remove the room</span><small data-said="set-room-remove-what">The thread goes · AIs and boxes stay</small></div><div class="set-control"><button type="button" id="salon-new-remove" class="danger" data-said="set-room-remove-dots">Remove…</button></div></div>
756
+ </div>
757
+ </details>
754
758
  </div>
755
- <div class="action-row"><span class="hint" id="salon-new-status"></span><button type="button" id="salon-new-cancel" data-said="set-room-cancel">Cancel</button><button type="button" id="salon-new-create" class="primary">Create</button></div>
759
+ <!-- A new room only: nothing is saved before it exists. -->
760
+ <div class="action-row" id="salon-new-actions"><button type="button" id="salon-new-create" class="primary" data-said="set-room-create">Create</button></div>
756
761
  </div>
757
762
  <div id="assets" class="sheet as-place" hidden>
758
763
  <div class="panel-top"><b>Assets</b></div>
package/web/kit.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * Switch yes / no (role=switch, aria-checked)
8
8
  * Segmented one of a few (pressed buttons, aria-pressed)
9
9
  * InlineConfirm confirm in place of the button: Keep, then the action
10
+ * MemberStack who is in a room: up to 3 small stacked avatars, "+n" (its bar's right); roomPatch, one
11
+ * change of a room's settings as it is saved at once
10
12
  * ConvCard ONE CONVERSATION, three sizes: `row` (the list, Recent), `card` (the home deck),
11
13
  * `member` (a room's members, and the one that welcomes it). Same dot, name, line
12
14
  * of what it does now, chips; each place adds its own gestures (`actions`).
@@ -106,6 +108,25 @@
106
108
  return g;
107
109
  }
108
110
 
111
+ /* IDLE (07-11, the tidy-up): nothing for 12 h or more. Never one that waits for you, never one asleep.
112
+ `idleFor` is how long (0: not idle); `idleWords` says it: « rien depuis 14 h », « … 2 j », « … 3 sem. ». */
113
+ const IDLE_MS = 12 * 3600_000;
114
+ const idleFor = (at, { now = Date.now(), waits = false, asleep = false } = {}) => {
115
+ const t = typeof at === 'number' ? at : Date.parse(at ?? '');
116
+ if (waits || asleep || !Number.isFinite(t) || t <= 0) return 0;
117
+ return now - t >= IDLE_MS ? now - t : 0;
118
+ };
119
+ const idleWords = (ms) => {
120
+ const h = Math.floor(ms / 3600_000); const d = Math.floor(h / 24);
121
+ const [key, en, n] = h < 48 ? ['set-idle-h', 'idle for {n} h', h] : d < 14 ? ['set-idle-d', 'idle for {n} d', d] : d < 60 ? ['set-idle-w', 'idle for {n} wk', Math.floor(d / 7)] : ['set-idle-mo', 'idle for {n} mo', Math.floor(d / 30)];
122
+ return say(key, en).replace('{n}', String(n));
123
+ };
124
+ /* WHAT A TIDY-UP OFFERS: of `items` ({ at, waits, asleep, hidden, ... }), those idle 12 h or more, the
125
+ longest idle first, each with its `idle` (ms). Hidden ones are left alone. */
126
+ const tidyList = (items, now = Date.now()) => items.filter((x) => x && !x.hidden)
127
+ .map((x) => ({ ...x, idle: idleFor(x.at, { now, waits: x.waits, asleep: x.asleep }) }))
128
+ .filter((x) => x.idle > 0).sort((a, b) => b.idle - a.idle);
129
+
109
130
  /* Confirm in place of `button`: the question, Keep (focused), then the action. Escape keeps.
110
131
  Returns { close }. `onYes()` may be async; the action stays pressed till it is done. */
111
132
  function InlineConfirm(button, { question = '', yes = 'Remove', keep, danger = true, onYes = () => {} } = {}) {
@@ -152,7 +173,7 @@
152
173
  const lines = [];
153
174
  if (size === 'row') {
154
175
  const marks = Chips((m.chips ?? []).filter((c) => c && !c.wide && c.tone !== 'count'));
155
- const sub = [m.last ? node('small', 'last', m.last) : null, m.now ? node('small', 'kit-conv-now', m.now) : null].filter(Boolean);
176
+ const sub = [m.last ? node('small', 'last', m.last) : null, m.now ? node('small', `kit-conv-now${m.idle ? ' kit-conv-idle' : ''}`, m.now) : null].filter(Boolean);
156
177
  if (marks && sub.length) { const line = node('span', 'kit-conv-sub'); line.append(sub[0], marks); lines.push(line, ...sub.slice(1)); }
157
178
  else if (marks) lines.push(marks);
158
179
  else lines.push(...sub);
@@ -534,6 +555,57 @@
534
555
  return box;
535
556
  }
536
557
 
558
+ /* WHO IS IN A ROOM, AT A GLANCE (07-11): its members as up to `max` small stacked avatars, on its bar's right --
559
+ the one that welcomes first, then its other AIs, its people, its boxes. More than `max`: `max - 1` of them and
560
+ "+n" (never more than `max` circles). `members` as the server lists them [{ name, kind, host? }]. */
561
+ const STACK_ORDER = { ai: 1, person: 2, box: 3 };
562
+ function memberStackModel(members = [], { max = 3 } = {}) {
563
+ const all = (members ?? []).filter((m) => m && m.name).map((m, i) => ({ m, i }))
564
+ .sort((a, b) => (Number(Boolean(b.m.host)) - Number(Boolean(a.m.host))) || ((STACK_ORDER[a.m.kind] ?? 4) - (STACK_ORDER[b.m.kind] ?? 4)) || a.i - b.i)
565
+ .map((x) => x.m);
566
+ const most = Math.max(1, Math.floor(max) || 1);
567
+ const shown = all.length > most ? all.slice(0, most - 1) : all;
568
+ return { shown: shown.map((m) => ({ name: m.name, kind: m.kind ?? 'ai', ...(m.host ? { host: true } : {}) })), more: all.length - shown.length, names: all.map((m) => m.name) };
569
+ }
570
+ /* The stack, drawn in `button` (the page's own, its id and listeners kept) or a new one; `onOpen` on a click.
571
+ `label`: what a click opens (« Members »), said with the names. */
572
+ function MemberStack(members = [], { max = 3, label = '', button = null, onOpen = null } = {}) {
573
+ const model = memberStackModel(members, { max });
574
+ const b = button ?? node('button'); b.type = 'button';
575
+ addClass(b, 'kit-stack');
576
+ b.replaceChildren(...model.shown.map((m) => {
577
+ const a = Avatar({ name: m.name, glyph: m.kind === 'box' ? node('span', 'kit-stack-box') : null });
578
+ a.dataset.kind = m.kind;
579
+ return a;
580
+ }));
581
+ if (model.more) { const more = node('span', 'kit-av kit-stack-more', `+${model.more}`); more.setAttribute('aria-hidden', 'true'); b.append(more); }
582
+ const names = model.names.join(', ');
583
+ b.title = names; b.setAttribute('aria-label', label ? `${label}: ${names}` : names);
584
+ if (onOpen) b.addEventListener('click', () => onOpen());
585
+ return b;
586
+ }
587
+
588
+ /* ONE CHANGE OF A ROOM, SAVED AT ONCE (07-11: its settings as every settings page, no Save): what POST
589
+ /rooms/<id> is given for it, or null when it changes nothing. `room` as the server lists it; `change` one of
590
+ { name } its new name (trimmed; empty: nothing -- the field shows the name again)
591
+ { ai, on } a conversation (its name) put in the room or taken out
592
+ { box, on } a voice box (its id) put in or taken out
593
+ { host } a conversation of the room (its name) that welcomes it from now on */
594
+ function roomPatch(room, change = {}) {
595
+ const members = room?.members ?? [];
596
+ const of = (kind, ref) => members.find((m) => m.kind === kind && m.ref === ref);
597
+ if ('name' in change) { const name = String(change.name ?? '').trim(); return name && name !== room?.name ? { name } : null; }
598
+ for (const kind of ['ai', 'box']) {
599
+ if (!(kind in change)) continue;
600
+ const ref = String(change[kind] ?? ''); if (!ref) return null;
601
+ const m = of(kind, ref);
602
+ if (change.on) return m ? null : { add: kind === 'ai' ? { conversations: [ref] } : { boxes: [ref] } };
603
+ return m ? { remove: [m.id] } : null;
604
+ }
605
+ if ('host' in change) { const m = of('ai', String(change.host ?? '')); return m && !m.host ? { host: m.id } : null; }
606
+ return null;
607
+ }
608
+
537
609
  /* ONE ASSET. `a`, what the page knows of it, said once:
538
610
  key, kind 'folder:reports', its kind (ASSET_ICONS) name its name
539
611
  live a live link (a connexion): its rights are a connexion's
@@ -803,10 +875,10 @@
803
875
 
804
876
  window.clavariumKit = {
805
877
  collectModel,
806
- AssetIcon, AssetCard, Holders, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, RIGHT_SAME,
878
+ AssetIcon, AssetCard, Holders, MemberStack, memberStackModel, roomPatch, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, RIGHT_SAME,
807
879
  ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
808
880
  shareSlices, ShareDonut, ShareKey,
809
- STATES, STATE_WORDS, stateOf, hueOf, initialOf,
881
+ STATES, STATE_WORDS, stateOf, hueOf, initialOf, IDLE_MS, idleFor, idleWords, tidyList,
810
882
  setWords(fn) { if (typeof fn === 'function') words = fn; },
811
883
  StatusDot, Avatar, Chips, Switch, Segmented, InlineConfirm, ConvCard, StepFlow, SecretPicker, NewAsset, TopBar,
812
884
  };
package/web/style.css CHANGED
@@ -2009,12 +2009,13 @@ span.callout.confirm { display: inline-flex; justify-content: flex-end; padding:
2009
2009
  :is(#newconv, #salon-new) .set-row > .set-control > :is(input, select) { width: 18rem; }
2010
2010
  #newconv .set-what > .err { font-size: .75rem; color: var(--danger); }
2011
2011
  /* A card of rows, as an open chapter without its fold. */
2012
- .nc-card, #newconv details.chapter { flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--bar); }
2013
- .nc-card > *, #newconv details.chapter > .chapter-body > * { margin: 0; padding: 12px 16px; }
2014
- .nc-card > * + *, #newconv details.chapter > .chapter-body > * + * { border-top: 1px solid var(--line); }
2012
+ /* A room's settings (07-11): the chapters of a new conversation's, of Settings. */
2013
+ .nc-card, :is(#newconv, #salon-new) details.chapter { flex-shrink: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--bar); }
2014
+ .nc-card > *, :is(#newconv, #salon-new) details.chapter > .chapter-body > * { margin: 0; padding: 12px 16px; }
2015
+ .nc-card > * + *, :is(#newconv, #salon-new) details.chapter > .chapter-body > :where(:not([hidden])) ~ :where(:not([hidden])) { border-top: 1px solid var(--line); }
2015
2016
  .nc-card > .sub-head { display: block; padding-bottom: 0; }
2016
2017
  .nc-card > .sub-head + * { border-top: 0; }
2017
- #newconv details.chapter > .chapter-body { gap: 0; padding: 0; background: var(--bar); border-radius: 0 0 10px 10px; }
2018
+ :is(#newconv, #salon-new) details.chapter > .chapter-body { gap: 0; padding: 0; background: var(--bar); border-radius: 0 0 10px 10px; }
2018
2019
  /* A choice of a few, each a card: its name and one short line. */
2019
2020
  .pick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
2020
2021
  .pick.two { grid-template-columns: 1fr 1fr; }
@@ -2342,7 +2343,6 @@ body.as-dragging #assets-rows .as-row { touch-action: none; }
2342
2343
  .salon-chip-box::before { content: ""; width: 9px; height: 9px; background: currentColor; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H3v6h3l5 4z'/%3E%3Cpath d='M15.5 8.5a5 5 0 0 1 0 7'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H3v6h3l5 4z'/%3E%3Cpath d='M15.5 8.5a5 5 0 0 1 0 7'/%3E%3C/svg%3E") center / contain no-repeat; }
2343
2344
  #salon { display: flex; flex-direction: column; min-height: 0; }
2344
2345
  #salon[hidden] { display: none; }
2345
- .salon-who { display: inline-flex; align-items: center; gap: 8px; border: 0; background: transparent; padding: 4px 14px; min-height: 44px; }
2346
2346
  .salon-members { display: flex; flex-direction: column; gap: 8px; padding: 0 14px 10px; border-bottom: 1px solid var(--line); }
2347
2347
  .salon-members-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
2348
2348
  .salon-members-row > .hint { width: 4.5rem; }
@@ -2353,8 +2353,6 @@ body.as-dragging #assets-rows .as-row { touch-action: none; }
2353
2353
  .salon-thinking { display: flex; align-items: center; gap: 6px; margin: 0; padding: 0 14px 8px; font-size: .75rem; color: var(--ink2); order: 2; }
2354
2354
  #salon-new { display: flex; flex-direction: column; }
2355
2355
  #salon-new[hidden] { display: none; }
2356
- #salon-new .nc-card > .hint { display: block; padding-top: 0; border-top: 0; }
2357
- #salon-new .pick .salon-welcome { color: var(--brass-ink); }
2358
2356
 
2359
2357
  /* THE PLACES OF THE WHOLE (03-11): settings, access, assets, the deck, rooms, a new conversation are not a
2360
2358
  conversation's -- in a part of the page of their own, over the conversation (which stays as it was, its
@@ -2363,34 +2361,39 @@ body.as-dragging #assets-rows .as-row { touch-action: none; }
2363
2361
  #places[hidden] { display: none; }
2364
2362
  @media (max-width: 700px) { #places { grid-column: 1; grid-row: 1; } }
2365
2363
  #app:has(> #list[hidden]) #places { grid-column: 1 / -1; }
2366
- .salon-bar { display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; }
2367
- .salon-bar .salon-who { flex: 1; }
2368
- #salon-new-remove-card .confirm { display: inline-flex; gap: 6px; flex-wrap: wrap; }
2369
2364
 
2370
2365
  /* A ROOM'S MEMBERS (03-11): the one that welcomes in a box of its own; every conversation on a row, its switch. */
2371
- .salon-host-box { display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 8px 12px; border: 1px solid var(--brass); border-radius: 10px; background: color-mix(in srgb, var(--brass) 10%, var(--panel)); transition: background .15s; }
2372
- .salon-host-box.over { background: color-mix(in srgb, var(--brass) 22%, var(--panel)); }
2373
2366
  .salon-member-what { flex: 1; min-width: 0; display: flex; flex-direction: column; }
2374
2367
  .salon-member-what b { font-weight: 600; font-size: .8125rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2375
2368
  .salon-member-what small { color: var(--ink2); font-size: .75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2376
- .salon-members-tools input { width: 100%; }
2369
+ /* In a chapter, its rows are the chapter's (07-11): no box in a box. */
2370
+ #salon-new .chapter-body > .salon-member-list { border: 0; border-radius: 0; background: transparent; max-height: none; }
2371
+ #salon-new .chapter-body .salon-member-row { padding: 8px 16px; }
2372
+ #salon-new .chapter-body > .salon-members-tools { padding-top: 10px; padding-bottom: 10px; }
2373
+ details.chapter > summary > .chapter-add { flex: none; align-self: center; min-height: 32px; padding: 4px 12px; font-size: .8125rem; }
2374
+ details.chapter > summary > .chapter-add[hidden] { display: none; }
2375
+ @media (max-width: 560px) {
2376
+ details.chapter > summary > .chapter-add { padding: 4px 8px; font-size: .75rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 0 1 auto; }
2377
+ details.chapter > summary:has(> .chapter-add:not([hidden])) > .chapter-about { display: none; }
2378
+ }
2379
+ .salon-saved { flex: none; font-size: .8125rem; color: var(--ok, var(--ink2)); white-space: nowrap; }
2380
+ .salon-saved:empty { display: none; }
2381
+ #salon-new-actions[hidden] { display: none; }
2377
2382
  .salon-member-list { display: flex; flex-direction: column; max-height: 22rem; overflow-y: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 0 !important; }
2378
2383
  .salon-member-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 48px; border-top: 1px solid var(--line); }
2379
2384
  .salon-member-row:first-child { border-top: 0; }
2380
2385
  .salon-member-row[draggable="true"] { cursor: grab; }
2381
- button.salon-welcome-btn { border-radius: 999px; padding: 4px 10px; font-size: .75rem; min-height: 32px; background: transparent; color: var(--ink2); }
2382
- button.salon-welcome-btn[aria-pressed="true"] { color: var(--brass-ink); border-color: var(--brass); background: color-mix(in srgb, var(--brass) 14%, transparent); }
2383
- .nc-card > .salon-host-box, .nc-card > .salon-member-list { margin: 4px 16px 12px; }
2384
- .nc-card > .salon-host-box { padding: 8px 12px; }
2385
- .nc-card > .salon-members-tools { padding-bottom: 4px; }
2386
- .nc-card > .salon-host-box + *, .nc-card > .salon-member-list + * { border-top: 0; }
2386
+ .salon-welcome-pill { flex: none; border: 1px solid var(--brass); border-radius: 999px; padding: 3px 10px; font-size: .75rem; font-weight: 600; color: var(--brass-ink); background: color-mix(in srgb, var(--brass) 14%, transparent); white-space: nowrap; }
2387
+ .salon-member-row[data-pick="true"] { cursor: pointer; }
2388
+ .salon-member-row[data-pick="true"]:hover { background: color-mix(in srgb, var(--ink) 4%, transparent); }
2389
+ .salon-member-row[data-pick="true"]:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; }
2387
2390
  /* Each AI's assets in a room's settings (06-11): its member card, its assets as chips below; give to all its AIs. */
2388
2391
  .salon-assets-list { max-height: none; }
2389
2392
  .salon-asset-ai { display: flex; flex-direction: column; gap: 2px; padding-bottom: 10px; border-top: 1px solid var(--line); }
2390
2393
  .salon-asset-ai:first-child { border-top: 0; }
2391
2394
  .salon-asset-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px; }
2392
2395
  button.salon-give-btn { border-radius: 999px; padding: 4px 10px; font-size: .75rem; min-height: 32px; background: transparent; color: var(--ink2); white-space: nowrap; }
2393
- .nc-card > .salon-give { margin: 4px 16px 12px; padding: 10px 12px; border: 1px solid var(--brass); border-radius: 10px; background: color-mix(in srgb, var(--brass) 6%, var(--panel)); display: flex; flex-direction: column; gap: 8px; }
2396
+ #salon-new .chapter-body > .salon-give { margin: 4px 16px 12px; padding: 10px 12px; border: 1px solid var(--brass); border-radius: 10px; background: color-mix(in srgb, var(--brass) 6%, var(--panel)); display: flex; flex-direction: column; gap: 8px; }
2394
2397
  .salon-give-assets { display: flex; flex-wrap: wrap; gap: 6px; max-height: 12rem; overflow-y: auto; }
2395
2398
  .salon-give-said { display: flex; flex-direction: column; gap: 2px; }
2396
2399
  .salon-give .action-row { padding: 0; border: 0; }
@@ -2431,14 +2434,19 @@ html[data-door="device"] header.topbar { height: auto; }
2431
2434
  .sheet.as-place > header.topbar { margin: 0; }
2432
2435
  :is(#newconv, #salon-new) > header.topbar { margin: 0 -20px 4px; }
2433
2436
  @media (max-width: 700px) { #newconv > header.topbar { margin: 0 -16px 4px; } }
2434
- /* A room: its name, who speaks now and who is in it on the line under it (a button that opens the list). */
2437
+ /* A room: its name, who speaks now on the line under it (only then); who is in it on the right, before its gear
2438
+ (07-11): up to 3 small stacked avatars, a button that opens the list under the bar. */
2439
+ header.topbar .kit-stack { display: inline-flex; align-items: center; flex: none; min-height: 34px; padding: 0 4px; border: 0; border-radius: 999px; background: transparent; cursor: pointer; }
2440
+ header.topbar .kit-stack:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
2441
+ header.topbar .kit-stack:focus-visible { outline: 2px solid var(--brass); outline-offset: 1px; }
2442
+ .kit-stack > .kit-av { --kc-av: 24px; border-radius: 50%; box-shadow: 0 0 0 2px var(--bar); }
2443
+ .kit-stack > .kit-av + .kit-av { margin-left: -7px; }
2444
+ .kit-stack > .kit-av[data-kind="box"] { background: var(--panel); color: var(--ink2); box-shadow: 0 0 0 2px var(--bar), inset 0 0 0 1px var(--line); }
2445
+ .kit-stack-box { width: 12px; height: 12px; background: currentColor; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H3v6h3l5 4z'/%3E%3Cpath d='M15.5 8.5a5 5 0 0 1 0 7'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H3v6h3l5 4z'/%3E%3Cpath d='M15.5 8.5a5 5 0 0 1 0 7'/%3E%3C/svg%3E") center / contain no-repeat; }
2446
+ .kit-stack > .kit-stack-more { background: var(--panel); color: var(--ink2); box-shadow: 0 0 0 2px var(--bar), inset 0 0 0 1px var(--line); font-size: .6875rem; }
2435
2447
  #salon-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2436
2448
  .salon-live { font-size: .75rem; font-weight: 400; color: var(--ink2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2437
2449
  .salon-live[hidden] { display: none; }
2438
- header.topbar .topbar-sub .salon-who { display: inline-flex; align-items: center; gap: 6px; min-height: 0; padding: 0; border: 0; background: transparent; color: var(--ink2); font: inherit; cursor: pointer; }
2439
- header.topbar .topbar-sub .salon-who:hover { color: var(--ink); }
2440
- header.topbar .topbar-sub .salon-chip { width: 15px; height: 15px; margin-left: -4px; border-width: 1px; font-size: .5625rem; }
2441
- header.topbar .topbar-sub .salon-chip:first-child { margin-left: 0; }
2442
2450
  /* A room's row in the list (03-11): a conversation's row, with a small sign of a room before its name (Lucide « users »). */
2443
2451
  .room-sign { display: inline-block; width: 14px; height: 14px; margin-right: 6px; vertical-align: -2px; background: var(--ink2);
2444
2452
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E") center / contain no-repeat;
@@ -2698,9 +2706,6 @@ header.topbar .topbar-sub .salon-chip:first-child { margin-left: 0; }
2698
2706
  /* The search atop a settings page (05-11). */
2699
2707
  :is(header.topbar, .panel-top) .settings-search { margin-left: auto; width: min(240px, 42vw); min-height: 32px; padding: 4px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); font-size: .875rem; }
2700
2708
  .set-row.search-out { display: none; }
2701
- /* A room's settings: the same group names as every settings page (05-11). */
2702
- #salon-new .room-group { margin: 6px 4px -6px; font-size: .6875rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink2); }
2703
- #salon-new .room-group[hidden] { display: none; }
2704
2709
 
2705
2710
  /* ======================================================================================================
2706
2711
  KIT (05-11, web/kit.js): the pieces every screen shares, one style each. A screen places them; it
@@ -2784,7 +2789,6 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2784
2789
  .kit-conv-member { --kc-ring: var(--panel); align-items: center; padding: 8px 12px; }
2785
2790
  .kit-conv-member .kit-conv-head > b { font-size: .8125rem; }
2786
2791
  .kit-conv-member .kit-conv-main > small { font-size: .75rem; }
2787
- .salon-host-box > .kit-conv-member { flex: 1; padding: 0; --kc-ring: color-mix(in srgb, var(--brass) 10%, var(--panel)); }
2788
2792
  @media (max-width: 520px) { .kit-conv-card { --kc-av: 30px; column-gap: 8px; padding: 10px; } }
2789
2793
 
2790
2794
  /* THE ROW, compact (05-11, designers' review): a smaller initial, a bigger ringed point, the marks as small
@@ -2897,3 +2901,22 @@ button.kit-switch[aria-checked="true"]::after { left: 18px; background: var(--ok
2897
2901
  .kit-rights-pick > button > .kit-rights-same { display: block; font-size: .6875rem; font-weight: 500; opacity: .8; }
2898
2902
  .kit-asdelete { display: inline; }
2899
2903
  .as-files-menu #files.embedded > .panel-top { display: none; }
2904
+ /* IDLE 12 H OR MORE (07-11): « rien depuis 14 h », in grey, never bold. */
2905
+ .item small.kit-conv-idle { color: var(--ink2); opacity: .8; }
2906
+ /* FAIRE LE MÉNAGE (07-11): a clear button at the bottom of the list, only when something is idle. */
2907
+ .tidy-open { display: block; width: calc(100% - 1.5rem); margin: .75rem .75rem .25rem; padding: .55rem .75rem; background: transparent; border: 1px dashed var(--line); border-radius: 10px; color: var(--ink2); font-size: .8125rem; font-weight: 600; text-align: center; }
2908
+ .tidy-open:hover { color: var(--ink); border-color: var(--ink2); }
2909
+ /* « Mettre en veille ? »: the app's pop-up (a sheet from the bottom on a phone, centred on a wider screen). */
2910
+ .tidy-list { display: flex; flex-direction: column; gap: 2px; max-height: 50dvh; overflow-y: auto; }
2911
+ .tidy-row { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 6px; border-radius: 8px; cursor: pointer; }
2912
+ .tidy-row:hover { background: var(--bar); }
2913
+ .tidy-row input { width: 18px; height: 18px; accent-color: var(--brass); flex: none; }
2914
+ .tidy-name { color: var(--ink2); font-size: .875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2915
+ .tidy-name b { color: var(--ink); font-weight: 600; }
2916
+ .tidy-go { min-height: 44px; }
2917
+ .tidy-said { color: var(--danger); }
2918
+ @media (min-width: 700px) {
2919
+ .tidy-wrap { align-items: center; justify-content: center; }
2920
+ .tidy-wrap .lk-sheet { width: min(440px, 92vw); border: 1px solid var(--line); border-radius: 14px; padding: 16px; }
2921
+ .tidy-wrap .lk-sheet::before { display: none; }
2922
+ }