@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.
- package/CHANGELOG.md +45 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +8 -7
- package/TRADEMARKS.md +65 -0
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +6 -5
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +529 -91
- package/public/es.js +1330 -1070
- package/public/macroverso.js +22 -2
- package/public/salas-mac.css +518 -24
- package/public/salas-mac.html +25 -21
- package/public/salas-mac.js +1454 -125
- package/public/styles.css +167 -3
- package/public/tour-chapters.js +162 -52
- package/public/tour.js +48 -12
- package/public/troubleshooting.js +2 -2
- package/src/auth-probe.mjs +29 -6
- package/src/credentials.mjs +80 -0
- package/src/directives.mjs +6 -6
- package/src/extensions.mjs +31 -10
- package/src/heartbeat.mjs +55 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +38 -5
- package/src/models.mjs +38 -0
- package/src/modules/ollama.mjs +14 -2
- package/src/outbound.mjs +17 -0
- package/src/room/escalation.mjs +1 -0
- package/src/room.mjs +60 -19
- package/src/server.mjs +350 -27
- package/src/tour.mjs +24 -9
- package/src/whats-new.mjs +10 -10
- package/docs/madre-banner.svg +0 -25
package/public/salas-mac.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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:
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
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
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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.
|
|
773
|
-
|
|
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
|
-
//
|
|
784
|
-
|
|
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
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
//
|
|
802
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
810
|
-
const
|
|
811
|
-
|
|
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
|
-
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
|
1164
|
+
for (const room of ring) {
|
|
830
1165
|
const spot = at.get(room.id);
|
|
831
1166
|
const on = linked.has(room.id);
|
|
832
|
-
const
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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',
|
|
848
|
-
face.firstElementChild?.setAttribute('height',
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2441
|
-
//
|
|
2442
|
-
//
|
|
2443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3658
|
+
facts.append(line);
|
|
2467
3659
|
}
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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');
|