@jossuealcala/madre 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +1945 -0
  3. package/CHANGELOG.md +78 -1
  4. package/CONTRIBUTING.md +5 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +34 -3
  7. package/README.en.md +245 -0
  8. package/README.md +23 -13
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +82 -0
  11. package/bin/madre.mjs +2 -0
  12. package/docs/INTERNALS.md +8 -2
  13. package/docs/REFERENCE.md +17 -7
  14. package/docs/en/INTERNALS.md +103 -0
  15. package/docs/en/REFERENCE.md +339 -0
  16. package/docs/en/SDK.md +306 -0
  17. package/docs/en/training/README.md +59 -0
  18. package/docs/madre-banner.png +0 -0
  19. package/docs/training/README.md +20 -8
  20. package/docs/training/train.sh +2 -1
  21. package/package.json +14 -5
  22. package/public/abduction.js +2 -0
  23. package/public/app.js +2066 -488
  24. package/public/attribution.js +71 -0
  25. package/public/brands.js +3 -1
  26. package/public/editor.js +2 -0
  27. package/public/es.js +1706 -1226
  28. package/public/i18n.js +25 -1
  29. package/public/index.html +13 -10
  30. package/public/inquiry.js +2 -0
  31. package/public/loader.js +2 -0
  32. package/public/macroverso.js +24 -2
  33. package/public/nostromo-engine.js +2 -0
  34. package/public/resay.js +6 -0
  35. package/public/review-ask.js +2 -0
  36. package/public/routing.js +23 -1
  37. package/public/salas-mac.css +539 -28
  38. package/public/salas-mac.html +30 -22
  39. package/public/salas-mac.js +1498 -144
  40. package/public/salas.css +2 -0
  41. package/public/salas.html +1 -0
  42. package/public/salas.js +4 -2
  43. package/public/styles.css +1123 -20
  44. package/public/syntax.js +2 -0
  45. package/public/touch.js +2 -0
  46. package/public/tour-chapters.js +164 -52
  47. package/public/tour.js +85 -12
  48. package/public/troubleshooting.js +20 -18
  49. package/src/adapters/claude.mjs +2 -0
  50. package/src/adapters/codex.mjs +2 -0
  51. package/src/adapters/gemini.mjs +3 -1
  52. package/src/adapters/madre.mjs +3 -1
  53. package/src/adapters/opencode.mjs +7 -3
  54. package/src/adapters/process.mjs +2 -0
  55. package/src/asking.mjs +2 -0
  56. package/src/auth-probe.mjs +32 -7
  57. package/src/bridge/devices.mjs +2 -0
  58. package/src/bridge/handoff.mjs +2 -0
  59. package/src/bridge/host.mjs +2 -0
  60. package/src/bridge/network.mjs +2 -0
  61. package/src/bridge/policy.mjs +3 -1
  62. package/src/bridge/qr.mjs +2 -0
  63. package/src/bridge/server.mjs +2 -0
  64. package/src/capabilities.mjs +2 -0
  65. package/src/chats.mjs +2 -0
  66. package/src/checkpoint.mjs +2 -0
  67. package/src/choices.mjs +2 -0
  68. package/src/cli-args.mjs +2 -0
  69. package/src/cold.mjs +2 -0
  70. package/src/commands.mjs +2 -0
  71. package/src/config.mjs +2 -0
  72. package/src/conversation-context.mjs +15 -1
  73. package/src/credentials.mjs +112 -3
  74. package/src/dataset.mjs +2 -0
  75. package/src/directives.mjs +8 -6
  76. package/src/distiller.mjs +2 -0
  77. package/src/embeddings.mjs +2 -0
  78. package/src/enlaces.mjs +6 -4
  79. package/src/event-store.mjs +2 -0
  80. package/src/exam.mjs +6 -4
  81. package/src/extensions.mjs +33 -10
  82. package/src/eyecat-watch.mjs +2 -0
  83. package/src/eyecat.mjs +2 -0
  84. package/src/file-ops.mjs +2 -0
  85. package/src/files.mjs +32 -0
  86. package/src/github.mjs +2 -0
  87. package/src/heartbeat.mjs +57 -0
  88. package/src/i18n.mjs +15 -1
  89. package/src/image-studio.mjs +2 -0
  90. package/src/json-file.mjs +2 -0
  91. package/src/launch.mjs +2 -0
  92. package/src/lease.mjs +5 -1
  93. package/src/lobby-work.mjs +2 -0
  94. package/src/local-ai.mjs +50 -0
  95. package/src/looks.mjs +40 -5
  96. package/src/maturity.mjs +3 -1
  97. package/src/mcp/connect.mjs +345 -0
  98. package/src/mcp/github-server.mjs +2 -0
  99. package/src/mcp/image-server.mjs +2 -0
  100. package/src/mcp/memory-server.mjs +2 -0
  101. package/src/mcp/proxy.mjs +153 -0
  102. package/src/mcp/smtp-server.mjs +2 -0
  103. package/src/memory-dashboard.mjs +2 -0
  104. package/src/memory-tools.mjs +2 -0
  105. package/src/memory.mjs +2 -0
  106. package/src/models.mjs +83 -3
  107. package/src/modules/ahp.mjs +2 -0
  108. package/src/modules/ash-policy.mjs +2 -0
  109. package/src/modules/ash.mjs +3 -1
  110. package/src/modules/bishop-plan.mjs +5 -3
  111. package/src/modules/bishop.mjs +13 -11
  112. package/src/modules/choices.mjs +2 -0
  113. package/src/modules/correo.mjs +2 -0
  114. package/src/modules/enlaces.mjs +2 -0
  115. package/src/modules/git-pulse.mjs +2 -0
  116. package/src/modules/github.mjs +2 -0
  117. package/src/modules/helpers.mjs +2 -0
  118. package/src/modules/image-studio.mjs +3 -1
  119. package/src/modules/index.mjs +4 -1
  120. package/src/modules/intents-calibration.mjs +2 -0
  121. package/src/modules/intents-desk.mjs +4 -2
  122. package/src/modules/intents-observe.mjs +2 -0
  123. package/src/modules/intents-policy.mjs +4 -1
  124. package/src/modules/intents-try.mjs +2 -0
  125. package/src/modules/intents.mjs +8 -6
  126. package/src/modules/mcp-connect.mjs +293 -0
  127. package/src/modules/ollama.mjs +17 -3
  128. package/src/modules/playwright.mjs +25 -10
  129. package/src/modules/ripley.mjs +2 -0
  130. package/src/modules/runs.mjs +5 -3
  131. package/src/modules/sdk.mjs +2 -0
  132. package/src/modules/updates.mjs +2 -0
  133. package/src/mother.mjs +2 -0
  134. package/src/ollama.mjs +2 -0
  135. package/src/outbound.mjs +24 -5
  136. package/src/override.mjs +2 -0
  137. package/src/ports.mjs +2 -0
  138. package/src/prices.mjs +2 -0
  139. package/src/privacy.mjs +2 -0
  140. package/src/quota-monitor.mjs +2 -0
  141. package/src/quota-sources.mjs +2 -0
  142. package/src/request-guard.mjs +2 -0
  143. package/src/review-ask.mjs +2 -0
  144. package/src/room/archivist.mjs +2 -0
  145. package/src/room/attachments.mjs +2 -0
  146. package/src/room/budget.mjs +2 -0
  147. package/src/room/context.mjs +2 -0
  148. package/src/room/control.mjs +7 -1
  149. package/src/room/copilot.mjs +76 -0
  150. package/src/room/economy.mjs +2 -0
  151. package/src/room/escalation.mjs +3 -0
  152. package/src/room/ghost.mjs +2 -0
  153. package/src/room/guard.mjs +2 -0
  154. package/src/room/prompt.mjs +13 -4
  155. package/src/room/returns.mjs +82 -0
  156. package/src/room/runs.mjs +4 -0
  157. package/src/room/vectors.mjs +2 -0
  158. package/src/room.mjs +264 -45
  159. package/src/router.mjs +2 -0
  160. package/src/runs.mjs +2 -0
  161. package/src/runtime-detection.mjs +2 -0
  162. package/src/salas.mjs +2 -0
  163. package/src/sentinel-errors.mjs +2 -0
  164. package/src/server-sentinel.mjs +2 -0
  165. package/src/server.mjs +485 -55
  166. package/src/setup.mjs +2 -0
  167. package/src/smtp.mjs +2 -0
  168. package/src/tour.mjs +26 -9
  169. package/src/updates.mjs +2 -0
  170. package/src/usage-sentinel.mjs +2 -0
  171. package/src/vault.mjs +2 -0
  172. package/src/verdict.mjs +3 -1
  173. package/src/whats-new.mjs +12 -10
  174. package/docs/madre-banner.svg +0 -25
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  // SALAS on the Mac: every room this Mac knows, open or asleep, in one place (docs/MACROVERSO.md,
2
4
  // phase 1). The room that serves this page answers for all of them (src/salas.mjs): it lists
3
5
  // them, opens one that is asleep, asks one to close, and stops every agent in every room. A room
@@ -5,11 +7,12 @@
5
7
  //
6
8
  // The phone has its own SALAS (salas.js), served by the bridge; the two read the same rooms.
7
9
 
8
- import { setLanguage, t } from './i18n.js';
10
+ import { browserLanguage, setLanguage, t } from './i18n.js';
9
11
  import { brandOf, monogram } from './brands.js';
10
12
  import { startReviewAsk } from './review-ask.js';
11
13
  import { startTour } from './tour.js';
12
14
  import { dropStickyHover } from './touch.js';
15
+ import { drawAttribution, drawLegal } from './attribution.js';
13
16
 
14
17
  // Where nothing can hover (a tablet), a tapped key does not stay lit (public/touch.js).
15
18
  dropStickyHover();
@@ -34,12 +37,14 @@ const ui = {
34
37
  attention: { title: $('sm-attention-title'), count: $('sm-attention-count'), list: $('sm-attention-list') },
35
38
  };
36
39
  let language = 'es';
40
+ let languageAdopted = false;
37
41
  let timer = 0;
38
42
  let arming = 0;
39
43
  let data = null;
40
44
  let macro = null; // the macroverse, while it is open
41
45
  let welcomed = false; // the first-run wizard was offered this visit
42
46
  let agentsSeen = null; // what /api/settings said of the agents, last time it was asked
47
+ let localSeen = null; // what /api/salas/local-ai said: the mode and which projects use the local AI
43
48
  let versionSeen = null; // what /api/version said of MADRE, last time it was asked
44
49
  let work = null; // MADRE's summary of the last work (/api/salas/work)
45
50
  let decisions = null; // the recent decisions of the open rooms (/api/salas/decisions)
@@ -78,7 +83,7 @@ const TUNES = {
78
83
  const HOME_LOOKS = {
79
84
  cards: { key: 'pulse.home.cards', values: ['full', 'compact'], words: { full: () => t('With their last conversation'), compact: () => t('Name and state only') } },
80
85
  text: { key: 'pulse.home.text', values: ['comfortable', 'larger', 'smaller'], words: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller') } },
81
- order: { key: 'pulse.home.order', values: ['attention', 'name', 'recent'], words: { attention: () => t('Attention'), name: () => t('Name'), recent: () => t('Recent') } },
86
+ order: { key: 'pulse.home.order', values: ['attention', 'name', 'recent', 'mine'], words: { attention: () => t('Attention'), name: () => t('Name'), recent: () => t('Recent'), mine: () => t('Your order') } },
82
87
  };
83
88
  const LOOK_KEYS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', ...Object.values(HOME_LOOKS).map((one) => one.key)];
84
89
  function readLooks() {
@@ -137,7 +142,7 @@ function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
137
142
  try {
138
143
  if (theme) localStorage.setItem('pulse.theme', theme);
139
144
  if (accent) keep('pulse.accent', accent, 'madre');
140
- if (wallpaper) keep('pulse.wallpaper', wallpaper, 'default');
145
+ if (wallpaper) { keep('pulse.wallpaper', wallpaper, 'default'); saveOwnWallpaper(wallpaper); }
141
146
  if (style) keep('pulse.wallpaper.style', style, 'veil');
142
147
  if (bubbles) keep('pulse.bubbles', bubbles, BUBBLES[0]);
143
148
  if (text) keep('pulse.text', text, TEXTS[0]);
@@ -151,6 +156,7 @@ function saveHome(name, value) {
151
156
  try { if (value === spec.values[0]) localStorage.removeItem(spec.key); else localStorage.setItem(spec.key, value); } catch { /* the choice lasts as long as the page */ }
152
157
  applyLooks();
153
158
  if (name === 'order' && typeof draw === 'function') draw();
159
+ if (name === 'order' && typeof paintOrderKey === 'function') paintOrderKey();
154
160
  settingsSheet?.paintLooks();
155
161
  }
156
162
  // A dial is saved when it is let go and shown while it moves; the sheet is not painted again under
@@ -164,7 +170,14 @@ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change'
164
170
  // origin, so what this browser kept here no project saw. ~/.pulse/looks.json is read when Home
165
171
  // opens and whenever it comes back into view, and written when a choice changes. The twin of
166
172
  // app.js's pullLooks; Home's own card choices (HOME_LOOKS) stay here.
167
- const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
173
+ // The wallpaper is each page's own (2026-10-13): Home keeps the one chosen in Home, each project
174
+ // the one chosen in it (src/looks.mjs, `rooms`). The rest of the looks are shared.
175
+ const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
176
+ // Before a page had its own, every page wore the shared one: that is where a page starts.
177
+ const wallpaperOf = (answer) => answer.own?.wallpaper ?? answer.looks?.['pulse.wallpaper'] ?? null;
178
+ function saveOwnWallpaper(value) {
179
+ void fetch('/api/looks/room', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ wallpaper: value }) }).catch(() => null);
180
+ }
168
181
  let looksPush = 0;
169
182
  function pushLooks() {
170
183
  clearTimeout(looksPush);
@@ -182,8 +195,8 @@ async function pullLooks() {
182
195
  // Nothing kept yet: the first page that has a choice of its own hands it to the others.
183
196
  if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
184
197
  let changed = false;
185
- for (const key of SHARED_LOOKS) {
186
- const value = answer.looks[key] ?? null;
198
+ const wall = wallpaperOf(answer);
199
+ for (const [key, value] of [...SHARED_LOOKS.map((one) => [one, answer.looks[one] ?? null]), ['pulse.wallpaper', wall === 'default' ? null : wall]]) {
187
200
  if (localStorage.getItem(key) === value) continue;
188
201
  if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
189
202
  changed = true;
@@ -221,8 +234,8 @@ const modeName = (n) => [t('INCOGNITO'), t('CHAT ONLY'), t('CREATE'), t('EDIT'),
221
234
  function askLine(ask) {
222
235
  if (ask.kind === 'mode') return t('@{agent} asks for #{n} {label}', { agent: ask.agent, n: ask.mode, label: modeName(ask.mode) });
223
236
  if (ask.kind === 'run') return ask.text ? t('@{agent} wants to run {line}', { agent: ask.agent, line: ask.text }) : t('@{agent} wants to run a command', { agent: ask.agent });
224
- if (ask.kind === 'bishop') return t('The Coordinator proposes #{n} to @{agent}: unlock it with your key', { n: ask.mode, agent: ask.agent });
225
- if (ask.kind === 'plan') return ask.text ? t('The Coordinator waits for your press: {reason}', { reason: ask.text }) : t('The Coordinator waits for your press');
237
+ if (ask.kind === 'bishop') return t('The Coordinator proposes #{n} to @{agent}: confirm it to unlock it', { n: ask.mode, agent: ask.agent });
238
+ if (ask.kind === 'plan') return ask.text ? t('The Coordinator waits for you to confirm: {reason}', { reason: ask.text }) : t('The Coordinator waits for you to confirm');
226
239
  return ask.text ? t('@{agent} asks you: {question}', { agent: ask.agent, question: ask.text }) : t('@{agent} asks you to choose', { agent: ask.agent });
227
240
  }
228
241
  // The folder, as a person writes it: the home folder is ~.
@@ -232,7 +245,7 @@ const addressOf = (room) => (room.here ? '/' : `http://127.0.0.1:${room.open.por
232
245
 
233
246
  // The memory kinds, in the colours NOSTROMO gives them (styles.css, --mem-*).
234
247
  const KINDS = ['decision', 'fact', 'preference', 'question', 'aberration'];
235
- const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
248
+ const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('DOUBTFUL MEMORY') };
236
249
  // A kind as a sentence says it: «Decisión», not «DECISIÓN».
237
250
  const kindWord = (kind) => { const word = String(KIND_WORDS[kind] ?? kind).toLowerCase(); return word.charAt(0).toUpperCase() + word.slice(1); };
238
251
 
@@ -260,6 +273,7 @@ function stateOf(room) {
260
273
  if (!room.open) return { key: 'asleep', words: room.lastOpened ? t('Asleep · last open {when}', { when: ago(room.lastOpened) }) : t('Asleep') };
261
274
  if (glance.outdated) return { key: 'outdated', words: t('Update MADRE in this project to see it here.') };
262
275
  if (glance.unreachable) return { key: 'quiet', words: t('Not answering right now') };
276
+ if (glance.delayed) return { key: 'quiet', words: t('Responding slowly · last known state') };
263
277
  if (glance.waitingCount) return { key: 'waiting', words: t('Waiting for you · {n}', { n: glance.waitingCount }) };
264
278
  if (glance.working?.length) return { key: 'working', words: glance.working.length === 1 ? t('@{agent} is working', { agent: glance.working[0] }) : t('{n} agents working', { n: glance.working.length }) };
265
279
  return { key: 'calm', words: t('Calm') };
@@ -277,6 +291,15 @@ function card(room) {
277
291
  const state = stateOf(room);
278
292
  const node = el('article', `sm-tile sm-card is-${state.key}${room.here ? ' here' : ''}`);
279
293
  node.dataset.room = room.id;
294
+ // Its own piece of the wallpaper (salas-mac.css): a place on the image, steady for the project.
295
+ let seed = 0;
296
+ for (const char of room.id) seed = (seed * 31 + char.charCodeAt(0)) >>> 0;
297
+ node.style.setProperty('--wx', `${seed % 101}%`);
298
+ node.style.setProperty('--wy', `${(seed >>> 8) % 101}%`);
299
+ // A project with a wallpaper of its own («only in this project») shows that one, whole.
300
+ // The wallpaper its own room wears (each page keeps its own), seen whole; none when it has none.
301
+ if (room.wallpaper && room.wallpaper !== 'default') { node.dataset.wp = room.wallpaper; node.style.setProperty('--wx', '50%'); node.style.setProperty('--wy', '50%'); }
302
+ else node.dataset.wp = 'none';
280
303
  // To the bar: dragged there, or pinned from its right-click.
281
304
  node.draggable = true;
282
305
  node.addEventListener('dragstart', (event) => pinsDragOut(event, room));
@@ -319,7 +342,7 @@ function card(room) {
319
342
  if (room.open && glance.census) {
320
343
  node.append(memoryStrip(glance.census));
321
344
  const counts = [conversationsCount(glance.chats ?? 0), memoriesCount(glance.census.memories)];
322
- if (glance.census.memories) counts.push(t('{n} travel', { n: glance.census.travelling }));
345
+ if (glance.census.memories) counts.push(t('{n} in use', { n: glance.census.travelling }));
323
346
  node.append(el('p', 'sm-counts', counts.join(' · ')));
324
347
  }
325
348
 
@@ -355,6 +378,22 @@ function card(room) {
355
378
  const ORDER = { waiting: 0, working: 1, calm: 2, quiet: 3, outdated: 4 };
356
379
  // The projects in the order the lobby shows them: the open ones by what needs the person, then
357
380
  // the asleep ones, the last opened first. BISHOP's own room is not a project.
381
+ const PROJECT_ORDER_KEY = 'pulse.home.projects';
382
+ function projectOrder() {
383
+ try { const list = JSON.parse(localStorage.getItem(PROJECT_ORDER_KEY) ?? '[]'); return Array.isArray(list) ? list.filter((id) => typeof id === 'string') : []; } catch { return []; }
384
+ }
385
+ // A project moved by hand: the whole order as it is on screen now, with that one moved, becomes
386
+ // «Yours» — so moving one never shuffles the others.
387
+ function moveProject(id, where) {
388
+ const list = projectsInOrder().all.map((room) => room.id);
389
+ const at = list.indexOf(id);
390
+ if (at < 0) return;
391
+ list.splice(at, 1);
392
+ const to = where === 'first' ? 0 : where === 'last' ? list.length : Math.max(0, Math.min(list.length, at + where));
393
+ list.splice(to, 0, id);
394
+ try { localStorage.setItem(PROJECT_ORDER_KEY, JSON.stringify(list)); } catch { /* the order lasts as long as the page */ }
395
+ saveHome('order', 'mine');
396
+ }
358
397
  function projectsInOrder() {
359
398
  const projects = data.rooms.filter((room) => room.id !== data.salas);
360
399
  const open = projects.filter((room) => room.open)
@@ -364,9 +403,14 @@ function projectsInOrder() {
364
403
  // last thing that happened in each, open or asleep.
365
404
  const order = readLooks().home.order;
366
405
  const touched = (room) => Math.max(Date.parse(room.glance?.chat?.at ?? 0) || 0, Date.parse(room.lastOpened ?? room.added ?? 0) || 0);
406
+ // «Yours» (2026-10-13): the order the person made with right-click › Move; a project they never
407
+ // placed comes after, in the order of what needs them.
408
+ const mine = order === 'mine' ? projectOrder() : [];
409
+ const placed = (room) => { const at = mine.indexOf(room.id); return at < 0 ? Number.MAX_SAFE_INTEGER : at; };
367
410
  const all = order === 'name' ? [...projects].sort((a, b) => a.name.localeCompare(b.name, language))
368
411
  : order === 'recent' ? [...projects].sort((a, b) => touched(b) - touched(a))
369
- : [...open, ...asleep];
412
+ : order === 'mine' ? [...open, ...asleep].map((room, index) => [room, index]).sort(([a, i], [b, j]) => placed(a) - placed(b) || i - j).map(([room]) => room)
413
+ : [...open, ...asleep];
370
414
  return { all, open, asleep };
371
415
  }
372
416
  function draw() {
@@ -387,6 +431,7 @@ function draw() {
387
431
  ui.macro.hidden = !data.rooms.length || window.innerWidth < 900;
388
432
  macro?.update(data.rooms, data.enlaces ?? []);
389
433
  settingsSheet?.sync();
434
+ fitTiles();
390
435
  }
391
436
 
392
437
  // The projects, as the search box and the filter leave them. Nothing is hidden by the filter
@@ -405,9 +450,48 @@ function drawProjects(all = data ? projectsInOrder().all : []) {
405
450
  ui.projects.hidden = !shown.length;
406
451
  ui.none.hidden = Boolean(shown.length);
407
452
  ui.none.replaceChildren();
453
+ fitDrawer(all);
408
454
  if (!all.length) ui.none.append(el('span', null, t('No projects yet. Add the first one.')), button('sm-btn primary small', t('Add a project'), openAddSheet));
409
455
  else if (!shown.length) ui.none.append(el('span', null, query ? t('No project matches «{query}».', { query }) : filter === 'open' ? t('No project is open.') : t('No project is asleep.')));
410
456
  }
457
+ // THE DRAWER (2026-10-13): the projects live in a tile of their own, moved like any other, three
458
+ // rows at a time. What does not fit is counted on a key under them — «+5», how many are open and
459
+ // how many asleep — and the key opens the drawer whole, or closes it back to three rows.
460
+ const drawerMore = $('sm-drawer-more');
461
+ let drawerOpen = false;
462
+ const DRAWER_ROWS = 3;
463
+ function fitDrawer(all = data ? projectsInOrder().all : []) {
464
+ const count = $('sm-drawer-count');
465
+ if (count) count.textContent = String(all.length);
466
+ const cards = [...ui.projects.querySelectorAll('.sm-card[data-room]')];
467
+ for (const one of cards) one.hidden = false;
468
+ if (!cards.length) { drawerMore.hidden = true; return; }
469
+ // How many fit in a row is the grid's to say, at this width: 4, 3 or 2.
470
+ const first = cards[0].getBoundingClientRect();
471
+ const perRow = Math.max(1, Math.round((ui.projects.getBoundingClientRect().width + 16) / ((first.width || 1) + 16)));
472
+ const room = perRow * DRAWER_ROWS;
473
+ const out = cards.slice(room);
474
+ if (!out.length) { drawerMore.hidden = true; return; }
475
+ if (!drawerOpen) for (const one of out) one.hidden = true;
476
+ const rooms = out.map((one) => data.rooms.find((room) => room.id === one.dataset.room)).filter(Boolean);
477
+ const open = rooms.filter((room) => room.open).length;
478
+ const asleep = rooms.length - open;
479
+ drawerMore.hidden = false;
480
+ drawerMore.replaceChildren();
481
+ if (drawerOpen) drawerMore.append(el('span', null, t('Show fewer')));
482
+ else {
483
+ drawerMore.append(el('b', null, `+${out.length}`));
484
+ const parts = [open ? t('{n} open', { n: open }) : null, asleep ? t('{n} asleep', { n: asleep }) : null].filter(Boolean);
485
+ drawerMore.append(el('span', null, parts.join(' · ')));
486
+ }
487
+ drawerMore.setAttribute('aria-expanded', String(drawerOpen));
488
+ drawerMore.title = drawerOpen ? t('Show three rows') : t('{n} more projects: {parts}', { n: out.length, parts: [open ? t('{n} open', { n: open }) : null, asleep ? t('{n} asleep', { n: asleep }) : null].filter(Boolean).join(', ') });
489
+ }
490
+ drawerMore?.addEventListener('click', () => { drawerOpen = !drawerOpen; fitDrawer(); fitTiles(); });
491
+ if (typeof ResizeObserver === 'function') {
492
+ let wide = 0;
493
+ new ResizeObserver(() => { const now = Math.round(ui.projects.getBoundingClientRect().width); if (now && now !== wide) { wide = now; fitDrawer(); } }).observe(ui.projects);
494
+ }
411
495
  ui.search.addEventListener('input', () => { query = ui.search.value.trim(); drawProjects(); });
412
496
  for (const choice of ui.filter.querySelectorAll('[data-filter]')) {
413
497
  choice.addEventListener('click', () => {
@@ -417,6 +501,31 @@ for (const choice of ui.filter.querySelectorAll('[data-filter]')) {
417
501
  });
418
502
  }
419
503
 
504
+ // ORDER, where the projects are (2026-10-13): the same four orders as Home's Appearance, one press
505
+ // away from the list they arrange. «Yours» is the order made by dragging a card or by its
506
+ // right-click › Move; the menu says so, so the way to make it is never a secret.
507
+ const SORT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4.5v11M3.5 13 6 15.5 8.5 13"/><path d="M11 6h5.5M11 10h4M11 14h2.5"/></svg>';
508
+ const orderKey = button('sm-btn sm-order', null, () => {
509
+ const box = orderKey.getBoundingClientRect();
510
+ const now = readLooks().home.order;
511
+ homeMenu({ x: box.left, y: box.bottom + 6 }, [
512
+ { heading: t('Order the projects') },
513
+ ...HOME_LOOKS.order.values.map((value) => ({ words: HOME_LOOKS.order.words[value](), icon: SORT_ICON, checked: now === value, act: () => saveHome('order', value) })),
514
+ 'sep',
515
+ { heading: t('Drag a card, or right-click it › Move, to make your own.') },
516
+ ], t('Order the projects'));
517
+ });
518
+ ui.filter.after(orderKey);
519
+ function paintOrderKey() {
520
+ const now = readLooks().home.order;
521
+ orderKey.innerHTML = SORT_ICON; // trusted: our own SVG string
522
+ orderKey.append(el('span', null, HOME_LOOKS.order.words[now]()));
523
+ orderKey.title = t('Order the projects');
524
+ orderKey.setAttribute('aria-label', `${t('Order the projects')}: ${HOME_LOOKS.order.words[now]()}`);
525
+ orderKey.setAttribute('aria-haspopup', 'menu');
526
+ }
527
+ paintOrderKey();
528
+
420
529
  // WAITING FOR YOU: every question an agent asks the person, from every open room, counted in one
421
530
  // number; the first ones said, each leading to its room.
422
531
  function drawWaiting() {
@@ -458,7 +567,7 @@ function drawMemory() {
458
567
  const item = el('span', `k-${kind}`, `${kindWord(kind)} · ${sum.kinds[kind]}`);
459
568
  legend.append(item);
460
569
  }
461
- ui.memory.body.append(legend, el('p', 'sm-hint', [sum.memories ? t('{n} travel', { n: sum.travelling }) : null, t('Counted in the open projects.')].filter(Boolean).join(' · ')));
570
+ ui.memory.body.append(legend, el('p', 'sm-hint', [sum.memories ? t('{n} in use', { n: sum.travelling }) : null, t('Counted in the open projects.')].filter(Boolean).join(' · ')));
462
571
  }
463
572
 
464
573
  // AGENTS: the ones on this computer, with the session each has. Connected once, every project
@@ -514,6 +623,31 @@ async function restartRoom(room) {
514
623
  await startRoom(room);
515
624
  }
516
625
 
626
+ // Every project still on the older MADRE, one after the other (1.0.1). Only the quiet ones: a
627
+ // project with an agent working or a question waiting for the person is left as it is, and said.
628
+ const roomQuiet = (room) => !room.glance?.working?.length && !room.glance?.waitingCount;
629
+ let restartingAll = false;
630
+ async function restartAll() {
631
+ if (restartingAll) return;
632
+ const behind = behindRooms();
633
+ const quiet = behind.filter(roomQuiet);
634
+ const left = behind.filter((room) => !roomQuiet(room));
635
+ if (!quiet.length) { toast(t('Every project on the older MADRE is busy now: restart them when they finish.'), 'look'); return; }
636
+ restartingAll = true;
637
+ try {
638
+ for (const [index, room] of quiet.entries()) {
639
+ toast(t('Restarting {room} · {n} of {total}', { room: roomLabel(room), n: index + 1, total: quiet.length }));
640
+ await restartRoom(room);
641
+ }
642
+ } finally {
643
+ restartingAll = false;
644
+ }
645
+ toast(left.length
646
+ ? t('{n} restarted. Left as they are, because they are busy: {rooms}.', { n: quiet.length, rooms: left.map(roomLabel).join(', ') })
647
+ : t('{n} restarted with MADRE {version}.', { n: quiet.length, version: data?.version ?? '' }), left.length ? 'look' : 'done');
648
+ settingsSheet?.sync({ detail: true });
649
+ }
650
+
517
651
  // NEEDS YOU: what the person would want to know before anything else, from what the rooms, the
518
652
  // agents and MADRE itself say: a room on an older MADRE or not answering, an agent without a
519
653
  // session, a link about to expire, a newer MADRE. Nothing listed is a guess.
@@ -537,7 +671,9 @@ function attentionItems() {
537
671
  }
538
672
  if (versionSeen?.available && versionSeen.latest) items.push({ tone: 'ok', words: t('MADRE {version} is out.', { version: versionSeen.latest }), hint: t('You run {version}. Home restarts with it from here.', { version: versionSeen.current }), act: () => openSettings('release'), go: t('Update') });
539
673
  // After Home updated, the projects that stayed open still run the version they started with.
540
- for (const room of behindRooms()) items.push({ tone: 'wait', words: t('{room} runs MADRE {version}.', { room: roomLabel(room), version: room.open.version }), hint: t('Restart it to bring it to {latest}: what is running stops.', { latest: data.version }), act: () => void restartRoom(room), go: t('Restart') });
674
+ const behind = behindRooms();
675
+ if (behind.length > 1) items.push({ tone: 'wait', words: t('{n} projects run an older MADRE.', { n: behind.length }), hint: t('Restart them all to bring them to {latest}. A project that is working or waiting for you is left for later.', { latest: data.version }), act: () => void restartAll(), go: t('Restart all') });
676
+ else for (const room of behind) items.push({ tone: 'wait', words: t('{room} runs MADRE {version}.', { room: roomLabel(room), version: room.open.version }), hint: t('Restart it to bring it to {latest}: what is running stops.', { latest: data.version }), act: () => void restartRoom(room), go: t('Restart') });
541
677
  return items;
542
678
  }
543
679
  function drawAttention() {
@@ -590,6 +726,7 @@ function drawDecisions() {
590
726
  item.append(head, el('p', null, one.text));
591
727
  list.append(item);
592
728
  }
729
+ if (typeof fitTiles === 'function') fitTiles();
593
730
  }
594
731
  // A project's name as a small tag: a way into it when it is open.
595
732
  function roomTag(id, name) {
@@ -644,8 +781,9 @@ function drawWork() {
644
781
  w.note.textContent = state === 'working' ? t('MADRE is summarising your latest conversations…')
645
782
  : state === 'unavailable' ? (bullets.length ? t('MADRE\'s local model is off: this is the last summary it wrote.') : t('MADRE\'s local model is off: these are your latest conversations, as they are.'))
646
783
  : state === 'failed' ? t('MADRE could not summarise them this time: these are your latest conversations, as they are.')
647
- : work.stale ? t('There is newer work than this summary: it is written again in a few minutes, or now if you ask.')
784
+ : work.stale ? t('There is newer work than this summary: it updates itself in a few minutes, or now if you press Summarize again.')
648
785
  : t('Written by MADRE on this Mac from your latest conversations.');
786
+ if (typeof fitTiles === 'function') fitTiles();
649
787
  }
650
788
  ui.work.again.addEventListener('click', () => void loadWork({ again: true }));
651
789
 
@@ -664,7 +802,10 @@ function linkRow(enlace) {
664
802
  const { source, reader } = endsOf(enlace);
665
803
  const node = el('article', `sm-link${revoking.has(enlace.id) ? ' revoking' : ''}`);
666
804
  node.dataset.link = enlace.id;
667
- node.append(el('p', 'who', enlace.direction === 'both' ? t('{a} and {b} read each other', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) }) : t('{reader} reads from {source}', { reader: roomName(reader), source: roomName(source) })));
805
+ node.append(el('span', 'sm-link-kind', source === data.salas || reader === data.salas ? t('With the Coordinator') : t('Between projects')));
806
+ const who = el('p', 'who');
807
+ who.append(enlace.direction === 'both' ? sayTinted('{a} and {b} read each other', { a: enlace.rooms.a, b: enlace.rooms.b }) : sayTinted('{reader} reads from {source}', { reader, source }));
808
+ node.append(who);
668
809
  const what = [kindsWords(enlace.kinds), enlace.expires ? t('until {date}', { date: new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'short' }) }) : t('no expiry')];
669
810
  if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
670
811
  // D-S13: a link BISHOP signed on its own says so, so it is never mistaken for one you signed.
@@ -694,7 +835,10 @@ function linkRow(enlace) {
694
835
  return node;
695
836
  }
696
837
  function drawLinks(projects) {
697
- const enlaces = data.enlaces ?? [];
838
+ const enlaces = [...(data.enlaces ?? [])].sort((a, b) => {
839
+ const direct = (one) => Number(one.rooms.a !== data.salas && one.rooms.b !== data.salas);
840
+ return direct(b) - direct(a) || roomName(endsOf(a).reader).localeCompare(roomName(endsOf(b).reader)) || roomName(endsOf(a).source).localeCompare(roomName(endsOf(b).source));
841
+ });
698
842
  const enough = data.rooms.length >= 2;
699
843
  ui.link.hidden = !enough;
700
844
  ui.linkMore.hidden = !enough;
@@ -703,34 +847,29 @@ function drawLinks(projects) {
703
847
  ui.linksSum.textContent = enlaces.length
704
848
  ? [enlaces.length === 1 ? t('1 in force') : t('{n} in force', { n: enlaces.length }), t('{n} reads in 24 h', { n: reads })].join(' · ')
705
849
  : enough ? t('No connections yet.') : t('Add a project to connect it.');
706
- ui.linksNote.textContent = t('A connection lets one project read the distilled notes of another, read-only, by a contract you sign here. Nothing is written across, and only notes that travel into turns cross.');
850
+ ui.linksNote.textContent = t('A connection lets one project read the summarized notes of another, read-only, by a contract you sign here. Nothing is written across, and only notes already used in answers are shared.');
707
851
  ui.links.replaceChildren(...enlaces.map(linkRow));
708
- drawMapLegend(enlaces);
709
852
  if (!enlaces.length && ui.linksSheet.open) ui.linksSheet.close();
710
853
  drawMap(projects, enlaces);
711
854
  }
712
- // Under the map, each connection in words, who looks up whom (2026-10-11): the first two, and how
713
- // many more there are; the whole list is one press away.
714
- const mapLegend = el('ul', 'sm-map-legend');
715
- function drawMapLegend(enlaces) {
716
- if (!mapLegend.isConnected) ui.map.after(mapLegend);
717
- mapLegend.replaceChildren();
718
- mapLegend.hidden = !enlaces.length;
719
- const said = (enlace) => {
720
- const { source, reader } = endsOf(enlace);
721
- if (enlace.direction === 'both') return sayBold('{a} and {b} look each other up', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) });
722
- if (reader === data.salas) return sayBold('The Coordinator looks up {source}', { source: roomName(source) });
723
- return sayBold('{reader} looks up {source}', { reader: roomName(reader), source: roomName(source) });
724
- };
725
- for (const enlace of enlaces.slice(0, 2)) {
726
- const line = el('li');
727
- const words = el('span', 'says');
728
- words.append(said(enlace));
729
- line.append(el('i'), words);
730
- if (enlace.orders?.allowed) line.append(el('span', 'sm-map-orders', t('orders up to #{n}', { n: enlace.orders.maxMode })));
731
- mapLegend.append(line);
855
+ // A sentence whose project names wear their tone (the map's): `vars` names room ids.
856
+ function sayTinted(key, vars) {
857
+ const out = document.createDocumentFragment();
858
+ const template = t(key);
859
+ let at = 0;
860
+ for (const match of template.matchAll(/\{(\w+)\}/g)) {
861
+ out.append(template.slice(at, match.index));
862
+ const id = vars[match[1]];
863
+ if (id === undefined) out.append(match[0]);
864
+ else {
865
+ const name = el('b', `sm-tinted${id === data.salas ? ' hub' : ''}`, roomName(id));
866
+ if (id !== data.salas) name.style.setProperty('--pc', toneOf(id));
867
+ out.append(name);
868
+ }
869
+ at = match.index + match[0].length;
732
870
  }
733
- if (enlaces.length > 2) mapLegend.append(el('li', 'more', t('and {n} more', { n: enlaces.length - 2 })));
871
+ out.append(template.slice(at));
872
+ return out;
734
873
  }
735
874
  async function revokeLink(id) {
736
875
  revoking.delete(id);
@@ -738,14 +877,17 @@ async function revokeLink(id) {
738
877
  await refresh();
739
878
  }
740
879
  ui.linksAll.addEventListener('click', () => { if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
880
+ // The tile's map is the small one: a press opens the map of connections in its own window.
881
+ ui.map.addEventListener('click', (event) => { if (event.target.closest('svg') && (data?.enlaces ?? []).length) openGraph(); });
882
+ // Closed, the sheet has no size to draw at: the wide map is drawn each time it opens.
883
+
741
884
  ui.linksClose.addEventListener('click', () => ui.linksSheet.close());
742
885
  ui.linkMore.addEventListener('click', () => openLinkSheet());
743
886
 
744
- // The map of the links: BISHOP in the middle, the projects round it (the first ten, in the
745
- // lobby's order), and a wire for each contract in force, from the room that serves to the one
746
- // that reads, the notes running along it the way they travel. Drawn at the tile's own size
747
- // (2026-10-11), so the names read at their real size: the projects a connection touches are
748
- // drawn whole and named in full; the rest stay quiet round the edge, a dashed ring when asleep.
887
+ // The map of the links (redrawn 2026-10-13): the Coordinator in the middle and, round it, the
888
+ // projects a contract touches, each in its own tone of the accent the person chose, so a project
889
+ // is the same colour on the map, on its wires and in the words under it. The tile draws only the
890
+ // connected ones, large enough to read; the sheet («See the connections») draws them all, wide.
749
891
  // Nothing here is drawn that the contracts do not say.
750
892
  const SVG = 'http://www.w3.org/2000/svg';
751
893
  const svgNode = (tag, attributes = {}) => {
@@ -753,44 +895,234 @@ const svgNode = (tag, attributes = {}) => {
753
895
  for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value));
754
896
  return node;
755
897
  };
898
+ // A project's tone: the accent turned round the colour wheel, keeping its own lightness and
899
+ // strength. The turns are shared out evenly among the projects a connection touches (in a steady
900
+ // order, by id), so no two of them look alike; the first wears the accent itself. Where the
901
+ // browser cannot turn a colour (no relative colour syntax), every project wears the accent.
902
+ const TURNS = typeof CSS !== 'undefined' && CSS.supports?.('color', 'oklch(from red l c h)');
903
+ function toneOf(id) {
904
+ const linked = [...new Set((data?.enlaces ?? []).flatMap((enlace) => [enlace.rooms.a, enlace.rooms.b]))].filter((one) => one !== data?.salas).sort();
905
+ const index = linked.indexOf(id);
906
+ if (!TURNS || index <= 0) return 'var(--s-acc)';
907
+ const step = 360 / Math.max(5, linked.length);
908
+ return `oklch(from var(--s-acc) l c calc(h + ${(index * step).toFixed(1)}))`;
909
+ }
910
+ // One project at a time (2026-10-13): under the pointer, or chosen in the window, a project
911
+ // lights its own connections and the projects at their other end; the rest steps back.
912
+ function focusable(svg, { pick = null, picked = null } = {}) {
913
+ const light = (id) => {
914
+ svg.classList.toggle('focusing', Boolean(id));
915
+ const near = new Set(id ? [id] : []);
916
+ for (const wire of svg.querySelectorAll('.wire')) {
917
+ const on = Boolean(id) && (wire.dataset.a === id || wire.dataset.b === id);
918
+ wire.classList.toggle('lit', Boolean(on || (picked?.link && wire.dataset.link === picked.link)));
919
+ if (on) { near.add(wire.dataset.a); near.add(wire.dataset.b); }
920
+ }
921
+ for (const dot of svg.querySelectorAll('.note, .flow')) dot.classList.toggle('lit', Boolean(id) && (dot.dataset.a === id || dot.dataset.b === id));
922
+ for (const node of svg.querySelectorAll('.node, .bishop')) node.classList.toggle('lit', near.has(node.dataset.id));
923
+ };
924
+ const resting = () => {
925
+ if (picked?.room) return light(picked.room);
926
+ if (picked?.link) {
927
+ const wire = svg.querySelector(`.wire[data-link="${CSS.escape(picked.link)}"]`);
928
+ svg.classList.add('focusing');
929
+ wire?.classList.add('lit');
930
+ for (const node of svg.querySelectorAll('.node, .bishop')) node.classList.toggle('lit', Boolean(wire) && (node.dataset.id === wire.dataset.a || node.dataset.id === wire.dataset.b));
931
+ return;
932
+ }
933
+ light(null);
934
+ };
935
+ for (const node of svg.querySelectorAll('.node, .bishop')) {
936
+ node.addEventListener('pointerenter', () => light(node.dataset.id));
937
+ node.addEventListener('pointerleave', resting);
938
+ if (pick) {
939
+ node.classList.add('pickable');
940
+ node.addEventListener('click', (event) => { event.stopPropagation(); pick({ room: node.dataset.id }); });
941
+ }
942
+ }
943
+ if (pick) {
944
+ for (const wire of svg.querySelectorAll('.wire')) {
945
+ wire.classList.add('pickable');
946
+ // A line is thin; the hand gets a wider, invisible one along it.
947
+ const hit = svgNode('path', { class: 'hit', d: wire.getAttribute('d') });
948
+ hit.addEventListener('click', (event) => { event.stopPropagation(); pick({ link: wire.dataset.link }); });
949
+ hit.addEventListener('pointerenter', () => wire.classList.add('hover'));
950
+ hit.addEventListener('pointerleave', () => wire.classList.remove('hover'));
951
+ wire.after(hit);
952
+ }
953
+ svg.addEventListener('click', () => pick(null));
954
+ }
955
+ resting();
956
+ }
957
+
958
+ // THE MAP OF CONNECTIONS, in a window of its own: the same drawing, wide, every project round the
959
+ // Coordinator, and beside it what was chosen — a project (whom it reads, who reads it) or a line
960
+ // (what crosses, until when, how often it was read). Opened from the tile's map.
961
+ let graphWindow = null;
962
+ function openGraph() {
963
+ if (graphWindow?.open) return;
964
+ graphWindow?.remove();
965
+ graphWindow = el('dialog', 'sm-sheet sm-graph');
966
+ const body = el('div', 'sm-sheet-body');
967
+ const head = el('header', 'sm-sheet-head');
968
+ const words = el('div', 'sm-graph-title');
969
+ words.append(el('h3', null, t('Map of connections')), el('small', null, t('Who looks up whom. Touch a project or a line.')));
970
+ const keys = el('span', 'surface-modes');
971
+ const close = button('surface-mode close x-close x-only', null, () => graphWindow.close());
972
+ close.setAttribute('aria-label', t('Close'));
973
+ close.innerHTML = '<span class="x-icon"><svg viewBox="0 0 20 20" focusable="false" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>'; // trusted
974
+ keys.append(close);
975
+ head.append(words, keys);
976
+ const stage = el('div', 'sm-graph-stage');
977
+ const canvas = el('div', 'sm-map sm-map-wide sm-graph-canvas');
978
+ const side = el('aside', 'sm-graph-side');
979
+ stage.append(canvas, side);
980
+ body.append(head, stage);
981
+ graphWindow.append(body);
982
+ document.body.append(graphWindow);
983
+ let picked = null;
984
+ const paint = () => {
985
+ if (!graphWindow?.isConnected || !mapDrawn) return;
986
+ paintMap(canvas, mapDrawn.projects, mapDrawn.enlaces, { wide: true, pick: (next) => { picked = next; paint(); }, picked });
987
+ paintSide(side, picked, () => { graphWindow.close(); if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
988
+ };
989
+ const watch = typeof ResizeObserver === 'function' ? new ResizeObserver(() => paint()) : null;
990
+ graphWindow.addEventListener('close', () => { watch?.disconnect(); graphWindow?.remove(); graphWindow = null; });
991
+ graphWindow.repaint = paint;
992
+ graphWindow.showModal();
993
+ watch?.observe(canvas);
994
+ requestAnimationFrame(paint);
995
+ }
996
+ // What the side of the window says about the chosen thing, only what the contracts and the lobby say.
997
+ function paintSide(side, picked, toList) {
998
+ side.replaceChildren();
999
+ const enlaces = mapDrawn?.enlaces ?? [];
1000
+ const reads = enlaces.reduce((sum, enlace) => sum + (enlace.traffic?.reads24h ?? 0), 0);
1001
+ const listKey = button('sm-btn ghost small', t('See the connections'), toList);
1002
+ if (!picked) {
1003
+ side.append(el('b', 'sm-graph-big', String(enlaces.length)), el('span', 'sm-graph-sub', enlaces.length === 1 ? t('connection in force') : t('connections in force')));
1004
+ side.append(el('b', 'sm-graph-big', String(reads)), el('span', 'sm-graph-sub', t('reads in the last 24 h')));
1005
+ side.append(el('p', 'sm-hint', t('Each line goes from the project whose notes are read to the one that reads them, in the colour of where they come from.')));
1006
+ const quiet = (() => { try { return JSON.parse(side.previousElementSibling?.dataset.quiet ?? '[]'); } catch { return []; } })();
1007
+ if (quiet.length) {
1008
+ const box = el('div', 'sm-graph-list');
1009
+ box.append(el('span', 'sm-graph-label', t('Not connected')));
1010
+ box.append(el('span', 'sm-graph-quiet', quiet.map((id) => roomName(id)).join(' · ')));
1011
+ side.append(box);
1012
+ }
1013
+ side.append(listKey);
1014
+ return;
1015
+ }
1016
+ if (picked.room) {
1017
+ const id = picked.room;
1018
+ const room = data.rooms.find((one) => one.id === id);
1019
+ const name = el('b', `sm-tinted sm-graph-name${id === data.salas ? ' hub' : ''}`, roomName(id));
1020
+ if (id !== data.salas) name.style.setProperty('--pc', toneOf(id));
1021
+ side.append(name);
1022
+ if (room && id !== data.salas) side.append(el('span', 'sm-graph-sub', stateOf(room).words));
1023
+ const readsFrom = enlaces.filter((enlace) => endsOf(enlace).reader === id || (enlace.direction === 'both' && (enlace.rooms.a === id || enlace.rooms.b === id))).map((enlace) => { const { source, reader } = endsOf(enlace); return source === id ? reader : source; });
1024
+ const readBy = enlaces.filter((enlace) => endsOf(enlace).source === id || (enlace.direction === 'both' && (enlace.rooms.a === id || enlace.rooms.b === id))).map((enlace) => { const { source, reader } = endsOf(enlace); return reader === id ? source : reader; });
1025
+ const list = (title, ids) => {
1026
+ const box = el('div', 'sm-graph-list');
1027
+ box.append(el('span', 'sm-graph-label', title));
1028
+ if (!ids.length) box.append(el('span', 'sm-graph-sub', t('nobody')));
1029
+ for (const one of [...new Set(ids)]) { const who = el('b', `sm-tinted${one === data.salas ? ' hub' : ''}`, roomName(one)); if (one !== data.salas) who.style.setProperty('--pc', toneOf(one)); box.append(who); }
1030
+ side.append(box);
1031
+ };
1032
+ list(t('Looks up'), readsFrom);
1033
+ list(t('Looked up by'), readBy);
1034
+ if (room?.open && id !== data.salas) side.append(button('sm-btn primary small', t('Open the project'), () => leaveTo(addressOf(room))));
1035
+ side.append(listKey);
1036
+ return;
1037
+ }
1038
+ const enlace = enlaces.find((one) => one.id === picked.link);
1039
+ if (!enlace) { side.append(el('p', 'sm-hint', t('That connection is no longer in force.')), listKey); return; }
1040
+ const { source, reader } = endsOf(enlace);
1041
+ const who = el('p', 'sm-graph-who');
1042
+ who.append(enlace.direction === 'both' ? sayTinted('{a} and {b} read each other', { a: enlace.rooms.a, b: enlace.rooms.b }) : sayTinted('{reader} reads from {source}', { reader, source }));
1043
+ side.append(who);
1044
+ const fact = (label, value) => { const row = el('div', 'sm-graph-fact'); row.append(el('span', 'sm-graph-label', label), el('span', null, value)); side.append(row); };
1045
+ fact(t('What crosses'), kindsWords(enlace.kinds) || '—');
1046
+ fact(t('Until'), enlace.expires ? new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'long' }) : t('no expiry'));
1047
+ fact(t('Reads in 24 h'), String(enlace.traffic?.reads24h ?? 0));
1048
+ if (enlace.orders?.allowed) fact(t('Orders'), t('up to #{n}', { n: enlace.orders.maxMode }));
1049
+ if (enlace.signed?.by === 'bishop') fact(t('Signed by'), t('Coordinator'));
1050
+ side.append(listKey);
1051
+ }
1052
+
756
1053
  let mapDrawn = null;
757
1054
  function drawMap(projects, enlaces) {
758
1055
  mapDrawn = { projects, enlaces };
759
- ui.map.replaceChildren();
760
- if (!projects.length) { ui.map.append(el('p', 'sm-hint', t('Your projects show here, round the Coordinator, with the connections between them.'))); return; }
761
- const box = ui.map.getBoundingClientRect();
762
- const W = Math.max(220, Math.round(box.width || 320)), H = Math.max(140, Math.round(box.height || 200));
763
- const cx = W / 2, cy = H / 2 - 4;
764
- const shown = projects.slice(0, 10);
1056
+ paintMap(ui.map, projects, enlaces, { wide: false });
1057
+ graphWindow?.repaint?.();
1058
+ }
1059
+ function paintMap(host, projects, enlaces, { wide, pick = null, picked = null }) {
1060
+ host.replaceChildren();
1061
+ if (!projects.length) { host.append(el('p', 'sm-hint', t('Your projects show here, round the Coordinator, with the connections between them.'))); return; }
1062
+ const box = host.getBoundingClientRect();
1063
+ const W = Math.max(240, Math.round(box.width || 320)), H = Math.max(170, Math.round(box.height || 220));
1064
+ const cx = W / 2, cy = H / 2;
765
1065
  const linked = new Set(enlaces.flatMap((enlace) => [enlace.rooms.a, enlace.rooms.b]));
766
- const rx = Math.max(64, W / 2 - 50), ry = Math.max(36, H / 2 - 26);
767
- // The Coordinator's halo (drawn while it is connected) is part of its body for the wires.
768
- const at = new Map([[data.salas, { x: cx, y: cy, r: 17, pad: linked.has(data.salas) ? 7 : 0, up: false }]]);
769
- shown.forEach((room, index) => {
770
- const angle = -Math.PI / 2 + (index * 2 * Math.PI) / shown.length + (shown.length === 2 ? Math.PI / 2 : 0);
1066
+ const connected = projects.filter((room) => linked.has(room.id));
1067
+ // Only who is connected goes round the Coordinator, evenly; the rest are named apart (the tile's
1068
+ // last line, the window's side), so the drawing is never weighed down on one side.
1069
+ const ring = (connected.length ? connected : projects).slice(0, wide ? 18 : 10);
1070
+ const quiet = projects.length - ring.length;
1071
+ const name = (room) => (room.name.length > 16 ? `${room.name.slice(0, 15)}…` : room.name);
1072
+ // The ring leaves room for the longest name on either side, so nothing is cut at the edge.
1073
+ const longest = Math.max(40, ...ring.map((room) => name(room).length * (wide ? 7.4 : 7)));
1074
+ let rx = Math.max(60, W / 2 - longest - 18), ry = Math.max(44, H / 2 - (wide ? 34 : 26));
1075
+ // Wide, the ring is a circle: an abstract map, not a shape stretched to its frame.
1076
+ if (wide) rx = ry = Math.max(60, Math.min(rx, ry));
1077
+ const hubR = wide ? 21 : 17;
1078
+ const at = new Map([[data.salas, { x: cx, y: cy, r: hubR, pad: linked.has(data.salas) ? 6 : 0 }]]);
1079
+ ring.forEach((room, index) => {
1080
+ const angle = -Math.PI / 2 + (index * 2 * Math.PI) / ring.length + (ring.length === 2 ? Math.PI / 2 : 0);
771
1081
  const memories = room.glance?.census?.memories ?? 0;
772
- const r = (room.open ? 6 + Math.min(4, Math.sqrt(memories) / 3) : 5.5) + (linked.has(room.id) ? 1 : 0);
773
- // A name above its body in the upper half, below it in the lower, so none runs into the middle.
774
- at.set(room.id, { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry, r, up: Math.sin(angle) < -0.35 });
1082
+ const r = linked.has(room.id) ? (wide ? 8 : 7) + Math.min(3, Math.sqrt(memories) / 4) : 4.5;
1083
+ at.set(room.id, { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry, r, cos: Math.cos(angle), sin: Math.sin(angle) });
775
1084
  });
776
1085
  const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img' });
777
1086
  svg.setAttribute('aria-label', ui.linksSum.textContent);
778
1087
  const defs = svgNode('defs');
779
- const arrow = svgNode('marker', { id: 'sm-arrow', viewBox: '0 0 10 10', refX: 7, refY: 5, markerWidth: 7, markerHeight: 7, markerUnits: 'userSpaceOnUse', orient: 'auto-start-reverse' });
780
- arrow.append(svgNode('path', { d: 'M1 1.5L8 5L1 8.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }));
781
- defs.append(arrow);
782
1088
  svg.append(defs);
783
- // From the edge of one body to the edge of the other.
784
- const edges = (from, to, gapFrom = 3, gapTo = 4) => {
1089
+ // STARS (2026-10-13): each project is a point of light in its tone, its glow a radial gradient
1090
+ // that fades to nothing; the notes on their way are sparks. The theme decides how bright
1091
+ // (salas-mac.css): a lit sky in the dark, a quieter one in the light, where the text must read.
1092
+ const glowId = `sm-glow-${wide ? 'w' : 't'}`;
1093
+ const glow = svgNode('filter', { id: glowId, x: '-200%', y: '-200%', width: '500%', height: '500%' });
1094
+ glow.append(svgNode('feGaussianBlur', { stdDeviation: wide ? 2.2 : 1.8, result: 'soft' }));
1095
+ const merge = svgNode('feMerge');
1096
+ merge.append(svgNode('feMergeNode', { in: 'soft' }), svgNode('feMergeNode', { in: 'SourceGraphic' }));
1097
+ glow.append(merge);
1098
+ defs.append(glow);
1099
+ const halos = new Map();
1100
+ const haloFor = (id, colour) => {
1101
+ if (halos.has(id)) return halos.get(id);
1102
+ const ref = `sm-halo-${wide ? 'w' : 't'}-${halos.size}`;
1103
+ const gradient = svgNode('radialGradient', { id: ref });
1104
+ for (const [offset, opacity] of [['0%', 0.95], ['38%', 0.42], ['100%', 0]]) {
1105
+ const stop = svgNode('stop', { offset, 'stop-opacity': opacity });
1106
+ stop.style.stopColor = colour;
1107
+ gradient.append(stop);
1108
+ }
1109
+ defs.append(gradient);
1110
+ halos.set(id, `url(#${ref})`);
1111
+ return halos.get(id);
1112
+ };
1113
+ const edges = (from, to, gapFrom = 4, gapTo = 5) => {
785
1114
  const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy) || 1;
786
1115
  const ux = dx / length, uy = dy / length;
787
1116
  const reach = (spot, gap) => spot.r + (spot.pad ?? 0) + gap;
788
1117
  return { start: { x: from.x + ux * reach(from, gapFrom), y: from.y + uy * reach(from, gapFrom) }, end: { x: to.x - ux * reach(to, gapTo), y: to.y - uy * reach(to, gapTo) } };
789
1118
  };
1119
+ // A hairline from the middle to each project on the ring that no contract touches: it is there,
1120
+ // and nothing crosses.
790
1121
  const centre = at.get(data.salas);
791
- for (const room of shown) {
792
- const { start, end } = edges(centre, at.get(room.id), 2, 2);
793
- svg.append(svgNode('line', { class: `spoke${linked.has(room.id) ? ' linked' : ''}`, x1: start.x.toFixed(1), y1: start.y.toFixed(1), x2: end.x.toFixed(1), y2: end.y.toFixed(1) }));
1122
+ for (const room of ring) {
1123
+ if (linked.has(room.id)) continue;
1124
+ const { start, end } = edges(centre, at.get(room.id), 3, 3);
1125
+ svg.append(svgNode('line', { class: 'spoke', x1: start.x.toFixed(1), y1: start.y.toFixed(1), x2: end.x.toFixed(1), y2: end.y.toFixed(1) }));
794
1126
  }
795
1127
  const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
796
1128
  enlaces.forEach((enlace, index) => {
@@ -798,70 +1130,674 @@ function drawMap(projects, enlaces) {
798
1130
  const from = at.get(source), to = at.get(reader);
799
1131
  if (!from || !to) return;
800
1132
  const { start, end } = edges(from, to);
801
- // With the Coordinator at one end the wire runs straight along its spoke; between two
802
- // projects it bows outwards, away from the middle, so it never crosses the Coordinator.
1133
+ // To or from the Coordinator the wire is straight; between two projects it bows away from the
1134
+ // middle, so it never runs through the Coordinator.
803
1135
  let d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} L${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
804
1136
  if (source !== data.salas && reader !== data.salas) {
805
1137
  const mx = (start.x + end.x) / 2, my = (start.y + end.y) / 2;
806
1138
  const ox = mx - cx, oy = my - cy, out = Math.hypot(ox, oy) || 1;
807
- d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${(mx + (ox / out) * 34).toFixed(1)} ${(my + (oy / out) * 34).toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
1139
+ const bow = Math.min(46, Math.hypot(end.x - start.x, end.y - start.y) * 0.28);
1140
+ d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${(mx + (ox / out) * bow).toFixed(1)} ${(my + (oy / out) * bow).toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
808
1141
  }
809
- const id = `sm-wire-${index}`;
810
- const wire = svgNode('path', { id, class: 'wire', d, 'marker-end': 'url(#sm-arrow)' });
811
- if (enlace.direction === 'both') wire.setAttribute('marker-start', 'url(#sm-arrow)');
1142
+ // The wire wears the tone of the project the notes come from.
1143
+ const tone = source === data.salas ? 'var(--text-3)' : toneOf(source);
1144
+ const id = `sm-wire-${wide ? 'w' : 't'}-${index}`;
1145
+ const wire = svgNode('path', { id, class: 'wire', d });
1146
+ wire.style.stroke = tone;
1147
+ wire.dataset.a = source;
1148
+ wire.dataset.b = reader;
1149
+ wire.dataset.link = enlace.id;
1150
+ const title = svgNode('title');
1151
+ title.textContent = enlace.direction === 'both' ? t('{a} and {b} look each other up', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) }) : reader === data.salas ? t('The Coordinator looks up {source}', { source: roomName(source) }) : t('{reader} looks up {source}', { reader: roomName(reader), source: roomName(source) });
1152
+ wire.append(title);
812
1153
  svg.append(wire);
813
- if (still) return;
814
- // The notes on their way: two dots one after the other, back the other way too when both read.
815
- const ways = enlace.direction === 'both' ? [['0;1', 0], ['1;0', 1.2]] : [['0;1', 0]];
816
- for (const [points, offset] of ways) {
817
- for (const lag of [0, 1.2]) {
818
- const dot = svgNode('circle', { class: 'note', r: 2.6, opacity: 0 });
819
- const begin = `${(lag + offset + index * 0.4).toFixed(1)}s`;
820
- const move = svgNode('animateMotion', { dur: '2.4s', repeatCount: 'indefinite', begin, keyPoints: points, keyTimes: '0;1', calcMode: 'linear' });
821
- const along = svgNode('mpath');
822
- along.setAttribute('href', `#${id}`);
823
- move.append(along);
824
- dot.append(move, svgNode('animate', { attributeName: 'opacity', values: '0;1;1;0', keyTimes: '0;.15;.85;1', dur: '2.4s', repeatCount: 'indefinite', begin }));
825
- svg.append(dot);
826
- }
1154
+ // ELECTRIC FLOW (2026-10-13): no arrowheads; the current runs along the line the way the notes
1155
+ // travel, from the project read to the one reading, and both ways when they read each other.
1156
+ // A still line under it says the connection is there even when nothing moves.
1157
+ const ways = enlace.direction === 'both' ? [['out', tone], ['back', reader === data.salas ? 'var(--text-3)' : toneOf(reader)]] : [['out', tone]];
1158
+ for (const [way, colour] of ways) {
1159
+ const flow = svgNode('path', { class: `flow ${way}`, d, filter: `url(#${glowId})` });
1160
+ flow.style.stroke = colour;
1161
+ flow.style.setProperty('--flow-delay', `${(index * 0.37) % 1.4}s`);
1162
+ flow.dataset.a = source;
1163
+ flow.dataset.b = reader;
1164
+ if (still) flow.classList.add('still');
1165
+ svg.append(flow);
827
1166
  }
828
1167
  });
829
- for (const room of shown) {
1168
+ for (const room of ring) {
830
1169
  const spot = at.get(room.id);
831
1170
  const on = linked.has(room.id);
832
- const body = svgNode('circle', { class: `node${room.open ? '' : ' asleep'}${on ? ' linked' : ''}${room.glance?.waitingCount ? ' waiting' : ''}`, cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: spot.r.toFixed(1) });
1171
+ const group = svgNode('g', { class: `node${on ? ' linked' : ''}${room.open ? '' : ' asleep'}${room.glance?.waitingCount ? ' waiting' : ''}` });
1172
+ group.style.setProperty('--pc', toneOf(room.id));
1173
+ group.dataset.id = room.id;
1174
+ if (on && room.glance?.waitingCount) group.append(svgNode('circle', { class: 'wait', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r + 5).toFixed(1) }));
1175
+ // The star: a halo that breathes, a core of its tone and a white heart. Each one twinkles at its
1176
+ // own pace, so the sky never pulses as one.
1177
+ const star = svgNode('g', { class: 'star' });
1178
+ star.style.setProperty('--tw', `${(2.6 + ((room.id.length * 37) % 19) / 10).toFixed(1)}s`);
1179
+ star.style.setProperty('--tw-delay', `${(((room.id.charCodeAt(0) ?? 0) * 13) % 23) / 10}s`);
1180
+ star.append(
1181
+ svgNode('circle', { class: 'halo', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r * (on ? 2.9 : 2.2)).toFixed(1), fill: haloFor(room.id, toneOf(room.id)) }),
1182
+ svgNode('circle', { class: 'body', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r * (on ? 0.82 : 0.9)).toFixed(1) }),
1183
+ svgNode('circle', { class: 'heart', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: Math.max(0.9, spot.r * 0.22).toFixed(1) }),
1184
+ );
1185
+ group.append(star);
833
1186
  const title = svgNode('title');
834
1187
  title.textContent = `${room.name} · ${stateOf(room).words}`;
835
- body.append(title);
836
- const name = svgNode('text', { class: `label${on ? ' linked' : ''}`, x: spot.x.toFixed(1), y: (spot.up ? spot.y - spot.r - 6 : spot.y + spot.r + 13).toFixed(1), 'text-anchor': 'middle' });
837
- name.textContent = room.name.length > 16 ? `${room.name.slice(0, 15)}…` : room.name;
838
- svg.append(body, name);
1188
+ group.append(title);
1189
+ // The name sits outside the ring, on the side its project is on: to the right on the right,
1190
+ // to the left on the left, above or below at the top and the bottom.
1191
+ const side = Math.abs(spot.cos) < 0.32 ? 'middle' : spot.cos > 0 ? 'start' : 'end';
1192
+ const gap = spot.r + 7;
1193
+ const x = side === 'middle' ? spot.x : spot.x + Math.sign(spot.cos) * gap;
1194
+ const y = side === 'middle' ? (spot.sin < 0 ? spot.y - gap - 2 : spot.y + gap + 10) : spot.y + 4;
1195
+ const label = svgNode('text', { class: 'label', x: x.toFixed(1), y: y.toFixed(1), 'text-anchor': side });
1196
+ label.textContent = name(room);
1197
+ group.append(label);
1198
+ svg.append(group);
839
1199
  }
840
- // BISHOP, in its own colours, with its own mark (brands.js), and a halo while it is connected.
1200
+ // The Coordinator, in its own colours and mark (brands.js).
841
1201
  const brand = brandOf('bishop');
842
1202
  const hub = svgNode('g', { class: `bishop${linked.has(data.salas) ? ' linked' : ''}` });
843
- if (linked.has(data.salas)) hub.append(svgNode('circle', { class: 'halo', cx, cy, r: 23 }));
844
- hub.append(svgNode('circle', { cx, cy, r: 17, fill: brand.solid ?? brand.color }));
845
- const face = svgNode('g', { transform: `translate(${cx - 10.5} ${cy - 10.5})`, color: brand.ink });
1203
+ hub.dataset.id = data.salas;
1204
+ // The Coordinator is the brightest star, in its own colour, its mark at the heart.
1205
+ hub.append(svgNode('circle', { class: 'corona', cx, cy, r: hubR * 2.4, fill: haloFor(data.salas, brand.solid ?? brand.color) }));
1206
+ if (linked.has(data.salas)) hub.append(svgNode('circle', { class: 'halo', cx, cy, r: hubR + 6 }));
1207
+ hub.append(svgNode('circle', { class: 'core', cx, cy, r: hubR, fill: brand.solid ?? brand.color }));
1208
+ const size = Math.round(hubR * 1.24);
1209
+ const face = svgNode('g', { transform: `translate(${cx - size / 2} ${cy - size / 2})`, color: brand.ink });
846
1210
  face.innerHTML = brand.mark(); // trusted: our own SVG string from brands.js
847
- face.firstElementChild?.setAttribute('width', '21');
848
- face.firstElementChild?.setAttribute('height', '21');
1211
+ face.firstElementChild?.setAttribute('width', String(size));
1212
+ face.firstElementChild?.setAttribute('height', String(size));
849
1213
  hub.append(face);
1214
+ const hubTitle = svgNode('title');
1215
+ hubTitle.textContent = t('Coordinator');
1216
+ hub.append(hubTitle);
850
1217
  svg.append(hub);
851
- ui.map.append(svg);
1218
+ host.append(svg);
1219
+ focusable(svg, { pick, picked });
1220
+ host.dataset.quiet = JSON.stringify(projects.filter((room) => !ring.includes(room)).map((room) => room.id));
1221
+ if (!wide && quiet > 0 && connected.length) host.append(el('span', 'sm-map-quiet', quiet === 1 ? t('1 more project, not connected') : t('{n} more projects, not connected', { n: quiet })));
852
1222
  }
853
- // The tile changes size with the window: the map is drawn again at the new size.
1223
+ // The tile and the sheet change size with the window: the map is drawn again at the new size.
854
1224
  if (typeof ResizeObserver === 'function') {
855
- let drawnAt = '';
856
- new ResizeObserver(() => {
857
- const box = ui.map.getBoundingClientRect();
858
- const size = `${Math.round(box.width)}x${Math.round(box.height)}`;
859
- if (!box.width || size === drawnAt || !mapDrawn) return;
860
- drawnAt = size;
861
- drawMap(mapDrawn.projects, mapDrawn.enlaces);
862
- }).observe(ui.map);
1225
+ for (const [host, wide] of [[ui.map, false]]) {
1226
+ let drawnAt = '';
1227
+ new ResizeObserver(() => {
1228
+ const box = host.getBoundingClientRect();
1229
+ const size = `${Math.round(box.width)}x${Math.round(box.height)}`;
1230
+ if (!box.width || size === drawnAt || !mapDrawn) return;
1231
+ drawnAt = size;
1232
+ paintMap(host, mapDrawn.projects, mapDrawn.enlaces, { wide });
1233
+ }).observe(host);
1234
+ }
1235
+ }
1236
+
1237
+ // HOME, ARRANGED BY ITS PERSON (2026-10-13). Every tile of Home — the Coordinator, who waits,
1238
+ // the connections, the decisions, what needs you, the memory, and the widgets added to it — sits
1239
+ // where the person put it, at the size they chose. Right-click a tile: move it, resize it, take it
1240
+ // off Home, or add another. What is kept is this browser's (Home's own layout, like its cards).
1241
+ // A widget exists only where something real feeds it (src/server.mjs, homeWidget): the machine,
1242
+ // the projects' servers sentinels and datasets, the Coordinator's own dataset, and Ollama.
1243
+ const LAYOUT_KEY = 'pulse.home.layout';
1244
+ // One board (2026-10-13): every tile of Home in one order, the projects' drawer among them.
1245
+ const BOARD = 'sm-top';
1246
+ const SIZES = [[4, () => t('Small')], [6, () => t('Medium')], [8, () => t('Large')], [12, () => t('Full width')]];
1247
+ // THE GRID (2026-10-13): 12 columns across and rows of 96 px down, 16 px between them. Every tile
1248
+ // takes whole columns and whole rows, so every edge falls on the same lines and tiles fit side by
1249
+ // side exactly: a 5-row tile beside a 2-row and a 3-row one ends where they end. A tile's height is
1250
+ // its default, «Automatic» (the rows its content needs), or the rows the person chose; what does
1251
+ // not fit in a chosen height scrolls inside the tile.
1252
+ const HEIGHTS = [[0, () => t('Automatic')], [2, () => t('2 rows')], [3, () => t('3 rows')], [4, () => t('4 rows')], [5, () => t('5 rows')]];
1253
+ const HOME_TILES = {
1254
+ bishop: { id: 'sm-bishop', name: () => t('Coordinator'), about: () => t('Your Coordinator, its state and its recent summary.'), size: 8, rows: 5 },
1255
+ waiting: { id: 'sm-waiting', name: () => t('Waiting for you'), about: () => t('The questions and permissions an agent is waiting on.'), size: 4, rows: 2 },
1256
+ links: { id: 'sm-links-tile', name: () => t('Connections'), about: () => t('The map of who looks up whom.'), size: 4, rows: 3 },
1257
+ projects: { id: 'sm-drawer', name: () => t('Projects'), about: () => t('Every project, open or asleep, three rows at a time.'), size: 12, sizes: [12], kept: true },
1258
+ decisions: { id: 'sm-decisions', name: () => t('Recent decisions'), about: () => t('What your projects decided lately.'), size: 4, rows: 3 },
1259
+ attention: { id: 'sm-attention', name: () => t('Needs you'), about: () => t('What Home noticed that needs a hand.'), size: 4, rows: 3 },
1260
+ memory: { id: 'sm-memory', name: () => t('Memory'), about: () => t('What your projects remember, by kind.'), size: 4, rows: 3 },
1261
+ };
1262
+ const WIDGETS = {
1263
+ 'w-system': { kind: 'system', name: () => t('System resources'), about: () => t('How busy this Mac\'s processor is, and how much memory is free.'), size: 4, rows: 3 },
1264
+ 'w-servers': { kind: 'servers', name: () => t('Servers'), about: () => t('The servers running in your open projects, and whether they answer.'), size: 4, rows: 3, needs: () => t('Turn on Preview in a project\'s Modules to see its servers.') },
1265
+ 'w-memory': { kind: 'memory', name: () => t('Memory quality'), about: () => t('How grown each open project\'s memory is, and its weakest side.'), size: 4, rows: 3, needs: () => t('Open a project to read its memory.') },
1266
+ 'w-coordinator': { kind: 'coordinator', name: () => t('Coordinator training'), about: () => t('How far the Coordinator\'s own record is from training a model of its own.'), size: 4, rows: 3 },
1267
+ // Only for MADRE's author, in a copy run from source with the author's token (src/server.mjs, homeWidget):
1268
+ // anywhere else the server has no such widget and Home never offers it.
1269
+ 'w-usage': { kind: 'usage', owner: true, name: () => t('MADRE usage'), about: () => t('How many copies of MADRE are on, which versions and whether people come back. Only you see this.'), size: 8, rows: 3 },
1270
+ 'w-models': { kind: 'models', name: () => t('Local models'), about: () => t('Ollama, its models, and how close each project is to its own.'), size: 4, rows: 3 },
1271
+ };
1272
+ const DEFAULT_LAYOUT = { order: ['bishop', 'waiting', 'links', 'projects', 'decisions', 'attention', 'memory'], size: {}, rows: {} };
1273
+ // What each tile is, drawn in the accent the person chose (the same line as the room's AJUSTES icons).
1274
+ const TILE_ICONS = {
1275
+ 'bishop': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="10" r="2.6"/><circle cx="10" cy="3.6" r="1.4"/><circle cx="4.4" cy="14" r="1.4"/><circle cx="15.6" cy="14" r="1.4"/><path d="M10 5v2.4M8 11.6 5.6 13.2M12 11.6l2.4 1.6"/></svg>',
1276
+ 'waiting': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5h8M6 16.5h8M6.8 3.5c0 3.2 3.2 4 3.2 6.5S6.8 13.3 6.8 16.5M13.2 3.5c0 3.2-3.2 4-3.2 6.5s3.2 3.3 3.2 6.5"/></svg>',
1277
+ 'projects': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="12.5" rx="2.4"/><path d="M3 9.2h14M8 6.6h4"/></svg>',
1278
+ 'links': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="13.5" r="2.2"/><circle cx="15" cy="6.5" r="2.2"/><path d="M7 12.2 13 7.8"/></svg>',
1279
+ 'decisions': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="13" height="13" rx="3"/><path d="m7 10.2 2.1 2.1L13.2 8"/></svg>',
1280
+ 'attention': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="10" r="6.5"/><path d="M10 6.6v4.2"/><path d="M10 13.4h.01" stroke-width="2.2"/></svg>',
1281
+ 'memory': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="10" cy="5.6" rx="5.5" ry="2.1"/><path d="M4.5 5.6v4.4c0 1.2 2.5 2.1 5.5 2.1s5.5-.9 5.5-2.1V5.6M4.5 10v4.4c0 1.2 2.5 2.1 5.5 2.1s5.5-.9 5.5-2.1V10"/></svg>',
1282
+ 'w-system': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.8"/><path d="M8 3v2.5M12 3v2.5M8 14.5V17M12 14.5V17M3 8h2.5M3 12h2.5M14.5 8H17M14.5 12H17"/></svg>',
1283
+ 'w-servers': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.8" width="13" height="5" rx="1.6"/><rect x="3.5" y="11.2" width="13" height="5" rx="1.6"/><path d="M6.4 6.3h.01M6.4 13.7h.01" stroke-width="2.2"/><path d="M10 6.3h3.4M10 13.7h3.4"/></svg>',
1284
+ 'w-memory': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.8 13.5a6.2 6.2 0 1 1 12.4 0"/><path d="m10 13.5 3-4"/><path d="M10 13.5h.01" stroke-width="2.4"/></svg>',
1285
+ 'w-coordinator': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 16.5h13"/><path d="M5 13.5l3.2-3.2 2.6 2.4 4.2-5.2"/><path d="M12.4 7.5H15v2.6"/></svg>',
1286
+ 'w-models': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3.2 16 6.6v6.8L10 16.8 4 13.4V6.6z"/><path d="M4 6.6 10 10l6-3.4M10 10v6.8"/></svg>',
1287
+ 'w-usage': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 16.5h13"/><path d="M5.5 13.5v-3M9 13.5V7M12.5 13.5v-4.5M16 13.5V4.5"/></svg>',
1288
+ };
1289
+ const tileIcon = (id) => { const box = el('span', 'sm-tile-icon'); box.innerHTML = TILE_ICONS[id] ?? ''; return box; }; // trusted: our own SVG strings
1290
+ const known = (id) => Object.hasOwn(HOME_TILES, id) || Object.hasOwn(WIDGETS, id);
1291
+ function readLayout() {
1292
+ let raw = null;
1293
+ try { raw = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? 'null'); } catch { raw = null; }
1294
+ if (!raw || typeof raw !== 'object') return structuredClone(DEFAULT_LAYOUT);
1295
+ const seen = new Set();
1296
+ const clean = (list) => (Array.isArray(list) ? list : []).filter((id) => typeof id === 'string' && known(id) && !seen.has(id) && seen.add(id));
1297
+ // An arrangement kept before the board was one (top · projects · low) reads as one order.
1298
+ const order = clean(Array.isArray(raw.order) ? raw.order : [...(raw.top ?? []), 'projects', ...(raw.low ?? [])]);
1299
+ // The drawer is never taken off: a list that lost it gets it back where the projects were.
1300
+ if (!order.includes('projects')) order.splice(Math.min(order.length, 3), 0, 'projects');
1301
+ const size = Object.fromEntries(Object.entries(raw.size ?? {}).filter(([id, span]) => known(id) && SIZES.some(([one]) => one === span)));
1302
+ const rows = Object.fromEntries(Object.entries(raw.rows ?? {}).filter(([id, count]) => known(id) && HEIGHTS.some(([one]) => one === count)));
1303
+ return { order, size, rows };
1304
+ }
1305
+ let layout = readLayout();
1306
+ function saveLayout() {
1307
+ try {
1308
+ if (JSON.stringify(layout) === JSON.stringify(DEFAULT_LAYOUT)) localStorage.removeItem(LAYOUT_KEY);
1309
+ else localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
1310
+ } catch { /* the arrangement lasts as long as the page */ }
1311
+ applyLayout();
1312
+ }
1313
+ const placed = (id) => layout.order.includes(id);
1314
+ const spanOf = (id) => layout.size[id] ?? (HOME_TILES[id] ?? WIDGETS[id]).size;
1315
+ const widgetNodes = new Map();
1316
+ function tileNode(id) {
1317
+ if (HOME_TILES[id]) return $(HOME_TILES[id].id);
1318
+ if (!widgetNodes.has(id)) widgetNodes.set(id, makeWidget(id));
1319
+ return widgetNodes.get(id);
1320
+ }
1321
+ function applyLayout() {
1322
+ const board = $(BOARD);
1323
+ for (const id of layout.order) {
1324
+ const node = tileNode(id);
1325
+ if (!node) continue;
1326
+ node.classList.remove('sm-off', 'c-3', 'c-4', 'c-5', 'c-6', 'c-8', 'c-12', 'r-1', 'r-2', 'r-3');
1327
+ node.classList.add(`c-${spanOf(id)}`);
1328
+ node.dataset.tile = id;
1329
+ board.append(node);
1330
+ tileFits.observe(node);
1331
+ }
1332
+ for (const id of Object.keys(HOME_TILES)) if (!placed(id)) $(HOME_TILES[id].id)?.classList.add('sm-off');
1333
+ for (const [id, node] of widgetNodes) if (!placed(id)) { tileFits.unobserve(node); node.remove(); node.stop?.(); widgetNodes.delete(id); }
1334
+ for (const [id, node] of widgetNodes) if (placed(id)) node.start?.();
1335
+ fitTiles();
1336
+ }
1337
+ // Each tile is as tall as what it holds (2026-10-13): the board's rows are 4 px and a tile spans as
1338
+ // many as its content needs, plus the gap under it. A tile made wider or narrower grows or shrinks
1339
+ // with its content instead of stretching to a height chosen for another size.
1340
+ const ROW_UNIT = 96;
1341
+ const ROW_GAP = 16;
1342
+ const rowsOf = (id) => layout.rows?.[id] ?? (HOME_TILES[id] ?? WIDGETS[id])?.rows ?? 0;
1343
+ // On a middle-width screen the tiles go in pairs, half and half: a pair ends level only if both
1344
+ // are as tall, so there a short tile takes at least the three rows the others take.
1345
+ const PAIRED = window.matchMedia?.('(max-width: 1180px) and (min-width: 761px)');
1346
+ PAIRED?.addEventListener?.('change', () => fitTiles());
1347
+ function fitTile(node) {
1348
+ const own = rowsOf(node.dataset.tile);
1349
+ const chosen = own && PAIRED?.matches && spanOf(node.dataset.tile) < 12 ? Math.max(3, own) : own;
1350
+ node.classList.toggle('sm-fixed', chosen > 0);
1351
+ // How many rows it has, for the tile's own CSS: a short tile shows what matters and leaves the rest.
1352
+ node.dataset.rows = chosen ? String(chosen) : 'auto';
1353
+ if (chosen) { node.style.gridRow = `auto / span ${chosen}`; return; }
1354
+ // Automatic: the rows its content needs, read at its natural height and rounded up to a whole row.
1355
+ node.style.alignSelf = 'start';
1356
+ const height = node.getBoundingClientRect().height;
1357
+ node.style.alignSelf = '';
1358
+ if (height) node.style.gridRow = `auto / span ${Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_UNIT + ROW_GAP)))}`;
1359
+ }
1360
+ function fitTiles() { for (const node of $(BOARD)?.querySelectorAll(':scope > [data-tile]') ?? []) fitTile(node); }
1361
+ const tileFits = typeof ResizeObserver === 'function' ? new ResizeObserver((entries) => { for (const entry of entries) fitTile(entry.target); }) : { observe() {}, unobserve() {} };
1362
+ function moveTile(id, where) {
1363
+ const list = layout.order;
1364
+ const at = list.indexOf(id);
1365
+ if (at < 0) return;
1366
+ list.splice(at, 1);
1367
+ const to = where === 'first' ? 0 : where === 'last' ? list.length : Math.max(0, Math.min(list.length, at + where));
1368
+ list.splice(to, 0, id);
1369
+ saveLayout();
1370
+ }
1371
+ function removeTile(id) {
1372
+ if ((HOME_TILES[id] ?? WIDGETS[id])?.kept) return;
1373
+ layout.order = layout.order.filter((one) => one !== id);
1374
+ delete layout.size[id];
1375
+ saveLayout();
1376
+ }
1377
+ function addTile(id) {
1378
+ if (placed(id)) return;
1379
+ layout.order.push(id);
1380
+ saveLayout();
1381
+ requestAnimationFrame(() => tileNode(id)?.scrollIntoView({ block: 'center', behavior: motionStill() ? 'auto' : 'smooth' }));
863
1382
  }
864
1383
 
1384
+ // The menu a right-click opens, shared by the tiles and the space between them: the same card and
1385
+ // keys as a project's menu (pinsMenu), closed the same way.
1386
+ const MOVE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3.5v13M5.5 8 10 3.5 14.5 8M5.5 12 10 16.5 14.5 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1387
+ const BEFORE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4.5 7 10l5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1388
+ const AFTER_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7.5 4.5 13 10l-5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1389
+ // A height is drawn as the rows it takes, out of five; «Automatic» as rows that follow the content.
1390
+ const heightIcon = (count) => `<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="5.2" y="2.8" width="9.6" height="14.4" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".45"/>${count ? `<rect x="7" y="${(15.4 - 10.8 * count / 5).toFixed(2)}" width="6" height="${(10.8 * count / 5).toFixed(2)}" rx="1.2" fill="currentColor"/>` : '<path d="M7.2 8.6 10 6l2.8 2.6M7.2 11.4 10 14l2.8-2.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'}</svg>`;
1391
+ // A size is drawn as the share of the row it takes: a third, a half, two thirds, all of it.
1392
+ const sizeIcon = (span) => `<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.8" y="5.2" width="14.4" height="9.6" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".45"/><rect x="4.6" y="7" width="${(10.8 * span / 12).toFixed(2)}" height="6" rx="1.2" fill="currentColor"/></svg>`;
1393
+ const ADD_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5v11M4.5 10h11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
1394
+ const RESET_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.2 8.6A6 6 0 1 1 4.8 13"/><path d="M3.8 4.6v4.2H8"/></svg>';
1395
+ const HIDE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 10h9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
1396
+ function homeMenu(point, entries, label) {
1397
+ pinsCloseMenu();
1398
+ const menu = el('div', 'bar-card sm-menu');
1399
+ menu.setAttribute('role', 'menu');
1400
+ if (label) menu.setAttribute('aria-label', label);
1401
+ for (const entry of entries) {
1402
+ if (entry === 'sep') { menu.append(el('i', 'sm-menu-sep')); continue; }
1403
+ if (entry.heading) { const head = el('span', 'sm-menu-head'); if (entry.tile) head.append(tileIcon(entry.tile)); head.append(el('span', null, entry.heading)); menu.append(head); continue; }
1404
+ const one = button(`sm-menu-item${entry.danger ? ' danger' : ''}${entry.checked ? ' checked' : ''}`, null, () => { pinsCloseMenu(); entry.act(); });
1405
+ one.setAttribute('role', entry.checked !== undefined ? 'menuitemradio' : 'menuitem');
1406
+ if (entry.checked !== undefined) one.setAttribute('aria-checked', String(Boolean(entry.checked)));
1407
+ if (entry.disabled) one.disabled = true;
1408
+ one.innerHTML = entry.icon ?? ''; // trusted: our own SVG strings
1409
+ one.append(el('span', null, entry.words));
1410
+ menu.append(one);
1411
+ }
1412
+ menu.addEventListener('keydown', (key) => {
1413
+ const items = [...menu.querySelectorAll('.sm-menu-item:not(:disabled)')];
1414
+ const at = items.indexOf(document.activeElement);
1415
+ if (key.key === 'ArrowDown') { key.preventDefault(); items[(at + 1) % items.length].focus(); }
1416
+ if (key.key === 'ArrowUp') { key.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
1417
+ });
1418
+ document.body.append(menu);
1419
+ pins.menu = menu;
1420
+ const width = menu.offsetWidth;
1421
+ const height = menu.offsetHeight;
1422
+ menu.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - width - 8, point.x)))}px`;
1423
+ menu.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - height - 8, point.y)))}px`;
1424
+ menu.style.right = 'auto';
1425
+ menu.querySelector('.sm-menu-item:not(:disabled)')?.focus({ preventScroll: true });
1426
+ }
1427
+ const customized = () => JSON.stringify(layout) !== JSON.stringify(DEFAULT_LAYOUT);
1428
+ const addEntries = () => [{ words: t('Add to Home…'), icon: ADD_ICON, act: () => openHomeAdd() }, ...(customized() ? [{ words: t('Back to the default layout'), icon: RESET_ICON, act: () => { layout = structuredClone(DEFAULT_LAYOUT); saveLayout(); } }] : [])];
1429
+ function tileMenu(event, id) {
1430
+ event.preventDefault();
1431
+ const list = layout.order;
1432
+ const at = list.indexOf(id);
1433
+ const spec = HOME_TILES[id] ?? WIDGETS[id];
1434
+ const sizes = SIZES.filter(([span]) => !spec.sizes || spec.sizes.includes(span));
1435
+ homeMenu({ x: event.clientX, y: event.clientY }, [
1436
+ { heading: spec.name(), tile: id },
1437
+ { words: t('Move before'), icon: BEFORE_ICON, disabled: at <= 0, act: () => moveTile(id, -1) },
1438
+ { words: t('Move after'), icon: AFTER_ICON, disabled: at >= list.length - 1, act: () => moveTile(id, 1) },
1439
+ { words: t('Move to the start'), icon: MOVE_ICON, disabled: at <= 0, act: () => moveTile(id, 'first') },
1440
+ { words: t('Move to the end'), icon: MOVE_ICON, disabled: at >= list.length - 1, act: () => moveTile(id, 'last') },
1441
+ ...(sizes.length > 1 ? ['sep', { heading: t('Width') }, ...sizes.map(([span, words]) => ({ words: words(), icon: sizeIcon(span), checked: spanOf(id) === span, act: () => { if (span === spec.size) delete layout.size[id]; else layout.size[id] = span; saveLayout(); } }))] : []),
1442
+ ...(spec.kept ? [] : ['sep', { heading: t('Height') }, ...HEIGHTS.map(([count, words]) => ({ words: words(), icon: heightIcon(count), checked: rowsOf(id) === count, act: () => { layout.rows ??= {}; if (count === (spec.rows ?? 0)) delete layout.rows[id]; else layout.rows[id] = count; saveLayout(); } }))]),
1443
+ 'sep',
1444
+ ...(spec.kept ? [] : [{ words: t('Take off Home'), icon: HIDE_ICON, act: () => removeTile(id) }]),
1445
+ ...addEntries(),
1446
+ ], spec.name());
1447
+ }
1448
+ $(BOARD)?.addEventListener('contextmenu', (event) => {
1449
+ // A project's card keeps its own menu (pinsMenu); the drawer around it has the tile's.
1450
+ if (event.target.closest('input, textarea, select, [contenteditable="true"], .sm-card')) return;
1451
+ const tile = event.target.closest('[data-tile]');
1452
+ if (tile) { tileMenu(event, tile.dataset.tile); return; }
1453
+ event.preventDefault();
1454
+ homeMenu({ x: event.clientX, y: event.clientY }, addEntries(), t('Home'));
1455
+ });
1456
+ // DRAG AND DROP (2026-10-13): a tile is taken by its head and set down where it should go — in the
1457
+ // same row of tiles or the other one, above or below the projects. While it is carried the tiles
1458
+ // make room for it; let go, that order is kept. Escape, or a drop outside, puts everything back.
1459
+ const TILE_TYPE = 'application/x-madre-tile';
1460
+ let carried = null;
1461
+ const grabbable = (event) => !event.target.closest('a, button, input, select, textarea, label, svg, .sm-map, .sm-card, .sm-tools, [contenteditable="true"]') && event.target.closest('.sm-tile-head, .sm-bishop-head, .sm-bishop-words');
1462
+ {
1463
+ const host = $(BOARD);
1464
+ host?.addEventListener('pointerdown', (event) => {
1465
+ const tile = event.target.closest('[data-tile]');
1466
+ if (tile && event.button === 0 && grabbable(event)) tile.draggable = true;
1467
+ });
1468
+ host?.addEventListener('dragstart', (event) => {
1469
+ const tile = event.target.closest?.('[data-tile]');
1470
+ if (!tile || !tile.draggable) return;
1471
+ carried = tile;
1472
+ event.dataTransfer.setData(TILE_TYPE, tile.dataset.tile);
1473
+ event.dataTransfer.effectAllowed = 'move';
1474
+ pinsCloseMenu();
1475
+ requestAnimationFrame(() => tile.classList.add('sm-carried'));
1476
+ });
1477
+ host?.addEventListener('dragover', (event) => {
1478
+ if (!carried || !event.dataTransfer.types.includes(TILE_TYPE)) return;
1479
+ event.preventDefault();
1480
+ event.dataTransfer.dropEffect = 'move';
1481
+ const over = event.target.closest('[data-tile]');
1482
+ if (over && !host.contains(over)) return;
1483
+ if (!over) { if (!host.contains(carried)) host.append(carried); return; }
1484
+ if (over === carried) return;
1485
+ const box = over.getBoundingClientRect();
1486
+ const after = event.clientY > box.bottom - box.height / 3 || (event.clientY > box.top + box.height / 3 && event.clientX > box.left + box.width / 2);
1487
+ const target = after ? over.nextSibling : over;
1488
+ if (target !== carried && carried.nextSibling !== target) host.insertBefore(carried, target);
1489
+ });
1490
+ host?.addEventListener('drop', (event) => {
1491
+ if (!carried || !event.dataTransfer.types.includes(TILE_TYPE)) return;
1492
+ event.preventDefault();
1493
+ layout.order = [...host.children].filter((node) => node.dataset.tile && known(node.dataset.tile) && !node.classList.contains('sm-off')).map((node) => node.dataset.tile);
1494
+ carried.classList.remove('sm-carried');
1495
+ carried.draggable = false;
1496
+ carried = null;
1497
+ saveLayout();
1498
+ });
1499
+ }
1500
+ document.addEventListener('dragend', (event) => {
1501
+ if (!carried || event.target !== carried) return;
1502
+ carried.classList.remove('sm-carried');
1503
+ carried.draggable = false;
1504
+ carried = null;
1505
+ applyLayout();
1506
+ });
1507
+ document.addEventListener('pointerup', () => { for (const tile of document.querySelectorAll('[data-tile][draggable="true"]')) if (tile !== carried) tile.draggable = false; });
1508
+
1509
+ // A project's card, dragged among the others (it was already dragged to the bar to pin it): the
1510
+ // cards make room as it passes, and where it is let go becomes «Yours». A search or a filter shows
1511
+ // only some of them: the ones out of sight keep their places.
1512
+ let projectCarried = null;
1513
+ ui.projects.addEventListener('dragstart', (event) => { projectCarried = event.target.closest?.('.sm-card[data-room]') ?? null; });
1514
+ ui.projects.addEventListener('dragover', (event) => {
1515
+ if (!projectCarried || !event.dataTransfer.types.includes(PIN_TYPE)) return;
1516
+ const over = event.target.closest('.sm-card[data-room]');
1517
+ event.preventDefault();
1518
+ event.dataTransfer.dropEffect = 'move';
1519
+ if (!over || over === projectCarried) return;
1520
+ projectCarried.classList.add('sm-carried');
1521
+ const box = over.getBoundingClientRect();
1522
+ const after = event.clientY > box.bottom - box.height / 3 || (event.clientY > box.top + box.height / 3 && event.clientX > box.left + box.width / 2);
1523
+ const target = after ? over.nextSibling : over;
1524
+ if (target !== projectCarried && projectCarried.nextSibling !== target) ui.projects.insertBefore(projectCarried, target);
1525
+ });
1526
+ ui.projects.addEventListener('drop', (event) => {
1527
+ if (!projectCarried || !event.dataTransfer.types.includes(PIN_TYPE)) return;
1528
+ event.preventDefault();
1529
+ const shown = [...ui.projects.querySelectorAll('.sm-card[data-room]')].map((node) => node.dataset.room);
1530
+ const all = projectsInOrder().all.map((room) => room.id);
1531
+ const slots = all.map((id) => shown.includes(id));
1532
+ let next = 0;
1533
+ const order = all.map((id, index) => (slots[index] ? shown[next++] : id));
1534
+ try { localStorage.setItem(PROJECT_ORDER_KEY, JSON.stringify(order)); } catch { /* the order lasts as long as the page */ }
1535
+ projectCarried.classList.remove('sm-carried');
1536
+ projectCarried = null;
1537
+ saveHome('order', 'mine');
1538
+ });
1539
+ document.addEventListener('dragend', () => {
1540
+ if (!projectCarried) return;
1541
+ projectCarried.classList.remove('sm-carried');
1542
+ projectCarried = null;
1543
+ draw();
1544
+ });
1545
+
1546
+ // THE BAR'S KEYS MOVE UNDER THE POINTER (2026-10-13), as the room's do (styles.css, «Each drawing
1547
+ // moves in its own way»): the two halves of Connections close on each other (CSS), and the planets
1548
+ // of the Project map go round their orbit while the pointer stays.
1549
+ {
1550
+ const key = $('sm-macro');
1551
+ const orbits = [...(key?.querySelectorAll('animateMotion') ?? [])];
1552
+ const still = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
1553
+ key?.addEventListener('pointerenter', () => { if (!still()) for (const one of orbits) one.beginElement?.(); });
1554
+ key?.addEventListener('pointerleave', () => { for (const one of orbits) one.endElement?.(); });
1555
+ }
1556
+
1557
+ // CARRIED TO THE EDGE (2026-10-13): while a tile or a project is carried, the page scrolls when the
1558
+ // pointer nears its top or its bottom — faster the closer it gets — so a tile goes anywhere on the
1559
+ // board without being let go. The browser's own scrolling while dragging is too slow to rely on.
1560
+ {
1561
+ const EDGE = 96;
1562
+ const FASTEST = 22;
1563
+ let pointerY = null;
1564
+ let frame = 0;
1565
+ const carrying = () => Boolean(carried || projectCarried);
1566
+ const step = () => {
1567
+ frame = 0;
1568
+ if (!carrying() || pointerY === null) return;
1569
+ const room = window.innerHeight;
1570
+ const near = pointerY < EDGE ? -(EDGE - pointerY) / EDGE : pointerY > room - EDGE ? (pointerY - (room - EDGE)) / EDGE : 0;
1571
+ if (near) window.scrollBy(0, Math.round(Math.sign(near) * Math.max(2, Math.abs(near) ** 1.6 * FASTEST)));
1572
+ frame = requestAnimationFrame(step);
1573
+ };
1574
+ document.addEventListener('dragover', (event) => {
1575
+ if (!carrying()) return;
1576
+ pointerY = event.clientY;
1577
+ if (!frame) frame = requestAnimationFrame(step);
1578
+ });
1579
+ const stop = () => { pointerY = null; cancelAnimationFrame(frame); frame = 0; };
1580
+ document.addEventListener('drop', stop);
1581
+ document.addEventListener('dragend', stop);
1582
+ }
1583
+
1584
+ // The space around the projects, right-clicked, offers the same.
1585
+ document.querySelector('.sm-main')?.addEventListener('contextmenu', (event) => {
1586
+ if (event.defaultPrevented || event.target.closest('.sm-tile, .sm-card, input, textarea, select, a, button, dialog')) return;
1587
+ event.preventDefault();
1588
+ homeMenu({ x: event.clientX, y: event.clientY }, addEntries(), t('Home'));
1589
+ });
1590
+
1591
+ // «Add to Home»: the tiles taken off and the widgets not yet on it, each said in a line, with what
1592
+ // it would need when nothing feeds it yet.
1593
+ let homeAddSheet = null;
1594
+ async function openHomeAdd() {
1595
+ homeAddSheet?.remove();
1596
+ homeAddSheet = el('dialog', 'sm-sheet sm-add-sheet');
1597
+ const body = el('div', 'sm-sheet-body');
1598
+ const head = el('header', 'sm-sheet-head');
1599
+ head.append(el('h3', null, t('Add to Home')));
1600
+ // The same ✕ as every sheet of Home (the connections, the settings).
1601
+ const keys = el('span', 'surface-modes');
1602
+ const close = button('surface-mode close x-close x-only', null, () => homeAddSheet.close());
1603
+ close.setAttribute('aria-label', t('Close'));
1604
+ close.innerHTML = '<span class="x-icon"><svg viewBox="0 0 20 20" focusable="false" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>'; // trusted
1605
+ keys.append(close);
1606
+ head.append(keys);
1607
+ body.append(head, el('p', 'hint', t('Tiles you took off and widgets you can put on Home. Right-click any tile to move it, resize it or take it off.')));
1608
+ const list = el('div', 'sm-add-list');
1609
+ body.append(list);
1610
+ homeAddSheet.append(body);
1611
+ homeAddSheet.addEventListener('close', () => { homeAddSheet?.remove(); homeAddSheet = null; });
1612
+ document.body.append(homeAddSheet);
1613
+ homeAddSheet.showModal();
1614
+ void homeTour.hint('home-add', list, { inside: true, title: t('Add to Home'), text: t('The tiles you took off, and the widgets. A widget is offered only when something on this Mac feeds it; right-click any tile to move it, resize it or take it off.') });
1615
+ // The author's widget is asked about before the list is drawn: where it cannot be read, it is not listed at all.
1616
+ const ownerOk = new Set();
1617
+ for (const [id, spec] of Object.entries(WIDGETS).filter(([id, spec]) => spec.owner && !placed(id))) {
1618
+ if (await fetch(`/api/salas/widget?kind=${spec.kind}`, { cache: 'no-store' }).then((response) => response.ok).catch(() => false)) ownerOk.add(id);
1619
+ }
1620
+ if (!homeAddSheet) return;
1621
+ const entries = [
1622
+ ...Object.entries(HOME_TILES).filter(([id]) => !placed(id)).map(([id, spec]) => ({ id, spec, ready: true })),
1623
+ ...Object.entries(WIDGETS).filter(([id, spec]) => !placed(id) && (!spec.owner || ownerOk.has(id))).map(([id, spec]) => ({ id, spec, ready: null })),
1624
+ ];
1625
+ if (!entries.length) { list.append(el('p', 'sm-hint', t('Everything is already on Home.'))); return; }
1626
+ for (const entry of entries) {
1627
+ const row = el('div', 'sm-add-row');
1628
+ row.append(tileIcon(entry.id));
1629
+ const words = el('div', 'sm-add-words');
1630
+ words.append(el('b', null, entry.spec.name()), el('small', null, entry.spec.about()));
1631
+ const said = el('small', 'sm-add-need');
1632
+ words.append(said);
1633
+ const add = button('sm-btn primary small', t('Add'), () => { addTile(entry.id); homeAddSheet?.close(); });
1634
+ row.append(words, add);
1635
+ list.append(row);
1636
+ // A widget is offered only when something feeds it: asked of the server, which says so.
1637
+ if (entry.ready === null) {
1638
+ add.disabled = true;
1639
+ said.textContent = t('Checking…');
1640
+ const reading = await fetch(`/api/salas/widget?kind=${entry.spec.kind}`, { cache: 'no-store' }).then((response) => (response.ok ? response.json() : { failed: response.status })).catch(() => ({ failed: true }));
1641
+ if (!row.isConnected) return;
1642
+ const ok = Boolean(reading?.available);
1643
+ add.disabled = !ok;
1644
+ // A page served by a project started before widgets existed cannot read them: said as that,
1645
+ // not as a Mac with nothing to show.
1646
+ said.textContent = ok ? '' : reading.failed === 404 ? t('This page is served by a project started before widgets existed: open Home from its own address, or restart that project.') : reading.failed ? t('It could not be read just now.') : (entry.spec.needs?.() ?? t('Nothing feeds it on this Mac yet.'));
1647
+ }
1648
+ }
1649
+ }
1650
+
1651
+ // ---- The widgets. Each is a tile that reads its own route every 20 s while Home is in view.
1652
+ const pct = (value) => (value === null || value === undefined ? '—' : `${Math.round(value * 100)}%`);
1653
+ const gb = (bytes) => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
1654
+ function meter(value, tone = null) {
1655
+ const bar = el('span', `sm-meter${tone ? ` ${tone}` : ''}`);
1656
+ const fill = el('i');
1657
+ fill.style.width = `${Math.round(Math.max(0, Math.min(1, value ?? 0)) * 100)}%`;
1658
+ bar.append(fill);
1659
+ return bar;
1660
+ }
1661
+ const toneFor = (share) => (share >= 0.85 ? 'hot' : share >= 0.6 ? 'warm' : null);
1662
+ const OS_WORDS = { darwin: 'macOS', linux: 'Linux', win32: 'Windows', other: () => t('other') };
1663
+ const WIDGET_PAINT = {
1664
+ usage(body, reading) {
1665
+ if (!reading.available) { body.append(el('p', 'sm-hint', reading.status ? t('madre.run answered {status}.', { status: reading.status }) : t('madre.run could not be reached.'))); return; }
1666
+ const grid = el('div', 'sm-w-grid sm-usage-figures');
1667
+ for (const [label, n, line] of [[t('Today'), reading.active.day, t('copies on')], [t('7 days'), reading.active.week, t('active copies')], [t('30 days'), reading.active.month, t('active copies')], [t('Ever'), reading.active.ever, t('copies seen')]]) {
1668
+ const cell = el('div', 'sm-w-metric');
1669
+ cell.append(el('span', 'sm-w-label', label), el('b', 'sm-w-big', Number(n).toLocaleString()), el('small', null, line));
1670
+ grid.append(cell);
1671
+ }
1672
+ body.append(grid);
1673
+ // The last 30 days: copies on each day, and the part of them seen for the first time.
1674
+ const top = Math.max(1, ...reading.days.map((one) => one.active));
1675
+ const days = el('div', 'sm-usage-days');
1676
+ days.setAttribute('role', 'img');
1677
+ days.setAttribute('aria-label', t('Copies on each day, last 30 days'));
1678
+ for (const one of reading.days) {
1679
+ const bar = el('i');
1680
+ bar.style.setProperty('--h', String(one.active / top));
1681
+ bar.style.setProperty('--new', String(one.active ? one.new / one.active : 0));
1682
+ bar.title = t('{day}: {n} on · {fresh} new', { day: one.day, n: one.active, fresh: one.new });
1683
+ days.append(bar);
1684
+ }
1685
+ body.append(days);
1686
+ const share = (list, word = (name) => name) => list.slice(0, 4).map((one) => `${typeof word(one.name) === 'function' ? word(one.name)() : word(one.name)} ${one.n}`).join(' · ') || '—';
1687
+ const back = (part) => (part.cohort ? `${Math.round((part.back / part.cohort) * 100)}%` : '—');
1688
+ const lines = el('div', 'sm-usage-lines');
1689
+ for (const [label, said] of [
1690
+ [t('Versions'), share(reading.versions)],
1691
+ [t('Systems'), share(reading.os, (name) => OS_WORDS[name] ?? name)],
1692
+ [t('Came back'), t('after 1 day {d1} · 7 days {d7} · 30 days {d30}', { d1: back(reading.retention.d1), d7: back(reading.retention.d7), d30: back(reading.retention.d30) })],
1693
+ ]) {
1694
+ const row = el('div', 'sm-w-line');
1695
+ row.append(el('b', null, label), el('small', null, said));
1696
+ lines.append(row);
1697
+ }
1698
+ body.append(lines);
1699
+ },
1700
+ system(body, reading) {
1701
+ const cpu = el('div', 'sm-w-metric');
1702
+ cpu.append(el('span', 'sm-w-label', t('Processor')), el('b', 'sm-w-big', pct(reading.cpu.busy)), meter(reading.cpu.busy, toneFor(reading.cpu.busy ?? 0)), el('small', null, t('{n} cores · load {load}', { n: reading.cpu.cores, load: reading.cpu.load[0] })));
1703
+ const used = reading.memory.total ? 1 - reading.memory.free / reading.memory.total : null;
1704
+ const mem = el('div', 'sm-w-metric');
1705
+ mem.append(el('span', 'sm-w-label', t('Memory')), el('b', 'sm-w-big', t('{free} free', { free: gb(reading.memory.free) })), meter(used, toneFor(used ?? 0)), el('small', null, t('of {total}. The system lends what is free to its cache and takes it back when needed.', { total: gb(reading.memory.total) })));
1706
+ const grid = el('div', 'sm-w-grid');
1707
+ grid.append(cpu, mem);
1708
+ body.append(grid);
1709
+ },
1710
+ servers(body, reading) {
1711
+ const running = reading.projects.flatMap((one) => one.ports.map((port) => ({ ...port, project: one.name })));
1712
+ if (!running.length) body.append(el('p', 'sm-hint', reading.projects.some((one) => one.preview) ? t('No server is running in your open projects.') : t('Turn on Preview in a project\'s Modules to see its servers.')));
1713
+ const grid = el('div', 'sm-w-grid');
1714
+ for (const one of running) {
1715
+ const healthy = one.status !== null && one.status < 500;
1716
+ const row = el('div', `sm-w-row${healthy ? ' ok' : ' bad'}`);
1717
+ row.append(el('i', 'sm-w-dot'), el('b', null, `:${one.port}`), el('span', null, one.project), el('small', null, one.status ? t('answers {status}', { status: one.status }) : t('no answer')));
1718
+ grid.append(row);
1719
+ }
1720
+ if (running.length) body.append(grid);
1721
+ const off = reading.projects.filter((one) => !one.preview && !one.unreachable).length;
1722
+ if (off && running.length) body.append(el('small', 'sm-w-foot', off === 1 ? t('1 project has Preview off.') : t('{n} projects have Preview off.', { n: off })));
1723
+ },
1724
+ memory(body, reading) {
1725
+ const read = reading.projects.filter((one) => !one.unreachable);
1726
+ if (!read.length) { body.append(el('p', 'sm-hint', t('Open a project to read its memory.'))); return; }
1727
+ const grid = el('div', 'sm-w-grid');
1728
+ body.append(grid);
1729
+ for (const one of read) {
1730
+ const row = el('div', 'sm-w-project');
1731
+ const top = el('div', 'sm-w-line');
1732
+ top.append(el('b', null, one.name), el('span', 'sm-w-stage', one.stage ?? '—'), el('small', null, pct(one.score)));
1733
+ row.append(top, meter(one.score));
1734
+ const notes = [one.weakest?.label ? t('weakest: {what}', { what: one.weakest.label.toLowerCase() }) : null, one.aberrations ? t('{n} doubtful memories', { n: one.aberrations }) : null].filter(Boolean);
1735
+ if (notes.length) row.append(el('small', null, notes.join(' · ')));
1736
+ grid.append(row);
1737
+ }
1738
+ },
1739
+ coordinator(body, reading) {
1740
+ const share = reading.target ? reading.pairs / reading.target : 0;
1741
+ const top = el('div', 'sm-w-line');
1742
+ top.append(el('b', 'sm-w-big', `${reading.pairs}`), el('span', null, t('of {n} exchanges to train on', { n: reading.target })));
1743
+ body.append(top, meter(share));
1744
+ body.append(el('p', 'sm-w-says', [reading.stage, reading.says].filter(Boolean).join(' · ')));
1745
+ const facts = [t('{n} memories', { n: reading.notes ?? 0 }), reading.trained ? t('model {name}', { name: reading.trained }) : t('no model of its own yet')];
1746
+ body.append(el('small', 'sm-w-foot', facts.join(' · ')));
1747
+ },
1748
+ models(body, reading) {
1749
+ const head = el('div', `sm-w-row${reading.ollama.running ? ' ok' : ' bad'}`);
1750
+ head.append(el('i', 'sm-w-dot'), el('b', null, 'Ollama'), el('span', null, reading.ollama.running ? t('{n} models on this Mac', { n: reading.ollama.models.length }) : t('not running')));
1751
+ // Which projects use it is set in Home's Settings › Local AI.
1752
+ const tune = button('sm-w-link', t('Choose projects'), (event) => { event.stopPropagation(); openSettings('local'); });
1753
+ head.append(tune);
1754
+ body.append(head);
1755
+ if (reading.ollama.models.length) body.append(el('small', 'sm-w-foot mono', reading.ollama.models.slice(0, 4).join(' · ') + (reading.ollama.models.length > 4 ? ' …' : '')));
1756
+ const grid = el('div', 'sm-w-grid');
1757
+ body.append(grid);
1758
+ for (const one of reading.projects.filter((project) => !project.unreachable)) {
1759
+ const row = el('div', 'sm-w-project');
1760
+ const top = el('div', 'sm-w-line');
1761
+ const ready = one.pairs >= one.target;
1762
+ const count = el('small', null, one.trained ? t('trained · {name}', { name: one.trained }) : ready ? t('ready to train') : `${one.pairs}/${one.target}`);
1763
+ count.title = t('{n} of {target} examples this project needs to train a model of its own', { n: one.pairs, target: one.target });
1764
+ top.append(el('b', null, one.name));
1765
+ if (one.localAi === false) { row.classList.add('sm-w-off'); top.append(el('span', 'sm-w-tag', t('without local AI'))); }
1766
+ top.append(count);
1767
+ row.append(top, meter(one.trained ? 1 : one.target ? one.pairs / one.target : 0));
1768
+ grid.append(row);
1769
+ }
1770
+ },
1771
+ };
1772
+ function makeWidget(id) {
1773
+ const spec = WIDGETS[id];
1774
+ if (!spec) return null;
1775
+ const node = el('section', `sm-tile sm-widget w-${spec.kind}`);
1776
+ const head = el('header', 'sm-tile-head');
1777
+ head.append(el('h2', null, spec.name()));
1778
+ const body = el('div', 'sm-w-body');
1779
+ body.append(el('p', 'sm-hint', t('Reading…')));
1780
+ node.append(head, body);
1781
+ let timer = 0;
1782
+ const read = async () => {
1783
+ if (document.hidden || !node.isConnected) return;
1784
+ const reading = await fetch(`/api/salas/widget?kind=${spec.kind}`, { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
1785
+ if (!node.isConnected) return;
1786
+ body.replaceChildren();
1787
+ // The author's widget is not there at all for a copy that cannot read it (no token, not from source).
1788
+ if (spec.owner) node.hidden = !reading;
1789
+ if (!reading) { body.append(el('p', 'sm-hint', t('It could not be read just now.'))); return; }
1790
+ WIDGET_PAINT[spec.kind](body, reading);
1791
+ fitTile(node);
1792
+ // The first time it shows, what it is (homeTour is set up below this, so it is read late).
1793
+ if (!node.dataset.guided) { node.dataset.guided = '1'; setTimeout(() => { try { guideWidget(node, spec.kind); } catch { /* the tour is not up yet */ } }, 1500); }
1794
+ };
1795
+ node.start = () => { if (timer) return; void read(); timer = setInterval(() => void read(), 20000); };
1796
+ node.stop = () => { clearInterval(timer); timer = 0; };
1797
+ return node;
1798
+ }
1799
+ applyLayout();
1800
+
865
1801
  // THE WELCOME OF 1.0 (public/tour.js): the bonjour the first time, the chapters after it, the pill
866
1802
  // that waits in the bar, `?` for the gestures, and the hint on Connections. In Home, «Take me
867
1803
  // there» opens what lives here; for what lives inside a project it opens the one open most
@@ -888,9 +1824,43 @@ const homeTour = startTour({
888
1824
  links: () => openLinkSheet(),
889
1825
  get maps() { return ui.macro.hidden ? undefined : () => ui.macro.click(); },
890
1826
  settings: () => openSettings(),
1827
+ local: () => openSettings('local'),
891
1828
  gestures: () => homeTour.openGestures(),
892
1829
  },
893
1830
  });
1831
+ // What each key of Home's bar opens, said the first time it opens (public/tour.js, firstOpen).
1832
+ const topSheet = () => [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
1833
+ homeTour.firstOpen('home-links', ui.bar.links, {
1834
+ opened: topSheet,
1835
+ inside: (box) => box.querySelector('.sm-link-list, .sm-sheet-body'),
1836
+ title: () => t('Connections'),
1837
+ text: () => t('Which projects can look up what another learned, and the contract each one signed. Nothing is shared without your signature, and you can cancel any connection here.'),
1838
+ });
1839
+ homeTour.firstOpen('home-map', ui.macro, {
1840
+ title: () => t('Project map'),
1841
+ text: () => t('Your projects as a map. Drag one onto another to connect them, click one to go to it, and press Esc to come back.'),
1842
+ after: 900,
1843
+ });
1844
+ homeTour.firstOpen('home-settings', ui.gear, {
1845
+ opened: () => document.querySelector('dialog.sm-settings[open]'),
1846
+ inside: (box) => box.querySelector('.sm-settings-window'),
1847
+ title: () => t('Home settings'),
1848
+ text: () => t('How Home looks and what its bar holds, your agents, the Coordinator, which projects use the local AI, your projects and the version. ⌘ , opens it from anywhere.'),
1849
+ });
1850
+ // What each widget shows, the first time it is on Home: its own line, and how to change it.
1851
+ const WIDGET_GUIDE = {
1852
+ system: () => t('How busy this Mac\'s processor is and how much memory is free. It updates on its own.'),
1853
+ servers: () => t('The servers running in your open projects and whether they answer. A project shows here when its Preview is on.'),
1854
+ memory: () => t('How grown each open project\'s memory is, and its weakest side: the one thing that would improve it most.'),
1855
+ coordinator: () => t('How far the Coordinator\'s own record is from training a model of its own.'),
1856
+ models: () => t('Ollama and its models, and how many examples each project has toward a model of its own (8/300 is 8 of 300). «Choose projects» sets which ones use the local AI.'),
1857
+ usage: () => t('How many copies of MADRE are on, which versions and whether people come back. Only you see this.'),
1858
+ };
1859
+ function guideWidget(node, kind) {
1860
+ const said = WIDGET_GUIDE[kind];
1861
+ if (!said) return;
1862
+ void homeTour.hint(`w-${kind}`, node.querySelector('.sm-tile-head') ?? node, { title: WIDGETS[`w-${kind}`]?.name() ?? '', text: `${said()} ${t('Right-click it to change its size or take it off.')}` });
1863
+ }
894
1864
  // Once Home has drawn: the bonjour if it is owed; if it was given, the hint on Connections — what
895
1865
  // it is, and, for whoever came from 0.7.2, what the word meant before.
896
1866
  let welcomeAsked = false;
@@ -910,6 +1880,15 @@ function welcomeHome() {
910
1880
  : t('A connection lets one project look up what another learned, by a contract you sign. Nothing is written across.'),
911
1881
  });
912
1882
  }, 2200);
1883
+ // 1.0.2 brings one key to Home's bar: said once, after any other card, never over a sheet.
1884
+ let tries = 0;
1885
+ const sayAdd = async () => {
1886
+ const key = document.getElementById('sm-add-widgets');
1887
+ if (!key || document.querySelector('dialog[open]') || !(await homeTour.hint('new-add-widgets', key, { title: t('New: add widgets'), text: t('This button opens «Add to Home»: choose the widgets you want to see here, or bring back the ones you removed.') }))) {
1888
+ if ((tries += 1) < 6) setTimeout(sayAdd, 6000)?.unref?.();
1889
+ }
1890
+ };
1891
+ setTimeout(sayAdd, 5000)?.unref?.();
913
1892
  });
914
1893
  }
915
1894
 
@@ -1240,7 +2219,7 @@ function openLinkSheet({ reader = null, source = null } = {}) {
1240
2219
  actions.append(next());
1241
2220
  } else if (kind === 'orders') {
1242
2221
  const name = nameOf(ordersInto());
1243
- intro(t('Orders from the coordinator'), sayBold('With orders, the Coordinator can ask {room} for a task, and its agents do it there. Without them, it only reads. Edit (#3) and Full access (#4) never go this way: they arrive as a proposal you unlock in that project.', { room: name }));
2222
+ intro(t('Orders from the Coordinator'), sayBold('With orders, the Coordinator can ask {room} for a task, and its agents do it there. Without them, it only reads. #3 EDIT and #4 FULL ACCESS never go this way: they arrive as a proposal you unlock in that project.', { room: name }));
1244
2223
  const box = card();
1245
2224
  box.append(row(sayBold('Allow orders into {room}', { room: name }), sayBold('Leave it off unless you need the Coordinator to act in {room}.', { room: name }), toggle(pick.orders, (on) => { pick.orders = on; render(); }, t('Allow orders into {room}', { room: name })), { recommended: !pick.orders }));
1246
2225
  if (pick.orders) {
@@ -1307,19 +2286,29 @@ function openLinkSheet({ reader = null, source = null } = {}) {
1307
2286
  }
1308
2287
  ui.link.addEventListener('click', () => openLinkSheet());
1309
2288
 
2289
+ let refreshPending = null;
1310
2290
  async function refresh() {
1311
- const answer = await fetch('/api/salas', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
2291
+ if (refreshPending) return refreshPending;
2292
+ refreshPending = fetch('/api/salas', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
2293
+ const answer = await refreshPending;
2294
+ refreshPending = null;
1312
2295
  if (!answer) { ui.foot.textContent = t('This project did not answer. It may have closed.'); ui.bar.live.classList.add('is-off'); ui.bar.live.lastChild.textContent = t('Not answering right now'); return; }
2296
+ // Home is the first screen of `npx madre`: a MADRE nobody has set takes this browser's language
2297
+ // here, once, and every project follows (public/i18n.js, browserLanguage).
2298
+ if (answer.languageChosen === false && !languageAdopted) {
2299
+ languageAdopted = true;
2300
+ let chosenHere = null;
2301
+ try { chosenHere = localStorage.getItem('pulse.language'); } catch { /* no storage */ }
2302
+ const mine = chosenHere === 'es' || chosenHere === 'en' ? chosenHere : browserLanguage();
2303
+ if (await post('/api/language', { language: mine }).then((response) => response?.ok !== false).catch(() => false)) answer.language = mine;
2304
+ }
1313
2305
  if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); homeTour.repaint(); }
1314
2306
  data = answer;
1315
2307
  // A project that came up after Home stopped waiting is open: its card no longer says it did not.
1316
2308
  for (const room of answer.rooms ?? []) if (room.open) failed.delete(room.id);
1317
2309
  ui.foot.textContent = t('Every project runs on this Mac, on its own, with its own memory.');
1318
2310
  // The room's corner (app.js, #colophon): the version this Home runs, and NOTICE's own line.
1319
- if (answer.version) {
1320
- ui.colophon.replaceChildren(el('b', null, `MADRE ${answer.version}`), document.createTextNode('\u00a0·\u00a0© 2026 Jossue Alcalá'));
1321
- ui.colophon.hidden = false;
1322
- }
2311
+ if (answer.version) drawAttribution(ui.colophon, answer.version);
1323
2312
  draw();
1324
2313
  }
1325
2314
 
@@ -1417,7 +2406,7 @@ function macroWords() {
1417
2406
  back: t('Back to Home'),
1418
2407
  hint: t('Drag the space to move it · wheel to come closer · click a project to look at it · drag it to turn it · carry one onto the Coordinator for the Coordinator to read it, or onto another to connect them · ← → from one to the next · Esc to come back'),
1419
2408
  bishop: t('Coordinator'),
1420
- bishopAbout: t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.'),
2409
+ bishopAbout: t('Your Coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.'),
1421
2410
  bishopReads: (n) => (n ? (n === 1 ? t('It reads 1 project by a connection') : t('It reads {n} projects by connections', { n })) : t('It reads no project yet: each one it reads is a connection you sign.')),
1422
2411
  notLinked: t('Not connected yet: touch one to connect it'),
1423
2412
  talk: t('Talk to the Coordinator'),
@@ -1425,7 +2414,7 @@ function macroWords() {
1425
2414
  recenter: t('RECENTER'),
1426
2415
  // The legend under its name, in the Memory map's words.
1427
2416
  legend: t('Memory kinds'),
1428
- kinds: { ...Object.fromEntries(KINDS.map((kind) => [kind, kindWord(kind)])), dim: t('OUT OF CIRCULATION') },
2417
+ kinds: { ...Object.fromEntries(KINDS.map((kind) => [kind, kindWord(kind)])), dim: t('NOT USED') },
1429
2418
  veins: (n) => t('{n} connections', { n }),
1430
2419
  links: t('Connections'),
1431
2420
  link: t('Connect…'),
@@ -1937,6 +2926,14 @@ function pinsMenu(event, room, at = null) {
1937
2926
  item(on ? t('Remove from the bar') : t('Pin to the bar'), on ? UNPIN_ICON : PIN_ICON, () => (on ? pinRemove(room.id) : pinAdd(room.id)));
1938
2927
  item(t('Arrange the bar'), ARRANGE_ICON, () => pinsEdit(true));
1939
2928
  menu.append(el('i', 'sm-menu-sep'));
2929
+ // Where it sits among the projects: moved by hand, the order becomes «Yours».
2930
+ const order = projectsInOrder().all.map((one) => one.id);
2931
+ const place = order.indexOf(room.id);
2932
+ if (place > 0) item(t('Move before'), BEFORE_ICON, () => moveProject(room.id, -1));
2933
+ if (place >= 0 && place < order.length - 1) item(t('Move after'), AFTER_ICON, () => moveProject(room.id, 1));
2934
+ if (place > 0) item(t('Move to the start'), BEFORE_ICON, () => moveProject(room.id, 'first'));
2935
+ item(t('Add to Home…'), ADD_ICON, () => openHomeAdd());
2936
+ menu.append(el('i', 'sm-menu-sep'));
1940
2937
  item(t('Remove from MADRE…'), FORGET_ICON, () => { forgetting.add(room.id); asking.delete(room.id); failed.delete(room.id); draw(); }, { danger: true });
1941
2938
  menu.addEventListener('keydown', (key) => {
1942
2939
  const items = [...menu.querySelectorAll('.sm-menu-item')];
@@ -2060,6 +3057,8 @@ function reloadWithGrace() {
2060
3057
  }, 900);
2061
3058
  }
2062
3059
  ui.bar.reload.addEventListener('click', reloadWithGrace);
3060
+ // «Add to Home» had only a right-click and a menu; now a key of its own in the bar (2026-10-14).
3061
+ document.getElementById('sm-add-widgets')?.addEventListener('click', () => openHomeAdd());
2063
3062
 
2064
3063
  // From Home into a project, and back (2026-10-10): the page settles back under a veil of its own
2065
3064
  // ground and goes (styles.css, madre-leaving); the room lifts the veil as it arrives. Every way in
@@ -2127,7 +3126,9 @@ function drawBishop(room) {
2127
3126
  const b = ui.bishop;
2128
3127
  const glance = room?.glance ?? {};
2129
3128
  const state = room?.open ? stateOf(room) : { key: 'asleep', words: t('Asleep') };
2130
- b.box.className = `sm-tile sm-bishop c-8 r-3 is-${state.key}`;
3129
+ // Only its state: its size is the person's (applyLayout), and a class written whole took it back.
3130
+ for (const one of [...b.box.classList]) if (one.startsWith('is-')) b.box.classList.remove(one);
3131
+ b.box.classList.add(`is-${state.key}`);
2131
3132
  b.state.querySelector('span').textContent = state.words;
2132
3133
  const waking = busy.get(data?.salas) === 'starting';
2133
3134
  for (const way of [b.go, b.tab]) way.setAttribute('aria-disabled', String(waking));
@@ -2437,44 +3438,281 @@ function openAddSheet() {
2437
3438
  }
2438
3439
  ui.add.addEventListener('click', openAddSheet);
2439
3440
 
2440
- // The agents of this computer, each with its session and the one action it needs (install, or
2441
- // sign in), kept current while it is on screen: polled, and told by the room's events what an
2442
- // install or a sign-in says as it goes. The wizard's first step and the settings sheet show it.
2443
- function agentsPanel({ onPaint = null } = {}) {
3441
+ // The agents of this computer, with everything a connection needs in one place: which account
3442
+ // each one signs in with, the doors it has, the box where a pasted key goes, the address a
3443
+ // sign-in opened and the code that page gives back. Home used to show a name, a lamp and one
3444
+ // button — the project's panel had the rest, so the same errand was two different screens.
3445
+ // Kept current while it is on screen: polled, and told by the room's events how it is going.
3446
+ // `compact` is the welcome's first step: there, a connected agent is done, and a button to change
3447
+ // the account it signs in with is a question nobody asked on their first minute.
3448
+ // Who makes each agent, said under its name as the project's AJUSTES says it.
3449
+ const VENDOR_WORDS = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'OpenCode' };
3450
+
3451
+ // OpenCode's providers in Home, the same block as the project's AJUSTES › Agents › OpenCode: a line
3452
+ // per provider with a key or an account and its switch for the chat, one row to add another, and
3453
+ // the model it answers with by default. Asked of the server once a minute at most, because asking
3454
+ // runs `opencode models`; a change asks again at once.
3455
+ const openCodeProviders = (() => {
3456
+ let reading = null;
3457
+ let at = 0;
3458
+ let host = null;
3459
+ const fetchNow = async () => {
3460
+ reading = await fetch('/api/agents/opencode/providers', { cache: 'no-store' }).then((response) => response.json().then((json) => ({ ok: response.ok, json }))).catch(() => ({ ok: false, json: {} }));
3461
+ at = Date.now();
3462
+ paintInto();
3463
+ };
3464
+ const sendKey = (path, payload) => fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
3465
+ .then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
3466
+ const switchFor = (on, change, label) => {
3467
+ const wrap = el('label', 'settings-switch');
3468
+ const box = el('input'); box.type = 'checkbox'; box.checked = on; box.setAttribute('aria-label', label);
3469
+ box.addEventListener('change', () => void change(box.checked, box));
3470
+ wrap.append(box, el('i'));
3471
+ return wrap;
3472
+ };
3473
+ function paintInto() {
3474
+ if (!host?.isConnected) return;
3475
+ host.replaceChildren(el('b', 'sm-oc-title', t('Providers')));
3476
+ if (!reading) { host.append(el('small', 'sm-agent-note', t('Reading OpenCode\'s providers…'))); return; }
3477
+ if (!reading.ok) { host.append(el('small', 'sm-agent-note', reading.json.error ?? t('OpenCode\'s providers could not be read.'))); return; }
3478
+ const providers = reading.json.providers ?? [];
3479
+ const hidden = new Set(providers.filter((one) => one.hidden).map((one) => one.id));
3480
+ for (const provider of providers.filter((one) => one.via)) {
3481
+ const row = el('div', 'sm-oc-row');
3482
+ const words = el('span', 'sm-agent-words');
3483
+ const status = provider.via === 'own' ? t('Included with OpenCode · {n} models', { n: provider.models.length })
3484
+ : provider.via === 'api' ? t('Connected with a key · {n} models', { n: provider.models.length })
3485
+ : provider.via === 'oauth' ? t('Connected with its account · {n} models', { n: provider.models.length })
3486
+ : t('Connected · {n} models', { n: provider.models.length });
3487
+ words.append(el('b', null, provider.label), el('small', 'sm-agent-note', status));
3488
+ const controls = el('span', 'sm-oc-controls');
3489
+ if (provider.via === 'api') {
3490
+ const remove = button('sm-btn ghost small', t('Remove the key'), async () => {
3491
+ if (remove.dataset.sure !== '1') { remove.dataset.sure = '1'; remove.textContent = t('Remove it?'); return; }
3492
+ remove.disabled = true;
3493
+ const answer = await sendKey('/api/agents/opencode/providers/remove', { provider: provider.id });
3494
+ if (!answer.ok) { remove.disabled = false; remove.textContent = answer.body.error ?? t('The key was not removed.'); return; }
3495
+ void fetchNow();
3496
+ });
3497
+ controls.append(remove);
3498
+ }
3499
+ const label = el('span', 'sm-oc-shown', t('In the chat'));
3500
+ controls.append(label, switchFor(!provider.hidden, async (on, box) => {
3501
+ if (on) hidden.delete(provider.id); else hidden.add(provider.id);
3502
+ const answer = await sendKey('/api/settings', { opencode: { hidden: [...hidden] } });
3503
+ if (!answer.ok) { box.checked = !on; if (on) hidden.add(provider.id); else hidden.delete(provider.id); }
3504
+ }, t('Show {provider} in the chat', { provider: provider.label })));
3505
+ row.append(words, controls);
3506
+ host.append(row);
3507
+ }
3508
+ const waiting = providers.filter((one) => !one.via && one.takesKey);
3509
+ if (waiting.length) {
3510
+ const form = el('form', 'sm-agent-key-form sm-oc-add');
3511
+ const which = el('select');
3512
+ which.setAttribute('aria-label', t('Provider'));
3513
+ for (const provider of waiting) { const option = el('option', null, provider.label); option.value = provider.id; which.append(option); }
3514
+ const input = el('input');
3515
+ input.type = 'password'; input.autocomplete = 'off'; input.spellcheck = false;
3516
+ const link = el('a', 'sm-agent-key-link', t('Where do I get one?'));
3517
+ link.target = '_blank'; link.rel = 'noopener';
3518
+ const said = el('small', 'sm-agent-line');
3519
+ const sync = () => {
3520
+ const provider = waiting.find((one) => one.id === which.value);
3521
+ input.placeholder = t('{provider} API key', { provider: provider?.label ?? '' });
3522
+ link.hidden = !provider?.keyUrl;
3523
+ if (provider?.keyUrl) link.href = provider.keyUrl;
3524
+ };
3525
+ which.addEventListener('change', sync);
3526
+ sync();
3527
+ const save = el('button', 'sm-btn primary small', t('Add'));
3528
+ save.type = 'submit';
3529
+ form.append(which, input, save, link, said);
3530
+ form.addEventListener('submit', async (event) => {
3531
+ event.preventDefault();
3532
+ if (!input.value.trim()) { input.focus(); return; }
3533
+ save.disabled = true;
3534
+ said.textContent = t('Checking…');
3535
+ const answer = await sendKey('/api/agents/opencode/key', { key: input.value.trim(), provider: which.value });
3536
+ input.value = '';
3537
+ save.disabled = false;
3538
+ if (answer.ok) void fetchNow(); else said.textContent = answer.body.error ?? t('The key was not accepted.');
3539
+ });
3540
+ host.append(el('b', 'sm-oc-title', t('Add a provider')), form);
3541
+ }
3542
+ }
3543
+ return {
3544
+ node() {
3545
+ // The same node across the panel's repaints, so a key being typed is never thrown away.
3546
+ host ??= el('div', 'sm-oc');
3547
+ if (!reading || Date.now() - at > 60000) void fetchNow(); else paintInto();
3548
+ return host;
3549
+ },
3550
+ };
3551
+ })();
3552
+
3553
+ function agentsPanel({ onPaint = null, compact = false } = {}) {
2444
3554
  const list = el('div', 'sm-agents');
2445
3555
  const lastLine = new Map(); // agent → what its install or sign-in said last
3556
+ const openMore = new Set(); // agents whose connection detail is unfolded
3557
+ const toggleMore = (id) => { if (openMore.has(id)) openMore.delete(id); else openMore.add(id); };
3558
+ let live = null; // the sign-in running right now, as the server tells it
2446
3559
  let poll = 0;
2447
3560
  let stream = null;
3561
+
3562
+ // The second half of a browser sign-in: the address stays put, and the code that page hands
3563
+ // back is typed here. Without it the sign-in could only wait until its clock killed it.
3564
+ const askBlock = (agent) => {
3565
+ if (!live || live.agent !== agent.id) return null;
3566
+ const box = el('div', 'sm-agent-ask');
3567
+ if (live.url) {
3568
+ const open = el('a', 'sm-btn primary small', t('Open the sign-in page'));
3569
+ open.href = live.url;
3570
+ open.target = '_blank';
3571
+ open.rel = 'noopener';
3572
+ box.append(open);
3573
+ }
3574
+ if (live.asksForCode || live.awaitingCode) {
3575
+ const form = el('form', 'sm-agent-code');
3576
+ const input = el('input');
3577
+ input.type = 'text';
3578
+ input.autocomplete = 'off';
3579
+ input.spellcheck = false;
3580
+ input.placeholder = t('Paste the code the page gave you');
3581
+ const send = el('button', 'sm-btn primary small', t('Send the code'));
3582
+ send.type = 'submit';
3583
+ const said = el('small', 'sm-agent-line');
3584
+ form.append(input, send, said);
3585
+ form.addEventListener('submit', async (event) => {
3586
+ event.preventDefault();
3587
+ send.disabled = true;
3588
+ said.textContent = t('Checking…');
3589
+ const answer = await post(`/api/agents/${agent.id}/login/code`, { code: input.value });
3590
+ if (answer.ok) { input.value = ''; said.textContent = t('Sent. The sign-in finishes on its own.'); }
3591
+ else said.textContent = answer.body.error ?? t('The code was not accepted.');
3592
+ send.disabled = false;
3593
+ });
3594
+ box.append(form);
3595
+ }
3596
+ box.append(button('sm-btn ghost small', t('Cancel the sign-in'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/login/cancel`); void paint(); }));
3597
+ return box;
3598
+ };
3599
+
3600
+ // Handing a key to a CLI, with the same promise the project's panel makes: it is written where
3601
+ // that CLI reads it, MADRE keeps no copy, and the CLI itself says whether it took.
3602
+ const keyForm = (agent) => {
3603
+ const plan = agent.key;
3604
+ const form = el('form', 'sm-agent-key-form');
3605
+ let select = null;
3606
+ if (plan.providers) {
3607
+ select = el('select');
3608
+ select.setAttribute('aria-label', t('Provider'));
3609
+ for (const provider of plan.providers) { const option = el('option', null, provider.label); option.value = provider.id; select.append(option); }
3610
+ form.append(select);
3611
+ }
3612
+ const input = el('input');
3613
+ input.type = 'password';
3614
+ input.autocomplete = 'off';
3615
+ input.spellcheck = false;
3616
+ input.placeholder = t(plan.label);
3617
+ const save = el('button', 'sm-btn primary small', t('Save the key'));
3618
+ save.type = 'submit';
3619
+ form.append(input, save);
3620
+ const said = el('small', 'sm-agent-line');
3621
+ const keyUrl = plan.providers ? (plan.providers[0]?.keyUrl ?? null) : plan.keyUrl;
3622
+ if (keyUrl) {
3623
+ const link = el('a', 'sm-agent-key-link', t('Where do I get one?'));
3624
+ link.href = keyUrl;
3625
+ link.target = '_blank';
3626
+ link.rel = 'noopener';
3627
+ form.append(link);
3628
+ select?.addEventListener('change', () => { const next = plan.providers.find((one) => one.id === select.value)?.keyUrl; if (next) link.href = next; });
3629
+ }
3630
+ form.append(said, el('small', 'sm-agent-note', t(plan.note)));
3631
+ form.addEventListener('submit', async (event) => {
3632
+ event.preventDefault();
3633
+ save.disabled = true;
3634
+ said.textContent = t('Checking…');
3635
+ const answer = await post(`/api/agents/${agent.id}/key`, { key: input.value, provider: select?.value ?? null });
3636
+ if (answer.ok) { input.value = ''; openMore.delete(agent.id); void paint(); }
3637
+ else { said.textContent = answer.body.error ?? t('The key was not accepted.'); save.disabled = false; }
3638
+ });
3639
+ return form;
3640
+ };
3641
+
2448
3642
  const paint = async () => {
2449
3643
  const answer = await fetch('/api/settings', { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
2450
3644
  if (!answer?.agents) return;
2451
3645
  agentsSeen = answer;
2452
3646
  drawAttention();
2453
- list.replaceChildren();
3647
+ live = answer.login ?? null;
2454
3648
  let ready = 0;
3649
+ for (const agent of answer.agents.filter((one) => one.id !== 'madre')) if (agentState(agent, answer).ok) ready += 1;
3650
+ // A field being typed into is never yanked away by the poll: the panel redraws every few
3651
+ // seconds, and a half-pasted key or code would go with it.
3652
+ if (list.contains(document.activeElement) && ['INPUT', 'SELECT'].includes(document.activeElement.tagName)) { onPaint?.(ready, answer); return; }
3653
+ list.replaceChildren();
2455
3654
  for (const agent of answer.agents.filter((one) => one.id !== 'madre')) {
2456
3655
  const state = agentState(agent, answer);
2457
- if (state.ok) ready += 1;
2458
- const item = el('div', `sm-agent${state.ok ? ' ok' : ''}`);
3656
+ // The project's AJUSTES design (2026-10-13): one card per agent, folded to a line that says
3657
+ // who it is and whether it has a session. One that needs the person opens by itself.
3658
+ const unfolded = openMore.has(agent.id) || !state.ok || state.busyNow || (live && live.agent === agent.id);
3659
+ const item = el('section', `sm-agent${state.ok ? ' ok' : ''}${unfolded ? ' open' : ''}`);
3660
+ item.dataset.agent = agent.id;
3661
+ const head = button('sm-agent-head', null, () => { toggleMore(agent.id); if (!openMore.has(agent.id) && !state.ok) openMore.add(agent.id); void paint(); });
3662
+ head.setAttribute('aria-expanded', String(unfolded));
2459
3663
  const words = el('span', 'sm-agent-words');
2460
- words.append(el('b', null, agent.label ?? agent.id));
2461
- words.append(el('small', `session-lamp ${state.key}`, state.words));
3664
+ words.append(el('b', null, agent.label ?? agent.id), el('small', null, VENDOR_WORDS[agent.id] ?? ''));
3665
+ const chip = el('span', `sm-agent-chip session-lamp ${state.key}`);
3666
+ chip.append(el('span', null, state.words));
3667
+ head.append(mark(agent.id, 34), words, chip, el('span', 'sm-chevron'));
3668
+ item.append(head);
3669
+ const body = el('div', 'sm-agent-body');
3670
+ // Which account it is signed in with, as its own CLI reports it, and which one it asks for.
3671
+ const facts = el('div', 'sm-agent-facts');
3672
+ if (agent.detected && agent.version) facts.append(el('code', 'sm-agent-line', `${agent.version}${agent.path ? ` · ${agent.path}` : ''}`));
3673
+ if (agent.detected && answer.sessions?.[agent.id]?.detail) facts.append(el('small', 'sm-agent-line', answer.sessions[agent.id].detail));
3674
+ if (agent.account) facts.append(el('small', 'sm-agent-note', agent.account));
2462
3675
  if (lastLine.has(agent.id) && !state.ok) {
2463
3676
  const said = lastLine.get(agent.id);
2464
3677
  const line = el('small', 'sm-agent-line', said.line);
2465
3678
  if (said.url) { const link = el('a', null, t('Open the sign-in page')); link.href = said.url; link.target = '_blank'; link.rel = 'noopener'; line.append(' ', link); }
2466
- words.append(line);
3679
+ facts.append(line);
2467
3680
  }
2468
- item.append(mark(agent.id, 34), words);
2469
- if (!state.ok && !state.busyNow) {
2470
- if (!agent.detected && agent.install) item.append(button('sm-btn ghost small', t('Install'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/install`); void paint(); }));
2471
- else if (agent.detected && agent.login) item.append(button('sm-btn ghost small', t('Sign in'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/login`); void paint(); }));
3681
+ if (facts.childNodes.length) body.append(facts);
3682
+ // One button per account the CLI can sign in with: Claude has two, and only the human can
3683
+ // say which one is paying.
3684
+ const doorKeys = () => {
3685
+ const doors = agent.login?.methods?.length ? agent.login.methods : [{ id: null, label: null, note: agent.login?.note }];
3686
+ return doors.map((door) => {
3687
+ const name = door.label ? t(door.label) : state.ok ? t('Sign in again') : t('Sign in');
3688
+ const key = button(`sm-btn ${state.ok ? '' : 'primary'} small`, name, async (event) => {
3689
+ event.currentTarget.disabled = true;
3690
+ await post(`/api/agents/${agent.id}/login`, { method: door.id });
3691
+ void paint();
3692
+ });
3693
+ if (door.note) key.title = t(door.note);
3694
+ return key;
3695
+ });
3696
+ };
3697
+ const acts = el('div', 'sm-agent-acts');
3698
+ if (!state.busyNow) {
3699
+ if (!agent.detected && agent.install) acts.append(button('sm-btn primary small', t('Install'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/install`); void paint(); }));
3700
+ else if (agent.detected && agent.login?.headless) for (const key of doorKeys()) acts.append(key);
2472
3701
  }
3702
+ if (acts.childNodes.length) body.append(acts);
3703
+ const ask = askBlock(agent);
3704
+ if (ask) body.append(ask);
3705
+ // A key, pasted here; OpenCode's are its providers', in the block below.
3706
+ if (agent.detected && agent.key && agent.id !== 'opencode') body.append(keyForm(agent));
3707
+ // A CLI that signs in from its own prompt gets the command, until it has a session.
3708
+ if (agent.detected && agent.login && !agent.login.headless && !state.ok) body.append(el('code', 'sm-agent-command', agent.login.display));
3709
+ if (agent.id === 'opencode' && agent.detected) body.append(openCodeProviders.node());
3710
+ item.append(body);
2473
3711
  list.append(item);
2474
3712
  }
2475
3713
  onPaint?.(ready, answer);
2476
3714
  };
2477
- const live = () => {
3715
+ const liveOn = () => {
2478
3716
  if (!list.isConnected || poll) return;
2479
3717
  poll = setInterval(() => { if (!document.hidden) void paint(); }, 2500);
2480
3718
  try {
@@ -2489,7 +3727,7 @@ function agentsPanel({ onPaint = null } = {}) {
2489
3727
  } catch { /* no stream: the poll keeps it current */ }
2490
3728
  };
2491
3729
  const stop = () => { clearInterval(poll); poll = 0; stream?.close(); stream = null; };
2492
- return { node: list, paint, live, stop };
3730
+ return { node: list, paint, live: liveOn, stop };
2493
3731
  }
2494
3732
 
2495
3733
  // ---- The first time: three steps. The agents (connected once, for every project: a CLI's session
@@ -2581,6 +3819,7 @@ function openWizard({ then = null } = {}) {
2581
3819
  const next = button('sm-btn primary', t('Continue'), () => { step = 1; projectsStep(); });
2582
3820
  actions.append(next);
2583
3821
  const fresh = agentsPanel({
3822
+ compact: true,
2584
3823
  onPaint: (ready, answer) => {
2585
3824
  next.textContent = ready ? t('Continue') : t('Continue without agents');
2586
3825
  // The scene lights the agents that are connected, and their wire to MADRE.
@@ -2658,7 +3897,7 @@ function openWizard({ then = null } = {}) {
2658
3897
  // in that room. Nothing scrolls inside the window: the screen behind it does.
2659
3898
  const SET_SECTIONS = [
2660
3899
  ['You', 'How Home looks and what its bar holds', ['look', 'bar']],
2661
- ['The agents', 'Who works on this Mac', ['agents', 'bishop']],
3900
+ ['The agents', 'Who works on this Mac', ['agents', 'bishop', 'local']],
2662
3901
  ['System', 'The projects of this Mac and the version', ['projects', 'release']],
2663
3902
  ];
2664
3903
  // The room's own icons where the room has the category (app.js, SETTINGS_CATS); the bar, BISHOP
@@ -2668,15 +3907,17 @@ const SET_CATS = {
2668
3907
  bar: { name: 'Top bar', icon: '<rect x="2.6" y="5.4" width="14.8" height="9.2" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6.2 10h2.2M11.6 10h2.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' },
2669
3908
  agents: { name: 'Agents', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
2670
3909
  bishop: { name: 'Coordinator', icon: '<path d="M2.6 10c1.7-3.1 4.3-4.8 7.4-4.8s5.7 1.7 7.4 4.8c-1.7 3.1-4.3 4.8-7.4 4.8S4.3 13.1 2.6 10z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="10" cy="10" r="2.3" fill="currentColor"/>' },
3910
+ local: { name: 'Local AI', icon: '<rect x="5" y="5" width="10" height="10" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 2.6v2.4M12 2.6v2.4M8 15v2.4M12 15v2.4M2.6 8h2.4M2.6 12h2.4M15 8h2.4M15 12h2.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="10" cy="10" r="1.8" fill="currentColor"/>' },
2671
3911
  projects: { name: 'Projects', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
2672
3912
  release: { name: 'Version', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
2673
3913
  };
2674
- const SET_ORDER = ['look', 'bar', 'agents', 'bishop', 'projects', 'release'];
3914
+ const SET_ORDER = ['look', 'bar', 'agents', 'bishop', 'local', 'projects', 'release'];
2675
3915
  // The words the search knows, each pointing to the category that holds it (app.js, SETTINGS_INDEX).
2676
3916
  const SET_INDEX = [
2677
3917
  ['look', 'Preview'], ['look', 'Theme'], ['look', 'Accent'], ['look', 'Wallpaper'], ['look', 'Intensity'], ['look', 'Blur'], ['look', 'Project cards'], ['look', 'Text'], ['look', 'Project order'], ['look', 'Language'],
2678
3918
  ['bar', 'Arrange the bar'], ['bar', 'Back to default'],
2679
3919
  ['bishop', 'Talk to the Coordinator'], ['bishop', 'Open the Coordinator in a new tab'],
3920
+ ['local', 'Ollama'], ['local', 'Automatic'], ['local', 'One by one'], ['local', 'Manual'],
2680
3921
  ['projects', 'Add a project'], ['projects', 'See the welcome again'], ['projects', 'The tour of MADRE 1.0'],
2681
3922
  ['release', 'Check now'],
2682
3923
  ];
@@ -2744,6 +3985,11 @@ function setStatus(id) {
2744
3985
  return { line: t('{n} of {total} signed in', { n: here.length - off.length, total: here.length }), warn: off.length ? t('{agents} without a session', { agents: off.map((agent) => agent.label ?? agent.id).join(', ') }) : null };
2745
3986
  }
2746
3987
  if (id === 'bishop') { const room = bishopRoom(); return { line: room ? stateOf(room).words : t('Reading…') }; }
3988
+ if (id === 'local') {
3989
+ if (!localSeen) return { line: localFailed ? t('Home did not answer.') : t('Reading…') };
3990
+ const on = localSeen.projects.filter((one) => one.on).length;
3991
+ return { line: `${LOCAL_MODE_WORDS[localSeen.mode]()} · ${t('{n} of {total} projects', { n: on, total: localSeen.projects.length })}`, warn: !localSeen.master ? t('Local AI is off for every project.') : !localSeen.ollama.running ? t('Ollama is not running') : null };
3992
+ }
2747
3993
  if (id === 'projects') {
2748
3994
  if (!data) return { line: t('Reading…') };
2749
3995
  const { all, open } = projectsInOrder();
@@ -2751,7 +3997,7 @@ function setStatus(id) {
2751
3997
  }
2752
3998
  if (id === 'release') {
2753
3999
  if (!versionSeen) return { line: t('Reading…') };
2754
- return { line: `MADRE ${versionSeen.current} · ${versionSeen.available ? t('{version} available', { version: versionSeen.latest }) : versionSeen.enabled === false ? t('not checked') : t('up to date')}`, warn: versionSeen.available ? t('{version} is on npm', { version: versionSeen.latest }) : null };
4000
+ return { line: `MADRE ${versionSeen.current} · ${versionSeen.available ? t('{version} available', { version: versionSeen.latest }) : versionSeen.enabled === false ? t('not checked') : t('up to date')}`, warn: versionSeen.available ? t('{version} is out', { version: versionSeen.latest }) : null };
2755
4001
  }
2756
4002
  return { line: '' };
2757
4003
  }
@@ -2903,13 +4149,13 @@ function fillLook(host) {
2903
4149
  paperBox.append(tune);
2904
4150
  }
2905
4151
  sxBlock(page, t('Wallpaper'), photo
2906
- ? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
4152
+ ? t('The project\'s background can be a photograph, shown as a layer or as a mesh of its colors: choose how strong it looks and how much the photograph blurs.')
2907
4153
  : t('Behind Home, and behind the conversation of the project served at this address. Light and dark each have their own.')).rows.append(paperBox);
2908
4154
 
2909
4155
  // Home's own: its cards, its text, the order of its projects. The conversation's bubbles and text
2910
4156
  // are each project's, in its own Settings.
2911
4157
  const home = sxBlock(side, t('Home'), t('Only for Home: each project keeps its conversation as you set it there.'));
2912
- const homeSeg = (name) => customSeg(HOME_LOOKS[name].values.map((value) => [value, HOME_LOOKS[name].words[value]()]), looks.home[name], (value) => saveHome(name, value), HOME_LOOKS[name].values.length === 2 ? 'two' : 'three');
4158
+ const homeSeg = (name) => customSeg(HOME_LOOKS[name].values.map((value) => [value, HOME_LOOKS[name].words[value]()]), looks.home[name], (value) => saveHome(name, value), ['', '', 'two', 'three', 'four'][HOME_LOOKS[name].values.length] ?? 'three');
2913
4159
  sxRow(home, t('Project cards'), null, homeSeg('cards'), { stack: true });
2914
4160
  sxRow(home, t('Text'), null, homeSeg('text'), { stack: true });
2915
4161
  sxRow(home, t('Project order'), null, homeSeg('order'), { stack: true });
@@ -2947,9 +4193,11 @@ function fillBar(host) {
2947
4193
  host.append(acts);
2948
4194
  }
2949
4195
  function fillAgents(host, keep) {
2950
- const block = sxBlock(host, t('Agents'), t('MADRE works with the AI tools already on this computer. Each one is connected once, and every project uses it.'));
4196
+ // The project's AJUSTES › Agents, here too: a sentence, then a card per agent.
4197
+ host.append(el('p', 'sx-intro', t('MADRE works with the AI tools already on this computer. Each one is connected once, and every project uses it.')));
2951
4198
  const panel = agentsPanel({ onPaint: () => settingsSheet?.sync() });
2952
- block.rows.append(panel.node);
4199
+ panel.node.classList.add('cards');
4200
+ host.append(panel.node);
2953
4201
  keep(panel.stop);
2954
4202
  void panel.paint().then(() => panel.live());
2955
4203
  }
@@ -2959,6 +4207,105 @@ function fillBishop(host) {
2959
4207
  sxRow(block, t('Talk to the Coordinator'), room ? stateOf(room).words : null, sxButton(t('Open'), () => { settingsSheet?.close(); openBishop(); }, { tone: 'primary' }));
2960
4208
  sxRow(block, t('Open the Coordinator in a new tab'), null, sxButton(t('Open'), () => openBishop({ newTab: true })));
2961
4209
  }
4210
+ // LOCAL AI (src/local-ai.mjs): whether every project uses Ollama, the ones picked here, or each
4211
+ // one as it decides in its own Settings. The switch of each project is the same one its Settings
4212
+ // show; Ollama itself and what it does are set in a project's Modules › Local AI.
4213
+ const LOCAL_MODE_WORDS = { auto: () => t('Automatic'), pick: () => t('One by one'), manual: () => t('Manual') };
4214
+ const LOCAL_MODE_SAYS = {
4215
+ auto: () => t('Every project uses it, new ones too.'),
4216
+ pick: () => t('Only the projects you switch on below. New projects start without it.'),
4217
+ manual: () => t('Each project decides in its own Modules › Local AI. New ones start with it.'),
4218
+ };
4219
+ let localFailed = false; // the last read got no answer: the sheet says so instead of «Reading…» forever
4220
+ async function loadLocal() {
4221
+ const answer = await fetch('/api/salas/local-ai', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
4222
+ if (answer) localSeen = answer;
4223
+ localFailed = !answer;
4224
+ settingsSheet?.sync({ detail: true });
4225
+ return answer;
4226
+ }
4227
+ async function saveLocal(patch) {
4228
+ const answer = await post('/api/salas/local-ai', patch);
4229
+ if (!answer.ok) { toast(answer.body?.error ?? t('Home did not answer.')); return; }
4230
+ localSeen = answer.body;
4231
+ if (localSeen.later?.length) toast(t('{names} will use it when restarted: it was started before this setting existed.', { names: localSeen.later.join(', ') }));
4232
+ settingsSheet?.sync({ detail: true });
4233
+ }
4234
+ // The rail every settings sheet uses (app.js, sxSeg; styles.css, .sx-seg): the mark moves when chosen.
4235
+ function sxSeg(options, current, pick, { label = null } = {}) {
4236
+ const seg = el('div', 'sx-seg');
4237
+ seg.setAttribute('role', 'radiogroup');
4238
+ if (label) seg.setAttribute('aria-label', label);
4239
+ for (const [value, words] of options) {
4240
+ const option = button(null, words, () => {
4241
+ if (value === current || seg.classList.contains('busy')) return;
4242
+ for (const other of seg.children) other.setAttribute('aria-checked', String(other === option));
4243
+ seg.classList.add('busy');
4244
+ void Promise.resolve(pick(value)).finally(() => seg.classList.remove('busy'));
4245
+ });
4246
+ option.setAttribute('role', 'radio');
4247
+ option.setAttribute('aria-checked', String(value === current));
4248
+ seg.append(option);
4249
+ }
4250
+ return seg;
4251
+ }
4252
+ const localModeSeg = () => sxSeg(Object.keys(LOCAL_MODE_WORDS).map((mode) => [mode, LOCAL_MODE_WORDS[mode]()]), localSeen?.mode, (mode) => saveLocal({ mode }), { label: t('Mode') });
4253
+ // The projects in pages of eight, two to a row where they fit: the sheet never needs a scroll.
4254
+ const LOCAL_PER_PAGE = 8;
4255
+ let localPage = 0;
4256
+ function fillLocal(host) {
4257
+ if (!localSeen && localFailed) {
4258
+ // A Home started before this setting existed has no answer for it: reloading the page reaches the one running now.
4259
+ const block = sxBlock(host, t('Home did not answer.'), t('The project serving this page was started before this setting existed. Restart it, or open Home from another project.'));
4260
+ sxRow(block, t('Try again'), null, sxButton(t('Reload'), () => location.reload(), { tone: 'primary' }));
4261
+ return;
4262
+ }
4263
+ if (!localSeen) { host.append(el('p', 'settings-reading', t('Reading…'))); return; }
4264
+ host.append(el('p', 'sx-intro', t('Ollama runs local AI: it searches and sums up each project\'s memory and answers as @madre, free and private. Choose which projects use it.')));
4265
+ const mode = sxBlock(host, t('Which projects'), LOCAL_MODE_SAYS[localSeen.mode]());
4266
+ sxRow(mode, t('Mode'), null, localModeSeg());
4267
+ if (!localSeen.master) sxRow(mode, t('Local AI is off for every project.'), t('Turn it on in a project, in Modules › Local AI.'));
4268
+ else if (!localSeen.ollama.running) sxRow(mode, t('Ollama is not running'), t('Projects keep working with their agents meanwhile. Start it from a project, in Modules › Local AI.'));
4269
+ const list = sxBlock(host, t('Projects'), localSeen.mode === 'auto' ? t('All of them use it. Choose «One by one» or «Manual» to turn some off.') : t('A switch here is the same one the project shows in its Settings.'));
4270
+ if (!localSeen.projects.length) { sxRow(list, t('No projects yet'), null); return; }
4271
+ const pages = Math.ceil(localSeen.projects.length / LOCAL_PER_PAGE);
4272
+ localPage = Math.max(0, Math.min(localPage, pages - 1));
4273
+ const grid = el('div', 'sm-local-grid');
4274
+ for (const one of localSeen.projects.slice(localPage * LOCAL_PER_PAGE, (localPage + 1) * LOCAL_PER_PAGE)) {
4275
+ const item = el('label', `sm-local-item${one.on ? '' : ' off'}`);
4276
+ const words = el('span', 'sm-local-words');
4277
+ words.append(el('b', null, one.name), el('small', null, one.open ? (one.on ? t('Open · using it') : t('Open · without it')) : t('Closed · applies when it opens')));
4278
+ const wrap = el('span', 'settings-switch');
4279
+ const box = el('input');
4280
+ box.type = 'checkbox';
4281
+ box.checked = Boolean(one.chosen);
4282
+ box.disabled = !one.editable || !localSeen.master;
4283
+ box.setAttribute('aria-label', one.name);
4284
+ box.addEventListener('change', () => { box.disabled = true; void saveLocal({ rooms: { [one.id]: box.checked } }); });
4285
+ wrap.append(box, el('i'));
4286
+ item.append(words, wrap);
4287
+ grid.append(item);
4288
+ }
4289
+ // The last page keeps the height of a full one, so ‹ › stay under the pointer.
4290
+ if (pages > 1) for (let gap = grid.children.length; gap < LOCAL_PER_PAGE; gap += 1) grid.append(el('span', 'sm-local-item sm-local-gap'));
4291
+ list.rows.append(grid);
4292
+ if (pages > 1) {
4293
+ const foot = el('div', 'sm-local-foot');
4294
+ const pager = el('div', 'sm-pager');
4295
+ const turn = (step) => { localPage += step; settingsSheet?.sync({ detail: true }); };
4296
+ const back = button('sm-icon-key sm-page', null, () => turn(-1));
4297
+ const on = button('sm-icon-key sm-page', null, () => turn(1));
4298
+ back.innerHTML = PAGE_BACK; // trusted: our own SVG strings
4299
+ on.innerHTML = PAGE_ON;
4300
+ back.setAttribute('aria-label', t('Previous'));
4301
+ on.setAttribute('aria-label', t('Next'));
4302
+ back.disabled = localPage === 0;
4303
+ on.disabled = localPage >= pages - 1;
4304
+ pager.append(back, el('span', 'sm-page-where', t('{n} of {total}', { n: localPage + 1, total: pages })), on);
4305
+ foot.append(pager, el('small', null, t('{n} projects', { n: localSeen.projects.length })));
4306
+ list.rows.append(foot);
4307
+ }
4308
+ }
2962
4309
  function fillProjects(host) {
2963
4310
  const block = sxBlock(host, t('Projects'), t('A project is a folder: MADRE gives it its own conversations and its own memory. Adding it does not open it; you open it from its card.'));
2964
4311
  sxRow(block, t('Add a project'), null, sxButton(t('Add'), () => { settingsSheet?.close(); openAddSheet(); }, { tone: 'primary' }));
@@ -2999,9 +4346,10 @@ function fillRelease(host) {
2999
4346
  // Home already runs the newer one: the projects still on the older one, each with its restart.
3000
4347
  const behind = behindRooms();
3001
4348
  if (behind.length) {
3002
- const late = sxBlock(host, t('Projects on an older MADRE'), t('They keep running the version they started with until you restart them. What is running in them stops.'));
4349
+ const late = sxBlock(host, t('Projects on an older MADRE'), t('They keep running the version they started with until you restart them. What is running in them stops.'), { aside: behind.length > 1 ? sxButton(t('Restart all'), () => void restartAll(), { tone: 'primary' }) : null });
3003
4350
  for (const room of behind) sxRow(late, roomLabel(room), `MADRE ${room.open.version}`, sxButton(t('Restart'), () => { settingsSheet?.close(); void restartRoom(room); }));
3004
4351
  }
4352
+ drawLegal(host);
3005
4353
  }
3006
4354
  async function updateHome(info, key, said) {
3007
4355
  key.disabled = true;
@@ -3030,10 +4378,11 @@ async function updateHome(info, key, said) {
3030
4378
  }
3031
4379
  drop(t('Home did not come back on its own. Start MADRE again from your terminal.'));
3032
4380
  }
3033
- const SET_FILL = { look: fillLook, bar: fillBar, agents: fillAgents, bishop: fillBishop, projects: fillProjects, release: fillRelease };
4381
+ const SET_FILL = { look: fillLook, bar: fillBar, agents: fillAgents, bishop: fillBishop, local: fillLocal, projects: fillProjects, release: fillRelease };
3034
4382
 
3035
4383
  let settingsSheet = null;
3036
4384
  function openSettings(section = null) {
4385
+ void loadLocal();
3037
4386
  if (settingsSheet) { settingsSheet.show(section); return; }
3038
4387
  const state = { open: null, query: '', stops: [] };
3039
4388
  const dialog = el('dialog', 'sm-sheet sm-settings');
@@ -3202,6 +4551,9 @@ function translateStatic() {
3202
4551
  ui.bar.links.querySelector('span').textContent = t('Connections');
3203
4552
  ui.bar.reload.title = t('Reload the page');
3204
4553
  ui.bar.reload.setAttribute('aria-label', t('Reload the page'));
4554
+ const addKey = document.getElementById('sm-add-widgets');
4555
+ addKey?.setAttribute('title', t('Add widgets to Home'));
4556
+ addKey?.setAttribute('aria-label', t('Add widgets to Home'));
3205
4557
  ui.stop.querySelector('span').textContent = t('Stop all');
3206
4558
  ui.macro.querySelector('span').textContent = t('Project map');
3207
4559
  ui.gear.title = t('Home settings');
@@ -3209,15 +4561,17 @@ function translateStatic() {
3209
4561
  ui.search.placeholder = t('Search a project…');
3210
4562
  ui.search.setAttribute('aria-label', t('Search a project…'));
3211
4563
  ui.filter.setAttribute('aria-label', t('Show'));
4564
+ $('sm-drawer-title').textContent = t('Projects');
3212
4565
  const FILTER_WORDS = { all: t('All projects'), open: t('Open projects'), asleep: t('Asleep projects') };
3213
4566
  for (const choice of ui.filter.querySelectorAll('[data-filter]')) choice.textContent = FILTER_WORDS[choice.dataset.filter];
4567
+ paintOrderKey();
3214
4568
  $('sm-bishop-name').textContent = t('Coordinator');
3215
4569
  ui.bishop.go.textContent = t('Talk to the Coordinator');
3216
4570
  ui.bishop.tab.title = t('Open the Coordinator in a new tab');
3217
4571
  ui.bishop.tab.setAttribute('aria-label', t('Open the Coordinator in a new tab'));
3218
- ui.bishop.about.textContent = t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.');
4572
+ ui.bishop.about.textContent = t('Your Coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.');
3219
4573
  ui.work.title.textContent = t('Recent summary');
3220
- ui.work.again.textContent = t('Summarise again');
4574
+ ui.work.again.textContent = t('Summarize again');
3221
4575
  ui.waiting.title.textContent = t('Waiting on you');
3222
4576
  ui.linksTileTitle.textContent = t('Connections');
3223
4577
  ui.link.textContent = t('Connect projects');