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