@jossuealcala/madre 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,7 @@ let data = null;
40
40
  let macro = null; // the macroverse, while it is open
41
41
  let welcomed = false; // the first-run wizard was offered this visit
42
42
  let agentsSeen = null; // what /api/settings said of the agents, last time it was asked
43
+ let localSeen = null; // what /api/salas/local-ai said: the mode and which projects use the local AI
43
44
  let versionSeen = null; // what /api/version said of MADRE, last time it was asked
44
45
  let work = null; // MADRE's summary of the last work (/api/salas/work)
45
46
  let decisions = null; // the recent decisions of the open rooms (/api/salas/decisions)
@@ -78,7 +79,7 @@ const TUNES = {
78
79
  const HOME_LOOKS = {
79
80
  cards: { key: 'pulse.home.cards', values: ['full', 'compact'], words: { full: () => t('With their last conversation'), compact: () => t('Name and state only') } },
80
81
  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') } },
82
+ 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
83
  };
83
84
  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
85
  function readLooks() {
@@ -137,7 +138,7 @@ function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
137
138
  try {
138
139
  if (theme) localStorage.setItem('pulse.theme', theme);
139
140
  if (accent) keep('pulse.accent', accent, 'madre');
140
- if (wallpaper) keep('pulse.wallpaper', wallpaper, 'default');
141
+ if (wallpaper) { keep('pulse.wallpaper', wallpaper, 'default'); saveOwnWallpaper(wallpaper); }
141
142
  if (style) keep('pulse.wallpaper.style', style, 'veil');
142
143
  if (bubbles) keep('pulse.bubbles', bubbles, BUBBLES[0]);
143
144
  if (text) keep('pulse.text', text, TEXTS[0]);
@@ -151,6 +152,7 @@ function saveHome(name, value) {
151
152
  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
153
  applyLooks();
153
154
  if (name === 'order' && typeof draw === 'function') draw();
155
+ if (name === 'order' && typeof paintOrderKey === 'function') paintOrderKey();
154
156
  settingsSheet?.paintLooks();
155
157
  }
156
158
  // A dial is saved when it is let go and shown while it moves; the sheet is not painted again under
@@ -164,7 +166,14 @@ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change'
164
166
  // origin, so what this browser kept here no project saw. ~/.pulse/looks.json is read when Home
165
167
  // opens and whenever it comes back into view, and written when a choice changes. The twin of
166
168
  // 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'];
169
+ // The wallpaper is each page's own (2026-10-13): Home keeps the one chosen in Home, each project
170
+ // the one chosen in it (src/looks.mjs, `rooms`). The rest of the looks are shared.
171
+ const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
172
+ // Before a page had its own, every page wore the shared one: that is where a page starts.
173
+ const wallpaperOf = (answer) => answer.own?.wallpaper ?? answer.looks?.['pulse.wallpaper'] ?? null;
174
+ function saveOwnWallpaper(value) {
175
+ void fetch('/api/looks/room', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ wallpaper: value }) }).catch(() => null);
176
+ }
168
177
  let looksPush = 0;
169
178
  function pushLooks() {
170
179
  clearTimeout(looksPush);
@@ -182,8 +191,8 @@ async function pullLooks() {
182
191
  // Nothing kept yet: the first page that has a choice of its own hands it to the others.
183
192
  if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
184
193
  let changed = false;
185
- for (const key of SHARED_LOOKS) {
186
- const value = answer.looks[key] ?? null;
194
+ const wall = wallpaperOf(answer);
195
+ for (const [key, value] of [...SHARED_LOOKS.map((one) => [one, answer.looks[one] ?? null]), ['pulse.wallpaper', wall === 'default' ? null : wall]]) {
187
196
  if (localStorage.getItem(key) === value) continue;
188
197
  if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
189
198
  changed = true;
@@ -260,6 +269,7 @@ function stateOf(room) {
260
269
  if (!room.open) return { key: 'asleep', words: room.lastOpened ? t('Asleep · last open {when}', { when: ago(room.lastOpened) }) : t('Asleep') };
261
270
  if (glance.outdated) return { key: 'outdated', words: t('Update MADRE in this project to see it here.') };
262
271
  if (glance.unreachable) return { key: 'quiet', words: t('Not answering right now') };
272
+ if (glance.delayed) return { key: 'quiet', words: t('Responding slowly · last known state') };
263
273
  if (glance.waitingCount) return { key: 'waiting', words: t('Waiting for you · {n}', { n: glance.waitingCount }) };
264
274
  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
275
  return { key: 'calm', words: t('Calm') };
@@ -277,6 +287,15 @@ function card(room) {
277
287
  const state = stateOf(room);
278
288
  const node = el('article', `sm-tile sm-card is-${state.key}${room.here ? ' here' : ''}`);
279
289
  node.dataset.room = room.id;
290
+ // Its own piece of the wallpaper (salas-mac.css): a place on the image, steady for the project.
291
+ let seed = 0;
292
+ for (const char of room.id) seed = (seed * 31 + char.charCodeAt(0)) >>> 0;
293
+ node.style.setProperty('--wx', `${seed % 101}%`);
294
+ node.style.setProperty('--wy', `${(seed >>> 8) % 101}%`);
295
+ // A project with a wallpaper of its own («only in this project») shows that one, whole.
296
+ // The wallpaper its own room wears (each page keeps its own), seen whole; none when it has none.
297
+ if (room.wallpaper && room.wallpaper !== 'default') { node.dataset.wp = room.wallpaper; node.style.setProperty('--wx', '50%'); node.style.setProperty('--wy', '50%'); }
298
+ else node.dataset.wp = 'none';
280
299
  // To the bar: dragged there, or pinned from its right-click.
281
300
  node.draggable = true;
282
301
  node.addEventListener('dragstart', (event) => pinsDragOut(event, room));
@@ -355,6 +374,22 @@ function card(room) {
355
374
  const ORDER = { waiting: 0, working: 1, calm: 2, quiet: 3, outdated: 4 };
356
375
  // The projects in the order the lobby shows them: the open ones by what needs the person, then
357
376
  // the asleep ones, the last opened first. BISHOP's own room is not a project.
377
+ const PROJECT_ORDER_KEY = 'pulse.home.projects';
378
+ function projectOrder() {
379
+ try { const list = JSON.parse(localStorage.getItem(PROJECT_ORDER_KEY) ?? '[]'); return Array.isArray(list) ? list.filter((id) => typeof id === 'string') : []; } catch { return []; }
380
+ }
381
+ // A project moved by hand: the whole order as it is on screen now, with that one moved, becomes
382
+ // «Yours» — so moving one never shuffles the others.
383
+ function moveProject(id, where) {
384
+ const list = projectsInOrder().all.map((room) => room.id);
385
+ const at = list.indexOf(id);
386
+ if (at < 0) return;
387
+ list.splice(at, 1);
388
+ const to = where === 'first' ? 0 : where === 'last' ? list.length : Math.max(0, Math.min(list.length, at + where));
389
+ list.splice(to, 0, id);
390
+ try { localStorage.setItem(PROJECT_ORDER_KEY, JSON.stringify(list)); } catch { /* the order lasts as long as the page */ }
391
+ saveHome('order', 'mine');
392
+ }
358
393
  function projectsInOrder() {
359
394
  const projects = data.rooms.filter((room) => room.id !== data.salas);
360
395
  const open = projects.filter((room) => room.open)
@@ -364,9 +399,14 @@ function projectsInOrder() {
364
399
  // last thing that happened in each, open or asleep.
365
400
  const order = readLooks().home.order;
366
401
  const touched = (room) => Math.max(Date.parse(room.glance?.chat?.at ?? 0) || 0, Date.parse(room.lastOpened ?? room.added ?? 0) || 0);
402
+ // «Yours» (2026-10-13): the order the person made with right-click › Move; a project they never
403
+ // placed comes after, in the order of what needs them.
404
+ const mine = order === 'mine' ? projectOrder() : [];
405
+ const placed = (room) => { const at = mine.indexOf(room.id); return at < 0 ? Number.MAX_SAFE_INTEGER : at; };
367
406
  const all = order === 'name' ? [...projects].sort((a, b) => a.name.localeCompare(b.name, language))
368
407
  : order === 'recent' ? [...projects].sort((a, b) => touched(b) - touched(a))
369
- : [...open, ...asleep];
408
+ : order === 'mine' ? [...open, ...asleep].map((room, index) => [room, index]).sort(([a, i], [b, j]) => placed(a) - placed(b) || i - j).map(([room]) => room)
409
+ : [...open, ...asleep];
370
410
  return { all, open, asleep };
371
411
  }
372
412
  function draw() {
@@ -387,6 +427,7 @@ function draw() {
387
427
  ui.macro.hidden = !data.rooms.length || window.innerWidth < 900;
388
428
  macro?.update(data.rooms, data.enlaces ?? []);
389
429
  settingsSheet?.sync();
430
+ fitTiles();
390
431
  }
391
432
 
392
433
  // The projects, as the search box and the filter leave them. Nothing is hidden by the filter
@@ -405,9 +446,48 @@ function drawProjects(all = data ? projectsInOrder().all : []) {
405
446
  ui.projects.hidden = !shown.length;
406
447
  ui.none.hidden = Boolean(shown.length);
407
448
  ui.none.replaceChildren();
449
+ fitDrawer(all);
408
450
  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
451
  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
452
  }
453
+ // THE DRAWER (2026-10-13): the projects live in a tile of their own, moved like any other, three
454
+ // rows at a time. What does not fit is counted on a key under them — «+5», how many are open and
455
+ // how many asleep — and the key opens the drawer whole, or closes it back to three rows.
456
+ const drawerMore = $('sm-drawer-more');
457
+ let drawerOpen = false;
458
+ const DRAWER_ROWS = 3;
459
+ function fitDrawer(all = data ? projectsInOrder().all : []) {
460
+ const count = $('sm-drawer-count');
461
+ if (count) count.textContent = String(all.length);
462
+ const cards = [...ui.projects.querySelectorAll('.sm-card[data-room]')];
463
+ for (const one of cards) one.hidden = false;
464
+ if (!cards.length) { drawerMore.hidden = true; return; }
465
+ // How many fit in a row is the grid's to say, at this width: 4, 3 or 2.
466
+ const first = cards[0].getBoundingClientRect();
467
+ const perRow = Math.max(1, Math.round((ui.projects.getBoundingClientRect().width + 16) / ((first.width || 1) + 16)));
468
+ const room = perRow * DRAWER_ROWS;
469
+ const out = cards.slice(room);
470
+ if (!out.length) { drawerMore.hidden = true; return; }
471
+ if (!drawerOpen) for (const one of out) one.hidden = true;
472
+ const rooms = out.map((one) => data.rooms.find((room) => room.id === one.dataset.room)).filter(Boolean);
473
+ const open = rooms.filter((room) => room.open).length;
474
+ const asleep = rooms.length - open;
475
+ drawerMore.hidden = false;
476
+ drawerMore.replaceChildren();
477
+ if (drawerOpen) drawerMore.append(el('span', null, t('Show fewer')));
478
+ else {
479
+ drawerMore.append(el('b', null, `+${out.length}`));
480
+ const parts = [open ? t('{n} open', { n: open }) : null, asleep ? t('{n} asleep', { n: asleep }) : null].filter(Boolean);
481
+ drawerMore.append(el('span', null, parts.join(' · ')));
482
+ }
483
+ drawerMore.setAttribute('aria-expanded', String(drawerOpen));
484
+ 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(', ') });
485
+ }
486
+ drawerMore?.addEventListener('click', () => { drawerOpen = !drawerOpen; fitDrawer(); fitTiles(); });
487
+ if (typeof ResizeObserver === 'function') {
488
+ let wide = 0;
489
+ new ResizeObserver(() => { const now = Math.round(ui.projects.getBoundingClientRect().width); if (now && now !== wide) { wide = now; fitDrawer(); } }).observe(ui.projects);
490
+ }
411
491
  ui.search.addEventListener('input', () => { query = ui.search.value.trim(); drawProjects(); });
412
492
  for (const choice of ui.filter.querySelectorAll('[data-filter]')) {
413
493
  choice.addEventListener('click', () => {
@@ -417,6 +497,31 @@ for (const choice of ui.filter.querySelectorAll('[data-filter]')) {
417
497
  });
418
498
  }
419
499
 
500
+ // ORDER, where the projects are (2026-10-13): the same four orders as Home's Appearance, one press
501
+ // away from the list they arrange. «Yours» is the order made by dragging a card or by its
502
+ // right-click › Move; the menu says so, so the way to make it is never a secret.
503
+ 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>';
504
+ const orderKey = button('sm-btn sm-order', null, () => {
505
+ const box = orderKey.getBoundingClientRect();
506
+ const now = readLooks().home.order;
507
+ homeMenu({ x: box.left, y: box.bottom + 6 }, [
508
+ { heading: t('Order the projects') },
509
+ ...HOME_LOOKS.order.values.map((value) => ({ words: HOME_LOOKS.order.words[value](), icon: SORT_ICON, checked: now === value, act: () => saveHome('order', value) })),
510
+ 'sep',
511
+ { heading: t('Drag a card, or right-click it › Move, to make your own.') },
512
+ ], t('Order the projects'));
513
+ });
514
+ ui.filter.after(orderKey);
515
+ function paintOrderKey() {
516
+ const now = readLooks().home.order;
517
+ orderKey.innerHTML = SORT_ICON; // trusted: our own SVG string
518
+ orderKey.append(el('span', null, HOME_LOOKS.order.words[now]()));
519
+ orderKey.title = t('Order the projects');
520
+ orderKey.setAttribute('aria-label', `${t('Order the projects')}: ${HOME_LOOKS.order.words[now]()}`);
521
+ orderKey.setAttribute('aria-haspopup', 'menu');
522
+ }
523
+ paintOrderKey();
524
+
420
525
  // WAITING FOR YOU: every question an agent asks the person, from every open room, counted in one
421
526
  // number; the first ones said, each leading to its room.
422
527
  function drawWaiting() {
@@ -514,6 +619,31 @@ async function restartRoom(room) {
514
619
  await startRoom(room);
515
620
  }
516
621
 
622
+ // Every project still on the older MADRE, one after the other (1.0.1). Only the quiet ones: a
623
+ // project with an agent working or a question waiting for the person is left as it is, and said.
624
+ const roomQuiet = (room) => !room.glance?.working?.length && !room.glance?.waitingCount;
625
+ let restartingAll = false;
626
+ async function restartAll() {
627
+ if (restartingAll) return;
628
+ const behind = behindRooms();
629
+ const quiet = behind.filter(roomQuiet);
630
+ const left = behind.filter((room) => !roomQuiet(room));
631
+ if (!quiet.length) { toast(t('Every project on the older MADRE is busy now: restart them when they finish.'), 'look'); return; }
632
+ restartingAll = true;
633
+ try {
634
+ for (const [index, room] of quiet.entries()) {
635
+ toast(t('Restarting {room} · {n} of {total}', { room: roomLabel(room), n: index + 1, total: quiet.length }));
636
+ await restartRoom(room);
637
+ }
638
+ } finally {
639
+ restartingAll = false;
640
+ }
641
+ toast(left.length
642
+ ? t('{n} restarted. Left as they are, because they are busy: {rooms}.', { n: quiet.length, rooms: left.map(roomLabel).join(', ') })
643
+ : t('{n} restarted with MADRE {version}.', { n: quiet.length, version: data?.version ?? '' }), left.length ? 'look' : 'done');
644
+ settingsSheet?.sync({ detail: true });
645
+ }
646
+
517
647
  // NEEDS YOU: what the person would want to know before anything else, from what the rooms, the
518
648
  // agents and MADRE itself say: a room on an older MADRE or not answering, an agent without a
519
649
  // session, a link about to expire, a newer MADRE. Nothing listed is a guess.
@@ -537,7 +667,9 @@ function attentionItems() {
537
667
  }
538
668
  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
669
  // 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') });
670
+ const behind = behindRooms();
671
+ 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') });
672
+ 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
673
  return items;
542
674
  }
543
675
  function drawAttention() {
@@ -590,6 +722,7 @@ function drawDecisions() {
590
722
  item.append(head, el('p', null, one.text));
591
723
  list.append(item);
592
724
  }
725
+ if (typeof fitTiles === 'function') fitTiles();
593
726
  }
594
727
  // A project's name as a small tag: a way into it when it is open.
595
728
  function roomTag(id, name) {
@@ -646,6 +779,7 @@ function drawWork() {
646
779
  : state === 'failed' ? t('MADRE could not summarise them this time: these are your latest conversations, as they are.')
647
780
  : work.stale ? t('There is newer work than this summary: it is written again in a few minutes, or now if you ask.')
648
781
  : t('Written by MADRE on this Mac from your latest conversations.');
782
+ if (typeof fitTiles === 'function') fitTiles();
649
783
  }
650
784
  ui.work.again.addEventListener('click', () => void loadWork({ again: true }));
651
785
 
@@ -664,7 +798,10 @@ function linkRow(enlace) {
664
798
  const { source, reader } = endsOf(enlace);
665
799
  const node = el('article', `sm-link${revoking.has(enlace.id) ? ' revoking' : ''}`);
666
800
  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) })));
801
+ node.append(el('span', 'sm-link-kind', source === data.salas || reader === data.salas ? t('With the Coordinator') : t('Between projects')));
802
+ const who = el('p', 'who');
803
+ 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 }));
804
+ node.append(who);
668
805
  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
806
  if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
670
807
  // D-S13: a link BISHOP signed on its own says so, so it is never mistaken for one you signed.
@@ -694,7 +831,10 @@ function linkRow(enlace) {
694
831
  return node;
695
832
  }
696
833
  function drawLinks(projects) {
697
- const enlaces = data.enlaces ?? [];
834
+ const enlaces = [...(data.enlaces ?? [])].sort((a, b) => {
835
+ const direct = (one) => Number(one.rooms.a !== data.salas && one.rooms.b !== data.salas);
836
+ return direct(b) - direct(a) || roomName(endsOf(a).reader).localeCompare(roomName(endsOf(b).reader)) || roomName(endsOf(a).source).localeCompare(roomName(endsOf(b).source));
837
+ });
698
838
  const enough = data.rooms.length >= 2;
699
839
  ui.link.hidden = !enough;
700
840
  ui.linkMore.hidden = !enough;
@@ -705,32 +845,27 @@ function drawLinks(projects) {
705
845
  : enough ? t('No connections yet.') : t('Add a project to connect it.');
706
846
  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.');
707
847
  ui.links.replaceChildren(...enlaces.map(linkRow));
708
- drawMapLegend(enlaces);
709
848
  if (!enlaces.length && ui.linksSheet.open) ui.linksSheet.close();
710
849
  drawMap(projects, enlaces);
711
850
  }
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);
851
+ // A sentence whose project names wear their tone (the map's): `vars` names room ids.
852
+ function sayTinted(key, vars) {
853
+ const out = document.createDocumentFragment();
854
+ const template = t(key);
855
+ let at = 0;
856
+ for (const match of template.matchAll(/\{(\w+)\}/g)) {
857
+ out.append(template.slice(at, match.index));
858
+ const id = vars[match[1]];
859
+ if (id === undefined) out.append(match[0]);
860
+ else {
861
+ const name = el('b', `sm-tinted${id === data.salas ? ' hub' : ''}`, roomName(id));
862
+ if (id !== data.salas) name.style.setProperty('--pc', toneOf(id));
863
+ out.append(name);
864
+ }
865
+ at = match.index + match[0].length;
732
866
  }
733
- if (enlaces.length > 2) mapLegend.append(el('li', 'more', t('and {n} more', { n: enlaces.length - 2 })));
867
+ out.append(template.slice(at));
868
+ return out;
734
869
  }
735
870
  async function revokeLink(id) {
736
871
  revoking.delete(id);
@@ -738,14 +873,17 @@ async function revokeLink(id) {
738
873
  await refresh();
739
874
  }
740
875
  ui.linksAll.addEventListener('click', () => { if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
876
+ // The tile's map is the small one: a press opens the map of connections in its own window.
877
+ ui.map.addEventListener('click', (event) => { if (event.target.closest('svg') && (data?.enlaces ?? []).length) openGraph(); });
878
+ // Closed, the sheet has no size to draw at: the wide map is drawn each time it opens.
879
+
741
880
  ui.linksClose.addEventListener('click', () => ui.linksSheet.close());
742
881
  ui.linkMore.addEventListener('click', () => openLinkSheet());
743
882
 
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.
883
+ // The map of the links (redrawn 2026-10-13): the Coordinator in the middle and, round it, the
884
+ // projects a contract touches, each in its own tone of the accent the person chose, so a project
885
+ // is the same colour on the map, on its wires and in the words under it. The tile draws only the
886
+ // connected ones, large enough to read; the sheet («See the connections») draws them all, wide.
749
887
  // Nothing here is drawn that the contracts do not say.
750
888
  const SVG = 'http://www.w3.org/2000/svg';
751
889
  const svgNode = (tag, attributes = {}) => {
@@ -753,44 +891,234 @@ const svgNode = (tag, attributes = {}) => {
753
891
  for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value));
754
892
  return node;
755
893
  };
894
+ // A project's tone: the accent turned round the colour wheel, keeping its own lightness and
895
+ // strength. The turns are shared out evenly among the projects a connection touches (in a steady
896
+ // order, by id), so no two of them look alike; the first wears the accent itself. Where the
897
+ // browser cannot turn a colour (no relative colour syntax), every project wears the accent.
898
+ const TURNS = typeof CSS !== 'undefined' && CSS.supports?.('color', 'oklch(from red l c h)');
899
+ function toneOf(id) {
900
+ const linked = [...new Set((data?.enlaces ?? []).flatMap((enlace) => [enlace.rooms.a, enlace.rooms.b]))].filter((one) => one !== data?.salas).sort();
901
+ const index = linked.indexOf(id);
902
+ if (!TURNS || index <= 0) return 'var(--s-acc)';
903
+ const step = 360 / Math.max(5, linked.length);
904
+ return `oklch(from var(--s-acc) l c calc(h + ${(index * step).toFixed(1)}))`;
905
+ }
906
+ // One project at a time (2026-10-13): under the pointer, or chosen in the window, a project
907
+ // lights its own connections and the projects at their other end; the rest steps back.
908
+ function focusable(svg, { pick = null, picked = null } = {}) {
909
+ const light = (id) => {
910
+ svg.classList.toggle('focusing', Boolean(id));
911
+ const near = new Set(id ? [id] : []);
912
+ for (const wire of svg.querySelectorAll('.wire')) {
913
+ const on = Boolean(id) && (wire.dataset.a === id || wire.dataset.b === id);
914
+ wire.classList.toggle('lit', Boolean(on || (picked?.link && wire.dataset.link === picked.link)));
915
+ if (on) { near.add(wire.dataset.a); near.add(wire.dataset.b); }
916
+ }
917
+ for (const dot of svg.querySelectorAll('.note, .flow')) dot.classList.toggle('lit', Boolean(id) && (dot.dataset.a === id || dot.dataset.b === id));
918
+ for (const node of svg.querySelectorAll('.node, .bishop')) node.classList.toggle('lit', near.has(node.dataset.id));
919
+ };
920
+ const resting = () => {
921
+ if (picked?.room) return light(picked.room);
922
+ if (picked?.link) {
923
+ const wire = svg.querySelector(`.wire[data-link="${CSS.escape(picked.link)}"]`);
924
+ svg.classList.add('focusing');
925
+ wire?.classList.add('lit');
926
+ 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));
927
+ return;
928
+ }
929
+ light(null);
930
+ };
931
+ for (const node of svg.querySelectorAll('.node, .bishop')) {
932
+ node.addEventListener('pointerenter', () => light(node.dataset.id));
933
+ node.addEventListener('pointerleave', resting);
934
+ if (pick) {
935
+ node.classList.add('pickable');
936
+ node.addEventListener('click', (event) => { event.stopPropagation(); pick({ room: node.dataset.id }); });
937
+ }
938
+ }
939
+ if (pick) {
940
+ for (const wire of svg.querySelectorAll('.wire')) {
941
+ wire.classList.add('pickable');
942
+ // A line is thin; the hand gets a wider, invisible one along it.
943
+ const hit = svgNode('path', { class: 'hit', d: wire.getAttribute('d') });
944
+ hit.addEventListener('click', (event) => { event.stopPropagation(); pick({ link: wire.dataset.link }); });
945
+ hit.addEventListener('pointerenter', () => wire.classList.add('hover'));
946
+ hit.addEventListener('pointerleave', () => wire.classList.remove('hover'));
947
+ wire.after(hit);
948
+ }
949
+ svg.addEventListener('click', () => pick(null));
950
+ }
951
+ resting();
952
+ }
953
+
954
+ // THE MAP OF CONNECTIONS, in a window of its own: the same drawing, wide, every project round the
955
+ // Coordinator, and beside it what was chosen — a project (whom it reads, who reads it) or a line
956
+ // (what crosses, until when, how often it was read). Opened from the tile's map.
957
+ let graphWindow = null;
958
+ function openGraph() {
959
+ if (graphWindow?.open) return;
960
+ graphWindow?.remove();
961
+ graphWindow = el('dialog', 'sm-sheet sm-graph');
962
+ const body = el('div', 'sm-sheet-body');
963
+ const head = el('header', 'sm-sheet-head');
964
+ const words = el('div', 'sm-graph-title');
965
+ words.append(el('h3', null, t('Map of connections')), el('small', null, t('Who looks up whom. Touch a project or a line.')));
966
+ const keys = el('span', 'surface-modes');
967
+ const close = button('surface-mode close x-close x-only', null, () => graphWindow.close());
968
+ close.setAttribute('aria-label', t('Close'));
969
+ 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
970
+ keys.append(close);
971
+ head.append(words, keys);
972
+ const stage = el('div', 'sm-graph-stage');
973
+ const canvas = el('div', 'sm-map sm-map-wide sm-graph-canvas');
974
+ const side = el('aside', 'sm-graph-side');
975
+ stage.append(canvas, side);
976
+ body.append(head, stage);
977
+ graphWindow.append(body);
978
+ document.body.append(graphWindow);
979
+ let picked = null;
980
+ const paint = () => {
981
+ if (!graphWindow?.isConnected || !mapDrawn) return;
982
+ paintMap(canvas, mapDrawn.projects, mapDrawn.enlaces, { wide: true, pick: (next) => { picked = next; paint(); }, picked });
983
+ paintSide(side, picked, () => { graphWindow.close(); if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
984
+ };
985
+ const watch = typeof ResizeObserver === 'function' ? new ResizeObserver(() => paint()) : null;
986
+ graphWindow.addEventListener('close', () => { watch?.disconnect(); graphWindow?.remove(); graphWindow = null; });
987
+ graphWindow.repaint = paint;
988
+ graphWindow.showModal();
989
+ watch?.observe(canvas);
990
+ requestAnimationFrame(paint);
991
+ }
992
+ // What the side of the window says about the chosen thing, only what the contracts and the lobby say.
993
+ function paintSide(side, picked, toList) {
994
+ side.replaceChildren();
995
+ const enlaces = mapDrawn?.enlaces ?? [];
996
+ const reads = enlaces.reduce((sum, enlace) => sum + (enlace.traffic?.reads24h ?? 0), 0);
997
+ const listKey = button('sm-btn ghost small', t('See the connections'), toList);
998
+ if (!picked) {
999
+ 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')));
1000
+ side.append(el('b', 'sm-graph-big', String(reads)), el('span', 'sm-graph-sub', t('reads in the last 24 h')));
1001
+ 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.')));
1002
+ const quiet = (() => { try { return JSON.parse(side.previousElementSibling?.dataset.quiet ?? '[]'); } catch { return []; } })();
1003
+ if (quiet.length) {
1004
+ const box = el('div', 'sm-graph-list');
1005
+ box.append(el('span', 'sm-graph-label', t('Not connected')));
1006
+ box.append(el('span', 'sm-graph-quiet', quiet.map((id) => roomName(id)).join(' · ')));
1007
+ side.append(box);
1008
+ }
1009
+ side.append(listKey);
1010
+ return;
1011
+ }
1012
+ if (picked.room) {
1013
+ const id = picked.room;
1014
+ const room = data.rooms.find((one) => one.id === id);
1015
+ const name = el('b', `sm-tinted sm-graph-name${id === data.salas ? ' hub' : ''}`, roomName(id));
1016
+ if (id !== data.salas) name.style.setProperty('--pc', toneOf(id));
1017
+ side.append(name);
1018
+ if (room && id !== data.salas) side.append(el('span', 'sm-graph-sub', stateOf(room).words));
1019
+ 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; });
1020
+ 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; });
1021
+ const list = (title, ids) => {
1022
+ const box = el('div', 'sm-graph-list');
1023
+ box.append(el('span', 'sm-graph-label', title));
1024
+ if (!ids.length) box.append(el('span', 'sm-graph-sub', t('nobody')));
1025
+ 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); }
1026
+ side.append(box);
1027
+ };
1028
+ list(t('Looks up'), readsFrom);
1029
+ list(t('Looked up by'), readBy);
1030
+ if (room?.open && id !== data.salas) side.append(button('sm-btn primary small', t('Open the project'), () => leaveTo(addressOf(room))));
1031
+ side.append(listKey);
1032
+ return;
1033
+ }
1034
+ const enlace = enlaces.find((one) => one.id === picked.link);
1035
+ if (!enlace) { side.append(el('p', 'sm-hint', t('That connection is no longer in force.')), listKey); return; }
1036
+ const { source, reader } = endsOf(enlace);
1037
+ const who = el('p', 'sm-graph-who');
1038
+ 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 }));
1039
+ side.append(who);
1040
+ 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); };
1041
+ fact(t('What crosses'), kindsWords(enlace.kinds) || '—');
1042
+ fact(t('Until'), enlace.expires ? new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'long' }) : t('no expiry'));
1043
+ fact(t('Reads in 24 h'), String(enlace.traffic?.reads24h ?? 0));
1044
+ if (enlace.orders?.allowed) fact(t('Orders'), t('up to #{n}', { n: enlace.orders.maxMode }));
1045
+ if (enlace.signed?.by === 'bishop') fact(t('Signed by'), t('Coordinator'));
1046
+ side.append(listKey);
1047
+ }
1048
+
756
1049
  let mapDrawn = null;
757
1050
  function drawMap(projects, enlaces) {
758
1051
  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);
1052
+ paintMap(ui.map, projects, enlaces, { wide: false });
1053
+ graphWindow?.repaint?.();
1054
+ }
1055
+ function paintMap(host, projects, enlaces, { wide, pick = null, picked = null }) {
1056
+ host.replaceChildren();
1057
+ if (!projects.length) { host.append(el('p', 'sm-hint', t('Your projects show here, round the Coordinator, with the connections between them.'))); return; }
1058
+ const box = host.getBoundingClientRect();
1059
+ const W = Math.max(240, Math.round(box.width || 320)), H = Math.max(170, Math.round(box.height || 220));
1060
+ const cx = W / 2, cy = H / 2;
765
1061
  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);
1062
+ const connected = projects.filter((room) => linked.has(room.id));
1063
+ // Only who is connected goes round the Coordinator, evenly; the rest are named apart (the tile's
1064
+ // last line, the window's side), so the drawing is never weighed down on one side.
1065
+ const ring = (connected.length ? connected : projects).slice(0, wide ? 18 : 10);
1066
+ const quiet = projects.length - ring.length;
1067
+ const name = (room) => (room.name.length > 16 ? `${room.name.slice(0, 15)}…` : room.name);
1068
+ // The ring leaves room for the longest name on either side, so nothing is cut at the edge.
1069
+ const longest = Math.max(40, ...ring.map((room) => name(room).length * (wide ? 7.4 : 7)));
1070
+ let rx = Math.max(60, W / 2 - longest - 18), ry = Math.max(44, H / 2 - (wide ? 34 : 26));
1071
+ // Wide, the ring is a circle: an abstract map, not a shape stretched to its frame.
1072
+ if (wide) rx = ry = Math.max(60, Math.min(rx, ry));
1073
+ const hubR = wide ? 21 : 17;
1074
+ const at = new Map([[data.salas, { x: cx, y: cy, r: hubR, pad: linked.has(data.salas) ? 6 : 0 }]]);
1075
+ ring.forEach((room, index) => {
1076
+ const angle = -Math.PI / 2 + (index * 2 * Math.PI) / ring.length + (ring.length === 2 ? Math.PI / 2 : 0);
771
1077
  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 });
1078
+ const r = linked.has(room.id) ? (wide ? 8 : 7) + Math.min(3, Math.sqrt(memories) / 4) : 4.5;
1079
+ 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
1080
  });
776
1081
  const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img' });
777
1082
  svg.setAttribute('aria-label', ui.linksSum.textContent);
778
1083
  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
1084
  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) => {
1085
+ // STARS (2026-10-13): each project is a point of light in its tone, its glow a radial gradient
1086
+ // that fades to nothing; the notes on their way are sparks. The theme decides how bright
1087
+ // (salas-mac.css): a lit sky in the dark, a quieter one in the light, where the text must read.
1088
+ const glowId = `sm-glow-${wide ? 'w' : 't'}`;
1089
+ const glow = svgNode('filter', { id: glowId, x: '-200%', y: '-200%', width: '500%', height: '500%' });
1090
+ glow.append(svgNode('feGaussianBlur', { stdDeviation: wide ? 2.2 : 1.8, result: 'soft' }));
1091
+ const merge = svgNode('feMerge');
1092
+ merge.append(svgNode('feMergeNode', { in: 'soft' }), svgNode('feMergeNode', { in: 'SourceGraphic' }));
1093
+ glow.append(merge);
1094
+ defs.append(glow);
1095
+ const halos = new Map();
1096
+ const haloFor = (id, colour) => {
1097
+ if (halos.has(id)) return halos.get(id);
1098
+ const ref = `sm-halo-${wide ? 'w' : 't'}-${halos.size}`;
1099
+ const gradient = svgNode('radialGradient', { id: ref });
1100
+ for (const [offset, opacity] of [['0%', 0.95], ['38%', 0.42], ['100%', 0]]) {
1101
+ const stop = svgNode('stop', { offset, 'stop-opacity': opacity });
1102
+ stop.style.stopColor = colour;
1103
+ gradient.append(stop);
1104
+ }
1105
+ defs.append(gradient);
1106
+ halos.set(id, `url(#${ref})`);
1107
+ return halos.get(id);
1108
+ };
1109
+ const edges = (from, to, gapFrom = 4, gapTo = 5) => {
785
1110
  const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy) || 1;
786
1111
  const ux = dx / length, uy = dy / length;
787
1112
  const reach = (spot, gap) => spot.r + (spot.pad ?? 0) + gap;
788
1113
  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
1114
  };
1115
+ // A hairline from the middle to each project on the ring that no contract touches: it is there,
1116
+ // and nothing crosses.
790
1117
  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) }));
1118
+ for (const room of ring) {
1119
+ if (linked.has(room.id)) continue;
1120
+ const { start, end } = edges(centre, at.get(room.id), 3, 3);
1121
+ 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
1122
  }
795
1123
  const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
796
1124
  enlaces.forEach((enlace, index) => {
@@ -798,70 +1126,674 @@ function drawMap(projects, enlaces) {
798
1126
  const from = at.get(source), to = at.get(reader);
799
1127
  if (!from || !to) return;
800
1128
  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.
1129
+ // To or from the Coordinator the wire is straight; between two projects it bows away from the
1130
+ // middle, so it never runs through the Coordinator.
803
1131
  let d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} L${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
804
1132
  if (source !== data.salas && reader !== data.salas) {
805
1133
  const mx = (start.x + end.x) / 2, my = (start.y + end.y) / 2;
806
1134
  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)}`;
1135
+ const bow = Math.min(46, Math.hypot(end.x - start.x, end.y - start.y) * 0.28);
1136
+ 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
1137
  }
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)');
1138
+ // The wire wears the tone of the project the notes come from.
1139
+ const tone = source === data.salas ? 'var(--text-3)' : toneOf(source);
1140
+ const id = `sm-wire-${wide ? 'w' : 't'}-${index}`;
1141
+ const wire = svgNode('path', { id, class: 'wire', d });
1142
+ wire.style.stroke = tone;
1143
+ wire.dataset.a = source;
1144
+ wire.dataset.b = reader;
1145
+ wire.dataset.link = enlace.id;
1146
+ const title = svgNode('title');
1147
+ 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) });
1148
+ wire.append(title);
812
1149
  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
- }
1150
+ // ELECTRIC FLOW (2026-10-13): no arrowheads; the current runs along the line the way the notes
1151
+ // travel, from the project read to the one reading, and both ways when they read each other.
1152
+ // A still line under it says the connection is there even when nothing moves.
1153
+ const ways = enlace.direction === 'both' ? [['out', tone], ['back', reader === data.salas ? 'var(--text-3)' : toneOf(reader)]] : [['out', tone]];
1154
+ for (const [way, colour] of ways) {
1155
+ const flow = svgNode('path', { class: `flow ${way}`, d, filter: `url(#${glowId})` });
1156
+ flow.style.stroke = colour;
1157
+ flow.style.setProperty('--flow-delay', `${(index * 0.37) % 1.4}s`);
1158
+ flow.dataset.a = source;
1159
+ flow.dataset.b = reader;
1160
+ if (still) flow.classList.add('still');
1161
+ svg.append(flow);
827
1162
  }
828
1163
  });
829
- for (const room of shown) {
1164
+ for (const room of ring) {
830
1165
  const spot = at.get(room.id);
831
1166
  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) });
1167
+ const group = svgNode('g', { class: `node${on ? ' linked' : ''}${room.open ? '' : ' asleep'}${room.glance?.waitingCount ? ' waiting' : ''}` });
1168
+ group.style.setProperty('--pc', toneOf(room.id));
1169
+ group.dataset.id = room.id;
1170
+ 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) }));
1171
+ // The star: a halo that breathes, a core of its tone and a white heart. Each one twinkles at its
1172
+ // own pace, so the sky never pulses as one.
1173
+ const star = svgNode('g', { class: 'star' });
1174
+ star.style.setProperty('--tw', `${(2.6 + ((room.id.length * 37) % 19) / 10).toFixed(1)}s`);
1175
+ star.style.setProperty('--tw-delay', `${(((room.id.charCodeAt(0) ?? 0) * 13) % 23) / 10}s`);
1176
+ star.append(
1177
+ 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)) }),
1178
+ svgNode('circle', { class: 'body', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r * (on ? 0.82 : 0.9)).toFixed(1) }),
1179
+ 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) }),
1180
+ );
1181
+ group.append(star);
833
1182
  const title = svgNode('title');
834
1183
  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);
1184
+ group.append(title);
1185
+ // The name sits outside the ring, on the side its project is on: to the right on the right,
1186
+ // to the left on the left, above or below at the top and the bottom.
1187
+ const side = Math.abs(spot.cos) < 0.32 ? 'middle' : spot.cos > 0 ? 'start' : 'end';
1188
+ const gap = spot.r + 7;
1189
+ const x = side === 'middle' ? spot.x : spot.x + Math.sign(spot.cos) * gap;
1190
+ const y = side === 'middle' ? (spot.sin < 0 ? spot.y - gap - 2 : spot.y + gap + 10) : spot.y + 4;
1191
+ const label = svgNode('text', { class: 'label', x: x.toFixed(1), y: y.toFixed(1), 'text-anchor': side });
1192
+ label.textContent = name(room);
1193
+ group.append(label);
1194
+ svg.append(group);
839
1195
  }
840
- // BISHOP, in its own colours, with its own mark (brands.js), and a halo while it is connected.
1196
+ // The Coordinator, in its own colours and mark (brands.js).
841
1197
  const brand = brandOf('bishop');
842
1198
  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 });
1199
+ hub.dataset.id = data.salas;
1200
+ // The Coordinator is the brightest star, in its own colour, its mark at the heart.
1201
+ hub.append(svgNode('circle', { class: 'corona', cx, cy, r: hubR * 2.4, fill: haloFor(data.salas, brand.solid ?? brand.color) }));
1202
+ if (linked.has(data.salas)) hub.append(svgNode('circle', { class: 'halo', cx, cy, r: hubR + 6 }));
1203
+ hub.append(svgNode('circle', { class: 'core', cx, cy, r: hubR, fill: brand.solid ?? brand.color }));
1204
+ const size = Math.round(hubR * 1.24);
1205
+ const face = svgNode('g', { transform: `translate(${cx - size / 2} ${cy - size / 2})`, color: brand.ink });
846
1206
  face.innerHTML = brand.mark(); // trusted: our own SVG string from brands.js
847
- face.firstElementChild?.setAttribute('width', '21');
848
- face.firstElementChild?.setAttribute('height', '21');
1207
+ face.firstElementChild?.setAttribute('width', String(size));
1208
+ face.firstElementChild?.setAttribute('height', String(size));
849
1209
  hub.append(face);
1210
+ const hubTitle = svgNode('title');
1211
+ hubTitle.textContent = t('Coordinator');
1212
+ hub.append(hubTitle);
850
1213
  svg.append(hub);
851
- ui.map.append(svg);
1214
+ host.append(svg);
1215
+ focusable(svg, { pick, picked });
1216
+ host.dataset.quiet = JSON.stringify(projects.filter((room) => !ring.includes(room)).map((room) => room.id));
1217
+ 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
1218
  }
853
- // The tile changes size with the window: the map is drawn again at the new size.
1219
+ // The tile and the sheet change size with the window: the map is drawn again at the new size.
854
1220
  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);
1221
+ for (const [host, wide] of [[ui.map, false]]) {
1222
+ let drawnAt = '';
1223
+ new ResizeObserver(() => {
1224
+ const box = host.getBoundingClientRect();
1225
+ const size = `${Math.round(box.width)}x${Math.round(box.height)}`;
1226
+ if (!box.width || size === drawnAt || !mapDrawn) return;
1227
+ drawnAt = size;
1228
+ paintMap(host, mapDrawn.projects, mapDrawn.enlaces, { wide });
1229
+ }).observe(host);
1230
+ }
1231
+ }
1232
+
1233
+ // HOME, ARRANGED BY ITS PERSON (2026-10-13). Every tile of Home — the Coordinator, who waits,
1234
+ // the connections, the decisions, what needs you, the memory, and the widgets added to it — sits
1235
+ // where the person put it, at the size they chose. Right-click a tile: move it, resize it, take it
1236
+ // off Home, or add another. What is kept is this browser's (Home's own layout, like its cards).
1237
+ // A widget exists only where something real feeds it (src/server.mjs, homeWidget): the machine,
1238
+ // the projects' servers sentinels and datasets, the Coordinator's own dataset, and Ollama.
1239
+ const LAYOUT_KEY = 'pulse.home.layout';
1240
+ // One board (2026-10-13): every tile of Home in one order, the projects' drawer among them.
1241
+ const BOARD = 'sm-top';
1242
+ const SIZES = [[4, () => t('Small')], [6, () => t('Medium')], [8, () => t('Large')], [12, () => t('Full width')]];
1243
+ // THE GRID (2026-10-13): 12 columns across and rows of 96 px down, 16 px between them. Every tile
1244
+ // takes whole columns and whole rows, so every edge falls on the same lines and tiles fit side by
1245
+ // side exactly: a 5-row tile beside a 2-row and a 3-row one ends where they end. A tile's height is
1246
+ // its default, «Automatic» (the rows its content needs), or the rows the person chose; what does
1247
+ // not fit in a chosen height scrolls inside the tile.
1248
+ const HEIGHTS = [[0, () => t('Automatic')], [2, () => t('2 rows')], [3, () => t('3 rows')], [4, () => t('4 rows')], [5, () => t('5 rows')]];
1249
+ const HOME_TILES = {
1250
+ bishop: { id: 'sm-bishop', name: () => t('Coordinator'), about: () => t('Your coordinator, its state and its recent summary.'), size: 8, rows: 5 },
1251
+ waiting: { id: 'sm-waiting', name: () => t('Waiting for you'), about: () => t('The questions and permissions an agent is waiting on.'), size: 4, rows: 2 },
1252
+ links: { id: 'sm-links-tile', name: () => t('Connections'), about: () => t('The map of who looks up whom.'), size: 4, rows: 3 },
1253
+ 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 },
1254
+ decisions: { id: 'sm-decisions', name: () => t('Recent decisions'), about: () => t('What your projects decided lately.'), size: 4, rows: 3 },
1255
+ attention: { id: 'sm-attention', name: () => t('Needs you'), about: () => t('What Home noticed that needs a hand.'), size: 4, rows: 3 },
1256
+ memory: { id: 'sm-memory', name: () => t('Memory'), about: () => t('What your projects remember, by kind.'), size: 4, rows: 3 },
1257
+ };
1258
+ const WIDGETS = {
1259
+ '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 },
1260
+ '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.') },
1261
+ '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.') },
1262
+ 'w-coordinator': { kind: 'coordinator', name: () => t('Coordinator training'), about: () => t('How far the Coordinator\'s own record is from a model of its own.'), size: 4, rows: 3 },
1263
+ // Only for MADRE's author, in a copy run from source with the author's token (src/server.mjs, homeWidget):
1264
+ // anywhere else the server has no such widget and Home never offers it.
1265
+ '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 },
1266
+ '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 },
1267
+ };
1268
+ const DEFAULT_LAYOUT = { order: ['bishop', 'waiting', 'links', 'projects', 'decisions', 'attention', 'memory'], size: {}, rows: {} };
1269
+ // What each tile is, drawn in the accent the person chose (the same line as the room's AJUSTES icons).
1270
+ const TILE_ICONS = {
1271
+ '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>',
1272
+ '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>',
1273
+ '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>',
1274
+ '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>',
1275
+ '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>',
1276
+ '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>',
1277
+ '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>',
1278
+ '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>',
1279
+ '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>',
1280
+ '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>',
1281
+ '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>',
1282
+ '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>',
1283
+ '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>',
1284
+ };
1285
+ const tileIcon = (id) => { const box = el('span', 'sm-tile-icon'); box.innerHTML = TILE_ICONS[id] ?? ''; return box; }; // trusted: our own SVG strings
1286
+ const known = (id) => Object.hasOwn(HOME_TILES, id) || Object.hasOwn(WIDGETS, id);
1287
+ function readLayout() {
1288
+ let raw = null;
1289
+ try { raw = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? 'null'); } catch { raw = null; }
1290
+ if (!raw || typeof raw !== 'object') return structuredClone(DEFAULT_LAYOUT);
1291
+ const seen = new Set();
1292
+ const clean = (list) => (Array.isArray(list) ? list : []).filter((id) => typeof id === 'string' && known(id) && !seen.has(id) && seen.add(id));
1293
+ // An arrangement kept before the board was one (top · projects · low) reads as one order.
1294
+ const order = clean(Array.isArray(raw.order) ? raw.order : [...(raw.top ?? []), 'projects', ...(raw.low ?? [])]);
1295
+ // The drawer is never taken off: a list that lost it gets it back where the projects were.
1296
+ if (!order.includes('projects')) order.splice(Math.min(order.length, 3), 0, 'projects');
1297
+ const size = Object.fromEntries(Object.entries(raw.size ?? {}).filter(([id, span]) => known(id) && SIZES.some(([one]) => one === span)));
1298
+ const rows = Object.fromEntries(Object.entries(raw.rows ?? {}).filter(([id, count]) => known(id) && HEIGHTS.some(([one]) => one === count)));
1299
+ return { order, size, rows };
1300
+ }
1301
+ let layout = readLayout();
1302
+ function saveLayout() {
1303
+ try {
1304
+ if (JSON.stringify(layout) === JSON.stringify(DEFAULT_LAYOUT)) localStorage.removeItem(LAYOUT_KEY);
1305
+ else localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
1306
+ } catch { /* the arrangement lasts as long as the page */ }
1307
+ applyLayout();
1308
+ }
1309
+ const placed = (id) => layout.order.includes(id);
1310
+ const spanOf = (id) => layout.size[id] ?? (HOME_TILES[id] ?? WIDGETS[id]).size;
1311
+ const widgetNodes = new Map();
1312
+ function tileNode(id) {
1313
+ if (HOME_TILES[id]) return $(HOME_TILES[id].id);
1314
+ if (!widgetNodes.has(id)) widgetNodes.set(id, makeWidget(id));
1315
+ return widgetNodes.get(id);
1316
+ }
1317
+ function applyLayout() {
1318
+ const board = $(BOARD);
1319
+ for (const id of layout.order) {
1320
+ const node = tileNode(id);
1321
+ if (!node) continue;
1322
+ node.classList.remove('sm-off', 'c-3', 'c-4', 'c-5', 'c-6', 'c-8', 'c-12', 'r-1', 'r-2', 'r-3');
1323
+ node.classList.add(`c-${spanOf(id)}`);
1324
+ node.dataset.tile = id;
1325
+ board.append(node);
1326
+ tileFits.observe(node);
1327
+ }
1328
+ for (const id of Object.keys(HOME_TILES)) if (!placed(id)) $(HOME_TILES[id].id)?.classList.add('sm-off');
1329
+ for (const [id, node] of widgetNodes) if (!placed(id)) { tileFits.unobserve(node); node.remove(); node.stop?.(); widgetNodes.delete(id); }
1330
+ for (const [id, node] of widgetNodes) if (placed(id)) node.start?.();
1331
+ fitTiles();
1332
+ }
1333
+ // Each tile is as tall as what it holds (2026-10-13): the board's rows are 4 px and a tile spans as
1334
+ // many as its content needs, plus the gap under it. A tile made wider or narrower grows or shrinks
1335
+ // with its content instead of stretching to a height chosen for another size.
1336
+ const ROW_UNIT = 96;
1337
+ const ROW_GAP = 16;
1338
+ const rowsOf = (id) => layout.rows?.[id] ?? (HOME_TILES[id] ?? WIDGETS[id])?.rows ?? 0;
1339
+ // On a middle-width screen the tiles go in pairs, half and half: a pair ends level only if both
1340
+ // are as tall, so there a short tile takes at least the three rows the others take.
1341
+ const PAIRED = window.matchMedia?.('(max-width: 1180px) and (min-width: 761px)');
1342
+ PAIRED?.addEventListener?.('change', () => fitTiles());
1343
+ function fitTile(node) {
1344
+ const own = rowsOf(node.dataset.tile);
1345
+ const chosen = own && PAIRED?.matches && spanOf(node.dataset.tile) < 12 ? Math.max(3, own) : own;
1346
+ node.classList.toggle('sm-fixed', chosen > 0);
1347
+ // How many rows it has, for the tile's own CSS: a short tile shows what matters and leaves the rest.
1348
+ node.dataset.rows = chosen ? String(chosen) : 'auto';
1349
+ if (chosen) { node.style.gridRow = `auto / span ${chosen}`; return; }
1350
+ // Automatic: the rows its content needs, read at its natural height and rounded up to a whole row.
1351
+ node.style.alignSelf = 'start';
1352
+ const height = node.getBoundingClientRect().height;
1353
+ node.style.alignSelf = '';
1354
+ if (height) node.style.gridRow = `auto / span ${Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_UNIT + ROW_GAP)))}`;
1355
+ }
1356
+ function fitTiles() { for (const node of $(BOARD)?.querySelectorAll(':scope > [data-tile]') ?? []) fitTile(node); }
1357
+ const tileFits = typeof ResizeObserver === 'function' ? new ResizeObserver((entries) => { for (const entry of entries) fitTile(entry.target); }) : { observe() {}, unobserve() {} };
1358
+ function moveTile(id, where) {
1359
+ const list = layout.order;
1360
+ const at = list.indexOf(id);
1361
+ if (at < 0) return;
1362
+ list.splice(at, 1);
1363
+ const to = where === 'first' ? 0 : where === 'last' ? list.length : Math.max(0, Math.min(list.length, at + where));
1364
+ list.splice(to, 0, id);
1365
+ saveLayout();
1366
+ }
1367
+ function removeTile(id) {
1368
+ if ((HOME_TILES[id] ?? WIDGETS[id])?.kept) return;
1369
+ layout.order = layout.order.filter((one) => one !== id);
1370
+ delete layout.size[id];
1371
+ saveLayout();
1372
+ }
1373
+ function addTile(id) {
1374
+ if (placed(id)) return;
1375
+ layout.order.push(id);
1376
+ saveLayout();
1377
+ requestAnimationFrame(() => tileNode(id)?.scrollIntoView({ block: 'center', behavior: motionStill() ? 'auto' : 'smooth' }));
1378
+ }
1379
+
1380
+ // The menu a right-click opens, shared by the tiles and the space between them: the same card and
1381
+ // keys as a project's menu (pinsMenu), closed the same way.
1382
+ 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>';
1383
+ 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>';
1384
+ 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>';
1385
+ // A height is drawn as the rows it takes, out of five; «Automatic» as rows that follow the content.
1386
+ 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>`;
1387
+ // A size is drawn as the share of the row it takes: a third, a half, two thirds, all of it.
1388
+ 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>`;
1389
+ 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>';
1390
+ 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>';
1391
+ 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>';
1392
+ function homeMenu(point, entries, label) {
1393
+ pinsCloseMenu();
1394
+ const menu = el('div', 'bar-card sm-menu');
1395
+ menu.setAttribute('role', 'menu');
1396
+ if (label) menu.setAttribute('aria-label', label);
1397
+ for (const entry of entries) {
1398
+ if (entry === 'sep') { menu.append(el('i', 'sm-menu-sep')); continue; }
1399
+ 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; }
1400
+ const one = button(`sm-menu-item${entry.danger ? ' danger' : ''}${entry.checked ? ' checked' : ''}`, null, () => { pinsCloseMenu(); entry.act(); });
1401
+ one.setAttribute('role', entry.checked !== undefined ? 'menuitemradio' : 'menuitem');
1402
+ if (entry.checked !== undefined) one.setAttribute('aria-checked', String(Boolean(entry.checked)));
1403
+ if (entry.disabled) one.disabled = true;
1404
+ one.innerHTML = entry.icon ?? ''; // trusted: our own SVG strings
1405
+ one.append(el('span', null, entry.words));
1406
+ menu.append(one);
1407
+ }
1408
+ menu.addEventListener('keydown', (key) => {
1409
+ const items = [...menu.querySelectorAll('.sm-menu-item:not(:disabled)')];
1410
+ const at = items.indexOf(document.activeElement);
1411
+ if (key.key === 'ArrowDown') { key.preventDefault(); items[(at + 1) % items.length].focus(); }
1412
+ if (key.key === 'ArrowUp') { key.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
1413
+ });
1414
+ document.body.append(menu);
1415
+ pins.menu = menu;
1416
+ const width = menu.offsetWidth;
1417
+ const height = menu.offsetHeight;
1418
+ menu.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - width - 8, point.x)))}px`;
1419
+ menu.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - height - 8, point.y)))}px`;
1420
+ menu.style.right = 'auto';
1421
+ menu.querySelector('.sm-menu-item:not(:disabled)')?.focus({ preventScroll: true });
1422
+ }
1423
+ const customized = () => JSON.stringify(layout) !== JSON.stringify(DEFAULT_LAYOUT);
1424
+ 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(); } }] : [])];
1425
+ function tileMenu(event, id) {
1426
+ event.preventDefault();
1427
+ const list = layout.order;
1428
+ const at = list.indexOf(id);
1429
+ const spec = HOME_TILES[id] ?? WIDGETS[id];
1430
+ const sizes = SIZES.filter(([span]) => !spec.sizes || spec.sizes.includes(span));
1431
+ homeMenu({ x: event.clientX, y: event.clientY }, [
1432
+ { heading: spec.name(), tile: id },
1433
+ { words: t('Move before'), icon: BEFORE_ICON, disabled: at <= 0, act: () => moveTile(id, -1) },
1434
+ { words: t('Move after'), icon: AFTER_ICON, disabled: at >= list.length - 1, act: () => moveTile(id, 1) },
1435
+ { words: t('Move to the start'), icon: MOVE_ICON, disabled: at <= 0, act: () => moveTile(id, 'first') },
1436
+ { words: t('Move to the end'), icon: MOVE_ICON, disabled: at >= list.length - 1, act: () => moveTile(id, 'last') },
1437
+ ...(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(); } }))] : []),
1438
+ ...(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(); } }))]),
1439
+ 'sep',
1440
+ ...(spec.kept ? [] : [{ words: t('Take off Home'), icon: HIDE_ICON, act: () => removeTile(id) }]),
1441
+ ...addEntries(),
1442
+ ], spec.name());
1443
+ }
1444
+ $(BOARD)?.addEventListener('contextmenu', (event) => {
1445
+ // A project's card keeps its own menu (pinsMenu); the drawer around it has the tile's.
1446
+ if (event.target.closest('input, textarea, select, [contenteditable="true"], .sm-card')) return;
1447
+ const tile = event.target.closest('[data-tile]');
1448
+ if (tile) { tileMenu(event, tile.dataset.tile); return; }
1449
+ event.preventDefault();
1450
+ homeMenu({ x: event.clientX, y: event.clientY }, addEntries(), t('Home'));
1451
+ });
1452
+ // DRAG AND DROP (2026-10-13): a tile is taken by its head and set down where it should go — in the
1453
+ // same row of tiles or the other one, above or below the projects. While it is carried the tiles
1454
+ // make room for it; let go, that order is kept. Escape, or a drop outside, puts everything back.
1455
+ const TILE_TYPE = 'application/x-madre-tile';
1456
+ let carried = null;
1457
+ 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');
1458
+ {
1459
+ const host = $(BOARD);
1460
+ host?.addEventListener('pointerdown', (event) => {
1461
+ const tile = event.target.closest('[data-tile]');
1462
+ if (tile && event.button === 0 && grabbable(event)) tile.draggable = true;
1463
+ });
1464
+ host?.addEventListener('dragstart', (event) => {
1465
+ const tile = event.target.closest?.('[data-tile]');
1466
+ if (!tile || !tile.draggable) return;
1467
+ carried = tile;
1468
+ event.dataTransfer.setData(TILE_TYPE, tile.dataset.tile);
1469
+ event.dataTransfer.effectAllowed = 'move';
1470
+ pinsCloseMenu();
1471
+ requestAnimationFrame(() => tile.classList.add('sm-carried'));
1472
+ });
1473
+ host?.addEventListener('dragover', (event) => {
1474
+ if (!carried || !event.dataTransfer.types.includes(TILE_TYPE)) return;
1475
+ event.preventDefault();
1476
+ event.dataTransfer.dropEffect = 'move';
1477
+ const over = event.target.closest('[data-tile]');
1478
+ if (over && !host.contains(over)) return;
1479
+ if (!over) { if (!host.contains(carried)) host.append(carried); return; }
1480
+ if (over === carried) return;
1481
+ const box = over.getBoundingClientRect();
1482
+ const after = event.clientY > box.bottom - box.height / 3 || (event.clientY > box.top + box.height / 3 && event.clientX > box.left + box.width / 2);
1483
+ const target = after ? over.nextSibling : over;
1484
+ if (target !== carried && carried.nextSibling !== target) host.insertBefore(carried, target);
1485
+ });
1486
+ host?.addEventListener('drop', (event) => {
1487
+ if (!carried || !event.dataTransfer.types.includes(TILE_TYPE)) return;
1488
+ event.preventDefault();
1489
+ layout.order = [...host.children].filter((node) => node.dataset.tile && known(node.dataset.tile) && !node.classList.contains('sm-off')).map((node) => node.dataset.tile);
1490
+ carried.classList.remove('sm-carried');
1491
+ carried.draggable = false;
1492
+ carried = null;
1493
+ saveLayout();
1494
+ });
1495
+ }
1496
+ document.addEventListener('dragend', (event) => {
1497
+ if (!carried || event.target !== carried) return;
1498
+ carried.classList.remove('sm-carried');
1499
+ carried.draggable = false;
1500
+ carried = null;
1501
+ applyLayout();
1502
+ });
1503
+ document.addEventListener('pointerup', () => { for (const tile of document.querySelectorAll('[data-tile][draggable="true"]')) if (tile !== carried) tile.draggable = false; });
1504
+
1505
+ // A project's card, dragged among the others (it was already dragged to the bar to pin it): the
1506
+ // cards make room as it passes, and where it is let go becomes «Yours». A search or a filter shows
1507
+ // only some of them: the ones out of sight keep their places.
1508
+ let projectCarried = null;
1509
+ ui.projects.addEventListener('dragstart', (event) => { projectCarried = event.target.closest?.('.sm-card[data-room]') ?? null; });
1510
+ ui.projects.addEventListener('dragover', (event) => {
1511
+ if (!projectCarried || !event.dataTransfer.types.includes(PIN_TYPE)) return;
1512
+ const over = event.target.closest('.sm-card[data-room]');
1513
+ event.preventDefault();
1514
+ event.dataTransfer.dropEffect = 'move';
1515
+ if (!over || over === projectCarried) return;
1516
+ projectCarried.classList.add('sm-carried');
1517
+ const box = over.getBoundingClientRect();
1518
+ const after = event.clientY > box.bottom - box.height / 3 || (event.clientY > box.top + box.height / 3 && event.clientX > box.left + box.width / 2);
1519
+ const target = after ? over.nextSibling : over;
1520
+ if (target !== projectCarried && projectCarried.nextSibling !== target) ui.projects.insertBefore(projectCarried, target);
1521
+ });
1522
+ ui.projects.addEventListener('drop', (event) => {
1523
+ if (!projectCarried || !event.dataTransfer.types.includes(PIN_TYPE)) return;
1524
+ event.preventDefault();
1525
+ const shown = [...ui.projects.querySelectorAll('.sm-card[data-room]')].map((node) => node.dataset.room);
1526
+ const all = projectsInOrder().all.map((room) => room.id);
1527
+ const slots = all.map((id) => shown.includes(id));
1528
+ let next = 0;
1529
+ const order = all.map((id, index) => (slots[index] ? shown[next++] : id));
1530
+ try { localStorage.setItem(PROJECT_ORDER_KEY, JSON.stringify(order)); } catch { /* the order lasts as long as the page */ }
1531
+ projectCarried.classList.remove('sm-carried');
1532
+ projectCarried = null;
1533
+ saveHome('order', 'mine');
1534
+ });
1535
+ document.addEventListener('dragend', () => {
1536
+ if (!projectCarried) return;
1537
+ projectCarried.classList.remove('sm-carried');
1538
+ projectCarried = null;
1539
+ draw();
1540
+ });
1541
+
1542
+ // THE BAR'S KEYS MOVE UNDER THE POINTER (2026-10-13), as the room's do (styles.css, «Each drawing
1543
+ // moves in its own way»): the two halves of Connections close on each other (CSS), and the planets
1544
+ // of the Project map go round their orbit while the pointer stays.
1545
+ {
1546
+ const key = $('sm-macro');
1547
+ const orbits = [...(key?.querySelectorAll('animateMotion') ?? [])];
1548
+ const still = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
1549
+ key?.addEventListener('pointerenter', () => { if (!still()) for (const one of orbits) one.beginElement?.(); });
1550
+ key?.addEventListener('pointerleave', () => { for (const one of orbits) one.endElement?.(); });
1551
+ }
1552
+
1553
+ // CARRIED TO THE EDGE (2026-10-13): while a tile or a project is carried, the page scrolls when the
1554
+ // pointer nears its top or its bottom — faster the closer it gets — so a tile goes anywhere on the
1555
+ // board without being let go. The browser's own scrolling while dragging is too slow to rely on.
1556
+ {
1557
+ const EDGE = 96;
1558
+ const FASTEST = 22;
1559
+ let pointerY = null;
1560
+ let frame = 0;
1561
+ const carrying = () => Boolean(carried || projectCarried);
1562
+ const step = () => {
1563
+ frame = 0;
1564
+ if (!carrying() || pointerY === null) return;
1565
+ const room = window.innerHeight;
1566
+ const near = pointerY < EDGE ? -(EDGE - pointerY) / EDGE : pointerY > room - EDGE ? (pointerY - (room - EDGE)) / EDGE : 0;
1567
+ if (near) window.scrollBy(0, Math.round(Math.sign(near) * Math.max(2, Math.abs(near) ** 1.6 * FASTEST)));
1568
+ frame = requestAnimationFrame(step);
1569
+ };
1570
+ document.addEventListener('dragover', (event) => {
1571
+ if (!carrying()) return;
1572
+ pointerY = event.clientY;
1573
+ if (!frame) frame = requestAnimationFrame(step);
1574
+ });
1575
+ const stop = () => { pointerY = null; cancelAnimationFrame(frame); frame = 0; };
1576
+ document.addEventListener('drop', stop);
1577
+ document.addEventListener('dragend', stop);
1578
+ }
1579
+
1580
+ // The space around the projects, right-clicked, offers the same.
1581
+ document.querySelector('.sm-main')?.addEventListener('contextmenu', (event) => {
1582
+ if (event.defaultPrevented || event.target.closest('.sm-tile, .sm-card, input, textarea, select, a, button, dialog')) return;
1583
+ event.preventDefault();
1584
+ homeMenu({ x: event.clientX, y: event.clientY }, addEntries(), t('Home'));
1585
+ });
1586
+
1587
+ // «Add to Home»: the tiles taken off and the widgets not yet on it, each said in a line, with what
1588
+ // it would need when nothing feeds it yet.
1589
+ let homeAddSheet = null;
1590
+ async function openHomeAdd() {
1591
+ homeAddSheet?.remove();
1592
+ homeAddSheet = el('dialog', 'sm-sheet sm-add-sheet');
1593
+ const body = el('div', 'sm-sheet-body');
1594
+ const head = el('header', 'sm-sheet-head');
1595
+ head.append(el('h3', null, t('Add to Home')));
1596
+ // The same ✕ as every sheet of Home (the connections, the settings).
1597
+ const keys = el('span', 'surface-modes');
1598
+ const close = button('surface-mode close x-close x-only', null, () => homeAddSheet.close());
1599
+ close.setAttribute('aria-label', t('Close'));
1600
+ 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
1601
+ keys.append(close);
1602
+ head.append(keys);
1603
+ 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.')));
1604
+ const list = el('div', 'sm-add-list');
1605
+ body.append(list);
1606
+ homeAddSheet.append(body);
1607
+ homeAddSheet.addEventListener('close', () => { homeAddSheet?.remove(); homeAddSheet = null; });
1608
+ document.body.append(homeAddSheet);
1609
+ homeAddSheet.showModal();
1610
+ 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.') });
1611
+ // The author's widget is asked about before the list is drawn: where it cannot be read, it is not listed at all.
1612
+ const ownerOk = new Set();
1613
+ for (const [id, spec] of Object.entries(WIDGETS).filter(([id, spec]) => spec.owner && !placed(id))) {
1614
+ if (await fetch(`/api/salas/widget?kind=${spec.kind}`, { cache: 'no-store' }).then((response) => response.ok).catch(() => false)) ownerOk.add(id);
1615
+ }
1616
+ if (!homeAddSheet) return;
1617
+ const entries = [
1618
+ ...Object.entries(HOME_TILES).filter(([id]) => !placed(id)).map(([id, spec]) => ({ id, spec, ready: true })),
1619
+ ...Object.entries(WIDGETS).filter(([id, spec]) => !placed(id) && (!spec.owner || ownerOk.has(id))).map(([id, spec]) => ({ id, spec, ready: null })),
1620
+ ];
1621
+ if (!entries.length) { list.append(el('p', 'sm-hint', t('Everything is already on Home.'))); return; }
1622
+ for (const entry of entries) {
1623
+ const row = el('div', 'sm-add-row');
1624
+ row.append(tileIcon(entry.id));
1625
+ const words = el('div', 'sm-add-words');
1626
+ words.append(el('b', null, entry.spec.name()), el('small', null, entry.spec.about()));
1627
+ const said = el('small', 'sm-add-need');
1628
+ words.append(said);
1629
+ const add = button('sm-btn primary small', t('Add'), () => { addTile(entry.id); homeAddSheet?.close(); });
1630
+ row.append(words, add);
1631
+ list.append(row);
1632
+ // A widget is offered only when something feeds it: asked of the server, which says so.
1633
+ if (entry.ready === null) {
1634
+ add.disabled = true;
1635
+ said.textContent = t('Checking…');
1636
+ 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 }));
1637
+ if (!row.isConnected) return;
1638
+ const ok = Boolean(reading?.available);
1639
+ add.disabled = !ok;
1640
+ // A page served by a project started before widgets existed cannot read them: said as that,
1641
+ // not as a Mac with nothing to show.
1642
+ 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.'));
1643
+ }
1644
+ }
863
1645
  }
864
1646
 
1647
+ // ---- The widgets. Each is a tile that reads its own route every 20 s while Home is in view.
1648
+ const pct = (value) => (value === null || value === undefined ? '—' : `${Math.round(value * 100)}%`);
1649
+ const gb = (bytes) => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
1650
+ function meter(value, tone = null) {
1651
+ const bar = el('span', `sm-meter${tone ? ` ${tone}` : ''}`);
1652
+ const fill = el('i');
1653
+ fill.style.width = `${Math.round(Math.max(0, Math.min(1, value ?? 0)) * 100)}%`;
1654
+ bar.append(fill);
1655
+ return bar;
1656
+ }
1657
+ const toneFor = (share) => (share >= 0.85 ? 'hot' : share >= 0.6 ? 'warm' : null);
1658
+ const OS_WORDS = { darwin: 'macOS', linux: 'Linux', win32: 'Windows', other: () => t('other') };
1659
+ const WIDGET_PAINT = {
1660
+ usage(body, reading) {
1661
+ 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; }
1662
+ const grid = el('div', 'sm-w-grid sm-usage-figures');
1663
+ 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')]]) {
1664
+ const cell = el('div', 'sm-w-metric');
1665
+ cell.append(el('span', 'sm-w-label', label), el('b', 'sm-w-big', Number(n).toLocaleString()), el('small', null, line));
1666
+ grid.append(cell);
1667
+ }
1668
+ body.append(grid);
1669
+ // The last 30 days: copies on each day, and the part of them seen for the first time.
1670
+ const top = Math.max(1, ...reading.days.map((one) => one.active));
1671
+ const days = el('div', 'sm-usage-days');
1672
+ days.setAttribute('role', 'img');
1673
+ days.setAttribute('aria-label', t('Copies on each day, last 30 days'));
1674
+ for (const one of reading.days) {
1675
+ const bar = el('i');
1676
+ bar.style.setProperty('--h', String(one.active / top));
1677
+ bar.style.setProperty('--new', String(one.active ? one.new / one.active : 0));
1678
+ bar.title = t('{day}: {n} on · {fresh} new', { day: one.day, n: one.active, fresh: one.new });
1679
+ days.append(bar);
1680
+ }
1681
+ body.append(days);
1682
+ 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(' · ') || '—';
1683
+ const back = (part) => (part.cohort ? `${Math.round((part.back / part.cohort) * 100)}%` : '—');
1684
+ const lines = el('div', 'sm-usage-lines');
1685
+ for (const [label, said] of [
1686
+ [t('Versions'), share(reading.versions)],
1687
+ [t('Systems'), share(reading.os, (name) => OS_WORDS[name] ?? name)],
1688
+ [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) })],
1689
+ ]) {
1690
+ const row = el('div', 'sm-w-line');
1691
+ row.append(el('b', null, label), el('small', null, said));
1692
+ lines.append(row);
1693
+ }
1694
+ body.append(lines);
1695
+ },
1696
+ system(body, reading) {
1697
+ const cpu = el('div', 'sm-w-metric');
1698
+ 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] })));
1699
+ const used = reading.memory.total ? 1 - reading.memory.free / reading.memory.total : null;
1700
+ const mem = el('div', 'sm-w-metric');
1701
+ 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) })));
1702
+ const grid = el('div', 'sm-w-grid');
1703
+ grid.append(cpu, mem);
1704
+ body.append(grid);
1705
+ },
1706
+ servers(body, reading) {
1707
+ const running = reading.projects.flatMap((one) => one.ports.map((port) => ({ ...port, project: one.name })));
1708
+ 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.')));
1709
+ const grid = el('div', 'sm-w-grid');
1710
+ for (const one of running) {
1711
+ const healthy = one.status !== null && one.status < 500;
1712
+ const row = el('div', `sm-w-row${healthy ? ' ok' : ' bad'}`);
1713
+ 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')));
1714
+ grid.append(row);
1715
+ }
1716
+ if (running.length) body.append(grid);
1717
+ const off = reading.projects.filter((one) => !one.preview && !one.unreachable).length;
1718
+ 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 })));
1719
+ },
1720
+ memory(body, reading) {
1721
+ const read = reading.projects.filter((one) => !one.unreachable);
1722
+ if (!read.length) { body.append(el('p', 'sm-hint', t('Open a project to read its memory.'))); return; }
1723
+ const grid = el('div', 'sm-w-grid');
1724
+ body.append(grid);
1725
+ for (const one of read) {
1726
+ const row = el('div', 'sm-w-project');
1727
+ const top = el('div', 'sm-w-line');
1728
+ top.append(el('b', null, one.name), el('span', 'sm-w-stage', one.stage ?? '—'), el('small', null, pct(one.score)));
1729
+ row.append(top, meter(one.score));
1730
+ const notes = [one.weakest?.label ? t('weakest: {what}', { what: one.weakest.label.toLowerCase() }) : null, one.aberrations ? t('{n} odd memories', { n: one.aberrations }) : null].filter(Boolean);
1731
+ if (notes.length) row.append(el('small', null, notes.join(' · ')));
1732
+ grid.append(row);
1733
+ }
1734
+ },
1735
+ coordinator(body, reading) {
1736
+ const share = reading.target ? reading.pairs / reading.target : 0;
1737
+ const top = el('div', 'sm-w-line');
1738
+ top.append(el('b', 'sm-w-big', `${reading.pairs}`), el('span', null, t('of {n} exchanges to train on', { n: reading.target })));
1739
+ body.append(top, meter(share));
1740
+ body.append(el('p', 'sm-w-says', [reading.stage, reading.says].filter(Boolean).join(' · ')));
1741
+ const facts = [t('{n} memories', { n: reading.notes ?? 0 }), reading.trained ? t('model {name}', { name: reading.trained }) : t('no model of its own yet')];
1742
+ body.append(el('small', 'sm-w-foot', facts.join(' · ')));
1743
+ },
1744
+ models(body, reading) {
1745
+ const head = el('div', `sm-w-row${reading.ollama.running ? ' ok' : ' bad'}`);
1746
+ 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')));
1747
+ // Which projects use it is set in Home's Settings › Local AI.
1748
+ const tune = button('sm-w-link', t('Choose projects'), (event) => { event.stopPropagation(); openSettings('local'); });
1749
+ head.append(tune);
1750
+ body.append(head);
1751
+ 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 ? ' …' : '')));
1752
+ const grid = el('div', 'sm-w-grid');
1753
+ body.append(grid);
1754
+ for (const one of reading.projects.filter((project) => !project.unreachable)) {
1755
+ const row = el('div', 'sm-w-project');
1756
+ const top = el('div', 'sm-w-line');
1757
+ const ready = one.pairs >= one.target;
1758
+ const count = el('small', null, one.trained ? t('trained · {name}', { name: one.trained }) : ready ? t('ready to train') : `${one.pairs}/${one.target}`);
1759
+ count.title = t('{n} of {target} examples this project needs to train a model of its own', { n: one.pairs, target: one.target });
1760
+ top.append(el('b', null, one.name));
1761
+ if (one.localAi === false) { row.classList.add('sm-w-off'); top.append(el('span', 'sm-w-tag', t('without local AI'))); }
1762
+ top.append(count);
1763
+ row.append(top, meter(one.trained ? 1 : one.target ? one.pairs / one.target : 0));
1764
+ grid.append(row);
1765
+ }
1766
+ },
1767
+ };
1768
+ function makeWidget(id) {
1769
+ const spec = WIDGETS[id];
1770
+ if (!spec) return null;
1771
+ const node = el('section', `sm-tile sm-widget w-${spec.kind}`);
1772
+ const head = el('header', 'sm-tile-head');
1773
+ head.append(el('h2', null, spec.name()));
1774
+ const body = el('div', 'sm-w-body');
1775
+ body.append(el('p', 'sm-hint', t('Reading…')));
1776
+ node.append(head, body);
1777
+ let timer = 0;
1778
+ const read = async () => {
1779
+ if (document.hidden || !node.isConnected) return;
1780
+ const reading = await fetch(`/api/salas/widget?kind=${spec.kind}`, { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
1781
+ if (!node.isConnected) return;
1782
+ body.replaceChildren();
1783
+ // The author's widget is not there at all for a copy that cannot read it (no token, not from source).
1784
+ if (spec.owner) node.hidden = !reading;
1785
+ if (!reading) { body.append(el('p', 'sm-hint', t('It could not be read just now.'))); return; }
1786
+ WIDGET_PAINT[spec.kind](body, reading);
1787
+ fitTile(node);
1788
+ // The first time it shows, what it is (homeTour is set up below this, so it is read late).
1789
+ if (!node.dataset.guided) { node.dataset.guided = '1'; setTimeout(() => { try { guideWidget(node, spec.kind); } catch { /* the tour is not up yet */ } }, 1500); }
1790
+ };
1791
+ node.start = () => { if (timer) return; void read(); timer = setInterval(() => void read(), 20000); };
1792
+ node.stop = () => { clearInterval(timer); timer = 0; };
1793
+ return node;
1794
+ }
1795
+ applyLayout();
1796
+
865
1797
  // THE WELCOME OF 1.0 (public/tour.js): the bonjour the first time, the chapters after it, the pill
866
1798
  // that waits in the bar, `?` for the gestures, and the hint on Connections. In Home, «Take me
867
1799
  // there» opens what lives here; for what lives inside a project it opens the one open most
@@ -888,9 +1820,43 @@ const homeTour = startTour({
888
1820
  links: () => openLinkSheet(),
889
1821
  get maps() { return ui.macro.hidden ? undefined : () => ui.macro.click(); },
890
1822
  settings: () => openSettings(),
1823
+ local: () => openSettings('local'),
891
1824
  gestures: () => homeTour.openGestures(),
892
1825
  },
893
1826
  });
1827
+ // What each key of Home's bar opens, said the first time it opens (public/tour.js, firstOpen).
1828
+ const topSheet = () => [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
1829
+ homeTour.firstOpen('home-links', ui.bar.links, {
1830
+ opened: topSheet,
1831
+ inside: (box) => box.querySelector('.sm-link-list, .sm-sheet-body'),
1832
+ title: () => t('Connections'),
1833
+ 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.'),
1834
+ });
1835
+ homeTour.firstOpen('home-map', ui.macro, {
1836
+ title: () => t('Project map'),
1837
+ 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.'),
1838
+ after: 900,
1839
+ });
1840
+ homeTour.firstOpen('home-settings', ui.gear, {
1841
+ opened: () => document.querySelector('dialog.sm-settings[open]'),
1842
+ inside: (box) => box.querySelector('.sm-settings-window'),
1843
+ title: () => t('Home settings'),
1844
+ 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.'),
1845
+ });
1846
+ // What each widget shows, the first time it is on Home: its own line, and how to change it.
1847
+ const WIDGET_GUIDE = {
1848
+ system: () => t('How busy this Mac\'s processor is and how much memory is free. It updates on its own.'),
1849
+ servers: () => t('The servers running in your open projects and whether they answer. A project shows here when its Preview is on.'),
1850
+ memory: () => t('How grown each open project\'s memory is, and its weakest side: the one thing that would improve it most.'),
1851
+ coordinator: () => t('How far the Coordinator\'s own record is from training a model of its own.'),
1852
+ 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.'),
1853
+ usage: () => t('How many copies of MADRE are on, which versions and whether people come back. Only you see this.'),
1854
+ };
1855
+ function guideWidget(node, kind) {
1856
+ const said = WIDGET_GUIDE[kind];
1857
+ if (!said) return;
1858
+ 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.')}` });
1859
+ }
894
1860
  // Once Home has drawn: the bonjour if it is owed; if it was given, the hint on Connections — what
895
1861
  // it is, and, for whoever came from 0.7.2, what the word meant before.
896
1862
  let welcomeAsked = false;
@@ -1307,8 +2273,12 @@ function openLinkSheet({ reader = null, source = null } = {}) {
1307
2273
  }
1308
2274
  ui.link.addEventListener('click', () => openLinkSheet());
1309
2275
 
2276
+ let refreshPending = null;
1310
2277
  async function refresh() {
1311
- const answer = await fetch('/api/salas', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
2278
+ if (refreshPending) return refreshPending;
2279
+ refreshPending = fetch('/api/salas', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
2280
+ const answer = await refreshPending;
2281
+ refreshPending = null;
1312
2282
  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; }
1313
2283
  if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); homeTour.repaint(); }
1314
2284
  data = answer;
@@ -1937,6 +2907,14 @@ function pinsMenu(event, room, at = null) {
1937
2907
  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
2908
  item(t('Arrange the bar'), ARRANGE_ICON, () => pinsEdit(true));
1939
2909
  menu.append(el('i', 'sm-menu-sep'));
2910
+ // Where it sits among the projects: moved by hand, the order becomes «Yours».
2911
+ const order = projectsInOrder().all.map((one) => one.id);
2912
+ const place = order.indexOf(room.id);
2913
+ if (place > 0) item(t('Move before'), BEFORE_ICON, () => moveProject(room.id, -1));
2914
+ if (place >= 0 && place < order.length - 1) item(t('Move after'), AFTER_ICON, () => moveProject(room.id, 1));
2915
+ if (place > 0) item(t('Move to the start'), BEFORE_ICON, () => moveProject(room.id, 'first'));
2916
+ item(t('Add to Home…'), ADD_ICON, () => openHomeAdd());
2917
+ menu.append(el('i', 'sm-menu-sep'));
1940
2918
  item(t('Remove from MADRE…'), FORGET_ICON, () => { forgetting.add(room.id); asking.delete(room.id); failed.delete(room.id); draw(); }, { danger: true });
1941
2919
  menu.addEventListener('keydown', (key) => {
1942
2920
  const items = [...menu.querySelectorAll('.sm-menu-item')];
@@ -2127,7 +3105,9 @@ function drawBishop(room) {
2127
3105
  const b = ui.bishop;
2128
3106
  const glance = room?.glance ?? {};
2129
3107
  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}`;
3108
+ // Only its state: its size is the person's (applyLayout), and a class written whole took it back.
3109
+ for (const one of [...b.box.classList]) if (one.startsWith('is-')) b.box.classList.remove(one);
3110
+ b.box.classList.add(`is-${state.key}`);
2131
3111
  b.state.querySelector('span').textContent = state.words;
2132
3112
  const waking = busy.get(data?.salas) === 'starting';
2133
3113
  for (const way of [b.go, b.tab]) way.setAttribute('aria-disabled', String(waking));
@@ -2437,44 +3417,281 @@ function openAddSheet() {
2437
3417
  }
2438
3418
  ui.add.addEventListener('click', openAddSheet);
2439
3419
 
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 } = {}) {
3420
+ // The agents of this computer, with everything a connection needs in one place: which account
3421
+ // each one signs in with, the doors it has, the box where a pasted key goes, the address a
3422
+ // sign-in opened and the code that page gives back. Home used to show a name, a lamp and one
3423
+ // button — the project's panel had the rest, so the same errand was two different screens.
3424
+ // Kept current while it is on screen: polled, and told by the room's events how it is going.
3425
+ // `compact` is the welcome's first step: there, a connected agent is done, and a button to change
3426
+ // the account it signs in with is a question nobody asked on their first minute.
3427
+ // Who makes each agent, said under its name as the project's AJUSTES says it.
3428
+ const VENDOR_WORDS = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'OpenCode' };
3429
+
3430
+ // OpenCode's providers in Home, the same block as the project's AJUSTES › Agents › OpenCode: a line
3431
+ // per provider with a key or an account and its switch for the chat, one row to add another, and
3432
+ // the model it answers with by default. Asked of the server once a minute at most, because asking
3433
+ // runs `opencode models`; a change asks again at once.
3434
+ const openCodeProviders = (() => {
3435
+ let reading = null;
3436
+ let at = 0;
3437
+ let host = null;
3438
+ const fetchNow = async () => {
3439
+ reading = await fetch('/api/agents/opencode/providers', { cache: 'no-store' }).then((response) => response.json().then((json) => ({ ok: response.ok, json }))).catch(() => ({ ok: false, json: {} }));
3440
+ at = Date.now();
3441
+ paintInto();
3442
+ };
3443
+ const sendKey = (path, payload) => fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
3444
+ .then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
3445
+ const switchFor = (on, change, label) => {
3446
+ const wrap = el('label', 'settings-switch');
3447
+ const box = el('input'); box.type = 'checkbox'; box.checked = on; box.setAttribute('aria-label', label);
3448
+ box.addEventListener('change', () => void change(box.checked, box));
3449
+ wrap.append(box, el('i'));
3450
+ return wrap;
3451
+ };
3452
+ function paintInto() {
3453
+ if (!host?.isConnected) return;
3454
+ host.replaceChildren(el('b', 'sm-oc-title', t('Providers')));
3455
+ if (!reading) { host.append(el('small', 'sm-agent-note', t('Reading OpenCode\'s providers…'))); return; }
3456
+ if (!reading.ok) { host.append(el('small', 'sm-agent-note', reading.json.error ?? t('OpenCode\'s providers could not be read.'))); return; }
3457
+ const providers = reading.json.providers ?? [];
3458
+ const hidden = new Set(providers.filter((one) => one.hidden).map((one) => one.id));
3459
+ for (const provider of providers.filter((one) => one.via)) {
3460
+ const row = el('div', 'sm-oc-row');
3461
+ const words = el('span', 'sm-agent-words');
3462
+ const status = provider.via === 'own' ? t('Included with OpenCode · {n} models', { n: provider.models.length })
3463
+ : provider.via === 'api' ? t('Connected with a key · {n} models', { n: provider.models.length })
3464
+ : provider.via === 'oauth' ? t('Connected with its account · {n} models', { n: provider.models.length })
3465
+ : t('Connected · {n} models', { n: provider.models.length });
3466
+ words.append(el('b', null, provider.label), el('small', 'sm-agent-note', status));
3467
+ const controls = el('span', 'sm-oc-controls');
3468
+ if (provider.via === 'api') {
3469
+ const remove = button('sm-btn ghost small', t('Remove the key'), async () => {
3470
+ if (remove.dataset.sure !== '1') { remove.dataset.sure = '1'; remove.textContent = t('Remove it?'); return; }
3471
+ remove.disabled = true;
3472
+ const answer = await sendKey('/api/agents/opencode/providers/remove', { provider: provider.id });
3473
+ if (!answer.ok) { remove.disabled = false; remove.textContent = answer.body.error ?? t('The key was not removed.'); return; }
3474
+ void fetchNow();
3475
+ });
3476
+ controls.append(remove);
3477
+ }
3478
+ const label = el('span', 'sm-oc-shown', t('In the chat'));
3479
+ controls.append(label, switchFor(!provider.hidden, async (on, box) => {
3480
+ if (on) hidden.delete(provider.id); else hidden.add(provider.id);
3481
+ const answer = await sendKey('/api/settings', { opencode: { hidden: [...hidden] } });
3482
+ if (!answer.ok) { box.checked = !on; if (on) hidden.add(provider.id); else hidden.delete(provider.id); }
3483
+ }, t('Show {provider} in the chat', { provider: provider.label })));
3484
+ row.append(words, controls);
3485
+ host.append(row);
3486
+ }
3487
+ const waiting = providers.filter((one) => !one.via && one.takesKey);
3488
+ if (waiting.length) {
3489
+ const form = el('form', 'sm-agent-key-form sm-oc-add');
3490
+ const which = el('select');
3491
+ which.setAttribute('aria-label', t('Provider'));
3492
+ for (const provider of waiting) { const option = el('option', null, provider.label); option.value = provider.id; which.append(option); }
3493
+ const input = el('input');
3494
+ input.type = 'password'; input.autocomplete = 'off'; input.spellcheck = false;
3495
+ const link = el('a', 'sm-agent-key-link', t('Where do I get one?'));
3496
+ link.target = '_blank'; link.rel = 'noopener';
3497
+ const said = el('small', 'sm-agent-line');
3498
+ const sync = () => {
3499
+ const provider = waiting.find((one) => one.id === which.value);
3500
+ input.placeholder = t('{provider} API key', { provider: provider?.label ?? '' });
3501
+ link.hidden = !provider?.keyUrl;
3502
+ if (provider?.keyUrl) link.href = provider.keyUrl;
3503
+ };
3504
+ which.addEventListener('change', sync);
3505
+ sync();
3506
+ const save = el('button', 'sm-btn primary small', t('Add'));
3507
+ save.type = 'submit';
3508
+ form.append(which, input, save, link, said);
3509
+ form.addEventListener('submit', async (event) => {
3510
+ event.preventDefault();
3511
+ if (!input.value.trim()) { input.focus(); return; }
3512
+ save.disabled = true;
3513
+ said.textContent = t('Checking…');
3514
+ const answer = await sendKey('/api/agents/opencode/key', { key: input.value.trim(), provider: which.value });
3515
+ input.value = '';
3516
+ save.disabled = false;
3517
+ if (answer.ok) void fetchNow(); else said.textContent = answer.body.error ?? t('The key was not accepted.');
3518
+ });
3519
+ host.append(el('b', 'sm-oc-title', t('Add a provider')), form);
3520
+ }
3521
+ }
3522
+ return {
3523
+ node() {
3524
+ // The same node across the panel's repaints, so a key being typed is never thrown away.
3525
+ host ??= el('div', 'sm-oc');
3526
+ if (!reading || Date.now() - at > 60000) void fetchNow(); else paintInto();
3527
+ return host;
3528
+ },
3529
+ };
3530
+ })();
3531
+
3532
+ function agentsPanel({ onPaint = null, compact = false } = {}) {
2444
3533
  const list = el('div', 'sm-agents');
2445
3534
  const lastLine = new Map(); // agent → what its install or sign-in said last
3535
+ const openMore = new Set(); // agents whose connection detail is unfolded
3536
+ const toggleMore = (id) => { if (openMore.has(id)) openMore.delete(id); else openMore.add(id); };
3537
+ let live = null; // the sign-in running right now, as the server tells it
2446
3538
  let poll = 0;
2447
3539
  let stream = null;
3540
+
3541
+ // The second half of a browser sign-in: the address stays put, and the code that page hands
3542
+ // back is typed here. Without it the sign-in could only wait until its clock killed it.
3543
+ const askBlock = (agent) => {
3544
+ if (!live || live.agent !== agent.id) return null;
3545
+ const box = el('div', 'sm-agent-ask');
3546
+ if (live.url) {
3547
+ const open = el('a', 'sm-btn primary small', t('Open the sign-in page'));
3548
+ open.href = live.url;
3549
+ open.target = '_blank';
3550
+ open.rel = 'noopener';
3551
+ box.append(open);
3552
+ }
3553
+ if (live.asksForCode || live.awaitingCode) {
3554
+ const form = el('form', 'sm-agent-code');
3555
+ const input = el('input');
3556
+ input.type = 'text';
3557
+ input.autocomplete = 'off';
3558
+ input.spellcheck = false;
3559
+ input.placeholder = t('Paste the code the page gave you');
3560
+ const send = el('button', 'sm-btn primary small', t('Send the code'));
3561
+ send.type = 'submit';
3562
+ const said = el('small', 'sm-agent-line');
3563
+ form.append(input, send, said);
3564
+ form.addEventListener('submit', async (event) => {
3565
+ event.preventDefault();
3566
+ send.disabled = true;
3567
+ said.textContent = t('Checking…');
3568
+ const answer = await post(`/api/agents/${agent.id}/login/code`, { code: input.value });
3569
+ if (answer.ok) { input.value = ''; said.textContent = t('Sent. The sign-in finishes on its own.'); }
3570
+ else said.textContent = answer.body.error ?? t('The code was not accepted.');
3571
+ send.disabled = false;
3572
+ });
3573
+ box.append(form);
3574
+ }
3575
+ 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(); }));
3576
+ return box;
3577
+ };
3578
+
3579
+ // Handing a key to a CLI, with the same promise the project's panel makes: it is written where
3580
+ // that CLI reads it, MADRE keeps no copy, and the CLI itself says whether it took.
3581
+ const keyForm = (agent) => {
3582
+ const plan = agent.key;
3583
+ const form = el('form', 'sm-agent-key-form');
3584
+ let select = null;
3585
+ if (plan.providers) {
3586
+ select = el('select');
3587
+ select.setAttribute('aria-label', t('Provider'));
3588
+ for (const provider of plan.providers) { const option = el('option', null, provider.label); option.value = provider.id; select.append(option); }
3589
+ form.append(select);
3590
+ }
3591
+ const input = el('input');
3592
+ input.type = 'password';
3593
+ input.autocomplete = 'off';
3594
+ input.spellcheck = false;
3595
+ input.placeholder = t(plan.label);
3596
+ const save = el('button', 'sm-btn primary small', t('Save the key'));
3597
+ save.type = 'submit';
3598
+ form.append(input, save);
3599
+ const said = el('small', 'sm-agent-line');
3600
+ const keyUrl = plan.providers ? (plan.providers[0]?.keyUrl ?? null) : plan.keyUrl;
3601
+ if (keyUrl) {
3602
+ const link = el('a', 'sm-agent-key-link', t('Where do I get one?'));
3603
+ link.href = keyUrl;
3604
+ link.target = '_blank';
3605
+ link.rel = 'noopener';
3606
+ form.append(link);
3607
+ select?.addEventListener('change', () => { const next = plan.providers.find((one) => one.id === select.value)?.keyUrl; if (next) link.href = next; });
3608
+ }
3609
+ form.append(said, el('small', 'sm-agent-note', t(plan.note)));
3610
+ form.addEventListener('submit', async (event) => {
3611
+ event.preventDefault();
3612
+ save.disabled = true;
3613
+ said.textContent = t('Checking…');
3614
+ const answer = await post(`/api/agents/${agent.id}/key`, { key: input.value, provider: select?.value ?? null });
3615
+ if (answer.ok) { input.value = ''; openMore.delete(agent.id); void paint(); }
3616
+ else { said.textContent = answer.body.error ?? t('The key was not accepted.'); save.disabled = false; }
3617
+ });
3618
+ return form;
3619
+ };
3620
+
2448
3621
  const paint = async () => {
2449
3622
  const answer = await fetch('/api/settings', { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
2450
3623
  if (!answer?.agents) return;
2451
3624
  agentsSeen = answer;
2452
3625
  drawAttention();
2453
- list.replaceChildren();
3626
+ live = answer.login ?? null;
2454
3627
  let ready = 0;
3628
+ for (const agent of answer.agents.filter((one) => one.id !== 'madre')) if (agentState(agent, answer).ok) ready += 1;
3629
+ // A field being typed into is never yanked away by the poll: the panel redraws every few
3630
+ // seconds, and a half-pasted key or code would go with it.
3631
+ if (list.contains(document.activeElement) && ['INPUT', 'SELECT'].includes(document.activeElement.tagName)) { onPaint?.(ready, answer); return; }
3632
+ list.replaceChildren();
2455
3633
  for (const agent of answer.agents.filter((one) => one.id !== 'madre')) {
2456
3634
  const state = agentState(agent, answer);
2457
- if (state.ok) ready += 1;
2458
- const item = el('div', `sm-agent${state.ok ? ' ok' : ''}`);
3635
+ // The project's AJUSTES design (2026-10-13): one card per agent, folded to a line that says
3636
+ // who it is and whether it has a session. One that needs the person opens by itself.
3637
+ const unfolded = openMore.has(agent.id) || !state.ok || state.busyNow || (live && live.agent === agent.id);
3638
+ const item = el('section', `sm-agent${state.ok ? ' ok' : ''}${unfolded ? ' open' : ''}`);
3639
+ item.dataset.agent = agent.id;
3640
+ const head = button('sm-agent-head', null, () => { toggleMore(agent.id); if (!openMore.has(agent.id) && !state.ok) openMore.add(agent.id); void paint(); });
3641
+ head.setAttribute('aria-expanded', String(unfolded));
2459
3642
  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));
3643
+ words.append(el('b', null, agent.label ?? agent.id), el('small', null, VENDOR_WORDS[agent.id] ?? ''));
3644
+ const chip = el('span', `sm-agent-chip session-lamp ${state.key}`);
3645
+ chip.append(el('span', null, state.words));
3646
+ head.append(mark(agent.id, 34), words, chip, el('span', 'sm-chevron'));
3647
+ item.append(head);
3648
+ const body = el('div', 'sm-agent-body');
3649
+ // Which account it is signed in with, as its own CLI reports it, and which one it asks for.
3650
+ const facts = el('div', 'sm-agent-facts');
3651
+ if (agent.detected && agent.version) facts.append(el('code', 'sm-agent-line', `${agent.version}${agent.path ? ` · ${agent.path}` : ''}`));
3652
+ if (agent.detected && answer.sessions?.[agent.id]?.detail) facts.append(el('small', 'sm-agent-line', answer.sessions[agent.id].detail));
3653
+ if (agent.account) facts.append(el('small', 'sm-agent-note', agent.account));
2462
3654
  if (lastLine.has(agent.id) && !state.ok) {
2463
3655
  const said = lastLine.get(agent.id);
2464
3656
  const line = el('small', 'sm-agent-line', said.line);
2465
3657
  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);
3658
+ facts.append(line);
2467
3659
  }
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(); }));
3660
+ if (facts.childNodes.length) body.append(facts);
3661
+ // One button per account the CLI can sign in with: Claude has two, and only the human can
3662
+ // say which one is paying.
3663
+ const doorKeys = () => {
3664
+ const doors = agent.login?.methods?.length ? agent.login.methods : [{ id: null, label: null, note: agent.login?.note }];
3665
+ return doors.map((door) => {
3666
+ const name = door.label ? t(door.label) : state.ok ? t('Sign in again') : t('Sign in');
3667
+ const key = button(`sm-btn ${state.ok ? '' : 'primary'} small`, name, async (event) => {
3668
+ event.currentTarget.disabled = true;
3669
+ await post(`/api/agents/${agent.id}/login`, { method: door.id });
3670
+ void paint();
3671
+ });
3672
+ if (door.note) key.title = t(door.note);
3673
+ return key;
3674
+ });
3675
+ };
3676
+ const acts = el('div', 'sm-agent-acts');
3677
+ if (!state.busyNow) {
3678
+ 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(); }));
3679
+ else if (agent.detected && agent.login?.headless) for (const key of doorKeys()) acts.append(key);
2472
3680
  }
3681
+ if (acts.childNodes.length) body.append(acts);
3682
+ const ask = askBlock(agent);
3683
+ if (ask) body.append(ask);
3684
+ // A key, pasted here; OpenCode's are its providers', in the block below.
3685
+ if (agent.detected && agent.key && agent.id !== 'opencode') body.append(keyForm(agent));
3686
+ // A CLI that signs in from its own prompt gets the command, until it has a session.
3687
+ if (agent.detected && agent.login && !agent.login.headless && !state.ok) body.append(el('code', 'sm-agent-command', agent.login.display));
3688
+ if (agent.id === 'opencode' && agent.detected) body.append(openCodeProviders.node());
3689
+ item.append(body);
2473
3690
  list.append(item);
2474
3691
  }
2475
3692
  onPaint?.(ready, answer);
2476
3693
  };
2477
- const live = () => {
3694
+ const liveOn = () => {
2478
3695
  if (!list.isConnected || poll) return;
2479
3696
  poll = setInterval(() => { if (!document.hidden) void paint(); }, 2500);
2480
3697
  try {
@@ -2489,7 +3706,7 @@ function agentsPanel({ onPaint = null } = {}) {
2489
3706
  } catch { /* no stream: the poll keeps it current */ }
2490
3707
  };
2491
3708
  const stop = () => { clearInterval(poll); poll = 0; stream?.close(); stream = null; };
2492
- return { node: list, paint, live, stop };
3709
+ return { node: list, paint, live: liveOn, stop };
2493
3710
  }
2494
3711
 
2495
3712
  // ---- The first time: three steps. The agents (connected once, for every project: a CLI's session
@@ -2581,6 +3798,7 @@ function openWizard({ then = null } = {}) {
2581
3798
  const next = button('sm-btn primary', t('Continue'), () => { step = 1; projectsStep(); });
2582
3799
  actions.append(next);
2583
3800
  const fresh = agentsPanel({
3801
+ compact: true,
2584
3802
  onPaint: (ready, answer) => {
2585
3803
  next.textContent = ready ? t('Continue') : t('Continue without agents');
2586
3804
  // The scene lights the agents that are connected, and their wire to MADRE.
@@ -2658,7 +3876,7 @@ function openWizard({ then = null } = {}) {
2658
3876
  // in that room. Nothing scrolls inside the window: the screen behind it does.
2659
3877
  const SET_SECTIONS = [
2660
3878
  ['You', 'How Home looks and what its bar holds', ['look', 'bar']],
2661
- ['The agents', 'Who works on this Mac', ['agents', 'bishop']],
3879
+ ['The agents', 'Who works on this Mac', ['agents', 'bishop', 'local']],
2662
3880
  ['System', 'The projects of this Mac and the version', ['projects', 'release']],
2663
3881
  ];
2664
3882
  // The room's own icons where the room has the category (app.js, SETTINGS_CATS); the bar, BISHOP
@@ -2668,15 +3886,17 @@ const SET_CATS = {
2668
3886
  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
3887
  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
3888
  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"/>' },
3889
+ 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
3890
  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
3891
  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
3892
  };
2674
- const SET_ORDER = ['look', 'bar', 'agents', 'bishop', 'projects', 'release'];
3893
+ const SET_ORDER = ['look', 'bar', 'agents', 'bishop', 'local', 'projects', 'release'];
2675
3894
  // The words the search knows, each pointing to the category that holds it (app.js, SETTINGS_INDEX).
2676
3895
  const SET_INDEX = [
2677
3896
  ['look', 'Preview'], ['look', 'Theme'], ['look', 'Accent'], ['look', 'Wallpaper'], ['look', 'Intensity'], ['look', 'Blur'], ['look', 'Project cards'], ['look', 'Text'], ['look', 'Project order'], ['look', 'Language'],
2678
3897
  ['bar', 'Arrange the bar'], ['bar', 'Back to default'],
2679
3898
  ['bishop', 'Talk to the Coordinator'], ['bishop', 'Open the Coordinator in a new tab'],
3899
+ ['local', 'Ollama'], ['local', 'Automatic'], ['local', 'One by one'], ['local', 'Manual'],
2680
3900
  ['projects', 'Add a project'], ['projects', 'See the welcome again'], ['projects', 'The tour of MADRE 1.0'],
2681
3901
  ['release', 'Check now'],
2682
3902
  ];
@@ -2744,6 +3964,11 @@ function setStatus(id) {
2744
3964
  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
3965
  }
2746
3966
  if (id === 'bishop') { const room = bishopRoom(); return { line: room ? stateOf(room).words : t('Reading…') }; }
3967
+ if (id === 'local') {
3968
+ if (!localSeen) return { line: localFailed ? t('Home did not answer.') : t('Reading…') };
3969
+ const on = localSeen.projects.filter((one) => one.on).length;
3970
+ 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 switched off for every project.') : !localSeen.ollama.running ? t('Ollama is not running') : null };
3971
+ }
2747
3972
  if (id === 'projects') {
2748
3973
  if (!data) return { line: t('Reading…') };
2749
3974
  const { all, open } = projectsInOrder();
@@ -2909,7 +4134,7 @@ function fillLook(host) {
2909
4134
  // Home's own: its cards, its text, the order of its projects. The conversation's bubbles and text
2910
4135
  // are each project's, in its own Settings.
2911
4136
  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');
4137
+ 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
4138
  sxRow(home, t('Project cards'), null, homeSeg('cards'), { stack: true });
2914
4139
  sxRow(home, t('Text'), null, homeSeg('text'), { stack: true });
2915
4140
  sxRow(home, t('Project order'), null, homeSeg('order'), { stack: true });
@@ -2947,9 +4172,11 @@ function fillBar(host) {
2947
4172
  host.append(acts);
2948
4173
  }
2949
4174
  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.'));
4175
+ // The project's AJUSTES › Agents, here too: a sentence, then a card per agent.
4176
+ 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
4177
  const panel = agentsPanel({ onPaint: () => settingsSheet?.sync() });
2952
- block.rows.append(panel.node);
4178
+ panel.node.classList.add('cards');
4179
+ host.append(panel.node);
2953
4180
  keep(panel.stop);
2954
4181
  void panel.paint().then(() => panel.live());
2955
4182
  }
@@ -2959,6 +4186,105 @@ function fillBishop(host) {
2959
4186
  sxRow(block, t('Talk to the Coordinator'), room ? stateOf(room).words : null, sxButton(t('Open'), () => { settingsSheet?.close(); openBishop(); }, { tone: 'primary' }));
2960
4187
  sxRow(block, t('Open the Coordinator in a new tab'), null, sxButton(t('Open'), () => openBishop({ newTab: true })));
2961
4188
  }
4189
+ // LOCAL AI (src/local-ai.mjs): whether every project uses Ollama, the ones picked here, or each
4190
+ // one as it decides in its own Settings. The switch of each project is the same one its Settings
4191
+ // show; Ollama itself and what it does are set in a project's Modules › Local AI.
4192
+ const LOCAL_MODE_WORDS = { auto: () => t('Automatic'), pick: () => t('One by one'), manual: () => t('Manual') };
4193
+ const LOCAL_MODE_SAYS = {
4194
+ auto: () => t('Every project uses the local AI, new ones too.'),
4195
+ pick: () => t('Only the projects you switch on below. New projects start without it.'),
4196
+ manual: () => t('Each project decides in its own Settings › Modules › Local AI. New projects start with it.'),
4197
+ };
4198
+ let localFailed = false; // the last read got no answer: the sheet says so instead of «Reading…» forever
4199
+ async function loadLocal() {
4200
+ const answer = await fetch('/api/salas/local-ai', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
4201
+ if (answer) localSeen = answer;
4202
+ localFailed = !answer;
4203
+ settingsSheet?.sync({ detail: true });
4204
+ return answer;
4205
+ }
4206
+ async function saveLocal(patch) {
4207
+ const answer = await post('/api/salas/local-ai', patch);
4208
+ if (!answer.ok) { toast(answer.body?.error ?? t('Home did not answer.')); return; }
4209
+ localSeen = answer.body;
4210
+ if (localSeen.later?.length) toast(t('{names} will use it when restarted: it was started before this setting existed.', { names: localSeen.later.join(', ') }));
4211
+ settingsSheet?.sync({ detail: true });
4212
+ }
4213
+ // The rail every settings sheet uses (app.js, sxSeg; styles.css, .sx-seg): the mark moves when chosen.
4214
+ function sxSeg(options, current, pick, { label = null } = {}) {
4215
+ const seg = el('div', 'sx-seg');
4216
+ seg.setAttribute('role', 'radiogroup');
4217
+ if (label) seg.setAttribute('aria-label', label);
4218
+ for (const [value, words] of options) {
4219
+ const option = button(null, words, () => {
4220
+ if (value === current || seg.classList.contains('busy')) return;
4221
+ for (const other of seg.children) other.setAttribute('aria-checked', String(other === option));
4222
+ seg.classList.add('busy');
4223
+ void Promise.resolve(pick(value)).finally(() => seg.classList.remove('busy'));
4224
+ });
4225
+ option.setAttribute('role', 'radio');
4226
+ option.setAttribute('aria-checked', String(value === current));
4227
+ seg.append(option);
4228
+ }
4229
+ return seg;
4230
+ }
4231
+ const localModeSeg = () => sxSeg(Object.keys(LOCAL_MODE_WORDS).map((mode) => [mode, LOCAL_MODE_WORDS[mode]()]), localSeen?.mode, (mode) => saveLocal({ mode }), { label: t('Mode') });
4232
+ // The projects in pages of eight, two to a row where they fit: the sheet never needs a scroll.
4233
+ const LOCAL_PER_PAGE = 8;
4234
+ let localPage = 0;
4235
+ function fillLocal(host) {
4236
+ if (!localSeen && localFailed) {
4237
+ // A Home started before this setting existed has no answer for it: reloading the page reaches the one running now.
4238
+ 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.'));
4239
+ sxRow(block, t('Try again'), null, sxButton(t('Reload'), () => location.reload(), { tone: 'primary' }));
4240
+ return;
4241
+ }
4242
+ if (!localSeen) { host.append(el('p', 'settings-reading', t('Reading…'))); return; }
4243
+ host.append(el('p', 'sx-intro', t('The local AI is Ollama, on this computer: it searches the memory by meaning, sums it up and answers as @madre, without spending tokens or sending anything out. Here you choose which projects use it.')));
4244
+ const mode = sxBlock(host, t('Which projects'), LOCAL_MODE_SAYS[localSeen.mode]());
4245
+ sxRow(mode, t('Mode'), null, localModeSeg());
4246
+ if (!localSeen.master) sxRow(mode, t('Local AI is switched off for every project.'), t('Turn it on in a project, in Modules › Local AI.'));
4247
+ else if (!localSeen.ollama.running) sxRow(mode, t('Ollama is not running'), t('The projects keep working with their agents until it starts. Start it from a project, in Modules › Local AI.'));
4248
+ 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.'));
4249
+ if (!localSeen.projects.length) { sxRow(list, t('No projects yet'), null); return; }
4250
+ const pages = Math.ceil(localSeen.projects.length / LOCAL_PER_PAGE);
4251
+ localPage = Math.max(0, Math.min(localPage, pages - 1));
4252
+ const grid = el('div', 'sm-local-grid');
4253
+ for (const one of localSeen.projects.slice(localPage * LOCAL_PER_PAGE, (localPage + 1) * LOCAL_PER_PAGE)) {
4254
+ const item = el('label', `sm-local-item${one.on ? '' : ' off'}`);
4255
+ const words = el('span', 'sm-local-words');
4256
+ 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')));
4257
+ const wrap = el('span', 'settings-switch');
4258
+ const box = el('input');
4259
+ box.type = 'checkbox';
4260
+ box.checked = Boolean(one.chosen);
4261
+ box.disabled = !one.editable || !localSeen.master;
4262
+ box.setAttribute('aria-label', one.name);
4263
+ box.addEventListener('change', () => { box.disabled = true; void saveLocal({ rooms: { [one.id]: box.checked } }); });
4264
+ wrap.append(box, el('i'));
4265
+ item.append(words, wrap);
4266
+ grid.append(item);
4267
+ }
4268
+ // The last page keeps the height of a full one, so ‹ › stay under the pointer.
4269
+ 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'));
4270
+ list.rows.append(grid);
4271
+ if (pages > 1) {
4272
+ const foot = el('div', 'sm-local-foot');
4273
+ const pager = el('div', 'sm-pager');
4274
+ const turn = (step) => { localPage += step; settingsSheet?.sync({ detail: true }); };
4275
+ const back = button('sm-icon-key sm-page', null, () => turn(-1));
4276
+ const on = button('sm-icon-key sm-page', null, () => turn(1));
4277
+ back.innerHTML = PAGE_BACK; // trusted: our own SVG strings
4278
+ on.innerHTML = PAGE_ON;
4279
+ back.setAttribute('aria-label', t('Previous'));
4280
+ on.setAttribute('aria-label', t('Next'));
4281
+ back.disabled = localPage === 0;
4282
+ on.disabled = localPage >= pages - 1;
4283
+ pager.append(back, el('span', 'sm-page-where', t('{n} of {total}', { n: localPage + 1, total: pages })), on);
4284
+ foot.append(pager, el('small', null, t('{n} projects', { n: localSeen.projects.length })));
4285
+ list.rows.append(foot);
4286
+ }
4287
+ }
2962
4288
  function fillProjects(host) {
2963
4289
  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
4290
  sxRow(block, t('Add a project'), null, sxButton(t('Add'), () => { settingsSheet?.close(); openAddSheet(); }, { tone: 'primary' }));
@@ -2999,7 +4325,7 @@ function fillRelease(host) {
2999
4325
  // Home already runs the newer one: the projects still on the older one, each with its restart.
3000
4326
  const behind = behindRooms();
3001
4327
  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.'));
4328
+ 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
4329
  for (const room of behind) sxRow(late, roomLabel(room), `MADRE ${room.open.version}`, sxButton(t('Restart'), () => { settingsSheet?.close(); void restartRoom(room); }));
3004
4330
  }
3005
4331
  }
@@ -3030,10 +4356,11 @@ async function updateHome(info, key, said) {
3030
4356
  }
3031
4357
  drop(t('Home did not come back on its own. Start MADRE again from your terminal.'));
3032
4358
  }
3033
- const SET_FILL = { look: fillLook, bar: fillBar, agents: fillAgents, bishop: fillBishop, projects: fillProjects, release: fillRelease };
4359
+ const SET_FILL = { look: fillLook, bar: fillBar, agents: fillAgents, bishop: fillBishop, local: fillLocal, projects: fillProjects, release: fillRelease };
3034
4360
 
3035
4361
  let settingsSheet = null;
3036
4362
  function openSettings(section = null) {
4363
+ void loadLocal();
3037
4364
  if (settingsSheet) { settingsSheet.show(section); return; }
3038
4365
  const state = { open: null, query: '', stops: [] };
3039
4366
  const dialog = el('dialog', 'sm-sheet sm-settings');
@@ -3209,8 +4536,10 @@ function translateStatic() {
3209
4536
  ui.search.placeholder = t('Search a project…');
3210
4537
  ui.search.setAttribute('aria-label', t('Search a project…'));
3211
4538
  ui.filter.setAttribute('aria-label', t('Show'));
4539
+ $('sm-drawer-title').textContent = t('Projects');
3212
4540
  const FILTER_WORDS = { all: t('All projects'), open: t('Open projects'), asleep: t('Asleep projects') };
3213
4541
  for (const choice of ui.filter.querySelectorAll('[data-filter]')) choice.textContent = FILTER_WORDS[choice.dataset.filter];
4542
+ paintOrderKey();
3214
4543
  $('sm-bishop-name').textContent = t('Coordinator');
3215
4544
  ui.bishop.go.textContent = t('Talk to the Coordinator');
3216
4545
  ui.bishop.tab.title = t('Open the Coordinator in a new tab');